@clear-capabilities/agentic-security-scanner 0.195.28 → 0.195.30

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 (48) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/agentic-security.js +13 -4
  3. package/dist/1340.index.js +11 -6
  4. package/dist/3500.index.js +943 -0
  5. package/dist/5616.index.js +146 -19
  6. package/dist/{3eabd90d6d9c8c8dbbd9.json → 7e58a58b8e6ccdde1659.json} +7 -6
  7. package/dist/9560.index.js +29 -5
  8. package/dist/agentic-security.mjs +2 -2
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +7 -6
  11. package/src/egress/policy.js +79 -1
  12. package/src/engine.js +1 -1
  13. package/src/language/CLAUDE.md +1 -1
  14. package/src/language/haskell-guards.js +26 -5
  15. package/src/lineage/CLAUDE.md +3 -1
  16. package/src/lineage/deployment/adapter-cloud.js +5 -2
  17. package/src/lineage/deployment/attack-paths.js +107 -30
  18. package/src/lineage/deployment/boundaries-run.js +2 -2
  19. package/src/lineage/deployment/iam-eval.js +105 -0
  20. package/src/lineage/deployment/projection.js +10 -4
  21. package/src/lineage/deployment/proof-binding.js +79 -0
  22. package/src/llm-validator/bounded-context.js +115 -8
  23. package/src/llm-validator/index.js +1 -1
  24. package/src/mcp/CLAUDE.md +6 -0
  25. package/src/mcp/tools.js +27 -3
  26. package/src/pipeline/evidence-record.js +1 -1
  27. package/src/posture/CLAUDE.md +2 -0
  28. package/src/posture/assurance/candidate-qualification.js +155 -0
  29. package/src/posture/assurance/release-package-cli.js +44 -2
  30. package/src/posture/auditor-walkthrough.js +2 -1
  31. package/src/posture/dnr/detection-ir.js +2 -2
  32. package/src/posture/dnr/hunt.js +22 -6
  33. package/src/posture/dnr/log-reader.js +47 -3
  34. package/src/posture/dnr/matchers.js +43 -3
  35. package/src/posture/dnr/outputs.js +16 -2
  36. package/src/posture/dnr/payload-classes.js +12 -2
  37. package/src/posture/dnr/validate.js +17 -4
  38. package/src/posture/evaluation/report.js +2 -1
  39. package/src/posture/evaluation/score.js +24 -7
  40. package/src/posture/logic-claims.js +15 -14
  41. package/src/posture/sibling-queue.js +69 -0
  42. package/src/posture/triage-memory.js +106 -26
  43. package/src/posture/verification-separation.js +55 -7
  44. package/src/report/index.js +4 -0
  45. package/src/runScan.js +5 -0
  46. package/src/scope/flags.js +1 -0
  47. package/src/scope/inventory.js +17 -1
  48. package/src/scope/release-scope.js +80 -0
@@ -9,8 +9,15 @@
9
9
  // invalid a citation names a span or a line that was not sent: the model reasoned about code it was not shown, so the reply
10
10
  // is not used (the caller turns it into `escalate`)
11
11
  //
12
- // What is not collected is stated in `gaps`, never implied: this tier has no call graph, so the callers of the enclosing function
13
- // are not included, and configuration files are not included.
12
+ // Callers and configuration are collected by name, and the prompt says so:
13
+ //
14
+ // callers the nearest function definition above the finding, then call sites of that name in scanned files of the same
15
+ // language. Matched by name, not resolved through imports or dispatch: a same-named function can appear, and
16
+ // calls through aliases, callbacks or dynamic dispatch are missed.
17
+ // configuration environment keys read in the finding's span, then the scanned configuration files that set them. Dotenv files
18
+ // other than examples are never read, and the value of a secret-like key is masked before redaction.
19
+ //
20
+ // What is not collected is stated in `gaps`, never implied.
14
21
 
15
22
  import { languagePromptExtras } from '../language/context.js';
16
23
 
