@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,200 @@
1
+ // Approvals, waivers and tenant isolation (E7-05).
2
+ //
3
+ // A WAIVER is a decision by named people to leave a finding (or a class of them) open for a stated time, for a stated reason, under a stated policy. Three
4
+ // things make it safe to honour and all three are enforced here, not left to convention:
5
+ // 1. It can never suppress silently. A waiver that is honoured puts the finding in `waived` with the waiver attached; one that is not honoured (expired, for
6
+ // another tenant or environment, signed under a superseded policy, short of approvers, badly signed) is listed in `refused` with the reason, and the finding
7
+ // stays open. `summary` counts both, so a gate can fail on an expired waiver instead of a person finding out later.
8
+ // 2. It is bound: to a tenant, an environment, a scope, an expiry and a policy version. None of those can be widened by editing another field.
9
+ // 3. It identifies people by opaque id only. An id that looks like an e-mail address is refused, justification text is scanned for secrets and personal data,
10
+ // and the audit log records ids and reason codes, never free text.
11
+ //
12
+ // The AUDIT LOG is append-only and hash-chained: each entry commits to the one before it, so removing or editing an entry is detected. Erasure of a tenant's
13
+ // evidence leaves a tombstone entry (who asked, when, which tenant), never the content, and is refused while a legal hold covers the tenant.
14
+ //
15
+ // TENANT ISOLATION. Evidence lives under one directory per tenant. A request names the tenant it acts for and the tenant whose evidence it wants; they must be
16
+ // the same, the names are validated so neither can walk out of the store, symlinks are refused, and every denial is written to the audit log.
17
+ import * as crypto from 'node:crypto';
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { canonicalJson, keyFingerprint } from '../evidence-bundle.js';
21
+ import { secretCanaryHit, canaryHit } from '../runtime-trace-ingest.js';
22
+ import { isUnderHold } from '../legal-hold.js';
23
+ import { digestOf } from './identity.js';
24
+ import { SCHEMA_VERSION, checkFields, checkHeader, checkString, guardObject, isPlainObject, makeCtx, result } from './schema-kit.js';
25
+
26
+ const WAIVER_SCHEMA = 'agentic-security/waiver';
27
+ const SCOPE_KINDS = Object.freeze(['finding', 'rule', 'path']);
28
+ const ALLOWED = ['schema', 'schemaVersion', 'id', 'tenant', 'environment', 'scope', 'actor', 'approvals', 'justification', 'compensatingControl', 'createdAt', 'expiresAt', 'policyVersion', 'signature'];
29
+ const REQUIRED = ['schema', 'schemaVersion', 'id', 'tenant', 'environment', 'scope', 'actor', 'approvals', 'justification', 'createdAt', 'expiresAt', 'policyVersion'];
30
+ const ID = /^[a-z0-9][a-z0-9._:-]{1,62}$/i;
31
+ const ACTOR_ID = /^[a-z0-9][a-z0-9._:-]{2,63}$/i;
32
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
33
+ export const DEFAULT_POLICY = Object.freeze({ version: 1, minPolicyVersion: 1, maxLifetimeDays: 90, requiredApprovals: 1, requireSignature: false, minJustificationChars: 20, requireCompensatingControl: true });
34
+
35
+ export const waiverId = (w) => `wvr:${digestOf({ tenant: w.tenant, environment: w.environment, scope: w.scope, actor: w.actor && w.actor.id, createdAt: w.createdAt, expiresAt: w.expiresAt, policyVersion: w.policyVersion }).slice(7, 7 + 16)}`;
36
+
37
+ function actorOk(ctx, p, a) {
38
+ if (!isPlainObject(a)) { ctx.err('BAD_TYPE', p, 'must be an object {id, role}'); return; }
39
+ for (const k of Object.keys(a)) if (!['id', 'role'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'only an opaque id and a role are recorded for a person; no name or contact detail');
40
+ if (typeof a.id !== 'string' || !ACTOR_ID.test(a.id)) ctx.err('BAD_ID', `${p}.id`, 'must be an opaque identifier (letters, digits, . _ : -), 3 to 64 characters');
41
+ else if (a.id.includes('@')) ctx.err('BAD_ID', `${p}.id`, 'an e-mail address is personal data and is not accepted as an identifier; use an opaque id');
42
+ checkString(ctx, `${p}.role`, a.role);
43
+ }
44
+
45
+ /** Closed-world validation of one waiver. Never throws. */
46
+ export function validateWaiver(w, policy = DEFAULT_POLICY) {
47
+ const g = guardObject(w); const ctx = g.ctx;
48
+ if (!g.ok) return result(ctx);
49
+ if (!checkHeader(ctx, w, WAIVER_SCHEMA)) return result(ctx);
50
+ checkFields(ctx, w, ALLOWED, REQUIRED);
51
+ for (const k of ['tenant', 'environment']) if (typeof w[k] !== 'string' || !ID.test(w[k])) ctx.err('BAD_ID', k, 'must be a plain identifier (letters, digits, . _ : -)');
52
+ if (!isPlainObject(w.scope) || !SCOPE_KINDS.includes(w.scope.kind) || typeof w.scope.value !== 'string' || !w.scope.value) ctx.err('BAD_TYPE', 'scope', `must be {kind: ${SCOPE_KINDS.join('|')}, value}`);
53
+ else if (w.scope.kind === 'path' && (w.scope.value === '**' || w.scope.value === '*')) ctx.err('RULE_VIOLATION', 'scope.value', 'a waiver for every path is not a scoped waiver');
54
+ actorOk(ctx, 'actor', w.actor);
55
+ if (!Array.isArray(w.approvals)) ctx.err('BAD_TYPE', 'approvals', 'must be a list');
56
+ else {
57
+ const seen = new Set();
58
+ w.approvals.forEach((a, i) => { actorOk(ctx, `approvals[${i}]`, a); if (a && a.id) { if (a.id === (w.actor && w.actor.id)) ctx.err('RULE_VIOLATION', `approvals[${i}]`, 'a person cannot approve their own waiver'); if (seen.has(a.id)) ctx.err('DUPLICATE_ID', `approvals[${i}]`, 'the same approver twice counts once'); seen.add(a.id); } });
59
+ if (seen.size < policy.requiredApprovals) ctx.err('RULE_VIOLATION', 'approvals', `${policy.requiredApprovals} distinct approver(s) required, ${seen.size} given`);
60
+ }
61
+ if (typeof w.justification !== 'string' || w.justification.trim().length < policy.minJustificationChars) ctx.err('RULE_VIOLATION', 'justification', `a justification of at least ${policy.minJustificationChars} characters is required`);
62
+ else { const hit = secretCanaryHit(w.justification) || canaryHit(w.justification); if (hit) ctx.err('RULE_VIOLATION', 'justification', `the justification contains something that looks like ${hit.replace(/-/g, ' ')}; waivers are stored and shown, so it is refused`); }
63
+ if (policy.requireCompensatingControl && (typeof w.compensatingControl !== 'string' || w.compensatingControl.trim().length < 10)) ctx.err('RULE_VIOLATION', 'compensatingControl', 'a compensating control is required: a reason to wait is not one');
64
+ for (const k of ['createdAt', 'expiresAt']) if (typeof w[k] !== 'string' || !ISO.test(w[k]) || !Number.isFinite(Date.parse(w[k]))) ctx.err('BAD_TYPE', k, 'must be a UTC ISO timestamp');
65
+ if (ISO.test(w.createdAt || '') && ISO.test(w.expiresAt || '')) {
66
+ const life = (Date.parse(w.expiresAt) - Date.parse(w.createdAt)) / 86400000;
67
+ if (life <= 0) ctx.err('RULE_VIOLATION', 'expiresAt', 'must be after createdAt');
68
+ if (life > policy.maxLifetimeDays) ctx.err('RULE_VIOLATION', 'expiresAt', `a waiver may last at most ${policy.maxLifetimeDays} days`);
69
+ }
70
+ if (!Number.isInteger(w.policyVersion) || w.policyVersion < 1) ctx.err('BAD_TYPE', 'policyVersion', 'must be a positive integer');
71
+ if (w.id && w.id !== waiverId(w)) ctx.err('ID_MISMATCH', 'id', 'the id does not match the waiver it names');
72
+ return result(ctx);
73
+ }
74
+
75
+ export function buildWaiver(f) {
76
+ const w = { schema: WAIVER_SCHEMA, schemaVersion: SCHEMA_VERSION, tenant: f.tenant, environment: f.environment, scope: f.scope, actor: f.actor, approvals: f.approvals || [], justification: f.justification, ...(f.compensatingControl ? { compensatingControl: f.compensatingControl } : {}), createdAt: f.createdAt, expiresAt: f.expiresAt, policyVersion: f.policyVersion };
77
+ w.id = waiverId(w);
78
+ return w;
79
+ }
80
+
81
+ const bytesOf = (w) => Buffer.from(canonicalJson((({ signature, ...rest }) => rest)(w)), 'utf8');
82
+ export function signWaiver(w, privateKeyPem) {
83
+ const pub = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
84
+ return { ...w, signature: { algorithm: 'ed25519', keyFingerprint: keyFingerprint(pub), value: crypto.sign(null, bytesOf(w), privateKeyPem).toString('base64') } };
85
+ }
86
+ function signatureState(w, trustedKeys) {
87
+ if (!w.signature) return 'unsigned';
88
+ const pem = trustedKeys && trustedKeys[w.signature.keyFingerprint];
89
+ if (!pem) return 'untrusted-key';
90
+ try { return crypto.verify(null, bytesOf(w), pem, Buffer.from(w.signature.value, 'base64')) ? 'valid' : 'invalid'; } catch { return 'invalid'; }
91
+ }
92
+
93
+ // ---- evaluation ----------------------------------------------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Can this waiver be honoured here and now?
97
+ * @param {{now:number, tenant:string, environment:string, policy?:object, trustedKeys?:Object<string,string>}} ctx
98
+ * @returns {{honoured:boolean, reasons:string[]}} `reasons` are stable codes: invalid, expired, tenant-mismatch, environment-mismatch, policy-version-superseded, unsigned, untrusted-key, bad-signature
99
+ */
100
+ export function evaluateWaiver(w, ctx) {
101
+ const policy = { ...DEFAULT_POLICY, ...(ctx.policy || {}) };
102
+ const reasons = [];
103
+ // judged against the policy in force NOW: a waiver made under looser rules (a longer lifetime, fewer approvers) stops being honoured when the rules tighten
104
+ const v = validateWaiver(w, policy);
105
+ if (!v.ok) return { honoured: false, reasons: ['invalid'], errors: v.errors.map((e) => `${e.path}: ${e.message}`) };
106
+ if (Date.parse(w.expiresAt) <= ctx.now) reasons.push('expired');
107
+ if (Date.parse(w.createdAt) > ctx.now) reasons.push('not-yet-valid');
108
+ if (w.tenant !== ctx.tenant) reasons.push('tenant-mismatch');
109
+ if (w.environment !== ctx.environment) reasons.push('environment-mismatch');
110
+ if (w.policyVersion < policy.minPolicyVersion) reasons.push('policy-version-superseded');
111
+ const sig = signatureState(w, ctx.trustedKeys);
112
+ if (sig === 'invalid') reasons.push('bad-signature'); else if (sig === 'untrusted-key') reasons.push('untrusted-key'); else if (sig === 'unsigned' && policy.requireSignature) reasons.push('unsigned');
113
+ return { honoured: reasons.length === 0, reasons };
114
+ }
115
+
116
+ const globToRe = (g) => new RegExp('^' + g.split('**').map((p) => p.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*') + '$');
117
+ function matches(w, f) {
118
+ if (w.scope.kind === 'finding') return w.scope.value === f.stableId || w.scope.value === f.id;
119
+ if (w.scope.kind === 'rule') return w.scope.value === f.vuln || w.scope.value === f.family || w.scope.value === f.cwe || w.scope.value === f.id;
120
+ return typeof f.file === 'string' && globToRe(w.scope.value).test(f.file);
121
+ }
122
+
123
+ /**
124
+ * Apply waivers to findings. A finding is removed from `open` only by a waiver that is honoured; everything else is reported.
125
+ * @returns {{open:object[], waived:{finding:object, waiverId:string}[], refused:{waiverId:string, findingId:string, reasons:string[]}[], summary:object}}
126
+ */
127
+ export function applyWaivers(findings, waivers, ctx, auditLog = null) {
128
+ const open = []; const waived = []; const refused = [];
129
+ const evals = new Map((waivers || []).map((w) => [w.id, evaluateWaiver(w, ctx)]));
130
+ for (const f of findings || []) {
131
+ const cand = (waivers || []).filter((w) => isPlainObject(w) && isPlainObject(w.scope) && typeof w.scope.value === 'string' && matches(w, f));
132
+ const ok = cand.find((w) => evals.get(w.id).honoured);
133
+ if (ok) { waived.push({ finding: f, waiverId: ok.id }); if (auditLog) auditLog.append({ event: 'waiver.applied', tenant: ctx.tenant, waiver: ok.id, finding: f.stableId || f.id }); continue; }
134
+ open.push(f);
135
+ for (const w of cand) { const e = evals.get(w.id); refused.push({ waiverId: w.id, findingId: f.stableId || f.id, reasons: e.reasons }); if (auditLog) auditLog.append({ event: 'waiver.refused', tenant: ctx.tenant, waiver: w.id, finding: f.stableId || f.id, reasons: e.reasons }); }
136
+ }
137
+ const expired = [...new Set(refused.filter((r) => r.reasons.includes('expired')).map((r) => r.waiverId))];
138
+ const unusedExpired = (waivers || []).filter((w) => evals.get(w.id) && evals.get(w.id).reasons.includes('expired') && !expired.includes(w.id)).map((w) => w.id);
139
+ return { open, waived, refused, summary: { findings: (findings || []).length, open: open.length, waived: waived.length, refusedMatches: refused.length, expiredWaivers: [...expired, ...unusedExpired].length, reopenedByExpiry: refused.filter((r) => r.reasons.includes('expired')).length } };
140
+ }
141
+
142
+ // ---- audit log -----------------------------------------------------------------------------------------------------------------
143
+
144
+ const AUDIT_FIELDS = new Set(['event', 'tenant', 'actor', 'waiver', 'finding', 'reasons', 'target', 'name', 'code']);
145
+ export function createAuditLog({ file = null, now = () => Date.now() } = {}) {
146
+ const entries = []; let prev = 'sha256:' + '0'.repeat(64);
147
+ const log = {
148
+ entries,
149
+ append(e) {
150
+ const body = {};
151
+ for (const [k, v] of Object.entries(e)) {
152
+ if (!AUDIT_FIELDS.has(k)) throw new Error(`the audit log does not record "${k}": ids and reason codes only`);
153
+ if (typeof v === 'string' && (canaryHit(v) || secretCanaryHit(v))) throw new Error(`the audit log refuses a value that looks like personal data or a secret (${k})`);
154
+ body[k] = v;
155
+ }
156
+ const entry = { seq: entries.length + 1, at: new Date(now()).toISOString(), ...body, prev };
157
+ entry.hash = digestOf(entry); prev = entry.hash; entries.push(entry);
158
+ if (file) fs.appendFileSync(file, JSON.stringify(entry) + '\n');
159
+ return entry;
160
+ },
161
+ };
162
+ return log;
163
+ }
164
+
165
+ /** Re-derive the chain. A removed, edited or reordered entry breaks it. @returns {{ok:boolean, brokenAt:number|null}} */
166
+ export function verifyAuditLog(entries) {
167
+ let prev = 'sha256:' + '0'.repeat(64);
168
+ for (const [i, e] of (entries || []).entries()) {
169
+ const { hash, ...rest } = e;
170
+ if (e.seq !== i + 1 || e.prev !== prev || hash !== digestOf(rest)) return { ok: false, brokenAt: i + 1 };
171
+ prev = hash;
172
+ }
173
+ return { ok: true, brokenAt: null };
174
+ }
175
+
176
+ // ---- tenant-isolated evidence store -------------------------------------------------------------------------------------------
177
+
178
+ export class AccessDenied extends Error { constructor(m) { super(m); this.code = 'ACCESS_DENIED'; } }
179
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
180
+
181
+ export function createTenantStore(root, { auditLog, holds = () => [], now = () => Date.now() } = {}) {
182
+ const dirOf = (tenant) => { if (typeof tenant !== 'string' || !ID.test(tenant)) throw new AccessDenied('invalid tenant identifier'); return path.join(root, 'tenants', tenant); };
183
+ const deny = (requester, target, why) => { if (auditLog) auditLog.append({ event: 'access.denied', tenant: typeof requester === 'string' ? requester.slice(0, 64) : 'invalid', target: typeof target === 'string' ? target.slice(0, 64) : 'invalid', code: why }); throw new AccessDenied(`access denied: ${why}`); };
184
+ const guard = (requester, tenant) => { if (typeof requester !== 'string' || !ID.test(requester)) deny(requester, tenant, 'invalid-requester'); if (requester !== tenant) deny(requester, tenant, 'other-tenant'); };
185
+ const fileOf = (tenant, name, requester) => { if (typeof name !== 'string' || !NAME.test(name) || name.includes('..')) deny(requester, tenant, 'invalid-name'); return path.join(dirOf(tenant), name); };
186
+ return {
187
+ put(requester, tenant, name, bytes) { guard(requester, tenant); const f = fileOf(tenant, name, requester); fs.mkdirSync(dirOf(tenant), { recursive: true }); try { if (fs.lstatSync(f).isSymbolicLink()) deny(requester, tenant, 'symlink'); } catch (e) { if (e instanceof AccessDenied) throw e; } fs.writeFileSync(f, bytes); if (auditLog) auditLog.append({ event: 'evidence.put', tenant, name }); },
188
+ get(requester, tenant, name) { guard(requester, tenant); const f = fileOf(tenant, name, requester); let st; try { st = fs.lstatSync(f); } catch { return null; } if (st.isSymbolicLink()) deny(requester, tenant, 'symlink'); if (auditLog) auditLog.append({ event: 'evidence.get', tenant, name }); return fs.readFileSync(f); },
189
+ list(requester, tenant) { guard(requester, tenant); try { return fs.readdirSync(dirOf(tenant)).sort(); } catch { return []; } },
190
+ /** Erase a tenant's evidence on request. Refused while a legal hold covers the tenant. Leaves a tombstone, not the content. */
191
+ erase(requester, tenant, { reason } = {}) {
192
+ guard(requester, tenant);
193
+ if (isUnderHold(`tenant:${tenant}`, holds(), now())) { if (auditLog) auditLog.append({ event: 'erasure.refused', tenant, code: 'legal-hold' }); return { erased: false, reason: 'legal-hold' }; }
194
+ let n = 0; try { n = fs.readdirSync(dirOf(tenant)).length; } catch { /* nothing stored */ }
195
+ fs.rmSync(dirOf(tenant), { recursive: true, force: true });
196
+ if (auditLog) auditLog.append({ event: 'erasure.done', tenant, code: typeof reason === 'string' && /^[a-z-]{3,32}$/.test(reason) ? reason : 'requested' });
197
+ return { erased: true, files: n };
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,100 @@
1
+ // The compliance truth model (E7-04): what can honestly be said about each control of a framework, in six states, with the denominators beside them.
2
+ //
3
+ // The framework evaluators (`auditor-walkthrough.js#evaluateFramework` and the privacy one) already refuse to call an unexamined bucket evidence, and the OSCAL
4
+ // export already refuses to invent a verdict for a control nobody decided. This module states the rules once, for any evaluator's rows, so that a report cannot
5
+ // say more than the evidence allows and a reader can see how much of the framework was actually looked at.
6
+ //
7
+ // assessed_satisfied every automated signal the control needs ran, cleared, on a complete scan, and the control is one the scanner can decide alone
8
+ // assessed_not_satisfied at least one signal found an open problem the finding list attributes to this control. A failure is conclusive even when the rest of
9
+ // the scan was incomplete
10
+ // not_assessed nothing looked: the control is rated checkable by its publisher but this tool has no check for it, or the evaluator reported a status
11
+ // this module does not recognise
12
+ // not_applicable someone with the authority declared it so, with a reason; the tool never infers this
13
+ // requires_human_review the control needs a person: no automated mapping and not code-testable, or the automated signals cleared but the control also needs
14
+ // organisational evidence, or a single scanner signal stands for an obligation wider than one check
15
+ // insufficient_evidence signals exist but cannot carry a verdict: the scan was incomplete or examined nothing, a mapping is unverifiable, or the evidence is weak
16
+ //
17
+ // NEVER claimed: that the system complies with the framework. `frameworkClaim.established` is false unless every applicable control is assessed_satisfied, and
18
+ // even then the statement is about the automated signals, not about compliance. One SAST family clearing never satisfies a control the publisher rates only
19
+ // partly or not at all checkable by code.
20
+ export const TRUTH_STATES = Object.freeze(['assessed_satisfied', 'assessed_not_satisfied', 'not_assessed', 'not_applicable', 'requires_human_review', 'insufficient_evidence']);
21
+
22
+ /**
23
+ * @param {{control:object, status:string, observations?:string[], controlRefs?:any[], evidence?:{tier?:string}, partiallyEvidenced?:boolean}} row one row of an evaluator
24
+ * @param {{scanStatus?:string, scanExaminedFiles?:boolean, notApplicable?:Object<string,{reason:string, declaredBy:string}>}} ctx
25
+ * @returns {{state:string, reasons:string[]}}
26
+ */
27
+ export function classifyControl(row, ctx = {}) {
28
+ const c = (row && row.control) || {};
29
+ const id = c.id;
30
+ const reasons = [];
31
+ const na = ctx.notApplicable && id !== undefined ? ctx.notApplicable[id] : null;
32
+ if (na) {
33
+ if (!na.reason || !na.declaredBy) return { state: 'insufficient_evidence', reasons: ['a not-applicable declaration needs a reason and a person who declared it; this one has neither, so it is not honoured'] };
34
+ return { state: 'not_applicable', reasons: [`declared not applicable by ${na.declaredBy}: ${na.reason}`] };
35
+ }
36
+ const testable = c.codeTestable; // 'yes' | 'partial' | 'no' | undefined
37
+ const status = row && row.status;
38
+ const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
39
+ const scanComplete = ctx.scanStatus === 'complete';
40
+
41
+ if (status === 'manual') {
42
+ if (testable === 'yes' || testable === 'partial') return { state: 'not_assessed', reasons: ['the publisher rates this control as checkable by code (at least in part) but this tool implements no check for it: a gap in the tool, not a finding about the system'] };
43
+ return { state: 'requires_human_review', reasons: ['no automated mapping, and the control is not code-testable: it needs a person'] };
44
+ }
45
+ if (!['present', 'partial', 'absent'].includes(status)) return { state: 'not_assessed', reasons: [`the evaluator reported status ${JSON.stringify(status)}, which this model does not recognise, so no verdict is made`] };
46
+
47
+ const attributable = Array.isArray(row.controlRefs) && row.controlRefs.length > 0;
48
+ if (status === 'absent' || (status === 'partial' && attributable)) {
49
+ return { state: 'assessed_not_satisfied', reasons: [attributable ? `${row.controlRefs.length} open finding(s) are attributed to this control` : 'signals exist for this control and none cleared'] };
50
+ }
51
+ if (status === 'partial') return { state: 'insufficient_evidence', reasons: ['no open finding is attributed to this control, but a mapped signal could not be verified, so a clean result is not evidence'] };
52
+
53
+ // status === 'present': every mapped signal cleared. What else has to be true before that is a verdict?
54
+ if (ctx.scanExaminedFiles === false) return { state: 'insufficient_evidence', reasons: ['the scan examined no files, so an empty finding list is not evidence'] };
55
+ if (!scanComplete) return { state: 'insufficient_evidence', reasons: [`the scan was ${JSON.stringify(ctx.scanStatus ?? 'unknown')}, not complete: a signal that cleared may not have looked everywhere`] };
56
+ if (row.partiallyEvidenced || (row.evidence && ['weak', 'unmeasured'].includes(row.evidence.tier))) return { state: 'insufficient_evidence', reasons: [`the evidence behind the signals is rated ${JSON.stringify(row.evidence && row.evidence.tier)}`] };
57
+ if (testable === 'no') return { state: 'requires_human_review', reasons: ['the automated signals cleared, but the publisher rates this control as not code-testable: they cannot decide it'] };
58
+ if (testable === 'partial') return { state: 'requires_human_review', reasons: ['the automated signals cleared, but the publisher rates this control as only partly code-testable: the rest needs organisational evidence'] };
59
+ if (maps.length === 1 && testable !== 'yes') return { state: 'requires_human_review', reasons: ['a single scanner signal stands for this obligation and the control is not rated fully code-testable'] };
60
+ reasons.push(`${Math.max(maps.length, 1)} automated signal(s) cleared on a complete scan, and the control is rated code-testable`);
61
+ return { state: 'assessed_satisfied', reasons };
62
+ }
63
+
64
+ /**
65
+ * Classify every control of a framework and state the denominators.
66
+ * @returns {{frameworkId:string|null, controls:object[], denominators:object, frameworkClaim:object}}
67
+ */
68
+ export function assessFrameworkTruth(framework, rows, ctx = {}) {
69
+ const controls = (rows || []).map((r) => {
70
+ const t = classifyControl(r, ctx);
71
+ return { id: r && r.control ? r.control.id : null, title: r && r.control ? (r.control.summary || r.control.title || '') : '', state: t.state, reasons: t.reasons, codeTestable: r && r.control ? r.control.codeTestable ?? null : null, evaluatorStatus: r ? r.status : null, observations: (r && r.observations) || [] };
72
+ });
73
+ const by = Object.fromEntries(TRUTH_STATES.map((s) => [s, controls.filter((c) => c.state === s).length]));
74
+ const total = controls.length;
75
+ const applicable = total - by.not_applicable;
76
+ const assessed = by.assessed_satisfied + by.assessed_not_satisfied;
77
+ const declared = framework && Number.isInteger(framework.controlCount) ? framework.controlCount : null;
78
+ const denominators = {
79
+ declaredControls: declared, evaluatedControls: total, unevaluatedControls: declared === null ? null : Math.max(0, declared - total),
80
+ applicable, byState: by,
81
+ assessed, assessedOfApplicable: applicable === 0 ? null : Number((assessed / applicable).toFixed(4)),
82
+ satisfied: by.assessed_satisfied, satisfiedOfApplicable: applicable === 0 ? null : Number((by.assessed_satisfied / applicable).toFixed(4)),
83
+ needAPersonOrMoreEvidence: by.requires_human_review + by.insufficient_evidence + by.not_assessed,
84
+ };
85
+ const everyApplicableSatisfied = applicable > 0 && by.assessed_satisfied === applicable;
86
+ const frameworkClaim = {
87
+ established: false,
88
+ statement: everyApplicableSatisfied
89
+ ? 'Every applicable control this tool can decide was satisfied by its automated signals. That is a statement about those signals; it is not a statement that the system complies with the framework.'
90
+ : `This tool assessed ${assessed} of ${applicable} applicable controls from automated signals (${by.assessed_satisfied} satisfied, ${by.assessed_not_satisfied} not satisfied); ${denominators.needAPersonOrMoreEvidence} need a person or more evidence. It does not establish compliance with the framework.`,
91
+ reasons: [
92
+ ...(by.assessed_not_satisfied ? [`${by.assessed_not_satisfied} control(s) are not satisfied`] : []),
93
+ ...(by.requires_human_review ? [`${by.requires_human_review} control(s) require human review`] : []),
94
+ ...(by.insufficient_evidence ? [`${by.insufficient_evidence} control(s) have insufficient evidence`] : []),
95
+ ...(by.not_assessed ? [`${by.not_assessed} control(s) were not assessed`] : []),
96
+ ...(denominators.unevaluatedControls ? [`${denominators.unevaluatedControls} control(s) of the framework were not evaluated at all`] : []),
97
+ ],
98
+ };
99
+ return { frameworkId: framework && framework.id ? framework.id : null, controls, denominators, frameworkClaim };
100
+ }
@@ -71,7 +71,9 @@ async function main() {
71
71
  }));
72
72
  const result = { findings };
73
73
  if (mode === 'diagnose') result.diagnostics = await diagnosticsOf(scan);
74
- process.stdout.write(`${RESULT_BEGIN}${JSON.stringify(result)}${RESULT_END}\n`);
74
+ // The parent reads this through a pipe. A pipe holds about 64 KB, and `process.exit()` right after a larger write drops what has not been read, so the
75
+ // parent saw "no result block" for any scan with a big diagnostics payload (62 of 272 forensics re-scans). Wait until the write has been handed over.
76
+ await new Promise((resolve, reject) => process.stdout.write(`${RESULT_BEGIN}${JSON.stringify(result)}${RESULT_END}\n`, (err) => (err ? reject(err) : resolve())));
75
77
  }
76
78
 
77
79
  // Only when run as a script: runner.js imports the markers and must not start a scan.
@@ -103,7 +103,9 @@ export function classifyMiss({ label, outcome, diagnostics, policy, layer = 'dee
103
103
 
104
104
  // ---- a candidate was DROPPED with a recorded reason: filters or suppression
105
105
  const dropped = (diagnostics.suppressions || [])
106
- .filter((s) => here(s) && inWindow(s.line, win) && sameKind(s, label))
106
+ // A drop that records neither a family nor a CWE says only that SOMETHING near the defect was dropped. Blaming the miss on it is a coincidence of location
107
+ // (a too-short secret candidate beside an authorization bug), so it is not a cause; it is counted in the evidence of whatever stage does explain the miss.
108
+ .filter((s) => here(s) && inWindow(s.line, win) && sameKind(s, label) && sameKind(s, label) !== 'location-only')
107
109
  .sort((a, b) => String(a.reason).localeCompare(String(b.reason)) || (a.line ?? 0) - (b.line ?? 0));
108
110
  if (dropped.length) {
109
111
  const d = dropped[0];
@@ -0,0 +1,141 @@
1
+ // Multi-level exploitability evidence (E5-05).
2
+ //
3
+ // "Is this exploitable?" is several independent questions, each answered from different evidence, and each can be unanswered. Collapsing them into
4
+ // one number hides which are known. This module states them separately:
5
+ //
6
+ // presentInDependency the vulnerable code arrives through a dependency
7
+ // includedInArtifact that code is in the built artifact that ships
8
+ // staticallyReachable the analysis found a path to it
9
+ // runtimeObserved a matched-build trace saw the path or the function used
10
+ // externallyReachable the path starts at something an outside party can call
11
+ // proofOfExecution a proof of concept RAN and showed the effect
12
+ // blockedByCompensatingControl a cited control stops it
13
+ //
14
+ // Each is { value, basis, source }. value is one of `yes`, `no`, `unknown`, `not_applicable`, plus two that are deliberately NOT `no`:
15
+ // not_observed runtimeObserved only: nothing was seen in the window. Says nothing about whether the code is reachable or safe.
16
+ // not_demonstrated proofOfExecution only: a proof ran and did not show the effect. A triage signal, not a verdict.
17
+ // `unknown` is a value like any other and is listed in `unknownLevels`. Nothing is inferred from another level.
18
+ //
19
+ // PRIORITIZATION. Context may move a finding's PRIORITY (never its severity, never its existence) under a pre-registered policy:
20
+ // * the policy is frozen data with a digest over its rules; a policy whose digest does not match is refused and nothing moves
21
+ // * every rule names the levels it needs and the adjustment it makes, and every adjustment records the evidence it cites
22
+ // * a rule that LOWERS priority may only rest on positive evidence (`no` for static reachability from an analysis that ran, or `yes` for a cited
23
+ // control); a rule that lowers on `not_observed`, `unknown` or `not_demonstrated` is invalid and rejected when the policy is validated
24
+ // * raising rests on `yes` levels
25
+ // The finding is returned with an `exploitabilityEvidence` block added and every other field untouched.
26
+ import { createHash } from 'node:crypto';
27
+ import { proofTierOf } from './proof-tier.js';
28
+
29
+ export const LEVELS = Object.freeze(['presentInDependency', 'includedInArtifact', 'staticallyReachable', 'runtimeObserved', 'externallyReachable', 'proofOfExecution', 'blockedByCompensatingControl']);
30
+ export const LEVEL_VALUES = Object.freeze(['yes', 'no', 'unknown', 'not_applicable', 'not_observed', 'not_demonstrated']);
31
+ const lv = (value, basis, source) => ({ value, basis, source });
32
+ const UNKNOWN = (basis) => lv('unknown', basis, 'none');
33
+
34
+ /**
35
+ * @param {object} finding
36
+ * @param {{artifact?:{included?:boolean|null, basis?:string}, controlsEvaluated?:boolean}} ctx evidence the caller has that the finding does not carry
37
+ */
38
+ export function buildEvidenceLevels(finding, ctx = {}) {
39
+ const f = finding || {};
40
+ const isDep = f.type === 'vulnerable_dep' || (!!f.package && !!f.reachabilityTier);
41
+ const levels = {};
42
+ levels.presentInDependency = isDep ? lv('yes', `finding is a vulnerable dependency (${f.package || 'package'})`, 'sca') : lv('not_applicable', 'the finding is in first-party code', 'finding');
43
+
44
+ const art = ctx.artifact;
45
+ levels.includedInArtifact = art && typeof art.included === 'boolean' ? lv(art.included ? 'yes' : 'no', art.basis || 'artifact analysis', 'artifact') : UNKNOWN('no artifact analysis was supplied');
46
+
47
+ const tier = f.reachabilityTier;
48
+ if (isDep && (tier === 'route-reachable-via-function' || tier === 'function-reachable')) levels.staticallyReachable = lv('yes', `function-level reachability: ${tier}`, 'sca-reachability');
49
+ else if (isDep && tier === 'unreachable') levels.staticallyReachable = lv('no', 'function-level analysis found no call to the vulnerable function', 'sca-reachability');
50
+ else if (!isDep && f.unreachable === true) levels.staticallyReachable = lv('no', 'the finding was demoted as unreachable by the reachability pass', 'reachability');
51
+ else if (!isDep && (f.parser === 'IR-TAINT' || f.routeRooted === true)) levels.staticallyReachable = lv('yes', f.routeRooted ? 'rooted in a route handler' : 'a taint path from a source reaches the sink', 'dataflow');
52
+ else levels.staticallyReachable = UNKNOWN(isDep ? `reachability tier "${tier || 'unrecorded'}" does not settle it` : 'no path analysis backs this finding');
53
+
54
+ const rt = f.runtimeEvidence;
55
+ if (rt && rt.state === 'observed') levels.runtimeObserved = lv('yes', rt.corroboration === 'corroborated' ? 'observed by a build matched to this snapshot' : 'observed, but the build is not tied to this snapshot', 'runtime');
56
+ else if (rt && rt.state === 'not_observed_in_window') levels.runtimeObserved = lv('not_observed', 'nothing on the path appeared in the window. This does not mean the code is unreachable or safe', 'runtime');
57
+ else levels.runtimeObserved = UNKNOWN(rt ? rt.basis || 'the trace cannot say' : 'no runtime trace');
58
+ if (levels.runtimeObserved.value === 'yes') levels.runtimeObserved.corroboration = rt.corroboration === 'corroborated' ? 'corroborated' : 'unattested';
59
+
60
+ if (f.routeRooted === true || (f._inRoute && f._inRoute.path)) levels.externallyReachable = lv('yes', `rooted in a route handler${f._inRoute && f._inRoute.path ? ` (${f._inRoute.path})` : ''}. Whether the route is exposed beyond the service is not verified here`, 'route-rooting');
61
+ else if (isDep && tier === 'route-reachable-via-function') levels.externallyReachable = lv('yes', 'the vulnerable function is called from a route handler', 'sca-reachability');
62
+ else levels.externallyReachable = UNKNOWN('no route handler roots this path');
63
+
64
+ const pt = proofTierOf(f);
65
+ levels.proofOfExecution = pt === 'execution-proven' ? lv('yes', 'a proof of concept ran in the sandbox and produced the predicted effect', 'execution-proof')
66
+ : pt === 'proof-failed' ? lv('not_demonstrated', 'a proof ran and did not show the effect. Absence of proof is not proof of absence', 'execution-proof') : UNKNOWN('no proof of concept has run');
67
+
68
+ if (f.sanitized === true) levels.blockedByCompensatingControl = lv('yes', 'a sanitizer applicable to this vulnerability family sits on the path', 'dataflow');
69
+ else if (f.compensatingControl && typeof f.compensatingControl === 'string') levels.blockedByCompensatingControl = lv('yes', `declared control: ${f.compensatingControl}`, 'suppression');
70
+ else if (ctx.controlsEvaluated === true) levels.blockedByCompensatingControl = lv('no', 'controls were evaluated and none applies', 'controls');
71
+ else levels.blockedByCompensatingControl = UNKNOWN('compensating controls were not evaluated');
72
+
73
+ return { levels, unknownLevels: LEVELS.filter((k) => levels[k].value === 'unknown') };
74
+ }
75
+
76
+ // ---- policy ------------------------------------------------------------------------------------------------------------------
77
+
78
+ const TIERS = Object.freeze(['info', 'low', 'medium', 'high', 'critical']);
79
+ const digestOf = (rules) => createHash('sha256').update(JSON.stringify(rules)).digest('hex');
80
+ const LOWERING_FORBIDS = Object.freeze({ runtimeObserved: ['not_observed', 'unknown'], proofOfExecution: ['not_demonstrated', 'unknown'], staticallyReachable: ['unknown'], blockedByCompensatingControl: ['unknown', 'no'], externallyReachable: ['unknown'], includedInArtifact: ['unknown'] });
81
+
82
+ /** Freeze a policy and stamp the digest over its rules. */
83
+ export function registerPolicy({ id, version, rules }) {
84
+ const frozen = Object.freeze({ id, version, rules: Object.freeze(rules.map((r) => Object.freeze({ ...r, when: Object.freeze({ ...r.when }) }))) });
85
+ return Object.freeze({ ...frozen, digest: digestOf(frozen.rules) });
86
+ }
87
+
88
+ /** @returns {{ok:boolean, errors:string[]}} */
89
+ export function validatePolicy(policy) {
90
+ const errors = [];
91
+ if (!policy || typeof policy !== 'object' || !policy.id || !policy.version || !Array.isArray(policy.rules)) return { ok: false, errors: ['policy needs id, version and rules'] };
92
+ if (policy.digest !== digestOf(policy.rules)) errors.push('policy digest does not match its rules: the policy was edited after it was registered');
93
+ for (const r of policy.rules) {
94
+ if (!r.id || !r.when || typeof r.when !== 'object' || ![-1, 1].includes(r.adjust)) { errors.push(`rule ${r.id || '?'}: needs id, when and adjust of 1 or -1`); continue; }
95
+ for (const [k, v] of Object.entries(r.when)) {
96
+ if (!LEVELS.includes(k)) errors.push(`rule ${r.id}: unknown level ${k}`);
97
+ else if (!LEVEL_VALUES.includes(v)) errors.push(`rule ${r.id}: unknown value ${v}`);
98
+ else if (r.adjust === -1 && (LOWERING_FORBIDS[k] || []).includes(v)) errors.push(`rule ${r.id}: lowering priority may not rest on ${k}=${v}`);
99
+ }
100
+ if (r.adjust === -1 && !Object.entries(r.when).some(([k, v]) => (k === 'staticallyReachable' && v === 'no') || (k === 'blockedByCompensatingControl' && v === 'yes') || (k === 'includedInArtifact' && v === 'no'))) errors.push(`rule ${r.id}: a lowering rule needs positive evidence (staticallyReachable=no, includedInArtifact=no or blockedByCompensatingControl=yes)`);
101
+ }
102
+ return { ok: errors.length === 0, errors };
103
+ }
104
+
105
+ /** The default policy: raise on executed proof, or on corroborated runtime evidence of an externally reachable path; lower only on positive evidence. */
106
+ export const DEFAULT_POLICY = registerPolicy({
107
+ id: 'default-exploitability', version: '1.0.0',
108
+ rules: [
109
+ { id: 'raise-execution-proven', when: { proofOfExecution: 'yes' }, adjust: 1 },
110
+ { id: 'raise-corroborated-runtime-external', when: { runtimeObserved: 'yes', staticallyReachable: 'yes', externallyReachable: 'yes' }, adjust: 1, requireCorroborated: true },
111
+ { id: 'lower-statically-unreachable', when: { staticallyReachable: 'no' }, adjust: -1 },
112
+ { id: 'lower-compensating-control', when: { blockedByCompensatingControl: 'yes' }, adjust: -1 },
113
+ { id: 'lower-not-in-artifact', when: { includedInArtifact: 'no' }, adjust: -1 },
114
+ ],
115
+ });
116
+
117
+ /**
118
+ * Attach the levels and the policy-driven priority. Severity is never changed and the finding is never removed.
119
+ * @returns {object} a copy of the finding with `exploitabilityEvidence`
120
+ */
121
+ export function withExploitabilityEvidence(finding, ctx = {}, policy = DEFAULT_POLICY) {
122
+ const { levels, unknownLevels } = buildEvidenceLevels(finding, ctx);
123
+ const valid = validatePolicy(policy);
124
+ const original = TIERS.includes(finding.severity) ? finding.severity : null;
125
+ const block = { schema: 'agentic-security/exploitability-evidence@1', levels, unknownLevels, priority: { original, adjusted: original, adjustments: [], policy: { id: policy && policy.id, version: policy && policy.version, digest: policy && policy.digest, valid: valid.ok } } };
126
+ if (!valid.ok) block.priority.refused = valid.errors;
127
+ else if (original) {
128
+ let idx = TIERS.indexOf(original);
129
+ for (const r of policy.rules) {
130
+ const hit = Object.entries(r.when).every(([k, v]) => levels[k] && levels[k].value === v);
131
+ if (!hit) continue;
132
+ if (r.requireCorroborated && levels.runtimeObserved.corroboration !== 'corroborated') continue;
133
+ const next = Math.max(0, Math.min(TIERS.length - 1, idx + r.adjust));
134
+ if (next === idx) continue;
135
+ idx = next;
136
+ block.priority.adjustments.push({ rule: r.id, direction: r.adjust > 0 ? 'raise' : 'lower', cited: Object.keys(r.when).map((k) => ({ level: k, value: levels[k].value, basis: levels[k].basis, source: levels[k].source })) });
137
+ }
138
+ block.priority.adjusted = TIERS[idx];
139
+ }
140
+ return { ...finding, exploitabilityEvidence: block };
141
+ }
@@ -186,6 +186,7 @@ export async function verifyFix({
186
186
  depFileContents,
187
187
  fixMeta,
188
188
  testTimeoutMs,
189
+ runTests,
189
190
  recordMetrics = true,
190
191
  poc,
191
192
  patchNegative,
@@ -203,12 +204,13 @@ export async function verifyFix({
203
204
  _lap('rescan');
204
205
  const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
205
206
  _lap('lint');
206
- const tests = runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
207
+ // `runTests` lets a caller supply a runner that tests the PATCHED tree (fix/test-commands.js); the default runs the tree as it is on disk.
208
+ const tests = runTests ? runTests() : runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
207
209
  _lap('tests');
208
210
  // True when a candidate patch was supplied but has not been written, so the
209
211
  // suite necessarily ran against the pre-patch tree. Surfaced in the summary
210
212
  // and on the result so a caller cannot mistake it for a verified patch.
211
- const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
213
+ const _testedPrePatch = !runTests && !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
212
214
  const testsOk = tests.skipped ? true : tests.passed === true;
213
215
  // R5 — the PoC leg. Re-run the finding's proof-of-concept against the
214
216
  // CANDIDATE patch inside R1's sandbox. A patch that still lets the PoC