@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
@@ -90,20 +90,68 @@ function _actionList(a) {
90
90
  return Array.isArray(a) ? a : [a];
91
91
  }
92
92
 
93
+ // Finds every balanced {...} substring in `raw` that parses as valid JSON
94
+ // and itself contains a "Statement" key. Recovers the case _isAwsPolicy
95
+ // already promises to route here: an AWS policy document embedded as a
96
+ // JSON-quoted string inside a non-JSON host file — most commonly a
97
+ // Terraform heredoc (`policy = <<EOF ... EOF`). Does not attempt to parse
98
+ // native-HCL jsonencode({...}) blocks (bare identifiers, no quotes) — that
99
+ // shape doesn't match _isAwsPolicy's own quoted-key detection regex either,
100
+ // so it's already outside this detector's claimed scope.
101
+ function _extractJsonPolicyBlocks(raw) {
102
+ const blocks = [];
103
+ for (let i = 0; i < raw.length; i++) {
104
+ if (raw[i] !== '{') continue;
105
+ let depth = 0, inStr = false, strCh = '', esc = false, j = i;
106
+ for (; j < raw.length; j++) {
107
+ const c = raw[j];
108
+ if (inStr) {
109
+ if (esc) esc = false;
110
+ else if (c === '\\') esc = true;
111
+ else if (c === strCh) inStr = false;
112
+ continue;
113
+ }
114
+ if (c === '"') { inStr = true; strCh = c; continue; }
115
+ if (c === '{') depth++;
116
+ else if (c === '}') { depth--; if (depth === 0) break; }
117
+ }
118
+ if (depth !== 0 || j >= raw.length) continue;
119
+ const candidate = raw.slice(i, j + 1);
120
+ if (!/"Statement"\s*:/.test(candidate)) continue;
121
+ try { blocks.push(JSON.parse(candidate)); } catch { /* not valid JSON on its own */ }
122
+ }
123
+ return blocks;
124
+ }
125
+
93
126
  function detectAws(file, raw, out, seen) {
94
- let parsed;
95
- try { parsed = JSON.parse(raw); } catch { return; }
96
- const ss = _statements(parsed);
127
+ let ss;
128
+ try {
129
+ ss = _statements(JSON.parse(raw));
130
+ } catch {
131
+ // Non-JSON host file (e.g. Terraform HCL) — fall back to locating
132
+ // embedded JSON policy-document blocks instead of giving up outright.
133
+ ss = _extractJsonPolicyBlocks(raw).flatMap(_statements);
134
+ if (!ss.length) return;
135
+ }
136
+ // _line() takes a raw-text offset, not a substring — a monotonically
137
+ // advancing cursor lets successive statements (JSON.parse preserves
138
+ // source order) each locate their OWN occurrence of a shared marker
139
+ // like `"Principal"` instead of every statement resolving to the same
140
+ // (first) occurrence in the file.
141
+ let _cursor = 0;
97
142
  for (const s of ss) {
98
143
  if ((s.Effect || 'Allow') !== 'Allow') continue;
99
144
  const actions = _actionList(s.Action);
100
145
  const isStarAction = actions.includes('*') || actions.some(a => /^[a-z]+:\*$/.test(a));
101
146
  const hasCondition = s.Condition && Object.keys(s.Condition).length > 0;
102
147
  const principalStar = _principalIsWildcard(s.Principal);
148
+ let _advanceTo = _cursor;
103
149
 
104
150
  // aws-public-s3-policy
105
151
  if (principalStar && actions.some(a => /^s3:/.test(a))) {
106
- const ln = _line(raw, `"Principal"`);
152
+ const idx = raw.indexOf('"Principal"', _cursor);
153
+ const ln = _line(raw, idx >= 0 ? idx : _cursor);
154
+ if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
107
155
  const id = `aws-public-s3-policy:${file}:${ln}`;
108
156
  if (!seen.has(id)) {
109
157
  seen.add(id);
@@ -118,7 +166,9 @@ function detectAws(file, raw, out, seen) {
118
166
 
119
167
  // aws-public-trust-policy (Principal:* on AssumeRole)
120
168
  if (principalStar && actions.includes('sts:AssumeRole')) {
121
- const ln = _line(raw, 'AssumeRole');
169
+ const idx = raw.indexOf('AssumeRole', _cursor);
170
+ const ln = _line(raw, idx >= 0 ? idx : _cursor);
171
+ if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
122
172
  const id = `aws-public-trust-policy:${file}:${ln}`;
123
173
  if (!seen.has(id)) {
124
174
  seen.add(id);
@@ -136,7 +186,9 @@ function detectAws(file, raw, out, seen) {
136
186
  if (_HIGH_RISK_AWS_ACTIONS.includes(a) || a === '*') {
137
187
  const conditionStr = JSON.stringify(s.Condition || {});
138
188
  if (!/MultiFactorAuthPresent|MultiFactorAuthAge/.test(conditionStr)) {
139
- const ln = _line(raw, `"${a}"`);
189
+ const idx = raw.indexOf(`"${a}"`, _cursor);
190
+ const ln = _line(raw, idx >= 0 ? idx : _cursor);
191
+ if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
140
192
  const id = `aws-no-mfa-condition:${file}:${a}:${ln}`;
141
193
  if (!seen.has(id)) {
142
194
  seen.add(id);
@@ -153,12 +205,13 @@ function detectAws(file, raw, out, seen) {
153
205
 
154
206
  // (iam:PassRole with Resource:* is detected by posture/iam-policy.js —
155
207
  // not duplicated here.)
208
+ _cursor = _advanceTo;
156
209
  }
157
210
 
158
211
  // aws-overbroad-managed-policy
159
212
  if (/"AdministratorAccess"|"PowerUserAccess"/.test(raw) && !/root\b|Bootstrap\b/.test(raw)) {
160
213
  const m = /"(AdministratorAccess|PowerUserAccess)"/.exec(raw);
161
- const ln = _line(raw, m[0]);
214
+ const ln = _line(raw, m.index);
162
215
  const id = `aws-overbroad-managed-policy:${file}:${ln}`;
163
216
  if (!seen.has(id)) {
164
217
  seen.add(id);
@@ -82,7 +82,7 @@ function familyOf(finding) {
82
82
  }
83
83
 
84
84
  // Return the primary-CWE family for a Juliet C/C++ test path, or null.
85
- export function julietPrimaryFamily(file) {
85
+ function julietPrimaryFamily(file) {
86
86
  const m = JULIET_DIR_RE.exec(String(file).replace(/\\/g, '/'));
87
87
  if (!m) return null;
88
88
  return CWE_TO_FAMILY[parseInt(m[1], 10)] || null;
package/src/sast/csrf.js CHANGED
@@ -63,11 +63,13 @@ export function scanCSRF(fp, raw) {
63
63
  if (!langSel) return [];
64
64
 
65
65
  const code = blankComments(raw, (ext === 'py' || ext === 'rb') ? 'py' : undefined);
66
- // Project-wide-ish: if the file shows CSRF defence anywhere or only handles
67
- // token-authenticated routes, we suppress.
68
- const csrfInScope = CSRF_DEFENCE_RE.test(code);
69
- const tokenAuthInScope = TOKEN_AUTH_RE.test(code);
70
- if (csrfInScope || tokenAuthInScope) return [];
66
+ // Per-route defence is checked below (±15-line window around each route).
67
+ // There used to also be a whole-file gate here ("if the file shows CSRF
68
+ // defence ANYWHERE, suppress every route in it") — by regex-substring
69
+ // monotonicity that gate always fires whenever the per-route window would
70
+ // have matched too (the window is a substring of the file), so it made the
71
+ // per-route check provably unreachable: one protected route silently
72
+ // suppressed every OTHER route in the same file, however unprotected.
71
73
 
72
74
  const findings = [];
73
75
  const seen = new Set();
@@ -4,12 +4,15 @@
4
4
  // NEXT_PUBLIC_ vars that expose private values, .env.example with real
5
5
  // credentials, and hardcoded fallback values that look real.
6
6
  //
7
- // Findings:
7
+ // Findings (4 implemented; ENV_MISSING_GITIGNORE was documented here but has
8
+ // no implementation anywhere in this file — found via Stage-0 doc audit,
9
+ // 2026. Either implement it or drop it from this list; left as a gap, not
10
+ // silently removed, so the missing coverage stays visible):
8
11
  // ENV_NEXT_PUBLIC_SECRET — NEXT_PUBLIC_ variable whose name implies it's secret
9
12
  // ENV_EXAMPLE_REAL_VALUE — .env.example / .env.sample with a real-looking value
10
13
  // ENV_HARDCODED_FALLBACK — process.env.X || "looks-real" fallback in source
11
- // ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
12
14
  // ENV_DOTENV_IN_SOURCE — .env file content loaded via require/import in prod code
15
+ // NOT IMPLEMENTED: ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
13
16
 
14
17
  const _ENV_FILE_RE = /^\.env(?:\.(?:local|development|production|test|staging))?$/i;
15
18
  const _NONPROD_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|node_modules)\//i;
@@ -42,6 +42,23 @@ function buildResolver(text) {
42
42
 
43
43
  function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
44
44
 
45
+ // AWS security-group `cidr_blocks` lives inside either an `ingress { }` or
46
+ // `egress { }` block. Egress-to-anywhere (0.0.0.0/0 outbound) is the
47
+ // standard default for nearly every security group and is not itself a
48
+ // finding; only the same value on an ingress (inbound) rule is an exposure.
49
+ // Finds the nearest enclosing block by walking back to the last occurrence
50
+ // of either keyword before the match position — security-group ingress/
51
+ // egress blocks don't nest another ingress/egress block inside themselves,
52
+ // so the closest preceding one is the enclosing one.
53
+ const _BLOCK_KEYWORD_RE = /\b(ingress|egress)\s*\{/g;
54
+ function _enclosingBlockType(raw, idx) {
55
+ let last = null;
56
+ _BLOCK_KEYWORD_RE.lastIndex = 0;
57
+ let m;
58
+ while ((m = _BLOCK_KEYWORD_RE.exec(raw)) && m.index < idx) last = m[1];
59
+ return last;
60
+ }
61
+
45
62
  const CHECKS = [
46
63
  {
47
64
  id: 'open-ingress', cwe: 'CWE-284', sev: 'high',
@@ -49,6 +66,13 @@ const CHECKS = [
49
66
  bad: (val) => /\b0\.0\.0\.0\/0\b|::\/0\b/.test(val),
50
67
  vuln: 'Terraform: security-group ingress open to the world (0.0.0.0/0)',
51
68
  fix: 'Restrict cidr_blocks to known networks; never default an ingress CIDR to 0.0.0.0/0.',
69
+ // Only ingress (inbound) exposure is a finding — egress-to-anywhere is
70
+ // the standard, expected default. A cidr_blocks outside any
71
+ // ingress/egress block (no enclosing keyword found) is kept: that's
72
+ // the standalone `aws_security_group_rule` resource shape, which needs
73
+ // a separate `type = "ingress"` attribute this module doesn't parse —
74
+ // erring toward reporting rather than silently dropping it.
75
+ blockGate: (blockType) => blockType !== 'egress',
52
76
  },
53
77
  {
54
78
  id: 'public-bucket', cwe: 'CWE-732', sev: 'high',
@@ -76,6 +100,7 @@ export function scanTerraform(fp, raw) {
76
100
  const re = new RegExp(check.attr.source, check.attr.flags);
77
101
  let m;
78
102
  while ((m = re.exec(raw))) {
103
+ if (check.blockGate && !check.blockGate(_enclosingBlockType(raw, m.index))) continue;
79
104
  const rawVal = m[1];
80
105
  // A literal list may hold several entries; resolve each token.
81
106
  const tokens = rawVal.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
@@ -671,7 +671,7 @@ const REQUEST_WRAPPER_CLASS_RE = /\b(?:public\s+|private\s+|protected\s+|static\
671
671
 
672
672
  /** Parse a Java file and return the names of any classes that wrap an
673
673
  * HttpServletRequest and expose String-returning getters. */
674
- export function findRequestWrapperGetters(file, raw) {
674
+ function findRequestWrapperGetters(file, raw) {
675
675
  if (!JAVA_EXT.test(file) || !raw || raw.length > 500_000) return [];
676
676
  const content = blankComments(raw);
677
677
  const out = [];
@@ -68,7 +68,7 @@ function intLocals(raw) {
68
68
  // Pattern 1: Constant ternary assigning to bar.
69
69
  // bar = (<expr> [+-*]) num <cmp> <literal> ? "<safe>" : param;
70
70
  // where `num` is a local int constant and the comparison folds to a known bool.
71
- export function hasConstantTernaryBarSafe(raw) {
71
+ function hasConstantTernaryBarSafe(raw) {
72
72
  const ints = intLocals(raw);
73
73
  // Try a few common shape variants. The first looks for the (lhs ± num) > N pattern.
74
74
  // We allow either order — `bar = literal ? ... : ...` style.
@@ -105,7 +105,7 @@ export function hasConstantTernaryBarSafe(raw) {
105
105
  }
106
106
 
107
107
  // Pattern 2: Constant if/else assigning to bar — same idea.
108
- export function hasConstantIfBarSafe(raw) {
108
+ function hasConstantIfBarSafe(raw) {
109
109
  const ints = intLocals(raw);
110
110
  // `if ((N op M) op2 num cmp K) bar = "<literal>"; else bar = param;`
111
111
  const re = /\bif\s*\(\s*\((-?\d+)\s*([+\-*])\s*(-?\d+)\)\s*([+\-])\s*(\w+)\s*([><=!]=?|==)\s*(-?\d+)\s*\)\s*bar\s*=\s*"[^"]*"\s*;\s*else\s+bar\s*=\s*(\w+)\s*;/g;
@@ -143,7 +143,7 @@ export function hasConstantIfBarSafe(raw) {
143
143
  // map.put("keyB-XXX", param);
144
144
  // bar = map.get("keyB-XXX");
145
145
  // bar = map.get("keyA-XXX"); // overwrites with safe
146
- export function hasMapDoubleGetSafe(raw) {
146
+ function hasMapDoubleGetSafe(raw) {
147
147
  // Use a tolerant regex — we don't need to bind every put to every get.
148
148
  // The presence of these three lines in this order suffices.
149
149
  const re = /\.\s*put\s*\(\s*("[^"]+")\s*,\s*"[^"]*"\s*\)\s*;[\s\S]{0,400}?\.\s*put\s*\(\s*("[^"]+")\s*,\s*\w+\s*\)[\s\S]{0,500}?\bbar\s*=\s*(?:\(String\)\s*)?\w+\.get\(\s*\2\s*\)[\s\S]{0,200}?\bbar\s*=\s*(?:\(String\)\s*)?\w+\.get\(\s*\1\s*\)/;
@@ -203,7 +203,7 @@ function _simulateListBar(raw) {
203
203
  return anyResolved;
204
204
  }
205
205
 
206
- export function hasListGetIndex0Safe(raw) {
206
+ function hasListGetIndex0Safe(raw) {
207
207
  return _simulateListBar(raw);
208
208
  }
209
209
 
@@ -217,7 +217,7 @@ export function hasListGetIndex0Safe(raw) {
217
217
  // case 'B': bar = "literal"; break; // TAKEN
218
218
  // ...
219
219
  // }
220
- export function hasSwitchCharAtConstantSafe(raw) {
220
+ function hasSwitchCharAtConstantSafe(raw) {
221
221
  // 1. Find `String <var> = "<lit>";` then `char <other> = <var>.charAt(<idx>);`.
222
222
  const decl = /\bString\s+(\w+)\s*=\s*"([^"]+)"\s*;\s*(?:\/\/[^\n]*\n)?\s*char\s+\w+\s*=\s*\1\s*\.\s*charAt\s*\(\s*(\d+)\s*\)\s*;/g;
223
223
  let m;
@@ -369,7 +369,10 @@ export function scanLLMOwasp(fp, raw) {
369
369
  if (!USER_INJECTED_RAG_RE.test(lines[li])) continue;
370
370
  // Suppress if there's an obvious auth or provenance check in the surrounding context
371
371
  const ctx20 = _windowText(lines, Math.max(0, li - 10), 20);
372
- if (/\b(?:require_auth|is_admin|check_permission|verified_source|provenance|signed|hmac)\b/i.test(ctx20)) break;
372
+ // continue, not break: a guard near THIS match says nothing about a
373
+ // later, unrelated match elsewhere in the file — stopping the loop here
374
+ // would hide every subsequent unguarded instance.
375
+ if (/\b(?:require_auth|is_admin|check_permission|verified_source|provenance|signed|hmac)\b/i.test(ctx20)) continue;
373
376
  push({
374
377
  id: `llm-owasp:${fp}:${li + 1}:llm03-user-injected-rag`,
375
378
  kind: 'sast', severity: 'high',
@@ -379,7 +382,6 @@ export function scanLLMOwasp(fp, raw) {
379
382
  fix: 'Authenticate the submitter and validate content before it enters the knowledge base. Tag each chunk with source, owner, and trust_level metadata at ingest time. At retrieval, filter or down-rank chunks from unverified sources. Scan injected content for prompt-injection markers before storing.',
380
383
  confidence: 0.88,
381
384
  });
382
- break;
383
385
  }
384
386
 
385
387
  // --- LLM03: auto-ingested third-party feed that may be compromised ---
@@ -101,10 +101,17 @@ export function scanMCP(fp, raw) {
101
101
 
102
102
  const findings = [];
103
103
  const seen = new Set();
104
+ // CMP-1 (Stage 6 follow-up): no call site here ever set `family`, so every
105
+ // finding fell through to a generic backfilled value — invisible to any
106
+ // compliance control mapped to `family:agent-tool-exec` (owasp-llm-top-10's
107
+ // LLM07), and to the family-keyed cost tables in risk-dollars.js/
108
+ // time-to-fix.js, which both already carry an 'agent-tool-exec' entry with
109
+ // nothing in the codebase that could ever produce a matching finding.
104
110
  const push = (f) => {
105
111
  const key = `${f.file}:${f.line}:${f.vuln}`;
106
112
  if (seen.has(key)) return;
107
113
  seen.add(key);
114
+ if (!f.family) f.family = 'agent-tool-exec';
108
115
  findings.push(f);
109
116
  };
110
117
 
@@ -43,6 +43,13 @@ const PIPELINE_PATTERNS = [
43
43
  vuln: 'Pipeline: untrusted github.event input interpolated into shell context',
44
44
  sev: 'critical', cwe: 'CWE-78',
45
45
  fix: 'Pipe untrusted github.event values through an environment variable instead of interpolating into the shell, e.g. `env: TITLE: ${{ github.event.issue.title }}` then use `"$TITLE"` in the run script.',
46
+ // Suppress the exact pattern this fix recommends: a pure YAML mapping
47
+ // entry (`KEY: ${{ github.event.… }}`) assigns the value to an env var
48
+ // at the workflow-engine level, not into a shell command string — the
49
+ // shell later reads it as an ordinary env-var reference ("$KEY"), which
50
+ // is safe. A match embedded inside a larger line (a run: script body)
51
+ // does not have this shape and is still flagged.
52
+ lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/,
46
53
  },
47
54
  {
48
55
  re: /\bid-token\s*:\s*write\b/g,
@@ -70,6 +77,7 @@ export function scanPipeline(fp, raw) {
70
77
  let m;
71
78
  while ((m = re.exec(raw))) {
72
79
  const line = raw.substring(0, m.index).split('\n').length;
80
+ if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
73
81
  const id = `pipeline:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 48)}`;
74
82
  if (seen.has(id)) continue;
75
83
  seen.add(id);
@@ -92,11 +92,6 @@ export function scanPromptTemplate(fp, raw) {
92
92
  const seen = new Set();
93
93
  const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
94
94
 
95
- // Strong negative for code files: a proper role-separated messages array anywhere
96
- // in the file means the developer is using the framework correctly. Suppress
97
- // inline-string findings in this case (still scan prompt template files).
98
- const hasRoleSeparation = isCodeFile && ROLE_SEPARATION_RE.test(raw);
99
-
100
95
  // CASE 1 — Prompt template files: scan the entire file content for
101
96
  // user-input interpolations without isolation markers nearby.
102
97
  if (isPromptFile) {
@@ -120,7 +115,7 @@ export function scanPromptTemplate(fp, raw) {
120
115
 
121
116
  // CASE 2 — Inline prompt strings in code files. Scan f-strings (Python) and
122
117
  // template literals (JS/TS) for prompt-shape markers + user interpolation.
123
- if (isCodeFile && !hasRoleSeparation) {
118
+ if (isCodeFile) {
124
119
  const candidates = [];
125
120
  if (/\.py$/i.test(fpNorm)) {
126
121
  let m;
@@ -142,6 +137,13 @@ export function scanPromptTemplate(fp, raw) {
142
137
  if (!pyInterp && !jsInterp) continue;
143
138
  // Suppress if isolation markers are inside the prompt string itself
144
139
  if (ISOLATION_MARKER_RE.test(c.text)) continue;
140
+ // Suppress if THIS candidate is itself the `content:` value inside a
141
+ // role-separated messages:[{role,content}] object — checked in a
142
+ // tight preceding window (same object literal), not file-wide, so a
143
+ // well-formed call elsewhere in the file can't blanket-suppress an
144
+ // unrelated, unisolated candidate.
145
+ const roleWindow = raw.substring(Math.max(0, c.start - 120), c.start);
146
+ if (ROLE_SEPARATION_RE.test(roleWindow) || /\brole\s*[:=]/i.test(roleWindow)) continue;
145
147
  const line = raw.substring(0, c.start).split('\n').length;
146
148
  push(_emit(fp, line,
147
149
  'Prompt Template: user input interpolated into prompt string without isolation',
@@ -89,9 +89,13 @@ export function scanPrototypePollution(fp, raw) {
89
89
  const r2 = new RegExp(re.source, re.flags);
90
90
  let mm;
91
91
  while ((mm = r2.exec(code))) {
92
- // Only flag write context: look for `=` within 20 chars after.
92
+ // Only flag write context: an `=` that is a genuine assignment, not
93
+ // part of a comparison operator (==, ===, !=, !==, <=, >=). The
94
+ // previous /=\s*[^=]/ check wasn't anchored to the first `=`, so it
95
+ // matched the trailing `=` of a `===` chain (e.g.
96
+ // `obj.__proto__ === Object.prototype`) as a false "write."
93
97
  const post = code.slice(mm.index, mm.index + 60);
94
- if (!/=\s*[^=]/.test(post)) continue;
98
+ if (!/(?<![=!<>])=(?!=)/.test(post)) continue;
95
99
  const line = lineOf(raw, mm.index);
96
100
  push({
97
101
  id: `prototype-pollution-direct:${fp}:${line}`,
@@ -273,7 +273,7 @@ export function isUnsafeRegex(body) {
273
273
  return detectSuperlinear(parsed.tree);
274
274
  }
275
275
 
276
- export function extractJsRegexBodies(code) {
276
+ function extractJsRegexBodies(code) {
277
277
  const out = [];
278
278
  // Regex literals: /pattern/flags
279
279
  for (const m of code.matchAll(/\/([^/\n]+)\/[gimsuy]*/g)) {
@@ -307,7 +307,7 @@ export function extractJavaRegexBodies(code) {
307
307
 
308
308
  // Go: regexp.MustCompile / Compile / MatchString("…") — Go string literals use
309
309
  // "…" (interpreted) or `…` (raw). Both carry the pattern verbatim.
310
- export function extractGoRegexBodies(code) {
310
+ function extractGoRegexBodies(code) {
311
311
  const out = [];
312
312
  for (const m of code.matchAll(/\bregexp\.(?:MustCompile|Compile|CompilePOSIX|MatchString|Match)\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
313
313
  out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
@@ -320,7 +320,7 @@ export function extractGoRegexBodies(code) {
320
320
 
321
321
  // PHP: preg_match / preg_match_all / preg_replace("/…/flags", …) — the regex is
322
322
  // a delimited string; strip the delimiter + trailing flags.
323
- export function extractPhpRegexBodies(code) {
323
+ function extractPhpRegexBodies(code) {
324
324
  const out = [];
325
325
  for (const m of code.matchAll(/\bpreg_(?:match|match_all|replace|replace_callback|split)\s*\(\s*(['"])(.+?)\1/g)) {
326
326
  let lit = m[2];
@@ -334,7 +334,7 @@ export function extractPhpRegexBodies(code) {
334
334
  }
335
335
 
336
336
  // Ruby: a regex literal /…/ used with =~ / .match / .match? / Regexp.new("…").
337
- export function extractRubyRegexBodies(code) {
337
+ function extractRubyRegexBodies(code) {
338
338
  const out = [];
339
339
  for (const m of code.matchAll(/(?:=~|\.match\??|\.scan|\.gsub|\.sub|grep)\s*\(?\s*\/((?:\\.|[^\/\n])+)\//g)) {
340
340
  out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
@@ -346,7 +346,7 @@ export function extractRubyRegexBodies(code) {
346
346
  }
347
347
 
348
348
  // C#: new Regex("…") / Regex.IsMatch(s, "…") / Regex.Match / Regex.Replace.
349
- export function extractCsharpRegexBodies(code) {
349
+ function extractCsharpRegexBodies(code) {
350
350
  const out = [];
351
351
  for (const m of code.matchAll(/\bnew\s+Regex\s*\(\s*@?"((?:\\.|""|[^"\n])+)"/g)) {
352
352
  out.push({ body: m[1].replace(/""/g, '"'), line: code.slice(0, m.index).split('\n').length });
@@ -358,7 +358,7 @@ export function extractCsharpRegexBodies(code) {
358
358
  }
359
359
 
360
360
  // Kotlin: Regex("…") / "…".toRegex() / Pattern.compile("…").
361
- export function extractKotlinRegexBodies(code) {
361
+ function extractKotlinRegexBodies(code) {
362
362
  const out = [];
363
363
  for (const m of code.matchAll(/\bRegex\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
364
364
  out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
@@ -48,11 +48,22 @@ export function scanSecretConcat(fp, raw) {
48
48
  const id = `secret-concat:${fp}:${line}`;
49
49
  if (seen.has(id)) continue;
50
50
  seen.add(id);
51
+ // Stage 4 correctness audit (coverage breadth, secrets): same
52
+ // unredacted-snippet leak found in engine.js's scanEntropySecrets/
53
+ // scanCredentials — `snippet` carried the raw source line, including
54
+ // the full reassembled secret's literal pieces, straight through to
55
+ // every report format this scanner emits. This detector never
56
+ // computed a masked value at all (the other two secret detectors do).
57
+ // Mask the exact matched concat-expression text (m[2], the same
58
+ // substring this detector reassembled `value` from) within the
59
+ // snippet before reporting it.
60
+ const masked = value.length > 8 ? value.substring(0, 4) + '…' + value.substring(value.length - 4) : '••••';
61
+ const rawSnippet = (raw.split('\n')[line - 1] || '').trim().slice(0, 200);
51
62
  findings.push({
52
63
  id, file: fp, line,
53
64
  vuln: 'Hardcoded credential — secret split across concatenated literals to evade detection',
54
- severity: 'high', cwe: 'CWE-798', family: 'secret', parser: 'SECRET-CONCAT', confidence: 0.78,
55
- snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
65
+ severity: 'high', cwe: 'CWE-798', family: 'hardcoded-secret', parser: 'SECRET-CONCAT', confidence: 0.78,
66
+ snippet: rawSnippet.split(m[2]).join(masked), masked,
56
67
  remediation: 'Load the secret from the environment or a secrets manager (process.env / os.environ / Vault / AWS Secrets Manager). Splitting the literal across a concatenation does not protect it — rotate the exposed value, it must be considered compromised.',
57
68
  });
58
69
  }
@@ -66,15 +66,18 @@ export function scanSSRFCloudMetadata(fp, raw) {
66
66
  }
67
67
 
68
68
  // 2. User-controlled URL into an HTTP client, with no metadata guard nearby.
69
- const fileHasMetadataGuard = METADATA_GUARD_RE.test(code);
70
69
  const r = new RegExp(SSRF_CLIENT_RE.source, SSRF_CLIENT_RE.flags);
71
70
  let m;
72
71
  while ((m = r.exec(code))) {
73
72
  const line = lineOf(raw, m.index);
74
- // Skip if a guard appears in ±10 lines.
73
+ // Skip if a guard appears in ±10 lines. A file-wide "does this file
74
+ // contain a guard anywhere" check used to OR into this and blanket-
75
+ // suppressed every unguarded call site once any one call site was
76
+ // properly guarded — removed; proximity is what makes a guard actually
77
+ // apply to a given call.
75
78
  const lines = raw.split('\n');
76
79
  const window = lines.slice(Math.max(0, line - 11), line + 10).join(' ');
77
- if (METADATA_GUARD_RE.test(window) || fileHasMetadataGuard) continue;
80
+ if (METADATA_GUARD_RE.test(window)) continue;
78
81
  push({
79
82
  id: `ssrf-meta-usercontrolled:${fp}:${line}`,
80
83
  file: fp, line,
@@ -91,7 +91,7 @@ export function scanXssReflectedMultilang(fp, raw) {
91
91
  for (const v of Object.values(LANGS)) { if (v.ext.test(fp)) { lang = v; break; } }
92
92
  if (!lang) return [];
93
93
 
94
- const code = blankComments(raw, /\.(?:rb|php)$/i.test(fp) ? 'py' : undefined);
94
+ const code = blankComments(raw, /\.rb$/i.test(fp) ? 'py' : (/\.(?:php|phtml)$/i.test(fp) ? 'php' : undefined));
95
95
  const lines = code.split('\n');
96
96
  const findings = [];
97
97
  const seen = new Set();
package/src/sast/xxe.js CHANGED
@@ -180,7 +180,7 @@ export function scanXXE(fp, raw) {
180
180
  };
181
181
 
182
182
  if (/\.(?:php|phtml)$/i.test(fp)) {
183
- const code = blankComments(raw, 'py');
183
+ const code = blankComments(raw, 'php');
184
184
  _emitOptIn(code, PHP_XXE_RE, null,
185
185
  () => 'XXE: XML parsed with LIBXML_NOENT / LIBXML_DTDLOAD (external entities enabled)',
186
186
  () => 'Drop the LIBXML_NOENT / LIBXML_DTDLOAD flags — PHP >= 8.0 disables entity substitution by default, so plain loadXML($xml) / simplexml_load_string($xml) is safe. If you must accept DTDs, set libxml_set_external_entity_loader to reject network/file access.');
package/src/sca/CLAUDE.md CHANGED
@@ -7,7 +7,7 @@ ecosystems and the most common lockfiles in each.
7
7
 
8
8
  Most of the SCA *pipeline* lives in `../engine.js` (the manifest dispatch
9
9
  in `parseManifests`, OSV/KEV/EPSS enrichment, attack-path computation).
10
- This directory holds the eight specialized modules called from there.
10
+ This directory holds the seven specialized modules called from there.
11
11
 
12
12
  ## Modules
13
13
 
@@ -16,10 +16,9 @@ This directory holds the eight specialized modules called from there.
16
16
  | `index.js` | Re-exports six public symbols from `../engine.js` so external consumers can `import { parseManifests, queryOSV, … } from '@…/sca'`. |
17
17
  | `binary-metadata.js` | **Opt-in via `AGENTIC_SECURITY_BINARY_SCA=1`.** Reads dependency metadata from compiled artifacts: JAR `META-INF/MANIFEST.MF` + `pom.properties`, Go binary `go.buildinfo`. Never executes the binary. JAR extraction uses `fs.mkdtemp` for an isolated scratch dir (premortem-derived: shared `/tmp` lets a hostile JAR plant a symlinked manifest that escapes the scratch). |
18
18
  | `container.js` | Dockerfile parser. Detects EOL `FROM` base images (alpine/debian/ubuntu/node/python) against `base-images.json`, and synthesizes lightweight SCA components from `apt-get install` / `apk add` package lists. No Docker daemon required. |
19
- | `dep-confusion.js` | Two related detectors. **Typosquat:** Levenshtein distance ≤ 2 against the top-1000 packages in `popular-packages.json`. **Dependency confusion:** internal-scoped names (declared in `.agentic-security/internal-scopes.yml`) appearing on the public registry. Local-first; OSV consulted only to confirm confusion findings. |
19
+ | `dep-confusion.js` | Two related detectors. **Typosquat:** Levenshtein distance ≤ 2 against `popular-packages.json` — **188 packages (115 npm + 73 pypi), not "top-1000"** as this row previously said; re-derive the count from the file rather than trusting a hardcoded number here again. **Dependency confusion:** internal-scoped names (declared in `.agentic-security/internal-scopes.yml`) appearing on the public registry. Local-first; **this module does not itself call OSV** — it reads a flag set by an earlier, separate OSV/queryRegistries pass upstream (whether a dep "resolved by OSV"), which is different from "OSV consulted [by this module] to confirm confusion findings" as previously stated. |
20
20
  | `llm-function-extract.js` | **Opt-in via `AGENTIC_SECURITY_LLM_SCA=1`.** LLM-assisted extraction of vulnerable function names for CVEs that lack OSV `ecosystem_specific.vulnerable_functions` data. Cached per CVE under `~/.config/agentic-security/llm-sca-cache/`. Endpoint-dependent — degrades to no-op when unreachable. |
21
21
  | `py-package-functions.js` | **Opt-in via `AGENTIC_SECURITY_DEEP=1`** (Python only). Locates installed Python packages via `site-packages` and parses them with the CPython `ast` module (subprocess) to *validate* that an OSV-named vulnerable function exists in the installed version. Closes the "OSV says this function is vulnerable, but the version you installed actually removed it" false-positive class. |
22
- | `sarif-ingest.js` | Normalizes SARIF 2.1.0 from external scanners and merges into the unified scan. Deduplicates by fingerprint `(CWE, file, line ± 2, rule)`. Twelve tool profiles supported with default-severity + semantic-kind mapping. |
23
22
  | `vendor-detect.js` | Detects libraries copied into `src/` (lodash, jQuery, Angular, React, etc.) via characteristic version strings and function signatures. Catches the case where a vulnerable library bypasses the lockfile because someone vendored it directly. |
24
23
 
25
24
  ## Data sources + caches
@@ -132,7 +131,7 @@ if a detector forgets to set them.
132
131
  is updated periodically; an alpine-3.16 today might not appear EOL until
133
132
  the file is refreshed. Bias is toward false negatives.
134
133
  - **Typosquat threshold is a single distance.** Levenshtein ≤ 2 against
135
- the top-1000 list. Increasing the threshold blows up the FP rate;
134
+ the popular-packages.json list (188 entries, not top-1000). Increasing the threshold blows up the FP rate;
136
135
  decreasing it loses real typosquats. This is the calibrated default.
137
136
 
138
137
  ## Adding a new detector here
@@ -80,9 +80,14 @@ export function scanContainer(fp, raw) {
80
80
  });
81
81
  }
82
82
 
83
+ return findings;
84
+ }
85
+
86
+ function _parsePackagesFromDockerfile(raw) {
83
87
  // Pass 2: apt/apk packages — surface as components hint for the SCA pipeline.
84
88
  // We do NOT query OSV here (the engine's SCA pass owns that). Just collect names.
85
89
  const packages = [];
90
+ let m;
86
91
  _APT_INSTALL_RE.lastIndex = 0;
87
92
  while ((m = _APT_INSTALL_RE.exec(raw))) {
88
93
  for (const tok of m[1].split(/\s+/)) {
@@ -101,7 +106,34 @@ export function scanContainer(fp, raw) {
101
106
  if (/^[a-z0-9][\w.+-]*$/.test(name)) packages.push({ ecosystem: 'alpine', name, version: ver || '' });
102
107
  }
103
108
  }
104
- // Stash packages on the first finding so the engine can consume them downstream
105
- if (packages.length && findings.length) findings[0]._containerPackages = packages;
106
- return findings;
109
+ return packages;
110
+ }
111
+
112
+ // Extract Dockerfile-declared apt/apk packages across every Dockerfile in
113
+ // fileContents, as full SCA component entries — mirrors image-packages.js's
114
+ // extractImagePackages(fileContents) signature/shape so both plug into the
115
+ // engine's components merge the same way. Deliberately independent of
116
+ // scanContainer's return value: piggybacking package data on a *finding*
117
+ // meant no packages were ever surfaced for a Dockerfile whose FROM line
118
+ // wasn't itself flagged (the common case — most Dockerfiles pin a
119
+ // non-EOL base image), even though apt/apk lines were still present and
120
+ // parseable.
121
+ export function extractContainerPackages(fileContents) {
122
+ const comps = [];
123
+ const seen = new Set();
124
+ for (const [file, content] of Object.entries(fileContents || {})) {
125
+ if (!_DOCKERFILE_RE.test(file.replace(/\\/g, '/'))) continue;
126
+ if (!content || content.length > 200_000) continue;
127
+ for (const p of _parsePackagesFromDockerfile(content)) {
128
+ const key = `${p.ecosystem}:${p.name}:${p.version}`;
129
+ if (seen.has(key)) continue;
130
+ seen.add(key);
131
+ comps.push({
132
+ name: p.name, version: p.version, group: '', scope: 'required',
133
+ purl: `pkg:${p.ecosystem === 'debian' ? 'deb/debian' : 'apk/alpine'}/${encodeURIComponent(p.name)}${p.version ? '@' + encodeURIComponent(p.version) : ''}`,
134
+ ecosystem: p.ecosystem, filePath: file, isUnpinned: !p.version, isOsPackage: true, reachable: true,
135
+ });
136
+ }
137
+ }
138
+ return comps;
107
139
  }
@@ -97,6 +97,12 @@ export function detectDepConfusion(components, scanRoot) {
97
97
  id, kind: 'sca', severity: bestDist === 1 ? 'critical' : 'high',
98
98
  vuln: `Possible typosquat: "${c.name}" (1–2 chars from "${bestMatch}")`,
99
99
  cwe: 'CWE-1357', stride: 'Tampering',
100
+ // CMP-1 (Stage 6 follow-up): neither finding here set `family`,
101
+ // so both fell through to the generic `vulnerable-dep` default
102
+ // every unset supplyChain finding gets — invisible to any
103
+ // compliance control mapped to the more specific
104
+ // family:dependency-confusion (ccpa.json).
105
+ family: 'dependency-confusion',
100
106
  file: c.filePath || 'package.json', line: 0,
101
107
  snippet: `${c.ecosystem}:${c.name}@${c.version}`,
102
108
  fix: `Verify "${c.name}" is the package you actually meant. The popular package "${bestMatch}" is ${bestDist} edit(s) away — typosquat malware commonly registers names like this. Double-check the publisher, weekly downloads, and recent changes before keeping this dep.`,
@@ -122,6 +128,7 @@ export function detectDepConfusion(components, scanRoot) {
122
128
  id, kind: 'sca', severity: 'high',
123
129
  vuln: `Internal-scoped package on public registry: "${c.name}"`,
124
130
  cwe: 'CWE-1357', stride: 'Tampering',
131
+ family: 'dependency-confusion',
125
132
  file: c.filePath || 'package.json', line: 0,
126
133
  snippet: `${c.ecosystem}:${c.name}@${c.version}`,
127
134
  fix: `"${c.name}" matches your internal scope "${scope}", but is being resolved from the public registry. Confirm whether your private registry is configured (e.g. .npmrc / .pypirc) — if a public copy exists with the same name an attacker could publish malicious updates and your installs would silently switch.`,