@clear-capabilities/agentic-security-scanner 0.184.0 → 0.185.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 +15 -0
- package/bin/agentic-security.js +87 -0
- package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -9
- package/dist/1128.index.js +908 -0
- package/dist/1336.index.js +261 -112
- package/dist/1665.index.js +828 -0
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +26 -9
- package/src/dataflow/CLAUDE.md +43 -1
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +929 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +51 -3
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/lineage/CLAUDE.md +71 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +138 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +13 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// Replayable path witness bundles (DAG-10, R10.1 to R10.7).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS ADDS
|
|
4
|
+
// --------------
|
|
5
|
+
// `evidence-bundle.js` signs a per-FINDING claim. A path witness is the evidence for one source-to-sink
|
|
6
|
+
// PATH, built so that someone holding only the referenced artifacts can re-derive it offline:
|
|
7
|
+
//
|
|
8
|
+
// * the DataFlowGraph (bound by `computeGraphDigest`),
|
|
9
|
+
// * the provenance store export (the raw hop stream plus the out-of-band truncation marks, bound by digest),
|
|
10
|
+
// * the source blobs the path's scopes name (bound by sha256),
|
|
11
|
+
// * the analysis configuration and the transfer-model fingerprints (bound by digest).
|
|
12
|
+
//
|
|
13
|
+
// A bundle records every edge in order with its full derivation inputs and its call/return binding. The
|
|
14
|
+
// replay verifier rebuilds a `PathStore` from the hop stream, requires every recorded edge to be the edge the
|
|
15
|
+
// store derives (same id, same endpoints, same discriminator fields, same truncation mark), requires the edges
|
|
16
|
+
// to chain, and re-checks every call/return binding against the hops that carry it. Altering an edge, a
|
|
17
|
+
// source blob, a call-site binding or the configuration digest makes replay fail.
|
|
18
|
+
//
|
|
19
|
+
// WHAT A VALID SIGNATURE PROVES
|
|
20
|
+
// -----------------------------
|
|
21
|
+
// Integrity only: the bundle is the one the signer produced. It does not say the transfer model is correct,
|
|
22
|
+
// the finding is real or the path is exploitable. Replay adds: the recorded path re-derives from the
|
|
23
|
+
// referenced artifacts under the recorded model fingerprints. It still does not validate the model itself.
|
|
24
|
+
//
|
|
25
|
+
// FOUR EVIDENCE STATUSES, KEPT APART
|
|
26
|
+
// ----------------------------------
|
|
27
|
+
// `evidence` carries exactly modeledPath, feasibility, runtimeSupport and exploitProof, each a status and
|
|
28
|
+
// nothing else. The verifier rejects any other key there, so a score or a merged confidence cannot be added
|
|
29
|
+
// without failing the check. See witness-runtime.js for the rules each one follows.
|
|
30
|
+
//
|
|
31
|
+
// PARTIAL STAYS PARTIAL
|
|
32
|
+
// ---------------------
|
|
33
|
+
// Completion is recorded as explicit fields (path, query, analysis, store) plus a derived `overall`. Every
|
|
34
|
+
// required field must be present after parsing (a missing field is invalid, never "complete"), and replay
|
|
35
|
+
// RE-DERIVES the path's terminal reason and the truncation marks from the artifacts, so a bundle edited to
|
|
36
|
+
// claim complete, or a partial one whose fields were dropped in transit, fails.
|
|
37
|
+
//
|
|
38
|
+
// REDACTION
|
|
39
|
+
// ---------
|
|
40
|
+
// The bundle holds ids, hashes, kinds, scopes and access paths, never payloads. Free text is passed through
|
|
41
|
+
// the existing secret redactor; identities are keyed-hash tokens (`identity-token.js`); a trace or session id
|
|
42
|
+
// becomes an opaque reference. Digests and ids are kept, so an authorized replay still works.
|
|
43
|
+
|
|
44
|
+
import * as crypto from 'node:crypto';
|
|
45
|
+
import { PathStore, HOP_FIELDS, classifyIn, classifyOut } from '../lineage/path-store.js';
|
|
46
|
+
import { provenanceEdgeId, provenanceNodeId, pathId as makePathId } from '../lineage/ids.js';
|
|
47
|
+
import { computeGraphDigest } from '../lineage/export-json.js';
|
|
48
|
+
import { DEFAULTS as PATH_QUERY_DEFAULTS } from '../lineage/path-query.js';
|
|
49
|
+
import { makeIdentityTokenizer } from '../lineage/identity-token.js';
|
|
50
|
+
import { redactString } from '../mcp/redact.js';
|
|
51
|
+
import { canonicalJson, keyFingerprint, TRUST_BASES, buildLocalIssuance } from './evidence-bundle.js';
|
|
52
|
+
import {
|
|
53
|
+
assessRuntimeSupport, bindExploitProofs, validateControlEvidence, buildEvidenceStatuses,
|
|
54
|
+
sanitizeCausalGroups, EVIDENCE_STATUS_KEYS, MODELED_PATH_STATUSES, FEASIBILITY_STATUSES, RUNTIME_SUPPORT_STATUSES, EXPLOIT_PROOF_STATUSES,
|
|
55
|
+
} from './witness-runtime.js';
|
|
56
|
+
|
|
57
|
+
export const WITNESS_SCHEMA = 'agentic-security/path-witness@1';
|
|
58
|
+
const PROVENANCE_ARTIFACT_SCHEMA = 'agentic-security/provenance-hops@1';
|
|
59
|
+
|
|
60
|
+
const WITNESS_BOUNDS = Object.freeze({ maxPathEdges: 5000, maxHops: 2_000_000, maxSources: 2000, maxSourceBytes: 64 * 1024 * 1024 });
|
|
61
|
+
|
|
62
|
+
const PROVES = 'The contents of this bundle are exactly what the signer attested, unmodified.';
|
|
63
|
+
const DOES_NOT_PROVE =
|
|
64
|
+
'A signature is an integrity claim only. It does not prove the finding is real or exploitable, and it does not ' +
|
|
65
|
+
'prove any transfer model is correct. Replay shows the recorded path re-derives from the referenced artifacts under ' +
|
|
66
|
+
'the recorded model fingerprints; it does not validate those models. Modeled path, feasibility, runtime support and ' +
|
|
67
|
+
'exploit proof are separate statuses and are never combined into one confidence.';
|
|
68
|
+
|
|
69
|
+
const WITNESS_UNSIGNED_KEYS = [
|
|
70
|
+
'schema', 'graphRef', 'query', 'references', 'path', 'artifacts', 'sources', 'config', 'transferModels',
|
|
71
|
+
'controlEvidence', 'completion', 'evidence', 'runtime', 'exploit', 'graphBinding', 'redaction', 'engine',
|
|
72
|
+
'proves', 'doesNotProve',
|
|
73
|
+
];
|
|
74
|
+
const WITNESS_TOP_KEYS = new Set([...WITNESS_UNSIGNED_KEYS, 'issuance', 'signature']);
|
|
75
|
+
const OVERALL = ['complete', 'partial', 'cancelled', 'unavailable', 'invalid'];
|
|
76
|
+
|
|
77
|
+
const sha = (text) => `sha256:${crypto.createHash('sha256').update(text).digest('hex')}`;
|
|
78
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
79
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
80
|
+
const jsonSafe = (v) => JSON.parse(JSON.stringify(v));
|
|
81
|
+
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
|
|
82
|
+
const fileOfScope = (scope) => (typeof scope === 'string' ? scope.split('::')[0] : null);
|
|
83
|
+
|
|
84
|
+
// ---- artifacts -------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The provenance store export: the normalized hop stream plus the truncation marks that never travel as hops.
|
|
88
|
+
* `incomplete` is `analysisCompletion.incomplete` from the graph builder (entries `{qid, context, reasons}`).
|
|
89
|
+
*/
|
|
90
|
+
export function exportProvenanceArtifact({ hops, incomplete = [] }) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
for (const h of arr(hops)) {
|
|
93
|
+
const norm = Object.fromEntries(HOP_FIELDS.map((f) => [f, h?.[f] ?? null]));
|
|
94
|
+
seen.set(canonicalJson(norm), norm);
|
|
95
|
+
}
|
|
96
|
+
const hopList = [...seen.keys()].sort().map((k) => seen.get(k));
|
|
97
|
+
const truncations = arr(incomplete)
|
|
98
|
+
.map((u) => ({ scope: u.qid ?? u.scope ?? null, context: u.context ?? null, reason: u.reasons?.[0] ?? u.reason ?? 'analysis-incomplete' }))
|
|
99
|
+
.sort((a, b) => (canonicalJson(a) < canonicalJson(b) ? -1 : 1));
|
|
100
|
+
return { schema: PROVENANCE_ARTIFACT_SCHEMA, hops: hopList, truncations, digest: sha(canonicalJson({ hops: hopList, truncations })) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function artifactDigest(a) {
|
|
104
|
+
return sha(canonicalJson({ hops: a.hops, truncations: a.truncations }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rebuildStore(artifact, storeLimits) {
|
|
108
|
+
const store = new PathStore(storeLimits);
|
|
109
|
+
store.addHops(artifact.hops);
|
|
110
|
+
for (const t of arr(artifact.truncations)) store.markTruncated(t.scope, t.context, t.reason);
|
|
111
|
+
return store;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fingerprints of the behaviour replay relies on. Computed by RUNNING the classifiers on fixed probe hops, not
|
|
116
|
+
* by hashing source text, so a minified bundle and the source tree agree and a behavioural change is caught.
|
|
117
|
+
*/
|
|
118
|
+
export function transferModelFingerprints() {
|
|
119
|
+
const base = { kind: 'production', subKind: 'ident', scope: 's', dataElementId: 'd', fromPath: null, toPath: null, syntacticPath: null, nodeId: 'n', line: 1, widenReason: null, lossReason: null, context: 'c', peerScope: null, peerContext: null };
|
|
120
|
+
const probes = [
|
|
121
|
+
{ ...base, fromPath: 'a' }, { ...base, peerScope: 'p', peerContext: 'pc' }, { ...base, peerScope: 'p', peerContext: 'pc', lossReason: 'context-cap-degraded' }, base,
|
|
122
|
+
{ ...base, kind: 'write-out', subKind: 'assign', toPath: 'b' }, { ...base, kind: 'write-out', subKind: 'assign', toPath: 'b', peerScope: 'p', peerContext: 'pc' },
|
|
123
|
+
{ ...base, kind: 'write-out', subKind: 'return' }, { ...base, kind: 'write-out', subKind: 'call-arg' }, { ...base, kind: 'write-out', subKind: 'x', lossReason: 'unsupported-target' },
|
|
124
|
+
];
|
|
125
|
+
const probeResults = probes.map((p) => (p.kind === 'write-out' ? classifyOut(p) : classifyIn(p)));
|
|
126
|
+
const edgeProbe = provenanceEdgeId({ fromNodeId: 'a', toNodeId: 'b', dataElementId: 'd', scope: 's', context: 'c', siteNodeId: 'n', inKind: 'production', inSubKind: 'ident', outKind: 'write-out', outSubKind: 'assign', widenReasons: ['w'], lossReasons: [] });
|
|
127
|
+
const nodeProbe = provenanceNodeId({ kind: 'path', scope: 's', context: 'c', path: 'p', siteNodeId: null, dataElementId: 'd' });
|
|
128
|
+
return {
|
|
129
|
+
hopRecord: sha(canonicalJson(HOP_FIELDS)),
|
|
130
|
+
halfEdgeClassification: sha(canonicalJson(probeResults)),
|
|
131
|
+
identity: sha(canonicalJson([edgeProbe, nodeProbe])),
|
|
132
|
+
pathQueryDefaults: sha(canonicalJson(PATH_QUERY_DEFAULTS)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- records ---------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function bindingOf(store, edge) {
|
|
139
|
+
const from = store.getNode(edge.fromNodeId); const to = store.getNode(edge.toNodeId);
|
|
140
|
+
const fromPeer = !!from && (from.scope !== edge.scope || from.context !== edge.context);
|
|
141
|
+
const toPeer = !!to && (to.scope !== edge.scope || to.context !== edge.context);
|
|
142
|
+
let direction = 'local'; let callee = null;
|
|
143
|
+
if (fromPeer && toPeer) direction = 'invalid';
|
|
144
|
+
else if (fromPeer) { direction = 'return'; callee = from; } else if (toPeer) { direction = 'call'; callee = to; }
|
|
145
|
+
return {
|
|
146
|
+
siteNodeId: edge.siteNodeId, callerScope: edge.scope, callerContext: edge.context, direction,
|
|
147
|
+
calleeScope: callee ? callee.scope : null, calleeContext: callee ? callee.context : null, line: edge.line ?? null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function edgeRecordOf(store, edge) {
|
|
152
|
+
return {
|
|
153
|
+
edgeId: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, dataElementId: edge.dataElementId,
|
|
154
|
+
scope: edge.scope, context: edge.context, siteNodeId: edge.siteNodeId,
|
|
155
|
+
inKind: edge.inKind, inSubKind: edge.inSubKind, outKind: edge.outKind, outSubKind: edge.outSubKind,
|
|
156
|
+
widenReasons: [...edge.widenReasons], lossReasons: [...edge.lossReasons],
|
|
157
|
+
crossScope: !!edge.crossScope, ambiguousCorrelation: !!edge.ambiguousCorrelation, originated: !!edge.originated,
|
|
158
|
+
truncated: !!edge.truncated, line: edge.line ?? null, binding: bindingOf(store, edge),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nodeRef(n) { return n ? { nodeId: n.id, kind: n.kind, scope: n.scope, context: n.context } : null; }
|
|
163
|
+
|
|
164
|
+
function deriveOverall(c) {
|
|
165
|
+
if (c.query.status === 'invalid') return 'invalid';
|
|
166
|
+
if (c.analysis.status === 'cancelled') return 'cancelled';
|
|
167
|
+
if (c.analysis.status === 'unavailable' || c.query.status === 'unavailable') return 'unavailable';
|
|
168
|
+
if (c.query.status === 'cancelled') return 'cancelled';
|
|
169
|
+
if (c.path.complete !== true || c.query.status !== 'complete' || c.analysis.status !== 'complete' || c.store.limitHit !== false) return 'partial';
|
|
170
|
+
return 'complete';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function terminalReasonFor(store, path, maxDepth) {
|
|
174
|
+
const first = path.nodeIds[0];
|
|
175
|
+
const incoming = store.edgesTo(first);
|
|
176
|
+
if (incoming.length === 0) {
|
|
177
|
+
return store.diagnostics().orphanedPeerSources.some((o) => o.nodeId === first) ? 'incomplete-record' : 'origin';
|
|
178
|
+
}
|
|
179
|
+
// The walk checks depth before it looks for a cycle, so a node at the depth limit is a depth-limit stop.
|
|
180
|
+
if (Number.isInteger(maxDepth) && path.nodeIds.length - 1 >= maxDepth) return 'depth-limit';
|
|
181
|
+
const onPath = new Set(path.nodeIds);
|
|
182
|
+
return incoming.every((e) => onPath.has(e.fromNodeId)) ? 'cycle' : 'depth-limit';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function compactEdgeStates(edgeStates, graphEdgeIds) {
|
|
186
|
+
if (!isObj(edgeStates) || !edgeStates.evaluated) return { evaluated: false, byEdge: {} };
|
|
187
|
+
const byEdge = {};
|
|
188
|
+
for (const id of graphEdgeIds) {
|
|
189
|
+
const s = edgeStates.byEdge?.[id];
|
|
190
|
+
byEdge[id] = { states: arr(s?.states).filter((x) => typeof x === 'string').sort(), uncertainObservations: Number.isInteger(s?.uncertainObservations) ? s.uncertainObservations : 0 };
|
|
191
|
+
}
|
|
192
|
+
return { evaluated: true, byEdge };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- build -----------------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build an unsigned witness bundle for one reconstructed path.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} o
|
|
201
|
+
* @param {object} o.result the `reconstructPaths` result the path came from
|
|
202
|
+
* @param {object} [o.path] one of `result.paths` (default: the first, or the one named by `o.pathId`)
|
|
203
|
+
* @param {object} o.store the PathStore the result was computed over
|
|
204
|
+
* @param {object} o.graph the DataFlowGraph the store's analysis produced
|
|
205
|
+
* @param {object} o.provenance `exportProvenanceArtifact(...)` of the same analysis
|
|
206
|
+
* @param {object} [o.analysisConfig] extra configuration to bind (budgets already recorded)
|
|
207
|
+
* @param {object} [o.sourceBlobs] { file: text } the path's scopes name
|
|
208
|
+
* @param {object} [o.graphBinding] { flowId } to bind the path to a DataFlowGraph flow (required for runtime evidence)
|
|
209
|
+
* @param {object|null} [o.controlEvidence] the DAG-04 slot; null until it exists
|
|
210
|
+
* @param {object} [o.runtime] { edgeStates, causalGroups, expected }
|
|
211
|
+
* @param {object[]} [o.exploitProofs]
|
|
212
|
+
* @returns {{ok:true, bundle:object}|{ok:false, reason:string}}
|
|
213
|
+
*/
|
|
214
|
+
export function buildWitnessBundle(o = {}) {
|
|
215
|
+
const { result, store, graph, provenance } = o;
|
|
216
|
+
if (!isObj(result) || !store || !isObj(graph) || !isObj(provenance)) return { ok: false, reason: 'result, store, graph and provenance are required' };
|
|
217
|
+
if (provenance.schema !== PROVENANCE_ARTIFACT_SCHEMA || artifactDigest(provenance) !== provenance.digest) return { ok: false, reason: 'provenance artifact is malformed or its digest does not match' };
|
|
218
|
+
const path = o.path ?? (o.pathId ? result.paths.find((p) => p.id === o.pathId) : result.paths[0]);
|
|
219
|
+
if (!path || !arr(path.edgeIds).length) return { ok: false, reason: 'no path to witness (an empty or unknown result has nothing to attest)' };
|
|
220
|
+
if (path.edgeIds.length > WITNESS_BOUNDS.maxPathEdges) return { ok: false, reason: `path exceeds ${WITNESS_BOUNDS.maxPathEdges} edges` };
|
|
221
|
+
|
|
222
|
+
const edges = [];
|
|
223
|
+
for (const id of path.edgeIds) {
|
|
224
|
+
const e = store.getEdge(id);
|
|
225
|
+
if (!e) return { ok: false, reason: `edge ${id} is not in the supplied store` };
|
|
226
|
+
edges.push(edgeRecordOf(store, e));
|
|
227
|
+
}
|
|
228
|
+
const srcNode = store.getNode(edges[0].fromNodeId); const sinkNode = store.getNode(edges[edges.length - 1].toNodeId);
|
|
229
|
+
if (!srcNode || !sinkNode) return { ok: false, reason: 'path endpoints are not in the supplied store' };
|
|
230
|
+
|
|
231
|
+
const storeLimits = store.limitStatus();
|
|
232
|
+
// The store handed in must be the one the provenance export describes: rebuild from the export and require
|
|
233
|
+
// identical edges. Otherwise the bundle could not be replayed, or a truncation mark could be missing from it.
|
|
234
|
+
const check = rebuildStore(provenance, storeLimits.limits);
|
|
235
|
+
for (const rec of edges) {
|
|
236
|
+
const e = check.getEdge(rec.edgeId);
|
|
237
|
+
if (!e || !same(rec, edgeRecordOf(check, e))) return { ok: false, reason: `the supplied store and the provenance export disagree on edge ${rec.edgeId}` };
|
|
238
|
+
}
|
|
239
|
+
const budget = { ...result.budget }; delete budget.expansionsUsed;
|
|
240
|
+
const config = { pathQuery: budget, storeLimits: storeLimits.limits, ...(isObj(o.analysisConfig) ? { extra: o.analysisConfig } : {}) };
|
|
241
|
+
const analysisConfigDigest = sha(canonicalJson(config));
|
|
242
|
+
const graphDigest = computeGraphDigest(graph);
|
|
243
|
+
|
|
244
|
+
const files = [...new Set([srcNode, sinkNode, ...edges.flatMap((e) => [store.getNode(e.fromNodeId), store.getNode(e.toNodeId)])].map((n) => fileOfScope(n?.scope)).filter(Boolean))].sort();
|
|
245
|
+
const sources = []; const unboundSourceFiles = [];
|
|
246
|
+
for (const f of files) {
|
|
247
|
+
const text = o.sourceBlobs?.[f];
|
|
248
|
+
if (typeof text === 'string' || Buffer.isBuffer(text)) {
|
|
249
|
+
const buf = Buffer.isBuffer(text) ? text : Buffer.from(text, 'utf8');
|
|
250
|
+
sources.push({ file: f, sha256: sha(buf), bytes: buf.length });
|
|
251
|
+
} else unboundSourceFiles.push(f);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const aCompletion = graph.coverage?.analysisCompletion ?? { status: 'complete' };
|
|
255
|
+
const completion = {
|
|
256
|
+
path: { complete: path.complete === true, terminalReason: path.terminal?.reason ?? null, analysisTruncated: path.analysisTruncated === true, ambiguousHopCount: path.ambiguousHopCount ?? 0 },
|
|
257
|
+
query: {
|
|
258
|
+
status: result.status ?? 'partial', queryExhausted: result.status !== 'invalid' && result.truncated === false,
|
|
259
|
+
truncated: result.truncated === true, truncationReasons: arr(result.truncationReasons),
|
|
260
|
+
modelCoverage: (aCompletion.status === 'complete' && !storeLimits.hit && path.analysisTruncated !== true) ? 'complete' : 'partial',
|
|
261
|
+
negativeClaimAllowed: false, droppedPathCount: result.droppedPathCount ?? 0, enumeratedPathCount: result.enumeratedPathCount ?? 0, cyclesClipped: result.cyclesClipped ?? 0,
|
|
262
|
+
},
|
|
263
|
+
analysis: { status: aCompletion.status ?? 'complete', incompleteUnits: arr(aCompletion.incomplete).length, reasons: [...new Set(arr(aCompletion.incomplete).flatMap((u) => arr(u.reasons)))].sort() },
|
|
264
|
+
store: { limitHit: storeLimits.hit === true, hopsRefused: storeLimits.hopsRefused, groupsSkipped: storeLimits.groupsSkipped.length, edgesRefused: storeLimits.edgesRefused },
|
|
265
|
+
};
|
|
266
|
+
completion.overall = deriveOverall(completion);
|
|
267
|
+
|
|
268
|
+
// ---- graph binding, runtime evidence, proofs ----
|
|
269
|
+
let graphBinding = null; let pathGraphEdgeIds = [];
|
|
270
|
+
if (o.graphBinding) {
|
|
271
|
+
const flow = arr(graph.flows).find((f) => f.id === o.graphBinding.flowId);
|
|
272
|
+
if (!flow) return { ok: false, reason: `flow ${o.graphBinding.flowId} is not in the graph` };
|
|
273
|
+
pathGraphEdgeIds = arr(flow.edgeIds).slice();
|
|
274
|
+
graphBinding = { flowId: flow.id, edgeIds: pathGraphEdgeIds, sourceNodeId: flow.source ?? flow.sourceNodeId ?? null, sinkNodeId: flow.sink ?? flow.sinkNodeId ?? null };
|
|
275
|
+
} else if (o.runtime) return { ok: false, reason: 'runtime evidence needs graphBinding.flowId: telemetry maps to DataFlowGraph edges' };
|
|
276
|
+
|
|
277
|
+
const ce = o.controlEvidence ?? null;
|
|
278
|
+
const ceCheck = validateControlEvidence(ce, edges.map((e) => e.edgeId));
|
|
279
|
+
if (!ceCheck.ok) return { ok: false, reason: `control evidence: ${ceCheck.errors.join('; ')}` };
|
|
280
|
+
|
|
281
|
+
const tokenize = o.tokenize ?? makeIdentityTokenizer();
|
|
282
|
+
let runtime;
|
|
283
|
+
if (o.runtime) {
|
|
284
|
+
const groups = sanitizeCausalGroups(o.runtime.causalGroups, { tokenize });
|
|
285
|
+
const expected = sanitizeExpected(o.runtime.expected, tokenize);
|
|
286
|
+
const edgeStates = compactEdgeStates(o.runtime.edgeStates, pathGraphEdgeIds);
|
|
287
|
+
runtime = { consulted: edgeStates.evaluated, inputs: { edgeStates, causalGroups: groups.groups, expected }, droppedFields: groups.droppedFields, rejectedGroups: groups.rejected.length };
|
|
288
|
+
} else runtime = { consulted: false, inputs: null, droppedFields: 0, rejectedGroups: 0 };
|
|
289
|
+
runtime.assessment = runtimeAssessment(runtime, pathGraphEdgeIds);
|
|
290
|
+
|
|
291
|
+
const sinkRef = sinkNode.id;
|
|
292
|
+
const exploitInputs = arr(o.exploitProofs).map((p) => sanitizeProof(p));
|
|
293
|
+
const exploit = { inputs: exploitInputs, ...bindExploitProofs(exploitInputs, { sinkRef, dataElementId: edges[0].dataElementId, graphDigest, analysisConfigDigest }) };
|
|
294
|
+
|
|
295
|
+
const sourcePath = redactString(srcNode.path ?? ''); const sinkPath = redactString(sinkNode.path ?? '');
|
|
296
|
+
const redactions = (srcNode.path && sourcePath !== srcNode.path ? 1 : 0) + (sinkNode.path && sinkPath !== sinkNode.path ? 1 : 0);
|
|
297
|
+
|
|
298
|
+
const bundle = jsonSafe({
|
|
299
|
+
schema: WITNESS_SCHEMA,
|
|
300
|
+
graphRef: { id: graph.graphId ?? null, digest: graphDigest, analysisConfigDigest },
|
|
301
|
+
query: { direction: 'backward', sinkRef, argumentIndex: null, accessPath: sinkPath || null, contextRef: sinkNode.context ?? null },
|
|
302
|
+
references: {
|
|
303
|
+
source: nodeRef(srcNode), sink: nodeRef(sinkNode),
|
|
304
|
+
field: { dataElementId: edges[0].dataElementId, sourcePath: sourcePath || null, sinkPath: sinkPath || null },
|
|
305
|
+
unboundSourceFiles,
|
|
306
|
+
},
|
|
307
|
+
path: {
|
|
308
|
+
pathId: path.id, nodeIds: arr(path.nodeIds), hopCount: edges.length, edges,
|
|
309
|
+
terminal: { nodeId: path.terminal?.nodeId ?? null, reason: path.terminal?.reason ?? null, kind: path.terminal?.kind ?? null },
|
|
310
|
+
},
|
|
311
|
+
artifacts: { provenance: { schema: provenance.schema, digest: provenance.digest, hopCount: provenance.hops.length, truncationCount: provenance.truncations.length } },
|
|
312
|
+
sources,
|
|
313
|
+
config,
|
|
314
|
+
transferModels: transferModelFingerprints(),
|
|
315
|
+
controlEvidence: ce,
|
|
316
|
+
completion,
|
|
317
|
+
evidence: buildEvidenceStatuses({ pathComplete: completion.path.complete && completion.overall === 'complete', controlEvidence: ce, runtime: runtime.assessment, exploit }),
|
|
318
|
+
runtime,
|
|
319
|
+
exploit,
|
|
320
|
+
graphBinding,
|
|
321
|
+
redaction: { redactions, identityTokens: 'keyed-hash (idt_)', sessionRefs: 'opaque (sref_)', payloads: 'never recorded' },
|
|
322
|
+
engine: { engineVersion: o.engine?.engineVersion ?? null, bundleSha: o.engine?.bundleSha ?? null, commit: o.engine?.commit ?? null },
|
|
323
|
+
proves: PROVES,
|
|
324
|
+
doesNotProve: DOES_NOT_PROVE,
|
|
325
|
+
});
|
|
326
|
+
return { ok: true, bundle };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sanitizeExpected(e, tokenize) {
|
|
330
|
+
const x = isObj(e) ? e : {};
|
|
331
|
+
const ref = (v) => (typeof v === 'string' && /^[A-Za-z0-9._:@/-]{1,160}$/.test(v) ? v : null);
|
|
332
|
+
const w = isObj(x.window) ? { start: typeof x.window.start === 'string' ? x.window.start : null, end: typeof x.window.end === 'string' ? x.window.end : null } : null;
|
|
333
|
+
const tok = typeof x.identityToken === 'string' && /^idt_[0-9a-f]{16}$/.test(x.identityToken) ? x.identityToken : (typeof x.identity === 'string' ? tokenize(x.identity) : null);
|
|
334
|
+
return { environment: ref(x.environment), deploymentRef: ref(x.deploymentRef), deploymentConfigDigest: ref(x.deploymentConfigDigest), identityToken: tok, window: w, maxWindowMs: Number.isFinite(x.maxWindowMs) ? x.maxWindowMs : null };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function sanitizeProof(p) {
|
|
338
|
+
if (!isObj(p)) return { id: null, kind: null };
|
|
339
|
+
const ref = (v) => (typeof v === 'string' && /^[A-Za-z0-9._:@/-]{1,160}$/.test(v) ? v : null);
|
|
340
|
+
return {
|
|
341
|
+
id: ref(p.id), kind: ['proof', 'dnr-hunt', 'incident'].includes(p.kind) ? p.kind : null, sinkRef: ref(p.sinkRef), dataElementId: ref(p.dataElementId),
|
|
342
|
+
graphDigest: ref(p.graphDigest), analysisConfigDigest: ref(p.analysisConfigDigest), note: typeof p.note === 'string' ? redactString(p.note).slice(0, 200) : null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function runtimeAssessment(runtime, pathGraphEdgeIds) {
|
|
347
|
+
if (!runtime.inputs) return assessRuntimeSupport({ pathEdgeIds: pathGraphEdgeIds, edgeStates: null });
|
|
348
|
+
const x = runtime.inputs;
|
|
349
|
+
return assessRuntimeSupport({
|
|
350
|
+
pathEdgeIds: pathGraphEdgeIds, edgeStates: x.edgeStates,
|
|
351
|
+
causalGroups: x.causalGroups, expected: { ...x.expected, maxWindowMs: x.expected.maxWindowMs ?? undefined },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- signing ---------------------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
function signedBytes(bundle) {
|
|
358
|
+
const signed = {};
|
|
359
|
+
for (const k of WITNESS_UNSIGNED_KEYS) signed[k] = bundle[k];
|
|
360
|
+
if (bundle.issuance !== undefined) signed.issuance = bundle.issuance;
|
|
361
|
+
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Sign with the same Ed25519 key and self-issued trust basis as finding evidence bundles. */
|
|
365
|
+
export function signWitnessBundle(bundle, privateKeyPem) {
|
|
366
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
367
|
+
const withIssuance = { ...bundle, issuance: buildLocalIssuance(publicKeyPem) };
|
|
368
|
+
const sig = crypto.sign(null, signedBytes(withIssuance), privateKeyPem);
|
|
369
|
+
return { ...withIssuance, signature: { algorithm: 'ed25519', canonicalisation: WITNESS_SCHEMA, value: sig.toString('base64') } };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Integrity check with a PUBLIC key. Never throws. */
|
|
373
|
+
export function verifyWitnessSignature(bundle, publicKeyPem) {
|
|
374
|
+
if (!isObj(bundle)) return { ok: false, reason: 'bundle is not an object' };
|
|
375
|
+
if (bundle.schema !== WITNESS_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
376
|
+
const unknown = Object.keys(bundle).filter((k) => !WITNESS_TOP_KEYS.has(k));
|
|
377
|
+
if (unknown.length) return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknown.join(', ')}` };
|
|
378
|
+
const missing = WITNESS_UNSIGNED_KEYS.filter((k) => !Object.prototype.hasOwnProperty.call(bundle, k));
|
|
379
|
+
if (missing.length) return { ok: false, reason: `required field(s) missing (a missing field is never read as complete): ${missing.join(', ')}` };
|
|
380
|
+
const sig = bundle.signature;
|
|
381
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
382
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
383
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
384
|
+
let ok = false;
|
|
385
|
+
try { ok = crypto.verify(null, signedBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64')); } catch (e) { return { ok: false, reason: `verification error: ${e.message}` }; }
|
|
386
|
+
if (!ok) return { ok: false, reason: 'signature does not match the bundle contents: it was modified after signing' };
|
|
387
|
+
const iss = bundle.issuance;
|
|
388
|
+
if (iss === undefined) return { ok: true, reason: null, trustBasis: 'none-declared', independentlyCertified: false, issuer: null, trustNote: 'No trust basis is declared. Treat it as unattested provenance.' };
|
|
389
|
+
const basis = isObj(iss) ? TRUST_BASES[iss.trustBasis] : undefined;
|
|
390
|
+
if (!basis) return { ok: false, reason: `unsupported trust basis: ${JSON.stringify(iss?.trustBasis ?? null)}` };
|
|
391
|
+
if (iss.independentlyCertified !== false) return { ok: false, reason: 'a self-issued bundle may not claim independent certification' };
|
|
392
|
+
let fp; try { fp = keyFingerprint(publicKeyPem); } catch (e) { return { ok: false, reason: `verification error: ${e.message}` }; }
|
|
393
|
+
if (iss.issuer?.keyFingerprint !== fp) return { ok: false, reason: 'the declared issuer key does not match the key that verified the signature' };
|
|
394
|
+
return { ok: true, reason: null, trustBasis: iss.trustBasis, independentlyCertified: false, issuer: iss.issuer, trustNote: basis.statement };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Strict parse: JSON, then the same required-field gate verification uses. */
|
|
398
|
+
export function parseWitnessBundle(text) {
|
|
399
|
+
let b; try { b = JSON.parse(text); } catch (e) { return { ok: false, reason: `not JSON: ${e.message}` }; }
|
|
400
|
+
if (!isObj(b) || b.schema !== WITNESS_SCHEMA) return { ok: false, reason: 'not a path witness bundle' };
|
|
401
|
+
const shape = shapeFailures(b);
|
|
402
|
+
return shape.length ? { ok: false, reason: shape[0].detail } : { ok: true, bundle: b };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- replay ----------------------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
function shapeFailures(b) {
|
|
408
|
+
const f = (detail) => [{ code: 'schema', detail }];
|
|
409
|
+
const missing = WITNESS_UNSIGNED_KEYS.filter((k) => !Object.prototype.hasOwnProperty.call(b, k));
|
|
410
|
+
if (missing.length) return f(`required field(s) missing: ${missing.join(', ')}`);
|
|
411
|
+
const c = b.completion;
|
|
412
|
+
if (!isObj(c) || !isObj(c.path) || !isObj(c.query) || !isObj(c.analysis) || !isObj(c.store)) return f('completion record is incomplete');
|
|
413
|
+
if (typeof c.path.complete !== 'boolean' || typeof c.query.truncated !== 'boolean' || typeof c.store.limitHit !== 'boolean') return f('completion flags must be booleans');
|
|
414
|
+
if (!['complete', 'partial', 'cancelled', 'unavailable', 'invalid'].includes(c.query.status) || typeof c.analysis.status !== 'string') return f('completion status is missing');
|
|
415
|
+
if (!OVERALL.includes(c.overall)) return f('completion.overall is missing or unknown');
|
|
416
|
+
if (!isObj(b.path) || !Array.isArray(b.path.edges) || !Array.isArray(b.path.nodeIds)) return f('path record is malformed');
|
|
417
|
+
if (!isObj(b.evidence) || Object.keys(b.evidence).sort().join() !== [...EVIDENCE_STATUS_KEYS].sort().join()) return f('evidence must hold exactly the four separate statuses and nothing else (no merged score or confidence)');
|
|
418
|
+
const legal = { modeledPath: MODELED_PATH_STATUSES, feasibility: FEASIBILITY_STATUSES, runtimeSupport: RUNTIME_SUPPORT_STATUSES, exploitProof: EXPLOIT_PROOF_STATUSES };
|
|
419
|
+
for (const k of EVIDENCE_STATUS_KEYS) {
|
|
420
|
+
if (!isObj(b.evidence[k]) || Object.keys(b.evidence[k]).join() !== 'status') return f(`evidence.${k} must be a status only`);
|
|
421
|
+
if (!legal[k].includes(b.evidence[k].status)) return f(`evidence.${k} has an unknown status`);
|
|
422
|
+
}
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Offline, bounded replay of a bundle against the artifact set it names.
|
|
428
|
+
*
|
|
429
|
+
* @param {object} bundle
|
|
430
|
+
* @param {{graph:object, provenance:object, sources?:Object<string,string>, config?:object}} artifacts
|
|
431
|
+
* @returns {{ok:boolean, failures:{code:string,detail:string}[], warnings:string[], derived:object}}
|
|
432
|
+
*/
|
|
433
|
+
export function replayWitness(bundle, artifacts, opts = {}) {
|
|
434
|
+
const bounds = { ...WITNESS_BOUNDS, ...opts.bounds };
|
|
435
|
+
const failures = []; const warnings = []; const derived = {};
|
|
436
|
+
const fail = (code, detail) => { if (failures.length < 50) failures.push({ code, detail }); };
|
|
437
|
+
const early = isObj(bundle) ? shapeFailures(bundle) : [{ code: 'schema', detail: 'bundle is not an object' }];
|
|
438
|
+
if (early.length) return { ok: false, failures: early, warnings, derived };
|
|
439
|
+
if (bundle.schema !== WITNESS_SCHEMA) return { ok: false, failures: [{ code: 'schema', detail: `unrecognised schema ${bundle.schema}` }], warnings, derived };
|
|
440
|
+
const unknown = Object.keys(bundle).filter((k) => !WITNESS_TOP_KEYS.has(k));
|
|
441
|
+
if (unknown.length) fail('schema', `unrecognised top-level key(s): ${unknown.join(', ')}`);
|
|
442
|
+
if (bundle.proves !== PROVES || bundle.doesNotProve !== DOES_NOT_PROVE) fail('schema', 'the proves / doesNotProve statements were altered or dropped');
|
|
443
|
+
if (!isObj(artifacts) || !isObj(artifacts.graph) || !isObj(artifacts.provenance)) return { ok: false, failures: [...failures, { code: 'artifacts', detail: 'graph and provenance artifacts are required to replay' }], warnings, derived };
|
|
444
|
+
if (bundle.path.edges.length === 0 || bundle.path.edges.length > bounds.maxPathEdges) fail('bounds', `path has ${bundle.path.edges.length} edges (allowed 1 to ${bounds.maxPathEdges})`);
|
|
445
|
+
if (arr(bundle.sources).length > bounds.maxSources) fail('bounds', 'too many source entries');
|
|
446
|
+
const prov = artifacts.provenance;
|
|
447
|
+
if (prov.schema !== PROVENANCE_ARTIFACT_SCHEMA || !Array.isArray(prov.hops) || !Array.isArray(prov.truncations)) {
|
|
448
|
+
fail('artifacts', 'provenance artifact is malformed');
|
|
449
|
+
return { ok: false, failures, warnings, derived };
|
|
450
|
+
}
|
|
451
|
+
if (prov.hops.length > bounds.maxHops) { fail('bounds', 'provenance artifact exceeds the replay hop bound'); return { ok: false, failures, warnings, derived }; }
|
|
452
|
+
if (failures.some((x) => x.code === 'bounds')) return { ok: false, failures, warnings, derived };
|
|
453
|
+
|
|
454
|
+
// 1. artifact and configuration digests
|
|
455
|
+
if (computeGraphDigest(artifacts.graph) !== bundle.graphRef.digest) fail('graph-digest', 'the graph artifact does not match the digest the bundle was built against');
|
|
456
|
+
if ((artifacts.graph.graphId ?? null) !== bundle.graphRef.id) fail('graph-id', 'the graph artifact is a different graph than the bundle names');
|
|
457
|
+
if (artifactDigest(prov) !== bundle.artifacts.provenance.digest) fail('provenance-digest', 'the provenance store export does not match the digest the bundle was built against');
|
|
458
|
+
if (sha(canonicalJson(bundle.config)) !== bundle.graphRef.analysisConfigDigest) fail('config-digest', 'the recorded configuration does not match its digest');
|
|
459
|
+
if (isObj(artifacts.config) && sha(canonicalJson(artifacts.config)) !== bundle.graphRef.analysisConfigDigest) fail('config-digest', 'the supplied configuration does not match the bundle configuration digest');
|
|
460
|
+
if (!same(bundle.transferModels, transferModelFingerprints())) fail('transfer-model', 'the transfer-model fingerprints differ from this verifier; replay under a different model proves nothing');
|
|
461
|
+
|
|
462
|
+
// 2. source blobs
|
|
463
|
+
let totalBytes = 0;
|
|
464
|
+
for (const s of arr(bundle.sources)) {
|
|
465
|
+
const text = artifacts.sources?.[s.file];
|
|
466
|
+
if (typeof text !== 'string' && !Buffer.isBuffer(text)) { fail('source-blob-missing', `source blob ${s.file} was not supplied`); continue; }
|
|
467
|
+
const buf = Buffer.isBuffer(text) ? text : Buffer.from(text, 'utf8');
|
|
468
|
+
totalBytes += buf.length;
|
|
469
|
+
if (totalBytes > bounds.maxSourceBytes) { fail('bounds', 'source blobs exceed the replay byte bound'); break; }
|
|
470
|
+
if (sha(buf) !== s.sha256) fail('source-blob-altered', `source blob ${s.file} does not match its recorded digest`);
|
|
471
|
+
}
|
|
472
|
+
if (arr(bundle.references.unboundSourceFiles).length) warnings.push(`${bundle.references.unboundSourceFiles.length} referenced source file(s) were not bound by digest`);
|
|
473
|
+
|
|
474
|
+
// 3. rebuild the store and re-derive every edge
|
|
475
|
+
const store = rebuildStore(prov, bundle.config?.storeLimits);
|
|
476
|
+
const lim = store.limitStatus();
|
|
477
|
+
derived.storeLimitHit = lim.hit;
|
|
478
|
+
if (lim.hit !== bundle.completion.store.limitHit) fail('completeness', 'the recorded store-limit state differs from the replayed store');
|
|
479
|
+
const hopIndex = new Map();
|
|
480
|
+
for (const h of prov.hops) {
|
|
481
|
+
if (h.peerScope == null) continue;
|
|
482
|
+
const k = canonicalJson([h.scope, h.nodeId, h.dataElementId, h.context, h.peerScope, h.peerContext]);
|
|
483
|
+
hopIndex.set(k, true);
|
|
484
|
+
}
|
|
485
|
+
const edges = bundle.path.edges; const nodeIds = [];
|
|
486
|
+
edges.forEach((rec, i) => {
|
|
487
|
+
if (!isObj(rec)) { fail('edge-altered', `edge ${i} is not an object`); return; }
|
|
488
|
+
const recomputed = provenanceEdgeId({ ...rec, widenReasons: arr(rec.widenReasons), lossReasons: arr(rec.lossReasons) });
|
|
489
|
+
if (recomputed !== rec.edgeId) fail('edge-derivation', `edge ${i} (${rec.edgeId}) does not derive from its recorded fields`);
|
|
490
|
+
const e = store.getEdge(rec.edgeId);
|
|
491
|
+
if (!e) { fail('edge-missing', `edge ${i} (${rec.edgeId}) is not derived by the provenance store export`); return; }
|
|
492
|
+
const truth = edgeRecordOf(store, e);
|
|
493
|
+
if (!same(rec, truth)) fail('edge-altered', `edge ${i} (${rec.edgeId}) differs from the edge the store derives`);
|
|
494
|
+
// call/return binding, against the model and against the carrying hops
|
|
495
|
+
const b = truth.binding;
|
|
496
|
+
if (b.direction === 'invalid') fail('binding', `edge ${i} has both endpoints in a callee namespace`);
|
|
497
|
+
if (truth.crossScope !== (b.direction === 'call' || b.direction === 'return')) fail('binding', `edge ${i} crossScope disagrees with its binding direction`);
|
|
498
|
+
if (b.direction === 'call' || b.direction === 'return') {
|
|
499
|
+
const k = canonicalJson([b.callerScope, b.siteNodeId, truth.dataElementId, b.callerContext, b.calleeScope, b.calleeContext]);
|
|
500
|
+
if (!hopIndex.has(k)) fail('binding', `edge ${i}: no recorded hop carries the ${b.direction} binding to ${b.calleeScope}`);
|
|
501
|
+
}
|
|
502
|
+
if (!same(rec.binding, b)) fail('binding', `edge ${i} call-site binding was altered`);
|
|
503
|
+
if (i === 0) nodeIds.push(rec.fromNodeId);
|
|
504
|
+
if (i > 0 && edges[i - 1].toNodeId !== rec.fromNodeId) fail('path-chain', `edge ${i} does not continue from edge ${i - 1}`);
|
|
505
|
+
nodeIds.push(rec.toNodeId);
|
|
506
|
+
});
|
|
507
|
+
if (!same(nodeIds, bundle.path.nodeIds)) fail('path-chain', 'recorded node sequence does not follow from the edge sequence');
|
|
508
|
+
const last = nodeIds[nodeIds.length - 1];
|
|
509
|
+
if (makePathId({ startNodeId: last, edgeIds: edges.map((e) => e.edgeId) }) !== bundle.path.pathId) fail('path-chain', 'path id does not derive from the edge sequence');
|
|
510
|
+
|
|
511
|
+
// 4. references
|
|
512
|
+
const src = store.getNode(nodeIds[0]); const sink = store.getNode(last);
|
|
513
|
+
if (!same(nodeRef(src), bundle.references.source) || !same(nodeRef(sink), bundle.references.sink)) fail('references', 'source or sink reference does not match the replayed store');
|
|
514
|
+
if (bundle.query.sinkRef !== last || bundle.query.contextRef !== (sink?.context ?? null)) fail('references', 'query sink or context reference does not match the replayed path');
|
|
515
|
+
if (bundle.references.field.dataElementId !== edges[0]?.dataElementId) fail('references', 'field reference does not match the path');
|
|
516
|
+
if (src && bundle.references.field.sourcePath !== (redactString(src.path ?? '') || null)) fail('references', 'source access path does not match the replayed store');
|
|
517
|
+
if (sink && bundle.references.field.sinkPath !== (redactString(sink.path ?? '') || null)) fail('references', 'sink access path does not match the replayed store');
|
|
518
|
+
const files = new Set([src, sink, ...nodeIds.map((id) => store.getNode(id))].map((n) => fileOfScope(n?.scope)).filter(Boolean));
|
|
519
|
+
const covered = new Set([...arr(bundle.sources).map((s) => s.file), ...arr(bundle.references.unboundSourceFiles)]);
|
|
520
|
+
if (!same([...files].sort(), [...covered].sort())) fail('source-blob-missing', 'the source files named by the path scopes are not all bound or declared unbound');
|
|
521
|
+
|
|
522
|
+
// 5. completion, re-derived
|
|
523
|
+
if (src) {
|
|
524
|
+
const reason = terminalReasonFor(store, { nodeIds }, bundle.config?.pathQuery?.maxDepth);
|
|
525
|
+
derived.terminalReason = reason;
|
|
526
|
+
if (bundle.path.terminal.reason !== reason || bundle.completion.path.terminalReason !== reason) fail('completeness', `terminal reason is ${reason} on replay, not ${bundle.path.terminal.reason}`);
|
|
527
|
+
const pathComplete = reason === 'origin';
|
|
528
|
+
if (bundle.completion.path.complete !== pathComplete) fail('completeness', 'path completeness was altered: the path ends at a non-origin node');
|
|
529
|
+
const anyTruncated = edges.some((_, i) => store.getEdge(edges[i].edgeId)?.truncated) || nodeIds.some((id) => store.getNode(id)?.truncated);
|
|
530
|
+
if (anyTruncated && bundle.completion.analysis.status === 'complete') fail('completeness', 'a node or edge on the path is marked truncated but the analysis is recorded complete');
|
|
531
|
+
if (prov.truncations.length > 0 && bundle.completion.analysis.status === 'complete') fail('completeness', 'the provenance export records incomplete analysis units but the bundle claims complete');
|
|
532
|
+
const overall = deriveOverall({ ...bundle.completion, path: { ...bundle.completion.path, complete: pathComplete && bundle.completion.path.complete } });
|
|
533
|
+
derived.overall = overall;
|
|
534
|
+
if (overall !== bundle.completion.overall) fail('completeness', `overall completeness is ${overall} on replay, not ${bundle.completion.overall}`);
|
|
535
|
+
if (bundle.completion.query.negativeClaimAllowed !== false) fail('completeness', 'a witness cannot carry a negative claim');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 6. graph binding, control evidence, runtime, proofs, evidence statuses
|
|
539
|
+
const gb = bundle.graphBinding;
|
|
540
|
+
if (gb) {
|
|
541
|
+
const flow = arr(artifacts.graph.flows).find((f) => f.id === gb.flowId);
|
|
542
|
+
if (!flow) fail('graph-binding', `flow ${gb.flowId} is not in the graph artifact`);
|
|
543
|
+
else if (!same(arr(flow.edgeIds), gb.edgeIds)) fail('graph-binding', 'the flow edge ids differ from the graph artifact');
|
|
544
|
+
const gEdges = new Set(arr(artifacts.graph.edges).map((e) => e.id));
|
|
545
|
+
if (gb.edgeIds.some((id) => !gEdges.has(id))) fail('graph-binding', 'a bound graph edge is not in the graph artifact');
|
|
546
|
+
}
|
|
547
|
+
const ceCheck = validateControlEvidence(bundle.controlEvidence, edges.map((e) => e.edgeId));
|
|
548
|
+
if (!ceCheck.ok) fail('control-evidence', ceCheck.errors.join('; '));
|
|
549
|
+
const rt = bundle.runtime;
|
|
550
|
+
const reAssessed = isObj(rt) ? runtimeAssessment(rt, gb ? gb.edgeIds : []) : null;
|
|
551
|
+
if (!reAssessed || !same(reAssessed, rt.assessment)) fail('runtime-inconsistent', 'the recorded runtime assessment does not follow from its recorded inputs');
|
|
552
|
+
if (rt?.inputs && !gb) fail('runtime-inconsistent', 'runtime inputs without a graph binding');
|
|
553
|
+
const reBound = bindExploitProofs(arr(bundle.exploit?.inputs), { sinkRef: last, dataElementId: edges[0]?.dataElementId, graphDigest: bundle.graphRef.digest, analysisConfigDigest: bundle.graphRef.analysisConfigDigest });
|
|
554
|
+
if (!same({ status: reBound.status, bound: reBound.bound, rejected: reBound.rejected }, { status: bundle.exploit?.status, bound: bundle.exploit?.bound, rejected: bundle.exploit?.rejected })) fail('proof-binding', 'the recorded proof binding does not follow from the sink, value and configuration it is bound to');
|
|
555
|
+
if (reAssessed && bundle.completion) {
|
|
556
|
+
const ev = buildEvidenceStatuses({ pathComplete: bundle.completion.path.complete && derived.overall === 'complete', controlEvidence: bundle.controlEvidence, runtime: reAssessed, exploit: reBound });
|
|
557
|
+
if (!same(ev, bundle.evidence)) fail('evidence-statuses', 'the four evidence statuses do not follow from their inputs');
|
|
558
|
+
}
|
|
559
|
+
return { ok: failures.length === 0, failures, warnings, derived };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Integrity (public key) plus, when artifacts are supplied, offline replay.
|
|
564
|
+
* `verdict`: 'replayed' (both hold), 'integrity-only' (signature valid, no replay attempted), 'invalid'.
|
|
565
|
+
*/
|
|
566
|
+
export function verifyWitness(bundle, publicKeyPem, artifacts, opts = {}) {
|
|
567
|
+
const signature = verifyWitnessSignature(bundle, publicKeyPem);
|
|
568
|
+
const statement = { proves: PROVES, doesNotProve: DOES_NOT_PROVE };
|
|
569
|
+
if (!signature.ok) return { ok: false, verdict: 'invalid', signature, replay: { performed: false, ok: false, failures: [], warnings: [] }, statement };
|
|
570
|
+
if (!artifacts) return { ok: true, verdict: 'integrity-only', signature, replay: { performed: false, ok: false, failures: [], warnings: [] }, statement };
|
|
571
|
+
const replay = replayWitness(bundle, artifacts, opts);
|
|
572
|
+
return { ok: replay.ok, verdict: replay.ok ? 'replayed' : 'invalid', signature, replay: { performed: true, ...replay }, statement };
|
|
573
|
+
}
|