@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
@@ -14,7 +14,6 @@ import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
14
14
  import { toPBOM } from '../src/sast/pipeline.js';
15
15
  import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
16
16
  import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
17
- import { ingestAndMerge } from '../src/sca/sarif-ingest.js';
18
17
  import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
19
18
  import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
20
19
  import { applyOverrides, validateOverrides, suppressionReport, renderSuppressionSummary } from '../src/posture/rule-overrides.js';
@@ -28,7 +27,6 @@ import { syncTickets } from '../src/integrations/tickets.js';
28
27
  import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
29
28
  import * as triage from '../src/posture/triage.js';
30
29
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
31
- import { globFiles } from '../src/util/glob.js';
32
30
 
33
31
  import { stateDir, statePath } from '../src/posture/state-dir.js';
34
32
  // last-scan.json integrity helpers — implementation in posture/integrity.js
@@ -111,7 +109,6 @@ Options:
111
109
  --show-threat-model Append the auto-derived STRIDE threat model summary
112
110
  --show-drift Append calibration-drift alarms (overconfidence detection)
113
111
  --sca-reachable-only Only SCA findings where the vulnerable function is reachable
114
- --ingest-sarif <glob> Merge external SARIF into this scan
115
112
  --scorecard Enrich components with OSSF Scorecard scores
116
113
  --no-network Skip OSV/registry queries (offline mode)
117
114
  --pr [ref] Diff-aware: scan only files changed since ref (auto-detects PR base)
@@ -297,7 +294,7 @@ function _bundleSha() {
297
294
  return 'unavailable';
298
295
  }
299
296
 
300
- async function writeMachineOutput(targetAbs, scan, meta, profile) {
297
+ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
301
298
  const stateDirPath = stateDir(targetAbs);
302
299
  const { isSafeStateDir: _isSafe, stateWritesEnabled: _writesOn } = await import('../src/posture/state-dir.js');
303
300
  // Read-only scan (NON_MUTATING_SCAN_PRD S1). These writes bypass
@@ -311,7 +308,7 @@ async function writeMachineOutput(targetAbs, scan, meta, profile) {
311
308
  // Always JSON (used by /security-fix and /security-report).
312
309
  await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
313
310
  JSON.stringify(toJSON(scan, meta), null, 2));
314
- if (profile.profile === 'pro' || profile.machineOutput) {
311
+ if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
315
312
  await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
316
313
  JSON.stringify(toSARIF(scan, meta), null, 2));
317
314
  await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
@@ -436,13 +433,23 @@ async function cmdScan(args) {
436
433
  if (process.stderr.isTTY) process.stderr.write(`\r[${p.phase}] ${p.current}/${p.total} ${p.file} `);
437
434
  },
438
435
  });
436
+ // The BOM/attestation emitters stamp the producing engine's version into
437
+ // their metadata; carry the real package version so it can never drift.
438
+ if (meta && meta.engineVersion == null) meta.engineVersion = PKG_VERSION;
439
439
  if (process.stderr.isTTY) process.stderr.write('\r' + ' '.repeat(80) + '\r');
440
440
 
441
441
  const only = args.flags.only;
442
442
  if (only) {
443
+ // Four channels exist (scanner/CLAUDE.md): findings (SAST), secrets,
444
+ // supplyChain (SCA), logicVulns (business-logic). --only sast keeps
445
+ // logicVulns (business-logic is source analysis, part of the SAST
446
+ // pillar); --only sca/secrets must also clear it — it's neither SCA
447
+ // nor secrets, and normalizeFindings()/exitCodeFor() both fold it in,
448
+ // so leaving it meant a business-logic finding leaked into a
449
+ // single-pillar scan's output AND its exit code.
443
450
  if (only === 'sast') { scan.secrets = []; scan.supplyChain = []; }
444
- if (only === 'sca') { scan.findings = []; scan.secrets = []; }
445
- if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; }
451
+ if (only === 'sca') { scan.findings = []; scan.secrets = []; scan.logicVulns = []; }
452
+ if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; scan.logicVulns = []; }
446
453
  }
447
454
 
448
455
  // --set-baseline: save current findings as baseline for future --since-baseline filtering
