@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
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
export const id = 3500;
|
|
2
|
+
export const ids = [3500,3917];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 63500:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
// ESM COMPAT FLAG
|
|
9
|
+
__webpack_require__.r(__webpack_exports__);
|
|
10
|
+
|
|
11
|
+
// EXPORTS
|
|
12
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
13
|
+
cmdReleasePackage: () => (/* binding */ cmdReleasePackage)
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
17
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
18
|
+
// EXTERNAL MODULE: external "node:path"
|
|
19
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
20
|
+
// EXTERNAL MODULE: ./src/posture/state-dir.js
|
|
21
|
+
var state_dir = __webpack_require__(31174);
|
|
22
|
+
// EXTERNAL MODULE: ./src/posture/integrity.js
|
|
23
|
+
var integrity = __webpack_require__(71130);
|
|
24
|
+
// EXTERNAL MODULE: ./src/posture/assurance/identity.js
|
|
25
|
+
var identity = __webpack_require__(41877);
|
|
26
|
+
// EXTERNAL MODULE: ./src/posture/assurance/schema-kit.js
|
|
27
|
+
var schema_kit = __webpack_require__(53353);
|
|
28
|
+
;// CONCATENATED MODULE: ./src/posture/assurance/evidence-registry.js
|
|
29
|
+
// The evidence contract inventory (E7-01): every kind of evidence the product emits, how each names its schema and identity, and one uniform way to
|
|
30
|
+
// bind any of them to an immutable snapshot.
|
|
31
|
+
//
|
|
32
|
+
// WHY. The evidence a release rests on comes from a dozen producers written at different times. They name their schema three different ways
|
|
33
|
+
// (`x/y@1`, `x/y` plus a `schemaVersion` of 1.0.0, `thing/1`), identify records by a field called `id`, `stableId`, `findingId` or nothing, and refer to the code
|
|
34
|
+
// they describe by a commit, a tree hash, an engine version or not at all. A reader (an auditor, a verifier, a signed release package) cannot tell what a
|
|
35
|
+
// record is about or whether two records are about the same code. This module does not change any producer. It
|
|
36
|
+
// 1. records, for each kind, how to read its schema, version and id (`describeEvidence`), so they are comparable;
|
|
37
|
+
// 2. wraps any record in an EvidenceRef: its kind, schema, version, id, a digest of its canonical content, the digest of the snapshot it describes, and
|
|
38
|
+
// the digests of the evidence it rests on (`buildEvidenceRef`), checked by `verifyEvidenceRef`;
|
|
39
|
+
// 3. checks a set of refs as a lineage (`checkLineage`): no ref points at a digest that is not in the set, no cycle, no ref bound to a different snapshot
|
|
40
|
+
// than the evidence it rests on;
|
|
41
|
+
// 4. refuses an unknown kind, a future major version and a record that does not carry what its kind promises (`inspectEvidence`).
|
|
42
|
+
//
|
|
43
|
+
// A SNAPSHOT is a description of the code and the tool that produced a result: commit, tree digest, whether the working tree was dirty, engine version,
|
|
44
|
+
// bundle digest, configuration digest. Its digest covers all of those. A snapshot with a dirty tree or no commit is `immutable: false`, and a ref bound to
|
|
45
|
+
// it says so: evidence about code nobody can recover is evidence, but it is not evidence a release can be signed on.
|
|
46
|
+
//
|
|
47
|
+
// PROVENANCE. The git origin of a finding is `findingProvenance`. A bare `provenance` property on a finding belongs to other features and means something
|
|
48
|
+
// else. This module reads only the first, never merges the two, and the ref for a finding carries each under its own name.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
const EVIDENCE_REF_SCHEMA = 'agentic-security/evidence-ref';
|
|
53
|
+
const EVIDENCE_REF_VERSION = '1.0.0';
|
|
54
|
+
const SNAPSHOT_SCHEMA = 'agentic-security/snapshot-ref';
|
|
55
|
+
|
|
56
|
+
/** How a kind spells its schema: `at` is `x/y@N`, `semver` is `{schema, schemaVersion}`, `slash` is `thing/N`, `none` is unversioned. */
|
|
57
|
+
const read = {
|
|
58
|
+
at: (p, c) => { const v = String(c.field || 'schema').split('.').reduce((a, k) => (a === null || a === undefined ? undefined : a[k]), p); const m = typeof v === 'string' ? /^(.*)@(\d+)$/.exec(v) : null; return m ? { schema: m[1], version: `${m[2]}.0.0` } : null; },
|
|
59
|
+
semver: (p, c) => (typeof p.schema === 'string' && typeof p.schemaVersion === 'string' ? { schema: p.schema, version: p.schemaVersion } : null),
|
|
60
|
+
semverNumber: (p, c) => (typeof p.schema === 'string' && Number.isInteger(p.schemaVersion) ? { schema: p.schema, version: `${p.schemaVersion}.0.0` } : null),
|
|
61
|
+
slash: (p, c) => { const m = typeof p[c.field] === 'string' ? /^(.*)\/(\d+)$/.exec(p[c.field]) : null; return m ? { schema: m[1], version: `${m[2]}.0.0` } : null; },
|
|
62
|
+
none: () => null,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const at = (field = 'schema') => ({ style: 'at', field });
|
|
66
|
+
const SNAP = Object.freeze({ commit: 'commit', tree: 'tree', engine: 'engine', digest: 'digest', none: 'none' });
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The inventory. `producer` is the module that creates the record (a test checks it exists); `id` names the identity field; `snapshot` says how the record
|
|
70
|
+
* refers to the code it describes today (`none` is a gap the EvidenceRef closes); `signed` says what protects it in transit.
|
|
71
|
+
*/
|
|
72
|
+
const EVIDENCE_KINDS = Object.freeze({
|
|
73
|
+
finding: { title: 'scanner finding', producer: 'scanner/src/engine.js', schemaRead: { style: 'none' }, id: ['stableId', 'id'], snapshot: SNAP.none, signed: 'none', provenanceField: 'findingProvenance' },
|
|
74
|
+
'scan-health': { title: 'scan health', producer: 'scanner/src/pipeline/scan-health.js', schemaRead: { style: 'versionOnly', field: 'schemaVersion' }, id: [], snapshot: SNAP.none, signed: 'hmac-with-last-scan' },
|
|
75
|
+
'finding-evidence': { title: 'per-finding evidence bundle', producer: 'scanner/src/posture/evidence-bundle.js', schemaRead: at(), id: ['finding.stableId', 'finding.id'], snapshot: SNAP.commit, signed: 'ed25519' },
|
|
76
|
+
'provenance-evidence': { title: 'finding provenance bundle', producer: 'scanner/src/posture/provenance-evidence-bundle.js', schemaRead: at(), id: ['finding.stableId', 'finding.id'], snapshot: SNAP.commit, signed: 'ed25519', provenanceField: 'provenance' },
|
|
77
|
+
'compliance-evidence': { title: 'compliance evidence', producer: 'scanner/src/posture/compliance-evidence-signing.js', schemaRead: { style: 'at', field: 'signature.canonicalisation' }, id: [], snapshot: SNAP.none, signed: 'ed25519', note: 'the schema is declared only inside the signature block' },
|
|
78
|
+
'obligation-evidence-pack': { title: 'regulatory obligation evidence pack', producer: 'scanner/src/posture/obligation-evidence-pack.js', schemaRead: at(), id: [], snapshot: SNAP.engine, signed: 'ed25519' },
|
|
79
|
+
'fix-verification': { title: 'patch verification record', producer: 'scanner/src/fix/verification-record.js', schemaRead: { style: 'semver' }, id: ['id'], snapshot: SNAP.commit, signed: 'none' },
|
|
80
|
+
verification: { title: 'hypothesis verification record', producer: 'scanner/src/posture/assurance/verification-record.js', schemaRead: { style: 'semver' }, id: ['id'], snapshot: SNAP.commit, signed: 'none' },
|
|
81
|
+
'release-evidence': { title: 'release evidence', producer: 'scanner/src/posture/assurance/contracts.js', schemaRead: { style: 'semver' }, id: ['id'], snapshot: SNAP.commit, signed: 'none' },
|
|
82
|
+
'dci-invariants': { title: 'context-intelligence invariants', producer: 'scanner/src/context-intelligence/invariants.js', schemaRead: { style: 'semver' }, id: [], snapshot: SNAP.none, signed: 'none' },
|
|
83
|
+
'runtime-explanation': { title: 'static/runtime explanation', producer: 'scanner/src/posture/runtime-explanation.js', schemaRead: at(), id: ['findingStableId', 'findingId'], snapshot: SNAP.commit, signed: 'none' },
|
|
84
|
+
'exploitability-evidence': { title: 'multi-level exploitability evidence', producer: 'scanner/src/posture/exploitability-evidence.js', schemaRead: at(), id: [], snapshot: SNAP.none, signed: 'none' },
|
|
85
|
+
'semantic-graph': { title: 'semantic graph', producer: 'scanner/src/ir/semantic-graph.js', schemaRead: { style: 'semver' }, id: [], snapshot: SNAP.none, signed: 'none' },
|
|
86
|
+
scorecard: { title: 'accuracy scorecard', producer: 'scanner/src/posture/accuracy-scorecard.js', schemaRead: at(), id: [], snapshot: SNAP.commit, signed: 'none' },
|
|
87
|
+
sbom: { title: 'software bill of materials', producer: 'scanner/src/posture/sbom.js', schemaRead: { style: 'cyclonedx' }, id: [], snapshot: SNAP.none, signed: 'none', note: 'serialNumber is a fresh UUID on every run, so it does not identify the content' },
|
|
88
|
+
'ai-bom': { title: 'AI bill of materials', producer: 'scanner/src/posture/aibom.js', schemaRead: { style: 'cyclonedx' }, id: [], snapshot: SNAP.none, signed: 'none', note: 'serialNumber is a fresh UUID on every run, so it does not identify the content' },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const EVIDENCE_KIND_NAMES = Object.freeze(Object.keys(EVIDENCE_KINDS));
|
|
92
|
+
|
|
93
|
+
const get = (o, path) => path.split('.').reduce((a, k) => (a === null || a === undefined ? undefined : a[k]), o);
|
|
94
|
+
|
|
95
|
+
/** Read schema, version and identity out of a record of `kind`, whatever convention the producer used. Never throws. */
|
|
96
|
+
function describeEvidence(kind, payload) {
|
|
97
|
+
const k = EVIDENCE_KINDS[kind];
|
|
98
|
+
if (!k) return { ok: false, reason: `unknown evidence kind "${kind}"` };
|
|
99
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(payload)) return { ok: false, reason: 'the record is not an object' };
|
|
100
|
+
const style = k.schemaRead.style;
|
|
101
|
+
let sv = null;
|
|
102
|
+
if (style === 'at' || style === 'slash') sv = read[style](payload, k.schemaRead);
|
|
103
|
+
else if (style === 'semver') sv = typeof payload.schemaVersion === 'number' ? read.semverNumber(payload) : read.semver(payload);
|
|
104
|
+
else if (style === 'versionOnly') sv = payload[k.schemaRead.field] !== undefined ? { schema: `agentic-security/${kind}`, version: Number.isInteger(payload[k.schemaRead.field]) ? `${payload[k.schemaRead.field]}.0.0` : String(payload[k.schemaRead.field]) } : null;
|
|
105
|
+
else if (style === 'cyclonedx') sv = payload.bomFormat === 'CycloneDX' && payload.specVersion ? { schema: 'cyclonedx', version: String(payload.specVersion) } : null;
|
|
106
|
+
let id = null;
|
|
107
|
+
for (const f of k.id) { const v = get(payload, f); if (typeof v === 'string' && v) { id = v; break; } }
|
|
108
|
+
return { ok: true, kind, schema: sv ? sv.schema : null, version: sv ? sv.version : null, id };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What a record of this kind should carry, checked against the record itself.
|
|
113
|
+
* @returns {{ok:boolean, gaps:string[], errors:string[]}} `gaps` are known weaknesses of the producer's format (informational); `errors` make the record unusable.
|
|
114
|
+
*/
|
|
115
|
+
function inspectEvidence(kind, payload) {
|
|
116
|
+
const d = describeEvidence(kind, payload);
|
|
117
|
+
if (!d.ok) return { ok: false, gaps: [], errors: [d.reason] };
|
|
118
|
+
const k = EVIDENCE_KINDS[kind]; const gaps = []; const errors = [];
|
|
119
|
+
if (k.schemaRead.style !== 'none' && !d.schema) errors.push('the record does not carry the schema declaration this kind promises');
|
|
120
|
+
if (k.schemaRead.style === 'none') gaps.push('the format declares no schema or version');
|
|
121
|
+
if (d.version) { const major = Number(d.version.split('.')[0]); if (Number.isFinite(major) && major > SUPPORTED_MAJOR && kind !== 'sbom' && kind !== 'ai-bom') errors.push(`major version ${major} is newer than this build supports (${SUPPORTED_MAJOR})`); }
|
|
122
|
+
if (k.id.length && !d.id) errors.push(`the record carries none of its identity fields (${k.id.join(', ')})`);
|
|
123
|
+
if (!k.id.length) gaps.push(k.note && /serialNumber/.test(k.note) ? 'the only identifier is random per run: the record is identified by the digest of its content' : 'the format has no identity field: it is identified by the digest of its content');
|
|
124
|
+
if (k.note && !/serialNumber/.test(k.note)) gaps.push(k.note);
|
|
125
|
+
if (k.snapshot === SNAP.none) gaps.push('the format does not say which code it describes');
|
|
126
|
+
if (k.signed === 'none') gaps.push('the format is not signed on its own');
|
|
127
|
+
return { ok: errors.length === 0, gaps, errors };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* How a record actually refers to the code it describes: the strongest of a commit, a tree hash, an engine version or a digest field that it carries WITH
|
|
132
|
+
* A VALUE. This is read from the record, so the registry's declared `snapshot` can be checked against what a producer emits.
|
|
133
|
+
*/
|
|
134
|
+
function observeSnapshotBinding(payload) {
|
|
135
|
+
const found = new Set();
|
|
136
|
+
const walk = (v, depth) => {
|
|
137
|
+
if (depth > 4 || !v || typeof v !== 'object') return;
|
|
138
|
+
for (const [key, val] of Object.entries(v)) {
|
|
139
|
+
if (typeof val === 'string' && val) {
|
|
140
|
+
if (/^(commit|preCommit|head)$/.test(key) && /^[0-9a-f]{40,64}$/.test(val)) found.add('commit');
|
|
141
|
+
else if (/^(preTreeHash|treeHash|treeDigest|postTreeHash)$/.test(key)) found.add('tree');
|
|
142
|
+
else if (key === 'engineVersion') found.add('engine');
|
|
143
|
+
else if (/^(sourceDigest|snapshotDigest|graphDigest|controlsDigest)$/.test(key)) found.add('digest');
|
|
144
|
+
} else if (val && typeof val === 'object') walk(val, depth + 1);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
walk(payload, 0);
|
|
148
|
+
for (const b of ['commit', 'tree', 'engine', 'digest']) if (found.has(b)) return b;
|
|
149
|
+
return 'none';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- snapshots -----------------------------------------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** A description of the code and tool a result is about, with a digest over all of it. */
|
|
155
|
+
function buildSnapshotRef({ commit = null, treeDigest = null, dirty = false, engineVersion = null, bundleSha = null, configDigest = null } = {}) {
|
|
156
|
+
const body = { schema: SNAPSHOT_SCHEMA, commit: commit || null, treeDigest: treeDigest || null, dirty: dirty === true, engineVersion: engineVersion || null, bundleSha: bundleSha || null, configDigest: configDigest || null };
|
|
157
|
+
const immutable = !!body.commit && body.dirty === false;
|
|
158
|
+
return { ...body, immutable, digest: (0,identity/* digestOf */.ol)(body) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function verifySnapshotRef(s) {
|
|
162
|
+
if (!(0,schema_kit/* isPlainObject */.Qd)(s) || s.schema !== SNAPSHOT_SCHEMA) return false;
|
|
163
|
+
const { digest, immutable, ...body } = s;
|
|
164
|
+
return digest === (0,identity/* digestOf */.ol)(body) && immutable === (!!body.commit && body.dirty === false);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- evidence refs -------------------------------------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {string} kind
|
|
171
|
+
* @param {object} payload the evidence record, unmodified
|
|
172
|
+
* @param {object} snapshot from buildSnapshotRef
|
|
173
|
+
* @param {{rests_on?:string[]}} opts digests of the evidence this record rests on
|
|
174
|
+
*/
|
|
175
|
+
function buildEvidenceRef(kind, payload, snapshot, { restsOn = [] } = {}) {
|
|
176
|
+
const d = describeEvidence(kind, payload);
|
|
177
|
+
if (!d.ok) throw new Error(d.reason);
|
|
178
|
+
if (!verifySnapshotRef(snapshot)) throw new Error('the snapshot is not a valid snapshot reference');
|
|
179
|
+
for (const r of restsOn) if (!(0,schema_kit/* isDigest */.Bc)(r)) throw new Error(`restsOn holds a value that is not a digest: ${JSON.stringify(r)}`);
|
|
180
|
+
const body = {
|
|
181
|
+
schema: EVIDENCE_REF_SCHEMA, schemaVersion: EVIDENCE_REF_VERSION, kind,
|
|
182
|
+
payloadSchema: d.schema, payloadVersion: d.version, payloadId: d.id,
|
|
183
|
+
payloadDigest: (0,identity/* digestOf */.ol)(payload),
|
|
184
|
+
snapshotDigest: snapshot.digest, snapshotImmutable: snapshot.immutable,
|
|
185
|
+
restsOn: [...new Set(restsOn)].sort(),
|
|
186
|
+
};
|
|
187
|
+
const out = { ...body, digest: (0,identity/* digestOf */.ol)(body) };
|
|
188
|
+
// the git origin of a finding is findingProvenance and nothing else; a bare `provenance` is another feature's field and is carried only as its own label
|
|
189
|
+
if (kind === 'finding') {
|
|
190
|
+
out.findingProvenanceDigest = (0,schema_kit/* isPlainObject */.Qd)(payload.findingProvenance) ? (0,identity/* digestOf */.ol)(payload.findingProvenance) : null;
|
|
191
|
+
out.otherProvenanceDigest = payload.provenance !== undefined ? (0,identity/* digestOf */.ol)(payload.provenance) : null;
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Re-derive a ref from the payload and snapshot it claims to describe. @returns {{ok:boolean, errors:string[]}} */
|
|
197
|
+
function verifyEvidenceRef(ref, payload, snapshot) {
|
|
198
|
+
const errors = [];
|
|
199
|
+
if (!isPlainObject(ref) || ref.schema !== EVIDENCE_REF_SCHEMA) return { ok: false, errors: ['not an evidence reference'] };
|
|
200
|
+
if (Number(String(ref.schemaVersion).split('.')[0]) !== SUPPORTED_MAJOR) return { ok: false, errors: [`unsupported evidence reference version ${ref.schemaVersion}`] };
|
|
201
|
+
const { digest, findingProvenanceDigest, otherProvenanceDigest, ...body } = ref;
|
|
202
|
+
if (digestOf(body) !== digest) errors.push('the reference was altered after it was built (its digest does not match its fields)');
|
|
203
|
+
if (!EVIDENCE_KINDS[ref.kind]) errors.push(`unknown evidence kind "${ref.kind}"`);
|
|
204
|
+
else {
|
|
205
|
+
if (digestOf(payload) !== ref.payloadDigest) errors.push('the evidence record does not match the digest in its reference');
|
|
206
|
+
if (!snapshot || snapshot.digest !== ref.snapshotDigest || !verifySnapshotRef(snapshot)) errors.push('the snapshot does not match the reference');
|
|
207
|
+
const d = describeEvidence(ref.kind, payload);
|
|
208
|
+
if (d.ok && (d.schema !== ref.payloadSchema || d.version !== ref.payloadVersion || d.id !== ref.payloadId)) errors.push('the record\'s schema, version or id differs from what the reference states');
|
|
209
|
+
if (ref.kind === 'finding' && isPlainObject(payload)) {
|
|
210
|
+
const fp = isPlainObject(payload.findingProvenance) ? digestOf(payload.findingProvenance) : null;
|
|
211
|
+
if ((findingProvenanceDigest ?? null) !== fp) errors.push('findingProvenance differs from what the reference states');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { ok: errors.length === 0, errors };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A set of refs is a lineage when every `restsOn` digest names a ref in the set, nothing rests on itself through a cycle, and evidence rests only on
|
|
219
|
+
* evidence about the SAME snapshot. @returns {{ok:boolean, errors:string[], order:string[]}} order is a dependency-first ordering of ref digests
|
|
220
|
+
*/
|
|
221
|
+
function checkLineage(refs) {
|
|
222
|
+
const errors = [];
|
|
223
|
+
const by = new Map();
|
|
224
|
+
for (const r of refs) { if (by.has(r.digest)) errors.push(`duplicate reference ${r.digest}`); by.set(r.digest, r); }
|
|
225
|
+
for (const r of refs) {
|
|
226
|
+
for (const dep of r.restsOn || []) {
|
|
227
|
+
const t = by.get(dep);
|
|
228
|
+
if (!t) errors.push(`${r.kind} ${r.payloadId ?? r.digest.slice(0, 19)} rests on ${dep.slice(0, 19)}..., which is not in the set (dangling)`);
|
|
229
|
+
else if (t.snapshotDigest !== r.snapshotDigest) errors.push(`${r.kind} rests on ${t.kind} that describes a different snapshot`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const order = []; const state = new Map();
|
|
233
|
+
const visit = (d, trail) => {
|
|
234
|
+
if (state.get(d) === 2) return; if (state.get(d) === 1) { errors.push(`cycle through ${trail.concat(d).map((x) => x.slice(7, 15)).join(' -> ')}`); return; }
|
|
235
|
+
state.set(d, 1);
|
|
236
|
+
for (const dep of (by.get(d) || {}).restsOn || []) if (by.has(dep)) visit(dep, trail.concat(d));
|
|
237
|
+
state.set(d, 2); order.push(d);
|
|
238
|
+
};
|
|
239
|
+
for (const d of [...by.keys()].sort()) visit(d, []);
|
|
240
|
+
return { ok: errors.length === 0, errors, order };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
246
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
247
|
+
// EXTERNAL MODULE: ./src/posture/evidence-bundle.js
|
|
248
|
+
var evidence_bundle = __webpack_require__(98317);
|
|
249
|
+
;// CONCATENATED MODULE: ./src/posture/assurance/release-package.js
|
|
250
|
+
// The signed release assurance package (E7-03): everything a reviewer needs to judge one release, in one portable file that can be checked offline.
|
|
251
|
+
//
|
|
252
|
+
// WHAT IT HOLDS. A snapshot (the code and tool the evidence is about), the evidence itself (each record with its EvidenceRef), the lineage that ties them
|
|
253
|
+
// together, the declared scan completeness taken from the scan's own `scanHealth`, the policy decisions applied, and a list of statements (`assertions`)
|
|
254
|
+
// each citing the evidence it rests on. It is signed with Ed25519. The signing key is the one the evidence bundles use (`attest` creates it); the verifier
|
|
255
|
+
// needs only the public key.
|
|
256
|
+
//
|
|
257
|
+
// TWO CLAIMS THAT MUST NOT BE CONFUSED.
|
|
258
|
+
// INTEGRITY The signature proves that nothing in the package changed since the key holder signed it: not a field, not the order of the evidence, not which
|
|
259
|
+
// evidence is present. That is all it proves.
|
|
260
|
+
// EXPLOITABILITY Whether a finding is real, and whether it is exploitable, is a separate claim, carried per finding as `proofTier` and
|
|
261
|
+
// `exploitabilityProven` (true only for `execution-proven`). A signed finding with `unproven` is still unproven. The verifier recomputes both from
|
|
262
|
+
// the finding in the package, so the summary cannot say more than the finding does.
|
|
263
|
+
//
|
|
264
|
+
// WHAT A VALID SIGNATURE DOES NOT MAKE TRUE. A package can be perfectly signed and still say the scan was incomplete. `completeness` is derived from
|
|
265
|
+
// `scanHealth` and the verifier refuses a package that claims `complete` over a scan health that is not. Required evidence is checked twice: against the list
|
|
266
|
+
// the package declares and against a minimum the VERIFIER holds, so a package cannot weaken its own requirements by leaving something out and not saying so.
|
|
267
|
+
//
|
|
268
|
+
// ISSUER. Locally generated keys are self-issued, exactly as for evidence bundles: the package says so inside the signed bytes.
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
const RELEASE_PACKAGE_SCHEMA = 'agentic-security/release-package';
|
|
274
|
+
const RELEASE_PACKAGE_VERSION = '1.0.0';
|
|
275
|
+
const MANDATORY_KINDS = Object.freeze(['scan-health', 'finding']);
|
|
276
|
+
|
|
277
|
+
/** Which side of the evidence a kind sits on. */
|
|
278
|
+
const EVIDENCE_ROLES = Object.freeze({
|
|
279
|
+
finding: 'finding', 'finding-evidence': 'finding', 'provenance-evidence': 'finding', 'fix-verification': 'finding', verification: 'finding',
|
|
280
|
+
'runtime-explanation': 'finding', 'exploitability-evidence': 'finding',
|
|
281
|
+
'scan-health': 'completeness', 'semantic-graph': 'context', scorecard: 'context', 'dci-invariants': 'context',
|
|
282
|
+
'compliance-evidence': 'control', 'obligation-evidence-pack': 'control', sbom: 'inventory', 'ai-bom': 'inventory', 'release-evidence': 'control',
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const PROVES = 'Nothing in this package changed after the key holder signed it: no field, no record, no ordering, no omission of the records it lists.';
|
|
286
|
+
const DOES_NOT_PROVE = 'That any finding is real or exploitable (read each finding\'s proofTier), that the scan covered everything (read completeness), or that the key holder is independently certified (read issuance).';
|
|
287
|
+
|
|
288
|
+
const SIGNING_DOMAIN = `${RELEASE_PACKAGE_SCHEMA}@${RELEASE_PACKAGE_VERSION.split('.')[0]}\n`;
|
|
289
|
+
|
|
290
|
+
const proofTierOf = (payload) => (payload && typeof payload === 'object' && typeof payload.proofTier === 'string' ? payload.proofTier : 'unproven');
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {object} o
|
|
294
|
+
* @param {object} o.snapshot from buildSnapshotRef
|
|
295
|
+
* @param {{kind:string, payload:object, restsOn?:number[]}[]} o.evidence `restsOn` are INDEXES into this array of the records this one rests on
|
|
296
|
+
* @param {object[]} [o.policyDecisions] decisions already taken (id, outcome, reason, policyVersion); recorded as given, never evaluated here
|
|
297
|
+
* @param {string[]} [o.requiredKinds] kinds this package declares it must contain (added to the minimum the verifier holds)
|
|
298
|
+
* @param {{id:string, text:string, evidence:number[], status:'supported'|'unsupported'|'unknown', reason?:string}[]} [o.assertions] indexes into `evidence`
|
|
299
|
+
*/
|
|
300
|
+
function buildReleasePackage({ snapshot, evidence, policyDecisions = [], requiredKinds = [], assertions = [] } = {}) {
|
|
301
|
+
if (!verifySnapshotRef(snapshot)) throw new Error('a valid snapshot reference is required');
|
|
302
|
+
if (!Array.isArray(evidence) || evidence.length === 0) throw new Error('at least one evidence record is required');
|
|
303
|
+
// refs are built in dependency order so each one can name the digests of the records it rests on
|
|
304
|
+
const built = new Array(evidence.length);
|
|
305
|
+
const state = new Array(evidence.length).fill(0);
|
|
306
|
+
const make = (i, trail) => {
|
|
307
|
+
if (built[i]) return built[i];
|
|
308
|
+
if (state[i] === 1) throw new Error(`evidence ${trail.concat(i).join(' -> ')} rests on itself`);
|
|
309
|
+
state[i] = 1;
|
|
310
|
+
const e = evidence[i];
|
|
311
|
+
const deps = (e.restsOn || []).map((j) => { if (!Number.isInteger(j) || j < 0 || j >= evidence.length) throw new Error(`evidence ${i} rests on ${j}, which does not exist`); return make(j, trail.concat(i)).ref.digest; });
|
|
312
|
+
built[i] = { ref: buildEvidenceRef(e.kind, e.payload, snapshot, { restsOn: deps }), payload: e.payload, role: EVIDENCE_ROLES[e.kind] || 'context' };
|
|
313
|
+
state[i] = 2;
|
|
314
|
+
return built[i];
|
|
315
|
+
};
|
|
316
|
+
for (let i = 0; i < evidence.length; i++) make(i, []);
|
|
317
|
+
const lineage = checkLineage(built.map((b) => b.ref));
|
|
318
|
+
if (!lineage.ok) throw new Error(`lineage is not valid: ${lineage.errors.join('; ')}`);
|
|
319
|
+
const byDigest = new Map(built.map((b) => [b.ref.digest, b]));
|
|
320
|
+
const ordered = lineage.order.map((d) => byDigest.get(d));
|
|
321
|
+
|
|
322
|
+
const health = built.find((b) => b.ref.kind === 'scan-health');
|
|
323
|
+
const healthPayload = health ? health.payload : null;
|
|
324
|
+
const findings = built.filter((b) => b.ref.kind === 'finding').map((b) => {
|
|
325
|
+
const tier = proofTierOf(b.payload);
|
|
326
|
+
return {
|
|
327
|
+
findingRef: b.ref.digest, findingId: b.ref.payloadId,
|
|
328
|
+
integrity: 'unmodified-since-signing',
|
|
329
|
+
proofTier: tier, exploitabilityProven: tier === 'execution-proven',
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
const body = {
|
|
333
|
+
schema: RELEASE_PACKAGE_SCHEMA, schemaVersion: RELEASE_PACKAGE_VERSION,
|
|
334
|
+
snapshot,
|
|
335
|
+
evidence: ordered.map((b) => ({ role: b.role, ref: b.ref, payload: b.payload })),
|
|
336
|
+
lineageOrder: lineage.order,
|
|
337
|
+
requiredKinds: [...new Set([...MANDATORY_KINDS, ...requiredKinds])].sort(),
|
|
338
|
+
findings: findings.sort((a, b) => (a.findingRef < b.findingRef ? -1 : 1)),
|
|
339
|
+
completeness: {
|
|
340
|
+
scanStatus: healthPayload && typeof healthPayload.status === 'string' ? healthPayload.status : 'unknown',
|
|
341
|
+
conditions: healthPayload && Array.isArray(healthPayload.conditions) ? [...healthPayload.conditions] : [],
|
|
342
|
+
verdict: healthPayload && healthPayload.status === 'complete' ? 'complete' : 'incomplete',
|
|
343
|
+
source: health ? health.ref.digest : null,
|
|
344
|
+
},
|
|
345
|
+
policy: { decisions: policyDecisions.map((d) => ({ ...d })) },
|
|
346
|
+
assertions: assertions.map((a) => ({ id: a.id, text: a.text, status: a.status, reason: a.reason ?? null, evidence: [...new Set(a.evidence || [])].map((i) => { if (!built[i]) throw new Error(`assertion ${a.id} cites evidence ${i}, which does not exist`); return built[i].ref.digest; }).sort() })),
|
|
347
|
+
proves: PROVES, doesNotProve: DOES_NOT_PROVE,
|
|
348
|
+
};
|
|
349
|
+
return body;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The exact bytes that are signed: a domain label, then the canonical JSON of everything except the signature. */
|
|
353
|
+
function signedBytes(pkg) {
|
|
354
|
+
const { signature, ...rest } = pkg;
|
|
355
|
+
return Buffer.from(SIGNING_DOMAIN + (0,evidence_bundle/* canonicalJson */.dj)(rest), 'utf8');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function signReleasePackage(pkg, privateKeyPem) {
|
|
359
|
+
const publicKeyPem = external_node_crypto_.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
360
|
+
const issuance = {
|
|
361
|
+
trustBasis: 'self-issued-local-key', independentlyCertified: false, statement: evidence_bundle/* TRUST_BASES */.oR['self-issued-local-key'].statement,
|
|
362
|
+
issuer: { keyFingerprint: (0,evidence_bundle/* keyFingerprint */.yy)(publicKeyPem) },
|
|
363
|
+
};
|
|
364
|
+
const withIssuance = { ...pkg, issuance };
|
|
365
|
+
const sig = external_node_crypto_.sign(null, signedBytes(withIssuance), privateKeyPem);
|
|
366
|
+
return { ...withIssuance, signature: { algorithm: 'ed25519', domain: SIGNING_DOMAIN.trim(), value: sig.toString('base64') } };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// EXTERNAL MODULE: ./src/posture/assurance/release-verdict.js
|
|
370
|
+
var release_verdict = __webpack_require__(618);
|
|
371
|
+
// EXTERNAL MODULE: ./src/posture/assurance/release-package-verify.js
|
|
372
|
+
var release_package_verify = __webpack_require__(93917);
|
|
373
|
+
;// CONCATENATED MODULE: ./src/posture/assurance/candidate-qualification.js
|
|
374
|
+
// Candidate qualification (threat-model PRD FR-10.1, FR-10.2, section 6 "Candidate release gates").
|
|
375
|
+
//
|
|
376
|
+
// A release claim is qualified for an EXACT scanner, target artifact, deployment profile, policy and observation window, and only for
|
|
377
|
+
// the cases it passes. The packet carries those identities and one record per required case (requirement, gate, expected and observed
|
|
378
|
+
// outcome, verdict and reason code, artifact and policy bindings, evidence digests, expiry, owner). The decision is recomputed here from
|
|
379
|
+
// the evidence, never read from the packet: a case whose bindings do not match the packet's target, that has expired, whose feed is
|
|
380
|
+
// stale or whose legitimate control failed keeps its gate open, and the candidate is qualified only when every required gate is met.
|
|
381
|
+
// A higher-level status never inherits a pass from a lower-level one: a gate with no case is unresolved, not met.
|
|
382
|
+
//
|
|
383
|
+
// Outcome per case: met (the expectation held), violated (it did not), unresolved (evidence missing, stale, expired or differently
|
|
384
|
+
// scoped) or blocked (a prerequisite such as an attested runner or an independent population is unavailable). Pure apart from the
|
|
385
|
+
// optional signed write, which is atomic.
|
|
386
|
+
//
|
|
387
|
+
// Evidence tiers stay separate qualifications (PRD section 1 and section 8). Each case carries `tier`: `local` (loopback fixtures on
|
|
388
|
+
// this host, the default), `staging` (the owner-authorized staging deployment) or `independent` (an independently held population or
|
|
389
|
+
// reviewer). A gate the caller declares in `requiredTiers` is met only by cases of that tier: local evidence never satisfies a staging
|
|
390
|
+
// or independent gate by being more of it. A decision records the target it was computed for, and `revalidateDecision` refuses it for
|
|
391
|
+
// any other artifact, policy or scanner bundle, so an upgrade invalidates candidate-bound evidence and a rollback is qualified only by
|
|
392
|
+
// the earlier artifact's own unexpired decision (FR-10.4).
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
const CANDIDATE_SCHEMA = 'agentic-security/candidate-packet';
|
|
398
|
+
const CANDIDATE_VERSION = 1;
|
|
399
|
+
const GATES = Object.freeze({
|
|
400
|
+
'G-01': 'code regression', 'G-02': 'scan and graph coverage', 'G-03': 'prevention and repair', 'G-04': 'evidence and harness integrity',
|
|
401
|
+
'G-05': 'detection and response', 'G-06': 'independent accuracy', 'G-07': 'operability',
|
|
402
|
+
});
|
|
403
|
+
const EXPECT = new Set(['positive', 'negative', 'legitimate']);
|
|
404
|
+
const TIERS = new Set(['local', 'staging', 'independent']);
|
|
405
|
+
const DOMAIN = `${CANDIDATE_SCHEMA}@1\n`;
|
|
406
|
+
const canon = (v) => {
|
|
407
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
|
|
408
|
+
if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
|
|
409
|
+
// an undefined value is absent, exactly as JSON would carry it, so a packet and its JSON round trip have one digest
|
|
410
|
+
return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
411
|
+
};
|
|
412
|
+
const packetDigest = (p) => `sha256:${external_node_crypto_.createHash('sha256').update(canon({ ...p, signature: undefined })).digest('hex')}`;
|
|
413
|
+
|
|
414
|
+
/** Sign a packet with an Ed25519 private key; `verifyCandidatePacket` checks it with the key the caller trusts. */
|
|
415
|
+
function signCandidatePacket(packet, privateKeyPem) {
|
|
416
|
+
const body = { schema: CANDIDATE_SCHEMA, version: CANDIDATE_VERSION, ...packet };
|
|
417
|
+
delete body.signature;
|
|
418
|
+
return { ...body, signature: crypto.sign(null, Buffer.from(DOMAIN + canon(body)), privateKeyPem).toString('base64') };
|
|
419
|
+
}
|
|
420
|
+
function verifyCandidatePacket(packet, publicKeyPem) {
|
|
421
|
+
if (!packet || packet.schema !== CANDIDATE_SCHEMA) return { ok: false, code: 'FORMAT' };
|
|
422
|
+
// a packet from a newer or older writer is refused by name, never read with this version's meaning
|
|
423
|
+
if (packet.version !== CANDIDATE_VERSION) return { ok: false, code: 'UNSUPPORTED_VERSION', version: packet.version ?? null };
|
|
424
|
+
const { signature, ...body } = packet;
|
|
425
|
+
try { return external_node_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' }; }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Judge one case record against the packet's target. */
|
|
429
|
+
function judgeCase(c, packet, now) {
|
|
430
|
+
const t = packet.target || {};
|
|
431
|
+
const miss = [];
|
|
432
|
+
for (const k of ['caseId', 'requirement', 'gate', 'expected', 'verdict', 'owner', 'expires']) if (c[k] === undefined || c[k] === null || c[k] === '') miss.push(k);
|
|
433
|
+
if (miss.length) return { outcome: 'unresolved', reason: `missing ${miss.join(', ')}`, owner: c.owner ?? null };
|
|
434
|
+
if (!GATES[c.gate] || !EXPECT.has(c.expected)) return { outcome: 'unresolved', reason: 'unknown gate or expectation' };
|
|
435
|
+
if (c.tier !== undefined && !TIERS.has(c.tier)) return { outcome: 'unresolved', reason: 'unknown evidence tier' };
|
|
436
|
+
if (c.blocked) return { outcome: 'blocked', reason: String(c.blocked) };
|
|
437
|
+
if (!(Date.parse(c.expires) > now)) return { outcome: 'unresolved', reason: 'the evidence has expired' };
|
|
438
|
+
if (c.artifactDigest && t.artifactDigest && c.artifactDigest !== t.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence was produced against another artifact' };
|
|
439
|
+
if (c.policyDigest && t.policyDigest && c.policyDigest !== t.policyDigest) return { outcome: 'unresolved', reason: 'the evidence was produced under another policy' };
|
|
440
|
+
if (c.scannerBundle && packet.scanner?.bundleDigest && c.scannerBundle !== packet.scanner.bundleDigest) return { outcome: 'unresolved', reason: 'the evidence came from another scanner bundle' };
|
|
441
|
+
if (c.requiresArtifactBinding !== false && !c.artifactDigest) return { outcome: 'unresolved', reason: 'the evidence is not bound to an artifact' };
|
|
442
|
+
const o = c.observed;
|
|
443
|
+
if (!o || typeof o !== 'object') return { outcome: 'unresolved', reason: 'no observation is recorded' };
|
|
444
|
+
// the observation and the expectation stay separately inspectable; met/violated is derived, not asserted
|
|
445
|
+
if (c.kind === 'detection') {
|
|
446
|
+
// a detection case judges the signal, not the effect: positive means the rule must fire, negative that it must stay quiet,
|
|
447
|
+
// and an observation that does not say whether the rule fired leaves the case open
|
|
448
|
+
if (typeof o.detected !== 'boolean') return { outcome: 'unresolved', reason: 'the observation does not record whether the detection fired' };
|
|
449
|
+
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' };
|
|
450
|
+
return o.detected ? { outcome: 'violated', reason: 'the detection fired where it must not' } : { outcome: 'met', reason: 'the detection stayed quiet where it must' };
|
|
451
|
+
}
|
|
452
|
+
const forbidden = o.forbiddenEffect === true; const legit = o.legitimateEffect === true;
|
|
453
|
+
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' };
|
|
454
|
+
if (c.expected === 'negative') return forbidden ? { outcome: 'violated', reason: 'the forbidden effect occurred on the fixed artifact' } : { outcome: 'met', reason: 'no forbidden effect' };
|
|
455
|
+
return legit && !forbidden ? { outcome: 'met', reason: 'the legitimate operation succeeded' } : { outcome: 'violated', reason: 'the legitimate operation did not succeed' };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Recompute every gate from the packet's evidence.
|
|
460
|
+
* @param {object} packet
|
|
461
|
+
* @param {{requiredGates?:string[], requiredCases?:string[], requiredTiers?:Object<string,string>, now?:number, maxFeedAgeMs?:number}} o
|
|
462
|
+
* @returns {{status:'qualified'|'not-qualified', gates:object, cases:object[], blockers:object[], tiers:object}}
|
|
463
|
+
*/
|
|
464
|
+
function evaluateCandidate(packet, { requiredGates = Object.keys(GATES), requiredCases = [], requiredTiers = {}, now = Date.now(), maxFeedAgeMs = 15 * 60 * 1000 } = {}) {
|
|
465
|
+
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) }));
|
|
466
|
+
const blockers = [];
|
|
467
|
+
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' }); }
|
|
468
|
+
// gate-level evidence that is not a case
|
|
469
|
+
const extra = {};
|
|
470
|
+
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' };
|
|
471
|
+
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' };
|
|
472
|
+
const ev = packet.evaluation;
|
|
473
|
+
if (!ev || ev.status !== 'measured') extra['G-06'] = { outcome: 'blocked', reason: ev?.reason || 'independent accuracy is unmeasured for this candidate' };
|
|
474
|
+
else if (ev.meetsFloors !== true) extra['G-06'] = { outcome: 'violated', reason: 'the measured population does not meet the registered floors' };
|
|
475
|
+
for (const f of packet.telemetry?.feeds || []) {
|
|
476
|
+
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; }
|
|
477
|
+
}
|
|
478
|
+
const gates = {};
|
|
479
|
+
for (const g of requiredGates) {
|
|
480
|
+
const need = requiredTiers[g];
|
|
481
|
+
const ofGate = cases.filter((c) => c.gate === g);
|
|
482
|
+
// a case of another tier is reported, but it cannot meet a gate that names its tier
|
|
483
|
+
const mine = need ? ofGate.filter((c) => c.tier === need) : ofGate;
|
|
484
|
+
const rank = { violated: 3, blocked: 2, unresolved: 1, met: 0 };
|
|
485
|
+
let worst = mine.reduce((w, c) => (rank[c.outcome] > rank[w] ? c.outcome : w), 'met');
|
|
486
|
+
const reasons = mine.filter((c) => c.outcome !== 'met').map((c) => `${c.caseId}: ${c.reason}`);
|
|
487
|
+
if (extra[g]) { if (rank[extra[g].outcome] > rank[worst]) worst = extra[g].outcome; reasons.push(extra[g].reason); }
|
|
488
|
+
// no evidence is not a pass: a gate with no case and no gate-level evidence stays unresolved
|
|
489
|
+
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)` : ''}`); }
|
|
490
|
+
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'); }
|
|
491
|
+
gates[g] = { name: GATES[g], outcome: worst, reasons };
|
|
492
|
+
if (worst !== 'met') blockers.push({ gate: g, reason: reasons.join('; ') });
|
|
493
|
+
}
|
|
494
|
+
const tiers = { local: 0, staging: 0, independent: 0 };
|
|
495
|
+
for (const c of cases) if (tiers[c.tier] !== undefined) tiers[c.tier]++;
|
|
496
|
+
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 };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Is a recorded decision still the decision for this target? A decision qualifies only the artifact, policy and scanner bundle it was
|
|
501
|
+
* computed for and only until its earliest case expiry, so an upgrade or policy change invalidates it and a rollback is covered only by
|
|
502
|
+
* the earlier artifact's own decision.
|
|
503
|
+
* @param {object} decision an `evaluateCandidate` result
|
|
504
|
+
* @param {{artifactDigest?:string, policyDigest?:string, scannerBundle?:string}} target
|
|
505
|
+
*/
|
|
506
|
+
function revalidateDecision(decision, target, { packet = null, now = Date.now() } = {}) {
|
|
507
|
+
if (!decision || typeof decision !== 'object' || !decision.scope) return { valid: false, reason: 'no recorded decision scope' };
|
|
508
|
+
if (decision.status !== 'qualified') return { valid: false, reason: 'the recorded decision did not qualify the candidate' };
|
|
509
|
+
if (!target || !target.artifactDigest || decision.scope.artifactDigest !== target.artifactDigest) return { valid: false, reason: 'the artifact differs from the one the decision qualified' };
|
|
510
|
+
if (decision.scope.policyDigest && decision.scope.policyDigest !== target.policyDigest) return { valid: false, reason: 'the deployment policy differs from the one the decision qualified' };
|
|
511
|
+
if (decision.scannerBundle && target.scannerBundle && decision.scannerBundle !== target.scannerBundle) return { valid: false, reason: 'the scanner bundle differs from the one the decision qualified' };
|
|
512
|
+
if (packet) {
|
|
513
|
+
if (packetDigest(packet) !== decision.packetDigest) return { valid: false, reason: 'the packet is not the one the decision was computed from' };
|
|
514
|
+
const soonest = Math.min(...(packet.cases || []).map((c) => Date.parse(c.expires)).filter(Number.isFinite));
|
|
515
|
+
if (Number.isFinite(soonest) && !(soonest > now)) return { valid: false, reason: 'evidence the decision rests on has expired' };
|
|
516
|
+
}
|
|
517
|
+
return { valid: true, reason: 'the decision covers this artifact, policy and scanner bundle' };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Write a decision atomically; an identical retried write leaves the same bytes. */
|
|
521
|
+
function writeCandidateDecision(file, decision) {
|
|
522
|
+
const body = `${canon(decision)}\n`;
|
|
523
|
+
try { if (external_node_fs_.readFileSync(file, 'utf8') === body) return { written: false, unchanged: true }; } catch { /* absent */ }
|
|
524
|
+
const tmp = external_node_path_.join(external_node_path_.dirname(file), `.${external_node_path_.basename(file)}.${process.pid}.${external_node_crypto_.randomBytes(4).toString('hex')}.tmp`);
|
|
525
|
+
external_node_fs_.writeFileSync(tmp, body, { mode: 0o644 });
|
|
526
|
+
external_node_fs_.renameSync(tmp, file);
|
|
527
|
+
return { written: true, unchanged: false };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
;// CONCATENATED MODULE: ./src/posture/assurance/release-package-cli.js
|
|
531
|
+
// `agentic-security release-package build|verify`: assembles a signed release assurance package from the last scan and checks one offline.
|
|
532
|
+
//
|
|
533
|
+
// release-package build [path] --private-key <pem> [--output <file>] [--fix-records <file>] [--public-key-out <file>]
|
|
534
|
+
// release-package verify <package.json> --public-key <pem> [--require kind,kind]
|
|
535
|
+
// release-package qualify <candidate-packet.json> --public-key <pem> [--require-case id,id] [--require-gate G-01,G-03]
|
|
536
|
+
// [--require-tier G-05=staging,...] [--output <decision.json>] [--format text|json]
|
|
537
|
+
//
|
|
538
|
+
// `qualify` is the strict candidate qualification of the threat-model PRD (FR-10.3): it verifies the packet's signature, recomputes
|
|
539
|
+
// every gate from the case evidence (`candidate-qualification.js`) and exits 0 only when the candidate is qualified. It never changes
|
|
540
|
+
// what an ordinary scan exits with.
|
|
541
|
+
//
|
|
542
|
+
// `io.git(root, ...args)` answers a read-only git query (or null); the caller supplies it so this layer never spawns a process itself.
|
|
543
|
+
// Exit codes: 0 built, verified or qualified; 1 the scan state failed its integrity check, a package was rejected or a candidate was not
|
|
544
|
+
// qualified; 2 a usage problem or unreadable input.
|
|
545
|
+
//
|
|
546
|
+
// 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
|
|
547
|
+
// 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
|
|
548
|
+
// executed counterexample is never inferred here. The verdict is computed from the package's own contents and recorded as a policy decision, so the stated
|
|
549
|
+
// verdict can always be recomputed by anyone holding the package.
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
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]';
|
|
561
|
+
const DEFAULT_OUT = 'release-package.json';
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
function release_package_cli_proofTierOf(f) {
|
|
565
|
+
if (typeof f.proofTier === 'string') return f.proofTier;
|
|
566
|
+
return f.parser === 'IR-TAINT' ? 'taint-proven' : 'unproven';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function findingPayload(f) {
|
|
570
|
+
return {
|
|
571
|
+
id: f.id, stableId: f.stableId ?? null, severity: f.severity, vuln: f.vuln, file: f.file, line: f.line ?? null,
|
|
572
|
+
parser: f.parser, family: f.family, cwe: f.cwe ?? null, proofTier: release_package_cli_proofTierOf(f),
|
|
573
|
+
...(f.triage ? { triage: f.triage } : {}),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function build(args, say, git) {
|
|
578
|
+
const root = external_node_path_.resolve(args._[2] || '.');
|
|
579
|
+
const keyFile = args.flags['private-key'];
|
|
580
|
+
if (!keyFile) { say.err(USAGE); return 2; }
|
|
581
|
+
let privateKey;
|
|
582
|
+
try { privateKey = external_node_fs_.readFileSync(external_node_path_.resolve(String(keyFile)), 'utf8'); } catch { say.err(`Could not read the private key at ${keyFile}.`); return 2; }
|
|
583
|
+
|
|
584
|
+
const file = (0,state_dir.statePath)(root, 'last-scan.json');
|
|
585
|
+
let body;
|
|
586
|
+
try { body = external_node_fs_.readFileSync(file, 'utf8'); } catch { say.err(`No scan found at ${file}. Run a scan first.`); return 2; }
|
|
587
|
+
const sig = (0,integrity/* verifyLastScan */.Ef)(body, file + '.sig');
|
|
588
|
+
if (sig !== true) { say.err(sig === null ? 'The last scan has no signature, so it is not used as evidence. Run a scan again.' : 'The last scan failed its integrity check, so it is not used as evidence.'); return 1; }
|
|
589
|
+
let scan;
|
|
590
|
+
try { scan = JSON.parse(body); } catch { say.err('The last scan is not valid JSON.'); return 1; }
|
|
591
|
+
if (!scan.scanHealth || typeof scan.scanHealth !== 'object') { say.err('The last scan carries no scan health record, which a release package requires.'); return 1; }
|
|
592
|
+
|
|
593
|
+
let fixes = [];
|
|
594
|
+
if (args.flags['fix-records']) {
|
|
595
|
+
try { fixes = JSON.parse(external_node_fs_.readFileSync(external_node_path_.resolve(String(args.flags['fix-records'])), 'utf8')); } catch (e) { say.err(`Could not read the fix records: ${e.message}`); return 2; }
|
|
596
|
+
if (!Array.isArray(fixes)) { say.err('The fix records file must hold a list.'); return 2; }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const findings = (scan.findings || []).filter(f => f && typeof f === 'object');
|
|
600
|
+
const evidence = [{ kind: 'scan-health', payload: scan.scanHealth }];
|
|
601
|
+
for (const f of findings) evidence.push({ kind: 'finding', payload: findingPayload(f) });
|
|
602
|
+
for (const rec of fixes) {
|
|
603
|
+
const at = findings.findIndex(f => f.id === rec.findingId || (rec.findingStableId && f.stableId === rec.findingStableId));
|
|
604
|
+
evidence.push({ kind: 'fix-verification', payload: rec, ...(at >= 0 ? { restsOn: [at + 1] } : {}) });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const commit = git(root, 'rev-parse', 'HEAD');
|
|
608
|
+
const dirty = commit ? (git(root, 'status', '--porcelain') || '').length > 0 : false;
|
|
609
|
+
const snapshot = buildSnapshotRef({ commit, dirty, engineVersion: scan.engineVersion || null });
|
|
610
|
+
let pkg;
|
|
611
|
+
try {
|
|
612
|
+
const draft = buildReleasePackage({ snapshot, evidence });
|
|
613
|
+
const v = (0,release_verdict/* releaseVerdict */.D)(draft);
|
|
614
|
+
pkg = signReleasePackage(buildReleasePackage({ snapshot, evidence, policyDecisions: [{ id: 'release-verdict', outcome: v.verdict, reason: v.statement, policyVersion: '1' }] }), privateKey);
|
|
615
|
+
} catch (e) { say.err(`Could not build the package: ${e.message}`); return 1; }
|
|
616
|
+
|
|
617
|
+
const out = external_node_path_.resolve(String(args.flags.output || external_node_path_.join(root, DEFAULT_OUT)));
|
|
618
|
+
external_node_fs_.writeFileSync(out, JSON.stringify(pkg, null, 2) + '\n');
|
|
619
|
+
if (args.flags['public-key-out']) {
|
|
620
|
+
const { createPublicKey } = await Promise.resolve(/* import() */).then(__webpack_require__.t.bind(__webpack_require__, 77598, 19));
|
|
621
|
+
external_node_fs_.writeFileSync(external_node_path_.resolve(String(args.flags['public-key-out'])), createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }));
|
|
622
|
+
}
|
|
623
|
+
const v = (0,release_verdict/* releaseVerdict */.D)(pkg);
|
|
624
|
+
say.out(`release package written to ${out}: ${pkg.evidence.length} evidence records, ${pkg.findings.length} findings, scan ${pkg.completeness.scanStatus}`);
|
|
625
|
+
say.out(`verdict: ${v.verdict}. ${v.statement}`);
|
|
626
|
+
if (!snapshot.immutable) say.out('The snapshot is not immutable: the working tree has uncommitted changes or there is no commit, and the package says so.');
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function verify(args, say) {
|
|
631
|
+
const file = args._[2];
|
|
632
|
+
const keyFile = args.flags['public-key'];
|
|
633
|
+
if (!file || !keyFile) { say.err(USAGE); return 2; }
|
|
634
|
+
let pkg; let key;
|
|
635
|
+
try { pkg = JSON.parse(external_node_fs_.readFileSync(external_node_path_.resolve(file), 'utf8')); key = external_node_fs_.readFileSync(external_node_path_.resolve(String(keyFile)), 'utf8'); } catch (e) { say.err(`Could not read the input: ${e.message}`); return 2; }
|
|
636
|
+
const r = (0,release_package_verify.verifyReleasePackage)(pkg, key, { require: String(args.flags.require || '').split(',').filter(Boolean) });
|
|
637
|
+
const stated = r.ok ? (0,release_verdict/* verifyStatedVerdict */.s)(pkg) : null;
|
|
638
|
+
if (r.ok && stated && stated.ok) { say.out(`release package verified: scan ${r.completeness}, ${pkg.evidence.length} records, ${pkg.findings.length} findings`); return 0; }
|
|
639
|
+
for (const e of (r.errors || [])) say.err(`${e.code}: ${e.message}`);
|
|
640
|
+
if (r.ok && stated && stated.ok === false) say.err(`verdict-mismatch: ${stated.message}`);
|
|
641
|
+
say.err('release package REJECTED');
|
|
642
|
+
return 1;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const listFlag = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
646
|
+
|
|
647
|
+
async function qualify(args, say) {
|
|
648
|
+
const file = args._[2];
|
|
649
|
+
const keyFile = args.flags['public-key'];
|
|
650
|
+
if (!file || !keyFile) { say.err(USAGE); return 2; }
|
|
651
|
+
let packet; let key;
|
|
652
|
+
try { packet = JSON.parse(external_node_fs_.readFileSync(external_node_path_.resolve(file), 'utf8')); key = external_node_fs_.readFileSync(external_node_path_.resolve(String(keyFile)), 'utf8'); } catch (e) { say.err(`Could not read the input: ${e.message}`); return 2; }
|
|
653
|
+
const requiredGates = args.flags['require-gate'] ? listFlag(args.flags['require-gate']) : Object.keys(GATES);
|
|
654
|
+
const unknown = requiredGates.filter((g) => !GATES[g]);
|
|
655
|
+
if (unknown.length) { say.err(`Unknown gate: ${unknown.join(', ')}. Gates: ${Object.keys(GATES).join(', ')}.`); return 2; }
|
|
656
|
+
const requiredTiers = {};
|
|
657
|
+
for (const pair of listFlag(args.flags['require-tier'])) {
|
|
658
|
+
const [g, t] = pair.split('=');
|
|
659
|
+
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; }
|
|
660
|
+
requiredTiers[g] = t;
|
|
661
|
+
}
|
|
662
|
+
const sig = verifyCandidatePacket(packet, key);
|
|
663
|
+
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; }
|
|
664
|
+
const d = evaluateCandidate(packet, { requiredGates, requiredCases: listFlag(args.flags['require-case']), requiredTiers });
|
|
665
|
+
if (args.flags.output) {
|
|
666
|
+
try { writeCandidateDecision(external_node_path_.resolve(String(args.flags.output)), d); } catch (e) { say.err(`Could not write the decision: ${e.message}`); return 2; }
|
|
667
|
+
}
|
|
668
|
+
if (args.flags.format === 'json') say.out(JSON.stringify(d, null, 2));
|
|
669
|
+
else {
|
|
670
|
+
const t = d.scope || {};
|
|
671
|
+
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'})`);
|
|
672
|
+
for (const [g, x] of Object.entries(d.gates)) say.out(` ${g} ${x.name}: ${x.outcome}${x.reasons.length ? ` (${x.reasons.join('; ')})` : ''}`);
|
|
673
|
+
say.out(` cases by tier: local ${d.tiers.local}, staging ${d.tiers.staging}, independent ${d.tiers.independent}`);
|
|
674
|
+
}
|
|
675
|
+
return d.status === 'qualified' ? 0 : 1;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function cmdReleasePackage(args, io = {}) {
|
|
679
|
+
const say = { out: io.out || ((s) => console.log(s)), err: io.err || ((s) => console.error(s)) };
|
|
680
|
+
const sub = args._[1];
|
|
681
|
+
if (sub === 'build') return build(args, say, io.git || (() => null));
|
|
682
|
+
if (sub === 'verify') return verify(args, say);
|
|
683
|
+
if (sub === 'qualify') return qualify(args, say);
|
|
684
|
+
say.err(USAGE);
|
|
685
|
+
return 2;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
/***/ }),
|
|
690
|
+
|
|
691
|
+
/***/ 93917:
|
|
692
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
693
|
+
|
|
694
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
695
|
+
/* harmony export */ verifyReleasePackage: () => (/* binding */ verifyReleasePackage)
|
|
696
|
+
/* harmony export */ });
|
|
697
|
+
/* unused harmony export MINIMUM_KINDS */
|
|
698
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(77598);
|
|
699
|
+
// The verifier for a signed release assurance package (E7-03). Used by `scripts/verify-release-package.mjs` (a command-line wrapper) and by `release-view`.
|
|
700
|
+
//
|
|
701
|
+
// This file imports nothing but Node built-ins. It does not use the producer's code, so a defect in the producer cannot make it agree with itself. What it
|
|
702
|
+
// checks, each reported under its own code:
|
|
703
|
+
// FORMAT known schema, supported major version, no unknown top-level keys
|
|
704
|
+
// SIGNATURE Ed25519 over the domain label plus the canonical JSON of everything but the signature, verified with the key the CALLER supplies; the key the
|
|
705
|
+
// package names must be the same key (a package cannot vouch for itself, and a signature lifted from another package does not verify)
|
|
706
|
+
// SNAPSHOT the snapshot digest matches its fields
|
|
707
|
+
// EVIDENCE every record matches the digest in its reference; every reference matches its own digest; every reference is bound to this snapshot
|
|
708
|
+
// LINEAGE every dependency is present, there is no cycle, and the stated order is the dependency order
|
|
709
|
+
// MANDATORY every kind in the verifier's own minimum, in the caller's --require list, and in the package's own list is present
|
|
710
|
+
// COMPLETE the package may not claim a complete scan over a scan health that is not complete
|
|
711
|
+
// FINDINGS each finding's proof tier and exploitability flag equal what the finding itself says
|
|
712
|
+
// ASSERTIONS every cited record is in the package, and a "supported" statement cites at least one
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
const MINIMUM_KINDS = Object.freeze(['scan-health', 'finding']);
|
|
716
|
+
const SCHEMA = 'agentic-security/release-package';
|
|
717
|
+
const DOMAIN = `${SCHEMA}@1\n`;
|
|
718
|
+
const TOP_KEYS = new Set(['schema', 'schemaVersion', 'snapshot', 'evidence', 'lineageOrder', 'requiredKinds', 'findings', 'completeness', 'policy', 'assertions', 'proves', 'doesNotProve', 'issuance', 'signature']);
|
|
719
|
+
const ROLES = new Set(['finding', 'completeness', 'context', 'control', 'inventory']);
|
|
720
|
+
|
|
721
|
+
const canon = (v) => {
|
|
722
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
|
|
723
|
+
if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
|
|
724
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
725
|
+
};
|
|
726
|
+
const digest = (v) => `sha256:${node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(canon(v)).digest('hex')}`;
|
|
727
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
728
|
+
|
|
729
|
+
function verifyReleasePackage(pkg, publicKeyPem, { require: extraRequired = [] } = {}) {
|
|
730
|
+
const errors = [];
|
|
731
|
+
const fail = (code, message) => errors.push({ code, message });
|
|
732
|
+
if (!isObj(pkg)) return { ok: false, errors: [{ code: 'FORMAT', message: 'the package is not an object' }] };
|
|
733
|
+
if (pkg.schema !== SCHEMA) fail('FORMAT', `unrecognised schema ${JSON.stringify(pkg.schema)}`);
|
|
734
|
+
const major = Number(String(pkg.schemaVersion).split('.')[0]);
|
|
735
|
+
if (major !== 1) fail('FORMAT', `unsupported version ${JSON.stringify(pkg.schemaVersion)}`);
|
|
736
|
+
for (const k of Object.keys(pkg)) if (!TOP_KEYS.has(k)) fail('FORMAT', `unrecognised top-level key "${k}", which the signature does not vouch for`);
|
|
737
|
+
if (errors.length) return { ok: false, errors };
|
|
738
|
+
|
|
739
|
+
// ---- SIGNATURE
|
|
740
|
+
const sig = pkg.signature;
|
|
741
|
+
let publicKey = null;
|
|
742
|
+
if (!publicKeyPem) fail('SIGNATURE', 'no public key was supplied');
|
|
743
|
+
else { try { publicKey = node_crypto__WEBPACK_IMPORTED_MODULE_0__.createPublicKey(publicKeyPem); } catch (e) { fail('SIGNATURE', `the public key is unusable: ${e.message}`); } }
|
|
744
|
+
if (!isObj(sig) || sig.algorithm !== 'ed25519' || typeof sig.value !== 'string') fail('SIGNATURE', 'the package is unsigned or uses an unsupported algorithm');
|
|
745
|
+
else if (publicKey) {
|
|
746
|
+
const { signature, ...rest } = pkg;
|
|
747
|
+
let ok = false;
|
|
748
|
+
try { ok = node_crypto__WEBPACK_IMPORTED_MODULE_0__.verify(null, Buffer.from(DOMAIN + canon(rest), 'utf8'), publicKey, Buffer.from(sig.value, 'base64')); } catch (e) { fail('SIGNATURE', `verification error: ${e.message}`); }
|
|
749
|
+
if (!ok && !errors.some((e) => e.code === 'SIGNATURE')) fail('SIGNATURE', 'the signature does not match the package contents: something changed after signing, or it was signed by another key');
|
|
750
|
+
const fp = node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(publicKey.export({ type: 'spki', format: 'der' })).digest('hex');
|
|
751
|
+
const named = pkg.issuance && pkg.issuance.issuer && pkg.issuance.issuer.keyFingerprint;
|
|
752
|
+
if (named !== fp) fail('SIGNATURE', 'the key the package names is not the key supplied for verification');
|
|
753
|
+
if (!pkg.issuance || pkg.issuance.independentlyCertified !== false) fail('SIGNATURE', 'a self-issued package may not claim independent certification');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ---- SNAPSHOT
|
|
757
|
+
const snap = pkg.snapshot;
|
|
758
|
+
if (!isObj(snap)) fail('SNAPSHOT', 'there is no snapshot');
|
|
759
|
+
else {
|
|
760
|
+
const { digest: d, immutable, ...body } = snap;
|
|
761
|
+
if (d !== digest(body)) fail('SNAPSHOT', 'the snapshot does not match its digest');
|
|
762
|
+
if (immutable !== (!!body.commit && body.dirty === false)) fail('SNAPSHOT', 'the snapshot\'s immutability flag is not what its fields imply');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ---- EVIDENCE
|
|
766
|
+
const entries = Array.isArray(pkg.evidence) ? pkg.evidence : [];
|
|
767
|
+
if (!Array.isArray(pkg.evidence)) fail('EVIDENCE', 'evidence is not a list');
|
|
768
|
+
const byDigest = new Map();
|
|
769
|
+
for (const [i, e] of entries.entries()) {
|
|
770
|
+
if (!isObj(e) || !isObj(e.ref) || !('payload' in e)) { fail('EVIDENCE', `entry ${i} is malformed`); continue; }
|
|
771
|
+
if (!ROLES.has(e.role)) fail('EVIDENCE', `entry ${i} has an unknown role ${JSON.stringify(e.role)}`);
|
|
772
|
+
const { digest: rd, findingProvenanceDigest, otherProvenanceDigest, ...rb } = e.ref;
|
|
773
|
+
if (rd !== digest(rb)) fail('EVIDENCE', `the reference of entry ${i} (${e.ref.kind}) does not match its own digest`);
|
|
774
|
+
if (e.ref.payloadDigest !== digest(e.payload)) fail('EVIDENCE', `the ${e.ref.kind} record in entry ${i} does not match the digest in its reference`);
|
|
775
|
+
if (isObj(snap) && e.ref.snapshotDigest !== snap.digest) fail('EVIDENCE', `the ${e.ref.kind} record in entry ${i} describes a different snapshot`);
|
|
776
|
+
if (byDigest.has(e.ref.digest)) fail('EVIDENCE', `entry ${i} duplicates another reference`);
|
|
777
|
+
byDigest.set(e.ref.digest, e);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---- LINEAGE
|
|
781
|
+
for (const e of byDigest.values()) for (const dep of e.ref.restsOn || []) if (!byDigest.has(dep)) fail('LINEAGE', `${e.ref.kind} rests on a record that is not in the package (${String(dep).slice(0, 19)}...)`);
|
|
782
|
+
const state = new Map(); const order = [];
|
|
783
|
+
const visit = (d, trail) => {
|
|
784
|
+
if (state.get(d) === 2) return; if (state.get(d) === 1) { fail('LINEAGE', `cycle through ${trail.concat(d).map((x) => x.slice(7, 15)).join(' -> ')}`); return; }
|
|
785
|
+
state.set(d, 1);
|
|
786
|
+
for (const dep of (byDigest.get(d) || { ref: {} }).ref.restsOn || []) if (byDigest.has(dep)) visit(dep, trail.concat(d));
|
|
787
|
+
state.set(d, 2); order.push(d);
|
|
788
|
+
};
|
|
789
|
+
for (const d of [...byDigest.keys()].sort()) visit(d, []);
|
|
790
|
+
if (JSON.stringify(order) !== JSON.stringify(pkg.lineageOrder)) fail('LINEAGE', 'the stated order is not the dependency order');
|
|
791
|
+
if (JSON.stringify(entries.map((e) => e && e.ref && e.ref.digest)) !== JSON.stringify(pkg.lineageOrder)) fail('LINEAGE', 'the evidence list is not in the stated order');
|
|
792
|
+
|
|
793
|
+
// ---- MANDATORY
|
|
794
|
+
const kinds = new Set([...byDigest.values()].map((e) => e.ref.kind));
|
|
795
|
+
const needed = new Set([...MINIMUM_KINDS, ...extraRequired, ...(Array.isArray(pkg.requiredKinds) ? pkg.requiredKinds : [])]);
|
|
796
|
+
for (const k of MINIMUM_KINDS) if (!(Array.isArray(pkg.requiredKinds) && pkg.requiredKinds.includes(k))) fail('MANDATORY', `the package does not declare "${k}" as required`);
|
|
797
|
+
for (const k of [...needed].sort()) if (!kinds.has(k)) fail('MANDATORY', `required evidence is missing: ${k}`);
|
|
798
|
+
|
|
799
|
+
// ---- COMPLETE
|
|
800
|
+
const healthEntry = [...byDigest.values()].find((e) => e.ref.kind === 'scan-health');
|
|
801
|
+
const c = pkg.completeness;
|
|
802
|
+
if (!isObj(c)) fail('COMPLETE', 'there is no completeness statement');
|
|
803
|
+
else if (healthEntry) {
|
|
804
|
+
const hs = healthEntry.payload && healthEntry.payload.status;
|
|
805
|
+
if (c.scanStatus !== hs) fail('COMPLETE', `the package states scan status ${JSON.stringify(c.scanStatus)} but the scan health says ${JSON.stringify(hs)}`);
|
|
806
|
+
if (c.verdict === 'complete' && hs !== 'complete') fail('COMPLETE', 'the package claims a complete scan over a scan health that is not complete');
|
|
807
|
+
if (c.verdict !== 'complete' && hs === 'complete') fail('COMPLETE', 'the package calls an unfinished scan complete or the reverse: the verdict disagrees with the scan health');
|
|
808
|
+
if (JSON.stringify(c.conditions) !== JSON.stringify(Array.isArray(healthEntry.payload.conditions) ? healthEntry.payload.conditions : [])) fail('COMPLETE', 'the stated conditions differ from the scan health\'s own');
|
|
809
|
+
if (c.source !== healthEntry.ref.digest) fail('COMPLETE', 'the completeness statement cites a different scan health');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ---- FINDINGS
|
|
813
|
+
const findingEntries = [...byDigest.values()].filter((e) => e.ref.kind === 'finding');
|
|
814
|
+
const listed = new Map((Array.isArray(pkg.findings) ? pkg.findings : []).map((f) => [f.findingRef, f]));
|
|
815
|
+
if (listed.size !== findingEntries.length) fail('FINDINGS', `the package lists ${listed.size} findings but contains ${findingEntries.length}`);
|
|
816
|
+
for (const e of findingEntries) {
|
|
817
|
+
const f = listed.get(e.ref.digest);
|
|
818
|
+
if (!f) { fail('FINDINGS', `finding ${e.ref.payloadId} is in the package but not in its findings summary`); continue; }
|
|
819
|
+
const tier = e.payload && typeof e.payload.proofTier === 'string' ? e.payload.proofTier : 'unproven';
|
|
820
|
+
if (f.proofTier !== tier) fail('FINDINGS', `finding ${e.ref.payloadId}: the summary says proof tier ${JSON.stringify(f.proofTier)} but the finding says ${JSON.stringify(tier)}`);
|
|
821
|
+
if (f.exploitabilityProven !== (tier === 'execution-proven')) fail('FINDINGS', `finding ${e.ref.payloadId}: exploitability is stated as proven without an execution proof`);
|
|
822
|
+
if (f.integrity !== 'unmodified-since-signing') fail('FINDINGS', `finding ${e.ref.payloadId}: unexpected integrity statement`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---- ASSERTIONS
|
|
826
|
+
for (const a of Array.isArray(pkg.assertions) ? pkg.assertions : []) {
|
|
827
|
+
for (const d of a.evidence || []) if (!byDigest.has(d)) fail('ASSERTIONS', `assertion ${a.id} cites a record that is not in the package`);
|
|
828
|
+
if (a.status === 'supported' && !(a.evidence || []).length) fail('ASSERTIONS', `assertion ${a.id} is "supported" but cites nothing`);
|
|
829
|
+
if (!['supported', 'unsupported', 'unknown'].includes(a.status)) fail('ASSERTIONS', `assertion ${a.id} has an unknown status`);
|
|
830
|
+
}
|
|
831
|
+
return { ok: errors.length === 0, errors, issuer: pkg.issuance && pkg.issuance.issuer, completeness: isObj(c) ? c.verdict : null };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
/***/ }),
|
|
837
|
+
|
|
838
|
+
/***/ 618:
|
|
839
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
840
|
+
|
|
841
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
842
|
+
/* harmony export */ D: () => (/* binding */ releaseVerdict),
|
|
843
|
+
/* harmony export */ s: () => (/* binding */ verifyStatedVerdict)
|
|
844
|
+
/* harmony export */ });
|
|
845
|
+
// The release verdict (E7-08): a deterministic function from a release package to one of three answers, with every reason in plain words.
|
|
846
|
+
//
|
|
847
|
+
// not_deployable a condition the policy treats as blocking is present: an unfinished scan, files that were not scanned, an analyzer that timed out or failed,
|
|
848
|
+
// a fix that was claimed and not verified, an open finding that is proven exploitable, an open high or critical finding
|
|
849
|
+
// needs_review nothing blocks, but something is unknown and a person has to decide: a signal the context analysis could not settle, runtime evidence that
|
|
850
|
+
// came from a different build, a finding of unproven exploitability that is high or critical
|
|
851
|
+
// deployable none of the above. This is NOT a statement that the release is safe; it says no blocking or unknown condition was found in the evidence the package holds
|
|
852
|
+
//
|
|
853
|
+
// The word "safe" is never produced. The verdict is a function of the package alone (no clock, no environment), so the same package gives the same verdict and the same
|
|
854
|
+
// reasons in the same order, and a stated verdict that differs from the one recomputed here is refused (`verifyStatedVerdict`).
|
|
855
|
+
//
|
|
856
|
+
// WHAT COUNTS AS A BLOCKER is the policy below, not a judgement made per release. Waived findings (a waiver the package lists as honoured) do not block but are listed.
|
|
857
|
+
const SEV_BLOCK = new Set(['critical', 'high']);
|
|
858
|
+
|
|
859
|
+
const asArray = (v) => (Array.isArray(v) ? v : []);
|
|
860
|
+
const by = (pkg, kind) => asArray(pkg && pkg.evidence).filter((e) => e && e.ref && e.ref.kind === kind);
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* @param {object} pkg a release package
|
|
864
|
+
* @param {{waivedIds?:string[]}} [opts] finding ids covered by an honoured waiver
|
|
865
|
+
* @returns {{verdict:'not_deployable'|'needs_review'|'deployable', blockers:{code:string, message:string, evidence:string[]}[], unknowns:{code:string, message:string, evidence:string[]}[], waived:string[], statement:string}}
|
|
866
|
+
*/
|
|
867
|
+
function releaseVerdict(pkg, { waivedIds = [] } = {}) {
|
|
868
|
+
const blockers = []; const unknowns = [];
|
|
869
|
+
const block = (code, message, evidence = []) => blockers.push({ code, message, evidence });
|
|
870
|
+
const unk = (code, message, evidence = []) => unknowns.push({ code, message, evidence });
|
|
871
|
+
const waived = new Set(waivedIds);
|
|
872
|
+
|
|
873
|
+
// ---- the scan itself
|
|
874
|
+
const health = by(pkg, 'scan-health')[0];
|
|
875
|
+
if (!health) block('NO_SCAN_HEALTH', 'the package holds no scan health, so nothing says the scan finished');
|
|
876
|
+
else {
|
|
877
|
+
const h = health.payload || {}; const ev = [health.ref.digest];
|
|
878
|
+
if (h.status !== 'complete') block('SCAN_INCOMPLETE', `the scan did not finish cleanly (status ${JSON.stringify(h.status ?? 'unknown')}): ${asArray(h.conditions).join('; ') || 'no reason was recorded'}`, ev);
|
|
879
|
+
const f = h.files || {};
|
|
880
|
+
if (Number.isFinite(f.expected) && Number.isFinite(f.scanned) && f.scanned < f.expected) block('FILES_NOT_SCANNED', `${f.expected - f.scanned} of ${f.expected} files were not scanned`, ev);
|
|
881
|
+
else if (Number.isFinite(f.unanalysed) ? f.unanalysed > 0 : (Number.isFinite(f.skipped) && f.skipped > 0)) block('FILES_SKIPPED', `${Number.isFinite(f.unanalysed) ? f.unanalysed : f.skipped} file(s) were skipped (too large or too dense to analyse)`, ev);
|
|
882
|
+
if (Number.isFinite(f.timedOut) && f.timedOut > 0) block('FILES_TIMED_OUT', `${f.timedOut} file(s) exceeded the per-file analysis time`, ev);
|
|
883
|
+
const a = h.analyzers || {};
|
|
884
|
+
if (Number.isFinite(a.timedOut) && a.timedOut > 0) block('ANALYZER_TIMED_OUT', `${a.timedOut} analyzer(s) timed out, so what they would have found is not in this result`, ev);
|
|
885
|
+
if (Number.isFinite(a.failed) && a.failed > 0 && !(Number.isFinite(a.timedOut) && a.failed === a.timedOut)) block('ANALYZER_FAILED', `${a.failed} analyzer(s) failed, so what they would have found is not in this result`, ev);
|
|
886
|
+
if (h.deepAnalysis && Array.isArray(h.deepAnalysis.incomplete) && h.deepAnalysis.incomplete.length) unk('DEEP_ANALYSIS_INCOMPLETE', `deep analysis stopped short: ${h.deepAnalysis.incomplete.map((x) => `${x.reason} x${x.count}`).join(', ')}`, ev);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ---- findings and fixes
|
|
890
|
+
const fixes = by(pkg, 'fix-verification');
|
|
891
|
+
const fixFor = (id) => fixes.filter((x) => x.payload && (x.payload.findingStableId === id || x.payload.findingId === id));
|
|
892
|
+
for (const e of by(pkg, 'finding')) {
|
|
893
|
+
const f = e.payload || {}; const id = e.ref.payloadId || f.stableId || f.id; const ev = [e.ref.digest];
|
|
894
|
+
const triage = f.triage && f.triage.state;
|
|
895
|
+
const falsePositive = triage === 'false_positive' || f.falsePositive === true;
|
|
896
|
+
const myFixes = fixFor(id); const fixed = myFixes.find((x) => x.payload.verifiedFull === true);
|
|
897
|
+
if (waived.has(id)) continue;
|
|
898
|
+
if (falsePositive) {
|
|
899
|
+
if (!(f.triage && f.triage.by && f.triage.reason)) unk('FALSE_POSITIVE_UNJUSTIFIED', `finding ${id} is marked a false positive without a named person and a reason`, ev);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
for (const x of myFixes) if (x.payload.verifiedFull !== true) block('FIX_NOT_VERIFIED', `a fix for ${id} is claimed but was not fully verified (${asArray(x.payload.degradedLegs).map((d) => (typeof d === 'string' ? d : d && d.leg)).filter(Boolean).join(', ') || 'checks did not all pass'})`, [...ev, x.ref.digest]);
|
|
903
|
+
if (fixed) continue;
|
|
904
|
+
const sev = String(f.severity || 'info'); const tier = typeof f.proofTier === 'string' ? f.proofTier : 'unproven';
|
|
905
|
+
const ci = f.contextIntelligence && (f.contextIntelligence.counterexample && f.contextIntelligence.counterexample.state || f.contextIntelligence.state);
|
|
906
|
+
if (tier === 'execution-proven') block('PROVEN_OPEN_FINDING', `finding ${id} (${sev}) is proven exploitable and is open`, ev);
|
|
907
|
+
else if (SEV_BLOCK.has(sev)) {
|
|
908
|
+
if (ci === 'unresolved' || ci === 'plausible') unk('CONTEXT_SIGNAL_UNSETTLED', `finding ${id} (${sev}) rests on a context signal the analysis could not settle (${ci}); a person has to decide whether it is real`, ev);
|
|
909
|
+
else block('OPEN_HIGH_FINDING', `finding ${id} is open at severity ${sev}`, ev);
|
|
910
|
+
} else if (ci === 'unresolved' || ci === 'plausible') unk('CONTEXT_SIGNAL_UNSETTLED', `finding ${id} rests on a context signal the analysis could not settle (${ci})`, ev);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ---- runtime evidence from another build
|
|
914
|
+
for (const e of by(pkg, 'runtime-explanation')) {
|
|
915
|
+
const edges = asArray(e.payload && e.payload.runtime && e.payload.runtime.edges); const bad = edges.filter((x) => Number(x.mismatchedRecords) > 0);
|
|
916
|
+
if (bad.length) unk('RUNTIME_BUILD_MISMATCH', `runtime records for ${bad.length} edge(s) of ${e.ref.payloadId || 'a finding'} came from a different build than the one released, so they say nothing about this release and are not counted as corroboration`, [e.ref.digest]);
|
|
917
|
+
const miss = asArray(e.payload && e.payload.runtime && e.payload.runtime.missingObservations);
|
|
918
|
+
if (!bad.length && miss.length && e.payload.compare && e.payload.compare.state !== 'corroborated') unk('RUNTIME_NOT_CORROBORATED', `runtime evidence for ${e.ref.payloadId || 'a finding'} does not corroborate the path: ${miss[0]}`, [e.ref.digest]);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
blockers.sort((a, b) => (a.code + a.message < b.code + b.message ? -1 : 1)); unknowns.sort((a, b) => (a.code + a.message < b.code + b.message ? -1 : 1));
|
|
922
|
+
const verdict = blockers.length ? 'not_deployable' : unknowns.length ? 'needs_review' : 'deployable';
|
|
923
|
+
const statement = verdict === 'deployable'
|
|
924
|
+
? 'No blocking or unknown condition was found in the evidence this package holds. That is not a statement that the release is safe.'
|
|
925
|
+
: verdict === 'needs_review' ? `Nothing blocks the release, but ${unknowns.length} thing(s) are unknown and a person has to decide.` : `${blockers.length} condition(s) block the release.`;
|
|
926
|
+
return { verdict, blockers, unknowns, waived: [...waived].sort(), statement };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Is the verdict a package states the one its own contents give? @returns {{ok:boolean, stated:string|null, computed:string, message:string|null}} */
|
|
930
|
+
function verifyStatedVerdict(pkg, opts) {
|
|
931
|
+
const computed = releaseVerdict(pkg, opts).verdict;
|
|
932
|
+
const decisions = asArray(pkg && pkg.policy && pkg.policy.decisions);
|
|
933
|
+
const d = decisions.find((x) => x && x.id === 'release-verdict');
|
|
934
|
+
const stated = d ? d.outcome : null;
|
|
935
|
+
if (stated === null) return { ok: false, stated, computed, message: 'the package states no release verdict' };
|
|
936
|
+
if (stated !== computed) return { ok: false, stated, computed, message: `the package states "${stated}" but its own contents give "${computed}"` };
|
|
937
|
+
return { ok: true, stated, computed, message: null };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
/***/ })
|
|
942
|
+
|
|
943
|
+
};
|