@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +28 -3
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +272 -20
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/lineage/build-attested-correlation.js +63 -0
  157. package/src/lineage/deployment-identity.js +76 -0
  158. package/src/mcp/tools.js +13 -34
  159. package/src/pipeline/canonical-output.js +64 -0
  160. package/src/pipeline/detector-runner.js +7 -0
  161. package/src/pipeline/scan-health.js +14 -0
  162. package/src/pipeline/supervised-analyzers.js +125 -0
  163. package/src/pipeline/supervised-child.js +17 -0
  164. package/src/pipeline/telemetry.js +79 -0
  165. package/src/posture/accuracy-scorecard.js +98 -9
  166. package/src/posture/artifact-registry.js +5 -0
  167. package/src/posture/assurance/evidence-registry.js +215 -0
  168. package/src/posture/assurance/release-package-verify.js +135 -0
  169. package/src/posture/assurance/release-package.js +118 -0
  170. package/src/posture/assurance/release-verdict.js +94 -0
  171. package/src/posture/assurance/waiver.js +200 -0
  172. package/src/posture/compliance-truth.js +100 -0
  173. package/src/posture/evaluation/scan-child.js +3 -1
  174. package/src/posture/evaluation/why-missed.js +3 -1
  175. package/src/posture/exploitability-evidence.js +141 -0
  176. package/src/posture/fix-verify.js +4 -2
  177. package/src/posture/fleet/exposure-ranking.js +115 -0
  178. package/src/posture/fleet/service-chain-graph.js +150 -0
  179. package/src/posture/graph-reconciliation.js +154 -0
  180. package/src/posture/provenance/coverage.js +103 -0
  181. package/src/posture/provenance/git-evidence.js +84 -0
  182. package/src/posture/provenance/origin-resolver.js +7 -1
  183. package/src/posture/provenance/predicate-replay.js +14 -4
  184. package/src/posture/runtime-correlation.js +61 -15
  185. package/src/posture/runtime-explanation.js +116 -0
  186. package/src/posture/runtime-trace-ingest.js +194 -0
  187. package/src/posture/scan-checkpoint.js +38 -10
  188. package/src/posture/waf-ingest.js +7 -2
  189. package/src/report/index.js +4 -0
  190. package/src/report/oscal.js +38 -0
  191. package/src/report/perspectives.js +150 -0
  192. package/src/sast/_comment-strip.js +8 -1
  193. package/src/sast/ruby.js +26 -0
  194. package/src/util/string-memo.js +38 -0
@@ -116,9 +116,14 @@ export class SummaryCache {
116
116
  }
117
117
 
118
118
  get(qid, taintedParams, receiverType) {
119
- return this._cache.get(this._key(qid, taintedParams, receiverType));
119
+ const r = this._cache.get(this._key(qid, taintedParams, receiverType));
120
+ if (r === undefined) this._misses = (this._misses || 0) + 1; else this._hits = (this._hits || 0) + 1;
121
+ return r;
120
122
  }
121
123
 
124
+ /** Lookups that found and did not find a summary (E6-01). */
125
+ stats() { return { hits: this._hits || 0, misses: this._misses || 0, size: this._cache.size }; }
126
+
122
127
  set(qid, taintedParams, summary, receiverType) {
123
128
  const k = this._key(qid, taintedParams, receiverType);
124
129
  this._cache.set(k, summary);
@@ -163,6 +168,7 @@ export class SummaryCache {
163
168
  return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _recursive: true };
164
169
  }
165
170
  if (++this._iter > this._maxIter) {
171
+ this._budgetHits = (this._budgetHits || 0) + 1;
166
172
  return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _budgetExceeded: true };
167
173
  }
168
174
  this._stack.add(qid);
