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

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 (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -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 +192 -15
  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 +15 -15
  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 +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -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.`,
@@ -1,187 +0,0 @@
1
- // 0.7.0 Feat-7: SARIF 2.1.0 ingest — normalizes and merges external scanner findings into unified report.
2
- //
3
- // Reads SARIF 2.1.0 from gitleaks / Semgrep / Bandit / Trivy / Checkov / SonarQube
4
- // and merges into our scan. Findings are normalised + deduplicated against our
5
- // own findings via a fingerprint of (CWE/CVE, file, line ±2, rule), and a
6
- // `sources[]` array is added to the merged finding so attribution is preserved.
7
- //
8
- // Pure ESM, no external deps. Reads JSON only — no schema validation library.
9
-
10
- import * as fs from 'node:fs';
11
- import * as crypto from 'node:crypto';
12
-
13
- // Tool-name → (kind, defaultSeverityIfUnset)
14
- const TOOL_PROFILE = {
15
- 'semgrep': { kind: 'sast', defSev: 'medium' },
16
- 'opengrep': { kind: 'sast', defSev: 'medium' },
17
- 'bandit': { kind: 'sast', defSev: 'medium' },
18
- 'eslint': { kind: 'sast', defSev: 'low' },
19
- 'sonarqube': { kind: 'sast', defSev: 'medium' },
20
- 'codeql': { kind: 'sast', defSev: 'high' },
21
- 'gitleaks': { kind: 'secret', defSev: 'high' },
22
- 'trufflehog': { kind: 'secret', defSev: 'high' },
23
- 'detect-secrets':{ kind: 'secret', defSev: 'high' },
24
- 'trivy': { kind: 'sca', defSev: 'high' },
25
- 'grype': { kind: 'sca', defSev: 'high' },
26
- 'osv-scanner': { kind: 'sca', defSev: 'high' },
27
- 'checkov': { kind: 'iac', defSev: 'medium' },
28
- 'tfsec': { kind: 'iac', defSev: 'medium' },
29
- 'kics': { kind: 'iac', defSev: 'medium' },
30
- };
31
-
32
- // SARIF level → our severity tier
33
- const LEVEL_MAP = { error: 'critical', warning: 'high', note: 'medium', none: 'low' };
34
-
35
- // Normalize a tool name into a TOOL_PROFILE key (case + version-agnostic)
36
- function _toolKey(rawName) {
37
- if (!rawName) return null;
38
- const n = String(rawName).toLowerCase().replace(/[^a-z0-9-]/g, '');
39
- for (const k of Object.keys(TOOL_PROFILE)) if (n.includes(k)) return k;
40
- return null;
41
- }
42
-
43
- function _fingerprint(file, line, vuln, cwe) {
44
- // Bucketed line (±2) so that off-by-one rule reports merge correctly.
45
- const bucket = Math.floor((line || 0) / 2) * 2;
46
- const key = `${file}:${bucket}:${(cwe || '').toUpperCase()}:${(vuln || '').replace(/\W+/g, '_').toLowerCase()}`;
47
- return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
48
- }
49
-
50
- function _extractCWE(rule) {
51
- // SARIF rules can carry CWE in `properties.tags`, `properties.cwe`, or `helpUri`.
52
- const tags = rule?.properties?.tags || [];
53
- for (const t of tags) {
54
- const m = String(t).match(/CWE-?(\d+)/i);
55
- if (m) return `CWE-${m[1]}`;
56
- }
57
- if (rule?.properties?.cwe) {
58
- const m = String(rule.properties.cwe).match(/(\d+)/);
59
- if (m) return `CWE-${m[1]}`;
60
- }
61
- if (rule?.helpUri) {
62
- const m = rule.helpUri.match(/cwe\.mitre\.org\/data\/definitions\/(\d+)/i);
63
- if (m) return `CWE-${m[1]}`;
64
- }
65
- return null;
66
- }
67
-
68
- function _extractCVE(result) {
69
- // Trivy / Grype / osv-scanner put CVE in ruleId or properties.
70
- const id = result?.ruleId || '';
71
- const m = id.match(/(CVE-\d{4}-\d{4,7})/i);
72
- if (m) return m[1].toUpperCase();
73
- const tags = result?.properties?.tags || [];
74
- for (const t of tags) {
75
- const tm = String(t).match(/(CVE-\d{4}-\d{4,7})/i);
76
- if (tm) return tm[1].toUpperCase();
77
- }
78
- return null;
79
- }
80
-
81
- // Convert a single SARIF result + its run context into our normalized finding.
82
- function _resultToFinding(result, run, toolKey, defSev) {
83
- const loc = result.locations?.[0]?.physicalLocation;
84
- const file = loc?.artifactLocation?.uri || result?.fingerprints?.path || '(unknown)';
85
- const line = loc?.region?.startLine || 0;
86
- const ruleId = result.ruleId || '';
87
- const rule = (run.tool?.driver?.rules || []).find(r => r.id === ruleId);
88
- const message = result.message?.text || rule?.fullDescription?.text || rule?.shortDescription?.text || ruleId;
89
- const sev = LEVEL_MAP[result.level] || defSev;
90
- const cwe = _extractCWE(rule || result);
91
- const cve = _extractCVE(result);
92
- const profile = TOOL_PROFILE[toolKey] || { kind: 'sast', defSev: 'medium' };
93
- return {
94
- file: file.replace(/^file:\/\//, ''),
95
- line,
96
- severity: sev,
97
- vuln: cve ? `${cve}: ${message}`.slice(0, 240) : message.slice(0, 240),
98
- cwe,
99
- cve,
100
- kind: profile.kind,
101
- ruleId,
102
- snippet: result.locations?.[0]?.physicalLocation?.contextRegion?.snippet?.text
103
- || result.locations?.[0]?.physicalLocation?.region?.snippet?.text
104
- || '',
105
- sources: [run.tool?.driver?.name || toolKey],
106
- };
107
- }
108
-
109
- // Load and parse a SARIF file. Returns an array of normalized findings.
110
- export function ingestSARIFFile(filePath) {
111
- let raw;
112
- try { raw = fs.readFileSync(filePath, 'utf8'); }
113
- catch (e) { throw new Error(`Cannot read SARIF file ${filePath}: ${e.message}`); }
114
- let doc;
115
- try { doc = JSON.parse(raw); }
116
- catch (e) { throw new Error(`Invalid JSON in SARIF file ${filePath}: ${e.message}`); }
117
- if (!doc || !Array.isArray(doc.runs)) {
118
- throw new Error(`Not a SARIF document (missing runs[]): ${filePath}`);
119
- }
120
- const out = [];
121
- for (const run of doc.runs) {
122
- const toolName = run.tool?.driver?.name;
123
- const toolKey = _toolKey(toolName);
124
- const profile = TOOL_PROFILE[toolKey] || { kind: 'sast', defSev: 'medium' };
125
- for (const result of (run.results || [])) {
126
- try { out.push(_resultToFinding(result, run, toolKey || (toolName||'').toLowerCase(), profile.defSev)); }
127
- catch (_) {}
128
- }
129
- }
130
- return out;
131
- }
132
-
133
- // Merge an array of external findings into the scan result. Mutates `scan` in place.
134
- // Behaviour:
135
- // - Findings whose fingerprint matches an existing scan.findings entry have their
136
- // source tool name appended to scan.findings[i].sources[] and (if the external
137
- // finding's severity is higher) the existing severity is bumped.
138
- // - New findings (no match) are appended to scan.findings, scan.secrets, or
139
- // scan.supplyChain depending on `kind`.
140
- export function mergeSARIFFindings(scan, externals) {
141
- // Index existing findings by fingerprint
142
- const existingFP = new Map();
143
- for (const f of (scan.findings || [])) {
144
- const fp = _fingerprint((f.file||'').split(' -> ').pop(), f.line||f.source?.line||f.sink?.line||0, f.vuln||f.type, f.cwe);
145
- existingFP.set(fp, f);
146
- }
147
- let merged = 0, added = 0;
148
- const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
149
- for (const ext of externals) {
150
- const fp = _fingerprint(ext.file, ext.line, ext.vuln, ext.cwe);
151
- const existing = existingFP.get(fp);
152
- if (existing) {
153
- existing.sources = Array.from(new Set([...(existing.sources || ['agentic-security']), ...ext.sources]));
154
- // Bump severity if the external tool reported higher
155
- if ((SEV_RANK[ext.severity] || 0) > (SEV_RANK[existing.severity] || 0)) {
156
- existing.severity = ext.severity;
157
- }
158
- merged++;
159
- continue;
160
- }
161
- // Append as new finding into the right bucket
162
- const id = `sarif-ingest:${fp}`;
163
- const newFinding = {
164
- id, kind: ext.kind, severity: ext.severity, vuln: ext.vuln,
165
- cwe: ext.cwe, file: ext.file, line: ext.line, snippet: ext.snippet || '',
166
- sources: ext.sources, parser: 'SARIF',
167
- };
168
- if (ext.kind === 'secret') (scan.secrets = scan.secrets || []).push(newFinding);
169
- else if (ext.kind === 'sca') (scan.supplyChain = scan.supplyChain || []).push({ ...newFinding, type: 'vulnerable_dep' });
170
- else if (ext.kind === 'iac' || ext.kind === 'sast' || ext.kind === 'logic') (scan.findings = scan.findings || []).push(newFinding);
171
- added++;
172
- }
173
- return { merged, added };
174
- }
175
-
176
- export function ingestAndMerge(scan, sarifPaths) {
177
- let totalMerged = 0, totalAdded = 0;
178
- for (const p of sarifPaths) {
179
- let externals;
180
- try { externals = ingestSARIFFile(p); }
181
- catch (e) { /* swallow per-file errors so one bad SARIF doesn't break the run */ continue; }
182
- const { merged, added } = mergeSARIFFindings(scan, externals);
183
- totalMerged += merged;
184
- totalAdded += added;
185
- }
186
- return { merged: totalMerged, added: totalAdded };
187
- }