@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
@@ -0,0 +1,65 @@
1
+ // Functional footprint of a patch (E3-05): did the patch fix the weakness, or remove the feature?
2
+ //
3
+ // A rescan only proves the finding stopped firing. A patch that deletes the vulnerable behaviour (a blanket 403 on an IDOR route, a 501 on
4
+ // the SQL endpoint, an emptied file) satisfies that just as well, so it is checked separately: the kinds of sensitive operation the
5
+ // changed files performed BEFORE the patch must still be performed AFTER it. A parameterised query is still a database read; an
6
+ // execFile call is still a process execution; a deny-all handler is neither.
7
+ //
8
+ // This is a HEURISTIC over source text, not a proof of equivalence. It catches the removal of a whole operation kind, not a subtler
9
+ // behaviour change. When it cannot see any operation kind in the original it says `unobservable` and the leg is not_run, never passed.
10
+ const KINDS = {
11
+ 'process-exec': /require\s*\(\s*['"](?:node:)?child_process['"]\s*\)|from\s+['"](?:node:)?child_process['"]|\b(?:execSync|execFileSync|execFile|spawnSync|spawn|exec|system|popen|Runtime\s*\.\s*getRuntime\s*\(\s*\)\s*\.\s*exec|ProcessBuilder|subprocess\s*\.\s*\w+|os\s*\.\s*system|shell_exec|passthru|proc_open)\s*\(/,
12
+ 'data-access': /\b(?:SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b|\.(?:find|findOne|findById|findAll|findUnique|findFirst|query|execute|executeQuery|executemany|prepare|raw|where|get_object_or_404|createQuery|queryForObject)\s*\(|\b(?:db|database|conn|connection|pool|cursor|collection|repository|repo|prisma|knex|sequelize|session)\w*\s*\.\s*\w+\s*\(/i,
13
+ 'file-access': /\b(?:readFile|readFileSync|createReadStream|writeFile|writeFileSync|createWriteStream|sendFile|readdir|readdirSync|unlink|open|os\.Open|ioutil\.ReadFile|file_get_contents|File\s*\(|FileInputStream|FileReader|Files\s*\.\s*\w+)\s*\(/,
14
+ 'network-out': /\b(?:fetch|axios|got|request|urlopen|requests\s*\.\s*\w+|http\s*\.\s*(?:get|request)|https\s*\.\s*(?:get|request)|HttpClient|http\.Get|curl_exec)\s*\(/,
15
+ 'html-output': /\b(?:res|response|reply|ctx)\b[^;\n]*\.\s*(?:send|write|render|json|end)\s*\(|\b(?:render_template|HttpResponse|JsonResponse|jsonify|innerHTML|echo|ResponseEntity|c\.JSON|c\.String)\b/,
16
+ };
17
+ const DENIAL = /\b(?:status\s*\(\s*(?:401|403|404|405|410|501|503)\s*\)|(?:abort|HTTPException|http_error|ResponseStatusException)\s*\(\s*(?:401|403|404|501)|(?:PermissionDenied|Forbidden|NotImplemented|AccessDenied|Unauthorized)\b|(?:forbidden|not implemented|access denied|disabled)\b|return\s+(?:403|401|501)\b|StatusForbidden|StatusNotImplemented)/i;
18
+ // Which kinds a CWE's fix must keep. Unlisted weaknesses are held to every kind the original had.
19
+ const CWE_KINDS = {
20
+ 'CWE-78': ['process-exec'], 'CWE-77': ['process-exec'], 'CWE-89': ['data-access'], 'CWE-564': ['data-access'], 'CWE-22': ['file-access'], 'CWE-73': ['file-access'],
21
+ 'CWE-918': ['network-out'], 'CWE-79': ['html-output'], 'CWE-639': ['data-access'], 'CWE-284': ['data-access'], 'CWE-285': ['data-access'], 'CWE-862': ['data-access'], 'CWE-863': ['data-access'], 'CWE-566': ['data-access'],
22
+ };
23
+
24
+ const stripComments = (t) => String(t || '').replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:\\])\/\/[^\n]*/g, '$1').replace(/(^|\s)#[^\n!][^\n]*/g, '$1');
25
+ const codeLines = (t) => stripComments(t).split('\n').filter((l) => l.trim() && !/^\s*[{}();,]*\s*$/.test(l)).length;
26
+
27
+ export function kindsOf(text) {
28
+ const src = stripComments(text);
29
+ return Object.entries(KINDS).filter(([, re]) => re.test(src)).map(([k]) => k).sort();
30
+ }
31
+
32
+ /**
33
+ * @param {{before:Record<string,string|null>, after:Record<string,string>, cwe?:string|null, intentionalRemoval?:{reason:string}|null}} o
34
+ * @returns {{status:'passed'|'failed'|'not_run', reason:string|null, files:object[], lost:string[]}}
35
+ */
36
+ export function checkFootprint({ before, after, cwe = null, intentionalRemoval = null }) {
37
+ const required = CWE_KINDS[String(cwe || '').toUpperCase()] || null;
38
+ const files = [];
39
+ const lostAll = new Set();
40
+ let observed = false;
41
+ for (const [rel, afterText] of Object.entries(after)) {
42
+ const b = before[rel];
43
+ if (b === null || b === undefined) { files.push({ file: rel, note: 'new file' }); continue; }
44
+ const kb = kindsOf(b); const ka = kindsOf(afterText);
45
+ const held = required ? kb.filter((k) => required.includes(k)) : kb;
46
+ if (held.length) observed = true;
47
+ let lost = held.filter((k) => !ka.includes(k));
48
+ const emptied = codeLines(afterText) === 0 && codeLines(b) > 0;
49
+ const denialOnly = lost.length > 0 && DENIAL.test(stripComments(afterText)) && !DENIAL.test(stripComments(b));
50
+ // With no known weakness class, losing an operation kind is not by itself a defect (an auth fix may legitimately drop a response path).
51
+ // What marks feature REMOVAL is the signature of it: the file emptied, or the lost operation replaced by a deny response.
52
+ if (!required && !emptied && !denialOnly) lost = [];
53
+ const shrunk = codeLines(b) >= 6 && codeLines(afterText) <= Math.floor(codeLines(b) * 0.25);
54
+ files.push({ file: rel, kindsBefore: kb, kindsAfter: ka, lost, emptied, denialOnly, shrunk });
55
+ if (emptied) { lost.push('everything'); observed = true; }
56
+ for (const k of lost) lostAll.add(k);
57
+ }
58
+ if (!observed && ![...lostAll].length) return { status: 'not_run', reason: 'unobservable: no sensitive operation was recognised in the original files, so removal of the feature cannot be judged', files, lost: [] };
59
+ if (lostAll.size) {
60
+ if (intentionalRemoval && typeof intentionalRemoval.reason === 'string' && intentionalRemoval.reason.trim()) return { status: 'not_run', reason: `the patch removes ${[...lostAll].join(', ')} and the caller declared that intentional (${intentionalRemoval.reason.trim().slice(0, 200)}); a person must confirm it`, files, lost: [...lostAll] };
61
+ const why = files.filter((f) => f.lost?.length).map((f) => `${f.file}: no longer performs ${f.lost.join(', ')}${f.denialOnly ? ' (replaced by a deny response)' : ''}${f.emptied ? ' (file emptied)' : ''}`).join('; ');
62
+ return { status: 'failed', reason: `the patch removes functionality instead of fixing it: ${why}`, files, lost: [...lostAll] };
63
+ }
64
+ return { status: 'passed', reason: null, files, lost: [] };
65
+ }
@@ -0,0 +1,49 @@
1
+ // Risk-tiered patch policy (E3-06): given a verified-patch record, may it be applied without a person, proposed for a person, or refused?
2
+ //
3
+ // Two modes. `requested` is a person (or an agent acting for one) who asked for the write: it proceeds on any tier that is not
4
+ // unverified or blocked, and the result carries the degraded legs as warnings so nothing about the pass is hidden. `automatic` is
5
+ // anything that would write with nobody asking: it is OFF unless the policy enables it, and even then it applies only a configured
6
+ // low-risk patch whose every REQUIRED check passed. Absent tests, a replay that did not run, an unobservable footprint, or any other
7
+ // degraded leg make an automatic write a proposal, never a silent write. Preview and undo are never removed by this module.
8
+ //
9
+ // Pure: no fs, no clock.
10
+ const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
11
+ const AUTH_CATEGORIES = /auth|crypto|permission|access|privilege|session|tenant/i;
12
+
13
+ export const DEFAULT_POLICY = Object.freeze({
14
+ automatic: Object.freeze({ enabled: false, maxSeverity: 'medium', allowedMaterialRisk: Object.freeze(['low']), requireExploitProof: false, maxResidualRisk: 'low' }),
15
+ });
16
+ const RESIDUAL_RANK = { none: 0, low: 1, medium: 2, high: 3, unknown: 4 };
17
+
18
+ export { deriveResidualRiskOf as deriveResidualRisk } from './verification-record.js';
19
+
20
+ const decide = (decision, record, reasons, extra = {}) => ({ decision, tier: record.fixCompleteness, verifiedFull: record.verifiedFull, residualRisk: record.residualRisk, reasons, warnings: record.degradedLegs, preview: true, undo: true, ...extra });
21
+
22
+ /**
23
+ * @param {{record:object, finding?:{severity?:string}, materialClassification?:{highImpactCategories?:string[], risk?:string}, mode?:'requested'|'automatic', policy?:object}} o
24
+ */
25
+ export function decidePatchApplication({ record, finding = {}, materialClassification = {}, mode = 'requested', policy = DEFAULT_POLICY }) {
26
+ const tier = record.fixCompleteness;
27
+ if (tier === 'blocked') return decide('refuse', record, ['a required check was blocked or a failure was recorded, so the patch cannot be applied']);
28
+ if (tier === 'unverified') return decide('refuse', record, ['the weakness is not shown to be gone, or a check failed']);
29
+ if (mode === 'requested') return decide('apply', record, [`${tier}: applied at the request of a person`]);
30
+
31
+ const a = { ...DEFAULT_POLICY.automatic, ...(policy?.automatic || {}) };
32
+ if (!a.enabled) return decide('propose', record, ['automatic application is not enabled by policy; the patch is proposed for a person to apply']);
33
+ const reasons = [];
34
+ if (tier !== 'FULL') reasons.push(`completeness is ${tier}, automatic application needs FULL`);
35
+ if (!record.verifiedFull) reasons.push('not every required check passed');
36
+ for (const d of record.degradedLegs) reasons.push(`degraded: ${d}`);
37
+ if (a.requireExploitProof && record.exploitProofAfter.status !== 'passed') reasons.push('policy requires an exploit replay and none confirmed the fix');
38
+ for (const leg of ['behaviorTests', 'behaviorPreservation']) if (record[leg].status !== 'passed') reasons.push(`${leg} did not pass (${record[leg].status})`);
39
+ const sev = SEVERITY_RANK[String(finding.severity || '').toLowerCase()];
40
+ if (sev === undefined) reasons.push('the finding severity is unknown');
41
+ else if (sev > SEVERITY_RANK[a.maxSeverity]) reasons.push(`severity ${finding.severity} is above the automatic limit (${a.maxSeverity})`);
42
+ const cats = materialClassification.highImpactCategories || [];
43
+ if (cats.length) reasons.push(`high-impact change (${cats.join(', ')}) needs a person`);
44
+ if (cats.some((c) => AUTH_CATEGORIES.test(c))) reasons.push('the change alters authentication, authorization or cryptography');
45
+ if (materialClassification.risk && !a.allowedMaterialRisk.includes(materialClassification.risk)) reasons.push(`material risk ${materialClassification.risk} is not in the allowed set`);
46
+ if ((RESIDUAL_RANK[record.residualRisk.level] ?? 4) > RESIDUAL_RANK[a.maxResidualRisk]) reasons.push(`residual risk ${record.residualRisk.level} is above ${a.maxResidualRisk}`);
47
+ if (reasons.length) return decide('propose', record, reasons);
48
+ return decide('apply', record, ['FULL, low risk, every required check passed']);
49
+ }
@@ -0,0 +1,39 @@
1
+ // Did the patch REMOVE a hard-coded secret, or only change how it is written? (E3-08)
2
+ //
3
+ // Moving `const k = 'abc123...'` to `const k = atob('YWJjMTIz...')`, splitting it in two literals, reversing it or hex-encoding it all
4
+ // make a pattern-based scanner stop seeing a secret that is still in the source. This looks for the original literal after the patch in
5
+ // the forms a lazy "fix" uses. It cannot catch encryption with a key kept next to it or other transformations; it says what it checked.
6
+ const MIN_LEN = 8;
7
+
8
+ const literalsOnLines = (text, lines) => {
9
+ const out = new Set();
10
+ const all = String(text).split('\n');
11
+ for (const n of lines) {
12
+ const l = all[n - 1];
13
+ if (!l) continue;
14
+ for (const m of l.matchAll(/(["'`])((?:\\.|(?!\1).){8,})\1/g)) out.add(m[2]);
15
+ }
16
+ return [...out].filter((s) => s.length >= MIN_LEN);
17
+ };
18
+ const squash = (t) => String(t).replace(/["'`+\s]/g, '');
19
+ const forms = (v) => {
20
+ const b64 = Buffer.from(v).toString('base64');
21
+ return [v, [...v].reverse().join(''), Buffer.from(v).toString('hex'), b64, b64.replace(/=+$/, ''), encodeURIComponent(v)].filter((x) => x.length >= MIN_LEN);
22
+ };
23
+
24
+ /**
25
+ * @param {{before:string, after:string, line:number|null}} o
26
+ * @returns {{status:'passed'|'failed'|'not_run', reason:string|null, checked:number}}
27
+ */
28
+ export function checkSecretPersistence({ before, after, line }) {
29
+ if (!Number.isInteger(line) || line < 1) return { status: 'not_run', reason: 'the finding has no line, so the secret literal cannot be located', checked: 0 };
30
+ const lits = literalsOnLines(before, [line - 1, line, line + 1].filter((n) => n >= 1));
31
+ if (!lits.length) return { status: 'not_run', reason: 'no string literal of 8 or more characters near the finding line', checked: 0 };
32
+ const flat = squash(after);
33
+ for (const lit of lits) {
34
+ for (const f of forms(lit)) {
35
+ if (String(after).includes(f) || flat.includes(squash(f))) return { status: 'failed', reason: `the secret is still in the source (${f === lit ? 'unchanged' : 'as a re-encoded or split form'}); moving or encoding it does not remove it`, checked: lits.length };
36
+ }
37
+ }
38
+ return { status: 'passed', reason: null, checked: lits.length };
39
+ }
@@ -0,0 +1,84 @@
1
+ // Run the project's tests against the PATCHED tree, in a throwaway copy (E3-05).
2
+ //
3
+ // verifyFix used to run the tests on the tree as it was before the patch was written, which says nothing about the patch. Here the
4
+ // patch is overlaid on a copy and the tests run there; the real tree is never touched.
5
+ //
6
+ // Runner discovery covers what the existing detector does (npm/pnpm/yarn/bun scripts, pytest, go test) plus cargo, dotnet, maven,
7
+ // gradle and rspec, and an explicit argv from the caller (`command`) which always wins. Commands are argv arrays, never a shell string.
8
+ // When the patched run fails the pristine copy is run too, so "the suite was already red" is not mistaken for "the patch broke it".
9
+ import { spawnSync } from 'node:child_process';
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { detectTestCommand } from '../posture/test-runner.js';
14
+ import { STATE_DIR_NAME } from '../posture/state-dir.js';
15
+
16
+ const MAX_COPY_BYTES = 200 * 1024 * 1024;
17
+ const SKIP_DIRS = new Set(['.git', STATE_DIR_NAME, 'node_modules', 'target', 'dist', 'build', '.venv', '__pycache__', 'bin', 'obj']);
18
+ const DEFAULT_TIMEOUT_MS = 300_000;
19
+
20
+ const has = (root, rel) => { try { return fs.existsSync(path.join(root, rel)); } catch { return false; } };
21
+ const available = (cmd) => { try { const r = spawnSync(cmd, ['--version'], { timeout: 8000, stdio: 'ignore' }); return !(r.error && r.error.code === 'ENOENT'); } catch { return false; } };
22
+
23
+ /** @returns {{cmd:string,args:string[],kind:string}|null} */
24
+ export function discoverTestCommand(root, { available: avail = available } = {}) {
25
+ const base = detectTestCommand(root);
26
+ if (base) return base;
27
+ if (has(root, 'Cargo.toml') && avail('cargo')) return { cmd: 'cargo', args: ['test', '--quiet'], kind: 'cargo' };
28
+ try { if (fs.readdirSync(root).some((f) => /\.(sln|csproj)$/.test(f)) && avail('dotnet')) return { cmd: 'dotnet', args: ['test', '--nologo'], kind: 'dotnet' }; } catch { /* unreadable root */ }
29
+ if (has(root, 'pom.xml') && avail('mvn')) return { cmd: 'mvn', args: ['-q', 'test'], kind: 'maven' };
30
+ if ((has(root, 'build.gradle') || has(root, 'build.gradle.kts')) && avail('gradle')) return { cmd: 'gradle', args: ['test', '--quiet'], kind: 'gradle' };
31
+ if ((has(root, '.rspec') || has(root, 'spec')) && has(root, 'Gemfile') && avail('bundle')) return { cmd: 'bundle', args: ['exec', 'rspec'], kind: 'rspec' };
32
+ return null;
33
+ }
34
+
35
+ function copyTree(root, dest) {
36
+ let bytes = 0;
37
+ fs.cpSync(root, dest, {
38
+ recursive: true, dereference: false, verbatimSymlinks: true,
39
+ filter: (src) => {
40
+ const rel = path.relative(root, src);
41
+ if (!rel) return true;
42
+ if (rel.split(path.sep).some((s) => SKIP_DIRS.has(s))) return false;
43
+ try { const st = fs.lstatSync(src); if (st.isFile()) { bytes += st.size; if (bytes > MAX_COPY_BYTES) throw new Error('tree-too-large'); } } catch (e) { if (e.message === 'tree-too-large') throw e; }
44
+ return true;
45
+ },
46
+ });
47
+ // dependencies are linked, not copied: tests need them, and the copy must not be able to change them
48
+ const nm = path.join(root, 'node_modules');
49
+ if (fs.existsSync(nm)) { try { fs.symlinkSync(nm, path.join(dest, 'node_modules'), 'dir'); } catch { /* best effort */ } }
50
+ }
51
+
52
+ function run(cwd, cmd, args, timeoutMs) {
53
+ const t0 = Date.now();
54
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, CI: '1' } });
55
+ const durationMs = Date.now() - t0;
56
+ if (r.error && r.error.code === 'ENOENT') return { status: 'skipped', passed: null, skipped: true, reason: `${cmd}-not-installed`, exitCode: null, timedOut: false, durationMs };
57
+ if (r.status === null) return { status: 'failed', passed: false, skipped: false, reason: 'timed-out', exitCode: null, timedOut: true, durationMs };
58
+ return { status: r.status === 0 ? 'passed' : 'failed', passed: r.status === 0, skipped: false, reason: r.status === 0 ? null : `exit-${r.status}`, exitCode: r.status, timedOut: false, durationMs };
59
+ }
60
+
61
+ /**
62
+ * @param {{scanRoot:string, files:Record<string,string>, command?:string[], timeoutMs?:number}} o
63
+ */
64
+ export function runTestsOnPatchedCopy({ scanRoot, files, command, timeoutMs = DEFAULT_TIMEOUT_MS }) {
65
+ const spec = command && command.length ? { cmd: command[0], args: command.slice(1), kind: 'explicit' } : discoverTestCommand(scanRoot);
66
+ if (!spec) return { status: 'skipped', passed: null, skipped: true, reason: 'no-test-command-detected', exitCode: null, timedOut: false, durationMs: 0, onPatchedTree: false };
67
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'patched-tests-'));
68
+ try {
69
+ const patched = path.join(work, 'patched');
70
+ try { copyTree(scanRoot, patched); } catch (e) { return { status: 'skipped', passed: null, skipped: true, reason: e.message === 'tree-too-large' ? 'tree-too-large-to-copy' : `copy-failed: ${e.message}`, exitCode: null, timedOut: false, durationMs: 0, onPatchedTree: false }; }
71
+ for (const [rel, text] of Object.entries(files || {})) {
72
+ const abs = path.resolve(patched, rel);
73
+ if (abs !== patched && !abs.startsWith(patched + path.sep)) return { status: 'skipped', passed: null, skipped: true, reason: 'patch-path-escapes-the-copy', exitCode: null, timedOut: false, durationMs: 0, onPatchedTree: false };
74
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
75
+ fs.writeFileSync(abs, String(text));
76
+ }
77
+ const result = { ...run(patched, spec.cmd, spec.args, timeoutMs), runner: spec.kind, onPatchedTree: true, preExistingFailure: false };
78
+ if (result.status === 'failed') {
79
+ const pristine = path.join(work, 'pristine');
80
+ try { copyTree(scanRoot, pristine); const base = run(pristine, spec.cmd, spec.args, timeoutMs); if (base.status === 'failed') result.preExistingFailure = true; } catch { /* attribution is best effort */ }
81
+ }
82
+ return result;
83
+ } finally { fs.rmSync(work, { recursive: true, force: true }); }
84
+ }
@@ -0,0 +1,97 @@
1
+ // Adapters from the results the existing routes already produce to the patch-level verification record (E3-02).
2
+ //
3
+ // An adapter only RELAYS: it maps what a route observed onto legs, and anything the route did not observe becomes `not_run` with the
4
+ // reason. It never upgrades a result. In particular a test run that happened before the patch was written (verifyFix sets
5
+ // `testedPrePatch`) is recorded as `not_run` for the patched tree, not as passed.
6
+ import { createHash } from 'node:crypto';
7
+ import { digestOf } from '../posture/assurance/identity.js';
8
+ import { buildFixVerificationRecord, evidenceItem, leg } from './verification-record.js';
9
+
10
+ const sha256Hex = (s) => createHash('sha256').update(s).digest('hex');
11
+
12
+ /** Digest of a patch: the files it writes, by path and content digest. Independent of key order. */
13
+ export function patchHashOf(files) {
14
+ const rows = Object.keys(files || {}).sort().map((p) => [p, sha256Hex(String(files[p]))]);
15
+ return digestOf({ files: rows });
16
+ }
17
+
18
+ /** Digest of the files as they were before the patch, for the paths the patch touches. `read(path)` returns the text or null. */
19
+ export function treeHashOf(paths, read) {
20
+ const rows = [...paths].sort().map((p) => { const t = read(p); return [p, t === null || t === undefined ? null : sha256Hex(String(t))]; });
21
+ return digestOf({ files: rows });
22
+ }
23
+
24
+ const asEvidence = (kind, value, note = null) => evidenceItem({ kind, mediaType: 'application/json', sha256: digestOf(value).slice('sha256:'.length), note });
25
+
26
+ /**
27
+ * @param {object} v the object verifyFix returns
28
+ * @param {object} ctx { finding:{id,stableId}, files, route, mode, preCommit, preFiles(read), tools, requiredLegs, findingPresent }
29
+ */
30
+ export function recordFromVerifyFix(v, ctx) {
31
+ const evidence = [];
32
+ const add = (kind, value, note) => { const e = asEvidence(kind, value, note); if (!evidence.some((x) => x.id === e.id)) evidence.push(e); return e.id; };
33
+ const failures = [];
34
+
35
+ const rescanId = v.rescan ? add('rescan-result', v.rescan, 'verifyFix rescan leg') : null;
36
+ const securityAfter = !v.rescan ? leg('not_run', { reason: 'the route returned no rescan result' })
37
+ : v.rescan.ok ? leg('passed', { evidenceIds: [rescanId] }) : leg('failed', { evidenceIds: [rescanId], reason: `the original finding is still present (${v.rescan.reason})` });
38
+ if (ctx.persistence?.status === 'failed') {
39
+ const id = add('secret-persistence', ctx.persistence, 'the original secret literal searched for in the patched source');
40
+ securityAfter.status = 'failed'; securityAfter.evidenceIds = [...securityAfter.evidenceIds, id]; securityAfter.reason = ctx.persistence.reason;
41
+ }
42
+ const introduced = v.rescan?.introduced || [];
43
+ const newFindingDelta = !v.rescan ? leg('not_run', { reason: 'the route returned no rescan result' })
44
+ : introduced.length ? leg('failed', { evidenceIds: [rescanId], reason: `${introduced.length} finding(s) introduced by the patch` }) : leg('passed', { evidenceIds: [rescanId] });
45
+
46
+ let lint;
47
+ if (!v.lint) lint = leg('not_run', { reason: 'the route returned no lint result' });
48
+ else if (v.lint.runner === 'none') lint = leg('not_applicable', { reason: 'no linter is configured for this project' });
49
+ else lint = v.lint.ok ? leg('passed', { evidenceIds: [add('lint-result', v.lint)] }) : leg('failed', { evidenceIds: [add('lint-result', v.lint)], reason: 'the linter reported errors on the patched files' });
50
+
51
+ let behaviorTests;
52
+ if (!v.tests) behaviorTests = leg('not_run', { reason: 'the route returned no test result' });
53
+ else if (v.tests.skipped) behaviorTests = leg('not_run', { reason: `tests were skipped (${v.tests.reason || 'no reason recorded'})` });
54
+ else if (v.tests.preExistingFailure || v.tests.reason === 'suite-already-failing-before-the-patch') behaviorTests = leg('not_run', { reason: 'the project test suite was already failing before the patch, so it cannot show the patch preserved behaviour' });
55
+ else if (v.testedPrePatch) behaviorTests = leg('not_run', { evidenceIds: [], reason: 'the tests ran on the tree BEFORE the patch was written, so they say nothing about the patch' });
56
+ else behaviorTests = v.tests.passed ? leg('passed', { evidenceIds: [add('test-result', v.tests)] }) : leg('failed', { evidenceIds: [add('test-result', v.tests)], reason: 'the project tests failed on the patched tree' });
57
+
58
+ const poc = v.poc?.status;
59
+ const pocId = v.poc && poc !== 'not-requested' ? add('exploit-replay', v.poc) : null;
60
+ const replay = ctx.replay || null;
61
+ const replayId = replay ? add('exploit-replay', replay, 'pre/post replay of the same reproducer') : null;
62
+ const replayLeg = (status, ok, bad) => status === 'passed' ? leg('passed', { evidenceIds: [replayId] }) : status === 'failed' ? leg('failed', { evidenceIds: [replayId], reason: bad }) : leg('not_run', { reason: replay.reason || ok });
63
+ const exploitProofAfter = replay ? replayLeg(replay.afterStatus, 'the replay did not run', replay.reason) : !v.poc || poc === 'not-requested' ? leg('not_run', { reason: 'no exploit replay was requested by this route' })
64
+ : poc === 'no-longer-proven' ? leg('passed', { evidenceIds: [pocId] })
65
+ : poc === 'still-exploitable' ? leg('failed', { evidenceIds: [pocId], reason: 'the exploit still works against the patched code' })
66
+ : leg('not_run', { evidenceIds: [], reason: `the exploit replay was inconclusive (${v.poc.reason || 'no reason recorded'})` });
67
+ const exploitProofBefore = replay ? replayLeg(replay.beforeStatus, 'the replay did not run', replay.reason) : !v.poc || poc === 'not-requested' ? leg('not_run', { reason: 'no exploit replay was requested by this route' })
68
+ : poc === 'inconclusive' ? leg('not_run', { reason: 'the exploit could not be reproduced on the original code' })
69
+ : leg('passed', { evidenceIds: [pocId] });
70
+
71
+ const findingEv = ctx.findingPresent === true && ctx.finding ? add('finding', { id: ctx.finding.id, stableId: ctx.finding.stableId ?? null }, 'the finding in the signed scan') : null;
72
+ const securityBefore = findingEv ? leg('passed', { evidenceIds: [findingEv] }) : leg('not_run', { reason: 'the finding was not shown to be present on the original code' });
73
+
74
+ if (v.honesty && v.honesty.ok === false) failures.push({ leg: 'securityAfter', reason: 'the honesty gate refused the claimed fix quality' });
75
+
76
+ const fp = ctx.footprint || null;
77
+ const footprintLeg = !fp ? leg('not_run', { reason: 'this route does not check that intended functionality survived' })
78
+ : fp.status === 'passed' ? leg('passed', { evidenceIds: [add('footprint', fp, 'sensitive operation kinds before and after the patch')] })
79
+ : fp.status === 'failed' ? leg('failed', { evidenceIds: [add('footprint', fp, 'sensitive operation kinds before and after the patch')], reason: fp.reason })
80
+ : leg('not_run', { reason: fp.reason });
81
+ const files = ctx.files || {};
82
+ return buildFixVerificationRecord({
83
+ findingId: ctx.finding?.id ?? 'unknown', findingStableId: ctx.finding?.stableId, mode: ctx.mode || 'verify', route: ctx.route || 'verify-fix',
84
+ preCommit: ctx.preCommit ?? null, preTreeHash: ctx.preFiles ? treeHashOf(Object.keys(files), ctx.preFiles) : null, postPatchHash: patchHashOf(files), patchHash: patchHashOf(files),
85
+ affectedFiles: Object.keys(files),
86
+ securityBefore, securityAfter, exploitProofBefore, exploitProofAfter, newFindingDelta, behaviorTests, lint,
87
+ behaviorPreservation: footprintLeg,
88
+ regressionTests: leg('not_run', { reason: 'this route runs a single test leg' }),
89
+ typecheck: leg('not_run', { reason: 'this route does not run a type checker' }),
90
+ dependencyChecks: leg('not_applicable', { reason: 'a source patch changes no dependency' }),
91
+ requiredLegs: ctx.requiredLegs || ['securityAfter', 'newFindingDelta', 'behaviorTests'],
92
+ failures, evidence, tools: ctx.tools || { engineVersion: null, verifier: 'verifyFix' },
93
+ residualRisk: ctx.residualRisk,
94
+ confidence: ctx.confidence || (replay?.executionProven ? { level: 'proven', policy: 'exploit-replay-v1', evidenceIds: [replayId] } : undefined),
95
+ ...(ctx.timestamps ? { timestamps: ctx.timestamps } : {}),
96
+ });
97
+ }
@@ -0,0 +1,243 @@
1
+ // The patch-level verification record (E3-02): one canonical statement of what was checked about ONE patch, by whichever route produced it.
2
+ //
3
+ // WHY A SECOND RECORD. posture/assurance/verification-record.js records a verdict on a HYPOTHESIS ("this finding is real"): confirmed,
4
+ // refuted, inconclusive. This records what was done to a PATCH ("this change fixes it and breaks nothing"). They are separate axes and the
5
+ // patch record points at the hypothesis it concerns; neither contains the other.
6
+ //
7
+ // HONESTY RULES (enforced by the validator, not by convention)
8
+ // 1. Every check is a leg with one of five statuses: passed, failed, blocked, not_run, not_applicable. `not_run` and `blocked` are never
9
+ // `passed`, and `not_applicable` carries a reason, so "did not apply" cannot hide "was not checked".
10
+ // 2. `verifiedFull` is DERIVED: true only when every required leg passed and none failed. A record that claims it without that is invalid.
11
+ // `degradedLegs` lists every required leg that did not pass, with the reason.
12
+ // 3. A leg may only cite evidence that is in the record, and every evidence item is addressed by its digest.
13
+ // 4. `confidence: proven` needs a named policy and evidence; confidence is never inferred from severity.
14
+ // 5. A route that ran fewer checks produces a record with those legs `not_run`; it does not produce a shorter record.
15
+ //
16
+ // Pure: no fs, no clock, no network. The id is content-derived from an allowlist of semantic fields, so timestamps and tool paths cannot
17
+ // change it.
18
+ import {
19
+ SCHEMA_VERSION, checkCommit, checkDigest, checkEnum, checkFields, checkHeader, checkString, guardObject, isPlainObject, makeCtx, result,
20
+ } from '../posture/assurance/schema-kit.js';
21
+ import { digestOf, semanticId } from '../posture/assurance/identity.js';
22
+
23
+ export const FIX_VERIFICATION_SCHEMA = 'agentic-security/fix-verification-record';
24
+ export const LEG_STATUSES = Object.freeze(['passed', 'failed', 'blocked', 'not_run', 'not_applicable']);
25
+ export const MODES = Object.freeze(['preview', 'verify', 'apply']);
26
+ export const COMPLETENESS = Object.freeze(['FULL', 'MITIGATION', 'WORKAROUND', 'unverified', 'blocked']);
27
+ export const RESIDUAL_LEVELS = Object.freeze(['none', 'low', 'medium', 'high', 'unknown']);
28
+ export const CONFIDENCE_LEVELS = Object.freeze(['proven', 'inferred', 'unknown']);
29
+
30
+ // The legs a record carries, in canonical order. Each is a { status, evidenceIds, reason } object.
31
+ export const LEG_NAMES = Object.freeze([
32
+ 'securityBefore', 'securityAfter', 'exploitProofBefore', 'exploitProofAfter', 'newFindingDelta', 'behaviorPreservation',
33
+ 'behaviorTests', 'regressionTests', 'lint', 'typecheck', 'dependencyChecks',
34
+ ]);
35
+
36
+ // What a leg means when it reads `passed`:
37
+ // securityBefore the weakness was present on the original code (reproduced or statically established)
38
+ // securityAfter the weakness is not present on the patched code
39
+ // exploitProofBefore an exploit succeeded against the original code
40
+ // exploitProofAfter the same exploit failed against the patched code
41
+ // newFindingDelta the patch introduced no new finding of equal or higher severity
42
+ // behaviorPreservation intended functionality survived (authorised use still works, the sensitive operation is not simply gone)
43
+ // behaviorTests the project's tests for the affected scope passed on the PATCHED tree
44
+ // regressionTests the wider project tests passed on the patched tree
45
+ // lint / typecheck the configured linter / type checker passed on the patched tree
46
+ // dependencyChecks dependency manifests and lockfiles are consistent and free of new known advisories
47
+ export const LEG_MEANING = Object.freeze({
48
+ securityBefore: 'the weakness was present on the original code',
49
+ securityAfter: 'the weakness is not present on the patched code',
50
+ exploitProofBefore: 'an exploit succeeded against the original code',
51
+ exploitProofAfter: 'the same exploit failed against the patched code',
52
+ newFindingDelta: 'the patch introduced no new finding of equal or higher severity',
53
+ behaviorPreservation: 'intended functionality survived',
54
+ behaviorTests: 'the affected-scope tests passed on the patched tree',
55
+ regressionTests: 'the wider project tests passed on the patched tree',
56
+ lint: 'the configured linter passed on the patched tree',
57
+ typecheck: 'the configured type checker passed on the patched tree',
58
+ dependencyChecks: 'dependency manifests are consistent and carry no new advisory',
59
+ });
60
+
61
+ const ALLOWED = ['schema', 'schemaVersion', 'id', 'findingId', 'findingStableId', 'hypothesisId', 'mode', 'route', 'preCommit', 'preTreeHash', 'postPatchHash', 'patchHash',
62
+ 'affectedFiles', ...LEG_NAMES, 'requiredLegs', 'coverage', 'degradedLegs', 'verifiedFull', 'fixCompleteness', 'residualRisk', 'confidence', 'tools', 'failures', 'evidence', 'timestamps'];
63
+ const REQUIRED = ['schema', 'schemaVersion', 'id', 'findingId', 'mode', 'route', 'patchHash', 'affectedFiles', ...LEG_NAMES, 'requiredLegs', 'coverage', 'degradedLegs', 'verifiedFull', 'fixCompleteness', 'residualRisk', 'confidence', 'tools', 'failures', 'evidence'];
64
+
65
+ /** Fields that determine identity. Timestamps, tool paths and evidence locations are deliberately absent. */
66
+ export const FIX_RECORD_ID_FIELDS = Object.freeze(['findingId', 'findingStableId', 'preCommit', 'preTreeHash', 'postPatchHash', 'patchHash', 'affectedFiles', ...LEG_NAMES, 'requiredLegs', 'verifiedFull', 'fixCompleteness']);
67
+
68
+ export const fixRecordId = (record) => semanticId('fvrec', record, FIX_RECORD_ID_FIELDS);
69
+
70
+ export const leg = (status, { evidenceIds = [], reason = null } = {}) => ({ status, evidenceIds, reason });
71
+ export const NOT_RUN = (reason) => leg('not_run', { reason });
72
+
73
+ /** Content-addressed evidence item. `sha256` is the digest of the bytes it stands for (a log, a scan result, a patch). */
74
+ export function evidenceItem({ kind, mediaType = 'text/plain', sha256, uri = null, note = null }) {
75
+ const body = { kind, mediaType, sha256 };
76
+ return { id: `ev:${digestOf(body).slice('sha256:'.length, 'sha256:'.length + 16)}`, ...body, ...(uri ? { uri } : {}), ...(note ? { note } : {}) };
77
+ }
78
+
79
+ /** Required legs that did not pass, as `name: status (reason)`. */
80
+ export function degradedLegsOf(record) {
81
+ return record.requiredLegs.filter((n) => record[n].status !== 'passed').map((n) => `${n}: ${record[n].status}${record[n].reason ? ` (${record[n].reason})` : ''}`);
82
+ }
83
+
84
+ /** True only when every required leg passed and no leg failed. Everything else is not a full verification. */
85
+ export function deriveVerifiedFull(record) {
86
+ if (LEG_NAMES.some((n) => record[n].status === 'failed')) return false;
87
+ if (record.failures.length) return false;
88
+ return record.requiredLegs.every((n) => record[n].status === 'passed');
89
+ }
90
+
91
+ /**
92
+ * The completeness tier of a verified patch.
93
+ * blocked a required leg is blocked, or a failure was recorded
94
+ * unverified the weakness is not shown to be gone
95
+ * FULL verifiedFull
96
+ * MITIGATION the weakness is gone but a required check did not run, or the exploit leg did not run
97
+ * WORKAROUND supplied by the caller when the route knows the change reduces exposure without removing the cause
98
+ */
99
+ export function deriveCompleteness(record, { workaround = false } = {}) {
100
+ if (record.failures.length || record.requiredLegs.some((n) => record[n].status === 'blocked')) return 'blocked';
101
+ if (record.securityAfter.status !== 'passed') return 'unverified';
102
+ if (LEG_NAMES.some((n) => record[n].status === 'failed')) return 'unverified';
103
+ if (workaround) return 'WORKAROUND';
104
+ return deriveVerifiedFull(record) ? 'FULL' : 'MITIGATION';
105
+ }
106
+
107
+ // Residual risk after a patch, from what was and was not established (also used by patch-policy.js).
108
+ export function deriveResidualRiskOf(record) {
109
+ const notes = [...record.degradedLegs];
110
+ const c = record.fixCompleteness;
111
+ let level;
112
+ if (c === 'FULL') level = record.exploitProofAfter.status === 'passed' ? 'low' : 'medium';
113
+ else if (c === 'MITIGATION') level = 'medium';
114
+ else if (c === 'WORKAROUND') level = 'high';
115
+ else level = 'unknown';
116
+ if (c === 'FULL' && record.exploitProofAfter.status !== 'passed') notes.push('no exploit replay confirmed the weakness is closed');
117
+ if (c === 'WORKAROUND') notes.push('exposure is reduced but the underlying cause remains');
118
+ return { level, notes };
119
+ }
120
+
121
+ export function buildFixVerificationRecord(f) {
122
+ const legs = {};
123
+ for (const n of LEG_NAMES) legs[n] = f[n] ? { status: f[n].status, evidenceIds: [...(f[n].evidenceIds || [])], reason: f[n].reason ?? null } : NOT_RUN('the route did not run this check');
124
+ const required = [...new Set(f.requiredLegs || [])].filter((n) => LEG_NAMES.includes(n)).sort((a, b) => LEG_NAMES.indexOf(a) - LEG_NAMES.indexOf(b));
125
+ const rec = {
126
+ schema: FIX_VERIFICATION_SCHEMA, schemaVersion: SCHEMA_VERSION,
127
+ findingId: f.findingId, ...(f.findingStableId ? { findingStableId: f.findingStableId } : {}), ...(f.hypothesisId ? { hypothesisId: f.hypothesisId } : {}),
128
+ mode: f.mode, route: f.route,
129
+ preCommit: f.preCommit ?? null, preTreeHash: f.preTreeHash ?? null, postPatchHash: f.postPatchHash ?? null, patchHash: f.patchHash,
130
+ affectedFiles: [...new Set(f.affectedFiles || [])].sort(),
131
+ ...legs,
132
+ requiredLegs: required,
133
+ failures: (f.failures || []).map((x) => ({ leg: x.leg, reason: String(x.reason).slice(0, 400) })),
134
+ evidence: [...(f.evidence || [])].sort((a, b) => (a.id < b.id ? -1 : 1)),
135
+ residualRisk: f.residualRisk || { level: 'unknown', notes: ['not assessed'] },
136
+ confidence: f.confidence || { level: 'unknown', policy: null, evidenceIds: [] },
137
+ tools: f.tools || { engineVersion: null, verifier: 'unspecified' },
138
+ };
139
+ rec.verifiedFull = deriveVerifiedFull(rec);
140
+ rec.degradedLegs = degradedLegsOf(rec);
141
+ rec.coverage = { required: required.length, passed: required.filter((n) => rec[n].status === 'passed').length, notRun: required.filter((n) => rec[n].status === 'not_run').length, blocked: required.filter((n) => rec[n].status === 'blocked').length, failed: required.filter((n) => rec[n].status === 'failed').length };
142
+ rec.fixCompleteness = f.fixCompleteness && f.fixCompleteness !== 'derive' ? f.fixCompleteness : deriveCompleteness(rec, { workaround: f.workaround === true });
143
+ if (f.timestamps) rec.timestamps = f.timestamps;
144
+ if (!f.residualRisk) rec.residualRisk = deriveResidualRiskOf(rec);
145
+ rec.id = fixRecordId(rec);
146
+ return rec;
147
+ }
148
+
149
+ export function validateFixVerificationRecord(record) {
150
+ const g = guardObject(record);
151
+ const ctx = g.ctx;
152
+ if (!g.ok) return result(ctx);
153
+ if (!checkHeader(ctx, record, FIX_VERIFICATION_SCHEMA)) return result(ctx);
154
+ checkFields(ctx, record, ALLOWED, REQUIRED);
155
+ checkString(ctx, 'findingId', record.findingId);
156
+ checkString(ctx, 'route', record.route);
157
+ checkEnum(ctx, 'mode', record.mode, MODES);
158
+ checkEnum(ctx, 'fixCompleteness', record.fixCompleteness, COMPLETENESS);
159
+ checkDigest(ctx, 'patchHash', record.patchHash);
160
+ checkDigest(ctx, 'postPatchHash', record.postPatchHash, { nullable: true });
161
+ checkDigest(ctx, 'preTreeHash', record.preTreeHash, { nullable: true });
162
+ checkCommit(ctx, 'preCommit', record.preCommit);
163
+ if (!Array.isArray(record.affectedFiles) || !record.affectedFiles.length || record.affectedFiles.some((x) => typeof x !== 'string' || !x || x.startsWith('/') || x.split('/').includes('..'))) ctx.err('BAD_TYPE', 'affectedFiles', 'must be a non-empty list of repository-relative paths');
164
+
165
+ const evidenceIds = new Set();
166
+ if (!Array.isArray(record.evidence)) ctx.err('BAD_TYPE', 'evidence', 'must be a list');
167
+ else record.evidence.forEach((e, i) => {
168
+ const p = `evidence[${i}]`;
169
+ if (!isPlainObject(e)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
170
+ for (const k of Object.keys(e)) if (!['id', 'kind', 'mediaType', 'sha256', 'uri', 'note'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'not part of an evidence item');
171
+ checkString(ctx, `${p}.kind`, e.kind);
172
+ if (typeof e.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(e.sha256)) ctx.err('BAD_DIGEST', `${p}.sha256`, 'must be 64 lowercase hex characters');
173
+ if (typeof e.id !== 'string' || !/^ev:[0-9a-f]{16}$/.test(e.id)) ctx.err('BAD_ID', `${p}.id`, 'must look like ev:<16 hex>');
174
+ else if (evidenceIds.has(e.id)) ctx.err('DUPLICATE_ID', `${p}.id`, `duplicate evidence id ${e.id}`);
175
+ else evidenceIds.add(e.id);
176
+ if (typeof e.uri === 'string' && (/^[a-z]+:\/\/[^/]*@/i.test(e.uri) || /[?&](token|key|secret|password)=/i.test(e.uri))) ctx.err('RULE_VIOLATION', `${p}.uri`, 'an evidence location must not embed credentials');
177
+ });
178
+
179
+ for (const n of LEG_NAMES) {
180
+ const l = record[n];
181
+ const p = n;
182
+ if (!isPlainObject(l)) { ctx.err('BAD_TYPE', p, 'must be a leg object'); continue; }
183
+ for (const k of Object.keys(l)) if (!['status', 'evidenceIds', 'reason'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'not part of a leg');
184
+ checkEnum(ctx, `${p}.status`, l.status, LEG_STATUSES);
185
+ if (!Array.isArray(l.evidenceIds)) ctx.err('BAD_TYPE', `${p}.evidenceIds`, 'must be a list');
186
+ else for (const id of l.evidenceIds) if (!evidenceIds.has(id)) ctx.err('DANGLING_REF', `${p}.evidenceIds`, `cites ${id}, which is not in evidence`);
187
+ if (['not_run', 'not_applicable', 'blocked', 'failed'].includes(l.status) && !(typeof l.reason === 'string' && l.reason.trim())) ctx.err('RULE_VIOLATION', `${p}.reason`, `a leg that is ${l.status} must say why`);
188
+ if (l.status === 'passed' && !l.evidenceIds?.length) ctx.err('RULE_VIOLATION', `${p}.evidenceIds`, 'a leg that passed must cite the evidence of the run');
189
+ }
190
+
191
+ if (!Array.isArray(record.requiredLegs) || record.requiredLegs.some((n) => !LEG_NAMES.includes(n))) ctx.err('BAD_TYPE', 'requiredLegs', 'must list leg names');
192
+ else {
193
+ if (new Set(record.requiredLegs).size !== record.requiredLegs.length) ctx.err('DUPLICATE_ID', 'requiredLegs', 'a leg is required once');
194
+ if (!record.requiredLegs.includes('securityAfter')) ctx.err('RULE_VIOLATION', 'requiredLegs', 'securityAfter is always required: a patch that does not show the weakness gone fixes nothing');
195
+ for (const n of record.requiredLegs) if (record[n]?.status === 'not_applicable') ctx.err('RULE_VIOLATION', n, 'a required leg cannot be not_applicable; drop it from requiredLegs with a reason on the leg instead');
196
+ }
197
+ if (!Array.isArray(record.failures)) ctx.err('BAD_TYPE', 'failures', 'must be a list');
198
+
199
+ if (record.requiredLegs && Array.isArray(record.failures) && LEG_NAMES.every((n) => isPlainObject(record[n]))) {
200
+ const expectFull = deriveVerifiedFull(record);
201
+ if (record.verifiedFull !== expectFull) ctx.err('RULE_VIOLATION', 'verifiedFull', `verifiedFull is ${record.verifiedFull} but the legs imply ${expectFull}`);
202
+ const expectDegraded = degradedLegsOf(record);
203
+ if (JSON.stringify(record.degradedLegs) !== JSON.stringify(expectDegraded)) ctx.err('RULE_VIOLATION', 'degradedLegs', 'does not list exactly the required legs that did not pass');
204
+ if (record.fixCompleteness === 'FULL' && !expectFull) ctx.err('RULE_VIOLATION', 'fixCompleteness', 'FULL requires verifiedFull');
205
+ if (record.fixCompleteness === 'FULL' && record.securityAfter.status !== 'passed') ctx.err('RULE_VIOLATION', 'fixCompleteness', 'FULL requires securityAfter to have passed');
206
+ if (['FULL', 'MITIGATION', 'WORKAROUND'].includes(record.fixCompleteness) && record.securityAfter.status !== 'passed') ctx.err('RULE_VIOLATION', 'fixCompleteness', `${record.fixCompleteness} requires securityAfter to have passed`);
207
+ if (record.fixCompleteness !== 'blocked' && record.requiredLegs.some((n) => record[n].status === 'blocked')) ctx.err('RULE_VIOLATION', 'fixCompleteness', 'a blocked required leg makes the completeness blocked');
208
+ if (!isPlainObject(record.coverage)) ctx.err('BAD_TYPE', 'coverage', 'must be an object');
209
+ }
210
+
211
+ const rr = record.residualRisk;
212
+ if (!isPlainObject(rr)) ctx.err('BAD_TYPE', 'residualRisk', 'must be an object');
213
+ else { checkEnum(ctx, 'residualRisk.level', rr.level, RESIDUAL_LEVELS); if (!Array.isArray(rr.notes)) ctx.err('BAD_TYPE', 'residualRisk.notes', 'must be a list'); }
214
+ const c = record.confidence;
215
+ if (!isPlainObject(c)) ctx.err('BAD_TYPE', 'confidence', 'must be an object');
216
+ else {
217
+ checkEnum(ctx, 'confidence.level', c.level, CONFIDENCE_LEVELS);
218
+ if (c.level === 'proven') {
219
+ if (!(typeof c.policy === 'string' && c.policy)) ctx.err('RULE_VIOLATION', 'confidence.policy', 'proven confidence needs a named proof policy');
220
+ if (!Array.isArray(c.evidenceIds) || !c.evidenceIds.length) ctx.err('RULE_VIOLATION', 'confidence.evidenceIds', 'proven confidence needs evidence');
221
+ else for (const id of c.evidenceIds) if (!evidenceIds.has(id)) ctx.err('DANGLING_REF', 'confidence.evidenceIds', `cites ${id}, which is not in evidence`);
222
+ if (record.exploitProofAfter?.status !== 'passed') ctx.err('RULE_VIOLATION', 'confidence.level', 'proven requires the exploit replay leg to have passed against the patched code');
223
+ }
224
+ }
225
+ if (!isPlainObject(record.tools)) ctx.err('BAD_TYPE', 'tools', 'must be an object');
226
+ if (typeof record.id === 'string' && LEG_NAMES.every((n) => isPlainObject(record[n])) && Array.isArray(record.affectedFiles) && Array.isArray(record.requiredLegs)) {
227
+ const want = fixRecordId(record);
228
+ if (record.id !== want) ctx.err('ID_MISMATCH', 'id', `id does not follow from the record's content (expected ${want})`);
229
+ }
230
+ return result(ctx);
231
+ }
232
+
233
+ /** The shape every pre-existing consumer reads: ok, verified, verifiedFull, degradedLegs. Additive fields are not included. */
234
+ export function legacyViewOf(record) {
235
+ const failedAny = LEG_NAMES.some((n) => record[n].status === 'failed') || record.failures.length > 0;
236
+ return {
237
+ ok: !failedAny && record.securityAfter.status === 'passed',
238
+ verified: !failedAny && record.securityAfter.status === 'passed',
239
+ verifiedFull: record.verifiedFull,
240
+ degradedLegs: [...record.degradedLegs],
241
+ completeness: record.fixCompleteness,
242
+ };
243
+ }