@@ -51,7 +51,8 @@ export function toFindingShape(candidate) {
51
51
  description: candidate.rationale
52
52
  ? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
53
53
  : `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
54
- remediation: `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
54
+ // a candidate that carries its own concrete remediation (a deterministic finding with a known fix shape) keeps it; every other candidate gets the generic review text
55
+ remediation: (typeof candidate.remediation === 'string' && candidate.remediation) ? candidate.remediation : `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
55
56
  parser: 'DISCOVERY',
56
57
  family: candidate.family || 'other',
57
58
  ruleId: `discovery:${candidate.lens}`,
package/src/engine.js CHANGED
@@ -58,7 +58,8 @@ import { proofCoverage } from './posture/proof-coverage.js';
58
58
  import { computeScanHealth, applyFreshness } from './pipeline/scan-health.js';
59
59
  import { computeCoverageLedger, summarizeCoverageForScanHealth } from './pipeline/coverage-ledger.js';
60
60
  import { runAnnotatorAsync } from './pipeline/annotator-runner.js';
61
- import { runDetector } from './pipeline/detector-runner.js';
61
+ import { runDetector, setDetectorTimingSink } from './pipeline/detector-runner.js';
62
+ import { createTelemetry, attributeErrors } from './pipeline/telemetry.js';
62
63
  import { registerProducer } from './pipeline/producer-registry.js';
63
64
  import { collectProducerResult } from './pipeline/producer-collector.js';
64
65
  import { completeEnrichment } from './pipeline/enrichment-completion.js';
@@ -129,6 +130,8 @@ import { scanClientSide } from './sast/client-side.js';
129
130
  import { scanPromptFirewall } from './sast/prompt-firewall.js';
130
131
  import { scanLlmRedteam } from './posture/llm-redteam.js';
131
132
  import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
133
+ import { signLastScan } from './posture/integrity.js';
134
+ import { secretCanaryHit } from './posture/runtime-trace-ingest.js';
132
135
  import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
133
136
  import { scanContainer } from './sca/container.js';
134
137
  import { detectDepConfusion } from './sca/dep-confusion.js';
@@ -210,6 +213,7 @@ import { applyPathConstraints } from './posture/path-predicates.js';
210
213
  import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
211
214
  import _v8 from 'node:v8';
212
215
  import _vm from 'node:vm';
216
+ import { createStringMemo } from './util/string-memo.js';
213
217
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
214
218
  import { runDeepAnalysis } from './dataflow/index.js';
215
219
  import { buildLineageGraph } from './lineage/index.js';
@@ -446,7 +450,11 @@ function _ruleAppliesIn(pat, ctx){
446
450
  // comments would put the second pass into a stuck string state, blanking the
447
451
  // rest of the file. Backtick `${...}` template expressions are preserved
448
452
  // verbatim because they contain real source/sink references.
449
- function stripNoiseAndStrings(code){
453
+ // Called by several detectors per file with the same text; the result depends only on the text, so it is computed once per distinct text
454
+ // (util/string-memo.js). AGENTIC_SECURITY_NO_STRING_MEMO=1 turns the memo off.
455
+ const _stripNoiseAndStringsMemo = createStringMemo((c) => _stripNoiseAndStringsRaw(c), { version: 'stripNoiseAndStrings@1', disableEnv: 'AGENTIC_SECURITY_NO_STRING_MEMO' });
456
+ function stripNoiseAndStrings(code){ return _stripNoiseAndStringsMemo(code); }
457
+ function _stripNoiseAndStringsRaw(code){
450
458
  const out = code.split('');
451
459
  const n = code.length;
452
460
  let i = 0;
@@ -3134,6 +3142,27 @@ function getProjectConstant(name){
3134
3142
  return hit ? hit.value : null;
3135
3143
  }
3136
3144
 
3145
+ // E6-04: a digest of every input OUTSIDE a single file that the per-file detector cascade reads: the project index (constants, properties, the
3146
+ // e-commerce flag), the Java tainted-method index, and the operator's rules file with its signature. A cached per-file result is reused only when this
3147
+ // is unchanged, so a change to a constant in one file cannot leave another file's cached answer standing.
3148
+ const _ANALYSIS_CACHE_NEVER_RE = /(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i;
3149
+ function _analysisContextDigest(scanRoot){
3150
+ const h = crypto.createHash('sha256');
3151
+ const mapEntries = (m) => [...(m || new Map()).entries()].map(([k, v]) => [k, v]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
3152
+ h.update(JSON.stringify({
3153
+ ecommerce: !!_projectIndex.hasEcommerceModel,
3154
+ byFile: mapEntries(_projectIndex.constantsByFile),
3155
+ byExport: mapEntries(_projectIndex.constantsByExport),
3156
+ props: mapEntries(_projectIndex.javaProperties),
3157
+ javaTainted: [..._GLOBAL_JAVA_TAINTED_METHODS].sort(),
3158
+ }));
3159
+ for (const name of ['rules.yml', 'rules.yaml', 'rules.json', 'rules.yml.sig', 'rules.yaml.sig', 'rules.json.sig']) {
3160
+ h.update(`\n${name}\0`);
3161
+ try { h.update(fs.readFileSync(statePath(scanRoot, name))); } catch { h.update('absent'); }
3162
+ }
3163
+ return h.digest('hex');
3164
+ }
3165
+
3137
3166
  const _COUPON_MUTATION_RE = /\b(?:apply|redeem|validate|use|deduct|decrement|increment|update|save|consume|create|destroy|remove|insert)\b/i;
3138
3167
  const _REAUTH_PRESENT_RE = /\b(?:bcrypt\.compare|crypto\.timingSafeEqual|currentPassword|recentLogin|verifyTotp|verifyMfa|mfaToken|requireRecentLogin|requireFreshSession|step[-_]?up|requireFreshAuth|verifyPassword)\b/;
3139
3168
 
@@ -8859,7 +8888,12 @@ function _gcHint(minHeapMiB) {
8859
8888
  _gcFn();
8860
8889
  }
8861
8890
 
8862
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8891
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){
8892
+ // E6-01: local, aggregate telemetry for this scan (see pipeline/telemetry.js). Nothing is sent anywhere; the snapshot is a field of the result.
8893
+ const _tel = createTelemetry({ deterministic: isDeterministic() });
8894
+ setDetectorTimingSink((name, ms) => _tel.detector(name, ms));
8895
+ _tel.sample();
8896
+ _resetSuppressions();_tel.time('index', () => _buildProjectIndex(fileContents));await _tel.timeAsync('custom-rules', () => _loadCustomRules(scanRoot));
8863
8897
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8864
8898
  // analysis can recognize calls to user-input-returning helper methods
8865
8899
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -8885,17 +8919,20 @@ function _deterministicFileTimings(timings) {
8885
8919
  }
8886
8920
 
8887
8921
  const _fileTimings = [];
8888
- let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
8922
+ let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0, _filesOversizeSkipped = 0;
8889
8923
  const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];const _detectorErrors=[];
8890
8924
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
8891
8925
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
8892
8926
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
8893
8927
  // nothing that depends on the whole tree can be stale on resume.
8894
8928
  const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
8929
+ // E6-04: opt-in cross-scan analysis cache (AGENTIC_SECURITY_ANALYSIS_CACHE=1). Same per-file record format as the resume checkpoint, but it is kept after
8930
+ // a completed scan, its records are authenticated with the install key, and the global key also covers the cross-file inputs a file's cascade reads.
8931
+ const _cacheEnabled = !_ckptEnabled && process.env.AGENTIC_SECURITY_ANALYSIS_CACHE === '1' && !!scanRoot;
8895
8932
  let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
8896
8933
  const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
8897
8934
  let _ckptInvalidated = [];
8898
- if (_ckptEnabled) {
8935
+ if (_ckptEnabled || _cacheEnabled) {
8899
8936
  try {
8900
8937
  const _ckptIdentity = {
8901
8938
  engineVersion: _ENGINE_VERSION,
@@ -8909,8 +8946,9 @@ function _deterministicFileTimings(timings) {
8909
8946
  // instead, so changing one file no longer discards every other file's
8910
8947
  // already-completed work — see scan-checkpoint.js's module header.
8911
8948
  _ckpt = openCheckpoint(scanRoot, {
8912
- globalKey: computeGlobalKey(_ckptIdentity),
8949
+ globalKey: computeGlobalKey(_cacheEnabled ? { ..._ckptIdentity, extra: _analysisContextDigest(scanRoot) } : _ckptIdentity),
8913
8950
  meta: globalKeyMeta(_ckptIdentity),
8951
+ ...(_cacheEnabled ? { persistent: true, fileName: 'analysis-cache.jsonl', sign: signLastScan } : {}),
8914
8952
  // Haskell/Nix sources hash with their import closure, so an imported
8915
8953
  // module's change invalidates its importers' checkpointed work too.
8916
8954
  fileContents: withLanguageClosure(fileContents, depFileContents),
@@ -8939,6 +8977,17 @@ function _deterministicFileTimings(timings) {
8939
8977
  for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
8940
8978
  for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
8941
8979
  for (const x of (d.suppressions || [])) _suppressionLog.push(x);
8980
+ // A finding's `source` and `sink` ARE the entries of this file's source and sink lists (one object, two references), and later passes annotate them in
8981
+ // place (flowStatus, riskStatus, ...). Reading a record back from JSON gives each reference its own copy, so the annotation would reach the list entry
8982
+ // and not the finding. Re-pointing the finding at the matching list entry restores the sharing a fresh analysis has.
8983
+ {
8984
+ const srcByJson = new Map(); for (const x of aSrc.slice(s0)) srcByJson.set(JSON.stringify(x), x);
8985
+ const sinkByJson = new Map(); for (const x of aSink.slice(k0)) sinkByJson.set(JSON.stringify(x), x);
8986
+ for (const f of aF.slice(f0)) {
8987
+ if (f && f.source && typeof f.source === 'object') { const m = srcByJson.get(JSON.stringify(f.source)); if (m) f.source = m; }
8988
+ if (f && f.sink && typeof f.sink === 'object') { const m = sinkByJson.get(JSON.stringify(f.sink)); if (m) f.sink = m; }
8989
+ }
8990
+ }
8942
8991
  const ta = Object.assign({}, d.pfr || {});
8943
8992
  ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
8944
8993
  ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
@@ -8947,9 +8996,10 @@ function _deterministicFileTimings(timings) {
8947
8996
  _ckptResumed++;
8948
8997
  return true;
8949
8998
  };
8999
+ let _cacheSecretSkips = 0;
8950
9000
  const _ckptRecord = (p, mk, ms, ta) => {
8951
9001
  if (!_ckpt || !_ckpt.enabled) return;
8952
- recordFileDone(_ckpt, p, {
9002
+ const _payload = {
8953
9003
  routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
8954
9004
  sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
8955
9005
  logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
@@ -8958,7 +9008,10 @@ function _deterministicFileTimings(timings) {
8958
9008
  pfr: _pfrMetaOnly(ta),
8959
9009
  pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
8960
9010
  ms,
8961
- });
9011
+ };
9012
+ // A cache that outlives the scan must not become a second copy of a secret: a file whose result contains a secret-shaped value is analysed afresh each time.
9013
+ if (_cacheEnabled) { let _txt = ''; try { _txt = JSON.stringify(_payload); } catch { /* not serializable: recordFileDone refuses it too */ } if (secretCanaryHit(_txt)) { _cacheSecretSkips++; return; } }
9014
+ recordFileDone(_ckpt, p, _payload);
8962
9015
  _ckptWrites++;
8963
9016
  // Fault injection for the resume test: a hard exit with no unwinding, which
8964
9017
  // is what the checkpoint format has to survive. Never set in normal use.
@@ -8978,12 +9031,13 @@ function _deterministicFileTimings(timings) {
8978
9031
  });
8979
9032
  }
8980
9033
  let i=0;
9034
+ const _tDet = Date.now();
8981
9035
  try {
8982
9036
  for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
8983
9037
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
8984
9038
  const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
8985
- try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
8986
- let _delta;
9039
+ try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;if(c&&c.length>500000)_filesOversizeSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
9040
+ let _delta; const _errsBefore = _detectorErrors.length;
8987
9041
  if (_cascadePool) {
8988
9042
  const _res = await _cascadePool.runFile(p, c, scanRoot, _detectorErrors, { timeoutMs: _perFileTimeoutMs });
8989
9043
  if (_res.ok) { _delta = _res.result; }
@@ -8997,7 +9051,9 @@ function _deterministicFileTimings(timings) {
8997
9051
  const _ftElapsed=Date.now()-_ft0;
8998
9052
  if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
8999
9053
  _fileTimings.push({file:p,ms:_ftElapsed});
9000
- _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
9054
+ // The cache only keeps a file whose analysis finished cleanly: a detector that threw, a timeout, or a file whose analysis reads or writes state outside
9055
+ // itself (the MCP rug-pull baseline) is analysed afresh every time so health reporting and side effects stay exactly as they are.
9056
+ if (!_cacheEnabled || (_detectorErrors.length === _errsBefore && _ftElapsed <= _perFileTimeoutMs && !_ANALYSIS_CACHE_NEVER_RE.test(p))) _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
9001
9057
  }catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}
9002
9058
  if(i%5===0)await new Promise(r=>setTimeout(r,0));}
9003
9059
  } finally {
@@ -9146,6 +9202,8 @@ function _deterministicFileTimings(timings) {
9146
9202
  };
9147
9203
  filterArr(aF); filterArr(aLogic); filterArr(aSecrets); filterArr(aSupply);
9148
9204
  }
9205
+ _tel.record('detectors-per-file', Date.now() - _tDet);
9206
+ const _tLink = Date.now();
9149
9207
  setProgress({current:i,total:files.length,file:"Cross-file...",phase:"Linking"});const ii=buildImportGraph(fc);const cf=crossFileTaint(pfr,fc,ii);aF.push(...cf);
9150
9208
  setProgress({current:i,total:files.length,file:"Stored taint...",phase:"Linking"});const storedRegistry=buildStoredTaintRegistry(fc);const stf=crossStoredTaint(fc,storedRegistry);aF.push(...stf);
9151
9209
  setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
@@ -9153,6 +9211,7 @@ function _deterministicFileTimings(timings) {
9153
9211
  // R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
9154
9212
  // Convention deviation is PROJECT-scoped by design: the convention is a
9155
9213
  // property of the codebase, not of one file (see convention-deviation.js).
9214
+ _tel.record('cross-file-linking', Date.now() - _tLink);
9156
9215
  aF.push(...(runDetector(_detectorErrors,'<project>','scanConventionDeviationProject',()=>scanConventionDeviationProject(fc))||[]));
9157
9216
  aF.push(...(runDetector(_detectorErrors,'<project>','scanApiBrokenAuthz',()=>scanApiBrokenAuthz(aR))||[]));
9158
9217
  // R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
@@ -9432,6 +9491,7 @@ function _deterministicFileTimings(timings) {
9432
9491
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
9433
9492
  let _deepCallGraph = null;
9434
9493
  let _deepFailure = null;
9494
+ let _deepIncomplete = null;
9435
9495
  if (_deepEnabled) {
9436
9496
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
9437
9497
  const t0 = Date.now();
@@ -9462,6 +9522,8 @@ function _deterministicFileTimings(timings) {
9462
9522
  }),
9463
9523
  });
9464
9524
  const elapsed = Date.now() - t0;
9525
+ _tel.record('deep-analysis', elapsed);
9526
+ if (irFindings.analysisIncomplete && irFindings._summaryCache && typeof irFindings._summaryCache.stats === 'function') { const st = irFindings._summaryCache.stats(); _tel.set('cache.summary.hits', st.hits); _tel.set('cache.summary.misses', st.misses); }
9465
9527
  if (elapsed > budgetMs) {
9466
9528
  // We exceeded budget — surface a single info finding so operators see it.
9467
9529
  aF.push({
@@ -9473,6 +9535,7 @@ function _deterministicFileTimings(timings) {
9473
9535
  confidence: 0.5,
9474
9536
  });
9475
9537
  }
9538
+ if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
9476
9539
  for (const f of irFindings) {
9477
9540
  f.unvalidated = true;
9478
9541
  f.validator_verdict = 'unvalidated';
@@ -9514,6 +9577,8 @@ function _deterministicFileTimings(timings) {
9514
9577
  ? (_inCi ? 'requested, but running in CI without AGENTIC_SECURITY_DEEP_IN_CI=1' : 'unknown')
9515
9578
  : (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
9516
9579
  failure: _deepFailure,
9580
+ // E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
9581
+ ...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
9517
9582
  };
9518
9583
  // Sub-project E, increment 5 — Data Flow Explorer lineage graph.
9519
9584
  // DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
@@ -9861,7 +9926,7 @@ function _deterministicFileTimings(timings) {
9861
9926
  let _annotatorIdx = 0;
9862
9927
  const _runAnnotator = (phase, fn) => {
9863
9928
  setProgress({ phase: 'Annotating', current: ++_annotatorIdx, total: _ANNOTATOR_TOTAL_ESTIMATE, file: phase });
9864
- return runAnnotatorAsync(_annotatorErrors, phase, fn);
9929
+ return _tel.timeAsync(`annotator:${phase}`, () => runAnnotatorAsync(_annotatorErrors, phase, fn));
9865
9930
  };
9866
9931
  await _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
9867
9932
  await _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
@@ -10740,7 +10805,7 @@ function _deterministicFileTimings(timings) {
10740
10805
  // seen when only N-of-those-candidates were actually analyzed.
10741
10806
  // checkpoint.total intentionally keeps files.length — that field means the
10742
10807
  // full candidate set for resume bookkeeping, a different, correct meaning.
10743
- _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
10808
+ _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesOversizeSkipped:_filesOversizeSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),...(_cacheEnabled?{kind:'analysis-cache',secretSkips:_cacheSecretSkips}:{}),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
10744
10809
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
10745
10810
  // it. Anything that threw before this point leaves it in place to resume from.
10746
10811
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
@@ -11163,7 +11228,10 @@ function _deterministicFileTimings(timings) {
11163
11228
  compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
11164
11229
  });
11165
11230
  } // end if (!skipAnnotators) — FR-PROV-029
11166
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
11231
+ setDetectorTimingSink(null);
11232
+ attributeErrors(_tel, { annotatorErrors: _annotatorErrors, detectorErrors: (typeof _detectorErrors !== 'undefined' ? _detectorErrors : []), deepFailure: _deepFailure, filesTimedOut: (_scanMeta && _scanMeta.filesTimedOut) || 0 });
11233
+ _tel.set('files.scanned', (_scanMeta && _scanMeta.filesScanned) || 0); _tel.set('files.skipped', (_scanMeta && _scanMeta.filesSkipped) || 0);
11234
+ return{telemetry:_tel.snapshot(),entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
11167
11235
 
11168
11236
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
11169
11237
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -39,6 +39,10 @@ import * as fsp from 'node:fs/promises';
39
39
  import * as path from 'node:path';
40
40
  import * as crypto from 'node:crypto';
41
41
  import { verifyLastScan } from '../posture/integrity.js';
42
+ import { verifyPatch, additiveFields } from './verify-patch.js';
43
+ import { decidePatchApplication } from './patch-policy.js';
44
+ import { beginBatch, commitBatch, abandonBatch, recoverBatches, faultPoint } from './batch-journal.js';
45
+ import { spawnSync } from 'node:child_process';
42
46
  import { stateDir } from '../posture/state-dir.js';
43
47
  import { applyFix as applyFixHistory, revertEntryById as revertFixEntry } from '../posture/fix-history.js';
44
48
  import { classifyFixMaterialRisk } from '../posture/material-change.js';
@@ -209,7 +213,7 @@ async function getVerifyFixCore() {
209
213
  * configured, tests when a runner is detected — genuinely ran and passed, not silently skipped.
210
214
  * `verified:true, verifiedFull:false` is a real, honest state: applied, but on a degraded pass.
211
215
  */
212
- export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false, patchNegative = null, assuranceConfig } = {}) {
216
+ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false, patchNegative = null, assuranceConfig, applyMode = 'requested', policy } = {}) {
213
217
  if (!scanRoot) return { ok: false, applied: false, reason: 'scanRoot required' };
214
218
  if (!finding) return { ok: false, applied: false, reason: 'finding required' };
215
219
  if (!files || typeof files !== 'object' || !Object.keys(files).length) {
@@ -231,6 +235,9 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
231
235
  return { ok: false, applied: false, reason: `last-scan.json integrity check failed: ${integrityStatus}` };
232
236
  }
233
237
 
238
+ // A previous run that died part-way through a multi-file write is settled first, so everything below sees the tree it was in before that run.
239
+ const settledBatches = dryRun ? [] : await recoverBatches(scanRoot);
240
+
234
241
  const confined = {};
235
242
  for (const [rel, content] of Object.entries(files)) {
236
243
  let abs;
@@ -253,6 +260,12 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
253
260
  } catch (e) {
254
261
  return { ok: false, applied: false, reason: `could not read current content of ${rel} to detect concurrent changes: ${e.message}` };
255
262
  }
263
+ // Binary content cannot be patched as text: reading it as UTF-8 and writing it back would corrupt it.
264
+ if (String(content).includes('\0')) return { ok: false, applied: false, reason: `binary content refused: the replacement for ${rel} contains NUL bytes` };
265
+ if (fs.existsSync(abs)) {
266
+ const raw = fs.readFileSync(abs);
267
+ if (raw.includes(0) || Buffer.from(raw.toString('utf8'), 'utf8').compare(raw) !== 0) return { ok: false, applied: false, reason: `binary file refused: ${rel} is not valid UTF-8 text, so a text patch would corrupt it` };
268
+ }
256
269
  confined[rel] = { abs, content: String(content), baselineHash };
257
270
  }
258
271
 
@@ -323,6 +336,7 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
323
336
  }
324
337
 
325
338
  let verify = null;
339
+ let fixVerification = null;
326
340
  let pn = null;
327
341
  let repairRecords = [];
328
342
  const pnView = (r) => ({ status: r.status, verifiedFix: r.verifiedFix, incompleteStep: r.incompleteStep, failureCode: r.failureCode, reason: r.reason, steps: r.steps, patchDigest: r.patchDigest, summary: r.summary, verificationRecord: r.verificationRecord });
@@ -332,14 +346,14 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
332
346
  }
333
347
  let verdict;
334
348
  try {
335
- const verifyFixCore = await getVerifyFixCore();
336
- verdict = await verifyFixCore({
337
- scanRoot,
338
- originalFindingStableId: finding.stableId,
349
+ const central = await verifyPatch({
350
+ scanRoot, finding, route: 'apply-fix-service', mode: dryRun ? 'preview' : 'apply',
339
351
  files: Object.fromEntries(Object.entries(confined).map(([rel, v]) => [rel, v.content])),
340
- fixMeta,
341
- ...(patchNegative ? { patchNegative, assuranceConfig } : {}),
352
+ fixMeta, verify: await getVerifyFixCore(),
353
+ ...(patchNegative ? { extra: { patchNegative, assuranceConfig } } : {}),
342
354
  });
355
+ verdict = central.verdict;
356
+ fixVerification = central.fixVerification;
343
357
  } catch (e) {
344
358
  return { ok: false, applied: false, reason: `verification failed: ${e.message}` };
345
359
  }
@@ -357,12 +371,25 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
357
371
  reason: `rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
358
372
  verify: { rescan: verdict.rescan, lint: verdict.lint ? { runner: verdict.lint.runner, ok: verdict.lint.ok } : null, honesty: verdict.honesty || null },
359
373
  ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}),