@@ -494,18 +501,6 @@ async function cmdScan(args) {
494
501
  // 0.9.0 Feat-18: --scorecard flag enables OSSF Scorecard enrichment
495
502
  if (args.flags['scorecard']) process.env.AGENTIC_SECURITY_SCORECARD = '1';
496
503
 
497
- // 0.7.0 Feat-7: --ingest-sarif <path-or-glob> merges SARIF from external tools (Semgrep,
498
- // gitleaks, Bandit, Trivy, Checkov, etc.) into this scan's findings, deduping by
499
- // fingerprint and tracking provenance via sources[].
500
- if (args.flags['ingest-sarif']) {
501
- const glob = args.flags['ingest-sarif'];
502
- const paths = await globFiles(glob);
503
- if (paths.length) {
504
- const r = ingestAndMerge(scan, paths);
505
- if (process.stderr.isTTY) process.stderr.write(`[ingest] merged ${r.merged} / added ${r.added} findings from ${paths.length} SARIF file(s)\n`);
506
- }
507
- }
508
-
509
504
  // 0.6.0 Feat-1: --sca-reachable-only filters to only SCA findings where the vulnerable
510
505
  // function was confirmed reachable from a route handler.
511
506
  if (args.flags['sca-reachable-only']) {
@@ -566,6 +561,31 @@ async function cmdScan(args) {
566
561
  try { await enrichWithEPSS(scan); } catch {}
567
562
  }
568
563
 
564
+ // Re-run risk-dollars now that both signals it needs are actually
565
+ // available: EPSS (just enriched above — engine.js's own call ran before
566
+ // this, so every finding's epssScore was still unset) and relevanceTier
567
+ // (posture/relevance.js runs last inside runScan/engine.js, so it's
568
+ // populated on `scan.findings` by the time we get here, but wasn't yet
569
+ // when engine.js's own annotateRiskDollars call ran mid-pipeline).
570
+ try {
571
+ const { annotateRiskDollars } = await import('../src/posture/risk-dollars.js');
572
+ annotateRiskDollars(targetAbs, scan.findings || []);
573
+ } catch {}
574
+
575
+ // Re-run composite risk for the same reason as annotateRiskDollars above:
576
+ // engine.js's mid-pipeline annotateCompositeRisk calls ran before
577
+ // enrichWithEPSS (just above) had a chance to set f.exploitedNow, so
578
+ // every finding's 'exploited-now-floor:75' boost was computed against a
579
+ // field that was always undefined at the time — an actively-exploited
580
+ // CVE (EPSS percentile >= 0.95) never got its compositeRisk score raised.
581
+ // annotateCompositeRisk is pure/idempotent (only sets compositeRisk*
582
+ // fields, reads everything else), so re-running it here is safe.
583
+ try {
584
+ const { annotateCompositeRisk } = await import('../src/posture/composite-risk.js');
585
+ annotateCompositeRisk(scan.findings || []);
586
+ annotateCompositeRisk(scan.supplyChain || []);
587
+ } catch {}
588
+
569
589
  // Blast-radius narrative — purely local, always safe to run.
570
590
  if (!args.flags['no-blast-radius']) {
571
591
  try { enrichWithBlastRadius(scan, targetAbs); } catch {}
@@ -622,7 +642,7 @@ async function cmdScan(args) {
622
642
  } catch { /* attestation is metadata; never fail a scan over it */ }
623
643
 
624
644
  // R2: Always emit machine-readable artifacts to .agentic-security/.
625
- await writeMachineOutput(targetAbs, scan, meta, profile);
645
+ await writeMachineOutput(targetAbs, scan, meta, profile, args);
626
646
 
627
647
  const includeSuppressed = !!args.flags['include-suppressed'];
628
648
  let body;
@@ -664,32 +684,70 @@ async function cmdScan(args) {
664
684
  // Persist last scan for /security-fix and /security-report
665
685
  const { isSafeStateDir: _isSafeStateDir, stateWritesEnabled: _writesOnScan } = await import('../src/posture/state-dir.js');
666
686
  const stateDirPath = stateDir(path.resolve(target));
687
+ // S7 (Stage-0 audit): declared here, not with `const` inside the `if`
688
+ // below — it used to be block-scoped there while recordScan() further
689
+ // down referenced it unconditionally, throwing a ReferenceError on every
690
+ // single scan (silently swallowed by that call's empty catch{}). streak.json
691
+ // — grades, streak days, achievements — never actually persisted through
692
+ // the real CLI as a result, reproduced live before this fix.
693
+ let persistedScan = null;
667
694
  if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
668
695
  await fsp.mkdir(stateDirPath, { recursive: true });
669
- const persistedScan = toJSON(scan, meta);
696
+ persistedScan = toJSON(scan, meta);
670
697
  // #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
671
698
  // every finding carries an age, SLA breaches can be surfaced, and the fix
672
699
  // loop can report time-to-clean. Best-effort; skipped under --deterministic
673
700
  // so deterministic state stays byte-identical run-to-run.
674
701
  if (!args.flags.deterministic) {
675
702
  try {
676
- const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
703
+ const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary, fingerprintFinding, computeMTTR } = await import('../src/posture/mttr.js');
677
704
  let baselineMap = new Map();
705
+ let prevAll = [];
678
706
  try {
679
707
  const prev = JSON.parse(await fsp.readFile(path.join(stateDirPath, 'last-scan.json'), 'utf8'));
680
708
  baselineMap = buildBaselineMap(prev);
709
+ // The previous scan's own findings already carry firstSeenAt/lastSeenAt
710
+ // from ITS baseline lookup — exactly the shape computeMTTR needs for
711
+ // whichever of them are no longer present now (i.e. were fixed).
712
+ prevAll = [
713
+ ...(prev?.findings || []), ...(prev?.secrets || []),
714
+ ...(prev?.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
715
+ ];
681
716
  } catch { /* first run — empty baseline, everything is firstSeen now */ }
682
717
  const now = Date.now();
683
718
  stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
684
719
  stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
685
720
  stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
721
+ // #10 — MTTR: which of the previous scan's findings are no longer
722
+ // present now (fixed), and how long each took. computeMTTR itself was
723
+ // fully built and tested but had never had a real caller — the module's
724
+ // own comment calls it "true MTTR," distinct from the open-backlog
725
+ // median-age proxy renderSlaSummary already surfaces below.
726
+ const currentFps = new Set([
727
+ ...(persistedScan.findings || []), ...(persistedScan.secrets || []),
728
+ ...(persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
729
+ ].map(fingerprintFinding));
730
+ const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
731
+ persistedScan.mttr = computeMTTR(removed);
686
732
  // Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
687
733
  const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
688
734
  if (!isJson) {
689
735
  const sla = renderSlaSummary(persistedScan.findings || []);
690
736
  if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
737
+ if (persistedScan.mttr.count > 0) {
738
+ process.stderr.write(`✅ agentic-security: ${persistedScan.mttr.count} finding(s) fixed since last scan, median ${Math.round(persistedScan.mttr.medianDays)}d to remediate\n`);
739
+ }
691
740
  }
692
- } catch { /* MTTR is best-effort — never block a scan write */ }
741
+ } catch (e) {
742
+ // MTTR is best-effort — never block a scan write. But a silent catch
743
+ // here previously hid a real, deterministic CI-only failure (the
744
+ // mttr field went missing on every hosted-CI run, never locally, and
745
+ // nothing explained why) for long enough that it shipped several
746
+ // releases undiagnosed. Surface it — never fail the scan on it.
747
+ if (process.env.AGENTIC_SECURITY_MTTR_DEBUG === '1' || process.env.CI || process.env.GITHUB_ACTIONS) {
748
+ process.stderr.write(`agentic-security: MTTR computation failed (best-effort, scan unaffected): ${(e && e.stack) || e}\n`);
749
+ }
750
+ }
693
751
 
694
752
  // R5 — report the observed time-to-validated-fix distribution from the
695
753
  // fix attempts recorded by `verifyFix`. This is measurement, not
@@ -735,10 +793,17 @@ async function cmdScan(args) {
735
793
 
736
794
  // 0.14.0 — update streak / achievements after every full scan. Suppress
737
795
  // streak side effects when the user only wants raw JSON output (CI piping).
796
+ // Gated on `persistedScan !== null` (i.e. the same _writesOnScan() &&
797
+ // _isSafeStateDir() condition above) — recordScan()'s own isSafeStateDir
798
+ // check alone does not know about --no-state / AGENTIC_SECURITY_NO_STATE,
799
+ // so without this a "read-only, mutates nothing" scan would still write
800
+ // streak.json. This was accidentally masked before the persistedScan
801
+ // scoping fix (recordScan was never reached at all, for the wrong reason);
802
+ // fixing that reachability bug required adding this guard explicitly.
738
803
  try {
739
- const streak = recordScan(stateDirPath, persistedScan);
804
+ const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
740
805
  // Print celebration / streak line to stderr so it doesn't pollute --format json
741
- if (process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
806
+ if (streak && process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
742
807
  const delta = formatGradeDelta(streak);
743
808
  const line = formatStreakLine(streak);
744
809
  if (delta) process.stderr.write('\n' + delta + '\n');
@@ -814,20 +879,48 @@ async function cmdCi(args) {
814
879
  const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
815
880
  if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
816
881
 
817
- // Persist the three CI artifacts.
882
+ // R4 same run attestation cmdScan attaches, computed here too so
883
+ // findings.json (the actual CI evidence artifact) carries the same
884
+ // tamper-evidence digest 'agentic-security scan' does, rather than
885
+ // always shipping attestation: null. Runs after every filter above, so
886
+ // it attests what actually ships. Metadata only — never fails the scan.
887
+ try {
888
+ const { computeRunAttestation } = await import('../src/posture/attestation.js');
889
+ const { keyProvenance } = await import('../src/posture/integrity.js');
890
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
891
+ scan.attestation = computeRunAttestation({
892
+ findings: normalizeFindings(scan),
893
+ engineVersion: PKG_VERSION,
894
+ rulesetVersion: effectiveVersion(targetAbs).version,
895
+ bundleSha: _bundleSha(),
896
+ root: targetAbs,
897
+ sign: true,
898
+ });
899
+ if (scan.attestation) scan.attestation.keyProvenance = keyProvenance();
900
+ } catch { /* attestation is metadata; never fail a scan over it */ }
901
+
902
+ // Persist the three CI artifacts — but a refusal to write must NEVER skip
903
+ // the --fail-on evaluation below. S1 (Stage-0 audit, 2026): this used to be
904
+ // a bare `return;` inside the write-guard, so `cmdCi` returned `undefined`
905
+ // and `process.exit(await cmdCi(args))` became `process.exit(undefined)`,
906
+ // which Node treats as exit 0 — a CI pipeline reading only $? would see a
907
+ // passing build whose findings were never evaluated against --fail-on at
908
+ // all. The write and the gate are orthogonal: "we couldn't persist
909
+ // artifacts" is not evidence about "the scan is clean."
818
910
  const stateDirPath = stateDir(targetAbs);
819
911
  const { isSafeStateDir: _isSafeCi, stateWritesEnabled: _writesOnCi } = await import('../src/posture/state-dir.js');
820
- if (!_writesOnCi() || !_isSafeCi(stateDirPath)) {
912
+ const _canWriteCi = _writesOnCi() && _isSafeCi(stateDirPath);
913
+ if (!_canWriteCi) {
821
914
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
822
- return;
915
+ } else {
916
+ await fsp.mkdir(stateDirPath, { recursive: true });
917
+ await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
918
+ JSON.stringify(toJSON(scan, meta), null, 2));
919
+ await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
920
+ JSON.stringify(toSARIF(scan, meta), null, 2));
921
+ await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
922
+ toJUnit(scan, meta));
823
923
  }
824
- await fsp.mkdir(stateDirPath, { recursive: true });
825
- await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
826
- JSON.stringify(toJSON(scan, meta), null, 2));
827
- await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
828
- JSON.stringify(toSARIF(scan, meta), null, 2));
829
- await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
830
- toJUnit(scan, meta));
831
924
 
832
925
  const scanCode = exitCodeFor(scan);
833
926
  const findings = normalizeFindings(scan);
@@ -835,7 +928,9 @@ async function cmdCi(args) {
835
928
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
836
929
  process.stderr.write(
837
930
  `[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
838
- `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n` +
931
+ (_canWriteCi
932
+ ? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
933
+ : `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
839
934
  `[ci] fail-on=${failOn} scan-exit=${scanCode}\n`
840
935
  );
841
936
  // FR-SDLC-9: when --policy <file.rego> is supplied, evaluate against the
@@ -1758,16 +1853,73 @@ async function cmdAttest(args) {
1758
1853
  return 0;
1759
1854
  }
1760
1855
 
1856
+ // R4/D2 correctness follow-up: attestation.js ships TWO independent
1857
+ // verifiers — verifyEvidenceBundle (per-finding, Ed25519, self-contained —
1858
+ // only needs a public key) and verifyRunAttestation (whole-run, per-install
1859
+ // HMAC, NOT self-contained — needs the caller to also supply the finding
1860
+ // set to re-derive against). This CLI command only ever called the first;
1861
+ // the second had zero production callers anywhere in the repo (confirmed by
1862
+ // grep — only test/attestation.test.js called it). Auto-detect which
1863
+ // artifact was handed in and dispatch to the matching verifier instead of
1864
+ // silently misinterpreting a run-attestation as a (structurally different)
1865
+ // evidence bundle.
1866
+ function _asRunAttestation(obj) {
1867
+ if (obj && typeof obj.digest === 'string' && obj.canonicalisation) return obj;
1868
+ if (obj && obj.attestation && typeof obj.attestation.digest === 'string') return obj.attestation;
1869
+ return null;
1870
+ }
1871
+
1872
+ // Verifies a run attestation the ONLY way it can be verified: by re-deriving
1873
+ // the digest from a fresh scan and comparing. Unlike an evidence bundle
1874
+ // (self-contained, just needs a public key), a run attestation is a claim
1875
+ // about a FINDING SET, not a standalone signed blob — "does scanning this
1876
+ // codebase right now reproduce the exact digest an earlier run attested"
1877
+ // is the actual, meaningful question this command answers.
1878
+ async function cmdVerifyRunAttestation(attestation, args) {
1879
+ const projectPath = path.resolve(args.flags.against || '.');
1880
+ if (!fs.existsSync(projectPath)) {
1881
+ console.error(`--against path does not exist: ${projectPath}`);
1882
+ return 2;
1883
+ }
1884
+ console.log(`Re-scanning ${projectPath} to check it reproduces the attested digest...`);
1885
+ const { runScan } = await import('../src/runScan.js');
1886
+ const { normalizeFindings } = await import('../src/report/index.js');
1887
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
1888
+ const { verifyRunAttestation } = await import('../src/posture/attestation.js');
1889
+ const { scan } = await runScan(projectPath);
1890
+ const r = verifyRunAttestation(attestation, {
1891
+ findings: normalizeFindings(scan),
1892
+ engineVersion: PKG_VERSION,
1893
+ rulesetVersion: effectiveVersion(projectPath).version,
1894
+ bundleSha: _bundleSha(),
1895
+ root: projectPath,
1896
+ });
1897
+ if (!r.ok) {
1898
+ console.error(`✗ INVALID — ${r.reason}`);
1899
+ return 1;
1900
+ }
1901
+ console.log('✓ VALID — a fresh scan of this project reproduces the attested digest exactly.');
1902
+ console.log('');
1903
+ console.log(` digest: ${attestation.digest}`);
1904
+ console.log(` findingCount: ${attestation.findingCount}`);
1905
+ console.log(` proves: ${attestation.proves}`);
1906
+ console.log(` does NOT prove: ${attestation.doesNotProve}`);
1907
+ return 0;
1908
+ }
1909
+
1761
1910
  async function cmdVerifyAttestation(args) {
1762
1911
  const { verifyEvidenceBundle, keyPaths } = await import('../src/posture/evidence-bundle.js');
1763
1912
  // `args._[0]` is the command name itself — the same convention cmdScan uses.
1764
1913
  const file = args.flags.bundle || args._[1];
1765
- if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json> [--public-key <path>]'); return 2; }
1914
+ if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
1766
1915
 
1767
1916
  let bundle;
1768
1917
  try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
1769
1918
  catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
1770
1919
 
1920
+ const runAttestation = _asRunAttestation(bundle);
1921
+ if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
1922
+
1771
1923
  const keyFile = args.flags['public-key'] || keyPaths().publicKey;
1772
1924
  let publicKeyPem = null;
1773
1925
  try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
package/dist/113.index.js CHANGED
@@ -78,7 +78,7 @@ function recordFixAttempt(scanRoot, record) {
78
78
  try {
79
79
  const dir = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .stateDir */ .Pn)(scanRoot);
80
80
  if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.isSafeStateDir)(dir)) return false;
81
- if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return;
81
+ if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return false;
82
82
  node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
83
83
  // One writeSync of one newline-terminated line: a concurrent reader sees
84
84
  // whole records or nothing, and a torn tail is dropped on read.
@@ -266,7 +266,16 @@ function _detectRunner(scanRoot) {
266
266
  } catch { return null; }
267
267
  })();
268
268
  if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
269
- return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
269
+ // --passWithNoTests is Jest-specific CLI syntax appending it
270
+ // unconditionally broke every non-Jest npm test script (mocha, vitest,
271
+ // ava, tap, or a plain node script) with an "unrecognized option" exit,
272
+ // failing verification for a reason that has nothing to do with
273
+ // whether the patch actually broke anything. Only add it when Jest is
274
+ // actually the configured runner.
275
+ const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
276
+ || Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
277
+ const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
278
+ return { runner: 'npm', cmd: 'npm', args };
270
279
  }
271
280
  if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
272
281
  return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
@@ -406,8 +415,8 @@ var external_node_child_process_ = __webpack_require__(1421);
406
415
  var external_node_fs_ = __webpack_require__(3024);
407
416
  // EXTERNAL MODULE: external "node:path"
408
417
  var external_node_path_ = __webpack_require__(6760);
409
- // EXTERNAL MODULE: ./src/engine.js + 499 modules
410
- var engine = __webpack_require__(2912);
418
+ // EXTERNAL MODULE: ./src/engine.js + 196 modules
419
+ var engine = __webpack_require__(2739);
411
420
  ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
412
421
  // Deterministic honesty gates on fix / finding output (#7).
413
422
  //
package/dist/178.index.js CHANGED
@@ -13,7 +13,7 @@ export const modules = {
13
13
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
14
14
  /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
15
15
  /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
16
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(2912);
16
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(2739);
17
17
  // Time-travel + counterfactual scanning (v0.68).
18
18
  //
19
19
  // Two new modes that exploit the pure-input shape of runFullScan:
package/dist/207.index.js CHANGED
@@ -6,9 +6,10 @@ export const modules = {
6
6
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
7
 
8
8
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
- /* harmony export */ computeRunAttestation: () => (/* binding */ computeRunAttestation)
9
+ /* harmony export */ computeRunAttestation: () => (/* binding */ computeRunAttestation),
10
+ /* harmony export */ verifyRunAttestation: () => (/* binding */ verifyRunAttestation)
10
11
  /* harmony export */ });
11
- /* unused harmony exports ATTESTATION_CANONICALISATION, verifyRunAttestation */
12
+ /* unused harmony export ATTESTATION_CANONICALISATION */
12
13
  /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7598);
13
14
  /* harmony import */ var _integrity_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1130);
14
15
  // R4 — determinism as a contract.
@@ -202,11 +203,11 @@ function verifyRunAttestation(attestation, inputs = {}) {
202
203
  }
203
204
  if (attestation.signature) {
204
205
  let expected;
205
- try { expected = signLastScan(attestation.digest); }
206
+ try { expected = (0,_integrity_js__WEBPACK_IMPORTED_MODULE_1__/* .signLastScan */ .lU)(attestation.digest); }
206
207
  catch { return { ok: false, reason: 'signature present but no key material available to check it' }; }
207
208
  const a = Buffer.from(String(attestation.signature), 'hex');
208
209
  const b = Buffer.from(expected, 'hex');
209
- if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
210
+ if (a.length !== b.length || !node_crypto__WEBPACK_IMPORTED_MODULE_0__.timingSafeEqual(a, b)) {
210
211
  return { ok: false, reason: 'signature does not verify under this install key' };
211
212
  }
212
213
  return { ok: true, reason: 'digest and per-install signature verify' };
package/dist/238.index.js CHANGED
@@ -78,7 +78,7 @@ function recordFixAttempt(scanRoot, record) {
78
78
  try {
79
79
  const dir = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .stateDir */ .Pn)(scanRoot);
80
80
  if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.isSafeStateDir)(dir)) return false;
81
- if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return;
81
+ if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.stateWritesEnabled)()) return false;
82
82
  node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
