@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
|
@@ -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
|
+
}
|
|
@@ -156,7 +156,8 @@ function collectConfiguration(finding, fileContents, { window, maxConfig, add, g
|
|
|
156
156
|
if (typeof code !== 'string' || !CONFIG_FILE.test(file) || code.length > 500_000) continue;
|
|
157
157
|
const cl = code.split('\n');
|
|
158
158
|
for (const key of keys) {
|
|
159
|
-
|
|
159
|
+
// the key is escaped before it reaches the pattern, which has no quantifier at all
|
|
160
|
+
const re = new RegExp(`(?<![\\w])${escapeRe(key)}(?![\\w])`); // agentic-security-ignore: CWE-1333
|
|
160
161
|
const i = cl.findIndex((t) => re.test(t));
|
|
161
162
|
if (i < 0) continue;
|
|
162
163
|
found.add(key);
|
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -88,6 +88,12 @@ the before and after image, **no content**) and `languageFix.planDigest` after r
|
|
|
88
88
|
caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
|
|
89
89
|
high-impact change).
|
|
90
90
|
|
|
91
|
+
**Approval binding (threat-model FR-06.3).** `fixMeta.approval` may carry `expires` (ISO time) and `scope: {tool, action, target,
|
|
92
|
+
change}`: `tool` is `apply_fix`, `action` is `apply-plan` (multi-file) or `apply-patch`, `target` is the finding's stableId, and
|
|
93
|
+
`change` is the plan digest or the digest of the exact edits (path, before and after image hashes). `approvalBindingRefusal` refuses
|
|
94
|
+
an expired approval and one issued for another tool, action, target or change. Under the restricted execution profile
|
|
95
|
+
(`AGENTIC_SECURITY_EGRESS_PROFILE=restricted`) both fields are required; elsewhere an approval without them works as before.
|
|
96
|
+
|
|
91
97
|
Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
|
|
92
98
|
re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
|
|
93
99
|
exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
|
package/src/mcp/tools.js
CHANGED
|
@@ -640,7 +640,26 @@ export const explain_finding = {
|
|
|
640
640
|
// FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
|
|
641
641
|
// shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
|
|
642
642
|
// when the change is not high-impact or is properly approved.
|
|
643
|
-
|
|
643
|
+
// Threat-model PRD FR-06.3: an approval is bound to what it approved. With a `scope` it names the tool, the action, the target finding
|
|
644
|
+
// and the change (the digest of the exact edits), and with `expires` it lapses. Under the restricted execution profile
|
|
645
|
+
// (AGENTIC_SECURITY_EGRESS_PROFILE=restricted) both are required; elsewhere a supplied scope or expiry is still enforced. An approval
|
|
646
|
+
// copied to another finding, another change or another tool, or used after it expired, is refused.
|
|
647
|
+
export function approvalBindingRefusal(approval, expected, { now = Date.now(), restricted = process.env.AGENTIC_SECURITY_EGRESS_PROFILE === 'restricted' } = {}) {
|
|
648
|
+
if (restricted && (!approval.expires || !approval.scope)) return 'under the restricted profile an approval must carry an expiry and a scope {tool, action, target, change}';
|
|
649
|
+
if (approval.expires !== undefined) {
|
|
650
|
+
const t = Date.parse(approval.expires);
|
|
651
|
+
if (!Number.isFinite(t)) return 'the approval expiry is not a date';
|
|
652
|
+
if (!(t > now)) return 'the approval has expired';
|
|
653
|
+
}
|
|
654
|
+
if (approval.scope !== undefined) {
|
|
655
|
+
const sc = approval.scope;
|
|
656
|
+
if (!sc || typeof sc !== 'object') return 'the approval scope is not an object';
|
|
657
|
+
for (const k of ['tool', 'action', 'target', 'change']) if (sc[k] !== expected[k]) return `the approval was issued for another ${k}`;
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, expected = null) {
|
|
644
663
|
if (!materialClassification.highImpactCategories.length) return null;
|
|
645
664
|
const cats = materialClassification.highImpactCategories.join(', ');
|
|
646
665
|
const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
|
|
@@ -654,6 +673,10 @@ function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
|
|
|
654
673
|
materialClassification,
|
|
655
674
|
};
|
|
656
675
|
}
|
|
676
|
+
if (expected) {
|
|
677
|
+
const bound = approvalBindingRefusal(approval, expected);
|
|
678
|
+
if (bound) return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${bound}`, materialClassification };
|
|
679
|
+
}
|
|
657
680
|
const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
|
|
658
681
|
const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
|
|
659
682
|
const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
|
|
@@ -739,7 +762,7 @@ async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status
|
|
|
739
762
|
if (!targets.ok) return refuse(targets.reason);
|
|
740
763
|
const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
|
|
741
764
|
if (!dryRun) {
|
|
742
|
-
const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
|
|
765
|
+
const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta, { tool: 'apply_fix', action: 'apply-plan', target: f.stableId || f.id, change: planDigest });
|
|
743
766
|
if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
|
|
744
767
|
}
|
|
745
768
|
// The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
|
|
@@ -942,7 +965,8 @@ export const apply_fix = {
|
|
|
942
965
|
if (dry_run) {
|
|
943
966
|
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, verificationRecord: verdict.verificationRecord ?? null, ...verificationFields(verdict.verificationRecord), materialClassification, ...(fixVerification ? additiveFields(fixVerification) : {}) };
|
|
944
967
|
}
|
|
945
|
-
const
|
|
968
|
+
const changeDigest = _sha256(JSON.stringify(Object.entries(filesForMaterialClassification).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, _sha256(v.before), _sha256(v.after)])));
|
|
969
|
+
const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta, { tool: 'apply_fix', action: 'apply-patch', target: f.stableId || f.id, change: changeDigest });
|
|
946
970
|
if (approvalRefusal) return approvalRefusal;
|
|
947
971
|
const written = [];
|
|
948
972
|
try {
|
|
@@ -55,7 +55,7 @@ export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mo
|
|
|
55
55
|
tool: { name: 'agentic-security', version: engineVersion || null },
|
|
56
56
|
source: sourceState(root),
|
|
57
57
|
invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
|
|
58
|
-
ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
|
|
58
|
+
ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics, inventoryDigest: scan.scope.inventoryDigest ?? null } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false, inventoryDigest: null },
|
|
59
59
|
rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
|
|
60
60
|
model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
|
|
61
61
|
capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -73,6 +73,8 @@ function is not exported.
|
|
|
73
73
|
|
|
74
74
|
Its contract is **recall-preserving, same precedent as `falsification.js` / `dataflow/proof-gate.js`**: it never removes a finding, never touches `severity`, and never asserts `unreachable` without positive evidence — a negative verdict additionally requires the intra-repo import graph to be hole-free *along the reachable set* (an unresolved relative import or a non-literal `require(x)` in a reachable file hides a possible edge, so every would-be `unreachable` degrades to `unknown`). `null`/`'unknown'` is a first-class state and is **not** the same as `false`. Wired in `engine.js` after the entry-point inventory is built and after every finding has been appended (multi-sink and cross-language chains included) so nothing escapes annotation; this is the one annotator that deliberately runs after `why-fired`.
|
|
75
75
|
|
|
76
|
+
**Threat-model hardening (local, threat-model PRD).** `verification-separation.js` `consensusOf` now votes by independent evidence group (a shared `evidence.observationId`, `generator` or `lineage`, else one verifier), keeps the per-lens counts, reports `conflict` and a `refutationAuthority`; `refutationRemoves` lets a refutation take a critical finding out of the queue only with `sufficient` authority. `triage-memory.js`: a family/directory bucket is advisory (no confidence change); only an exact (stable id + file) decision applies, and for critical/high it also needs a reviewer, expiry, matching scope, unchanged fingerprint and the install's HMAC signature (`pastDecision.status`, `pastDecisionApplies`). `assurance/candidate-qualification.js`: the candidate packet and `evaluateCandidate`, which recomputes gates G-01..G-07 from case evidence (met, violated, unresolved, blocked) and never inherits a pass; `writeCandidateDecision` is atomic and idempotent.
|
|
77
|
+
|
|
76
78
|
**Enforced verification separation (R7)** — `verification-separation.js`. The falsification pass could already try to *disprove* a finding; what it could not do was prove the checker was not the producer. This module supplies that structural guarantee:
|
|
77
79
|
|
|
78
80
|
- `recordProducer(finding, producerId)` stamps provenance **write-once** — a later party cannot re-stamp itself as producer to manufacture separation.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Candidate qualification (threat-model PRD FR-10.1, FR-10.2, section 6 "Candidate release gates").
|
|
2
|
+
//
|
|
3
|
+
// A release claim is qualified for an EXACT scanner, target artifact, deployment profile, policy and observation window, and only for
|
|
4
|
+
// the cases it passes. The packet carries those identities and one record per required case (requirement, gate, expected and observed
|
|
5
|
+
// outcome, verdict and reason code, artifact and policy bindings, evidence digests, expiry, owner). The decision is recomputed here from
|
|
6
|
+
// the evidence, never read from the packet: a case whose bindings do not match the packet's target, that has expired, whose feed is
|
|
7
|
+
// stale or whose legitimate control failed keeps its gate open, and the candidate is qualified only when every required gate is met.
|
|
8
|
+
// A higher-level status never inherits a pass from a lower-level one: a gate with no case is unresolved, not met.
|
|
9
|
+
//
|
|
10
|
+
// Outcome per case: met (the expectation held), violated (it did not), unresolved (evidence missing, stale, expired or differently
|
|
11
|
+
// scoped) or blocked (a prerequisite such as an attested runner or an independent population is unavailable). Pure apart from the
|
|
12
|
+
// optional signed write, which is atomic.
|
|
13
|
+
//
|
|
14
|
+
// Evidence tiers stay separate qualifications (PRD section 1 and section 8). Each case carries `tier`: `local` (loopback fixtures on
|
|
15
|
+
// this host, the default), `staging` (the owner-authorized staging deployment) or `independent` (an independently held population or
|
|
16
|
+
// reviewer). A gate the caller declares in `requiredTiers` is met only by cases of that tier: local evidence never satisfies a staging
|
|
17
|
+
// or independent gate by being more of it. A decision records the target it was computed for, and `revalidateDecision` refuses it for
|
|
18
|
+
// any other artifact, policy or scanner bundle, so an upgrade invalidates candidate-bound evidence and a rollback is qualified only by
|
|
19
|
+
// the earlier artifact's own unexpired decision (FR-10.4).
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const CANDIDATE_SCHEMA = 'agentic-security/candidate-packet';
|
|
25
|
+
const CANDIDATE_VERSION = 1;
|
|
26
|
+
export const GATES = Object.freeze({
|
|
27
|
+
'G-01': 'code regression', 'G-02': 'scan and graph coverage', 'G-03': 'prevention and repair', 'G-04': 'evidence and harness integrity',
|
|
28
|
+
'G-05': 'detection and response', 'G-06': 'independent accuracy', 'G-07': 'operability',
|
|
29
|
+
});
|
|
30
|
+
const EXPECT = new Set(['positive', 'negative', 'legitimate']);
|
|
31
|
+
const TIERS = new Set(['local', 'staging', 'independent']);
|
|
32
|
+
const DOMAIN = `${CANDIDATE_SCHEMA}@1\n`;
|
|
33
|
+
const canon = (v) => {
|
|
34
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
|
|
35
|
+
if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
|
|
36
|
+
// an undefined value is absent, exactly as JSON would carry it, so a packet and its JSON round trip have one digest
|
|
37
|
+
return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
38
|
+
};
|
|
39
|
+
export const packetDigest = (p) => `sha256:${crypto.createHash('sha256').update(canon({ ...p, signature: undefined })).digest('hex')}`;
|
|
40
|
+
|
|
41
|
+
/** Sign a packet with an Ed25519 private key; `verifyCandidatePacket` checks it with the key the caller trusts. */
|
|
42
|
+
export function signCandidatePacket(packet, privateKeyPem) {
|
|
43
|
+
const body = { schema: CANDIDATE_SCHEMA, version: CANDIDATE_VERSION, ...packet };
|
|
44
|
+
delete body.signature;
|
|
45
|
+
return { ...body, signature: crypto.sign(null, Buffer.from(DOMAIN + canon(body)), privateKeyPem).toString('base64') };
|
|
46
|
+
}
|
|
47
|
+
export function verifyCandidatePacket(packet, publicKeyPem) {
|
|
48
|
+
if (!packet || packet.schema !== CANDIDATE_SCHEMA) return { ok: false, code: 'FORMAT' };
|
|
49
|
+
// a packet from a newer or older writer is refused by name, never read with this version's meaning
|
|
50
|
+
if (packet.version !== CANDIDATE_VERSION) return { ok: false, code: 'UNSUPPORTED_VERSION', version: packet.version ?? null };
|
|
51
|
+
const { signature, ...body } = packet;
|
|
52
|
+
try { return crypto.verify(null, Buffer.from(DOMAIN + canon(body)), publicKeyPem, Buffer.from(String(signature), 'base64')) ? { ok: true, code: 'VERIFIED' } : { ok: false, code: 'SIGNATURE' }; } catch { return { ok: false, code: 'SIGNATURE' }; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Judge one case record against the packet's target. */
|
|
56
|
+
function judgeCase(c, packet, now) {
|
|
57
|
+
const t = packet.target || {};
|
|
58
|
+
const miss = [];
|
|
59
|
+
for (const k of ['caseId', 'requirement', 'gate', 'expected', 'verdict', 'owner', 'expires']) if (c[k] === undefined || c[k] === null || c[k] === '') miss.push(k);
|
|
60
|
+
if (miss.length) return { outcome: 'unresolved', reason: `missing ${miss.join(', ')}`, owner: c.owner ?? null };
|
|
61
|
+
if (!GATES[c.gate] || !EXPECT.has(c.expected)) return { outcome: 'unresolved', reason: 'unknown gate or expectation' };
|
|
62
|
+
if (c.tier !== undefined && !TIERS.has(c.tier)) return { outcome: 'unresolved', reason: 'unknown evidence tier' };
|
|
63
|
+
if (c.blocked) return { outcome: 'blocked', reason: String(c.blocked) };
|
|
64
|
+
if (!(Date.parse(c.expires) > now)) return { outcome: 'unresolved', reason: 'the evidence has expired' };
|
|
65
|
+
if (c.artifactDigest && t.artifactDigest && c.artifactDigest !== t.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence was produced against another artifact' };
|
|
66
|
+
if (c.policyDigest && t.policyDigest && c.policyDigest !== t.policyDigest) return { outcome: 'unresolved', reason: 'the evidence was produced under another policy' };
|
|
67
|
+
if (c.scannerBundle && packet.scanner?.bundleDigest && c.scannerBundle !== packet.scanner.bundleDigest) return { outcome: 'unresolved', reason: 'the evidence came from another scanner bundle' };
|
|
68
|
+
if (c.requiresArtifactBinding !== false && !c.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence is not bound to an artifact' };
|
|
69
|
+
const o = c.observed;
|
|
70
|
+
if (!o || typeof o !== 'object') return { outcome: 'unresolved', reason: 'no observation is recorded' };
|
|
71
|
+
// the observation and the expectation stay separately inspectable; met/violated is derived, not asserted
|
|
72
|
+
if (c.kind === 'detection') {
|
|
73
|
+
// a detection case judges the signal, not the effect: positive means the rule must fire, negative that it must stay quiet,
|
|
74
|
+
// and an observation that does not say whether the rule fired leaves the case open
|
|
75
|
+
if (typeof o.detected !== 'boolean') return { outcome: 'unresolved', reason: 'the observation does not record whether the detection fired' };
|
|
76
|
+
if (c.expected === 'positive') return o.detected ? { outcome: 'met', reason: 'the detection fired on the reproduced effect' } : { outcome: 'violated', reason: 'the detection missed the reproduced effect' };
|
|
77
|
+
return o.detected ? { outcome: 'violated', reason: 'the detection fired where it must not' } : { outcome: 'met', reason: 'the detection stayed quiet where it must' };
|
|
78
|
+
}
|
|
79
|
+
const forbidden = o.forbiddenEffect === true; const legit = o.legitimateEffect === true;
|
|
80
|
+
if (c.expected === 'positive') return forbidden ? { outcome: 'met', reason: 'the vulnerable control reproduced the forbidden effect' } : { outcome: 'violated', reason: 'the vulnerable control did not reproduce the effect, so the fixture shows nothing' };
|
|
81
|
+
if (c.expected === 'negative') return forbidden ? { outcome: 'violated', reason: 'the forbidden effect occurred on the fixed artifact' } : { outcome: 'met', reason: 'no forbidden effect' };
|
|
82
|
+
return legit && !forbidden ? { outcome: 'met', reason: 'the legitimate operation succeeded' } : { outcome: 'violated', reason: 'the legitimate operation did not succeed' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Recompute every gate from the packet's evidence.
|
|
87
|
+
* @param {object} packet
|
|
88
|
+
* @param {{requiredGates?:string[], requiredCases?:string[], requiredTiers?:Object<string,string>, now?:number, maxFeedAgeMs?:number}} o
|
|
89
|
+
* @returns {{status:'qualified'|'not-qualified', gates:object, cases:object[], blockers:object[], tiers:object}}
|
|
90
|
+
*/
|
|
91
|
+
export function evaluateCandidate(packet, { requiredGates = Object.keys(GATES), requiredCases = [], requiredTiers = {}, now = Date.now(), maxFeedAgeMs = 15 * 60 * 1000 } = {}) {
|
|
92
|
+
const cases = (packet.cases || []).map((c) => ({ caseId: c.caseId ?? null, gate: c.gate ?? null, requirement: c.requirement ?? null, owner: c.owner ?? null, tier: c.tier ?? 'local', ...judgeCase(c, packet, now) }));
|
|
93
|
+
const blockers = [];
|
|
94
|
+
for (const id of requiredCases) if (!cases.some((c) => c.caseId === id)) { cases.push({ caseId: id, gate: null, outcome: 'unresolved', reason: 'a required case has no record' }); blockers.push({ gate: null, caseId: id, reason: 'missing required case' }); }
|
|
95
|
+
// gate-level evidence that is not a case
|
|
96
|
+
const extra = {};
|
|
97
|
+
if (!packet.scope || packet.scope.status !== 'satisfied') extra['G-02'] = { outcome: 'unresolved', reason: packet.scope ? `the scope reconciliation is ${packet.scope.status}` : 'no scope reconciliation is in the packet' };
|
|
98
|
+
if (!packet.runner || packet.runner.attested !== true) extra['G-04'] = { outcome: 'blocked', reason: packet.runner?.reason || 'the runner did not attest the confinement the execution cases need' };
|
|
99
|
+
const ev = packet.evaluation;
|
|
100
|
+
if (!ev || ev.status !== 'measured') extra['G-06'] = { outcome: 'blocked', reason: ev?.reason || 'independent accuracy is unmeasured for this candidate' };
|
|
101
|
+
else if (ev.meetsFloors !== true) extra['G-06'] = { outcome: 'violated', reason: 'the measured population does not meet the registered floors' };
|
|
102
|
+
for (const f of packet.telemetry?.feeds || []) {
|
|
103
|
+
if (!f.lastHeartbeat || now - Date.parse(f.lastHeartbeat) > maxFeedAgeMs) { extra['G-05'] = { outcome: 'unresolved', reason: `feed ${f.name} is stale or has no heartbeat` }; break; }
|
|
104
|
+
}
|
|
105
|
+
const gates = {};
|
|
106
|
+
for (const g of requiredGates) {
|
|
107
|
+
const need = requiredTiers[g];
|
|
108
|
+
const ofGate = cases.filter((c) => c.gate === g);
|
|
109
|
+
// a case of another tier is reported, but it cannot meet a gate that names its tier
|
|
110
|
+
const mine = need ? ofGate.filter((c) => c.tier === need) : ofGate;
|
|
111
|
+
const rank = { violated: 3, blocked: 2, unresolved: 1, met: 0 };
|
|
112
|
+
let worst = mine.reduce((w, c) => (rank[c.outcome] > rank[w] ? c.outcome : w), 'met');
|
|
113
|
+
const reasons = mine.filter((c) => c.outcome !== 'met').map((c) => `${c.caseId}: ${c.reason}`);
|
|
114
|
+
if (extra[g]) { if (rank[extra[g].outcome] > rank[worst]) worst = extra[g].outcome; reasons.push(extra[g].reason); }
|
|
115
|
+
// no evidence is not a pass: a gate with no case and no gate-level evidence stays unresolved
|
|
116
|
+
if (need && !mine.length) { worst = rank[worst] > rank.unresolved ? worst : 'unresolved'; reasons.push(`no ${need} evidence for this gate${ofGate.length ? ` (${ofGate.length} case(s) of another tier do not count)` : ''}`); }
|
|
117
|
+
else if (!mine.length && !extra[g] && !(g === 'G-02' && packet.scope?.status === 'satisfied') && !(g === 'G-04' && packet.runner?.attested === true) && !(g === 'G-06' && ev?.status === 'measured')) { worst = 'unresolved'; reasons.push('no evidence for this gate'); }
|
|
118
|
+
gates[g] = { name: GATES[g], outcome: worst, reasons };
|
|
119
|
+
if (worst !== 'met') blockers.push({ gate: g, reason: reasons.join('; ') });
|
|
120
|
+
}
|
|
121
|
+
const tiers = { local: 0, staging: 0, independent: 0 };
|
|
122
|
+
for (const c of cases) if (tiers[c.tier] !== undefined) tiers[c.tier]++;
|
|
123
|
+
return { status: blockers.length ? 'not-qualified' : 'qualified', scope: packet.target ? { ...packet.target } : null, scannerBundle: packet.scanner?.bundleDigest ?? null, packetDigest: packetDigest(packet), gates, cases, blockers, tiers };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Is a recorded decision still the decision for this target? A decision qualifies only the artifact, policy and scanner bundle it was
|
|
128
|
+
* computed for and only until its earliest case expiry, so an upgrade or policy change invalidates it and a rollback is covered only by
|
|
129
|
+
* the earlier artifact's own decision.
|
|
130
|
+
* @param {object} decision an `evaluateCandidate` result
|
|
131
|
+
* @param {{artifactDigest?:string, policyDigest?:string, scannerBundle?:string}} target
|
|
132
|
+
*/
|
|
133
|
+
export function revalidateDecision(decision, target, { packet = null, now = Date.now() } = {}) {
|
|
134
|
+
if (!decision || typeof decision !== 'object' || !decision.scope) return { valid: false, reason: 'no recorded decision scope' };
|
|
135
|
+
if (decision.status !== 'qualified') return { valid: false, reason: 'the recorded decision did not qualify the candidate' };
|
|
136
|
+
if (!target || !target.artifactDigest || decision.scope.artifactDigest !== target.artifactDigest) return { valid: false, reason: 'the artifact differs from the one the decision qualified' };
|
|
137
|
+
if (decision.scope.policyDigest && decision.scope.policyDigest !== target.policyDigest) return { valid: false, reason: 'the deployment policy differs from the one the decision qualified' };
|
|
138
|
+
if (decision.scannerBundle && target.scannerBundle && decision.scannerBundle !== target.scannerBundle) return { valid: false, reason: 'the scanner bundle differs from the one the decision qualified' };
|
|
139
|
+
if (packet) {
|
|
140
|
+
if (packetDigest(packet) !== decision.packetDigest) return { valid: false, reason: 'the packet is not the one the decision was computed from' };
|
|
141
|
+
const soonest = Math.min(...(packet.cases || []).map((c) => Date.parse(c.expires)).filter(Number.isFinite));
|
|
142
|
+
if (Number.isFinite(soonest) && !(soonest > now)) return { valid: false, reason: 'evidence the decision rests on has expired' };
|
|
143
|
+
}
|
|
144
|
+
return { valid: true, reason: 'the decision covers this artifact, policy and scanner bundle' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Write a decision atomically; an identical retried write leaves the same bytes. */
|
|
148
|
+
export function writeCandidateDecision(file, decision) {
|
|
149
|
+
const body = `${canon(decision)}\n`;
|
|
150
|
+
try { if (fs.readFileSync(file, 'utf8') === body) return { written: false, unchanged: true }; } catch { /* absent */ }
|
|
151
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
152
|
+
fs.writeFileSync(tmp, body, { mode: 0o644 });
|
|
153
|
+
fs.renameSync(tmp, file);
|
|
154
|
+
return { written: true, unchanged: false };
|
|
155
|
+
}
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
//
|
|
3
3
|
// release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]
|
|
4
4
|
// release-package verify <package.json> --public-key <pem> [--require kind,kind]
|
|
5
|
+
// release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03]
|
|
6
|
+
// [--require-tier G-05=staging,...] [--output <decision.json>] [--format text|json]
|
|
7
|
+
//
|
|
8
|
+
// `qualify` is the strict candidate qualification of the threat-model PRD (FR-10.3): it verifies the packet's signature, recomputes
|
|
9
|
+
// every gate from the case evidence (`candidate-qualification.js`) and exits 0 only when the candidate is qualified. It never changes
|
|
10
|
+
// what an ordinary scan exits with.
|
|
5
11
|
//
|
|
6
12
|
// `io.git(root, ...args)` answers a read-only git query (or null); the caller supplies it so this layer never spawns a process itself.
|
|
7
|
-
// Exit codes: 0 built or
|
|
13
|
+
// Exit codes: 0 built, verified or qualified; 1 the scan state failed its integrity check, a package was rejected or a candidate was not
|
|
14
|
+
// qualified; 2 a usage problem or unreadable input.
|
|
8
15
|
//
|
|
9
16
|
// What goes in: the scan's health record and one record per finding (and, with --fix-records, one verification record per fix claimed). The proof tier of a
|
|
10
17
|
// finding is whatever the scan recorded; a finding that recorded none is `taint-proven` when the taint engine found its path and `unproven` otherwise. An
|
|
@@ -18,8 +25,9 @@ import { buildSnapshotRef } from './evidence-registry.js';
|
|
|
18
25
|
import { buildReleasePackage, signReleasePackage } from './release-package.js';
|
|
19
26
|
import { releaseVerdict, verifyStatedVerdict } from './release-verdict.js';
|
|
20
27
|
import { verifyReleasePackage } from './release-package-verify.js';
|
|
28
|
+
import { evaluateCandidate, verifyCandidatePacket, writeCandidateDecision, GATES } from './candidate-qualification.js';
|
|
21
29
|
|
|
22
|
-
const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]';
|
|
30
|
+
const USAGE = 'Usage: agentic-security release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]\n agentic-security release-package verify <package.json> --public-key <pem> [--require kind,kind]\n agentic-security release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03] [--require-tier G-05=staging] [--output <decision.json>] [--format text|json]';
|
|
23
31
|
const DEFAULT_OUT = 'release-package.json';
|
|
24
32
|
|
|
25
33
|
|
|
@@ -104,11 +112,45 @@ async function verify(args, say) {
|
|
|
104
112
|
return 1;
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
const listFlag = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
116
|
+
|
|
117
|
+
async function qualify(args, say) {
|
|
118
|
+
const file = args._[2];
|
|
119
|
+
const keyFile = args.flags['public-key'];
|
|
120
|
+
if (!file || !keyFile) { say.err(USAGE); return 2; }
|
|
121
|
+
let packet; let key;
|
|
122
|
+
try { packet = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); key = fs.readFileSync(path.resolve(String(keyFile)), 'utf8'); } catch (e) { say.err(`Could not read the input: ${e.message}`); return 2; }
|
|
123
|
+
const requiredGates = args.flags['require-gate'] ? listFlag(args.flags['require-gate']) : Object.keys(GATES);
|
|
124
|
+
const unknown = requiredGates.filter((g) => !GATES[g]);
|
|
125
|
+
if (unknown.length) { say.err(`Unknown gate: ${unknown.join(', ')}. Gates: ${Object.keys(GATES).join(', ')}.`); return 2; }
|
|
126
|
+
const requiredTiers = {};
|
|
127
|
+
for (const pair of listFlag(args.flags['require-tier'])) {
|
|
128
|
+
const [g, t] = pair.split('=');
|
|
129
|
+
if (!GATES[g] || !['local', 'staging', 'independent'].includes(t)) { say.err(`--require-tier takes gate=tier pairs (tier local, staging or independent), got ${pair}`); return 2; }
|
|
130
|
+
requiredTiers[g] = t;
|
|
131
|
+
}
|
|
132
|
+
const sig = verifyCandidatePacket(packet, key);
|
|
133
|
+
if (!sig.ok) { say.err(`${sig.code}: the candidate packet ${sig.code === 'UNSUPPORTED_VERSION' ? `has version ${sig.version}, which this scanner does not read` : 'did not verify under the given key'}`); say.err('candidate NOT QUALIFIED'); return 1; }
|
|
134
|
+
const d = evaluateCandidate(packet, { requiredGates, requiredCases: listFlag(args.flags['require-case']), requiredTiers });
|
|
135
|
+
if (args.flags.output) {
|
|
136
|
+
try { writeCandidateDecision(path.resolve(String(args.flags.output)), d); } catch (e) { say.err(`Could not write the decision: ${e.message}`); return 2; }
|
|
137
|
+
}
|
|
138
|
+
if (args.flags.format === 'json') say.out(JSON.stringify(d, null, 2));
|
|
139
|
+
else {
|
|
140
|
+
const t = d.scope || {};
|
|
141
|
+
say.out(`candidate ${d.status === 'qualified' ? 'QUALIFIED' : 'NOT QUALIFIED'} for ${t.service || t.repository || 'an unnamed target'} artifact ${String(t.artifactDigest || 'unbound').slice(0, 19)} (${t.deploymentProfile || 'no profile'})`);
|
|
142
|
+
for (const [g, x] of Object.entries(d.gates)) say.out(` ${g} ${x.name}: ${x.outcome}${x.reasons.length ? ` (${x.reasons.join('; ')})` : ''}`);
|
|
143
|
+
say.out(` cases by tier: local ${d.tiers.local}, staging ${d.tiers.staging}, independent ${d.tiers.independent}`);
|
|
144
|
+
}
|
|
145
|
+
return d.status === 'qualified' ? 0 : 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
export async function cmdReleasePackage(args, io = {}) {
|
|
108
149
|
const say = { out: io.out || ((s) => console.log(s)), err: io.err || ((s) => console.error(s)) };
|
|
109
150
|
const sub = args._[1];
|
|
110
151
|
if (sub === 'build') return build(args, say, io.git || (() => null));
|
|
111
152
|
if (sub === 'verify') return verify(args, say);
|
|
153
|
+
if (sub === 'qualify') return qualify(args, say);
|
|
112
154
|
say.err(USAGE);
|
|
113
155
|
return 2;
|
|
114
156
|
}
|