374
+ ...(fixVerification ? additiveFields(fixVerification) : {}),
360
375
  };
361
376
  }
362
377
  }
363
378
 
379
+ // E3-06: may this be written? `requested` (a person asked) proceeds on any verified tier and carries the warnings; `automatic` needs the
380
+ // policy to enable it and every required check to have passed, otherwise the patch is only proposed.
381
+ const uncommitted = uncommittedFiles(scanRoot, Object.keys(confined));
382
+ let policyDecision = fixVerification ? decidePatchApplication({ record: fixVerification, finding, materialClassification, mode: applyMode, policy }) : null;
383
+ if (policyDecision && uncommitted.length) {
384
+ policyDecision = { ...policyDecision, warnings: [...policyDecision.warnings, `uncommitted changes in ${uncommitted.join(', ')}`] };
385
+ if (applyMode === 'automatic' && policyDecision.decision === 'apply') policyDecision = { ...policyDecision, decision: 'propose', reasons: [`uncommitted changes in ${uncommitted.join(', ')}: an automatic write would mix with unsaved work`] };
386
+ }
387
+ if (policyDecision && policyDecision.decision !== 'apply' && !dryRun) {
388
+ return { ok: true, applied: false, proposed: policyDecision.decision === 'propose', reason: `not applied: ${policyDecision.reasons[0]}`, files: Object.keys(confined), verify, materialClassification, policyDecision, ...additiveFields(fixVerification) };
389
+ }
390
+
364
391
  if (dryRun) {
365
- return { ok: true, applied: false, dryRun: true, verified: !skipVerification, files: Object.keys(confined), verify, materialClassification, ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}) };
392
+ return { ok: true, applied: false, dryRun: true, ...(policyDecision ? { policyDecision } : {}), verified: !skipVerification, files: Object.keys(confined), verify, materialClassification, ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}), ...(fixVerification ? additiveFields(fixVerification) : {}) };
366
393
  }