83
83
  // One writeSync of one newline-terminated line: a concurrent reader sees
84
84
  // whole records or nothing, and a torn tail is dropped on read.
package/dist/317.index.js CHANGED
@@ -123,12 +123,25 @@ function ensureKeyPair(dir = keyDir()) {
123
123
  return { privateKeyPem, publicKeyPem, created: true, ...p };
124
124
  } catch (e) {
125
125
  if (e.code !== 'EEXIST') throw e;
126
- return {
127
- privateKeyPem: node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p.privateKey, 'utf8'),
128
- publicKeyPem: node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p.publicKey, 'utf8'),
129
- created: false,
130
- ...p,
131
- };
126
+ // We lost the race on the private key — someone else's 'wx' won.
127
+ // Their public-key write follows immediately after their private-key
128
+ // write but is not itself atomic with it, so it may not have landed
129
+ // yet: a bare read here would throw an uncaught ENOENT on a genuinely
130
+ // transient state, not a real error. Retry briefly rather than crash.
131
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
132
+ for (let attempt = 0; ; attempt++) {
133
+ try {
134
+ return {
135
+ privateKeyPem: node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p.privateKey, 'utf8'),
136
+ publicKeyPem: node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(p.publicKey, 'utf8'),
137
+ created: false,
138
+ ...p,
139
+ };
140
+ } catch (readErr) {
141
+ if (readErr.code !== 'ENOENT' || attempt >= 50) throw readErr;
142
+ Atomics.wait(sleepBuf, 0, 0, 10); // 10ms; ~500ms total budget
143
+ }
144
+ }
132
145
  }
