@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.0
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 +26 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +282 -24
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +20 -0
- package/src/language/nixos-hardening.js +72 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/sast/zip-slip.js +79 -0
- package/src/util/string-memo.js +38 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// 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
|
|
2
|
+
// bind any of them to an immutable snapshot.
|
|
3
|
+
//
|
|
4
|
+
// WHY. The evidence a release rests on comes from a dozen producers written at different times. They name their schema three different ways
|
|
5
|
+
// (`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
|
|
6
|
+
// 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
|
|
7
|
+
// record is about or whether two records are about the same code. This module does not change any producer. It
|
|
8
|
+
// 1. records, for each kind, how to read its schema, version and id (`describeEvidence`), so they are comparable;
|
|
9
|
+
// 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
|
|
10
|
+
// the digests of the evidence it rests on (`buildEvidenceRef`), checked by `verifyEvidenceRef`;
|
|
11
|
+
// 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
|
|
12
|
+
// than the evidence it rests on;
|
|
13
|
+
// 4. refuses an unknown kind, a future major version and a record that does not carry what its kind promises (`inspectEvidence`).
|
|
14
|
+
//
|
|
15
|
+
// 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,
|
|
16
|
+
// 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
|
|
17
|
+
// it says so: evidence about code nobody can recover is evidence, but it is not evidence a release can be signed on.
|
|
18
|
+
//
|
|
19
|
+
// PROVENANCE. The git origin of a finding is `findingProvenance`. A bare `provenance` property on a finding belongs to other features and means something
|
|
20
|
+
// else. This module reads only the first, never merges the two, and the ref for a finding carries each under its own name.
|
|
21
|
+
import { digestOf, canonicalize } from './identity.js';
|
|
22
|
+
import { SUPPORTED_MAJOR, isDigest, isPlainObject } from './schema-kit.js';
|
|
23
|
+
|
|
24
|
+
export const EVIDENCE_REF_SCHEMA = 'agentic-security/evidence-ref';
|
|
25
|
+
export const EVIDENCE_REF_VERSION = '1.0.0';
|
|
26
|
+
export const SNAPSHOT_SCHEMA = 'agentic-security/snapshot-ref';
|
|
27
|
+
|
|
28
|
+
/** How a kind spells its schema: `at` is `x/y@N`, `semver` is `{schema, schemaVersion}`, `slash` is `thing/N`, `none` is unversioned. */
|
|
29
|
+
const read = {
|
|
30
|
+
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; },
|
|
31
|
+
semver: (p, c) => (typeof p.schema === 'string' && typeof p.schemaVersion === 'string' ? { schema: p.schema, version: p.schemaVersion } : null),
|
|
32
|
+
semverNumber: (p, c) => (typeof p.schema === 'string' && Number.isInteger(p.schemaVersion) ? { schema: p.schema, version: `${p.schemaVersion}.0.0` } : null),
|
|
33
|
+
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; },
|
|
34
|
+
none: () => null,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const at = (field = 'schema') => ({ style: 'at', field });
|
|
38
|
+
const SNAP = Object.freeze({ commit: 'commit', tree: 'tree', engine: 'engine', digest: 'digest', none: 'none' });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 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
|
|
42
|
+
* refers to the code it describes today (`none` is a gap the EvidenceRef closes); `signed` says what protects it in transit.
|
|
43
|
+
*/
|
|
44
|
+
export const EVIDENCE_KINDS = Object.freeze({
|
|
45
|
+
finding: { title: 'scanner finding', producer: 'scanner/src/engine.js', schemaRead: { style: 'none' }, id: ['stableId', 'id'], snapshot: SNAP.none, signed: 'none', provenanceField: 'findingProvenance' },
|
|
46
|
+
'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' },
|
|
47
|
+
'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' },
|
|
48
|
+
'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' },
|
|
49
|
+
'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' },
|
|
50
|
+
'obligation-evidence-pack': { title: 'regulatory obligation evidence pack', producer: 'scanner/src/posture/obligation-evidence-pack.js', schemaRead: at(), id: [], snapshot: SNAP.engine, signed: 'ed25519' },
|
|
51
|
+
'fix-verification': { title: 'patch verification record', producer: 'scanner/src/fix/verification-record.js', schemaRead: { style: 'semver' }, id: ['id'], snapshot: SNAP.commit, signed: 'none' },
|
|
52
|
+
verification: { title: 'hypothesis verification record', producer: 'scanner/src/posture/assurance/verification-record.js', schemaRead: { style: 'semver' }, id: ['id'], snapshot: SNAP.commit, signed: 'none' },
|
|
53
|
+
'release-evidence': { title: 'release evidence', producer: 'scanner/src/posture/assurance/contracts.js', schemaRead: { style: 'semver' }, id: ['id'], snapshot: SNAP.commit, signed: 'none' },
|
|
54
|
+
'dci-invariants': { title: 'context-intelligence invariants', producer: 'scanner/src/context-intelligence/invariants.js', schemaRead: { style: 'semver' }, id: [], snapshot: SNAP.none, signed: 'none' },
|
|
55
|
+
'runtime-explanation': { title: 'static/runtime explanation', producer: 'scanner/src/posture/runtime-explanation.js', schemaRead: at(), id: ['findingStableId', 'findingId'], snapshot: SNAP.commit, signed: 'none' },
|
|
56
|
+
'exploitability-evidence': { title: 'multi-level exploitability evidence', producer: 'scanner/src/posture/exploitability-evidence.js', schemaRead: at(), id: [], snapshot: SNAP.none, signed: 'none' },
|
|
57
|
+
'semantic-graph': { title: 'semantic graph', producer: 'scanner/src/ir/semantic-graph.js', schemaRead: { style: 'semver' }, id: [], snapshot: SNAP.none, signed: 'none' },
|
|
58
|
+
scorecard: { title: 'accuracy scorecard', producer: 'scanner/src/posture/accuracy-scorecard.js', schemaRead: at(), id: [], snapshot: SNAP.commit, signed: 'none' },
|
|
59
|
+
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' },
|
|
60
|
+
'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' },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const EVIDENCE_KIND_NAMES = Object.freeze(Object.keys(EVIDENCE_KINDS));
|
|
64
|
+
|
|
65
|
+
const get = (o, path) => path.split('.').reduce((a, k) => (a === null || a === undefined ? undefined : a[k]), o);
|
|
66
|
+
|
|
67
|
+
/** Read schema, version and identity out of a record of `kind`, whatever convention the producer used. Never throws. */
|
|
68
|
+
export function describeEvidence(kind, payload) {
|
|
69
|
+
const k = EVIDENCE_KINDS[kind];
|
|
70
|
+
if (!k) return { ok: false, reason: `unknown evidence kind "${kind}"` };
|
|
71
|
+
if (!isPlainObject(payload)) return { ok: false, reason: 'the record is not an object' };
|
|
72
|
+
const style = k.schemaRead.style;
|
|
73
|
+
let sv = null;
|
|
74
|
+
if (style === 'at' || style === 'slash') sv = read[style](payload, k.schemaRead);
|
|
75
|
+
else if (style === 'semver') sv = typeof payload.schemaVersion === 'number' ? read.semverNumber(payload) : read.semver(payload);
|
|
76
|
+
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;
|
|
77
|
+
else if (style === 'cyclonedx') sv = payload.bomFormat === 'CycloneDX' && payload.specVersion ? { schema: 'cyclonedx', version: String(payload.specVersion) } : null;
|
|
78
|
+
let id = null;
|
|
79
|
+
for (const f of k.id) { const v = get(payload, f); if (typeof v === 'string' && v) { id = v; break; } }
|
|
80
|
+
return { ok: true, kind, schema: sv ? sv.schema : null, version: sv ? sv.version : null, id };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What a record of this kind should carry, checked against the record itself.
|
|
85
|
+
* @returns {{ok:boolean, gaps:string[], errors:string[]}} `gaps` are known weaknesses of the producer's format (informational); `errors` make the record unusable.
|
|
86
|
+
*/
|
|
87
|
+
export function inspectEvidence(kind, payload) {
|
|
88
|
+
const d = describeEvidence(kind, payload);
|
|
89
|
+
if (!d.ok) return { ok: false, gaps: [], errors: [d.reason] };
|
|
90
|
+
const k = EVIDENCE_KINDS[kind]; const gaps = []; const errors = [];
|
|
91
|
+
if (k.schemaRead.style !== 'none' && !d.schema) errors.push('the record does not carry the schema declaration this kind promises');
|
|
92
|
+
if (k.schemaRead.style === 'none') gaps.push('the format declares no schema or version');
|
|
93
|
+
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})`); }
|
|
94
|
+
if (k.id.length && !d.id) errors.push(`the record carries none of its identity fields (${k.id.join(', ')})`);
|
|
95
|
+
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');
|
|
96
|
+
if (k.note && !/serialNumber/.test(k.note)) gaps.push(k.note);
|
|
97
|
+
if (k.snapshot === SNAP.none) gaps.push('the format does not say which code it describes');
|
|
98
|
+
if (k.signed === 'none') gaps.push('the format is not signed on its own');
|
|
99
|
+
return { ok: errors.length === 0, gaps, errors };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 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
|
|
104
|
+
* A VALUE. This is read from the record, so the registry's declared `snapshot` can be checked against what a producer emits.
|
|
105
|
+
*/
|
|
106
|
+
export function observeSnapshotBinding(payload) {
|
|
107
|
+
const found = new Set();
|
|
108
|
+
const walk = (v, depth) => {
|
|
109
|
+
if (depth > 4 || !v || typeof v !== 'object') return;
|
|
110
|
+
for (const [key, val] of Object.entries(v)) {
|
|
111
|
+
if (typeof val === 'string' && val) {
|
|
112
|
+
if (/^(commit|preCommit|head)$/.test(key) && /^[0-9a-f]{40,64}$/.test(val)) found.add('commit');
|
|
113
|
+
else if (/^(preTreeHash|treeHash|treeDigest|postTreeHash)$/.test(key)) found.add('tree');
|
|
114
|
+
else if (key === 'engineVersion') found.add('engine');
|
|
115
|
+
else if (/^(sourceDigest|snapshotDigest|graphDigest|controlsDigest)$/.test(key)) found.add('digest');
|
|
116
|
+
} else if (val && typeof val === 'object') walk(val, depth + 1);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(payload, 0);
|
|
120
|
+
for (const b of ['commit', 'tree', 'engine', 'digest']) if (found.has(b)) return b;
|
|
121
|
+
return 'none';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- snapshots -----------------------------------------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** A description of the code and tool a result is about, with a digest over all of it. */
|
|
127
|
+
export function buildSnapshotRef({ commit = null, treeDigest = null, dirty = false, engineVersion = null, bundleSha = null, configDigest = null } = {}) {
|
|
128
|
+
const body = { schema: SNAPSHOT_SCHEMA, commit: commit || null, treeDigest: treeDigest || null, dirty: dirty === true, engineVersion: engineVersion || null, bundleSha: bundleSha || null, configDigest: configDigest || null };
|
|
129
|
+
const immutable = !!body.commit && body.dirty === false;
|
|
130
|
+
return { ...body, immutable, digest: digestOf(body) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function verifySnapshotRef(s) {
|
|
134
|
+
if (!isPlainObject(s) || s.schema !== SNAPSHOT_SCHEMA) return false;
|
|
135
|
+
const { digest, immutable, ...body } = s;
|
|
136
|
+
return digest === digestOf(body) && immutable === (!!body.commit && body.dirty === false);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- evidence refs -------------------------------------------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} kind
|
|
143
|
+
* @param {object} payload the evidence record, unmodified
|
|
144
|
+
* @param {object} snapshot from buildSnapshotRef
|
|
145
|
+
* @param {{rests_on?:string[]}} opts digests of the evidence this record rests on
|
|
146
|
+
*/
|
|
147
|
+
export function buildEvidenceRef(kind, payload, snapshot, { restsOn = [] } = {}) {
|
|
148
|
+
const d = describeEvidence(kind, payload);
|
|
149
|
+
if (!d.ok) throw new Error(d.reason);
|
|
150
|
+
if (!verifySnapshotRef(snapshot)) throw new Error('the snapshot is not a valid snapshot reference');
|
|
151
|
+
for (const r of restsOn) if (!isDigest(r)) throw new Error(`restsOn holds a value that is not a digest: ${JSON.stringify(r)}`);
|
|
152
|
+
const body = {
|
|
153
|
+
schema: EVIDENCE_REF_SCHEMA, schemaVersion: EVIDENCE_REF_VERSION, kind,
|
|
154
|
+
payloadSchema: d.schema, payloadVersion: d.version, payloadId: d.id,
|
|
155
|
+
payloadDigest: digestOf(payload),
|
|
156
|
+
snapshotDigest: snapshot.digest, snapshotImmutable: snapshot.immutable,
|
|
157
|
+
restsOn: [...new Set(restsOn)].sort(),
|
|
158
|
+
};
|
|
159
|
+
const out = { ...body, digest: digestOf(body) };
|
|
160
|
+
// 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
|
|
161
|
+
if (kind === 'finding') {
|
|
162
|
+
out.findingProvenanceDigest = isPlainObject(payload.findingProvenance) ? digestOf(payload.findingProvenance) : null;
|
|
163
|
+
out.otherProvenanceDigest = payload.provenance !== undefined ? digestOf(payload.provenance) : null;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Re-derive a ref from the payload and snapshot it claims to describe. @returns {{ok:boolean, errors:string[]}} */
|
|
169
|
+
export function verifyEvidenceRef(ref, payload, snapshot) {
|
|
170
|
+
const errors = [];
|
|
171
|
+
if (!isPlainObject(ref) || ref.schema !== EVIDENCE_REF_SCHEMA) return { ok: false, errors: ['not an evidence reference'] };
|
|
172
|
+
if (Number(String(ref.schemaVersion).split('.')[0]) !== SUPPORTED_MAJOR) return { ok: false, errors: [`unsupported evidence reference version ${ref.schemaVersion}`] };
|
|
173
|
+
const { digest, findingProvenanceDigest, otherProvenanceDigest, ...body } = ref;
|
|
174
|
+
if (digestOf(body) !== digest) errors.push('the reference was altered after it was built (its digest does not match its fields)');
|
|
175
|
+
if (!EVIDENCE_KINDS[ref.kind]) errors.push(`unknown evidence kind "${ref.kind}"`);
|
|
176
|
+
else {
|
|
177
|
+
if (digestOf(payload) !== ref.payloadDigest) errors.push('the evidence record does not match the digest in its reference');
|
|
178
|
+
if (!snapshot || snapshot.digest !== ref.snapshotDigest || !verifySnapshotRef(snapshot)) errors.push('the snapshot does not match the reference');
|
|
179
|
+
const d = describeEvidence(ref.kind, payload);
|
|
180
|
+
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');
|
|
181
|
+
if (ref.kind === 'finding' && isPlainObject(payload)) {
|
|
182
|
+
const fp = isPlainObject(payload.findingProvenance) ? digestOf(payload.findingProvenance) : null;
|
|
183
|
+
if ((findingProvenanceDigest ?? null) !== fp) errors.push('findingProvenance differs from what the reference states');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { ok: errors.length === 0, errors };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 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
|
|
191
|
+
* evidence about the SAME snapshot. @returns {{ok:boolean, errors:string[], order:string[]}} order is a dependency-first ordering of ref digests
|
|
192
|
+
*/
|
|
193
|
+
export function checkLineage(refs) {
|
|
194
|
+
const errors = [];
|
|
195
|
+
const by = new Map();
|
|
196
|
+
for (const r of refs) { if (by.has(r.digest)) errors.push(`duplicate reference ${r.digest}`); by.set(r.digest, r); }
|
|
197
|
+
for (const r of refs) {
|
|
198
|
+
for (const dep of r.restsOn || []) {
|
|
199
|
+
const t = by.get(dep);
|
|
200
|
+
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)`);
|
|
201
|
+
else if (t.snapshotDigest !== r.snapshotDigest) errors.push(`${r.kind} rests on ${t.kind} that describes a different snapshot`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const order = []; const state = new Map();
|
|
205
|
+
const visit = (d, trail) => {
|
|
206
|
+
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; }
|
|
207
|
+
state.set(d, 1);
|
|
208
|
+
for (const dep of (by.get(d) || {}).restsOn || []) if (by.has(dep)) visit(dep, trail.concat(d));
|
|
209
|
+
state.set(d, 2); order.push(d);
|
|
210
|
+
};
|
|
211
|
+
for (const d of [...by.keys()].sort()) visit(d, []);
|
|
212
|
+
return { ok: errors.length === 0, errors, order };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { canonicalize };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 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`.
|
|
2
|
+
//
|
|
3
|
+
// 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
|
|
4
|
+
// checks, each reported under its own code:
|
|
5
|
+
// FORMAT known schema, supported major version, no unknown top-level keys
|
|
6
|
+
// 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
|
|
7
|
+
// package names must be the same key (a package cannot vouch for itself, and a signature lifted from another package does not verify)
|
|
8
|
+
// SNAPSHOT the snapshot digest matches its fields
|
|
9
|
+
// EVIDENCE every record matches the digest in its reference; every reference matches its own digest; every reference is bound to this snapshot
|
|
10
|
+
// LINEAGE every dependency is present, there is no cycle, and the stated order is the dependency order
|
|
11
|
+
// MANDATORY every kind in the verifier's own minimum, in the caller's --require list, and in the package's own list is present
|
|
12
|
+
// COMPLETE the package may not claim a complete scan over a scan health that is not complete
|
|
13
|
+
// FINDINGS each finding's proof tier and exploitability flag equal what the finding itself says
|
|
14
|
+
// ASSERTIONS every cited record is in the package, and a "supported" statement cites at least one
|
|
15
|
+
import * as crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export const MINIMUM_KINDS = Object.freeze(['scan-health', 'finding']);
|
|
18
|
+
const SCHEMA = 'agentic-security/release-package';
|
|
19
|
+
const DOMAIN = `${SCHEMA}@1\n`;
|
|
20
|
+
const TOP_KEYS = new Set(['schema', 'schemaVersion', 'snapshot', 'evidence', 'lineageOrder', 'requiredKinds', 'findings', 'completeness', 'policy', 'assertions', 'proves', 'doesNotProve', 'issuance', 'signature']);
|
|
21
|
+
const ROLES = new Set(['finding', 'completeness', 'context', 'control', 'inventory']);
|
|
22
|
+
|
|
23
|
+
const canon = (v) => {
|
|
24
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null);
|
|
25
|
+
if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
|
|
26
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
27
|
+
};
|
|
28
|
+
const digest = (v) => `sha256:${crypto.createHash('sha256').update(canon(v)).digest('hex')}`;
|
|
29
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
30
|
+
|
|
31
|
+
export function verifyReleasePackage(pkg, publicKeyPem, { require: extraRequired = [] } = {}) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
const fail = (code, message) => errors.push({ code, message });
|
|
34
|
+
if (!isObj(pkg)) return { ok: false, errors: [{ code: 'FORMAT', message: 'the package is not an object' }] };
|
|
35
|
+
if (pkg.schema !== SCHEMA) fail('FORMAT', `unrecognised schema ${JSON.stringify(pkg.schema)}`);
|
|
36
|
+
const major = Number(String(pkg.schemaVersion).split('.')[0]);
|
|
37
|
+
if (major !== 1) fail('FORMAT', `unsupported version ${JSON.stringify(pkg.schemaVersion)}`);
|
|
38
|
+
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`);
|
|
39
|
+
if (errors.length) return { ok: false, errors };
|
|
40
|
+
|
|
41
|
+
// ---- SIGNATURE
|
|
42
|
+
const sig = pkg.signature;
|
|
43
|
+
let publicKey = null;
|
|
44
|
+
if (!publicKeyPem) fail('SIGNATURE', 'no public key was supplied');
|
|
45
|
+
else { try { publicKey = crypto.createPublicKey(publicKeyPem); } catch (e) { fail('SIGNATURE', `the public key is unusable: ${e.message}`); } }
|
|
46
|
+
if (!isObj(sig) || sig.algorithm !== 'ed25519' || typeof sig.value !== 'string') fail('SIGNATURE', 'the package is unsigned or uses an unsupported algorithm');
|
|
47
|
+
else if (publicKey) {
|
|
48
|
+
const { signature, ...rest } = pkg;
|
|
49
|
+
let ok = false;
|
|
50
|
+
try { ok = crypto.verify(null, Buffer.from(DOMAIN + canon(rest), 'utf8'), publicKey, Buffer.from(sig.value, 'base64')); } catch (e) { fail('SIGNATURE', `verification error: ${e.message}`); }
|
|
51
|
+
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');
|
|
52
|
+
const fp = crypto.createHash('sha256').update(publicKey.export({ type: 'spki', format: 'der' })).digest('hex');
|
|
53
|
+
const named = pkg.issuance && pkg.issuance.issuer && pkg.issuance.issuer.keyFingerprint;
|
|
54
|
+
if (named !== fp) fail('SIGNATURE', 'the key the package names is not the key supplied for verification');
|
|
55
|
+
if (!pkg.issuance || pkg.issuance.independentlyCertified !== false) fail('SIGNATURE', 'a self-issued package may not claim independent certification');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- SNAPSHOT
|
|
59
|
+
const snap = pkg.snapshot;
|
|
60
|
+
if (!isObj(snap)) fail('SNAPSHOT', 'there is no snapshot');
|
|
61
|
+
else {
|
|
62
|
+
const { digest: d, immutable, ...body } = snap;
|
|
63
|
+
if (d !== digest(body)) fail('SNAPSHOT', 'the snapshot does not match its digest');
|
|
64
|
+
if (immutable !== (!!body.commit && body.dirty === false)) fail('SNAPSHOT', 'the snapshot\'s immutability flag is not what its fields imply');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- EVIDENCE
|
|
68
|
+
const entries = Array.isArray(pkg.evidence) ? pkg.evidence : [];
|
|
69
|
+
if (!Array.isArray(pkg.evidence)) fail('EVIDENCE', 'evidence is not a list');
|
|
70
|
+
const byDigest = new Map();
|
|
71
|
+
for (const [i, e] of entries.entries()) {
|
|
72
|
+
if (!isObj(e) || !isObj(e.ref) || !('payload' in e)) { fail('EVIDENCE', `entry ${i} is malformed`); continue; }
|
|
73
|
+
if (!ROLES.has(e.role)) fail('EVIDENCE', `entry ${i} has an unknown role ${JSON.stringify(e.role)}`);
|
|
74
|
+
const { digest: rd, findingProvenanceDigest, otherProvenanceDigest, ...rb } = e.ref;
|
|
75
|
+
if (rd !== digest(rb)) fail('EVIDENCE', `the reference of entry ${i} (${e.ref.kind}) does not match its own digest`);
|
|
76
|
+
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`);
|
|
77
|
+
if (isObj(snap) && e.ref.snapshotDigest !== snap.digest) fail('EVIDENCE', `the ${e.ref.kind} record in entry ${i} describes a different snapshot`);
|
|
78
|
+
if (byDigest.has(e.ref.digest)) fail('EVIDENCE', `entry ${i} duplicates another reference`);
|
|
79
|
+
byDigest.set(e.ref.digest, e);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- LINEAGE
|
|
83
|
+
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)}...)`);
|
|
84
|
+
const state = new Map(); const order = [];
|
|
85
|
+
const visit = (d, trail) => {
|
|
86
|
+
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; }
|
|
87
|
+
state.set(d, 1);
|
|
88
|
+
for (const dep of (byDigest.get(d) || { ref: {} }).ref.restsOn || []) if (byDigest.has(dep)) visit(dep, trail.concat(d));
|
|
89
|
+
state.set(d, 2); order.push(d);
|
|
90
|
+
};
|
|
91
|
+
for (const d of [...byDigest.keys()].sort()) visit(d, []);
|
|
92
|
+
if (JSON.stringify(order) !== JSON.stringify(pkg.lineageOrder)) fail('LINEAGE', 'the stated order is not the dependency order');
|
|
93
|
+
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');
|
|
94
|
+
|
|
95
|
+
// ---- MANDATORY
|
|
96
|
+
const kinds = new Set([...byDigest.values()].map((e) => e.ref.kind));
|
|
97
|
+
const needed = new Set([...MINIMUM_KINDS, ...extraRequired, ...(Array.isArray(pkg.requiredKinds) ? pkg.requiredKinds : [])]);
|
|
98
|
+
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`);
|
|
99
|
+
for (const k of [...needed].sort()) if (!kinds.has(k)) fail('MANDATORY', `required evidence is missing: ${k}`);
|
|
100
|
+
|
|
101
|
+
// ---- COMPLETE
|
|
102
|
+
const healthEntry = [...byDigest.values()].find((e) => e.ref.kind === 'scan-health');
|
|
103
|
+
const c = pkg.completeness;
|
|
104
|
+
if (!isObj(c)) fail('COMPLETE', 'there is no completeness statement');
|
|
105
|
+
else if (healthEntry) {
|
|
106
|
+
const hs = healthEntry.payload && healthEntry.payload.status;
|
|
107
|
+
if (c.scanStatus !== hs) fail('COMPLETE', `the package states scan status ${JSON.stringify(c.scanStatus)} but the scan health says ${JSON.stringify(hs)}`);
|
|
108
|
+
if (c.verdict === 'complete' && hs !== 'complete') fail('COMPLETE', 'the package claims a complete scan over a scan health that is not complete');
|
|
109
|
+
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');
|
|
110
|
+
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');
|
|
111
|
+
if (c.source !== healthEntry.ref.digest) fail('COMPLETE', 'the completeness statement cites a different scan health');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- FINDINGS
|
|
115
|
+
const findingEntries = [...byDigest.values()].filter((e) => e.ref.kind === 'finding');
|
|
116
|
+
const listed = new Map((Array.isArray(pkg.findings) ? pkg.findings : []).map((f) => [f.findingRef, f]));
|
|
117
|
+
if (listed.size !== findingEntries.length) fail('FINDINGS', `the package lists ${listed.size} findings but contains ${findingEntries.length}`);
|
|
118
|
+
for (const e of findingEntries) {
|
|
119
|
+
const f = listed.get(e.ref.digest);
|
|
120
|
+
if (!f) { fail('FINDINGS', `finding ${e.ref.payloadId} is in the package but not in its findings summary`); continue; }
|
|
121
|
+
const tier = e.payload && typeof e.payload.proofTier === 'string' ? e.payload.proofTier : 'unproven';
|
|
122
|
+
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)}`);
|
|
123
|
+
if (f.exploitabilityProven !== (tier === 'execution-proven')) fail('FINDINGS', `finding ${e.ref.payloadId}: exploitability is stated as proven without an execution proof`);
|
|
124
|
+
if (f.integrity !== 'unmodified-since-signing') fail('FINDINGS', `finding ${e.ref.payloadId}: unexpected integrity statement`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- ASSERTIONS
|
|
128
|
+
for (const a of Array.isArray(pkg.assertions) ? pkg.assertions : []) {
|
|
129
|
+
for (const d of a.evidence || []) if (!byDigest.has(d)) fail('ASSERTIONS', `assertion ${a.id} cites a record that is not in the package`);
|
|
130
|
+
if (a.status === 'supported' && !(a.evidence || []).length) fail('ASSERTIONS', `assertion ${a.id} is "supported" but cites nothing`);
|
|
131
|
+
if (!['supported', 'unsupported', 'unknown'].includes(a.status)) fail('ASSERTIONS', `assertion ${a.id} has an unknown status`);
|
|
132
|
+
}
|
|
133
|
+
return { ok: errors.length === 0, errors, issuer: pkg.issuance && pkg.issuance.issuer, completeness: isObj(c) ? c.verdict : null };
|
|
134
|
+
}
|
|
135
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The signed release assurance package (E7-03): everything a reviewer needs to judge one release, in one portable file that can be checked offline.
|
|
2
|
+
//
|
|
3
|
+
// 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
|
|
4
|
+
// together, the declared scan completeness taken from the scan's own `scanHealth`, the policy decisions applied, and a list of statements (`assertions`)
|
|
5
|
+
// 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
|
|
6
|
+
// needs only the public key.
|
|
7
|
+
//
|
|
8
|
+
// TWO CLAIMS THAT MUST NOT BE CONFUSED.
|
|
9
|
+
// 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
|
|
10
|
+
// evidence is present. That is all it proves.
|
|
11
|
+
// EXPLOITABILITY Whether a finding is real, and whether it is exploitable, is a separate claim, carried per finding as `proofTier` and
|
|
12
|
+
// `exploitabilityProven` (true only for `execution-proven`). A signed finding with `unproven` is still unproven. The verifier recomputes both from
|
|
13
|
+
// the finding in the package, so the summary cannot say more than the finding does.
|
|
14
|
+
//
|
|
15
|
+
// 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
|
|
16
|
+
// `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
|
|
17
|
+
// 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.
|
|
18
|
+
//
|
|
19
|
+
// ISSUER. Locally generated keys are self-issued, exactly as for evidence bundles: the package says so inside the signed bytes.
|
|
20
|
+
import * as crypto from 'node:crypto';
|
|
21
|
+
import { buildEvidenceRef, buildSnapshotRef, checkLineage, verifySnapshotRef } from './evidence-registry.js';
|
|
22
|
+
import { canonicalJson, keyFingerprint, TRUST_BASES } from '../evidence-bundle.js';
|
|
23
|
+
|
|
24
|
+
const RELEASE_PACKAGE_SCHEMA = 'agentic-security/release-package';
|
|
25
|
+
const RELEASE_PACKAGE_VERSION = '1.0.0';
|
|
26
|
+
export const MANDATORY_KINDS = Object.freeze(['scan-health', 'finding']);
|
|
27
|
+
|
|
28
|
+
/** Which side of the evidence a kind sits on. */
|
|
29
|
+
const EVIDENCE_ROLES = Object.freeze({
|
|
30
|
+
finding: 'finding', 'finding-evidence': 'finding', 'provenance-evidence': 'finding', 'fix-verification': 'finding', verification: 'finding',
|
|
31
|
+
'runtime-explanation': 'finding', 'exploitability-evidence': 'finding',
|
|
32
|
+
'scan-health': 'completeness', 'semantic-graph': 'context', scorecard: 'context', 'dci-invariants': 'context',
|
|
33
|
+
'compliance-evidence': 'control', 'obligation-evidence-pack': 'control', sbom: 'inventory', 'ai-bom': 'inventory', 'release-evidence': 'control',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
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.';
|
|
37
|
+
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).';
|
|
38
|
+
|
|
39
|
+
const SIGNING_DOMAIN = `${RELEASE_PACKAGE_SCHEMA}@${RELEASE_PACKAGE_VERSION.split('.')[0]}\n`;
|
|
40
|
+
|
|
41
|
+
const proofTierOf = (payload) => (payload && typeof payload === 'object' && typeof payload.proofTier === 'string' ? payload.proofTier : 'unproven');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} o
|
|
45
|
+
* @param {object} o.snapshot from buildSnapshotRef
|
|
46
|
+
* @param {{kind:string, payload:object, restsOn?:number[]}[]} o.evidence `restsOn` are INDEXES into this array of the records this one rests on
|
|
47
|
+
* @param {object[]} [o.policyDecisions] decisions already taken (id, outcome, reason, policyVersion); recorded as given, never evaluated here
|
|
48
|
+
* @param {string[]} [o.requiredKinds] kinds this package declares it must contain (added to the minimum the verifier holds)
|
|
49
|
+
* @param {{id:string, text:string, evidence:number[], status:'supported'|'unsupported'|'unknown', reason?:string}[]} [o.assertions] indexes into `evidence`
|
|
50
|
+
*/
|
|
51
|
+
export function buildReleasePackage({ snapshot, evidence, policyDecisions = [], requiredKinds = [], assertions = [] } = {}) {
|
|
52
|
+
if (!verifySnapshotRef(snapshot)) throw new Error('a valid snapshot reference is required');
|
|
53
|
+
if (!Array.isArray(evidence) || evidence.length === 0) throw new Error('at least one evidence record is required');
|
|
54
|
+
// refs are built in dependency order so each one can name the digests of the records it rests on
|
|
55
|
+
const built = new Array(evidence.length);
|
|
56
|
+
const state = new Array(evidence.length).fill(0);
|
|
57
|
+
const make = (i, trail) => {
|
|
58
|
+
if (built[i]) return built[i];
|
|
59
|
+
if (state[i] === 1) throw new Error(`evidence ${trail.concat(i).join(' -> ')} rests on itself`);
|
|
60
|
+
state[i] = 1;
|
|
61
|
+
const e = evidence[i];
|
|
62
|
+
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; });
|
|
63
|
+
built[i] = { ref: buildEvidenceRef(e.kind, e.payload, snapshot, { restsOn: deps }), payload: e.payload, role: EVIDENCE_ROLES[e.kind] || 'context' };
|
|
64
|
+
state[i] = 2;
|
|
65
|
+
return built[i];
|
|
66
|
+
};
|
|
67
|
+
for (let i = 0; i < evidence.length; i++) make(i, []);
|
|
68
|
+
const lineage = checkLineage(built.map((b) => b.ref));
|
|
69
|
+
if (!lineage.ok) throw new Error(`lineage is not valid: ${lineage.errors.join('; ')}`);
|
|
70
|
+
const byDigest = new Map(built.map((b) => [b.ref.digest, b]));
|
|
71
|
+
const ordered = lineage.order.map((d) => byDigest.get(d));
|
|
72
|
+
|
|
73
|
+
const health = built.find((b) => b.ref.kind === 'scan-health');
|
|
74
|
+
const healthPayload = health ? health.payload : null;
|
|
75
|
+
const findings = built.filter((b) => b.ref.kind === 'finding').map((b) => {
|
|
76
|
+
const tier = proofTierOf(b.payload);
|
|
77
|
+
return {
|
|
78
|
+
findingRef: b.ref.digest, findingId: b.ref.payloadId,
|
|
79
|
+
integrity: 'unmodified-since-signing',
|
|
80
|
+
proofTier: tier, exploitabilityProven: tier === 'execution-proven',
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
const body = {
|
|
84
|
+
schema: RELEASE_PACKAGE_SCHEMA, schemaVersion: RELEASE_PACKAGE_VERSION,
|
|
85
|
+
snapshot,
|
|
86
|
+
evidence: ordered.map((b) => ({ role: b.role, ref: b.ref, payload: b.payload })),
|
|
87
|
+
lineageOrder: lineage.order,
|
|
88
|
+
requiredKinds: [...new Set([...MANDATORY_KINDS, ...requiredKinds])].sort(),
|
|
89
|
+
findings: findings.sort((a, b) => (a.findingRef < b.findingRef ? -1 : 1)),
|
|
90
|
+
completeness: {
|
|
91
|
+
scanStatus: healthPayload && typeof healthPayload.status === 'string' ? healthPayload.status : 'unknown',
|
|
92
|
+
conditions: healthPayload && Array.isArray(healthPayload.conditions) ? [...healthPayload.conditions] : [],
|
|
93
|
+
verdict: healthPayload && healthPayload.status === 'complete' ? 'complete' : 'incomplete',
|
|
94
|
+
source: health ? health.ref.digest : null,
|
|
95
|
+
},
|
|
96
|
+
policy: { decisions: policyDecisions.map((d) => ({ ...d })) },
|
|
97
|
+
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() })),
|
|
98
|
+
proves: PROVES, doesNotProve: DOES_NOT_PROVE,
|
|
99
|
+
};
|
|
100
|
+
return body;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The exact bytes that are signed: a domain label, then the canonical JSON of everything except the signature. */
|
|
104
|
+
export function signedBytes(pkg) {
|
|
105
|
+
const { signature, ...rest } = pkg;
|
|
106
|
+
return Buffer.from(SIGNING_DOMAIN + canonicalJson(rest), 'utf8');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function signReleasePackage(pkg, privateKeyPem) {
|
|
110
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
111
|
+
const issuance = {
|
|
112
|
+
trustBasis: 'self-issued-local-key', independentlyCertified: false, statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
113
|
+
issuer: { keyFingerprint: keyFingerprint(publicKeyPem) },
|
|
114
|
+
};
|
|
115
|
+
const withIssuance = { ...pkg, issuance };
|
|
116
|
+
const sig = crypto.sign(null, signedBytes(withIssuance), privateKeyPem);
|
|
117
|
+
return { ...withIssuance, signature: { algorithm: 'ed25519', domain: SIGNING_DOMAIN.trim(), value: sig.toString('base64') } };
|
|
118
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The release verdict (E7-08): a deterministic function from a release package to one of three answers, with every reason in plain words.
|
|
2
|
+
//
|
|
3
|
+
// 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,
|
|
4
|
+
// a fix that was claimed and not verified, an open finding that is proven exploitable, an open high or critical finding
|
|
5
|
+
// 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
|
|
6
|
+
// came from a different build, a finding of unproven exploitability that is high or critical
|
|
7
|
+
// 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
|
|
8
|
+
//
|
|
9
|
+
// 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
|
|
10
|
+
// reasons in the same order, and a stated verdict that differs from the one recomputed here is refused (`verifyStatedVerdict`).
|
|
11
|
+
//
|
|
12
|
+
// 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.
|
|
13
|
+
const SEV_BLOCK = new Set(['critical', 'high']);
|
|
14
|
+
|
|
15
|
+
const asArray = (v) => (Array.isArray(v) ? v : []);
|
|
16
|
+
const by = (pkg, kind) => asArray(pkg && pkg.evidence).filter((e) => e && e.ref && e.ref.kind === kind);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} pkg a release package
|
|
20
|
+
* @param {{waivedIds?:string[]}} [opts] finding ids covered by an honoured waiver
|
|
21
|
+
* @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}}
|
|
22
|
+
*/
|
|
23
|
+
export function releaseVerdict(pkg, { waivedIds = [] } = {}) {
|
|
24
|
+
const blockers = []; const unknowns = [];
|
|
25
|
+
const block = (code, message, evidence = []) => blockers.push({ code, message, evidence });
|
|
26
|
+
const unk = (code, message, evidence = []) => unknowns.push({ code, message, evidence });
|
|
27
|
+
const waived = new Set(waivedIds);
|
|
28
|
+
|
|
29
|
+
// ---- the scan itself
|
|
30
|
+
const health = by(pkg, 'scan-health')[0];
|
|
31
|
+
if (!health) block('NO_SCAN_HEALTH', 'the package holds no scan health, so nothing says the scan finished');
|
|
32
|
+
else {
|
|
33
|
+
const h = health.payload || {}; const ev = [health.ref.digest];
|
|
34
|
+
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);
|
|
35
|
+
const f = h.files || {};
|
|
36
|
+
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);
|
|
37
|
+
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);
|
|
38
|
+
if (Number.isFinite(f.timedOut) && f.timedOut > 0) block('FILES_TIMED_OUT', `${f.timedOut} file(s) exceeded the per-file analysis time`, ev);
|
|
39
|
+
const a = h.analyzers || {};
|
|
40
|
+
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);
|
|
41
|
+
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);
|
|
42
|
+
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);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- findings and fixes
|
|
46
|
+
const fixes = by(pkg, 'fix-verification');
|
|
47
|
+
const fixFor = (id) => fixes.filter((x) => x.payload && (x.payload.findingStableId === id || x.payload.findingId === id));
|
|
48
|
+
for (const e of by(pkg, 'finding')) {
|
|
49
|
+
const f = e.payload || {}; const id = e.ref.payloadId || f.stableId || f.id; const ev = [e.ref.digest];
|
|
50
|
+
const triage = f.triage && f.triage.state;
|
|
51
|
+
const falsePositive = triage === 'false_positive' || f.falsePositive === true;
|
|
52
|
+
const myFixes = fixFor(id); const fixed = myFixes.find((x) => x.payload.verifiedFull === true);
|
|
53
|
+
if (waived.has(id)) continue;
|
|
54
|
+
if (falsePositive) {
|
|
55
|
+
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);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
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]);
|
|
59
|
+
if (fixed) continue;
|
|
60
|
+
const sev = String(f.severity || 'info'); const tier = typeof f.proofTier === 'string' ? f.proofTier : 'unproven';
|
|
61
|
+
const ci = f.contextIntelligence && (f.contextIntelligence.counterexample && f.contextIntelligence.counterexample.state || f.contextIntelligence.state);
|
|
62
|
+
if (tier === 'execution-proven') block('PROVEN_OPEN_FINDING', `finding ${id} (${sev}) is proven exploitable and is open`, ev);
|
|
63
|
+
else if (SEV_BLOCK.has(sev)) {
|
|
64
|
+
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);
|
|
65
|
+
else block('OPEN_HIGH_FINDING', `finding ${id} is open at severity ${sev}`, ev);
|
|
66
|
+
} else if (ci === 'unresolved' || ci === 'plausible') unk('CONTEXT_SIGNAL_UNSETTLED', `finding ${id} rests on a context signal the analysis could not settle (${ci})`, ev);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- runtime evidence from another build
|
|
70
|
+
for (const e of by(pkg, 'runtime-explanation')) {
|
|
71
|
+
const edges = asArray(e.payload && e.payload.runtime && e.payload.runtime.edges); const bad = edges.filter((x) => Number(x.mismatchedRecords) > 0);
|
|
72
|
+
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]);
|
|
73
|
+
const miss = asArray(e.payload && e.payload.runtime && e.payload.runtime.missingObservations);
|
|
74
|
+
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]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
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));
|
|
78
|
+
const verdict = blockers.length ? 'not_deployable' : unknowns.length ? 'needs_review' : 'deployable';
|
|
79
|
+
const statement = verdict === 'deployable'
|
|
80
|
+
? 'No blocking or unknown condition was found in the evidence this package holds. That is not a statement that the release is safe.'
|
|
81
|
+
: 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.`;
|
|
82
|
+
return { verdict, blockers, unknowns, waived: [...waived].sort(), statement };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Is the verdict a package states the one its own contents give? @returns {{ok:boolean, stated:string|null, computed:string, message:string|null}} */
|
|
86
|
+
export function verifyStatedVerdict(pkg, opts) {
|
|
87
|
+
const computed = releaseVerdict(pkg, opts).verdict;
|
|
88
|
+
const decisions = asArray(pkg && pkg.policy && pkg.policy.decisions);
|
|
89
|
+
const d = decisions.find((x) => x && x.id === 'release-verdict');
|
|
90
|
+
const stated = d ? d.outcome : null;
|
|
91
|
+
if (stated === null) return { ok: false, stated, computed, message: 'the package states no release verdict' };
|
|
92
|
+
if (stated !== computed) return { ok: false, stated, computed, message: `the package states "${stated}" but its own contents give "${computed}"` };
|
|
93
|
+
return { ok: true, stated, computed, message: null };
|
|
94
|
+
}
|