367
394
 
368
395
  // FR-304: re-check every file against its pre-verification baseline hash,
@@ -375,8 +402,11 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
375
402
  }
376
403
 
377
404
  const written = [];
405
+ const batch = await beginBatch(scanRoot, { findingId: finding.id || finding.findingId, files: Object.entries(confined).map(([rel, v]) => ({ file: rel, content: v.content, existed: fs.existsSync(v.abs) })) });
378
406
  try {
407
+ let n = 0;
379
408
  for (const [rel, v] of Object.entries(confined)) {
409
+ faultPoint(`before-file-${n}`);
380
410
  const fileExisted = fs.existsSync(v.abs);
381
411
  const originalContent = fileExisted ? await fsp.readFile(v.abs, 'utf8') : '';
382
412
  const entry = await applyFixHistory({
@@ -388,7 +418,10 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
388
418
  findingProvenance: finding.findingProvenance || null,
389
419
  });
390
420
  written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath, attemptOrdinal: entry.attemptOrdinal });
421
+ faultPoint(`after-file-${n}`);
422
+ n++;
391
423
  }
424
+ await commitBatch(scanRoot, batch);
392
425
  } catch (e) {
393
426
  // FR-306: "injected write failure restores all files" — applyFixHistory
394
427
  // already rolled back the ONE file that just failed; this rolls back
@@ -400,6 +433,7 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
400
433
  for (const w of written) {
401
434
  try { await revertFixEntry(scanRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
402
435
  }
436
+ await abandonBatch(scanRoot, batch).catch(() => {});
403
437
  // X-204: the rollback is its own record, after the promotion it undoes (a write that failed part-way)
404
438
  if (pn) {
405
439
  const rolled = appendRepairRecord(repairRecords, { kind: 'rolled-back', hypothesisId: finding.stableId, revision: pn.promotion?.revision ?? null, diffDigest: pn.patchDigest, reason: `the write failed and every file written in this batch was restored: ${String(e?.message || e).slice(0, 160)}` });
@@ -429,6 +463,19 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
429
463
  written,
430
464
  verify: verify ? { summary: verify.summary, verifiedFull: verify.verifiedFull, degradedLegs: verify.degradedLegs || [] } : null,
431
465
  materialClassification,
466
+ ...(policyDecision ? { policyDecision } : {}),
467
+ ...(settledBatches.length ? { recoveredBatches: settledBatches } : {}),
432
468
  ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}),
469
+ // the canonical record is the authority for verifiedFull and degradedLegs: it also knows when the tests ran before the patch existed
470
+ ...(fixVerification ? additiveFields(fixVerification) : {}),
433
471
  };
434
472
  }
