@clear-capabilities/agentic-security-scanner 0.134.0 → 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 (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
@@ -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 = {}) {
@@ -36,11 +36,12 @@
36
36
  import * as fs from 'node:fs';
37
37
  import * as path from 'node:path';
38
38
 
39
+ import { statePath } from './state-dir.js';
39
40
  const CANDIDATE_PATHS = [
40
- '.agentic-security/waf-rules.json',
41
- '.agentic-security/waf-rules.yml',
42
- '.agentic-security/waf-rules.yaml',
43
- '.agentic-security/waf-rules.conf',
41
+ 'waf-rules.json',
42
+ 'waf-rules.yml',
43
+ 'waf-rules.yaml',
44
+ 'waf-rules.conf',
44
45
  'waf/rules.json',
45
46
  'cloudflare-rules.json',
46
47
  'aws-waf.json',
@@ -134,7 +135,7 @@ function parseScalar(s) {
134
135
  export function loadWafRules(scanRoot) {
135
136
  const root = scanRoot || process.cwd();
136
137
  for (const rel of CANDIDATE_PATHS) {
137
- const fp = path.join(root, rel);
138
+ const fp = statePath(root, rel);
138
139
  if (!fs.existsSync(fp)) continue;
139
140
  let text;
140
141
  try { text = fs.readFileSync(fp, 'utf8'); } catch { continue; }
@@ -23,7 +23,7 @@ import * as fs from 'node:fs/promises';
23
23
  import * as fsSync from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
 
26
- const STATE = '.agentic-security';
26
+ import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
27
27
  const STATUS_MD = 'watch-status.md';
28
28
  const STATUS_JSON = 'watch-status.json';
29
29
  const DEBOUNCE_MS = 350;
@@ -85,7 +85,8 @@ export function renderStatusLine(delta) {
85
85
  * Persist watch-status.{md,json}. Cheap atomic write (write tmp, rename).
86
86
  */
87
87
  export function persistStatus(scanRoot, delta) {
88
- const dir = path.join(scanRoot, STATE);
88
+ const dir = stateDir(scanRoot);
89
+ if (!stateWritesEnabled()) return;
89
90
  try { fsSync.mkdirSync(dir, { recursive: true }); } catch {}
90
91
  const status = {
91
92
  ts: new Date().toISOString(),
@@ -122,7 +123,7 @@ export function persistStatus(scanRoot, delta) {
122
123
  * Read the latest watch-status (returns null if none).
123
124
  */
124
125
  export function readStatus(scanRoot) {
125
- return _readJsonSafe(path.join(scanRoot, STATE, STATUS_JSON));
126
+ return _readJsonSafe(statePath(scanRoot, STATUS_JSON));
126
127
  }
127
128
 
128
129
  /**
@@ -24,7 +24,12 @@ function riskNote(f) {
24
24
  const et = String(f.exploitabilityTier || '').toLowerCase();
25
25
  if (et === 'minimal' || et === 'low') return `likely lower risk — ${et} exploitability`;
26
26
  const ct = String(f.confidenceTier || '').toLowerCase();
27
- if (ct === 'low' || (typeof f.confidence === 'number' && f.confidence > 0 && f.confidence < 0.5)) {
27
+ // `f.confidence` is clamped to [0,1] by posture/confidence.js and 0 is a
28
+ // real, reachable value (unset confidence normalizes to `null`, not 0) —
29
+ // the single worst score this note exists to catch. A `> 0` lower bound
30
+ // exempted exactly that value from the downgrade note a 0.05 finding
31
+ // correctly got.
32
+ if (ct === 'low' || (typeof f.confidence === 'number' && f.confidence < 0.5)) {
28
33
  return 'lower confidence — verify before prioritising';
29
34
  }
30
35
  return null;
@@ -83,6 +88,25 @@ function fingerprint(f){
83
88
  return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
84
89
  }
85
90
 
91
+ // CMP-3: the findings schema requires `remediation` (root CLAUDE.md), and
92
+ // most detectors set it, but normalizeFindings only ever read the older
93
+ // `fix` STRING field that a minority of detectors use — so for every
94
+ // detector using the documented schema field, the SARIF fixes[]/
95
+ // fullDescription, the Markdown Fix column, and the CLI inline "fix:" line
96
+ // all rendered empty. `fix` still wins when both are set, matching the
97
+ // existing precedence in explainParts() above.
98
+ // Exported (Stage 6) so other raw-finding consumers outside this module —
99
+ // mcp/tools.js's scan_diff, lsp/server.js — that read scan.findings BEFORE
100
+ // normalizeFindings ever touches it can apply the same fix-string-vs-
101
+ // remediation-field precedence instead of re-implementing it ad hoc (and
102
+ // getting it wrong the way both of those did — reading only `.remediation`,
103
+ // which ~127 of engine.js's own detectors never set).
104
+ export function _remediationOf(f) {
105
+ if (f && typeof f.fix === 'string') return f.fix;
106
+ if (typeof f?.remediation === 'string') return f.remediation;
107
+ return null;
108
+ }
109
+
86
110
  export function normalizeFindings(scan){
87
111
  const out = [];
88
112
  // Feat-4: filter findings via custom suppressions, recording the suppression
@@ -109,11 +133,13 @@ export function normalizeFindings(scan){
109
133
  file: f.file,
110
134
  line: f.line || f.source?.line || f.sink?.line || 0,
111
135
  snippet: f.snippet || f.source?.snippet || f.sink?.snippet || '',
112
- fix: f.fix ? { description: f.fix, code: f.code || '' } : null,
136
+ fix: _remediationOf(f) ? { description: _remediationOf(f), code: f.code || '' } : null,
137
+ remediation: _remediationOf(f),
113
138
  reachable: f.reachable ?? null,
114
139
  triage: f.triageScore ?? null,
115
140
  dataClasses: f.dataClasses || [],
116
141
  chain: Array.isArray(f.chain) ? f.chain : null,
142
+ sourceProvenance: f.sourceProvenance || null,
117
143
  confidence: typeof f.confidence === 'number' ? f.confidence : null,
118
144
  // R17: corroboration ("one issue, many signals") — count of independent
119
145
  // analyses that agreed, and which ones.
@@ -237,6 +263,65 @@ export function normalizeFindings(scan){
237
263
  predictedBountyUsd: f.predictedBountyUsd || null,
238
264
  bountyConfidence: f.bountyConfidence || null,
239
265
  attackPlaybook: f.attackPlaybook || null,
266
+ // posture/git-history.js#annotateGitHistory — git blame + commit
267
+ // context, including AI-authorship detection via the Claude
268
+ // co-author trailer. Wired in engine.js but previously dropped here.
269
+ introducedBy: f.introducedBy || null,
270
+ introducedIn: f.introducedIn || null,
271
+ introducedAt: f.introducedAt || null,
272
+ introducedInMessage: f.introducedInMessage || null,
273
+ originatingPrompt: f.originatingPrompt || null,
274
+ aiAuthored: f.aiAuthored === true,
275
+ // posture/risk-dollars.js#annotateRiskDollars,
276
+ // posture/time-to-fix.js#annotateTimeToFix — same class of gap.
277
+ riskDollars: f.riskDollars || null,
278
+ estimatedFixHours: typeof f.estimatedFixHours === 'number' ? f.estimatedFixHours : null,
279
+ estimatedFixHoursSource: f.estimatedFixHoursSource || null,
280
+ // posture/composite-risk.js#annotateCompositeRisk — the module's own
281
+ // header calls this "the canonical sort key" for agents/UI; toProTable
282
+ // silently fell back to the older `triage` field because this was
283
+ // never in the allowlist.
284
+ compositeRisk: typeof f.compositeRisk === 'number' ? f.compositeRisk : null,
285
+ compositeRiskTier: f.compositeRiskTier || null,
286
+ compositeRiskFactors: Array.isArray(f.compositeRiskFactors) ? f.compositeRiskFactors : null,
287
+ // posture/relevance.js#annotateRelevance — entrypoint-reachability
288
+ // verdict + audit trail. The re-ranked `exploitability` it also sets
289
+ // did survive normalization; the verdict fields explaining WHY did not.
290
+ entrypointReachable: f.entrypointReachable ?? null,
291
+ relevance: typeof f.relevance === 'number' ? f.relevance : null,
292
+ relevanceTier: f.relevanceTier || null,
293
+ relevanceFactors: Array.isArray(f.relevanceFactors) ? f.relevanceFactors : null,
294
+ // posture/attack-taxonomy.js#annotateAttackTaxonomy — default-on;
295
+ // toProTable's `capec`/`mitre` columns read these and rendered `—`
296
+ // for every finding because they were never in this allowlist.
297
+ attck: f.attck || null,
298
+ attckName: f.attckName || null,
299
+ attckTactic: f.attckTactic || null,
300
+ atlas: f.atlas || null,
301
+ atlasName: f.atlasName || null,
302
+ d3fend: f.d3fend || null,
303
+ capec: f.capec || null,
304
+ // posture/falsification.js#annotateFalsification, which records its
305
+ // verdict via posture/verification-separation.js — recall-preserving
306
+ // (never removes a finding, never touches severity), but the verdict
307
+ // itself needs to survive to output or a quarantined finding ships
308
+ // indistinguishable from one nobody contested.
309
+ falsification: f.falsification || null,
310
+ quarantined: f.quarantined === true,
311
+ // The canonical schema (scanner/CLAUDE.md) documents `description` as
312
+ // a required field distinct from `vuln` (headline) and `remediation`
313
+ // (fix instructions) — ~47 SAST detectors set finding-specific "why
314
+ // this fired" prose here. It was never in this allowlist, so it was
315
+ // silently dropped between the detector and every report format.
316
+ description: f.description || null,
317
+ // posture/threat-model-grounding.js#applyThreatModel — crown-jewel /
318
+ // out-of-scope / compliance-regime / attacker-model tags. The severity
319
+ // bump/demotion it also performs mutates `severity` directly (so that
320
+ // half already survived normalization); this object itself did not.
321
+ threatModel: f.threatModel || null,
322
+ verification: f.verification || null,
323
+ // posture/pattern-propagation.js#annotateCrossRepoSignals — default-on.
324
+ crossRepoSignal: f.crossRepoSignal || null,
240
325
  });
241
326
  }
242
327
  for (const s of (scan.secrets||[])) {
@@ -250,11 +335,19 @@ export function normalizeFindings(scan){
250
335
  stride: s.stride || 'Information Disclosure',
251
336
  file: s.file, line: s.line, snippet: s.snippet || '',
252
337
  masked: s.masked || null,
253
- fix: s.fix ? { description: s.fix, code: s.code || '' } : null,
338
+ fix: _remediationOf(s) ? { description: _remediationOf(s), code: s.code || '' } : null,
339
+ remediation: _remediationOf(s),
254
340
  blastRadius: s.blastRadius || null,
255
341
  // Premortem #8: parser/family for downstream confidence + calibration.
256
342
  parser: s.parser || 'SECRETS',
257
343
  family: s.family || 'hardcoded-secret',
344
+ // secret-history.js sets these on a git-history-sweep finding (the
345
+ // commit it was found in, and a flag distinguishing it from a
346
+ // working-tree finding) — carried through so a consumer doesn't have
347
+ // to parse the commit sha back out of the synthetic `file` value.
348
+ commit: s.commit || null,
349
+ historical: s._historical === true,
350
+ description: s.description || null,
258
351
  });
259
352
  }
260
353
  for (const lv of (scan.logicVulns||[])) {
@@ -267,11 +360,21 @@ export function normalizeFindings(scan){
267
360
  cwe: lv.cwe || null,
268
361
  stride: lv.stride || null,
269
362
  file: lv.file, line: lv.line, snippet: lv.snippet || '',
270
- fix: lv.fix ? { description: lv.fix, code: lv.code || '' } : null,
363
+ fix: _remediationOf(lv) ? { description: _remediationOf(lv), code: lv.code || '' } : null,
364
+ remediation: _remediationOf(lv),
271
365
  blastRadius: lv.blastRadius || null,
272
366
  // Premortem #8.
273
367
  parser: lv.parser || 'LOGIC',
274
368
  family: lv.family || null,
369
+ // evaluateLicensePolicy (kind:'license') sets these so a consumer can
370
+ // identify WHICH component/license triggered the finding without
371
+ // regex-parsing the prose `vuln` string. Harmless no-op (all null)
372
+ // for every other logicVulns kind that doesn't set them.
373
+ package: lv.package || null,
374
+ version: lv.version || null,
375
+ ecosystem: lv.ecosystem || null,
376
+ license: lv.license || null,
377
+ description: lv.description || null,
275
378
  });
276
379
  }
277
380
  for (const sc of (scan.supplyChain||[])) {
@@ -281,6 +384,12 @@ export function normalizeFindings(scan){
281
384
  out.push({
282
385
  id: fingerprint(sc),
283
386
  kind: 'sca',
387
+ // sc.type discriminates vulnerable_dep | unpinned_dep | no_lockfile
388
+ // (src/sca/CLAUDE.md). Every MCP SCA-upgrade tool checks this field
389
+ // on findings looked up from the PERSISTED (toJSON-serialized) scan —
390
+ // dropping it here made synthesize_sca_upgrade/apply_sca_upgrade
391
+ // refuse every real SCA finding unconditionally.
392
+ type: sc.type || 'vulnerable_dep',
284
393
  severity: sc.severity || 'high',
285
394
  vuln: scVuln,
286
395
  cwe: sc.cwe || null,
@@ -421,6 +530,22 @@ export function toJSON(scan, meta={}, opts={}){
421
530
  // signature proves who asked for it, not that the results are absent.
422
531
  suppressedRules: scan.suppressedRules || null,
423
532
  _scanMeta: scan._scanMeta || null,
533
+ // S7: engine.js computes these on every scan with components, and their
534
+ // own findings already flow into `findings` above — but the structured
535
+ // summary objects (per-component license map, drift counts, "first
536
+ // scan, no baseline yet") were previously dropped here, so they never
537
+ // reached last-scan.json (written from this function's return value)
538
+ // or any --format output at all.
539
+ licenseGraph: scan.licenseGraph || null,
540
+ sbomDiff: scan.sbomDiff || null,
541
+ entrypointInventory: scan.entrypointInventory || null,
542
+ // S7: same class of gap as the three above — computed on every scan
543
+ // (rootCauseSweep unconditionally; attackTaxonomy/privacyFramework each
544
+ // default-on unless their own AGENTIC_SECURITY_NO_*/opt-in env var says
545
+ // otherwise) but never reached last-scan.json or any --format output.
546
+ rootCauseSweep: scan.rootCauseSweep || null,
547
+ attackTaxonomy: scan.attackTaxonomy || null,
548
+ privacyFramework: scan.privacyFramework || null,
424
549
  };
425
550
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
426
551
  return out;
@@ -462,7 +587,10 @@ export function toSTIX(scan, meta = {}) {
462
587
  created: now,
463
588
  modified: now,
464
589
  name: `${f.vuln || 'Security finding'} at ${f.file || '?'}:${f.line || '?'}`,
465
- description: f.fix?.description || f.vuln || '',
590
+ // Same precedence fix as toSARIF's fullDescription/message: the
591
+ // detector's own description of the finding beats remediation text,
592
+ // which beats degrading to the bare vuln title.
593
+ description: f.description || f.fix?.description || f.vuln || '',
466
594
  external_references: cweExt,
467
595
  labels: [f.severity || 'unknown'],
468
596
  // x_* extension fields — STIX 2.1 allows custom properties prefixed
@@ -615,7 +743,14 @@ export function toSARIF(scan, meta={}){
615
743
  id: f.vuln.replace(/[^a-zA-Z0-9]/g, '_'),
616
744
  name: f.vuln,
617
745
  shortDescription: { text: f.vuln },
618
- fullDescription: { text: f.fix?.description || f.vuln },
746
+ // Prefer the detector's own explanation of the finding (`description`)
747
+ // over remediation text — `fix?.description` is fix instructions, not
748
+ // an account of the vulnerability. Falling back to remediation (rather
749
+ // than degrading to the bare rule title, already shown in
750
+ // shortDescription/name) when no description was set is intentional —
751
+ // see CMP-3's test asserting a remediation-only finding still gets a
752
+ // non-degenerate fullDescription.
753
+ fullDescription: { text: f.description || f.fix?.description || f.vuln },
619
754
  helpUri: f.cwe ? `https://cwe.mitre.org/data/definitions/${f.cwe.replace(/[^0-9]/g,'')}.html` : undefined,
620
755
  properties: { tags: [f.cwe, f.stride].filter(Boolean) },
621
756
  });
@@ -680,7 +815,7 @@ export function toSARIF(scan, meta={}){
680
815
  return {
681
816
  ruleId: f.vuln ? f.vuln.replace(/[^a-zA-Z0-9]/g, '_') : 'unknown',
682
817
  level: SEV_TO_SARIF[f.severity] || 'warning',
683
- message: { text: f.fix?.description || f.vuln || 'Security finding' },
818
+ message: { text: f.description || f.fix?.description || f.vuln || 'Security finding' },
684
819
  locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: Math.max(1, f.line||1) } } }],
685
820
  ...(codeFlows ? { codeFlows } : {}),
686
821
  ...(fixes ? { fixes } : {}),
@@ -792,7 +927,12 @@ export function toHTML(scan, meta = {}) {
792
927
  for (const f of findings) byFile[f.file] = (byFile[f.file] || 0) + 1;
793
928
  const hotspots = Object.entries(byFile).sort((a,b)=>b[1]-a[1]).slice(0, 10);
794
929
  const data = JSON.stringify(findings).replace(/</g, '\\u003c');
795
- const generatedAt = new Date().toISOString();
930
+ // Every other emitter falls back to meta.startedAt (which
931
+ // posture/deterministic.js forces to a fixed value under --deterministic)
932
+ // before minting a fresh timestamp — toHTML never consulted it, so it was
933
+ // the one format that stayed non-deterministic run-to-run even under
934
+ // --deterministic.
935
+ const generatedAt = meta.startedAt || new Date().toISOString();
796
936
  const SEV_HEX = { critical: '#ff2d55', high: '#ff6b35', medium: '#ffb800', low: '#34d058', info: '#82aaff' };
797
937
  const sevBars = Object.entries(counts).map(([k, v]) =>
798
938
  `<div class="sev-row"><span class="sev-tag" style="background:${SEV_HEX[k]}22;color:${SEV_HEX[k]}">${k}</span><span class="sev-bar" style="width:${Math.min(100, v * 4)}%;background:${SEV_HEX[k]}"></span><span class="sev-num">${v}</span></div>`
@@ -1098,8 +1238,29 @@ export function toShipVerdict(scan, options = {}) {
1098
1238
  const profile = options.profile || { confidenceMin: CONF_DEFAULT_VIB, showTaxonomy: false };
1099
1239
  const color = options.color !== false;
1100
1240
  const c = (s, code) => color ? `${code}${s}${RESET}` : s;
1101
- const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_VIB);
1102
- const actionable = findings.filter(f => /critical|high/.test(f.severity));
1241
+ // CMP-4 (Stage-0 audit, 2026): the safety headline (Safe/Not-safe-to-deploy)
1242
+ // and the critical/high counts that drive it MUST come from the FULL
1243
+ // finding set, matching exitCodeFor — confidence filtering must never be
1244
+ // able to make a real critical/high invisible to the verdict. Before this
1245
+ // fix, `findings` was confidence-filtered BEFORE the severity split, so a
1246
+ // critical at confidence 0.85 (below the 0.9 vibecoder floor) made the
1247
+ // verdict print "Safe to deploy" while exitCodeFor — reading the same scan
1248
+ // unfiltered — returned 3. Same scan, contradictory answers depending on
1249
+ // which one you read.
1250
+ //
1251
+ // Confidence filtering KEEPS its legitimate purpose for low/medium/info
1252
+ // findings — those never gate the safety verdict, so hiding noisy
1253
+ // low-signal ones from a non-expert reader is a reasonable UX choice, not a
1254
+ // security decision. What changes is that low/medium/info findings hidden
1255
+ // this way are now DISCLOSED by count rather than silently vanishing,
1256
+ // matching the project's no-silent-truncation convention.
1257
+ const allFindings = normalizeFindings(scan);
1258
+ const min = profile.confidenceMin ?? CONF_DEFAULT_VIB;
1259
+ const criticalOrHighAll = allFindings.filter(f => /critical|high/.test(f.severity));
1260
+ const restFiltered = _withConfidence(allFindings.filter(f => !/critical|high/.test(f.severity)), min);
1261
+ const findings = [...criticalOrHighAll, ...restFiltered];
1262
+ const filteredOutCount = allFindings.length - findings.length;
1263
+ const actionable = criticalOrHighAll;
1103
1264
  const advisoryCount = findings.length - actionable.length;
1104
1265
  const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
1105
1266
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
@@ -1164,6 +1325,12 @@ export function toShipVerdict(scan, options = {}) {
1164
1325
  } else if (advisoryCount > 0) {
1165
1326
  lines.push(c(` ${advisoryCount} advisory item${advisoryCount === 1 ? '' : 's'} — run /security-scan-all --firehose to see them.`, DIM));
1166
1327
  }
1328
+ // No-silent-truncation: a low/medium/info finding hidden by the confidence
1329
+ // floor must be disclosed by count. Critical/high are never in this count —
1330
+ // see filteredOutCount's computation above.
1331
+ if (filteredOutCount > 0) {
1332
+ lines.push(c(` ${filteredOutCount} more below your confidence threshold — run /security-scan-all --firehose to see them.`, DIM));
1333
+ }
1167
1334
  // Discoverability: the depth (per-finding explanation) and the shareable report
1168
1335
  // exist but aren't obvious from the one-screen verdict — point to them.
1169
1336
  if (findings.length > 0) {
@@ -1201,10 +1368,12 @@ export function toProTable(scan, options = {}) {
1201
1368
  const columns = options.columns || 'standard'; // 'standard' | 'mitre' | 'capec' | 'owasp'
1202
1369
  const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_PRO);
1203
1370
 
1204
- // Rank by triage score (or severity rank if absent).
1371
+ // Rank by compositeRisk (the canonical priority key, per composite-risk.js's
1372
+ // own header) when present, falling back to the older triage score, then
1373
+ // severity rank.
1205
1374
  findings.sort((a, b) => {
1206
- const ea = a.triage ?? (1 - (SEV_RANK[a.severity] || 0) / 4);
1207
- const eb = b.triage ?? (1 - (SEV_RANK[b.severity] || 0) / 4);
1375
+ const ea = a.compositeRisk ?? (a.triage != null ? a.triage * 100 : (1 - (SEV_RANK[a.severity] || 0) / 4) * 100);
1376
+ const eb = b.compositeRisk ?? (b.triage != null ? b.triage * 100 : (1 - (SEV_RANK[b.severity] || 0) / 4) * 100);
1208
1377
  return eb - ea;
1209
1378
  });
1210
1379
 
@@ -1229,7 +1398,7 @@ export function toProTable(scan, options = {}) {
1229
1398
  const cwe = (f.cwe || '—').padEnd(10);
1230
1399
  const cvss = (f.cvss || f.cvssV3?.score || '—').toString().padEnd(5);
1231
1400
  const owasp = (f.owasp || f.owaspCategory || '—').padEnd(10);
1232
- const mitre = (f.mitreAttack || f.attckTechnique || '—').padEnd(20);
1401
+ const mitre = (f.attck || '—').padEnd(20);
1233
1402
  const capec = (f.capec || '—').padEnd(10);
1234
1403
  const conf = (f.confidence == null ? '—' : f.confidence.toFixed(2));
1235
1404
  const vuln = (f.vuln || '').slice(0, 60);
package/src/runScan.js CHANGED
@@ -120,7 +120,7 @@ export async function runScan(rootDir, opts = {}) {
120
120
 
121
121
  // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
122
122
  // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
123
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
123
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
124
124
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
125
125
  // notify if the operator pinned a different version than what's installed.
126
126
  try { stampScan(root, scan); } catch {}
@@ -7,16 +7,25 @@
7
7
  // - JS/TS/Java/Go/C/C++/Rust line comments // ...
8
8
  // - JS/TS/Java/Go/C/C++/Rust block comments /* ... */
9
9
  // - Python line comments # ...
10
+ // - PHP: all three of the above — `//`, `/* */`, AND `#` are all valid
11
+ // PHP line/block comment forms simultaneously (unlike Python, which
12
+ // only has `#`), so PHP needs its own mode rather than reusing 'py'
13
+ // (which would strip `#` but silently leave `//`/`/* */` PHP comments
14
+ // unstripped — a source of false positives on commented-out code).
10
15
  //
11
16
  // Skips comment-like content inside string literals (single/double/backtick).
12
17
  //
13
- // The `lang` parameter is optional; pass 'py' to treat `#` as a line comment.
18
+ // The `lang` parameter is optional; pass 'py' to treat `#` as a line comment
19
+ // (and skip `//`/`/* */`), or 'php' to strip all three comment forms.
14
20
 
15
21
  export function blankComments(s, lang) {
16
22
  let out = '';
17
23
  let inS = null;
18
24
  let i = 0;
19
25
  const isPy = lang === 'py';
26
+ const isPhp = lang === 'php';
27
+ const stripSlashForms = !isPy || isPhp;
28
+ const stripHash = isPy || isPhp;
20
29
  while (i < s.length) {
21
30
  const c = s[i];
22
31
  if (inS) {
@@ -26,17 +35,19 @@ export function blankComments(s, lang) {
26
35
  i++; continue;
27
36
  }
28
37
  if (c === "'" || c === '"' || c === '`') { inS = c; out += c; i++; continue; }
29
- if (!isPy && c === '/' && s[i+1] === '/') {
38
+ if (stripSlashForms && c === '/' && s[i+1] === '/') {
30
39
  while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
31
40
  continue;
32
41
  }
33
- if (!isPy && c === '/' && s[i+1] === '*') {
42
+ if (stripSlashForms && c === '/' && s[i+1] === '*') {
34
43
  const end = s.indexOf('*/', i + 2);
35
44
  const stop = end < 0 ? s.length : end + 2;
36
45
  while (i < stop) { out += (s[i] === '\n' ? '\n' : ' '); i++; }
37
46
  continue;
38
47
  }
39
- if (isPy && c === '#') {
48
+ // PHP 8 attributes (`#[Route(...)]`) use the same `#` prefix as a line
49
+ // comment — `#[` is never a comment, so don't blank it.
50
+ if (stripHash && c === '#' && s[i+1] !== '[') {
40
51
  while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
41
52
  continue;
42
53
  }
@@ -63,7 +63,7 @@ const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
63
63
  // Configurable thresholds. Defaults tuned against the Juliet Java
64
64
  // 468-FP / 1-TP collapse — these settings drop FPs to ~30 without
65
65
  // losing the AWS-key-shaped TP.
66
- export const DEFAULT_OPTIONS = {
66
+ const DEFAULT_OPTIONS = {
67
67
  // Empirical floor for *non-dictionary* credentials. Lower than the
68
68
  // 3.5 ceiling Shannon-quoted for "true randomness" because real test
69
69
  // fixtures and rotated secrets often use repetitive base alphabets
package/src/sast/authz.js CHANGED
@@ -145,10 +145,12 @@ export function scanAuthZ(fp, raw) {
145
145
  const m2 = ln.match(JWT_HARDCODED_SECRET_RE);
146
146
  if (m2) {
147
147
  const val = m2[1];
148
- // Suppress only template/env placeholders
149
- if (!/process\.env|\$\{|<.*?>|^\s*$/.test(val) && !/\bsecret\b|\bchange.?me\b|^example$/i.test(val) === false || val.length >= 4) {
150
- // We still flag well-known placeholders ("secret", "changeme") because they
151
- // are the most common production foot-gun.
148
+ // Suppress only template/env placeholders. We still flag well-known
149
+ // placeholders ("secret", "changeme", "example") because they are the
150
+ // most common production foot-gun.
151
+ const looksLikePlaceholder = /process\.env|\$\{|<.*?>|^\s*$/.test(val);
152
+ const isKnownBadPlaceholder = /\bsecret\b|\bchange.?me\b|^example$/i.test(val);
153
+ if (!looksLikePlaceholder || isKnownBadPlaceholder) {
152
154
  push(_emit(fp, i + 1,
153
155
  'AuthZ: hardcoded JWT secret in source',
154
156
  'critical', 'CWE-798', ln.replace(val, '<redacted>'),
@@ -21,7 +21,7 @@
21
21
  // set) AND strips the marker comments from the corpus before scanning, so the
22
22
  // engine's true detection capability is measured.
23
23
 
24
- export function isBenchShape() {
24
+ function isBenchShape() {
25
25
  return process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1';
26
26
  }
27
27
 
@@ -38,11 +38,6 @@ export {
38
38
  applyJavaBenchSuppressions,
39
39
  } from '../java-bench-extras.js';
40
40
 
41
- // Re-export the cpp-bench-extras suppressor — gated at call sites.
42
- export {
43
- applyJulietCppSuppressions as applyJulietCppFamilySuppressions,
44
- } from '../cpp-bench-extras.js';
45
-
46
41
  // OWASP Benchmark @WebServlet route-category extractor.
47
42
  // Returns the canonical vuln family (e.g. 'sql-injection') for files whose
48
43
  // @WebServlet URL encodes the test category, or null.
@@ -54,7 +49,7 @@ const _OWASP_BENCH_CATEGORY_MAP = {
54
49
  'weakrand': 'weak-rng', 'trustbound': 'trust-boundary',
55
50
  'securecookie': 'header-hardening',
56
51
  };
57
- export function benchShapeWebServletCategory(cleaned) {
52
+ function benchShapeWebServletCategory(cleaned) {
58
53
  if (!isBenchShape()) return null;
59
54
  const m = cleaned.match(/@WebServlet\s*\(\s*(?:value\s*=\s*)?["'](?:[^"']*\/)?(\w+?)-\d+\//);
60
55
  if (!m) return null;