@clear-capabilities/agentic-security-scanner 0.195.29 → 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.
- package/CHANGELOG.md +31 -0
- package/bin/agentic-security.js +6 -3
- package/dist/1340.index.js +11 -6
- package/dist/3500.index.js +943 -0
- package/dist/5616.index.js +146 -19
- package/dist/{c06a1b4c20155c06fb48.json → 7e58a58b8e6ccdde1659.json} +6 -5
- package/dist/9560.index.js +29 -5
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -5
- package/src/egress/policy.js +79 -1
- package/src/lineage/CLAUDE.md +3 -1
- package/src/lineage/deployment/adapter-cloud.js +5 -2
- package/src/lineage/deployment/attack-paths.js +107 -30
- package/src/lineage/deployment/boundaries-run.js +2 -2
- package/src/lineage/deployment/iam-eval.js +105 -0
- package/src/lineage/deployment/projection.js +10 -4
- package/src/lineage/deployment/proof-binding.js +79 -0
- package/src/llm-validator/bounded-context.js +2 -1
- package/src/mcp/CLAUDE.md +6 -0
- package/src/mcp/tools.js +27 -3
- package/src/pipeline/evidence-record.js +1 -1
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/assurance/candidate-qualification.js +155 -0
- package/src/posture/assurance/release-package-cli.js +44 -2
- package/src/posture/auditor-walkthrough.js +2 -1
- package/src/posture/dnr/detection-ir.js +2 -2
- package/src/posture/dnr/hunt.js +22 -6
- package/src/posture/dnr/log-reader.js +47 -3
- package/src/posture/dnr/matchers.js +43 -3
- package/src/posture/dnr/outputs.js +16 -2
- package/src/posture/dnr/payload-classes.js +12 -2
- package/src/posture/dnr/validate.js +17 -4
- package/src/posture/evaluation/report.js +2 -1
- package/src/posture/evaluation/score.js +24 -7
- package/src/posture/logic-claims.js +15 -14
- package/src/posture/sibling-queue.js +69 -0
- package/src/posture/triage-memory.js +106 -26
- package/src/posture/verification-separation.js +55 -7
- package/src/report/index.js +4 -0
- package/src/runScan.js +5 -0
- package/src/scope/flags.js +1 -0
- package/src/scope/inventory.js +17 -1
- package/src/scope/release-scope.js +80 -0
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
// lenses live here in deterministic code rather than in the agent's prompt: a
|
|
41
41
|
// reviewer asked to double-check itself is the same party voting twice.
|
|
42
42
|
|
|
43
|
-
import { recordProducer, recordVerdict, consensusOf } from './verification-separation.js';
|
|
43
|
+
import { recordProducer, recordVerdict, consensusOf, refutationRemoves } from './verification-separation.js';
|
|
44
44
|
import { inferredInvariant } from './invariants/lifecycle.js';
|
|
45
45
|
|
|
46
46
|
export const PRODUCER = 'agent:logic-reviewer';
|
|
@@ -132,7 +132,7 @@ export function verifyLogicClaim(claim, fileContents) {
|
|
|
132
132
|
// ── citation ──────────────────────────────────────────────────────────────
|
|
133
133
|
if (content === null) {
|
|
134
134
|
recordVerdict(finding, {
|
|
135
|
-
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
|
|
135
|
+
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted', evidence: { kind: 'deterministic' },
|
|
136
136
|
reason: `no file '${claim.file}' was scanned, so the cited location does not exist`,
|
|
137
137
|
});
|
|
138
138
|
finding.consensus = consensusOf(finding);
|
|
@@ -143,12 +143,12 @@ export function verifyLogicClaim(claim, fileContents) {
|
|
|
143
143
|
const line = Number(claim.line);
|
|
144
144
|
if (!Number.isInteger(line) || line < 1 || line > total) {
|
|
145
145
|
recordVerdict(finding, {
|
|
146
|
-
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
|
|
146
|
+
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted', evidence: { kind: 'deterministic' },
|
|
147
147
|
reason: `cited line ${claim.line} is outside ${claim.file} (${total} lines)`,
|
|
148
148
|
});
|
|
149
149
|
} else {
|
|
150
150
|
recordVerdict(finding, {
|
|
151
|
-
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld',
|
|
151
|
+
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld', evidence: { kind: 'deterministic' },
|
|
152
152
|
reason: `${claim.file}:${line} exists`,
|
|
153
153
|
});
|
|
154
154
|
}
|
|
@@ -158,7 +158,7 @@ export function verifyLogicClaim(claim, fileContents) {
|
|
|
158
158
|
// snippet is UNDECIDED here, not upheld — silence is not corroboration.
|
|
159
159
|
if (!claim.snippet || !String(claim.snippet).trim()) {
|
|
160
160
|
recordVerdict(finding, {
|
|
161
|
-
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
|
|
161
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided', evidence: { kind: 'deterministic' },
|
|
162
162
|
reason: 'the claim quotes no source, so there is nothing to check it against',
|
|
163
163
|
});
|
|
164
164
|
} else {
|
|
@@ -170,19 +170,19 @@ export function verifyLogicClaim(claim, fileContents) {
|
|
|
170
170
|
const anywhere = _normalize(content);
|
|
171
171
|
if (window.includes(want)) {
|
|
172
172
|
recordVerdict(finding, {
|
|
173
|
-
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld',
|
|
173
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld', evidence: { kind: 'deterministic' },
|
|
174
174
|
reason: 'the quoted source appears at the cited line',
|
|
175
175
|
});
|
|
176
176
|
} else if (anywhere.includes(want)) {
|
|
177
177
|
// Right file, wrong line. Not a fabrication, but the location is not
|
|
178
178
|
// usable as-is, so it is not corroboration either.
|
|
179
179
|
recordVerdict(finding, {
|
|
180
|
-
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
|
|
180
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided', evidence: { kind: 'deterministic' },
|
|
181
181
|
reason: 'the quoted source is in the file but not at the cited line',
|
|
182
182
|
});
|
|
183
183
|
} else {
|
|
184
184
|
recordVerdict(finding, {
|
|
185
|
-
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted',
|
|
185
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted', evidence: { kind: 'deterministic' },
|
|
186
186
|
reason: 'the quoted source does not appear in the cited file',
|
|
187
187
|
});
|
|
188
188
|
}
|
|
@@ -193,30 +193,31 @@ export function verifyLogicClaim(claim, fileContents) {
|
|
|
193
193
|
if (finding.kind === 'missing-authentication') {
|
|
194
194
|
const hit = AUTH_HINTS.find((re) => re.test(block));
|
|
195
195
|
recordVerdict(finding, hit
|
|
196
|
-
? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
|
|
196
|
+
? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted', evidence: { kind: 'deterministic' },
|
|
197
197
|
reason: `the handler around this line does authenticate (${hit.source})` }
|
|
198
|
-
: { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
|
|
198
|
+
: { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld', evidence: { kind: 'deterministic' },
|
|
199
199
|
reason: 'no authentication marker anywhere in the enclosing handler' });
|
|
200
200
|
} else if (finding.kind === 'missing-authorization' || finding.kind === 'missing-ownership-check') {
|
|
201
201
|
const hit = OWNERSHIP_HINTS.find((re) => re.test(block));
|
|
202
202
|
recordVerdict(finding, hit
|
|
203
|
-
? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
|
|
203
|
+
? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted', evidence: { kind: 'deterministic' },
|
|
204
204
|
reason: `the handler around this line does scope the record to a user (${hit.source})` }
|
|
205
|
-
: { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
|
|
205
|
+
: { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld', evidence: { kind: 'deterministic' },
|
|
206
206
|
reason: 'no ownership scoping in the enclosing handler' });
|
|
207
207
|
} else {
|
|
208
208
|
// No deterministic lens exists for this kind. Said out loud rather than
|
|
209
209
|
// counted as agreement — an unchecked claim and a corroborated one must
|
|
210
210
|
// not read the same in the consensus.
|
|
211
211
|
recordVerdict(finding, {
|
|
212
|
-
verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided',
|
|
212
|
+
verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided', evidence: { kind: 'deterministic' },
|
|
213
213
|
reason: `no deterministic lens covers claim kind '${finding.kind}'`,
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
finding.consensus = consensusOf(finding);
|
|
218
218
|
// Quarantine, not deletion — the same contract falsification uses.
|
|
219
|
-
|
|
219
|
+
// threat-model FR-04.3: a critical claim is quarantined only on a refutation with sufficient independent authority
|
|
220
|
+
finding.quarantined = refutationRemoves(finding, finding.consensus);
|
|
220
221
|
return finding;
|
|
221
222
|
}
|
|
222
223
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Sibling review queue for a confirmed root cause (threat-model PRD FR-05.3, AC-05C).
|
|
2
|
+
//
|
|
3
|
+
// The root-cause sweep (`root-cause-sweep.js`) finds structural siblings of a confirmed finding and counts them exactly. Its
|
|
4
|
+
// `mitigated` count means only that a DETECTOR already reports a finding at that location: detector coverage, not a repair. This
|
|
5
|
+
// queue turns one sweep into reviewable items, each with the predicate that discovered it and a binding to its source line, and keeps
|
|
6
|
+
// six states apart:
|
|
7
|
+
//
|
|
8
|
+
// detector-reported a detector reports this location (the sweep's legacy `mitigated`)
|
|
9
|
+
// candidate a structural sibling nobody has looked at
|
|
10
|
+
// independently-confirmed a reviewer or verifier other than the producer confirmed it
|
|
11
|
+
// repair-proposed a fix has been proposed for it
|
|
12
|
+
// repaired a fix has been applied to it
|
|
13
|
+
// verified a replay-verified repair record for THIS sibling (its own hypothesis id) exists
|
|
14
|
+
//
|
|
15
|
+
// A state is raised only by evidence that names the sibling by its id. A verification record for one corrected sibling never closes
|
|
16
|
+
// another, however similar. Totals come from the sweep's exact counts; the per-state counts cover the sampled items, and `unsampled`
|
|
17
|
+
// is what the sample left out, so the numbers reconcile when the sweep capped its list.
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { validateVerificationRecord } from './assurance/verification-record.js';
|
|
20
|
+
|
|
21
|
+
const SIBLING_STATES = Object.freeze(['detector-reported', 'candidate', 'independently-confirmed', 'repair-proposed', 'repaired', 'verified']);
|
|
22
|
+
const RANK = Object.fromEntries(SIBLING_STATES.map((s, i) => [s, i]));
|
|
23
|
+
const MAX_QUEUE = 100;
|
|
24
|
+
|
|
25
|
+
/** The stable id of one sibling: its file, line and the exact text of the matched line. */
|
|
26
|
+
export const siblingId = (file, line, snippet) => `sibling:${crypto.createHash('sha256').update(`${file}\u0000${line}\u0000${snippet}`).digest('hex').slice(0, 24)}`;
|
|
27
|
+
|
|
28
|
+
function raise(item, state, basis) { if (RANK[state] > RANK[item.state]) { item.state = state; item.basis = basis; } }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} sweep one entry of `sweepRootCauses(...).sweeps`
|
|
32
|
+
* @param {{evidence?: {siblingId:string, kind:'confirmation'|'repair-proposal'|'repair'|'verification', by?:string, record?:object}[], producer?:string}} o
|
|
33
|
+
*/
|
|
34
|
+
export function buildSiblingQueue(sweep, { evidence = [], producer = null } = {}) {
|
|
35
|
+
if (!sweep || typeof sweep !== 'object') return { ok: false, reason: 'no sweep' };
|
|
36
|
+
const items = (sweep.instances || []).slice(0, MAX_QUEUE).map((m) => ({
|
|
37
|
+
id: siblingId(m.file, m.line, m.snippet), file: m.file, line: m.line,
|
|
38
|
+
discovery: { fromFindingId: sweep.fromFindingId ?? null, sinkPattern: sweep.sinkPattern ?? null, sourcePattern: sweep.sourcePattern ?? null },
|
|
39
|
+
state: m.status === 'mitigated-or-known' ? 'detector-reported' : 'candidate',
|
|
40
|
+
basis: m.status === 'mitigated-or-known' ? 'a detector reports a finding at this location (detector coverage, not a repair)' : 'structural sibling of the confirmed root cause',
|
|
41
|
+
rejected: [],
|
|
42
|
+
}));
|
|
43
|
+
const byId = new Map(items.map((it) => [it.id, it]));
|
|
44
|
+
for (const e of evidence) {
|
|
45
|
+
const it = e && byId.get(e.siblingId);
|
|
46
|
+
if (!it) continue; // evidence for a sibling not in this queue changes nothing here
|
|
47
|
+
if (e.kind === 'confirmation') {
|
|
48
|
+
if (!e.by || (producer && e.by === producer)) { it.rejected.push({ kind: e.kind, reason: 'a confirmation must come from a party other than the producer' }); continue; }
|
|
49
|
+
raise(it, 'independently-confirmed', `confirmed by ${String(e.by).slice(0, 60)}`);
|
|
50
|
+
} else if (e.kind === 'repair-proposal') raise(it, 'repair-proposed', 'a repair was proposed');
|
|
51
|
+
else if (e.kind === 'repair') raise(it, 'repaired', 'a repair was applied');
|
|
52
|
+
else if (e.kind === 'verification') {
|
|
53
|
+
const r = e.record;
|
|
54
|
+
const v = r ? validateVerificationRecord(r) : { ok: false };
|
|
55
|
+
if (!v.ok) { it.rejected.push({ kind: e.kind, reason: 'the verification record failed validation' }); continue; }
|
|
56
|
+
if (r.hypothesisId !== it.id) { it.rejected.push({ kind: e.kind, reason: 'the record verifies another sibling' }); continue; }
|
|
57
|
+
if (!r.repair || r.repair.status !== 'replay-verified') { it.rejected.push({ kind: e.kind, reason: 'the record is not a replay-verified repair' }); continue; }
|
|
58
|
+
raise(it, 'verified', `replay-verified repair record ${String(r.id).slice(0, 24)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const counts = Object.fromEntries(SIBLING_STATES.map((s) => [s, 0]));
|
|
62
|
+
for (const it of items) counts[it.state]++;
|
|
63
|
+
const found = Number(sweep.found ?? items.length);
|
|
64
|
+
return {
|
|
65
|
+
ok: true, fromFindingId: sweep.fromFindingId ?? null, items,
|
|
66
|
+
totals: { found, detectorReported: Number(sweep.mitigated ?? 0), candidatesAtDiscovery: Number(sweep.candidates ?? 0), sampled: items.length, unsampled: Math.max(0, found - items.length), byState: counts },
|
|
67
|
+
note: 'detector-reported means a detector covers the location; only a verified item has a replay-verified repair of its own',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -17,9 +17,18 @@
|
|
|
17
17
|
// why") rather than on calibration math.
|
|
18
18
|
//
|
|
19
19
|
// Opt-out: AGENTIC_SECURITY_NO_TRIAGE_MEMORY=1
|
|
20
|
+
//
|
|
21
|
+
// Threat-model PRD FR-05 (TM-07): a family/directory bucket, and any legacy free-text record, is ADVISORY history. It is attached to
|
|
22
|
+
// a finding so a reviewer can see it, and it never changes the finding's confidence. Only an EXACT decision (the same stable finding
|
|
23
|
+
// id in the same file) applies, and for a critical or high finding it also needs a named reviewer, an expiry that has not passed, a
|
|
24
|
+
// scope that matches this scan, an unchanged source fingerprint and a signature made with this install's key. Each finding's
|
|
25
|
+
// `pastDecision.status` says which: applied, advisory, expired, stale, out-of-scope or rejected. The free-text reason stays in the
|
|
26
|
+
// memory file and is never copied into a report.
|
|
20
27
|
|
|
21
28
|
import * as fs from 'node:fs';
|
|
22
29
|
import * as path from 'node:path';
|
|
30
|
+
import * as crypto from 'node:crypto';
|
|
31
|
+
import { signLastScan } from './integrity.js';
|
|
23
32
|
|
|
24
33
|
import { stateDir, stateWritesEnabled } from './state-dir.js';
|
|
25
34
|
const MEMORY_FILE = 'triage-memory.jsonl';
|
|
@@ -40,11 +49,15 @@ function _bucketKey(finding) {
|
|
|
40
49
|
* triage-memory.jsonl (structured). Idempotent — repeated calls add a
|
|
41
50
|
* single new line; existing entries are not deduped (history is a feature).
|
|
42
51
|
*/
|
|
43
|
-
export function recordDecision(scanRoot, finding, decision, reason) {
|
|
52
|
+
export function recordDecision(scanRoot, finding, decision, reason, opts = {}) {
|
|
44
53
|
if (!scanRoot || !finding || !decision) return null;
|
|
45
54
|
if (!['wont-fix', 'false-positive'].includes(decision)) return null;
|
|
46
55
|
if (!stateWritesEnabled()) return false;
|
|
47
56
|
try { fs.mkdirSync(_stateDir(scanRoot), { recursive: true }); } catch {}
|
|
57
|
+
// a retried call with the same decision for the same finding adds nothing (idempotent under retry)
|
|
58
|
+
const sid = finding.stableId || finding.id || null;
|
|
59
|
+
const prior = loadMemory(scanRoot).find((e) => e.stableId && e.stableId === sid && e.decision === decision && e.file === (finding.file || finding.file_path || '') && (e.reviewer || null) === (opts.reviewer || null) && (e.expires || null) === (opts.expires || null));
|
|
60
|
+
if (prior) return prior;
|
|
48
61
|
|
|
49
62
|
const entry = {
|
|
50
63
|
at: new Date().toISOString(),
|
|
@@ -58,7 +71,14 @@ export function recordDecision(scanRoot, finding, decision, reason) {
|
|
|
58
71
|
file: finding.file || finding.file_path || '',
|
|
59
72
|
line: finding.line || 0,
|
|
60
73
|
id: finding.id || finding.stableId || null,
|
|
74
|
+
// exact binding (FR-05.1): the stable id, the source fingerprint the decision was made on, and its scope, reviewer and expiry
|
|
75
|
+
stableId: sid,
|
|
76
|
+
fingerprint: findingFingerprint(finding),
|
|
77
|
+
...(opts.reviewer ? { reviewer: String(opts.reviewer).slice(0, 120) } : {}),
|
|
78
|
+
...(opts.expires ? { expires: String(opts.expires) } : {}),
|
|
79
|
+
...(opts.scope ? { scope: pickScope(opts.scope) } : {}),
|
|
61
80
|
};
|
|
81
|
+
if (entry.reviewer && entry.expires) entry.sig = signEntry(entry);
|
|
62
82
|
try { fs.appendFileSync(_memPath(scanRoot), JSON.stringify(entry) + '\n'); } catch {}
|
|
63
83
|
|
|
64
84
|
// Narrative entry in AGENTS.md — short, human-readable, surfaced at SessionStart.
|
|
@@ -67,7 +87,7 @@ export function recordDecision(scanRoot, finding, decision, reason) {
|
|
|
67
87
|
`- Finding: ${entry.vuln || entry.family || 'unknown'} at ${entry.file}:${entry.line}`,
|
|
68
88
|
`- Bucket: \`${entry.bucket}\``,
|
|
69
89
|
reason ? `- Why: ${entry.reason}` : null,
|
|
70
|
-
`-
|
|
90
|
+
`- Advisory only: similar findings under this bucket are NOT reviewed by this decision; each new finding is judged on its own.`,
|
|
71
91
|
'',
|
|
72
92
|
].filter(Boolean).join('\n');
|
|
73
93
|
try { fs.appendFileSync(_agentsPath(scanRoot), '\n' + narrative); } catch {}
|
|
@@ -78,16 +98,59 @@ export function recordDecision(scanRoot, finding, decision, reason) {
|
|
|
78
98
|
/**
|
|
79
99
|
* Load past decisions from triage-memory.jsonl.
|
|
80
100
|
*/
|
|
81
|
-
export function loadMemory(scanRoot) {
|
|
101
|
+
export function loadMemory(scanRoot) { return loadMemoryWithDiagnostics(scanRoot).entries; }
|
|
102
|
+
|
|
103
|
+
const MAX_MEMORY_BYTES = 8 * 1024 * 1024;
|
|
104
|
+
/** Load past decisions with bounded diagnostics: malformed lines (a torn write) are counted and skipped, never fatal. */
|
|
105
|
+
export function loadMemoryWithDiagnostics(scanRoot) {
|
|
82
106
|
const fp = _memPath(scanRoot);
|
|
83
|
-
|
|
107
|
+
const out = { entries: [], malformed: 0, unreadable: false, truncated: false };
|
|
108
|
+
let text;
|
|
84
109
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} catch { return
|
|
110
|
+
const st = fs.statSync(fp);
|
|
111
|
+
if (st.size > MAX_MEMORY_BYTES) out.truncated = true;
|
|
112
|
+
const fd = fs.openSync(fp, 'r');
|
|
113
|
+
try { const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES)); fs.readSync(fd, buf, 0, buf.length, 0); text = buf.toString('utf8'); } finally { fs.closeSync(fd); }
|
|
114
|
+
} catch (e) { if (e && e.code !== 'ENOENT') out.unreadable = true; return out; }
|
|
115
|
+
for (const l of text.split('\n')) {
|
|
116
|
+
if (!l.trim()) continue;
|
|
117
|
+
try { const e = JSON.parse(l); if (e && typeof e === 'object' && !Array.isArray(e)) out.entries.push(e); else out.malformed++; } catch { out.malformed++; }
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── exact decisions (threat-model FR-05) ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
const SCOPE_KEYS = ['repository', 'environment', 'tenant'];
|
|
124
|
+
function pickScope(s) { const o = {}; for (const k of SCOPE_KEYS) if (s && s[k]) o[k] = String(s[k]).slice(0, 120); return o; }
|
|
125
|
+
/** The source fingerprint a decision is bound to: the finding record's source hash when there is one, else its location and snippet. */
|
|
126
|
+
function findingFingerprint(f) {
|
|
127
|
+
const h = f.findingRecord?.sourceHash || f.record?.sourceHash || f.sourceHash;
|
|
128
|
+
if (h) return String(h);
|
|
129
|
+
return crypto.createHash('sha256').update(JSON.stringify([f.file || '', f.line || 0, f.cwe || '', String(f.snippet || '').slice(0, 400)])).digest('hex');
|
|
130
|
+
}
|
|
131
|
+
const SIGNED_FIELDS = ['decision', 'stableId', 'file', 'fingerprint', 'reviewer', 'expires', 'scope'];
|
|
132
|
+
function canonicalEntry(e) { return JSON.stringify(SIGNED_FIELDS.map((k) => [k, e[k] ?? null])); }
|
|
133
|
+
function signEntry(e) { return signLastScan(canonicalEntry(e)); }
|
|
134
|
+
function signatureValid(e) {
|
|
135
|
+
if (typeof e.sig !== 'string') return false;
|
|
136
|
+
const want = signEntry(e);
|
|
137
|
+
return want.length === e.sig.length && crypto.timingSafeEqual(Buffer.from(want, 'hex'), Buffer.from(e.sig, 'hex'));
|
|
90
138
|
}
|
|
139
|
+
const CONSEQUENTIAL = new Set(['critical', 'high']);
|
|
140
|
+
|
|
141
|
+
/** How one exact decision bears on one finding now: applied | expired | out-of-scope | stale | advisory | rejected, with a reason code. */
|
|
142
|
+
function judgeExact(e, f, ctx) {
|
|
143
|
+
const now = ctx.now ?? Date.now();
|
|
144
|
+
if (e.expires && !(Date.parse(e.expires) > now)) return { status: 'expired', code: 'expired' };
|
|
145
|
+
for (const k of SCOPE_KEYS) if (e.scope && e.scope[k] && ctx[k] && e.scope[k] !== ctx[k]) return { status: 'out-of-scope', code: `${k}-differs` };
|
|
146
|
+
if (e.fingerprint && e.fingerprint !== findingFingerprint(f)) return { status: 'stale', code: 'source-changed' };
|
|
147
|
+
if (CONSEQUENTIAL.has(f.severity)) {
|
|
148
|
+
if (!e.reviewer || !e.expires) return { status: 'advisory', code: 'unreviewed-or-unexpiring' };
|
|
149
|
+
if (!signatureValid(e)) return { status: 'rejected', code: e.sig ? 'signature-invalid' : 'unsigned' };
|
|
150
|
+
}
|
|
151
|
+
return { status: 'applied', code: 'exact-match' };
|
|
152
|
+
}
|
|
153
|
+
const reasonDigest = (r) => (r ? crypto.createHash('sha256').update(String(r)).digest('hex').slice(0, 16) : null);
|
|
91
154
|
|
|
92
155
|
/**
|
|
93
156
|
* Annotator: for each finding, check if its (family, file-glob) bucket
|
|
@@ -97,11 +160,18 @@ export function loadMemory(scanRoot) {
|
|
|
97
160
|
* Does NOT remove findings — only annotates. UI / report layer decides
|
|
98
161
|
* how to display.
|
|
99
162
|
*/
|
|
100
|
-
export function suppressByPastDecisions(scanRoot, findings) {
|
|
163
|
+
export function suppressByPastDecisions(scanRoot, findings, ctx = {}) {
|
|
101
164
|
if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY === '1') return { applied: 0 };
|
|
102
165
|
if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
|
|
103
|
-
const
|
|
104
|
-
|
|
166
|
+
const diag = loadMemoryWithDiagnostics(scanRoot);
|
|
167
|
+
const memory = diag.entries;
|
|
168
|
+
if (!memory.length) return { applied: 0, diagnostics: { malformed: diag.malformed, unreadable: diag.unreadable, truncated: diag.truncated } };
|
|
169
|
+
const exactById = new Map();
|
|
170
|
+
for (const e of memory) {
|
|
171
|
+
if (!e.stableId) continue;
|
|
172
|
+
const k = `${e.stableId}\u0000${e.file || ''}`;
|
|
173
|
+
if (!exactById.has(k) || exactById.get(k).at < e.at) exactById.set(k, e);
|
|
174
|
+
}
|
|
105
175
|
|
|
106
176
|
// Build bucket → most-recent decision map.
|
|
107
177
|
const bucketDecision = new Map();
|
|
@@ -111,24 +181,34 @@ export function suppressByPastDecisions(scanRoot, findings) {
|
|
|
111
181
|
}
|
|
112
182
|
}
|
|
113
183
|
|
|
114
|
-
let applied = 0;
|
|
184
|
+
let applied = 0; let advisory = 0;
|
|
185
|
+
const byStatus = {};
|
|
115
186
|
for (const f of findings) {
|
|
116
|
-
const
|
|
117
|
-
const past = bucketDecision.get(
|
|
187
|
+
const exact = exactById.get(`${f.stableId || f.id || ''}\u0000${f.file || f.file_path || ''}`);
|
|
188
|
+
const past = exact || bucketDecision.get(_bucketKey(f));
|
|
118
189
|
if (!past) continue;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
sameBucket: true,
|
|
124
|
-
};
|
|
125
|
-
// Soft demote: drop confidence and add a tag explaining why.
|
|
126
|
-
if (typeof f.confidence === 'number') f.confidence = Math.max(0.2, f.confidence * 0.5);
|
|
190
|
+
const j = exact ? judgeExact(exact, f, ctx) : { status: 'advisory', code: 'family-directory-bucket' };
|
|
191
|
+
byStatus[j.status] = (byStatus[j.status] || 0) + 1;
|
|
192
|
+
// the reason is a digest here: the free text stays in the memory file and never reaches a report
|
|
193
|
+
f.pastDecision = { status: j.status, basis: exact ? 'exact' : 'bucket', reasonCode: j.code, decision: past.decision, at: past.at, reasonDigest: reasonDigest(past.reason), ...(exact && exact.reviewer ? { reviewer: exact.reviewer } : {}), ...(exact && exact.expires ? { expires: exact.expires } : {}), sameBucket: !exact };
|
|
127
194
|
f.tags = Array.isArray(f.tags) ? f.tags : [];
|
|
128
|
-
if (
|
|
129
|
-
|
|
195
|
+
if (j.status === 'applied') {
|
|
196
|
+
// an exact, in-scope decision: the soft demote and tag, as before
|
|
197
|
+
if (typeof f.confidence === 'number') f.confidence = Math.max(0.2, f.confidence * 0.5);
|
|
198
|
+
if (!f.tags.includes('past-decision')) f.tags.push('past-decision');
|
|
199
|
+
applied++;
|
|
200
|
+
} else {
|
|
201
|
+
if (!f.tags.includes('past-decision-advisory')) f.tags.push('past-decision-advisory');
|
|
202
|
+
advisory++;
|
|
203
|
+
}
|
|
130
204
|
}
|
|
131
|
-
return { applied, total: findings.length };
|
|
205
|
+
return { applied, advisory, byStatus, total: findings.length, diagnostics: { malformed: diag.malformed, unreadable: diag.unreadable, truncated: diag.truncated } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** True only for a past decision that applies; an advisory, expired, stale, out-of-scope or rejected one leaves the finding open. */
|
|
209
|
+
export function pastDecisionApplies(pd) {
|
|
210
|
+
if (pd === true) return true; // a legacy boolean marker set by an older caller
|
|
211
|
+
return !!pd && typeof pd === 'object' && (pd.status === undefined || pd.status === 'applied');
|
|
132
212
|
}
|
|
133
213
|
|
|
134
214
|
/**
|
|
@@ -86,7 +86,7 @@ export function assertSeparation(finding, verifierId) {
|
|
|
86
86
|
* nothing — when separation fails or the verdict is not one of
|
|
87
87
|
* VERIFICATION_VERDICTS. Never touches severity, never removes anything.
|
|
88
88
|
*/
|
|
89
|
-
export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {}) {
|
|
89
|
+
export function recordVerdict(finding, { verifierId, lens, verdict, reason, evidence } = {}) {
|
|
90
90
|
if (!finding || typeof finding !== 'object') return _refuse('no finding');
|
|
91
91
|
if (!lens) return _refuse('no lens');
|
|
92
92
|
if (!VERIFICATION_VERDICTS.includes(verdict)) {
|
|
@@ -100,6 +100,7 @@ export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {
|
|
|
100
100
|
lens: String(lens),
|
|
101
101
|
verdict,
|
|
102
102
|
...(reason ? { reason: String(reason) } : {}),
|
|
103
|
+
...(evidence && typeof evidence === 'object' ? { evidence: _evidence(evidence) } : {}),
|
|
103
104
|
};
|
|
104
105
|
const list = finding.verification.verdicts;
|
|
105
106
|
const i = list.findIndex(v => v.verifierId === entry.verifierId && v.lens === entry.lens);
|
|
@@ -107,10 +108,29 @@ export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {
|
|
|
107
108
|
return { ok: true, recorded: entry };
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
// Threat-model PRD FR-04.3 (TM-06): what a verdict rests on. Votes that share an observation, a generator or a lineage are ONE piece of
|
|
112
|
+
// evidence however many lenses they come through; a verdict with none declared counts as its verifier's own. `kind` says what sort of
|
|
113
|
+
// evidence it is: 'effect' (an observed effect), 'deterministic' (a check of the code itself) or 'reasoning' (an argument, a model).
|
|
114
|
+
const EVIDENCE_KINDS = new Set(['effect', 'deterministic', 'reasoning']);
|
|
115
|
+
function _evidence(e) {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const k of ['observationId', 'generator', 'lineage']) if (e[k]) out[k] = String(e[k]).slice(0, 120);
|
|
118
|
+
if (EVIDENCE_KINDS.has(e.kind)) out.kind = e.kind;
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function _groupKey(v) {
|
|
122
|
+
const e = v.evidence || {};
|
|
123
|
+
return e.observationId ? `observation:${e.observationId}` : e.lineage ? `lineage:${e.lineage}` : e.generator ? `generator:${e.generator}` : `verifier:${v.verifierId}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
/**
|
|
111
|
-
* Majority across
|
|
112
|
-
*
|
|
113
|
-
*
|
|
127
|
+
* Majority across INDEPENDENT evidence groups (threat-model FR-04.3). Each group (a shared observation, generator or lineage, else one
|
|
128
|
+
* verifier) has one vote, the majority of its own verdicts; two lenses from one verifier are one vote. Ties, including "no verdicts at
|
|
129
|
+
* all", are 'undecided'. The per-lens counts (`upheld`, `refuted`, `undecided`) and `lenses` are kept for compatibility and for the
|
|
130
|
+
* reviewer. `conflict` is true when independent groups disagree. `refutationAuthority` says whether the refutation could remove a
|
|
131
|
+
* critical finding from the actionable queue: 'sufficient' only with no conflict and at least one refuting group backed by effect or
|
|
132
|
+
* deterministic evidence; a correlated majority or reasoning alone is 'advisory'. A generator that produced the finding cannot
|
|
133
|
+
* corroborate it.
|
|
114
134
|
*/
|
|
115
135
|
export function consensusOf(finding) {
|
|
116
136
|
const list = (finding && finding.verification && Array.isArray(finding.verification.verdicts))
|
|
@@ -124,8 +144,36 @@ export function consensusOf(finding) {
|
|
|
124
144
|
else if (v.verdict === 'refuted') refuted++;
|
|
125
145
|
else if (v.verdict === 'undecided') undecided++;
|
|
126
146
|
}
|
|
147
|
+
const producer = finding && finding.verification && finding.verification.producer;
|
|
148
|
+
const groups = new Map();
|
|
149
|
+
let selfAuthored = 0;
|
|
150
|
+
for (const v of list) {
|
|
151
|
+
if (!v || !VERIFICATION_VERDICTS.includes(v.verdict)) continue;
|
|
152
|
+
if (producer && v.evidence && v.evidence.generator && `detector:${v.evidence.generator}` === producer) { selfAuthored++; continue; }
|
|
153
|
+
const k = _groupKey(v);
|
|
154
|
+
const g = groups.get(k) || { key: k, upheld: 0, refuted: 0, undecided: 0, kinds: new Set(), lenses: new Set() };
|
|
155
|
+
g[v.verdict]++; if (v.evidence && v.evidence.kind) g.kinds.add(v.evidence.kind); g.lenses.add(String(v.lens));
|
|
156
|
+
groups.set(k, g);
|
|
157
|
+
}
|
|
158
|
+
const vote = (g) => (g.upheld > g.refuted ? 'upheld' : g.refuted > g.upheld ? 'refuted' : 'undecided');
|
|
159
|
+
const independent = { upheld: 0, refuted: 0, undecided: 0, groups: groups.size };
|
|
160
|
+
for (const g of groups.values()) independent[vote(g)]++;
|
|
127
161
|
let verdict = 'undecided';
|
|
128
|
-
if (upheld > refuted) verdict = 'upheld';
|
|
129
|
-
else if (refuted > upheld) verdict = 'refuted';
|
|
130
|
-
|
|
162
|
+
if (independent.upheld > independent.refuted) verdict = 'upheld';
|
|
163
|
+
else if (independent.refuted > independent.upheld) verdict = 'refuted';
|
|
164
|
+
const conflict = independent.upheld > 0 && independent.refuted > 0;
|
|
165
|
+
const strongRefuter = [...groups.values()].some((g) => vote(g) === 'refuted' && (g.kinds.has('effect') || g.kinds.has('deterministic')));
|
|
166
|
+
const refutationAuthority = verdict !== 'refuted' ? 'none' : (!conflict && strongRefuter ? 'sufficient' : 'advisory');
|
|
167
|
+
return {
|
|
168
|
+
verdict, upheld, refuted, undecided, lenses: [...lenses].sort(),
|
|
169
|
+
independent, conflict, refutationAuthority, ...(selfAuthored ? { selfAuthoredExcluded: selfAuthored } : {}),
|
|
170
|
+
groups: [...groups.values()].map((g) => ({ key: g.key, vote: vote(g), lenses: [...g.lenses].sort(), kinds: [...g.kinds].sort() })).sort((a, b) => a.key.localeCompare(b.key)),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether a refuted consensus may take a finding of this severity out of the actionable queue. Critical needs sufficient authority. */
|
|
175
|
+
export function refutationRemoves(finding, consensus = consensusOf(finding)) {
|
|
176
|
+
if (!consensus || consensus.verdict !== 'refuted') return false;
|
|
177
|
+
if (finding && finding.severity === 'critical') return consensus.refutationAuthority === 'sufficient';
|
|
178
|
+
return true;
|
|
131
179
|
}
|
package/src/report/index.js
CHANGED
|
@@ -776,6 +776,10 @@ export function toJSON(scan, meta={}, opts={}){
|
|
|
776
776
|
// hand-built `scan` object in a test), same convention as the S7 fields
|
|
777
777
|
// above; a real scan always computes it.
|
|
778
778
|
...(scan.scanRecord ? { scanRecord: scan.scanRecord } : {}),
|
|
779
|
+
// Threat-model FR-01.1: the scan scope (policy, exact totals, reasons, overrides, coverage conditions, the inventory digest and
|
|
780
|
+
// bounded samples) whenever the scan computed one. Absent for a legacy (non-Git-scoped) scan, which therefore cannot satisfy a
|
|
781
|
+
// strict scope gate.
|
|
782
|
+
...(scan.scope ? { scope: scan.scope } : {}),
|
|
779
783
|
...(scan.reportView ? { reportView: scan.reportView } : {}),
|
|
780
784
|
...(scan.dispositionCounts ? { dispositionCounts: scan.dispositionCounts } : {}),
|
|
781
785
|
// PRD FR 05: every candidate stays discoverable. Equivalent claims grouped under a primary are listed here, and the
|
package/src/runScan.js
CHANGED
|
@@ -301,11 +301,16 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
301
301
|
// The included list itself lets a later comparison tell "no longer reported because it was fixed" from "no longer reported
|
|
302
302
|
// because a narrower scan did not look". Kept only when small enough not to bloat the record.
|
|
303
303
|
...(scopeInventory.included.length <= 5000 ? { included: scopeInventory.included } : { includedOmitted: true }),
|
|
304
|
+
// Threat-model FR-01.1: the digest covers every decision before the caps below; the lists are samples, the counts are exact.
|
|
305
|
+
inventoryDigest: scopeInventory.digest,
|
|
304
306
|
skippedCount: scopeInventory.skipped.length,
|
|
305
307
|
skippedByReason: reasons,
|
|
308
|
+
skippedSampled: scopeInventory.skipped.length > 500,
|
|
306
309
|
skipped: scopeInventory.skipped.slice(0, 500),
|
|
307
310
|
overrides: scopeInventory.overrides,
|
|
308
311
|
coverageConditions: scopeInventory.skipped.filter((x) => x.coverageCondition).slice(0, 100),
|
|
312
|
+
// eligibility is not analysis: how many eligible files were actually read, and how many were too large to read
|
|
313
|
+
read: { files: Object.keys(fileContents || {}).length + Object.keys(depFileContents || {}).length, oversize: Array.isArray(sizeSkipped) ? sizeSkipped.length : 0 },
|
|
309
314
|
};
|
|
310
315
|
}
|
|
311
316
|
// FR 10: additive evidence contract. Finding records carry the source hash and span; the scan record ties the run to a commit,
|
package/src/scope/flags.js
CHANGED
|
@@ -35,6 +35,7 @@ export function renderScopePreview(inv, { maxLines = 40 } = {}) {
|
|
|
35
35
|
const p = inv.policy;
|
|
36
36
|
L.push(`Scope mode: ${p.mode}${p.respectGitignore ? '' : ' (git ignore rules disabled)'}; tracked-file semantics: ${p.trackedFileSemantics ? 'yes' : 'no'}`);
|
|
37
37
|
if (p.ruleDigest) L.push(`Effective ignore-rule digest: ${p.ruleDigest}`);
|
|
38
|
+
if (inv.digest) L.push(`Inventory digest: ${inv.digest}`);
|
|
38
39
|
for (const d of p.disclosures) L.push(`Disclosure: ${d}`);
|
|
39
40
|
L.push(`Included: ${inv.included.length} file(s)`);
|
|
40
41
|
for (const f of inv.included.slice(0, maxLines)) L.push(` + ${f}`);
|
package/src/scope/inventory.js
CHANGED
|
@@ -220,7 +220,23 @@ export function buildScanInventory(root, o = {}) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
included.sort();
|
|
223
|
-
return { included, skipped, overrides, policy };
|
|
223
|
+
return { included, skipped, overrides, policy, digest: inventoryDigest({ included, skipped, overrides, policy }) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Threat-model FR-01.1: a deterministic digest over the COMPLETE normalized eligibility decisions (every included path, every skipped
|
|
228
|
+
* path and its reason, every override, and the policy), computed before any display cap. Two scans with the same digest made the same
|
|
229
|
+
* eligibility decisions; an `--include-path` changes it. Eligibility is not analysis: a file can be eligible and still not be read.
|
|
230
|
+
*/
|
|
231
|
+
export function inventoryDigest({ included, skipped, overrides, policy }) {
|
|
232
|
+
const canon = {
|
|
233
|
+
v: 1,
|
|
234
|
+
policy: { mode: policy.mode, respectGitignore: !!policy.respectGitignore, trackedFileSemantics: !!policy.trackedFileSemantics, ruleDigest: policy.ruleDigest ?? null },
|
|
235
|
+
included: [...included].sort(),
|
|
236
|
+
skipped: skipped.map((x) => `${x.path}\u0000${x.reason}`).sort(),
|
|
237
|
+
overrides: overrides.map((x) => `${x.path}\u0000${x.overrode}`).sort(),
|
|
238
|
+
};
|
|
239
|
+
return crypto.createHash('sha256').update(JSON.stringify(canon)).digest('hex');
|
|
224
240
|
}
|
|
225
241
|
|
|
226
242
|
function walkAll(root) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Release scope reconciliation (threat-model PRD FR-01.2, AC-01B, AC-01C).
|
|
2
|
+
//
|
|
3
|
+
// An ordinary scan reports its own scope and is never failed by this module. A QUALIFIED release claim reconciles the scan against
|
|
4
|
+
// what actually ships: the authoritative build or package inventory, the route manifest and the artifact's source binding. Every
|
|
5
|
+
// critical shipped unit and route ends in one of: covered, excluded (with the scope's reason), excepted (a scoped, independently
|
|
6
|
+
// verified compensating control that has not expired), unsupported or unknown. The gate is satisfied only when every critical item is
|
|
7
|
+
// covered or excepted and the binding is current. A report with no scope (a legacy or non-Git-scoped scan) cannot satisfy it.
|
|
8
|
+
//
|
|
9
|
+
// Pure: report and declarations in, decision out. No file is read.
|
|
10
|
+
|
|
11
|
+
const norm = (p) => String(p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
12
|
+
// `/records/:id`, `/records/{id}` and `/records/<id>` are the same template
|
|
13
|
+
const routeKey = (method, path) => `${String(method ?? 'GET').toUpperCase()} ${String(path ?? '').replace(/(?::[A-Za-z_]\w*|\{[^}/]+\}|<[^>/]+>)/g, ':p').replace(/\/+$/, '') || '/'}`;
|
|
14
|
+
|
|
15
|
+
function exceptionFor(exceptions, kind, key, now) {
|
|
16
|
+
for (const e of exceptions || []) {
|
|
17
|
+
if (!e || e.kind !== kind || norm(e.target) !== norm(key)) continue;
|
|
18
|
+
const c = e.compensatingControl;
|
|
19
|
+
if (!c || !c.id || !c.verifiedBy || c.independent !== true) return { ok: false, reason: 'the exception names no independently verified compensating control' };
|
|
20
|
+
if (!e.expires || !(Date.parse(e.expires) > now)) return { ok: false, reason: e.expires ? 'the exception has expired' : 'the exception has no expiry' };
|
|
21
|
+
return { ok: true, control: c.id, expires: e.expires };
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} report the JSON report (`scope`, `routes`, `scanRecord`)
|
|
28
|
+
* @param {{shipped?:{path:string,critical?:boolean,provenance?:string}[], routes?:{method:string,path:string,critical?:boolean}[],
|
|
29
|
+
* artifact?:{sourceCommit?:string}|null, exceptions?:object[], now?:number}} d
|
|
30
|
+
*/
|
|
31
|
+
export function evaluateReleaseScope(report, d = {}) {
|
|
32
|
+
const now = d.now ?? Date.now();
|
|
33
|
+
const reasons = [];
|
|
34
|
+
const items = [];
|
|
35
|
+
const scope = report && report.scope;
|
|
36
|
+
if (!scope) {
|
|
37
|
+
return { status: 'unresolved', reasons: [{ code: 'scope-missing', detail: 'the report carries no scan scope (a legacy or non-Git-scoped scan), so what it covered cannot be shown' }], items };
|
|
38
|
+
}
|
|
39
|
+
const included = Array.isArray(scope.included) ? new Set(scope.included.map(norm)) : null;
|
|
40
|
+
const skippedByPath = new Map((scope.skipped || []).map((s) => [norm(s.path), s]));
|
|
41
|
+
const skippedUnder = (p) => { for (const [k, s] of skippedByPath) if (k.endsWith('/') ? p.startsWith(k) : (p === k || p.startsWith(`${k}/`))) return s; return null; };
|
|
42
|
+
|
|
43
|
+
for (const u of d.shipped || []) {
|
|
44
|
+
const p = norm(u.path);
|
|
45
|
+
let outcome; let reason = null;
|
|
46
|
+
if (included && included.has(p)) outcome = 'covered';
|
|
47
|
+
else if (skippedUnder(p)) { outcome = 'excluded'; reason = skippedUnder(p).reason; }
|
|
48
|
+
else if (!included) { outcome = 'unknown'; reason = 'the scan recorded only counts for its included files (too many to list), so this file cannot be confirmed'; }
|
|
49
|
+
else { outcome = 'unknown'; reason = 'the shipped file is neither in the scan\'s inventory nor among its recorded exclusions'; }
|
|
50
|
+
if (outcome !== 'covered') {
|
|
51
|
+
const ex = exceptionFor(d.exceptions, 'file', p, now);
|
|
52
|
+
if (ex && ex.ok) { outcome = 'excepted'; reason = `compensating control ${ex.control} until ${ex.expires}`; }
|
|
53
|
+
else if (ex) reason = `${reason}; ${ex.reason}`;
|
|
54
|
+
}
|
|
55
|
+
items.push({ kind: 'file', target: p, critical: u.critical !== false, outcome, reason, provenance: u.provenance ?? 'build-inventory' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const scanned = new Set((report.routes || []).filter((r) => r && r.path && r.path !== '(file-based)').map((r) => routeKey(r.method, r.path)));
|
|
59
|
+
for (const r of d.routes || []) {
|
|
60
|
+
const key = routeKey(r.method, r.path);
|
|
61
|
+
let outcome = scanned.has(key) ? 'covered' : 'unknown';
|
|
62
|
+
let reason = outcome === 'covered' ? null : 'no route in the scanned code matches this manifest entry';
|
|
63
|
+
if (outcome !== 'covered') {
|
|
64
|
+
const ex = exceptionFor(d.exceptions, 'route', key, now);
|
|
65
|
+
if (ex && ex.ok) { outcome = 'excepted'; reason = `compensating control ${ex.control} until ${ex.expires}`; }
|
|
66
|
+
else if (ex) reason = `${reason}; ${ex.reason}`;
|
|
67
|
+
}
|
|
68
|
+
items.push({ kind: 'route', target: key, critical: r.critical !== false, outcome, reason, provenance: 'route-manifest' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const source = report.scanRecord && report.scanRecord.source;
|
|
72
|
+
if (d.artifact && d.artifact.sourceCommit) {
|
|
73
|
+
if (!source || !source.commit) reasons.push({ code: 'binding-unknown', detail: 'the report records no source commit, so the artifact cannot be bound to it' });
|
|
74
|
+
else if (source.commit !== d.artifact.sourceCommit) reasons.push({ code: 'stale-binding', detail: `the artifact was built from ${String(d.artifact.sourceCommit).slice(0, 12)} but the scan was of ${String(source.commit).slice(0, 12)}` });
|
|
75
|
+
else if (source.dirty) reasons.push({ code: 'dirty-input', detail: 'the scanned tree had uncommitted changes, so it is not exactly the artifact\'s source' });
|
|
76
|
+
} else reasons.push({ code: 'artifact-unbound', detail: 'no artifact source commit was declared, so the scan is not bound to what ships' });
|
|
77
|
+
|
|
78
|
+
for (const it of items) if (it.critical && it.outcome !== 'covered' && it.outcome !== 'excepted') reasons.push({ code: `${it.kind}-${it.outcome}`, detail: `${it.target}: ${it.reason}` });
|
|
79
|
+
return { status: reasons.length ? 'unresolved' : 'satisfied', reasons, items, inventoryDigest: scope.inventoryDigest ?? null };
|
|
80
|
+
}
|