473
+
474
+ /** Files among `rels` with uncommitted changes in a git work tree. [] when not a git repo or git is unavailable (unknown is not clean, but is not blocking). */
475
+ function uncommittedFiles(scanRoot, rels) {
476
+ try {
477
+ const r = spawnSync('git', ['status', '--porcelain', '--', ...rels], { cwd: scanRoot, encoding: 'utf8', timeout: 8000 });
478
+ if (r.status !== 0 || !r.stdout) return [];
479
+ return r.stdout.split('\n').filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => rels.includes(f));
480
+ } catch { return []; }
481
+ }
@@ -0,0 +1,84 @@
1
+ // All-or-nothing multi-file application that survives a crash (E3-07).
2
+ //
3
+ // fix-history.js already makes ONE file's write crash-safe: backup, pending log entry, atomic rename, promote. A patch that touches
4
+ // several files was only safe against an exception: if the process died after file A was promoted and before file B was written, A stayed
5
+ // patched and B did not, with nothing that said so. This journal closes that gap. Before the first write the batch is recorded as
6
+ // `writing`; after the last file is promoted it is `committed`. On the next run, `recoverBatches` finds any batch still `writing`, settles
7
+ // the per-file history first, then reverts every file of the batch that reached its patched content, so the tree returns to what it was.
8
+ //
9
+ // A fault seam (`faultPoint`) lets a test kill the process at a named point. It does nothing unless BOTH environment variables are set,
10
+ // so it cannot fire in normal use.
11
+ import * as fs from 'node:fs';
12
+ import * as fsp from 'node:fs/promises';
13
+ import * as path from 'node:path';
14
+ import * as crypto from 'node:crypto';
15
+ import { statePath } from '../posture/state-dir.js';
16
+ import { readLog, recover, revertEntryById } from '../posture/fix-history.js';
17
+
18
+ const dirOf = (root) => statePath(root, 'fix-batches');
19
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
20
+
21
+ export function faultPoint(name) {
22
+ if (process.env.AGENTIC_SECURITY_TEST_FAULTS !== '1') return;
23
+ if (process.env.AGENTIC_SECURITY_FAULT_AT === name) process.kill(process.pid, 'SIGKILL');
24
+ }
25
+
26
+ async function writeJson(file, value) {
27
+ await fsp.mkdir(path.dirname(file), { recursive: true });
28
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
29
+ const h = await fsp.open(tmp, 'w');
30
+ try { await h.writeFile(JSON.stringify(value, null, 2)); await h.sync(); } finally { await h.close(); }
31
+ await fsp.rename(tmp, file);
32
+ }
33
+
34
+ export async function beginBatch(scanRoot, { files, findingId }) {
35
+ const id = `batch-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
36
+ const batch = { id, state: 'writing', findingId: findingId ?? null, startedAt: new Date().toISOString(), files: files.map((f) => ({ file: f.file, newSha: sha(f.content), existed: f.existed })) };
37
+ await writeJson(path.join(dirOf(scanRoot), `${id}.json`), batch);
38
+ return batch;
39
+ }
40
+
41
+ export async function commitBatch(scanRoot, batch) {
42
+ await writeJson(path.join(dirOf(scanRoot), `${batch.id}.json`), { ...batch, state: 'committed', committedAt: new Date().toISOString() });
43
+ }
44
+
45
+ export async function abandonBatch(scanRoot, batch, state = 'rolled-back') {
46
+ await writeJson(path.join(dirOf(scanRoot), `${batch.id}.json`), { ...batch, state, settledAt: new Date().toISOString() });
47
+ }
48
+
49
+ const STRAY_TMP = /^\..+\.tmp-\d+-[0-9a-f]{8}$/;
50
+
51
+ /**
52
+ * Settle every batch that was still `writing` when a previous run stopped. Safe to call at any time; a no-op when nothing is pending.
53
+ * @returns {Promise<{batch:string, reverted:string[], untouched:string[], failed:string[]}[]>}
54
+ */
55
+ export async function recoverBatches(scanRoot) {
56
+ const dir = dirOf(scanRoot);
57
+ let names = [];
58
+ try { names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.json')); } catch { return []; }
59
+ const out = [];
60
+ let settled = false;
61
+ for (const n of names.sort()) {
62
+ let batch; try { batch = JSON.parse(await fsp.readFile(path.join(dir, n), 'utf8')); } catch { continue; }
63
+ if (batch.state !== 'writing') continue;
64
+ if (!settled) { await recover(scanRoot); settled = true; }
65
+ const log = readLog(scanRoot);
66
+ const reverted = []; const untouched = []; const failed = [];
67
+ for (const f of batch.files) {
68
+ const hits = log.filter((e) => e.file === f.file && !e.reverted && e.newSha === f.newSha.slice(0, 16) && e.appliedAt >= batch.startedAt && (e.status === 'applied' || e.status === 'applied-stale'));
69
+ if (!hits.length) { untouched.push(f.file); continue; }
70
+ for (const e of hits.reverse()) {
71
+ const r = await revertEntryById(scanRoot, e.id);
72
+ if (r && !r.error) reverted.push(f.file); else failed.push(f.file);
73
+ }
74
+ }
75
+ // an interrupted atomic write leaves a hidden temp file beside its target
76
+ for (const f of batch.files) {
77
+ const d = path.dirname(path.join(path.resolve(scanRoot), f.file));
78
+ try { for (const t of await fsp.readdir(d)) if (STRAY_TMP.test(t)) await fsp.unlink(path.join(d, t)).catch(() => {}); } catch { /* directory gone */ }
79
+ }
80
+ await abandonBatch(scanRoot, batch, failed.length ? 'rollback-incomplete' : 'rolled-back');
81
+ out.push({ batch: batch.id, reverted: [...new Set(reverted)], untouched, failed });
82
+ }
83
+ return out;
84
+ }
@@ -0,0 +1,51 @@
1
+ // Controlled pre/post exploit replay for a candidate patch (E3-04).
2
+ //
3
+ // Runs the SAME local reproducer against the pristine code and then against the code with the patch overlaid, using the proof fabric's
4
+ // confined runner (confined filesystem, scrubbed environment, bounded time and output, no external network, repository sources read-only
5
+ // and confined to the scan root). It targets only files under `scanRoot`; there is no URL input.
6
+ //
7
+ // What each outcome means:
8
+ // before every attempt completed and the oracle fired in all of them -> the exploit is real on the original code
9
+ // after every attempt completed and the oracle fired in none -> the patch blocks it
10
+ // anything that did not complete (timeout, build failure, no sandbox, invalid reproducer) is `not_run`/inconclusive. It is NEVER read
11
+ // as "the exploit failed", so an unavailable replay cannot masquerade as a fix.
12
+ //
13
+ // `executionProven` is true only when the precondition held (before) AND the postcondition failed on completed runs (after). It says the
14
+ // exploit stopped; whether the intended behaviour survived is a different question (functional preservation, E3-05).
15
+ import { runAttempt as defaultAttempt } from '../posture/proof-fabric/runner.js';
16
+ import { validateReproducer, reproducerDigest } from '../posture/proof-fabric/reproducer.js';
17
+
18
+ const clampAttempts = (n) => Math.max(1, Math.min(5, Number.isInteger(n) ? n : 3));
19
+
20
+ async function series(reproducer, n, opts, attemptFn) {
21
+ const rows = [];
22
+ for (let i = 0; i < n; i++) {
23
+ if (opts.signal?.aborted) break;
24
+ const r = await attemptFn(structuredClone(reproducer), opts);
25
+ rows.push({ status: r.status, fired: r.oracle?.fired === true, inconclusive: r.oracle?.inconclusive === true || r.status !== 'completed', reasons: r.reasons || [], degraded: r.degraded || [] });
26
+ }
27
+ return rows;
28
+ }
29
+
30
+ const summarize = (rows) => ({ attempts: rows.length, completed: rows.filter((r) => !r.inconclusive).length, fired: rows.filter((r) => r.fired && !r.inconclusive).length, statuses: rows.map((r) => r.status), reasons: [...new Set(rows.flatMap((r) => r.reasons))].slice(0, 5) });
31
+
32
+ /**
33
+ * @param {{scanRoot:string, reproducer:object, files:Record<string,string>, attempts?:number, timeoutMs?:number, signal?:AbortSignal, attemptFn?:Function}} o
34
+ */
35
+ export async function replayExploit({ scanRoot, reproducer, files, attempts, timeoutMs, signal, attemptFn = defaultAttempt }) {
36
+ const base = { schema: 'agentic-security/exploit-replay', version: 1, reproducerDigest: null };
37
+ const errs = validateReproducer(reproducer);
38
+ if (errs.length) return { ...base, before: null, after: null, beforeStatus: 'not_run', afterStatus: 'not_run', executionProven: false, reason: `the reproducer is invalid: ${errs[0]}` };
39
+ base.reproducerDigest = reproducerDigest(reproducer);
40
+ const n = clampAttempts(attempts);
41
+ const common = { sourceRoot: scanRoot, requireContainer: false, signal, limits: timeoutMs ? { timeoutMs } : undefined };
42
+
43
+ const before = summarize(await series(reproducer, n, common, attemptFn));
44
+ if (before.completed < before.attempts || before.attempts === 0) return { ...base, before, after: null, beforeStatus: 'not_run', afterStatus: 'not_run', executionProven: false, reason: `the original-code replay did not complete (${before.reasons[0] || before.statuses.join(',')}), so nothing can be said about the patch` };
45
+ if (before.fired < before.attempts) return { ...base, before, after: null, beforeStatus: 'not_run', afterStatus: 'not_run', executionProven: false, reason: `the exploit did not succeed on the original code in all ${before.attempts} attempts (${before.fired}), so the precondition is not established` };
46
+
47
+ const after = summarize(await series(reproducer, n, { ...common, overlay: files }, attemptFn));
48
+ if (after.fired > 0) return { ...base, before, after, beforeStatus: 'passed', afterStatus: 'failed', executionProven: false, reason: `the exploit still succeeds against the patched code (${after.fired} of ${after.attempts})` };
49
+ if (after.completed < after.attempts || after.attempts === 0) return { ...base, before, after, beforeStatus: 'passed', afterStatus: 'not_run', executionProven: false, reason: `the patched replay did not complete (${after.reasons[0] || after.statuses.join(',')}); an incomplete replay is not a blocked exploit` };
50
+ return { ...base, before, after, beforeStatus: 'passed', afterStatus: 'passed', executionProven: true, mechanism: 'the oracle did not fire in any completed replay of the patched code', reason: null };
51
+ }