@@ -19,13 +26,13 @@ const lineOf = (x) => (x && Number.isInteger(x.line) && x.line > 0 ? x.line : nu
19
26
  /**
20
27
  * @returns {{spans:{id:string, role:string, file:string, start:number, end:number, text:string}[], gaps:string[]}}
21
28
  */
22
- export function collectBoundedContext(finding, fileContents, { window = 20, stepWindow = 3, maxSpans = 10 } = {}) {
29
+ export function collectBoundedContext(finding, fileContents, { window = 20, stepWindow = 3, maxSpans = 10, maxCallers = 3, maxConfig = 2 } = {}) {
23
30
  const raw = [];
24
31
  const gaps = [];
25
- const add = (role, file, line, w) => {
32
+ const add = (role, file, line, w, mask = null) => {
26
33
  const code = fileContents && typeof fileContents[file] === 'string' ? fileContents[file] : null;
27
34
  if (code === null) { gaps.push(`${role} file ${file} was not available`); return; }
28
- const lines = code.split('\n');
35
+ const lines = mask ? code.split('\n').map(mask) : code.split('\n');
29
36
  const start = Math.max(1, line - w);
30
37
  const end = Math.min(lines.length, line + w);
31
38
  if (start > end) return;
@@ -38,6 +45,12 @@ export function collectBoundedContext(finding, fileContents, { window = 20, step
38
45
  for (const step of Array.isArray(finding && finding.path) ? finding.path : []) {
39
46
  if (lineOf(step)) add('path-step', step.file || finding.file, step.line, stepWindow);
40
47
  }
48
+ if (finding && finding.file && lineOf(finding) && typeof (fileContents || {})[finding.file] === 'string') {
49
+ collectCallers(finding, fileContents, { maxCallers, stepWindow, add, gaps });
50
+ collectConfiguration(finding, fileContents, { window, maxConfig, add, gaps });
51
+ } else {
52
+ gaps.push('the finding has no readable source line, so callers and configuration were not collected');
53
+ }
41
54
  // Merge spans that overlap or touch in the same file; the first role stays, the others are kept as `alsoRoles`.
42
55
  const merged = [];
43
56
  for (const s of raw) {
@@ -49,7 +62,7 @@ export function collectBoundedContext(finding, fileContents, { window = 20, step
49
62
  id: `S${i + 1}`, role: m.role, ...(m.alsoRoles.length ? { alsoRoles: m.alsoRoles } : {}), file: m.file, start: m.start, end: m.end,
50
63
  text: m.lines.slice(m.start - 1, m.end).map((l, k) => `${m.start + k}: ${l}`).join('\n'),
51
64
  }));
52
- if (merged.length > maxSpans) gaps.push(`${merged.length - maxSpans} path span(s) beyond the first ${maxSpans} were not sent`);
65
+ if (merged.length > maxSpans) gaps.push(`${merged.length - maxSpans} span(s) beyond the first ${maxSpans} were not sent`);
53
66
  try {
54
67
  for (const x of languagePromptExtras(finding, fileContents)) {
55
68
  const n = x.text.split('\n').length;
@@ -57,11 +70,105 @@ export function collectBoundedContext(finding, fileContents, { window = 20, step
57
70
  }
58
71
  } catch { /* related excerpts are advisory */ }
59
72
  if (!spans.length) gaps.push('no source text was available for the finding');
60
- gaps.push('callers of the enclosing function were not collected (this tier has no call graph)');
61
- gaps.push('configuration files were not collected');
73
+ gaps.push('deployment configuration outside the repository (environment, secrets stores, cloud policy) was not collected');
62
74
  return { spans, gaps };
63
75
  }
64
76
 
77
+ // ── callers ─────────────────────────────────────────────────────────────────
78
+ const NOT_A_NAME = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'else', 'do', 'try', 'new', 'typeof', 'await',
79
+ 'with', 'sizeof', 'elif', 'except', 'when', 'match', 'case', 'super', 'this', 'self', 'import', 'require', 'where', 'let', 'in']);
80
+ // Lines carrying a CWE-1333 pragma: bounded by MAX_DEF_LINE below, so the polynomial worst case is capped per line.
81
+ const LANGS = [
82
+ { ext: /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i, defs: [/\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/, /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, /^\s*(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/] }, // agentic-security-ignore: CWE-1333
83
+ { ext: /\.py$/i, defs: [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/] },
84
+ { ext: /\.rb$/i, defs: [/^\s*def\s+(?:self\.)?([A-Za-z_]\w*[?!]?)/] },
85
+ { ext: /\.go$/i, defs: [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*[[(]/] },
86
+ { ext: /\.rs$/i, defs: [/\bfn\s+([A-Za-z_]\w*)/] },
87
+ { ext: /\.php$/i, defs: [/\bfunction\s+([A-Za-z_]\w*)\s*\(/] },
88
+ { ext: /\.(?:java|kt|kts|cs|scala)$/i, defs: [/^\s*(?:(?:public|private|protected|internal|static|final|override|suspend|async|virtual|abstract|synchronized|open)\s+)*(?:fun\s+|def\s+)?(?:[\w<>\[\],.?]+\s+)?([A-Za-z_]\w*)\s*\([^;]*$/] }, // agentic-security-ignore: CWE-1333
89
+ { ext: /\.hs$/i, defs: [/^([a-z_][\w']*)\s*::/, /^([a-z_][\w']*)\b[^=\n]*=/], bare: true },
90
+ ];
91
+ const langOf = (file) => LANGS.find((l) => l.ext.test(file)) || null;
92
+ const escapeRe = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ // The definition patterns run over scanned (untrusted) source. None backtracks exponentially, but the modifier and type groups can go
94
+ // quadratic on a long line, so a line longer than any real definition header is never matched.
95
+ const MAX_DEF_LINE = 400;
96
+ const defName = (lang, line) => {
97
+ if (line.length > MAX_DEF_LINE) return null;
98
+ for (const re of lang.defs) { const m = re.exec(line); if (m && !NOT_A_NAME.has(m[1])) return m[1]; }
99
+ return null;
100
+ };
101
+
102
+ /** The nearest function definition at or above `line` (1-based), within 400 lines. */
103
+ export function enclosingFunction(file, code, line) {
104
+ const lang = langOf(file);
105
+ if (!lang) return null;
106
+ const lines = code.split('\n');
107
+ for (let i = Math.min(line, lines.length) - 1; i >= 0 && i >= line - 400; i--) {
108
+ const name = defName(lang, lines[i]);
109
+ if (name) return { name, line: i + 1 };
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function collectCallers(finding, fileContents, { maxCallers, stepWindow, add, gaps }) {
115
+ const lang = langOf(finding.file);
116
+ const fn = lang ? enclosingFunction(finding.file, fileContents[finding.file], finding.line) : null;
117
+ if (!fn) { gaps.push('the enclosing function could not be determined, so callers were not collected'); return; }
118
+ const call = lang.bare ? new RegExp(`(?<![\\w'.])${escapeRe(fn.name)}(?![\\w'])`) : new RegExp(`(?<![\\w$])${escapeRe(fn.name)}\\s*\\(`);
119
+ const sites = [];
120
+ for (const [file, code] of Object.entries(fileContents)) {
121
+ if (typeof code !== 'string' || code.length > 1_000_000 || !lang.ext.test(file) || !code.includes(fn.name)) continue;
122
+ code.split('\n').forEach((text, i) => {
123
+ if (!call.test(text) || defName(lang, text) === fn.name) return; // the definition (or a signature) is not a call
124
+ if (file === finding.file && i + 1 >= fn.line && i + 1 <= finding.line) return; // recursion inside the finding's own function
125
+ sites.push({ file, line: i + 1 });
126
+ });
127
+ }
128
+ if (!sites.length) {
129
+ gaps.push(`no call site of ${fn.name} was found by name in the scanned files of the same language (it may be an entry point, exported, or called dynamically)`);
130
+ return;
131
+ }
132
+ for (const s of sites.slice(0, maxCallers)) add('caller', s.file, s.line, stepWindow);
133
+ gaps.push(`callers of ${fn.name} were matched by name, not resolved: a same-named function can appear, and calls through aliases, callbacks or dynamic dispatch are missed`);
134
+ if (sites.length > maxCallers) gaps.push(`${sites.length - maxCallers} further call site(s) of ${fn.name} were not sent`);
135
+ }
136
+
137
+ // ── configuration ───────────────────────────────────────────────────────────
138
+ const ENV_READS = [
139
+ /process\.env\.([A-Za-z_][\w]*)/g, /process\.env\[\s*['"]([^'"]+)['"]\s*\]/g, /os\.environ(?:\.get)?\s*[[(]\s*['"]([^'"]+)['"]/g,
140
+ /\bgetenv\(\s*['"]([^'"]+)['"]/g, /\b(?:getEnv|lookupEnv)\s+"([^"]+)"/g, /System\.getenv\(\s*"([^"]+)"/g,
141
+ /\bENV(?:\.fetch\(\s*|\[\s*)['"]([^'"]+)['"]/g, /os\.(?:Getenv|LookupEnv)\(\s*"([^"]+)"/g, /env::var\(\s*"([^"]+)"/g,
142
+ ];
143
+ const CONFIG_FILE = /(?:^|\/)(?:\.env\.(?:example|sample|template|dist)|[^/]+\.(?:ya?ml|toml|ini|conf|cfg|properties|nix)|docker-compose[^/]*|Dockerfile[^/]*|app\.json)$/i;
144
+ const SECRET_KEY = /secret|token|passw|pwd|credential|private|api[_-]?key|auth/i;
145
+ // Masks the value of a secret-like key; the generic redaction then runs over the whole span.
146
+ const maskSecretValues = (text) => text.replace(/^(\s*(?:export\s+|-\s+)?["']?([\w.-]+)["']?\s*[:=]\s*)(.+)$/, (all, head, key) => (SECRET_KEY.test(key) ? `${head}<masked>` : all));
147
+
148
+ function collectConfiguration(finding, fileContents, { window, maxConfig, add, gaps }) {
149
+ const lines = fileContents[finding.file].split('\n');
150
+ const span = lines.slice(Math.max(0, finding.line - 1 - window), finding.line + window).join('\n');
151
+ const keys = new Set();
152
+ for (const re of ENV_READS) for (const m of span.matchAll(re)) keys.add(m[1]);
153
+ if (!keys.size) { gaps.push("no configuration key is read near the finding, so no configuration file was collected"); return; }
154
+ const found = new Set(); let sent = 0; let more = 0;
155
+ for (const [file, code] of Object.entries(fileContents)) {
156
+ if (typeof code !== 'string' || !CONFIG_FILE.test(file) || code.length > 500_000) continue;
157
+ const cl = code.split('\n');
158
+ for (const key of keys) {
159
+ // the key is escaped before it reaches the pattern, which has no quantifier at all
160
+ const re = new RegExp(`(?<![\\w])${escapeRe(key)}(?![\\w])`); // agentic-security-ignore: CWE-1333
161
+ const i = cl.findIndex((t) => re.test(t));
162
+ if (i < 0) continue;
163
+ found.add(key);
164
+ if (sent < maxConfig) { add('configuration', file, i + 1, 1, maskSecretValues); sent++; } else more++;
165
+ }
166
+ }
167
+ const missing = [...keys].filter((k) => !found.has(k)).sort();
168
+ if (missing.length) gaps.push(`configuration key(s) ${missing.join(', ')} read near the finding are set in no scanned configuration file (they may be set at deploy time)`);
169
+ if (more) gaps.push(`${more} further configuration match(es) were not sent`);
170
+ }
171
+
65
172
  /** Render spans as the untrusted excerpt block. */
66
173
  export function renderSpans(spans) {
67
174
  return spans.map((s) => `[${s.id} ${s.role}${s.alsoRoles ? `+${s.alsoRoles.join('+')}` : ''}] ${s.file}:${s.start}-${s.end}\n${s.text}`).join('\n\n');
@@ -90,7 +90,7 @@ const MAX_OUTPUT_TOKENS = 512;
90
90
  // reads as a refusal rather than as "no endpoint configured".
91
91
  let _localPresetRefusal = null;
92
92
 
93
- export const PROMPT_VERSION = 'v2.1-cited';
93
+ export const PROMPT_VERSION = 'v2.2-callers-config';
94
94
 
95
95
  // System preamble — embeds a per-request challenge token the model MUST
96
96
  // echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
package/src/mcp/CLAUDE.md CHANGED
@@ -88,6 +88,12 @@ the before and after image, **no content**) and `languageFix.planDigest` after r
88
88
  caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
89
89
  high-impact change).
90
90
 
91
+ **Approval binding (threat-model FR-06.3).** `fixMeta.approval` may carry `expires` (ISO time) and `scope: {tool, action, target,
92
+ change}`: `tool` is `apply_fix`, `action` is `apply-plan` (multi-file) or `apply-patch`, `target` is the finding's stableId, and
93
+ `change` is the plan digest or the digest of the exact edits (path, before and after image hashes). `approvalBindingRefusal` refuses
94
+ an expired approval and one issued for another tool, action, target or change. Under the restricted execution profile
95
+ (`AGENTIC_SECURITY_EGRESS_PROFILE=restricted`) both fields are required; elsewhere an approval without them works as before.
96
+
91
97
  Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
92
98
  re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
93
99
  exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
package/src/mcp/tools.js CHANGED
@@ -640,7 +640,26 @@ export const explain_finding = {
640
640
  // FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
641
641
  // shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
642
642
  // when the change is not high-impact or is properly approved.
643
- function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
643
+ // Threat-model PRD FR-06.3: an approval is bound to what it approved. With a `scope` it names the tool, the action, the target finding
644
+ // and the change (the digest of the exact edits), and with `expires` it lapses. Under the restricted execution profile
645
+ // (AGENTIC_SECURITY_EGRESS_PROFILE=restricted) both are required; elsewhere a supplied scope or expiry is still enforced. An approval
646
+ // copied to another finding, another change or another tool, or used after it expired, is refused.
647
+ export function approvalBindingRefusal(approval, expected, { now = Date.now(), restricted = process.env.AGENTIC_SECURITY_EGRESS_PROFILE === 'restricted' } = {}) {
648
+ if (restricted && (!approval.expires || !approval.scope)) return 'under the restricted profile an approval must carry an expiry and a scope {tool, action, target, change}';
649
+ if (approval.expires !== undefined) {
650
+ const t = Date.parse(approval.expires);
651
+ if (!Number.isFinite(t)) return 'the approval expiry is not a date';
652
+ if (!(t > now)) return 'the approval has expired';
653
+ }
654
+ if (approval.scope !== undefined) {
655
+ const sc = approval.scope;
656
+ if (!sc || typeof sc !== 'object') return 'the approval scope is not an object';
657
+ for (const k of ['tool', 'action', 'target', 'change']) if (sc[k] !== expected[k]) return `the approval was issued for another ${k}`;
658
+ }
659
+ return null;
660
+ }
661
+
662
+ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, expected = null) {
644
663
  if (!materialClassification.highImpactCategories.length) return null;
645
664
  const cats = materialClassification.highImpactCategories.join(', ');
646
665
  const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
@@ -654,6 +673,10 @@ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
654
673
  materialClassification,
655
674
  };
656
675
  }
676
+ if (expected) {
677
+ const bound = approvalBindingRefusal(approval, expected);
678
+ if (bound) return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${bound}`, materialClassification };
679
+ }
657
680
  const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
658
681
  const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
659
682
  const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
@@ -739,7 +762,7 @@ async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status
739
762
  if (!targets.ok) return refuse(targets.reason);
740
763
  const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
741
764
  if (!dryRun) {
742
- const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
765
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta, { tool: 'apply_fix', action: 'apply-plan', target: f.stableId || f.id, change: planDigest });
743
766
  if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
744
767
  }
745
768
  // The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
@@ -942,7 +965,8 @@ export const apply_fix = {
942
965
  if (dry_run) {
943
966
  return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
944
967
  }
945
- const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
968
+ const changeDigest = _sha256(JSON.stringify(Object.entries(filesForMaterialClassification).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, _sha256(v.before), _sha256(v.after)])));
969
+ const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, { tool: 'apply_fix', action: 'apply-patch', target: f.stableId || f.id, change: changeDigest });
946
970
  if (approvalRefusal) return approvalRefusal;
947
971
  const written = [];
948
972
  try {
@@ -55,7 +55,7 @@ export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mo
55
55
  tool: { name: 'agentic-security', version: engineVersion || null },
56
56
  source: sourceState(root),
57
57
  invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
58
- ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
58
+ ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics, inventoryDigest: scan.scope.inventoryDigest ?? null } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false, inventoryDigest: null },
59
59
  rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
60
60
  model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
61
61
  capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
@@ -73,6 +73,8 @@ function is not exported.
73
73
 
74
74
  Its contract is **recall-preserving, same precedent as `falsification.js` / `dataflow/proof-gate.js`**: it never removes a finding, never touches `severity`, and never asserts `unreachable` without positive evidence — a negative verdict additionally requires the intra-repo import graph to be hole-free *along the reachable set* (an unresolved relative import or a non-literal `require(x)` in a reachable file hides a possible edge, so every would-be `unreachable` degrades to `unknown`). `null`/`'unknown'` is a first-class state and is **not** the same as `false`. Wired in `engine.js` after the entry-point inventory is built and after every finding has been appended (multi-sink and cross-language chains included) so nothing escapes annotation; this is the one annotator that deliberately runs after `why-fired`.
75
75
 
76
+ **Threat-model hardening (local, threat-model PRD).** `verification-separation.js` `consensusOf` now votes by independent evidence group (a shared `evidence.observationId`, `generator` or `lineage`, else one verifier), keeps the per-lens counts, reports `conflict` and a `refutationAuthority`; `refutationRemoves` lets a refutation take a critical finding out of the queue only with `sufficient` authority. `triage-memory.js`: a family/directory bucket is advisory (no confidence change); only an exact (stable id + file) decision applies, and for critical/high it also needs a reviewer, expiry, matching scope, unchanged fingerprint and the install's HMAC signature (`pastDecision.status`, `pastDecisionApplies`). `assurance/candidate-qualification.js`: the candidate packet and `evaluateCandidate`, which recomputes gates G-01..G-07 from case evidence (met, violated, unresolved, blocked) and never inherits a pass; `writeCandidateDecision` is atomic and idempotent.
77
+
76
78
  **Enforced verification separation (R7)** — `verification-separation.js`. The falsification pass could already try to *disprove* a finding; what it could not do was prove the checker was not the producer. This module supplies that structural guarantee:
77
79
 
78
80
  - `recordProducer(finding, producerId)` stamps provenance **write-once** — a later party cannot re-stamp itself as producer to manufacture separation.
@@ -0,0 +1,155 @@
1
+ // Candidate qualification (threat-model PRD FR-10.1, FR-10.2, section 6 "Candidate release gates").
2
+ //
3
+ // A release claim is qualified for an EXACT scanner, target artifact, deployment profile, policy and observation window, and only for
4
+ // the cases it passes. The packet carries those identities and one record per required case (requirement, gate, expected and observed
5
+ // outcome, verdict and reason code, artifact and policy bindings, evidence digests, expiry, owner). The decision is recomputed here from
6
+ // the evidence, never read from the packet: a case whose bindings do not match the packet's target, that has expired, whose feed is
7
+ // stale or whose legitimate control failed keeps its gate open, and the candidate is qualified only when every required gate is met.
8
+ // A higher-level status never inherits a pass from a lower-level one: a gate with no case is unresolved, not met.
9
+ //
10
+ // Outcome per case: met (the expectation held), violated (it did not), unresolved (evidence missing, stale, expired or differently
11
+ // scoped) or blocked (a prerequisite such as an attested runner or an independent population is unavailable). Pure apart from the
12
+ // optional signed write, which is atomic.
13
+ //
14
+ // Evidence tiers stay separate qualifications (PRD section 1 and section 8). Each case carries `tier`: `local` (loopback fixtures on
15
+ // this host, the default), `staging` (the owner-authorized staging deployment) or `independent` (an independently held population or
16
+ // reviewer). A gate the caller declares in `requiredTiers` is met only by cases of that tier: local evidence never satisfies a staging
17
+ // or independent gate by being more of it. A decision records the target it was computed for, and `revalidateDecision` refuses it for
18
+ // any other artifact, policy or scanner bundle, so an upgrade invalidates candidate-bound evidence and a rollback is qualified only by
19
+ // the earlier artifact's own unexpired decision (FR-10.4).
20
+ import crypto from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const CANDIDATE_SCHEMA = 'agentic-security/candidate-packet';
25
+ const CANDIDATE_VERSION = 1;
26
+ export const GATES = Object.freeze({
27
+ 'G-01': 'code regression', 'G-02': 'scan and graph coverage', 'G-03': 'prevention and repair', 'G-04': 'evidence and harness integrity',
28
+ 'G-05': 'detection and response', 'G-06': 'independent accuracy', 'G-07': 'operability',
29
+ });
30
+ const EXPECT = new Set(['positive', 'negative', 'legitimate']);
31
+ const TIERS = new Set(['local', 'staging', 'independent']);
32
+ const DOMAIN = `${CANDIDATE_SCHEMA}@1\n`;
33
+ const canon = (v) => {
34
+ if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
35
+ if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
36
+ // an undefined value is absent, exactly as JSON would carry it, so a packet and its JSON round trip have one digest
37
+ return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
38
+ };
39
+ export const packetDigest = (p) => `sha256:${crypto.createHash('sha256').update(canon({ ...p, signature: undefined })).digest('hex')}`;
40
+
41
+ /** Sign a packet with an Ed25519 private key; `verifyCandidatePacket` checks it with the key the caller trusts. */
42
+ export function signCandidatePacket(packet, privateKeyPem) {
43
+ const body = { schema: CANDIDATE_SCHEMA, version: CANDIDATE_VERSION, ...packet };
44
+ delete body.signature;
45
+ return { ...body, signature: crypto.sign(null, Buffer.from(DOMAIN + canon(body)), privateKeyPem).toString('base64') };
46
+ }
47
+ export function verifyCandidatePacket(packet, publicKeyPem) {
48
+ if (!packet || packet.schema !== CANDIDATE_SCHEMA) return { ok: false, code: 'FORMAT' };
49
+ // a packet from a newer or older writer is refused by name, never read with this version's meaning
50
+ if (packet.version !== CANDIDATE_VERSION) return { ok: false, code: 'UNSUPPORTED_VERSION', version: packet.version ?? null };
51
+ const { signature, ...body } = packet;
52
+ try { return crypto.verify(null, Buffer.from(DOMAIN + canon(body)), publicKeyPem, Buffer.from(String(signature), 'base64')) ? { ok: true, code: 'VERIFIED' } : { ok: false, code: 'SIGNATURE' }; } catch { return { ok: false, code: 'SIGNATURE' }; }
53
+ }
54
+
55
+ /** Judge one case record against the packet's target. */
56
+ function judgeCase(c, packet, now) {
57
+ const t = packet.target || {};
58
+ const miss = [];
59
+ for (const k of ['caseId', 'requirement', 'gate', 'expected', 'verdict', 'owner', 'expires']) if (c[k] === undefined || c[k] === null || c[k] === '') miss.push(k);
60
+ if (miss.length) return { outcome: 'unresolved', reason: `missing ${miss.join(', ')}`, owner: c.owner ?? null };
61
+ if (!GATES[c.gate] || !EXPECT.has(c.expected)) return { outcome: 'unresolved', reason: 'unknown gate or expectation' };
62
+ if (c.tier !== undefined && !TIERS.has(c.tier)) return { outcome: 'unresolved', reason: 'unknown evidence tier' };
63
+ if (c.blocked) return { outcome: 'blocked', reason: String(c.blocked) };
64
+ if (!(Date.parse(c.expires) > now)) return { outcome: 'unresolved', reason: 'the evidence has expired' };
65
+ if (c.artifactDigest && t.artifactDigest && c.artifactDigest !== t.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence was produced against another artifact' };
66
+ if (c.policyDigest && t.policyDigest && c.policyDigest !== t.policyDigest) return { outcome: 'unresolved', reason: 'the evidence was produced under another policy' };
67
+ if (c.scannerBundle && packet.scanner?.bundleDigest && c.scannerBundle !== packet.scanner.bundleDigest) return { outcome: 'unresolved', reason: 'the evidence came from another scanner bundle' };
68
+ if (c.requiresArtifactBinding !== false && !c.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence is not bound to an artifact' };
69
+ const o = c.observed;
70
+ if (!o || typeof o !== 'object') return { outcome: 'unresolved', reason: 'no observation is recorded' };
71
+ // the observation and the expectation stay separately inspectable; met/violated is derived, not asserted
72
+ if (c.kind === 'detection') {
73
+ // a detection case judges the signal, not the effect: positive means the rule must fire, negative that it must stay quiet,
74
+ // and an observation that does not say whether the rule fired leaves the case open
75
+ if (typeof o.detected !== 'boolean') return { outcome: 'unresolved', reason: 'the observation does not record whether the detection fired' };
76
+ if (c.expected === 'positive') return o.detected ? { outcome: 'met', reason: 'the detection fired on the reproduced effect' } : { outcome: 'violated', reason: 'the detection missed the reproduced effect' };
77
+ return o.detected ? { outcome: 'violated', reason: 'the detection fired where it must not' } : { outcome: 'met', reason: 'the detection stayed quiet where it must' };
78
+ }
79
+ const forbidden = o.forbiddenEffect === true; const legit = o.legitimateEffect === true;
80
+ if (c.expected === 'positive') return forbidden ? { outcome: 'met', reason: 'the vulnerable control reproduced the forbidden effect' } : { outcome: 'violated', reason: 'the vulnerable control did not reproduce the effect, so the fixture shows nothing' };
81
+ if (c.expected === 'negative') return forbidden ? { outcome: 'violated', reason: 'the forbidden effect occurred on the fixed artifact' } : { outcome: 'met', reason: 'no forbidden effect' };
82
+ return legit && !forbidden ? { outcome: 'met', reason: 'the legitimate operation succeeded' } : { outcome: 'violated', reason: 'the legitimate operation did not succeed' };
83
+ }
84
+
85
+ /**
86
+ * Recompute every gate from the packet's evidence.
87
+ * @param {object} packet
88
+ * @param {{requiredGates?:string[], requiredCases?:string[], requiredTiers?:Object<string,string>, now?:number, maxFeedAgeMs?:number}} o
89
+ * @returns {{status:'qualified'|'not-qualified', gates:object, cases:object[], blockers:object[], tiers:object}}
90
+ */
91
+ export function evaluateCandidate(packet, { requiredGates = Object.keys(GATES), requiredCases = [], requiredTiers = {}, now = Date.now(), maxFeedAgeMs = 15 * 60 * 1000 } = {}) {
92
+ const cases = (packet.cases || []).map((c) => ({ caseId: c.caseId ?? null, gate: c.gate ?? null, requirement: c.requirement ?? null, owner: c.owner ?? null, tier: c.tier ?? 'local', ...judgeCase(c, packet, now) }));
93
+ const blockers = [];
94
+ for (const id of requiredCases) if (!cases.some((c) => c.caseId === id)) { cases.push({ caseId: id, gate: null, outcome: 'unresolved', reason: 'a required case has no record' }); blockers.push({ gate: null, caseId: id, reason: 'missing required case' }); }
95
+ // gate-level evidence that is not a case
96
+ const extra = {};
97
+ if (!packet.scope || packet.scope.status !== 'satisfied') extra['G-02'] = { outcome: 'unresolved', reason: packet.scope ? `the scope reconciliation is ${packet.scope.status}` : 'no scope reconciliation is in the packet' };
98
+ if (!packet.runner || packet.runner.attested !== true) extra['G-04'] = { outcome: 'blocked', reason: packet.runner?.reason || 'the runner did not attest the confinement the execution cases need' };
99
+ const ev = packet.evaluation;
100
+ if (!ev || ev.status !== 'measured') extra['G-06'] = { outcome: 'blocked', reason: ev?.reason || 'independent accuracy is unmeasured for this candidate' };
101
+ else if (ev.meetsFloors !== true) extra['G-06'] = { outcome: 'violated', reason: 'the measured population does not meet the registered floors' };
102
+ for (const f of packet.telemetry?.feeds || []) {
103
+ if (!f.lastHeartbeat || now - Date.parse(f.lastHeartbeat) > maxFeedAgeMs) { extra['G-05'] = { outcome: 'unresolved', reason: `feed ${f.name} is stale or has no heartbeat` }; break; }
104
+ }
105
+ const gates = {};
106
+ for (const g of requiredGates) {
107
+ const need = requiredTiers[g];
108
+ const ofGate = cases.filter((c) => c.gate === g);
109
+ // a case of another tier is reported, but it cannot meet a gate that names its tier
110
+ const mine = need ? ofGate.filter((c) => c.tier === need) : ofGate;
111
+ const rank = { violated: 3, blocked: 2, unresolved: 1, met: 0 };
112
+ let worst = mine.reduce((w, c) => (rank[c.outcome] > rank[w] ? c.outcome : w), 'met');
113
+ const reasons = mine.filter((c) => c.outcome !== 'met').map((c) => `${c.caseId}: ${c.reason}`);
114
+ if (extra[g]) { if (rank[extra[g].outcome] > rank[worst]) worst = extra[g].outcome; reasons.push(extra[g].reason); }
115
+ // no evidence is not a pass: a gate with no case and no gate-level evidence stays unresolved
116
+ if (need && !mine.length) { worst = rank[worst] > rank.unresolved ? worst : 'unresolved'; reasons.push(`no ${need} evidence for this gate${ofGate.length ? ` (${ofGate.length} case(s) of another tier do not count)` : ''}`); }
117
+ else if (!mine.length && !extra[g] && !(g === 'G-02' && packet.scope?.status === 'satisfied') && !(g === 'G-04' && packet.runner?.attested === true) && !(g === 'G-06' && ev?.status === 'measured')) { worst = 'unresolved'; reasons.push('no evidence for this gate'); }
118
+ gates[g] = { name: GATES[g], outcome: worst, reasons };
119
+ if (worst !== 'met') blockers.push({ gate: g, reason: reasons.join('; ') });
120
+ }
121
+ const tiers = { local: 0, staging: 0, independent: 0 };
122
+ for (const c of cases) if (tiers[c.tier] !== undefined) tiers[c.tier]++;
123
+ return { status: blockers.length ? 'not-qualified' : 'qualified', scope: packet.target ? { ...packet.target } : null, scannerBundle: packet.scanner?.bundleDigest ?? null, packetDigest: packetDigest(packet), gates, cases, blockers, tiers };
124
+ }
125
+
126
+ /**
127
+ * Is a recorded decision still the decision for this target? A decision qualifies only the artifact, policy and scanner bundle it was
128
+ * computed for and only until its earliest case expiry, so an upgrade or policy change invalidates it and a rollback is covered only by
129
+ * the earlier artifact's own decision.
130
+ * @param {object} decision an `evaluateCandidate` result
131
+ * @param {{artifactDigest?:string, policyDigest?:string, scannerBundle?:string}} target
132
+ */
133
+ export function revalidateDecision(decision, target, { packet = null, now = Date.now() } = {}) {
134
+ if (!decision || typeof decision !== 'object' || !decision.scope) return { valid: false, reason: 'no recorded decision scope' };
135
+ if (decision.status !== 'qualified') return { valid: false, reason: 'the recorded decision did not qualify the candidate' };
136
+ if (!target || !target.artifactDigest || decision.scope.artifactDigest !== target.artifactDigest) return { valid: false, reason: 'the artifact differs from the one the decision qualified' };
137
+ if (decision.scope.policyDigest && decision.scope.policyDigest !== target.policyDigest) return { valid: false, reason: 'the deployment policy differs from the one the decision qualified' };
138
+ if (decision.scannerBundle && target.scannerBundle && decision.scannerBundle !== target.scannerBundle) return { valid: false, reason: 'the scanner bundle differs from the one the decision qualified' };
139
+ if (packet) {
140
+ if (packetDigest(packet) !== decision.packetDigest) return { valid: false, reason: 'the packet is not the one the decision was computed from' };
141
+ const soonest = Math.min(...(packet.cases || []).map((c) => Date.parse(c.expires)).filter(Number.isFinite));
142
+ if (Number.isFinite(soonest) && !(soonest > now)) return { valid: false, reason: 'evidence the decision rests on has expired' };
143
+ }
144
+ return { valid: true, reason: 'the decision covers this artifact, policy and scanner bundle' };
145
+ }
146
+
147
+ /** Write a decision atomically; an identical retried write leaves the same bytes. */
148
+ export function writeCandidateDecision(file, decision) {
149
+ const body = `${canon(decision)}\n`;
150
+ try { if (fs.readFileSync(file, 'utf8') === body) return { written: false, unchanged: true }; } catch { /* absent */ }
151
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
152
+ fs.writeFileSync(tmp, body, { mode: 0o644 });
153
+ fs.renameSync(tmp, file);
154
+ return { written: true, unchanged: false };
155
+ }
@@ -2,9 +2,16 @@
2
2
  //
3
3
  // release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]
4
4
  // release-package verify <package.json> --public-key <pem> [--require kind,kind]
5
+ // release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03]
6
+ // [--require-tier G-05=staging,...] [--output <decision.json>] [--format text|json]
7
+ //
8
+ // `qualify` is the strict candidate qualification of the threat-model PRD (FR-10.3): it verifies the packet's signature, recomputes
9
+ // every gate from the case evidence (`candidate-qualification.js`) and exits 0 only when the candidate is qualified. It never changes
10
+ // what an ordinary scan exits with.
5
11
  //
6
12
  // `io.git(root, ...args)` answers a read-only git query (or null); the caller supplies it so this layer never spawns a process itself.
7
- // Exit codes: 0 built or verified; 1 the scan state failed its integrity check, or a package was rejected; 2 a usage problem or unreadable input.
13
+ // Exit codes: 0 built, verified or qualified; 1 the scan state failed its integrity check, a package was rejected or a candidate was not
14
+ // qualified; 2 a usage problem or unreadable input.
8
15
  //
9
16
  // What goes in: the scan's health record and one record per finding (and, with --fix-records, one verification record per fix claimed). The proof tier of a
10
17
  // finding is whatever the scan recorded; a finding that recorded none is `taint-proven` when the taint engine found its path and `unproven` otherwise. An
@@ -18,8 +25,9 @@ import { buildSnapshotRef } from './evidence-registry.js';
18
25
  import { buildReleasePackage, signReleasePackage } from './release-package.js';
19
26
  import { releaseVerdict, verifyStatedVerdict } from './release-verdict.js';
20
27
  import { verifyReleasePackage } from './release-package-verify.js';
28
+ import { evaluateCandidate, verifyCandidatePacket, writeCandidateDecision, GATES } from './candidate-qualification.js';
21
29
 
22
- const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]';
30
+ const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]\n agentic-security release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03] [--require-tier G-05=staging] [--output <decision.json>] [--format text|json]';
23
31
  const DEFAULT_OUT = 'release-package.json';
24
32
 
25
33
 
@@ -104,11 +112,45 @@ async function verify(args, say) {
104
112
  return 1;
105
113
  }
106
114
 
115
+ const listFlag = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
116
+
117
+ async function qualify(args, say) {
118
+ const file = args._[2];
119
+ const keyFile = args.flags['public-key'];
120
+ if (!file || !keyFile) { say.err(USAGE); return 2; }
121
+ let packet; let key;
122
+ try { packet = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); key = fs.readFileSync(path.resolve(String(keyFile)), 'utf8'); } catch (e) { say.err(`Could not read the input: ${e.message}`); return 2; }
123
+ const requiredGates = args.flags['require-gate'] ? listFlag(args.flags['require-gate']) : Object.keys(GATES);
124
+ const unknown = requiredGates.filter((g) => !GATES[g]);
125
+ if (unknown.length) { say.err(`Unknown gate: ${unknown.join(', ')}. Gates: ${Object.keys(GATES).join(', ')}.`); return 2; }
126
+ const requiredTiers = {};
127
+ for (const pair of listFlag(args.flags['require-tier'])) {
128
+ const [g, t] = pair.split('=');
129
+ if (!GATES[g] || !['local', 'staging', 'independent'].includes(t)) { say.err(`--require-tier takes gate=tier pairs (tier local, staging or independent), got ${pair}`); return 2; }
130
+ requiredTiers[g] = t;
131
+ }
132
+ const sig = verifyCandidatePacket(packet, key);
133
+ if (!sig.ok) { say.err(`${sig.code}: the candidate packet ${sig.code === 'UNSUPPORTED_VERSION' ? `has version ${sig.version}, which this scanner does not read` : 'did not verify under the given key'}`); say.err('candidate NOT QUALIFIED'); return 1; }
134
+ const d = evaluateCandidate(packet, { requiredGates, requiredCases: listFlag(args.flags['require-case']), requiredTiers });
135
+ if (args.flags.output) {
136
+ try { writeCandidateDecision(path.resolve(String(args.flags.output)), d); } catch (e) { say.err(`Could not write the decision: ${e.message}`); return 2; }
137
+ }
138
+ if (args.flags.format === 'json') say.out(JSON.stringify(d, null, 2));
139
+ else {
140
+ const t = d.scope || {};
141
+ say.out(`candidate ${d.status === 'qualified' ? 'QUALIFIED' : 'NOT QUALIFIED'} for ${t.service || t.repository || 'an unnamed target'} artifact ${String(t.artifactDigest || 'unbound').slice(0, 19)} (${t.deploymentProfile || 'no profile'})`);
142
+ for (const [g, x] of Object.entries(d.gates)) say.out(` ${g} ${x.name}: ${x.outcome}${x.reasons.length ? ` (${x.reasons.join('; ')})` : ''}`);
143
+ say.out(` cases by tier: local ${d.tiers.local}, staging ${d.tiers.staging}, independent ${d.tiers.independent}`);
144
+ }
145
+ return d.status === 'qualified' ? 0 : 1;
146
+ }
147
+
107
148
  export async function cmdReleasePackage(args, io = {}) {
108
149
  const say = { out: io.out || ((s) => console.log(s)), err: io.err || ((s) => console.error(s)) };
109
150
  const sub = args._[1];
110
151
  if (sub === 'build') return build(args, say, io.git || (() => null));
111
152
  if (sub === 'verify') return verify(args, say);
153
+ if (sub === 'qualify') return qualify(args, say);
112
154
  say.err(USAGE);
113
155
  return 2;
114
156
  }
@@ -64,6 +64,7 @@ import { strengthOfControl as _strengthOfControl } from './coverage-strength.js'
64
64
  // visible regression on common real-world author names, not a fix.
65
65
  import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
66
66
  import { evaluateGraphFlowPredicate, buildObligationMappingFromGraphPredicate } from '../lineage/obligation-predicates.js';
67
+ import { pastDecisionApplies } from './triage-memory.js';
67
68
 
68
69
  // Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
69
70
  // safety) but never honoured `--pseudonymize-authors` at all — an operator
@@ -597,7 +598,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
597
598
  .flatMap(k => families.get(k) || []);
598
599
  const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
599
600
  const minRank = SEVERITY_RANK[minSeverity];
600
- const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
601
+ const open = scoped.filter(f => !f.intentSuppressed && !pastDecisionApplies(f.pastDecision) && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
601
602
  if (open.length) {
602
603
  allCleared = false;
603
604
  contributingFindings.push(...open);
@@ -74,14 +74,14 @@ export function buildDetectionIR({ finding, proof = null, allowUnproven = false
74
74
 
75
75
  let parameter = null;
76
76
  if (attack?.parameter) { parameter = { name: attack.parameter, location: attack.location }; facts.parameter = { source: 'reproducer', ref: 'attack.parameter' }; }
77
- else if (payloadClass !== 'auth-bypass') limitations.push('no vulnerable parameter is known, so the payload test is applied to every query and body value of the endpoint');
77
+ else if (def.matcher !== 'behavior') limitations.push('no vulnerable parameter is known, so the payload test is applied to every query and body value of the endpoint');
78
78
 
79
79
  let sink = null;
80
80
  if (attack?.sink) { sink = attack.sink; facts.sink = { source: 'reproducer', ref: 'attack.sink' }; }
81
81
  const affectedOperation = { sink, file: finding.file ? String(finding.file) : null, line: Number.isInteger(finding.line) ? finding.line : null };
82
82
 
83
83
  const confidence = Math.max(0.1, Math.min(0.95,
84
- (grounding === 'exploit-confirmed' ? 0.9 : grounding === 'reproduced' ? 0.7 : 0.4) - (endpoint ? 0 : 0.2) - (parameter || payloadClass === 'auth-bypass' ? 0 : 0.1)));
84
+ (grounding === 'exploit-confirmed' ? 0.9 : grounding === 'reproduced' ? 0.7 : 0.4) - (endpoint ? 0 : 0.2) - (parameter || def.matcher === 'behavior' ? 0 : 0.1)));
85
85
  if (grounding !== 'exploit-confirmed') limitations.push(grounding === 'reproduced' ? 'the exploit was reproduced but not independently confirmed' : 'the exploit was not executed; this detection rests on static analysis only');
86
86
 
87
87
  const ir = {