@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
@@ -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
 
@@ -61,7 +61,7 @@ export function toCycloneDX(scan, meta = {}) {
61
61
  version: 1,
62
62
  metadata: {
63
63
  timestamp: meta.startedAt || new Date().toISOString(),
64
- tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: '0.7.0' }],
64
+ tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
65
65
  component: { type: 'application', name: 'scan-target', version: '1.0.0' },
66
66
  },
67
67
  components: cdxComponents,
@@ -117,7 +117,7 @@ export function toSPDX(scan, meta = {}) {
117
117
  documentNamespace: docNamespace,
118
118
  creationInfo: {
119
119
  created: ts,
120
- creators: ['Tool: agentic-security-0.7.0'],
120
+ creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
121
121
  },
122
122
  packages,
123
123
  relationships: packages.map(p => ({
@@ -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
  }
@@ -17,9 +17,11 @@
17
17
  // against a caller-provided target URL (AGENTIC_SECURITY_VERIFY_TARGET).
18
18
  // Without a target, live mode falls back to validate-only with a
19
19
  // `cannot-verify` verdict + reason 'no-target'.
20
- // * Sandbox: Docker by default with restrictive flags; subprocess fallback
21
- // with ulimit. The sandbox runner is exported so the CLI subcommand can
22
- // reuse it.
20
+ // * Sandbox: live execution runs through src/sandbox/index.js's confined
21
+ // execution facility (the same one execution-proof.js uses) never a
22
+ // bare, unconfined subprocess. When no confinement primitive is
23
+ // available on the host, live verification refuses rather than falling
24
+ // back to running the PoC unconfined.
23
25
  //
24
26
  // Fail-closed semantics (FR-VER-7): any error — Docker missing, target down,
25
27
  // PoC throws — produces `cannot-verify`, never `rejected`. An attacker who
@@ -28,7 +30,7 @@
28
30
  import * as fs from 'node:fs';
29
31
  import * as path from 'node:path';
30
32
  import * as os from 'node:os';
31
- import { spawnSync } from 'node:child_process';
33
+ import { runConfined, sandboxAvailable } from '../sandbox/index.js';
32
34
  import { isExplicitlyNoPoc } from './poc-cwe-map.js';
33
35
 
34
36
  // ─── PoC static validation ──────────────────────────────────────────────────
@@ -122,18 +124,36 @@ export function proveSanitizerAbsence(finding, fileContents) {
122
124
  function runSandboxed(poc, opts = {}) {
123
125
  const target = opts.target;
124
126
  if (!target) return { ok: false, reason: 'no-target' };
125
- // Materialise the PoC to a temp file.
126
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'as-poc-'));
127
- const file = path.join(dir, poc.lang === 'python' ? 'poc.py' : 'poc.mjs');
127
+ if (!sandboxAvailable() && !opts.force) {
128
+ return { ok: false, reason: 'no confinement primitive available on this host; refusing to execute the PoC unconfined', runner: 'disabled' };
129
+ }
130
+ // Materialise the PoC into a fresh sandbox root.
131
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'as-poc-')));
132
+ const file = poc.lang === 'python' ? 'poc.py' : 'poc.mjs';
128
133
  try {
129
- fs.writeFileSync(file, _patchTarget(poc.code, target));
134
+ fs.writeFileSync(path.join(dir, file), _patchTarget(poc.code, target));
130
135
  } catch (e) {
136
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
131
137
  return { ok: false, reason: `write-failed:${e.message}` };
132
138
  }
133
- const docker = _haveDocker() ? _runDocker(file, dir, poc.lang, opts) : null;
134
- const result = docker || _runSubprocess(file, poc.lang, opts);
135
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
136
- return result;
139
+ try {
140
+ const argv = poc.lang === 'python' ? ['python3', file] : [process.execPath, file];
141
+ // allowNetwork: the whole point of live verification is reaching the
142
+ // caller-provided target — writes and everything else stay confined.
143
+ const r = runConfined(argv, { root: dir, timeoutMs: opts.timeoutMs || 15000, allowNetwork: true, force: opts.force });
144
+ if (r.status === 'disabled') {
145
+ return { ok: false, reason: 'confined execution is disabled; the PoC was refused and never executed', runner: r.backend };
146
+ }
147
+ if (r.status === 'error') {
148
+ return { ok: false, reason: `sandbox-error:${(r.stderr || '').trim() || 'unknown'}`, runner: r.backend };
149
+ }
150
+ if (r.timedOut) {
151
+ return { ok: false, reason: 'poc-timeout', runner: r.backend };
152
+ }
153
+ return { ok: true, exitCode: r.exitCode, stderr: r.stderr || '', stdout: r.stdout || '', runner: r.backend, denied: r.denied };
154
+ } finally {
155
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
156
+ }
137
157
  }
138
158
 
139
159
  function _patchTarget(code, target) {
@@ -141,51 +161,6 @@ function _patchTarget(code, target) {
141
161
  return code.replace(/http:\/\/localhost:3000/g, target);
142
162
  }
143
163
 
144
- function _haveDocker() {
145
- try {
146
- const r = spawnSync('docker', ['version'], { stdio: 'ignore', timeout: 3000 });
147
- return r.status === 0;
148
- } catch { return false; }
149
- }
150
-
151
- function _runDocker(file, dir, lang, opts) {
152
- const image = lang === 'python' ? 'python:3.12-slim' : 'node:22-slim';
153
- const cmd = lang === 'python' ? ['python3', '/work/poc.py'] : ['node', '/work/poc.mjs'];
154
- const args = [
155
- 'run', '--rm',
156
- '--network=host', // PoC must reach the target; host is the smallest blast radius
157
- '--cap-drop=ALL',
158
- '--memory=256m',
159
- '--cpu-quota=20000',
160
- '--pids-limit=64',
161
- '--read-only',
162
- '--tmpfs=/tmp',
163
- '--user', 'nobody',
164
- '-v', `${dir}:/work:ro`,
165
- image,
166
- ...cmd,
167
- ];
168
- const r = spawnSync('docker', args, {
169
- timeout: opts.timeoutMs || 15000,
170
- encoding: 'utf8',
171
- });
172
- if (r.error) return { ok: false, reason: `docker-error:${r.error.code || r.error.message}`, runner: 'docker' };
173
- return { ok: true, exitCode: r.status, stderr: r.stderr || '', stdout: r.stdout || '', runner: 'docker' };
174
- }
175
-
176
- function _runSubprocess(file, lang, opts) {
177
- const bin = lang === 'python' ? 'python3' : 'node';
178
- const r = spawnSync(bin, [file], {
179
- timeout: opts.timeoutMs || 15000,
180
- encoding: 'utf8',
181
- // Best-effort containment without Docker. Operators are warned in stderr
182
- // that the subprocess fallback offers materially weaker isolation.
183
- env: { PATH: process.env.PATH || '', NODE_OPTIONS: '' },
184
- });
185
- if (r.error) return { ok: false, reason: `subprocess-error:${r.error.code || r.error.message}`, runner: 'subprocess' };
186
- return { ok: true, exitCode: r.status, stderr: r.stderr || '', stdout: r.stdout || '', runner: 'subprocess' };
187
- }
188
-
189
164
  // ─── Per-finding verdict assignment ─────────────────────────────────────────
190
165
 
191
166
  export function verdictForFinding(finding, ctx = {}) {