133
146
  }
134
147
 
@@ -246,9 +259,26 @@ function signEvidenceBundle(bundle, privateKeyPem) {
246
259
  * Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
247
260
  * source is an expected input, not an exceptional one.
248
261
  */
262
+ // The complete set of top-level keys a legitimately-built, signed bundle can
263
+ // carry — buildEvidenceBundle's six plus signEvidenceBundle's `signature`.
264
+ // EA-03 (Stage-0 audit, 2026): canonicalBytes SIGNS an allowlist of fields;
265
+ // verifyEvidenceBundle never checked for keys OUTSIDE that allowlist, so a
266
+ // bundle with a fabricated `verdict`/`proofLevel`/anything-else stapled on
267
+ // after signing verified as authentic — the signature simply never covered
268
+ // those bytes. Rejecting unknown keys here closes that; it must exactly match
269
+ // what buildEvidenceBundle+signEvidenceBundle actually produce, or a
270
+ // legitimate bundle would start failing verification.
271
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([
272
+ 'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
273
+ ]);
274
+
249
275
  function verifyEvidenceBundle(bundle, publicKeyPem) {
250
276
  if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
251
277
  if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
278
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
279
+ if (unknownKeys.length) {
280
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
281
+ }
252
282
  const sig = bundle.signature;
253
283
  if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
254
284
  if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
package/dist/384.index.js CHANGED
@@ -8,7 +8,7 @@ export const modules = {
8
8
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
9
  /* harmony export */ scanCredentials: () => (/* reexport safe */ _engine_js__WEBPACK_IMPORTED_MODULE_0__.Sv)
10
10
  /* harmony export */ });
11
- /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2912);
11
+ /* harmony import */ var _engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2739);
12
12
  // Secrets submodule view of the engine — credential + entropy + TODO scanning.
13
13
 
14
14