@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
@@ -104,12 +104,25 @@ export function ensureKeyPair(dir = keyDir()) {
104
104
  return { privateKeyPem, publicKeyPem, created: true, ...p };
105
105
  } catch (e) {
106
106
  if (e.code !== 'EEXIST') throw e;
107
- return {
108
- privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
109
- publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
110
- created: false,
111
- ...p,
112
- };
107
+ // We lost the race on the private key — someone else's 'wx' won.
108
+ // Their public-key write follows immediately after their private-key
109
+ // write but is not itself atomic with it, so it may not have landed
110
+ // yet: a bare read here would throw an uncaught ENOENT on a genuinely
111
+ // transient state, not a real error. Retry briefly rather than crash.
112
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
113
+ for (let attempt = 0; ; attempt++) {
114
+ try {
115
+ return {
116
+ privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
117
+ publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
118
+ created: false,
119
+ ...p,
120
+ };
121
+ } catch (readErr) {
122
+ if (readErr.code !== 'ENOENT' || attempt >= 50) throw readErr;
123
+ Atomics.wait(sleepBuf, 0, 0, 10); // 10ms; ~500ms total budget
124
+ }
125
+ }
113
126
  }
114
127
  }
115
128
 
@@ -227,9 +240,26 @@ export function signEvidenceBundle(bundle, privateKeyPem) {
227
240
  * Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
228
241
  * source is an expected input, not an exceptional one.
229
242
  */
243
+ // The complete set of top-level keys a legitimately-built, signed bundle can
244
+ // carry — buildEvidenceBundle's six plus signEvidenceBundle's `signature`.
245
+ // EA-03 (Stage-0 audit, 2026): canonicalBytes SIGNS an allowlist of fields;
246
+ // verifyEvidenceBundle never checked for keys OUTSIDE that allowlist, so a
247
+ // bundle with a fabricated `verdict`/`proofLevel`/anything-else stapled on
248
+ // after signing verified as authentic — the signature simply never covered
249
+ // those bytes. Rejecting unknown keys here closes that; it must exactly match
250
+ // what buildEvidenceBundle+signEvidenceBundle actually produce, or a
251
+ // legitimate bundle would start failing verification.
252
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([
253
+ 'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
254
+ ]);
255
+
230
256
  export function verifyEvidenceBundle(bundle, publicKeyPem) {
231
257
  if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
232
258
  if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
259
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
260
+ if (unknownKeys.length) {
261
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
262
+ }
233
263
  const sig = bundle.signature;
234
264
  if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
235
265
  if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
@@ -189,9 +189,21 @@ export function annotateExploitProbability(findings, ctx = {}) {
189
189
  p = _clamp01(p);
190
190
  // Wilson CI: prefer historical CI when we have one, otherwise derive
191
191
  // a wider CI from the prior (n=10 implied sample at base rate).
192
+ //
193
+ // The point estimate (exploitProbability) is ALWAYS the per-finding,
194
+ // factor-adjusted `p` computed above — never the population-level
195
+ // historical hit rate. This module's own header scopes historical data
196
+ // explicitly to the CI ("Wilson CI computed from operator's historical
197
+ // hit rate... when enough samples exist"); overwriting the point
198
+ // estimate too collapsed every finding in a CWE×language slice with
199
+ // enough history to the SAME number regardless of its own reachability/
200
+ // sanitizer/auth signals — a maximally-dangerous finding and a
201
+ // maximally-safe one scored identically, while exploitProbabilityWhy
202
+ // kept listing factors that, in that branch, had zero effect on the
203
+ // reported number.
192
204
  const hist = _historicalCi(history, cwe, f.language || (f.file || '').split('.').pop());
193
205
  if (hist) {
194
- f.exploitProbability = hist.p;
206
+ f.exploitProbability = p;
195
207
  f.exploitProbabilityCI95 = hist.ci;
196
208
  f.exploitProbabilitySlice = hist.slice;
197
209
  f.exploitProbabilityN = hist.n;
@@ -22,7 +22,19 @@ import {
22
22
  } from './verification-separation.js';
23
23
 
24
24
  const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
25
- const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
25
+ // Stage 3 correctness audit (detection depth): this ladder was missing
26
+ // 'critical' — exploitability.js sets f.exploitabilityTier = 'critical' at
27
+ // score >= 0.80 (the tier falsification most needs to demote, since it's
28
+ // exactly the findings a false "survives" verdict would most overstate).
29
+ // _dropTier('critical') hit the `i <= 0` "unknown tier, leave unchanged"
30
+ // branch (indexOf returns -1 for an unrecognized value), so a falsified
31
+ // finding at the critical exploitability tier kept its full tier — the
32
+ // demotion silently no-op'd for the highest tier in the system. 'very-low'
33
+ // (confidence.js's own floor tier) is included too, for the same reason
34
+ // confidenceTier is demoted by this same function — it was already
35
+ // unchanged-at-floor by the same `i <= 0` fallback, so this is a
36
+ // completeness fix there, not a behavior change.
37
+ const TIERS = ['very-low', 'low', 'medium', 'high', 'critical']; // confidence / exploitability tier order
26
38
 
27
39
  function _dropTier(tier) {
28
40
  const i = TIERS.indexOf(tier);
@@ -66,7 +78,16 @@ export function classifyFinding(finding, fileContents) {
66
78
  }
67
79
  // A sanitizer that doesn't match the sink context does NOT block the flow —
68
80
  // the finding survives (this is a real bug, not a mitigation).
69
- if (finding.sanitizerMismatch === true) {
81
+ //
82
+ // Stage 3 correctness audit (detection depth): this was `=== true`, but
83
+ // the field's real producer (engine.js's applySanitizerEffectiveness)
84
+ // sets `f.sanitizerMismatch = f.sanitizerType` — a STRING sanitizer-type
85
+ // label ("Type Guard", "JWT Algo Pinning", ...), never the literal
86
+ // boolean `true`. Every OTHER consumer of this field (confidence.js,
87
+ // exploitability.js, engine.js's own scoring) checks it via plain
88
+ // truthiness; this strict-equality check could never match a real
89
+ // finding, making the whole branch dead code.
90
+ if (finding.sanitizerMismatch) {
70
91
  return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
71
92
  }
72
93
  const window = _pathWindow(finding, fileContents);
@@ -62,7 +62,7 @@ export function recordFixAttempt(scanRoot, record) {
62
62
  try {
63
63
  const dir = stateDir(scanRoot);
64
64
  if (!isSafeStateDir(dir)) return false;
65
- if (!stateWritesEnabled()) return;
65
+ if (!stateWritesEnabled()) return false;
66
66
  fs.mkdirSync(dir, { recursive: true });
67
67
  // One writeSync of one newline-terminated line: a concurrent reader sees
68
68
  // whole records or nothing, and a torn tail is dropped on read.
@@ -38,7 +38,16 @@ function _detectRunner(scanRoot) {
38
38
  } catch { return null; }
39
39
  })();
40
40
  if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
41
- return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
41
+ // --passWithNoTests is Jest-specific CLI syntax appending it
42
+ // unconditionally broke every non-Jest npm test script (mocha, vitest,
43
+ // ava, tap, or a plain node script) with an "unrecognized option" exit,
44
+ // failing verification for a reason that has nothing to do with
45
+ // whether the patch actually broke anything. Only add it when Jest is
46
+ // actually the configured runner.
47
+ const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
48
+ || Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
49
+ const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
50
+ return { runner: 'npm', cmd: 'npm', args };
42
51
  }
43
52
  if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
44
53
  return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
@@ -1,22 +1,28 @@
1
1
  // IaC → application code reachability bridge (Sentinel-parity FR-DET-4).
2
2
  //
3
- // Detects publicly-exposed cloud resources in IaC (Terraform / CloudFormation
4
- // / Kubernetes) and correlates them with application-code references to the
5
- // same resource (by name, ARN, or hostname). Application-code findings on
6
- // resources that IaC has exposed get a severity bump and an explicit
7
- // "exposed-via-iac" tag.
3
+ // Detects publicly-exposed cloud resources in IaC and correlates them with
4
+ // application-code references to the same resource (by name, ARN, or
5
+ // hostname). Application-code findings on resources that IaC has exposed get
6
+ // a severity bump and an explicit "exposed-via-iac" tag.
8
7
  //
9
- // Patterns detected:
8
+ // **Terraform only.** Only `parseTerraform` exists in this file — this header
9
+ // previously also listed CloudFormation and five Kubernetes patterns as
10
+ // detected; neither has any parser function anywhere here (found via
11
+ // Stage-0 doc audit, 2026; confirmed by grep for `function parse*` in this
12
+ // file, which returns exactly one hit). The pattern list below is therefore
13
+ // the Terraform-only reality, not the originally-documented superset:
10
14
  //
11
15
  // S3 bucket with public-read ACL / public-access-block disabled
12
16
  // RDS / DocumentDB / Redshift with publicly_accessible = true
13
17
  // Security group with 0.0.0.0/0 ingress on a sensitive port
14
18
  // ALB / NLB / API Gateway with internet-facing scheme
15
- // K8s Service of type LoadBalancer with no NetworkPolicy
16
- // K8s Ingress with no auth annotation
17
19
  // Lambda function URL with auth_type = NONE
18
20
  // ECS task with assignPublicIp = ENABLED
19
21
  //
22
+ // NOT IMPLEMENTED despite being previously documented here: CloudFormation
23
+ // support, K8s Service/LoadBalancer + NetworkPolicy correlation, K8s Ingress
24
+ // auth-annotation checking.
25
+ //
20
26
  // Output: { exposedResources: [{name, kind, file, line, severity}], findings: [...new findings] }
21
27
 
22
28
  const SENSITIVE_PORTS = new Set([22, 23, 25, 110, 143, 3306, 3389, 5432, 6379, 27017, 9200, 9300, 1521, 5984, 11211]);
@@ -52,19 +52,37 @@ function _readOrGenerateKey() {
52
52
  if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
53
53
  }
54
54
  } catch { /* fall through to generate */ }
55
- // Generate, mode 0600.
55
+ // Generate, mode 0600. `wx` — exclusive create, same TOCTOU fix
56
+ // evidence-bundle.js's ensureKeyPair() already applies to its own key
57
+ // material: on first use, two concurrent processes can both pass the
58
+ // existsSync check above as false and both reach here. Without exclusive
59
+ // create, the last writer's key silently wins on disk while every OTHER
60
+ // process keeps signing with the key it generated and lost — a key that
61
+ // now exists nowhere, so every signature made under it fails to verify
62
+ // forever after, indistinguishable from real tampering.
56
63
  const buf = crypto.randomBytes(32);
57
64
  try {
58
65
  fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
59
- fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600 });
66
+ fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
60
67
  _keySource = 'per-install-new';
61
- } catch {
62
- // Could not persist — this key lives for this process only, so nothing
63
- // signed with it will verify on any later run. Callers must be able to see
64
- // that, or a permanently-unverifiable signature looks like a valid one.
68
+ return buf;
69
+ } catch (e) {
70
+ if (e.code === 'EEXIST') {
71
+ // Another process won the race and persisted its key first — use
72
+ // THAT key instead of the one we generated, or we'd return a key
73
+ // that matches nothing on disk.
74
+ try {
75
+ const hex = fs.readFileSync(fp, 'utf8').trim();
76
+ if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
77
+ } catch { /* fall through to ephemeral */ }
78
+ }
79
+ // Could not persist (or the winner's key was unreadable/malformed) —
80
+ // this key lives for this process only, so nothing signed with it will
81
+ // verify on any later run. Callers must be able to see that, or a
82
+ // permanently-unverifiable signature looks like a valid one.
65
83
  _keySource = 'ephemeral';
84
+ return buf;
66
85
  }
67
- return buf;
68
86
  }
69
87
 
70
88
  // REMOVED (2026-08-08): the legacy hostname-derived key.
@@ -74,4 +74,69 @@ export function summarizeDelta(changed) {
74
74
  return lines.join('\n');
75
75
  }
76
76
 
77
+ // Stage 6 correctness audit: diffValidatorRuns/persistRescanReport/
78
+ // summarizeDelta above were fully built, but nothing in the codebase ever
79
+ // produced a `{model, results: {findingId: {verdict, reason}}}` run file
80
+ // for them to consume — commands/labs.md's `--model-rescan` mode was
81
+ // disclosed as genuinely unwired rather than fabricated. This is the
82
+ // missing producer: runs the SAME findings through the LLM validator twice
83
+ // — once under whatever model the environment currently resolves to
84
+ // ("from"), once under `toModel` ("to", via the existing per-role env
85
+ // override `AGENTIC_SECURITY_LLM_MODEL_VALIDATE` — no new plumbing needed,
86
+ // llm-validator/providers.js already supports it) — and turns the two runs
87
+ // into a real delta report. Reuses validateMany's own candidate filter
88
+ // (critical/high severity, low confidence, or AST parser) rather than
89
+ // re-validating every finding, matching normal validation scope. When no
90
+ // LLM endpoint is configured, validateMany degrades every finding to
91
+ // 'unvalidated' with no network call — this function inherits that
92
+ // no-network-by-default behavior rather than working around it.
93
+ export async function runModelRescan(scanRoot, { toModel } = {}) {
94
+ if (!toModel) return { ok: false, reason: 'no --model given to rescan with' };
95
+ const scan = _readJson(scanRoot, 'last-scan.json');
96
+ if (!scan) return { ok: false, reason: 'no .agentic-security/last-scan.json — run a scan first' };
97
+ const findings = Array.isArray(scan.findings) ? scan.findings : [];
98
+ if (!findings.length) return { ok: false, reason: 'last scan has no findings to re-validate' };
99
+
100
+ const fileContents = {};
101
+ for (const f of findings) {
102
+ if (!f.file || fileContents[f.file] !== undefined) continue;
103
+ try { fileContents[f.file] = fs.readFileSync(path.join(scanRoot, f.file), 'utf8'); }
104
+ catch { /* file may have moved/been deleted since the scan; validateMany skips it */ }
105
+ }
106
+
107
+ const { validateMany } = await import('../llm-validator/index.js');
108
+ const { resolveProvider } = await import('../llm-validator/providers.js');
109
+
110
+ const runFor = async (envKey, modelOverride) => {
111
+ const prev = envKey ? process.env[envKey] : undefined;
112
+ if (envKey) process.env[envKey] = modelOverride;
113
+ let resolvedModel = 'unvalidated';
114
+ try {
115
+ const r = resolveProvider({ role: 'validate' });
116
+ if (r.ok) resolvedModel = r.config.model;
117
+ const clones = findings.map(f => ({ ...f }));
118
+ await validateMany(clones, { fileContents, scanRoot });
119
+ const results = {};
120
+ for (const f of clones) {
121
+ const id = f.stableId || f.id;
122
+ if (!id) continue;
123
+ results[id] = { verdict: f.validator_verdict || 'unvalidated', reason: f.validator_reasoning || null };
124
+ }
125
+ return { model: resolvedModel, results };
126
+ } finally {
127
+ if (envKey) {
128
+ if (prev === undefined) delete process.env[envKey];
129
+ else process.env[envKey] = prev;
130
+ }
131
+ }
132
+ };
133
+
134
+ const runA = await runFor(null, null);
135
+ const runB = await runFor('AGENTIC_SECURITY_LLM_MODEL_VALIDATE', toModel);
136
+
137
+ const changed = diffValidatorRuns(runA, runB);
138
+ const reportPath = persistRescanReport(scanRoot, runA.model, runB.model, changed);
139
+ return { ok: true, from: runA.model, to: runB.model, changed, reportPath, summary: summarizeDelta(changed) };
140
+ }
141
+
77
142
  export const _internals = {};
@@ -10,6 +10,11 @@
10
10
  import * as crypto from 'node:crypto';
11
11
 
12
12
  // Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
13
+ // Exported so a caller can compute the "removed since baseline" (i.e. fixed)
14
+ // set that computeMTTR needs, using the exact same identity function
15
+ // buildBaselineMap uses internally — a caller-side reimplementation would
16
+ // risk drifting from this one and silently under/over-counting fixes.
17
+ export function fingerprintFinding(f) { return _fingerprint(f); }
13
18
  function _fingerprint(f) {
14
19
  const file = (f.file || '').split(' -> ').pop();
15
20
  const line = f.line || f.source?.line || f.sink?.line || 0;
@@ -119,14 +119,33 @@ function _binding(finding, call) {
119
119
  // The request property the handler reads. Anchored to the request identifier
120
120
  // the export actually binds, so a file that reads `req.query` while exporting
121
121
  // `(request, response)` does not produce a PoC built on the wrong name.
122
- function _requestSource(content, reqIdent) {
122
+ //
123
+ // A handler that reads more than one request property (extremely common —
124
+ // e.g. a harmless query param for pagination alongside the actual body
125
+ // param used in the sink) used to always get the first property found in a
126
+ // fixed query>body>params priority, with no relationship to which one
127
+ // actually reaches the sink — silently building a PoC against an inert
128
+ // parameter while the real injection point went untouched. When sinkLine
129
+ // is known, prefer whichever match sits closest to it (line proximity is a
130
+ // cheap, effective proxy for "this is the value that flows into the sink a
131
+ // few lines below/above it"); otherwise fall back to the old first-found
132
+ // behavior for callers that can't supply a line.
133
+ function _requestSource(content, reqIdent, sinkLine) {
123
134
  const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
135
+ const candidates = [];
124
136
  for (const prop of ['query', 'body', 'params']) {
125
- const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`);
126
- const m = content.match(re);
127
- if (m) return { prop, key: m[1] };
137
+ const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`, 'g');
138
+ let m;
139
+ while ((m = re.exec(content))) {
140
+ const line = content.slice(0, m.index).split('\n').length;
141
+ candidates.push({ prop, key: m[1], line });
142
+ }
128
143
  }
129
- return null;
144
+ if (!candidates.length) return null;
145
+ if (typeof sinkLine === 'number') {
146
+ candidates.sort((a, b) => Math.abs(a.line - sinkLine) - Math.abs(b.line - sinkLine));
147
+ }
148
+ return { prop: candidates[0].prop, key: candidates[0].key };
130
149
  }
131
150
 
132
151
  // The sink must interpolate into a SHELL, not an argv array. `exec`/`execSync`
@@ -184,7 +203,7 @@ export function synthesizeInProcessPoc(finding, fileContent) {
184
203
  return { ok: false, reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface' };
185
204
  }
186
205
 
187
- const src = _requestSource(fileContent, reqIdent);
206
+ const src = _requestSource(fileContent, reqIdent, finding.line);
188
207
  if (!src) {
189
208
  return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
190
209
  }
@@ -392,7 +411,7 @@ function _sqlInjectionPoc(finding, fileContent) {
392
411
  if (!found) return NO_HANDLER;
393
412
  const { call, reqIdent } = found;
394
413
 
395
- const src = _requestSource(fileContent, reqIdent);
414
+ const src = _requestSource(fileContent, reqIdent, finding.line);
396
415
  if (!src) {
397
416
  return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
398
417
  }
@@ -490,7 +509,7 @@ function _pathTraversalPoc(finding, fileContent) {
490
509
  if (!READ_SINK.test(fileContent)) {
491
510
  return { ok: false, reason: 'no readFile/sendFile sink in the file, so there is no served content to observe coming back' };
492
511
  }
493
- const src = _requestSource(fileContent, reqIdent);
512
+ const src = _requestSource(fileContent, reqIdent, finding.line);
494
513
  if (!src) {
495
514
  return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the traversal point is unknown` };
496
515
  }
@@ -103,18 +103,30 @@ test('${finding.vuln.replace(/'/g, "\\'")}', async () => {
103
103
  }
104
104
 
105
105
  function _renderPytest(finding, poc) {
106
+ // Same finding-specific extraction _renderJest uses, not the hardcoded
107
+ // SQL-injection payload every call used to emit regardless of the actual
108
+ // vulnerability class. poc.code is assumed to follow this project's own
109
+ // URL_/METHOD/PAYLOAD synthesis convention (poc-generator.js), same as
110
+ // the JS PoCs this extraction already works against.
111
+ const url = _extractUrl(poc.code) || 'http://localhost:3000';
112
+ const method = (_extractMethod(poc.code) || 'POST').toLowerCase();
113
+ const payload = _extractPayloadValue(poc.code) || 'PoC';
106
114
  return `# Regression test for ${finding.vuln} (${finding.cwe || 'unknown CWE'}).
107
- # Auto-generated from P1.1 PoC.
115
+ # Auto-generated from P1.1 PoC; fails on vulnerable code, passes after the fix.
108
116
 
109
117
  import requests
110
118
 
111
119
 
112
120
  def test_${(finding.stableId || finding.id || 'finding').replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 40)}():
113
- url = 'http://localhost:3000'
114
- r = requests.post(url, json={'input': "' UNION SELECT username, password FROM users--"})
115
- # After the fix, the response should not contain SQL-syntax-error evidence.
121
+ url = ${JSON.stringify(url)}
122
+ payload = ${JSON.stringify(payload)}
123
+ r = requests.${method}(url, json={'input': payload}) if ${JSON.stringify(method)} != 'get' \\
124
+ else requests.get(url, params={'input': payload})
125
+ # The assertion below is the "exploit demonstrated" signal — when this
126
+ # test PASSES, the vuln is still present. After the fix, this assertion
127
+ # SHOULD fail. Inverse the assertion in a green build.
116
128
  text = r.text
117
- demonstrated = 'syntax error' in text.lower() or '<script>' in text
129
+ demonstrated = 'PoC' in text or 'syntax error' in text.lower() or '<script>' in text
118
130
  assert not demonstrated, f'Vulnerability still present: {text[:200]}'
119
131
  `;
120
132
  }
@@ -127,10 +139,13 @@ function _extractMethod(code) {
127
139
  const m = String(code || '').match(/METHOD = (['"])([A-Z]+)\1/);
128
140
  return m ? m[2] : null;
129
141
  }
130
- function _extractPayloadLine(code) {
142
+ function _extractPayloadValue(code) {
131
143
  const m = String(code || '').match(/PAYLOAD = `([^`]+)`/);
132
- if (m) return `const PAYLOAD = ${JSON.stringify(m[1])};`;
133
- return `const PAYLOAD = 'PoC';`;
144
+ return m ? m[1] : null;
145
+ }
146
+ function _extractPayloadLine(code) {
147
+ const v = _extractPayloadValue(code);
148
+ return `const PAYLOAD = ${JSON.stringify(v || 'PoC')};`;
134
149
  }
135
150
 
136
151
  /**
@@ -96,7 +96,11 @@ export function annotateScaReverseBlast(findings, fileContents) {
96
96
  if (!Object.keys(map).length) return findings;
97
97
  for (const f of findings) {
98
98
  if (!f || typeof f !== 'object') continue;
99
- const pkg = f.package || f.dependency || f.pkg;
99
+ // SCA findings (engine.js's queryOSV) carry the package name as `.name`
100
+ // — `.package`/`.dependency`/`.pkg` are never set anywhere in this
101
+ // codebase; kept as a fallback in case a caller supplies a differently-
102
+ // shaped finding.
103
+ const pkg = f.name || f.package || f.dependency || f.pkg;
100
104
  if (!pkg || !map[pkg]) continue;
101
105
  f.reverseExposure = {
102
106
  importerCount: map[pkg].directImporters.length,
@@ -110,8 +110,25 @@ function _impactFor(finding, cfg) {
110
110
  return table.default;
111
111
  }
112
112
 
113
+ // SCA entries carry reachabilityTier/routeReachable (engine.js's SCA
114
+ // reachability pass); SAST findings never do — they carry relevanceTier/
115
+ // entrypointReachable instead (posture/relevance.js). Without this
116
+ // fallback, _reachDiscount always read 'unknown' (0.3) for every SAST
117
+ // finding, regardless of whether it was actually route-reachable.
118
+ function _relevanceTierToReachTier(relevanceTier) {
119
+ switch (relevanceTier) {
120
+ case 'direct': return 'route-reachable';
121
+ case 'indirect': return 'function-reachable';
122
+ case 'unreachable': return 'unreachable';
123
+ default: return null;
124
+ }
125
+ }
126
+
113
127
  function _reachDiscount(finding) {
114
- const tier = finding.reachabilityTier || finding.routeReachable && 'route-reachable' || 'unknown';
128
+ const tier = finding.reachabilityTier
129
+ || (finding.routeReachable && 'route-reachable')
130
+ || _relevanceTierToReachTier(finding.relevanceTier)
131
+ || 'unknown';
115
132
  return REACH_DISCOUNT[tier] || 0.3;
116
133
  }
117
134
 
@@ -31,17 +31,26 @@ export function scanHistoryDiff(diffText, commit, detectFn) {
31
31
  if (!added.trim()) return [];
32
32
  let findings = [];
33
33
  try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
34
- return findings.map((f) => ({
35
- ...f,
36
- id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
37
- file: `git-history@${commit}`,
38
- line: 0,
39
- commit,
40
- _historical: true,
41
- vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
42
- description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
43
- remediation: 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.',
44
- }));
34
+ return findings.map((f) => {
35
+ const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
36
+ return {
37
+ ...f,
38
+ id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
39
+ file: `git-history@${commit}`,
40
+ line: 0,
41
+ commit,
42
+ _historical: true,
43
+ vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
44
+ description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
45
+ remediation,
46
+ // report/index.js's _remediationOf checks `.fix` before `.remediation`
47
+ // — the underlying detector already set `.fix` to a generic "remove
48
+ // the line" string, which would otherwise silently shadow this
49
+ // history-specific instruction ("removing it from HEAD alone is
50
+ // insufficient") in every report format.
51
+ fix: remediation,
52
+ };
53
+ });
45
54
  }
46
55
 
47
56
  /**
@@ -35,7 +35,13 @@ function _snapshotFromScan(scan, label) {
35
35
  medium: findings.filter(f => f.severity === 'medium').length,
36
36
  low: findings.filter(f => f.severity === 'low').length,
37
37
  kev: findings.filter(f => f.kev).length,
38
- ids: new Set(findings.map(f => f.id).filter(Boolean)),
38
+ // stable-id.js exists specifically because the default `id` embeds file
39
+ // path + line number, so any refactor that shifts a line rotates the id
40
+ // — using it here would report the same unfixed vulnerability as one
41
+ // "fixed" finding and one "introduced" finding on every such shift.
42
+ // stableId omits the exact line by design; fall back to `id` only for
43
+ // finding shapes that never got a stableId annotated.
44
+ ids: new Set(findings.map(f => f.stableId || f.id).filter(Boolean)),
39
45
  };
40
46
  }
41
47
 
@@ -185,6 +185,18 @@ function _buildPlaybook(stack) {
185
185
  ]});
186
186
  }
187
187
 
188
+ // Express
189
+ if (stack.has('express')) {
190
+ sections.push({ title: 'Express', items: [
191
+ 'Use helmet() to set security headers (X-Frame-Options, X-Content-Type-Options, HSTS) — Express sets none of these by default',
192
+ 'Never use body-parser / express.json() without a size limit — set `limit` explicitly to prevent request-body DoS',
193
+ 'Apply express-rate-limit to authentication and any expensive routes',
194
+ 'Validate and sanitize all req.params / req.query / req.body — Express does not validate input for you',
195
+ 'Set `app.disable(\'x-powered-by\')` so error responses and headers do not advertise the framework/version to attackers',
196
+ 'Use a CSRF middleware (e.g. csrf-csrf) on any route that relies on cookie-based sessions',
197
+ ]});
198
+ }
199
+
188
200
  // Django
189
201
  if (stack.has('django')) {
190
202
  sections.push({ title: 'Django', items: [
@@ -202,7 +214,16 @@ function _buildPlaybook(stack) {
202
214
  function _findingFromItem(scanRoot, stackName, item, idx) {
203
215
  return {
204
216
  id: `stack-playbook:${stackName.replace(/\s+/g, '_').toUpperCase()}:${idx}`,
205
- title: `[${stackName} Security Checklist] ${item.slice(0, 80)}`,
217
+ // The findings schema requires `vuln` (root CLAUDE.md); this used to set
218
+ // `title` instead, which isn't a schema field at all. engine.js's generic
219
+ // no-vuln-name filter (`_shouldKeep`) treats any non-SCA finding with no
220
+ // `vuln` string as unenriched noise and drops it — silently, for every
221
+ // stack, confirmed live via a real scan whose logicVulns went from 6
222
+ // playbook findings right after they were pushed to 0 by the time the
223
+ // scan returned. `vuln` is now the actionable string this finding is
224
+ // actually about; `description`/`remediation` (already correct) keep
225
+ // the fuller text.
226
+ vuln: `[${stackName} Security Checklist] ${item.slice(0, 80)}`,
206
227
  severity: 'info',
207
228
  file: 'package.json',
208
229
  line: 1,
@@ -13,8 +13,8 @@
13
13
  // SOC2 / HIPAA / GDPR) adds compliance-tag fields to findings in
14
14
  // matching families (PII → HIPAA/GDPR; auth → SOC2 CC6.1; etc.).
15
15
  // - **Stated attacker** — "## Attacker model" / "## Threat actor"
16
- // section sets f.attackerProfile = 'script-kiddie' | 'apt' | 'insider'
17
- // for use in downstream prioritization.
16
+ // section sets f.threatModel.attacker = 'script-kiddie' | 'apt' |
17
+ // 'insider' for use in downstream prioritization.
18
18
  //
19
19
  // Opt-out: AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING=1
20
20
 
@@ -92,15 +92,22 @@ export function recordTriage(scanRoot, { family, verdict, stableId }) {
92
92
  const data = _read(scanRoot);
93
93
  data.productionTriage = data.productionTriage || {};
94
94
  const row = data.productionTriage[family] = data.productionTriage[family] || { tp: 0, fp: 0, wontfix: 0, lastAt: null };
95
+ void stableId;
96
+ // Already frozen from a previous call — `_capped: true` was persisted at
97
+ // the moment the cap was crossed (below), so this is a deliberate,
98
+ // visible freeze: nothing new is written, but nothing was silently lost
99
+ // either. Previously the crossing call itself never called _write, so
100
+ // `_capped` never reached disk and EVERY call after the cap — not just
101
+ // more of the same verdict, any verdict — silently vanished with the
102
+ // on-disk row frozen one write short of the real crossing point.
103
+ if (row._capped) return row;
95
104
  row[verdict] = (row[verdict] || 0) + 1;
96
105
  row.lastAt = new Date().toISOString();
97
106
  // Cap per-family rows so a runaway triage script can't bloat the file.
107
+ // The crossing call still writes — that's what makes the freeze visible.
98
108
  if ((row.tp || 0) + (row.fp || 0) + (row.wontfix || 0) > 10_000) {
99
- // Stop accumulating; the trend is well-established by now.
100
109
  row._capped = true;
101
- return row;
102
110
  }
103
- void stableId;
104
111
  _write(scanRoot, data);
105
112
  return row;
106
113
  }