@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.
- package/CHANGELOG.md +41 -0
- package/bin/agentic-security.js +13 -4
- package/dist/1340.index.js +11 -6
- package/dist/3500.index.js +943 -0
- package/dist/5616.index.js +146 -19
- package/dist/{3eabd90d6d9c8c8dbbd9.json → 7e58a58b8e6ccdde1659.json} +7 -6
- 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 +7 -6
- package/src/egress/policy.js +79 -1
- package/src/engine.js +1 -1
- package/src/language/CLAUDE.md +1 -1
- package/src/language/haskell-guards.js +26 -5
- 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 +115 -8
- package/src/llm-validator/index.js +1 -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
|
@@ -26,9 +26,12 @@ import { indexGraph, validateBoundaryGraph, boundaryCrossings } from './boundary
|
|
|
26
26
|
import { semanticId, hypothesisIdFromFinding } from '../../posture/assurance/identity.js';
|
|
27
27
|
import { validateVerificationRecord, deriveConfirmationLevel } from '../../posture/assurance/verification-record.js';
|
|
28
28
|
import { nonTaintClassOf, SCENARIO_CLASSES } from '../../posture/oracles/scenario-classes.js';
|
|
29
|
+
import { decideAction, resourceMatches, UNKNOWN_ACTION } from './iam-eval.js';
|
|
30
|
+
import { judgePathPromotion } from './proof-binding.js';
|
|
29
31
|
|
|
30
32
|
export const BOUNDARY_CONTEXT_SCHEMA = 'agentic-security/boundary-context';
|
|
31
|
-
|
|
33
|
+
// 1.1.0 (threat-model FR-03.3): optional `truncation` (why a search stopped) and `action` (the action a query asked about).
|
|
34
|
+
export const BOUNDARY_CONTEXT_VERSION = '1.1.0';
|
|
32
35
|
|
|
33
36
|
export const TRAVERSED_RELATIONS = Object.freeze(['routes-to', 'calls', 'depends-on', 'network-allows', 'assumes', 'grants']);
|
|
34
37
|
const TRAVERSED = new Set(TRAVERSED_RELATIONS);
|
|
@@ -122,24 +125,61 @@ function hopsFrom(ix, nodeId) {
|
|
|
122
125
|
else if (TRAVERSED.has(e.relation)) h.edges.push(e);
|
|
123
126
|
hops.set(e.to, h);
|
|
124
127
|
}
|
|
128
|
+
// threat-model FR-03.1: a deny statement whose resource is a pattern (`*`, `arn:aws:s3:::bucket/*`) is a node of its own, so before
|
|
129
|
+
// this it never reached the hop to the concrete resource it covers. It now applies to every resource its pattern matches.
|
|
130
|
+
const patternDenies = [];
|
|
131
|
+
for (const [to, h] of hops) {
|
|
132
|
+
const name = ix.nodesById.get(to)?.name ?? '';
|
|
133
|
+
if (/[*?]/.test(name)) for (const d of h.denies) if (d.relation === 'grants') patternDenies.push({ d, pattern: name });
|
|
134
|
+
}
|
|
135
|
+
for (const [to, h] of hops) {
|
|
136
|
+
const name = ix.nodesById.get(to)?.name ?? '';
|
|
137
|
+
for (const { d, pattern } of patternDenies) if (d.to !== to && resourceMatches(pattern, name) && !h.denies.includes(d)) h.denies.push(d);
|
|
138
|
+
}
|
|
125
139
|
for (const [k, h] of [...hops]) if (!h.edges.length) hops.delete(k);
|
|
126
140
|
return hops;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
function
|
|
130
|
-
if (h.denies.length) return 'blocked';
|
|
143
|
+
function baseState(h, from, to) {
|
|
131
144
|
if (h.edges.some((e) => e.pathState === 'runtime-supported')) return 'runtime-supported';
|
|
132
145
|
if (!from.resolved || !to.resolved || h.edges.every((e) => e.pathState === 'unresolved' || e.pathState === 'blocked')) return 'unresolved';
|
|
133
146
|
return 'possible';
|
|
134
147
|
}
|
|
135
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Decide a hop. A network deny, or a deny on a relation other than an IAM grant, blocks it as before. An IAM hop is decided per
|
|
151
|
+
* action (threat-model FR-03.1): with a requested action, that action only; without one, every action the allow statements grant, and
|
|
152
|
+
* the hop is possible when any of them is. A deny blocks only an action it matches, under a condition that holds for the request
|
|
153
|
+
* context; a condition that cannot be evaluated leaves the action unresolved.
|
|
154
|
+
* @returns {{state:string, denies:object[], actions:object[]|null}}
|
|
155
|
+
*/
|
|
156
|
+
function hopDecision(h, from, to, q = null) {
|
|
157
|
+
const other = h.denies.filter((d) => d.relation !== 'grants');
|
|
158
|
+
if (other.length) return { state: 'blocked', denies: other.map((d) => ({ d, why: null })), actions: null };
|
|
159
|
+
const allows = h.edges.filter((e) => e.relation === 'grants' && e.effect === 'allow');
|
|
160
|
+
const iamDenies = h.denies.filter((d) => d.relation === 'grants');
|
|
161
|
+
const base = baseState(h, from, to);
|
|
162
|
+
if (!allows.length) return { state: base, denies: [], actions: null };
|
|
163
|
+
const recorded = [...new Set(allows.flatMap((e) => String(e.attrs?.actions ?? '').split(',').filter(Boolean)))].sort();
|
|
164
|
+
// with no IAM deny on the hop and no requested action there is nothing to decide per action: the grant stands as before
|
|
165
|
+
if (!iamDenies.length && !(q && q.action)) return { state: base, denies: [], actions: recorded.length ? recorded.map((action) => ({ action, outcome: base === 'unresolved' ? 'unresolved' : 'possible', blockedBy: [], unresolvedBy: [] })) : null };
|
|
166
|
+
const wanted = q && q.action ? [q.action] : (recorded.length ? recorded : [UNKNOWN_ACTION]);
|
|
167
|
+
const decisions = wanted.map((a) => decideAction(allows, iamDenies, a, q && q.context));
|
|
168
|
+
const pick = (o) => decisions.some((d) => d.outcome === o);
|
|
169
|
+
const state = pick('possible') ? base : pick('unresolved') ? 'unresolved' : pick('blocked') ? 'blocked' : 'not-granted';
|
|
170
|
+
const denies = state === 'blocked' ? decisions.flatMap((d) => d.blockers).map((b) => ({ d: iamDenies.find((x) => x.id === b.edgeId), why: b })) : [];
|
|
171
|
+
return { state, denies, actions: decisions.map(({ action, outcome, blockers, unresolvedBy }) => ({ action, outcome, blockedBy: blockers.map((b) => b.edgeId), unresolvedBy })) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hopState(h, from, to, q = null) { return hopDecision(h, from, to, q).state; }
|
|
175
|
+
|
|
136
176
|
function isStale(e, nowMs, staleAfterMs) {
|
|
137
177
|
if (e.attrs?.stale === true) return true;
|
|
138
178
|
if (nowMs === null || !e.observationInterval) return false;
|
|
139
179
|
return nowMs - Date.parse(e.observationInterval.end) > staleAfterMs;
|
|
140
180
|
}
|
|
141
181
|
|
|
142
|
-
function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
|
|
182
|
+
function buildPath(ix, seq, hopList, nowMs, staleAfterMs, q = null) {
|
|
143
183
|
const nodes = seq.map((id) => ix.nodesById.get(id));
|
|
144
184
|
const hops = [];
|
|
145
185
|
const conditions = [];
|
|
@@ -151,16 +191,20 @@ function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
|
|
|
151
191
|
hopList.forEach((h, i) => {
|
|
152
192
|
from = nodes[i];
|
|
153
193
|
const to = nodes[i + 1];
|
|
154
|
-
const
|
|
194
|
+
const dec = hopDecision(h, from, to, q);
|
|
195
|
+
const state = dec.state;
|
|
155
196
|
const crossing = boundaryCrossings(h.edges[0], ix.nodesById);
|
|
156
197
|
for (const c of crossing) crossings.push({ hop: i, type: c.type, detail: c.detail });
|
|
157
198
|
hops.push({
|
|
158
199
|
index: i, from: nodeSummary(from), to: nodeSummary(to), relations: sortedUnique(h.edges.map((e) => e.relation)), state,
|
|
159
200
|
edges: h.edges.map(edgeSummary).sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
201
|
+
...(dec.actions ? { actions: dec.actions } : {}),
|
|
160
202
|
});
|
|
161
|
-
|
|
203
|
+
// each blocker is the rule that actually decided the hop, with the action and the condition it matched
|
|
204
|
+
for (const { d, why } of dec.denies) {
|
|
205
|
+
if (!d) continue;
|
|
162
206
|
blockers.push({ hop: i, edgeId: d.id, relation: d.relation, provenance: d.provenance, source: d.source ? { file: d.source.file, digest: d.source.digest } : null,
|
|
163
|
-
detail: `${d.relation} (deny) from ${from.name} to ${to.name}
|
|
207
|
+
detail: `${d.relation} (deny) from ${from.name} to ${to.name}`, ...(why ? { action: why.action, matchedActions: why.matchedActions, condition: why.condition } : {}) });
|
|
164
208
|
}
|
|
165
209
|
if (from.tenant !== null && to.tenant !== null && from.tenant !== to.tenant) {
|
|
166
210
|
conditions.push({ kind: 'tenant-binding', hop: i, detail: `${from.name} (tenant ${from.tenant}) reaches ${to.name} (tenant ${to.tenant}): reachable only if no tenant binding is enforced on this transition, and the configuration does not establish either` });
|
|
@@ -193,6 +237,10 @@ function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
|
|
|
193
237
|
else if (networkHops.length && networkHops.every((h) => h.state === 'runtime-supported')) state = 'runtime-supported';
|
|
194
238
|
else state = 'possible';
|
|
195
239
|
|
|
240
|
+
// threat-model FR-03.2: a path that carries several actions keeps their separate outcomes; one is the worst over its IAM hops
|
|
241
|
+
const actionOutcomes = {};
|
|
242
|
+
const RANK = { possible: 0, unresolved: 1, blocked: 2, 'not-granted': 3 };
|
|
243
|
+
for (const hp of hops) for (const a of hp.actions || []) if (!(a.action in actionOutcomes) || RANK[a.outcome] > RANK[actionOutcomes[a.action]]) actionOutcomes[a.action] = a.outcome;
|
|
196
244
|
const observedEnds = hops.flatMap((h) => h.edges).filter((e) => e.observationInterval).map((e) => e.observationInterval);
|
|
197
245
|
const supportingEdgeIds = sortedUnique(hops.flatMap((h) => h.edges.map((e) => e.id)));
|
|
198
246
|
const envs = sortedUnique([...hops.flatMap((h) => h.edges.map((e) => e.environment)), ...nodes.map((n) => n.environment)].filter(Boolean));
|
|
@@ -202,6 +250,8 @@ function buildPath(ix, seq, hopList, nowMs, staleAfterMs) {
|
|
|
202
250
|
id: semanticId('bpath', { hops: hops.map((h) => h.edges.map((e) => e.id).sort()) }, ['hops']),
|
|
203
251
|
entry: nodeSummary(nodes[0]), target: nodeSummary(nodes[nodes.length - 1]),
|
|
204
252
|
state, conditional: conditions.length > 0, conditions, blockers, assumptions: [...assumptions].sort(),
|
|
253
|
+
...(Object.keys(actionOutcomes).length ? { actionOutcomes: Object.fromEntries(Object.entries(actionOutcomes).sort()) } : {}),
|
|
254
|
+
...(q && q.action ? { action: q.action } : {}),
|
|
205
255
|
environments: envs, tenants,
|
|
206
256
|
provenance: sortedUnique(hops.flatMap((h) => h.edges.map((e) => e.provenance))),
|
|
207
257
|
crossings: dedupCross, supportingEdgeIds, hops,
|
|
@@ -239,7 +289,7 @@ function defaultTargets(graph) {
|
|
|
239
289
|
*/
|
|
240
290
|
export function computeAttackPaths(graph, o = {}) {
|
|
241
291
|
const v = validateBoundaryGraph(graph);
|
|
242
|
-
if (!v.ok) return { ok: false, paths: [], entries: [], targets: [], truncated: false, limits: { ...PATH_LIMITS }, statement: 'the graph failed validation, so no path was computed', errors: v.errors };
|
|
292
|
+
if (!v.ok) return { ok: false, paths: [], entries: [], targets: [], truncated: false, truncation: { reasons: [], depthCutoffs: 0, frontier: [], frontierCapped: false }, limits: { ...PATH_LIMITS }, statement: 'the graph failed validation, so no path was computed', errors: v.errors };
|
|
243
293
|
const limits = {
|
|
244
294
|
maxHops: Math.min(o.maxHops ?? PATH_LIMITS.maxHops, PATH_LIMITS.maxHops), maxPaths: Math.min(o.maxPaths ?? PATH_LIMITS.maxPaths, PATH_LIMITS.maxPaths),
|
|
245
295
|
maxExpansions: Math.min(o.maxExpansions ?? PATH_LIMITS.maxExpansions, PATH_LIMITS.maxExpansions),
|
|
@@ -252,25 +302,34 @@ export function computeAttackPaths(graph, o = {}) {
|
|
|
252
302
|
const staleAfterMs = o.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
253
303
|
const found = [];
|
|
254
304
|
let expansions = 0;
|
|
255
|
-
let
|
|
305
|
+
let stopped = false;
|
|
306
|
+
const q = o.action ? { action: String(o.action), context: o.context ?? {} } : (o.context ? { action: null, context: o.context } : null);
|
|
307
|
+
// threat-model FR-03.3 (TM-04): every bound that ended the search is recorded, so "no path" after a depth cutoff is never read as
|
|
308
|
+
// "no path exists". Reaching the depth bound on a node with no unvisited continuation is not a cutoff.
|
|
309
|
+
const cut = { depth: 0, results: false, expansions: false, frontier: [] };
|
|
310
|
+
const next = (seq, here) => [...hopsFrom(ix, here)].sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
311
|
+
.filter(([to, h]) => !seq.includes(to) && !(q && q.action && hopState(h, ix.nodesById.get(here), ix.nodesById.get(to), q) === 'not-granted'));
|
|
256
312
|
|
|
257
313
|
const walk = (seq, hopList) => {
|
|
258
|
-
if (
|
|
314
|
+
if (stopped) return;
|
|
259
315
|
const here = seq[seq.length - 1];
|
|
260
316
|
if (hopList.length && targetSet.has(here)) {
|
|
261
|
-
found.push(buildPath(ix, seq, hopList, Number.isNaN(nowMs) ? null : nowMs, staleAfterMs));
|
|
262
|
-
if (found.length >= limits.maxPaths) {
|
|
317
|
+
found.push(buildPath(ix, seq, hopList, Number.isNaN(nowMs) ? null : nowMs, staleAfterMs, q));
|
|
318
|
+
if (found.length >= limits.maxPaths) { stopped = true; cut.results = true; return; }
|
|
319
|
+
}
|
|
320
|
+
const cont = next(seq, here);
|
|
321
|
+
if (hopList.length >= limits.maxHops) {
|
|
322
|
+
if (cont.length) { cut.depth++; if (cut.frontier.length < 20) cut.frontier.push({ node: here, depth: hopList.length, unexplored: cont.length }); }
|
|
323
|
+
return;
|
|
263
324
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (seq.includes(to)) continue;
|
|
267
|
-
if (++expansions > limits.maxExpansions) { truncated = true; return; }
|
|
325
|
+
for (const [to, h] of cont) {
|
|
326
|
+
if (++expansions > limits.maxExpansions) { stopped = true; cut.expansions = true; return; }
|
|
268
327
|
walk([...seq, to], [...hopList, h]);
|
|
269
328
|
}
|
|
270
329
|
};
|
|
271
330
|
for (const entry of entries) {
|
|
272
331
|
if (o.includeSelf && targetSet.has(entry)) {
|
|
273
|
-
const p = buildPath(ix, [entry], [], Number.isNaN(nowMs) ? null : nowMs, staleAfterMs);
|
|
332
|
+
const p = buildPath(ix, [entry], [], Number.isNaN(nowMs) ? null : nowMs, staleAfterMs, q);
|
|
274
333
|
p.assumptions = sortedUnique([...p.assumptions, 'the entry point is itself the target; its public trust zone is declared in configuration']);
|
|
275
334
|
found.push(p);
|
|
276
335
|
}
|
|
@@ -278,9 +337,13 @@ export function computeAttackPaths(graph, o = {}) {
|
|
|
278
337
|
}
|
|
279
338
|
found.sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || a.hops.length - b.hops.length || (a.id < b.id ? -1 : 1));
|
|
280
339
|
const n = found.length;
|
|
340
|
+
const reasons = [...(cut.depth ? ['depth'] : []), ...(cut.results ? ['result-count'] : []), ...(cut.expansions ? ['expansion'] : [])];
|
|
341
|
+
const truncated = reasons.length > 0;
|
|
281
342
|
return {
|
|
282
343
|
ok: true, paths: found, entries, targets, truncated, limits,
|
|
283
|
-
|
|
344
|
+
truncation: { reasons, depthCutoffs: cut.depth, frontier: cut.frontier, frontierCapped: cut.depth > cut.frontier.length },
|
|
345
|
+
...(q && q.action ? { action: q.action } : {}),
|
|
346
|
+
statement: `${n} path(s) found in the supplied configuration${truncated ? ` (search stopped at a limit: ${reasons.join(', ')}; more may exist)` : ''}; a path that is not listed has not been shown to exist or to be absent`,
|
|
284
347
|
errors: [],
|
|
285
348
|
};
|
|
286
349
|
}
|
|
@@ -306,7 +369,7 @@ export function exposureReach(graph, { entries } = {}) {
|
|
|
306
369
|
const cur = best.get(id);
|
|
307
370
|
for (const [to, h] of hopsFrom(ix, id)) {
|
|
308
371
|
const st = hopState(h, ix.nodesById.get(id), ix.nodesById.get(to));
|
|
309
|
-
if (st === 'blocked') continue;
|
|
372
|
+
if (st === 'blocked' || st === 'not-granted') continue;
|
|
310
373
|
const state = cur.state === 'unresolved' || st === 'unresolved' ? 'unresolved' : 'definite';
|
|
311
374
|
const prev = best.get(to);
|
|
312
375
|
if (prev && (prev.state === 'definite' || state === 'unresolved')) continue;
|
|
@@ -324,7 +387,7 @@ export function exposureReach(graph, { entries } = {}) {
|
|
|
324
387
|
* own claim: the record is validated, its confirmation level is recomputed from its evidence, and its oracle must be the one the
|
|
325
388
|
* finding's class names (a finding of an unsupported class has no applicable oracle).
|
|
326
389
|
*/
|
|
327
|
-
export function assessVerification(finding, records, hypothesisId = hypothesisIdFromFinding(finding)) {
|
|
390
|
+
export function assessVerification(finding, records, hypothesisId = hypothesisIdFromFinding(finding), { revision = null } = {}) {
|
|
328
391
|
const out = [];
|
|
329
392
|
const cls = nonTaintClassOf(finding);
|
|
330
393
|
for (const rec of Array.isArray(records) ? records : []) {
|
|
@@ -340,12 +403,16 @@ export function assessVerification(finding, records, hypothesisId = hypothesisId
|
|
|
340
403
|
const adapter = SCENARIO_CLASSES.find((c) => c.id === cls.classId)?.adapterId;
|
|
341
404
|
if (!rec.oracle || rec.oracle.id !== adapter) reasons.push(`the record's oracle is not '${adapter}', the oracle for class '${cls.classId}'`);
|
|
342
405
|
}
|
|
343
|
-
|
|
406
|
+
// threat-model FR-04.1: an exact matching commit is the minimum for a CURRENT component proof; a record for another revision is
|
|
407
|
+
// kept, with the reason, as historical evidence and never applied to this graph
|
|
408
|
+
let historical = false;
|
|
409
|
+
if (revision && rec.commit !== revision) { historical = true; reasons.push(`the record was made at ${String(rec.commit).slice(0, 12)}, not this graph's revision ${String(revision).slice(0, 12)}; it is historical evidence for that revision only`); }
|
|
410
|
+
out.push({ recordId: rec.id, outcome: rec.outcome, applicable: reasons.length === 0, reasons, ...(historical ? { historical: true, commit: rec.commit } : {}) });
|
|
344
411
|
}
|
|
345
412
|
return out.sort((a, b) => String(a.recordId).localeCompare(String(b.recordId)));
|
|
346
413
|
}
|
|
347
414
|
|
|
348
|
-
function labelPath(path, assessments) {
|
|
415
|
+
function labelPath(path, assessments, pc = null) {
|
|
349
416
|
const ok = assessments.find((a) => a.applicable);
|
|
350
417
|
if (!ok) {
|
|
351
418
|
const why = assessments.length ? assessments.flatMap((a) => a.reasons).slice(0, 3).join('; ') : 'no verification record for this hypothesis was supplied';
|
|
@@ -353,7 +420,12 @@ function labelPath(path, assessments) {
|
|
|
353
420
|
}
|
|
354
421
|
if (path.state === 'blocked') return { label: 'oracle-confirmed-path-blocked', recordId: ok.recordId, reason: 'the oracle confirmed the effect in its declared scenario, but configuration explicitly denies a hop on this path' };
|
|
355
422
|
if (path.state === 'unresolved') return { label: 'oracle-confirmed-path-unresolved', recordId: ok.recordId, reason: 'the oracle confirmed the effect in its declared scenario, but deployment reachability along this path is unresolved' };
|
|
356
|
-
|
|
423
|
+
// threat-model FR-04.1 (TM-05): promotion to exploitable needs an issued binding of this proof to THIS path; a component proof alone
|
|
424
|
+
// is reported beside the path and does not promote it
|
|
425
|
+
const record = pc && pc.records.find((r) => r && r.id === ok.recordId);
|
|
426
|
+
const j = record ? judgePathPromotion({ path, kind: pc.kind, graph: pc.graph, record, bindings: pc.proofBindings, trustRoot: pc.trustRoot, now: pc.now, expect: pc.expect }) : { promote: false, code: 'component-only', reason: 'no binding was supplied', diagnostics: [] };
|
|
427
|
+
if (!j.promote) return { label: 'not-established', recordId: null, reason: `a trusted oracle confirmed the component, but it is not shown on this ${pc ? pc.kind : 'deployed'} path: ${j.reason}`, componentProof: { recordId: ok.recordId, code: j.code, diagnostics: j.diagnostics } };
|
|
428
|
+
return { label: 'exploitable-confirmed', recordId: ok.recordId, reason: 'a trusted oracle confirmed the effect in its declared scenario, and an issued binding ties that proof to this path; deployment reachability comes from configuration or sampled observation, not from a production probe', bindingId: j.bindingId };
|
|
357
429
|
}
|
|
358
430
|
|
|
359
431
|
// ------------------------------------------------------------ the per-finding context
|
|
@@ -384,21 +456,25 @@ function summarizeExposure(paths) {
|
|
|
384
456
|
* @param {Array} [args.records] version-1 verification records
|
|
385
457
|
* @param {number|string} [args.now]
|
|
386
458
|
*/
|
|
387
|
-
export function analyzeFindingBoundaries({ graph, finding, bindings = [], records = [], now, maxHops, maxPaths } = {}) {
|
|
459
|
+
export function analyzeFindingBoundaries({ graph, finding, bindings = [], records = [], now, maxHops, maxPaths, action, context: requestContext, proofBindings = [], trustRoot = {}, expect = {} } = {}) {
|
|
388
460
|
const v = validateBoundaryGraph(graph);
|
|
389
461
|
if (!v.ok) return { ok: false, context: null, errors: v.errors };
|
|
390
462
|
const hypothesisId = hypothesisIdFromFinding(finding);
|
|
391
463
|
const binding = bindFindingToService(finding, bindings, graph);
|
|
392
|
-
const assessments = assessVerification(finding, records, hypothesisId);
|
|
464
|
+
const assessments = assessVerification(finding, records, hypothesisId, { revision: graph.revision ?? null });
|
|
465
|
+
const clock = now === undefined || now === null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now));
|
|
466
|
+
const pc = (kind) => ({ kind, graph, records: Array.isArray(records) ? records : [], proofBindings, trustRoot, now: clock, expect });
|
|
393
467
|
const paths = [];
|
|
394
468
|
let truncated = false;
|
|
395
|
-
const
|
|
469
|
+
const truncationReasons = new Set();
|
|
470
|
+
const popts = { now, maxHops, maxPaths, ...(action ? { action } : {}), ...(requestContext ? { context: requestContext } : {}) };
|
|
396
471
|
if (binding.status === 'bound') {
|
|
397
472
|
const exposure = computeAttackPaths(graph, { ...popts, targets: [binding.service.id], includeSelf: true });
|
|
398
473
|
const impact = computeAttackPaths(graph, { ...popts, entries: [binding.service.id] });
|
|
399
474
|
truncated = exposure.truncated || impact.truncated;
|
|
400
|
-
for (const
|
|
401
|
-
for (const p of
|
|
475
|
+
for (const r of [...exposure.truncation.reasons, ...impact.truncation.reasons]) truncationReasons.add(r);
|
|
476
|
+
for (const p of exposure.paths) paths.push({ kind: 'exposure', ...p, exploitability: labelPath(p, assessments, pc('exposure')) });
|
|
477
|
+
for (const p of impact.paths) paths.push({ kind: 'impact', ...p, exploitability: labelPath(p, assessments, pc('impact')) });
|
|
402
478
|
}
|
|
403
479
|
const exposurePaths = paths.filter((p) => p.kind === 'exposure');
|
|
404
480
|
const exposure = binding.status === 'bound' ? summarizeExposure(exposurePaths)
|
|
@@ -422,7 +498,7 @@ export function analyzeFindingBoundaries({ graph, finding, bindings = [], record
|
|
|
422
498
|
const warnings = ['Runtime traffic coverage is not established by any supplied observation; configured relationships were not exercised.'];
|
|
423
499
|
if (binding.status !== 'bound') warnings.push(`This finding is not tied to a deployed service (${binding.status}); no path was computed.`);
|
|
424
500
|
if (graph.gaps.length) warnings.push(`The boundary graph has ${graph.gaps.length} gap(s) (${sortedUnique(graph.gaps.map((g) => g.code)).join(', ')}); relationships behind them are missing, not absent.`);
|
|
425
|
-
if (truncated) warnings.push(
|
|
501
|
+
if (truncated) warnings.push(`The path search stopped at a limit (${[...truncationReasons].sort().join(', ')}); more paths may exist, so no listed absence is a proof of absence.`);
|
|
426
502
|
|
|
427
503
|
const context = {
|
|
428
504
|
schema: BOUNDARY_CONTEXT_SCHEMA, schemaVersion: BOUNDARY_CONTEXT_VERSION, id: '', hypothesisId,
|
|
@@ -435,7 +511,8 @@ export function analyzeFindingBoundaries({ graph, finding, bindings = [], record
|
|
|
435
511
|
tenants: sortedUnique(graph.nodes.map((n) => n.tenant).filter(Boolean)),
|
|
436
512
|
gapCodes: sortedUnique(graph.gaps.map((g) => g.code)), gapCount: graph.gaps.length,
|
|
437
513
|
},
|
|
438
|
-
exposure, paths, truncated,
|
|
514
|
+
exposure, paths, truncated, truncation: { reasons: [...truncationReasons].sort() },
|
|
515
|
+
...(action ? { action } : {}),
|
|
439
516
|
verification: assessments, uncovered, warnings,
|
|
440
517
|
};
|
|
441
518
|
context.id = semanticId('bctx', { hypothesisId, graph: graph.digest, binding: binding.binding, service: binding.service?.id ?? null, paths: paths.map((p) => p.id) }, ['hypothesisId', 'graph', 'binding', 'service', 'paths']);
|
|
@@ -98,7 +98,7 @@ export function readScanFindings(file, { maxBytes, verify } = {}) {
|
|
|
98
98
|
* @param {string} [o.now] ISO time used to judge trace staleness; defaults to the current time
|
|
99
99
|
* @returns {object} typed result; `status` is ok | disabled | blocked | unsupported | error. Never throws.
|
|
100
100
|
*/
|
|
101
|
-
export function runBoundaries({ config, from, environment = 'prod', repository = null, revision = null, findingsFile = null, findings: suppliedFindings = null, verifySignature, now } = {}) {
|
|
101
|
+
export function runBoundaries({ config, from, environment = 'prod', repository = null, revision = null, findingsFile = null, findings: suppliedFindings = null, verifySignature, now, action = null } = {}) {
|
|
102
102
|
const gate = featureStatus(config, DEPLOYMENT_FEATURE);
|
|
103
103
|
if (gate.status !== 'ok') return { ...gate, report: null };
|
|
104
104
|
const limits = limitValues(config);
|
|
@@ -175,7 +175,7 @@ export function runBoundaries({ config, from, environment = 'prod', repository =
|
|
|
175
175
|
if (integrity === 'unsigned') notes.push('the scan result carries no signature, so its integrity was not verified');
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
const attached = attachBoundaryContext({ config, findings, graph, bindings, now: clock });
|
|
178
|
+
const attached = attachBoundaryContext({ config, findings, graph, bindings, now: clock, action });
|
|
179
179
|
if (attached.status !== 'ok') return { ...attached, report: null };
|
|
180
180
|
const contexts = attached.findings.map((f) => f.boundaryContext).filter(Boolean);
|
|
181
181
|
const rows = attached.findings.map((f) => ({
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Action-specific IAM decisions for the boundary graph (threat-model PRD FR-03.1, FR-03.4; TM-03).
|
|
2
|
+
//
|
|
3
|
+
// A deny blocks a requested action only when its action and resource patterns match that action and resource AND its condition holds.
|
|
4
|
+
// Before this, any deny edge on a hop blocked it: an unrelated `s3:DeleteObject` deny closed an `s3:GetObject` path, and a conditional
|
|
5
|
+
// deny whose predicate was never evaluated closed it too.
|
|
6
|
+
//
|
|
7
|
+
// The finite policy subset this module decides, and nothing more:
|
|
8
|
+
// actions and resources exact strings and IAM globs (`*`, `?`), case-insensitive for actions, case-sensitive for resources
|
|
9
|
+
// conditions StringEquals, StringNotEquals, Bool, ArnEquals; every operator and every key must hold (AND), any listed
|
|
10
|
+
// value of a key satisfies it (OR); a key the request context does not supply is UNKNOWN
|
|
11
|
+
// anything else (an `...IfExists` or set operator, a policy variable, a value outside the attribute grammar, a clipped
|
|
12
|
+
// action list) is recorded as unsupported and leaves the decision UNRESOLVED, never allowed or blocked
|
|
13
|
+
//
|
|
14
|
+
// Network denies are not IAM decisions and are not judged here: they still block the hop they sit on.
|
|
15
|
+
const SUPPORTED_OPERATORS = new Set(['StringEquals', 'StringNotEquals', 'Bool', 'ArnEquals']);
|
|
16
|
+
const MAX_CONDITIONS = 8;
|
|
17
|
+
const VALUE_GRAMMAR = /^[A-Za-z0-9_.:\/*,@ +=-]{0,200}$/;
|
|
18
|
+
|
|
19
|
+
/** IAM glob to RegExp. */
|
|
20
|
+
function glob(pattern, flags = '') {
|
|
21
|
+
return new RegExp(`^${String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, flags);
|
|
22
|
+
}
|
|
23
|
+
export const actionMatches = (pattern, action) => glob(pattern, 'i').test(String(action));
|
|
24
|
+
export const resourceMatches = (pattern, resource) => glob(pattern).test(String(resource));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The structured attributes a grants edge carries for one statement. Fits the graph's attribute grammar (at most 16 keys; values
|
|
28
|
+
* limited to a safe character set), so the graph contract and its digests are unchanged for every existing field.
|
|
29
|
+
*/
|
|
30
|
+
export function statementAttrs(actions, condition) {
|
|
31
|
+
const joined = actions.join(',');
|
|
32
|
+
const attrs = { actions: joined.slice(0, 250), actionsComplete: joined.length <= 250 };
|
|
33
|
+
if (!condition || typeof condition !== 'object' || !Object.keys(condition).length) return attrs;
|
|
34
|
+
attrs.conditioned = true;
|
|
35
|
+
let n = 0; let supported = true;
|
|
36
|
+
for (const [op, block] of Object.entries(condition)) {
|
|
37
|
+
if (!SUPPORTED_OPERATORS.has(op) || !block || typeof block !== 'object') { supported = false; continue; }
|
|
38
|
+
for (const [key, raw] of Object.entries(block)) {
|
|
39
|
+
const values = [].concat(raw).map(String);
|
|
40
|
+
const text = `${op} ${key}=${values.join(',')}`;
|
|
41
|
+
if (n >= MAX_CONDITIONS || !VALUE_GRAMMAR.test(text) || values.some((v) => v.includes(',') || v.includes('${'))) { supported = false; continue; }
|
|
42
|
+
attrs[`cond${n++}`] = text;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
attrs.conditionSupported = supported;
|
|
46
|
+
return attrs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Evaluate an edge's encoded condition against a request context. 'true' | 'false' | 'unknown' (with the reason). */
|
|
50
|
+
export function evaluateCondition(attrs, context = {}) {
|
|
51
|
+
if (!attrs || !attrs.conditioned) return { result: 'true', reason: 'unconditional' };
|
|
52
|
+
if (attrs.conditionSupported !== true) return { result: 'unknown', reason: 'the condition uses an operator or value outside the supported subset' };
|
|
53
|
+
const ctx = context && typeof context === 'object' ? context : {};
|
|
54
|
+
let unknown = null;
|
|
55
|
+
for (const [k, text] of Object.entries(attrs)) {
|
|
56
|
+
if (!/^cond\d+$/.test(k)) continue;
|
|
57
|
+
const m = /^(\S+) ([^=]+)=(.*)$/.exec(String(text));
|
|
58
|
+
if (!m) return { result: 'unknown', reason: 'an encoded condition could not be read' };
|
|
59
|
+
const [, op, key, list] = m;
|
|
60
|
+
const values = list.split(',');
|
|
61
|
+
if (!Object.prototype.hasOwnProperty.call(ctx, key)) { unknown = unknown ?? `the request context does not supply ${key}`; continue; }
|
|
62
|
+
const actual = String(ctx[key]);
|
|
63
|
+
const holds = op === 'StringNotEquals' ? !values.includes(actual) : op === 'Bool' ? values.map((v) => v.toLowerCase()).includes(actual.toLowerCase()) : values.includes(actual);
|
|
64
|
+
if (!holds) return { result: 'false', reason: `${op} ${key} does not hold for ${actual}` };
|
|
65
|
+
}
|
|
66
|
+
return unknown ? { result: 'unknown', reason: unknown } : { result: 'true', reason: 'every condition holds for the request context' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const actionsOf = (e) => String(e.attrs?.actions ?? '').split(',').filter(Boolean);
|
|
70
|
+
const clipped = (e) => e.attrs?.actionsComplete === false;
|
|
71
|
+
/** An edge that records no action list (another adapter, a hand-built graph): what it covers is not known. */
|
|
72
|
+
export const opaque = (e) => actionsOf(e).length === 0;
|
|
73
|
+
/** The pseudo-action used when the allow statements record no actions: "whatever was granted". */
|
|
74
|
+
export const UNKNOWN_ACTION = '(unrecorded)';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decide one action across a hop's allow and deny grants edges.
|
|
78
|
+
* @returns {{action:string, outcome:'possible'|'blocked'|'unresolved'|'not-granted', blockers:object[], unresolvedBy:string[]}}
|
|
79
|
+
*/
|
|
80
|
+
export function decideAction(allows, denies, action, context) {
|
|
81
|
+
const unresolvedBy = [];
|
|
82
|
+
let granted = false;
|
|
83
|
+
for (const e of allows) {
|
|
84
|
+
const pats = actionsOf(e);
|
|
85
|
+
if (opaque(e)) { granted = true; continue; } // an allow with no action list grants something; it is not narrowed here
|
|
86
|
+
if (!pats.some((p) => actionMatches(p, action))) { if (clipped(e)) unresolvedBy.push(`allow ${e.id}: its action list was clipped, so ${action} may be granted`); continue; }
|
|
87
|
+
const c = evaluateCondition(e.attrs, context);
|
|
88
|
+
if (c.result === 'true') granted = true;
|
|
89
|
+
else if (c.result === 'unknown') { granted = true; unresolvedBy.push(`allow ${e.id}: ${c.reason}`); }
|
|
90
|
+
}
|
|
91
|
+
if (!granted && !unresolvedBy.length) return { action, outcome: 'not-granted', blockers: [], unresolvedBy };
|
|
92
|
+
const blockers = [];
|
|
93
|
+
for (const d of denies) {
|
|
94
|
+
const pats = actionsOf(d);
|
|
95
|
+
// a deny with no action list, or any deny when the granted actions are themselves unrecorded, cannot be matched: unresolved
|
|
96
|
+
if (opaque(d)) { unresolvedBy.push(`deny ${d.id}: it records no action list, so whether it covers ${action} is unknown`); continue; }
|
|
97
|
+
if (action === UNKNOWN_ACTION) { unresolvedBy.push(`deny ${d.id}: the granted actions are not recorded, so whether it covers them is unknown`); continue; }
|
|
98
|
+
if (!pats.some((p) => actionMatches(p, action))) { if (clipped(d)) unresolvedBy.push(`deny ${d.id}: its action list was clipped`); continue; }
|
|
99
|
+
const c = evaluateCondition(d.attrs, context);
|
|
100
|
+
if (c.result === 'true') blockers.push({ edgeId: d.id, action, matchedActions: pats.filter((p) => actionMatches(p, action)), condition: c.reason });
|
|
101
|
+
else if (c.result === 'unknown') unresolvedBy.push(`deny ${d.id}: ${c.reason}`);
|
|
102
|
+
}
|
|
103
|
+
if (blockers.length) return { action, outcome: 'blocked', blockers, unresolvedBy };
|
|
104
|
+
return { action, outcome: unresolvedBy.length ? 'unresolved' : 'possible', blockers, unresolvedBy };
|
|
105
|
+
}
|
|
@@ -27,6 +27,9 @@ export const COVERAGE_SCHEMA = 'agentic-security/boundary-coverage';
|
|
|
27
27
|
export const BOUNDARY_FEATURE = 'deployment-boundaries';
|
|
28
28
|
|
|
29
29
|
const CONTEXT_KEYS = ['schema', 'schemaVersion', 'id', 'hypothesisId', 'finding', 'binding', 'graph', 'exposure', 'paths', 'truncated', 'verification', 'uncovered', 'warnings'];
|
|
30
|
+
// Optional from 1.1.0: a 1.0.0 context stays valid without them, and may not carry them.
|
|
31
|
+
const OPTIONAL_KEYS_1_1 = ['truncation', 'action'];
|
|
32
|
+
const SUPPORTED_VERSIONS = ['1.0.0', '1.1.0'];
|
|
30
33
|
const FILTER_KEYS = ['service', 'repository', 'environment', 'tenant'];
|
|
31
34
|
const MAX_STRING = 600;
|
|
32
35
|
|
|
@@ -72,7 +75,9 @@ export function validateBoundaryContext(ctx) {
|
|
|
72
75
|
const errors = [];
|
|
73
76
|
if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return { ok: false, errors: [{ code: 'BAD_TYPE', path: '', message: 'a boundary context must be an object' }] };
|
|
74
77
|
if (ctx.schema !== BOUNDARY_CONTEXT_SCHEMA) errors.push({ code: 'BAD_SCHEMA', path: 'schema', message: `schema must be '${BOUNDARY_CONTEXT_SCHEMA}'` });
|
|
75
|
-
|
|
78
|
+
if (!SUPPORTED_VERSIONS.includes(ctx.schemaVersion)) errors.push({ code: 'BAD_SCHEMA', path: 'schemaVersion', message: `schemaVersion must be one of ${SUPPORTED_VERSIONS.join(', ')}` });
|
|
79
|
+
const allowed = ctx.schemaVersion === '1.0.0' ? CONTEXT_KEYS : [...CONTEXT_KEYS, ...OPTIONAL_KEYS_1_1];
|
|
80
|
+
for (const k of Object.keys(ctx)) if (!allowed.includes(k)) errors.push({ code: 'UNKNOWN_FIELD', path: k, message: `field '${k}' is not part of a boundary context ${ctx.schemaVersion}` });
|
|
76
81
|
for (const k of CONTEXT_KEYS) if (!(k in ctx)) errors.push({ code: 'MISSING_FIELD', path: k, message: `field '${k}' is required` });
|
|
77
82
|
if (errors.length) return { ok: false, errors };
|
|
78
83
|
if (typeof ctx.id !== 'string' || !ctx.id) errors.push({ code: 'BAD_TYPE', path: 'id', message: 'id must be a non-empty string' });
|
|
@@ -95,13 +100,14 @@ function pathView(p) {
|
|
|
95
100
|
environments: p.environments, tenants: p.tenants, services, repositories, provenance: p.provenance,
|
|
96
101
|
entry: p.entry, target: p.target,
|
|
97
102
|
hops: p.hops.map((h) => ({
|
|
98
|
-
index: h.index, from: h.from, to: h.to, relations: h.relations, state: h.state,
|
|
103
|
+
index: h.index, from: h.from, to: h.to, relations: h.relations, state: h.state, ...(h.actions ? { actions: h.actions } : {}),
|
|
99
104
|
edges: h.edges.map((e) => ({
|
|
100
105
|
id: e.id, relation: e.relation, provenance: e.provenance, effect: e.effect, confidence: e.confidence, pathState: e.pathState,
|
|
101
106
|
sourceRevision: e.sourceRevision, source: e.source, observationInterval: e.observationInterval, completeness: e.completeness,
|
|
102
107
|
})),
|
|
103
108
|
})),
|
|
104
109
|
supportingEdgeIds: p.supportingEdgeIds, conditions: p.conditions, blockers: p.blockers, assumptions: p.assumptions,
|
|
110
|
+
...(p.actionOutcomes ? { actionOutcomes: p.actionOutcomes } : {}),
|
|
105
111
|
crossings: p.crossings, uncoveredBoundaries: uncovered,
|
|
106
112
|
freshness: {
|
|
107
113
|
observedFrom: p.coverage.observedFrom, observedUntil: p.coverage.observedUntil, staleHops: p.coverage.staleHops,
|
|
@@ -231,12 +237,12 @@ export function boundaryCoverage(contexts) {
|
|
|
231
237
|
*
|
|
232
238
|
* @returns {{ status: string, code?: string, reason?: string, findings: object[], errors: object[] }}
|
|
233
239
|
*/
|
|
234
|
-
export function attachBoundaryContext({ config, findings, graph, bindings = [], records = [], now } = {}) {
|
|
240
|
+
export function attachBoundaryContext({ config, findings, graph, bindings = [], records = [], now, action = null } = {}) {
|
|
235
241
|
const gate = featureStatus(config, BOUNDARY_FEATURE);
|
|
236
242
|
if (gate.status !== 'ok') return { ...gate, findings, errors: [] };
|
|
237
243
|
const out = [], errors = [];
|
|
238
244
|
for (const f of Array.isArray(findings) ? findings : []) {
|
|
239
|
-
const r = analyzeFindingBoundaries({ graph, finding: f, bindings, records, now });
|
|
245
|
+
const r = analyzeFindingBoundaries({ graph, finding: f, bindings, records, now, ...(action ? { action } : {}) });
|
|
240
246
|
if (!r.ok) { errors.push(...r.errors); out.push(f); continue; }
|
|
241
247
|
out.push({ ...f, boundaryContext: r.context });
|
|
242
248
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Scope-bound proof for deployed paths (threat-model PRD FR-04.1, FR-04.2; TM-05).
|
|
2
|
+
//
|
|
3
|
+
// A verification record proves a COMPONENT behaviour at one commit. Before this, a record for revision A promoted both the exposure
|
|
4
|
+
// and the impact paths of a revision B graph to exploitable. Now:
|
|
5
|
+
//
|
|
6
|
+
// component proof the record is applicable (validated, trusted runtime proof, the class's oracle) AND its commit is the graph's
|
|
7
|
+
// revision. A record for another revision is kept as historical evidence with the mismatch, never applied.
|
|
8
|
+
// path promotion additionally needs a proof-binding envelope, version 1, for that record and that exact path: the artifact and
|
|
9
|
+
// policy (graph) digests, environment, tenant, actor, route and action, the path id and its kind (exposure or
|
|
10
|
+
// impact), the oracle version and the effect the oracle observed, issued and signed by an issuer the operator
|
|
11
|
+
// trusts (an Ed25519 public key in the trust root). A matching digest or a caller's label is not authority.
|
|
12
|
+
//
|
|
13
|
+
// Every refusal has its own code, so a reviewer can tell a malformed envelope from a tampered, an expired or a replayed one. An exact
|
|
14
|
+
// duplicate envelope adds nothing.
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export const PROOF_BINDING_SCHEMA = 'agentic-security/proof-binding';
|
|
18
|
+
export const PROOF_BINDING_VERSION = 1;
|
|
19
|
+
const FIELDS = ['schema', 'version', 'recordId', 'commit', 'artifactDigest', 'policyDigest', 'environment', 'tenant', 'actor', 'route', 'action', 'pathId', 'claim', 'oracleVersion', 'effect', 'issuer', 'issuedAt', 'expires'];
|
|
20
|
+
const REQUIRED = ['recordId', 'commit', 'artifactDigest', 'policyDigest', 'environment', 'actor', 'pathId', 'claim', 'oracleVersion', 'effect', 'issuer', 'issuedAt', 'expires'];
|
|
21
|
+
const CLAIMS = new Set(['exposure', 'impact']);
|
|
22
|
+
|
|
23
|
+
const canonical = (e) => JSON.stringify(FIELDS.map((k) => [k, e[k] ?? null]));
|
|
24
|
+
export const bindingDigest = (e) => crypto.createHash('sha256').update(canonical(e)).digest('hex');
|
|
25
|
+
|
|
26
|
+
/** Sign an envelope with an issuer's Ed25519 private key (used by an issuer and by tests). */
|
|
27
|
+
export function signProofBinding(envelope, privateKeyPem) {
|
|
28
|
+
const body = { schema: PROOF_BINDING_SCHEMA, version: PROOF_BINDING_VERSION, ...envelope };
|
|
29
|
+
return { ...body, signature: crypto.sign(null, Buffer.from(canonical(body)), privateKeyPem).toString('base64') };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Structural and authority check of one envelope. Returns { ok, code, reason }. */
|
|
33
|
+
export function verifyProofBinding(e, { trustRoot = {}, now = Date.now() } = {}) {
|
|
34
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, code: 'malformed', reason: 'the binding is not an object' };
|
|
35
|
+
if (e.schema !== PROOF_BINDING_SCHEMA || e.version !== PROOF_BINDING_VERSION) return { ok: false, code: 'unsupported-version', reason: 'the binding schema or version is not supported' };
|
|
36
|
+
for (const k of Object.keys(e)) if (!FIELDS.includes(k) && k !== 'signature') return { ok: false, code: 'malformed', reason: `unknown binding field ${k}` };
|
|
37
|
+
for (const k of REQUIRED) if (e[k] === undefined || e[k] === null || e[k] === '') return { ok: false, code: 'missing-binding', reason: `the binding has no ${k}` };
|
|
38
|
+
if (!CLAIMS.has(e.claim)) return { ok: false, code: 'malformed', reason: 'claim must be exposure or impact' };
|
|
39
|
+
const key = trustRoot[e.issuer];
|
|
40
|
+
if (!key) return { ok: false, code: 'untrusted-issuer', reason: `issuer ${String(e.issuer).slice(0, 60)} is not in the operator's trust root` };
|
|
41
|
+
let good = false;
|
|
42
|
+
try { good = typeof e.signature === 'string' && crypto.verify(null, Buffer.from(canonical(e)), key, Buffer.from(e.signature, 'base64')); } catch { good = false; }
|
|
43
|
+
if (!good) return { ok: false, code: 'tampered', reason: 'the signature does not verify against the issuer\'s key' };
|
|
44
|
+
if (!(Date.parse(e.expires) > now)) return { ok: false, code: 'expired', reason: 'the binding has expired' };
|
|
45
|
+
return { ok: true, code: 'verified', reason: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decide whether a path may be promoted by a component proof, given the bindings supplied for it.
|
|
50
|
+
* @param {{path:object, kind:'exposure'|'impact', graph:object, record:object, bindings:object[], trustRoot:object, now:number,
|
|
51
|
+
* expect:{artifactDigest?:string, environment?:string, tenant?:string|null, actor?:string, route?:string, action?:string}}} a
|
|
52
|
+
* @returns {{promote:boolean, code:string, reason:string, bindingId:string|null, diagnostics:object[]}}
|
|
53
|
+
*/
|
|
54
|
+
export function judgePathPromotion({ path, kind, graph, record, bindings = [], trustRoot = {}, now = Date.now(), expect = {} }) {
|
|
55
|
+
const diagnostics = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const forRecord = bindings.filter((b) => b && b.recordId === record.id);
|
|
58
|
+
if (!forRecord.length) return { promote: false, code: 'component-only', reason: 'the proof covers the component; no issued binding ties it to this deployed path', bindingId: null, diagnostics };
|
|
59
|
+
for (const b of forRecord) {
|
|
60
|
+
const id = bindingDigest(b);
|
|
61
|
+
if (seen.has(id)) continue; // an exact duplicate adds no authority
|
|
62
|
+
seen.add(id);
|
|
63
|
+
const v = verifyProofBinding(b, { trustRoot, now });
|
|
64
|
+
if (!v.ok) { diagnostics.push({ bindingId: id.slice(0, 16), code: v.code, reason: v.reason }); continue; }
|
|
65
|
+
const mismatch = [];
|
|
66
|
+
if (b.commit !== record.commit) mismatch.push('commit');
|
|
67
|
+
if (graph.revision && b.commit !== graph.revision) mismatch.push('revision');
|
|
68
|
+
if (b.policyDigest !== graph.digest) mismatch.push('policy');
|
|
69
|
+
if (b.pathId !== path.id) mismatch.push('path');
|
|
70
|
+
if (b.claim !== kind) mismatch.push('claim');
|
|
71
|
+
if (record.oracle && b.oracleVersion !== String(record.oracle.version ?? '')) mismatch.push('oracle');
|
|
72
|
+
if (!(path.environments || []).includes(b.environment)) mismatch.push('environment');
|
|
73
|
+
if ((path.tenants || []).length && !(path.tenants || []).includes(b.tenant)) mismatch.push('tenant');
|
|
74
|
+
for (const k of ['artifactDigest', 'environment', 'tenant', 'actor', 'route', 'action']) if (expect[k] !== undefined && expect[k] !== null && b[k] !== expect[k]) mismatch.push(k);
|
|
75
|
+
if (mismatch.length) { diagnostics.push({ bindingId: id.slice(0, 16), code: 'context-mismatch', reason: `the binding was issued for another ${[...new Set(mismatch)].join(', ')}` }); continue; }
|
|
76
|
+
return { promote: true, code: 'bound', reason: `issued binding ${id.slice(0, 16)} by ${b.issuer} ties the proof to this ${kind} path`, bindingId: id.slice(0, 16), diagnostics };
|
|
77
|
+
}
|
|
78
|
+
return { promote: false, code: diagnostics.length ? diagnostics[0].code : 'component-only', reason: diagnostics.length ? diagnostics.map((d) => d.reason).join('; ') : 'no binding applies', bindingId: null, diagnostics };
|
|
79
|
+
}
|