@clear-capabilities/agentic-security-scanner 0.183.1 → 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 +29 -0
- package/bin/agentic-security.js +88 -0
- package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
- package/dist/1128.index.js +908 -0
- package/dist/1336.index.js +261 -112
- package/dist/1665.index.js +828 -0
- package/dist/2923.index.js +17 -9
- package/dist/9382.index.js +23 -2
- package/dist/9579.index.js +11 -4
- 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 +27 -9
- package/src/context-intelligence/controls.js +15 -0
- package/src/context-intelligence/reachability.js +8 -2
- 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/discovery/hunter.js +6 -2
- package/src/discovery/index.js +5 -2
- package/src/engine.js +63 -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/language/haskell-disclosure.js +13 -1
- 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/observation-adapters.js +17 -9
- 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 +19 -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
|
@@ -32,7 +32,7 @@ both are settled here:
|
|
|
32
32
|
1. **Seeding** (§3) — how a real call site becomes a field-identity entry
|
|
33
33
|
state. Today the shipped driver hardcodes `emptyState()` and produces
|
|
34
34
|
**exactly zero hops** on real code. Measured, twice, in `E1/1`.
|
|
35
|
-
2. **Projection** (§6) — how a provenance
|
|
35
|
+
2. **Projection** (§6) — how a provenance graph that is *variable*-granular
|
|
36
36
|
(494 pnodes for a 33-file project) becomes a `DataFlowGraph v1` document
|
|
37
37
|
that is *system*-granular (14 nodes for a whole synthetic platform).
|
|
38
38
|
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Path Provenance Recording — Design Record
|
|
2
2
|
|
|
3
|
+
> Terminology note (DAG-01): this record predates the SCC work and says "DAG"
|
|
4
|
+
> for the recorded provenance structure. That structure is a **provenance
|
|
5
|
+
> graph** and can be cyclic (see section 9.3). The acyclic "DAG" is the derived
|
|
6
|
+
> component view built by `scanner/src/graph/scc.js`. Read every "DAG" below as
|
|
7
|
+
> "provenance graph". Decisions here are unchanged.
|
|
8
|
+
|
|
3
9
|
Scope: Sub-project C of Milestone 1, increment C1 (see
|
|
4
10
|
`docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-c-scoping.md`).
|
|
5
11
|
This document decides **how provenance is recorded** as data flows through
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// boundary-contract.js: DAG-08. Versioned, field-aware contracts for the HTTP, queue and storage edges between services.
|
|
2
|
+
//
|
|
3
|
+
// A contract is an OPERATOR DECLARATION that a producer site hands named fields to a consumer site across a trust boundary.
|
|
4
|
+
// What it can establish, and what it cannot:
|
|
5
|
+
// * It establishes a MODELED untrusted input at the consumer: the declared consumer parameter/record fields are treated as
|
|
6
|
+
// attacker-influenced when the taint engine runs. It does not establish that any value was observed on the wire, nor that a
|
|
7
|
+
// sink in the consumer is exploited. Every attribution carries `modeledInput: true, observed: false`.
|
|
8
|
+
// * It is bound to a parser-resolved local site (a function in the call graph and one of its parameters). A declaration that
|
|
9
|
+
// cannot be bound (no such file/function/parameter, an ambiguous name, a field the Haskell record does not have) becomes an
|
|
10
|
+
// explicit COVERAGE GAP. It never becomes a finding at a guessed function.
|
|
11
|
+
// * Field mappings carry renames (`customer_id` -> `customerId`). The consumer is seeded only at the mapped consumer fields
|
|
12
|
+
// (consumer projection), so a sibling field such as `displayName` is not attributed to the boundary.
|
|
13
|
+
// * `persistence` separates an immediate call edge (http) from a handoff that outlives the request (storage, durable queue).
|
|
14
|
+
// A storage write and a later read correlate only under a declared storage contract that names the same store, table and
|
|
15
|
+
// key/schema version. Two sites that merely share a field name are never joined.
|
|
16
|
+
// * Evidence kinds stay separately attributable: foreign graph validation, digest check, manual assertion, code mapping and
|
|
17
|
+
// observed connection. Only an `observed-connection` item can make `observed` true, and this module never creates one.
|
|
18
|
+
//
|
|
19
|
+
// Pure: no file access, no dependency on the taint engine. The seeding step that needs the engine lives in
|
|
20
|
+
// dataflow/cross-service-taint.js and consumes the bindings built here.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { EDGE_PROVENANCE_VALUES } from './schema.js';
|
|
24
|
+
import { computeScc } from '../graph/scc.js';
|
|
25
|
+
|
|
26
|
+
export const BOUNDARY_CONTRACT_VERSION = '1.0.0';
|
|
27
|
+
export const BOUNDARY_CONTRACTS_FILENAME = 'boundary-contracts.json';
|
|
28
|
+
export const BOUNDARY_KINDS = Object.freeze(['http', 'queue', 'storage']);
|
|
29
|
+
export const PERSISTENCE_VALUES = Object.freeze(['immediate', 'persisted']);
|
|
30
|
+
export const EVIDENCE_KINDS = Object.freeze(['foreign-graph-validation', 'digest-check', 'manual-assertion', 'code-mapping', 'observed-connection']);
|
|
31
|
+
|
|
32
|
+
const isStr = (v) => typeof v === 'string' && v.length > 0;
|
|
33
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
const sorted = (a) => [...a].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
|
|
35
|
+
|
|
36
|
+
function canonical(v) {
|
|
37
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
38
|
+
if (isObj(v)) return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
39
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Digest of everything that defines what the contract says (not who declared it or when). */
|
|
43
|
+
export function contractDigest(c) {
|
|
44
|
+
const basis = {
|
|
45
|
+
version: c.version, kind: c.kind, persistence: c.persistence, channel: c.channel, schema: c.schema,
|
|
46
|
+
producer: { service: c.producer && c.producer.service, site: (c.producer && c.producer.site) || null, untrustedFields: sorted((c.producer && c.producer.untrustedFields) || []) },
|
|
47
|
+
consumer: { service: c.consumer && c.consumer.service, site: (c.consumer && c.consumer.site) || null },
|
|
48
|
+
fieldMappings: (c.fieldMappings || []).map((m) => ({ from: m.from, to: m.to })).sort((a, b) => (a.from + a.to < b.from + b.to ? -1 : 1)),
|
|
49
|
+
};
|
|
50
|
+
return `sha256:${createHash('sha256').update(canonical(basis)).digest('hex')}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function channelKey(c) {
|
|
54
|
+
const ch = c.channel || {};
|
|
55
|
+
if (c.kind === 'http') return `http:${String(ch.method || '*').toUpperCase()} ${ch.route || ''}`;
|
|
56
|
+
if (c.kind === 'queue') return `queue:${ch.topic || ''}`;
|
|
57
|
+
return `storage:${ch.store || ''}/${ch.table || ''}#${ch.key || ''}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Structural validation. Never throws, never touches a graph. */
|
|
61
|
+
export function validateBoundaryContract(c) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
const err = (path, message) => errors.push({ path, message });
|
|
64
|
+
if (!isObj(c)) return { valid: false, errors: [{ path: '$', message: 'contract must be an object' }] };
|
|
65
|
+
if (!isStr(c.id) || !c.id.startsWith('boundary:')) err('$.id', 'id is required and must start with "boundary:"');
|
|
66
|
+
if (c.version !== BOUNDARY_CONTRACT_VERSION) err('$.version', `unsupported contract version "${c.version}" (supported: ${BOUNDARY_CONTRACT_VERSION})`);
|
|
67
|
+
if (!BOUNDARY_KINDS.includes(c.kind)) err('$.kind', `kind must be one of ${BOUNDARY_KINDS.join('|')}`);
|
|
68
|
+
if (!PERSISTENCE_VALUES.includes(c.persistence)) err('$.persistence', `persistence must be one of ${PERSISTENCE_VALUES.join('|')}`);
|
|
69
|
+
else if (c.kind === 'storage' && c.persistence !== 'persisted') err('$.persistence', 'a storage contract is persisted: the value outlives the request');
|
|
70
|
+
else if (c.kind === 'http' && c.persistence !== 'immediate') err('$.persistence', 'an http contract is an immediate call edge');
|
|
71
|
+
if (!isObj(c.channel)) err('$.channel', 'channel is required');
|
|
72
|
+
else if (c.kind === 'http' && !isStr(c.channel.route)) err('$.channel.route', 'http channel needs a route');
|
|
73
|
+
else if (c.kind === 'queue' && !isStr(c.channel.topic)) err('$.channel.topic', 'queue channel needs a topic');
|
|
74
|
+
else if (c.kind === 'storage') {
|
|
75
|
+
if (!isStr(c.channel.store)) err('$.channel.store', 'storage channel needs a store');
|
|
76
|
+
if (!isStr(c.channel.table)) err('$.channel.table', 'storage channel needs a table');
|
|
77
|
+
if (!isStr(c.channel.key)) err('$.channel.key', 'storage channel needs a key contract');
|
|
78
|
+
}
|
|
79
|
+
if (!isObj(c.schema) || !isStr(c.schema.name) || !isStr(c.schema.version)) err('$.schema', 'schema {name, version} is required');
|
|
80
|
+
for (const side of ['producer', 'consumer']) {
|
|
81
|
+
const s = c[side];
|
|
82
|
+
if (!isObj(s) || !isStr(s.service)) { err(`$.${side}.service`, `${side}.service is required`); continue; }
|
|
83
|
+
if (s.site !== undefined && s.site !== null) {
|
|
84
|
+
if (!isObj(s.site) || !isStr(s.site.file) || !isStr(s.site.function)) err(`$.${side}.site`, `${side}.site needs file and function`);
|
|
85
|
+
else if (side === 'consumer' && !isStr(s.site.param)) err('$.consumer.site.param', 'consumer.site needs the handler parameter that receives the record');
|
|
86
|
+
} else if (side === 'consumer') err('$.consumer.site', 'consumer.site is required to seed taint');
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(c.fieldMappings)) err('$.fieldMappings', 'fieldMappings must be an array');
|
|
89
|
+
else c.fieldMappings.forEach((m, i) => { if (!isObj(m) || !isStr(m.from) || !isStr(m.to)) err(`$.fieldMappings[${i}]`, 'mapping needs from and to'); });
|
|
90
|
+
if (!EDGE_PROVENANCE_VALUES.includes(c.provenance)) err('$.provenance', `provenance must be one of ${EDGE_PROVENANCE_VALUES.join('|')}`);
|
|
91
|
+
if (!isObj(c.evidence) || c.evidence.declared !== true || c.evidence.observed !== false) err('$.evidence', 'evidence must be {declared: true, observed: false}: a contract is never an observed flow');
|
|
92
|
+
if (c.configDigest !== undefined && !isStr(c.configDigest)) err('$.configDigest', 'configDigest must be a string');
|
|
93
|
+
if (!isStr(c.declaredBy)) err('$.declaredBy', 'declaredBy is required');
|
|
94
|
+
return { valid: errors.length === 0, errors };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build a well-formed contract (the digest is computed, never trusted from input). */
|
|
98
|
+
export function createBoundaryContract(spec) {
|
|
99
|
+
const c = {
|
|
100
|
+
version: BOUNDARY_CONTRACT_VERSION, provenance: 'manual', evidence: { declared: true, observed: false }, ...spec,
|
|
101
|
+
};
|
|
102
|
+
if (!c.persistence) c.persistence = c.kind === 'storage' ? 'persisted' : c.kind === 'http' ? 'immediate' : 'persisted';
|
|
103
|
+
c.configDigest = contractDigest(c);
|
|
104
|
+
return c;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Evidence items; each keeps its own kind and basis. `observed` is true only for an observed-connection item. */
|
|
108
|
+
export function evidenceItem(kind, detail = {}) {
|
|
109
|
+
if (!EVIDENCE_KINDS.includes(kind)) throw new TypeError(`unknown evidence kind "${kind}"`);
|
|
110
|
+
return { kind, observed: kind === 'observed-connection', ...detail };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function attributeEvidence(items) {
|
|
114
|
+
const byKind = Object.fromEntries(EVIDENCE_KINDS.map((k) => [k, []]));
|
|
115
|
+
for (const it of items || []) if (it && byKind[it.kind]) byKind[it.kind].push(it);
|
|
116
|
+
return { byKind, observed: byKind['observed-connection'].length > 0 && byKind['observed-connection'].every((i) => i.observed === true), declaredOnly: byKind['observed-connection'].length === 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Evidence available for one contract. A passed digest check is a digest check, not an observation. */
|
|
120
|
+
export function contractEvidence(c, { binding = null, foreignValidation = null } = {}) {
|
|
121
|
+
const items = [];
|
|
122
|
+
if (c.provenance === 'manual' || c.provenance === 'schema') items.push(evidenceItem('manual-assertion', { declaredBy: c.declaredBy, provenance: c.provenance }));
|
|
123
|
+
if (c.configDigest) items.push(evidenceItem('digest-check', { expected: c.configDigest, actual: contractDigest(c), ok: c.configDigest === contractDigest(c) }));
|
|
124
|
+
if (binding) items.push(evidenceItem('code-mapping', { file: binding.file, line: binding.line, qid: binding.qid }));
|
|
125
|
+
if (foreignValidation) items.push(evidenceItem('foreign-graph-validation', { ok: foreignValidation.ok === true, graphDigest: foreignValidation.graphDigest || null }));
|
|
126
|
+
return items;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const paramNameOf = (p) => (typeof p === 'string' ? p : p && p.name);
|
|
130
|
+
const norm = (f) => String(f || '').replace(/^\.\//, '').replace(/\\/g, '/');
|
|
131
|
+
|
|
132
|
+
function resolveSite(site, callGraph) {
|
|
133
|
+
const fns = [...(callGraph && callGraph.functions && callGraph.functions.values ? callGraph.functions.values() : [])];
|
|
134
|
+
const file = norm(site.file);
|
|
135
|
+
const hits = fns.filter((f) => norm(f.file) === file && (f.name === site.function || String(f.name).endsWith(`.${site.function}`)));
|
|
136
|
+
if (hits.length === 1) return { fn: hits[0] };
|
|
137
|
+
if (hits.length > 1) return { gap: 'ambiguous-site', detail: `${hits.length} functions named ${site.function} in ${site.file}` };
|
|
138
|
+
return { gap: fns.some((f) => norm(f.file) === file) ? 'function-not-found' : 'file-not-found', detail: `${site.function} in ${site.file}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Bind consumer (and, when given, producer) sites to parser-resolved functions.
|
|
143
|
+
* Returns `{bindings, gaps, rejected}`; nothing is guessed. A consumer binding carries the access paths to seed.
|
|
144
|
+
*/
|
|
145
|
+
export function bindBoundaryContracts(contracts, callGraph, { language = null } = {}) {
|
|
146
|
+
const bindings = []; const gaps = []; const rejected = [];
|
|
147
|
+
for (const c of contracts || []) {
|
|
148
|
+
const v = validateBoundaryContract(c);
|
|
149
|
+
if (!v.valid) { rejected.push({ contractId: c && c.id, errors: v.errors }); continue; }
|
|
150
|
+
const gap = (side, reason, detail, extra = {}) => gaps.push({ contractId: c.id, kind: 'unbound-boundary', side, reason, detail, service: c[side].service, site: c[side].site || null, ...extra });
|
|
151
|
+
if (c.producer.site) {
|
|
152
|
+
const r = resolveSite(c.producer.site, callGraph);
|
|
153
|
+
if (r.gap) gap('producer', r.gap, r.detail);
|
|
154
|
+
}
|
|
155
|
+
const site = c.consumer.site;
|
|
156
|
+
const r = resolveSite(site, callGraph);
|
|
157
|
+
if (r.gap) { gap('consumer', r.gap, r.detail); continue; }
|
|
158
|
+
const fn = r.fn;
|
|
159
|
+
const rec = ((fn.hs && fn.hs.recordParams) || []).find((p) => p.name === site.param) || null;
|
|
160
|
+
if (!(fn.params || []).map(paramNameOf).includes(site.param)) { gap('consumer', 'param-not-found', `${site.param} is not a parameter of ${fn.name}`); continue; }
|
|
161
|
+
if (!c.fieldMappings.length) { gap('consumer', 'no-field-mappings', 'a contract with no field mappings seeds nothing'); continue; }
|
|
162
|
+
const seeds = []; const mapped = [];
|
|
163
|
+
for (const m of c.fieldMappings) {
|
|
164
|
+
if (rec && !(rec.fields || []).includes(m.to)) { gap('consumer', 'field-not-in-record', `${m.to} is not a field of ${rec.type}`, { field: m.to }); continue; }
|
|
165
|
+
seeds.push([site.param, site.recordPath, m.to].filter(Boolean).join('.'));
|
|
166
|
+
mapped.push({ from: m.from, to: m.to, renamed: m.from !== m.to });
|
|
167
|
+
}
|
|
168
|
+
if (!seeds.length) continue;
|
|
169
|
+
bindings.push({
|
|
170
|
+
contractId: c.id, kind: c.kind, persistence: c.persistence, digest: contractDigest(c), schema: c.schema,
|
|
171
|
+
channel: channelKey(c), upstream: { service: c.producer.service, site: c.producer.site || null },
|
|
172
|
+
consumerService: c.consumer.service, qid: fn.qid, file: fn.file, line: fn.line, param: site.param,
|
|
173
|
+
language: language || (/\.l?hs$/i.test(fn.file) ? 'haskell' : null), record: rec ? { type: rec.type, fields: rec.fields } : null,
|
|
174
|
+
originalLocation: { file: fn.file, line: fn.line }, seeds, mappedFields: mapped,
|
|
175
|
+
evidence: contractEvidence(c, { binding: { file: fn.file, line: fn.line, qid: fn.qid } }),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return { bindings, gaps, rejected };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The attribution stamped on a finding that came from a bound boundary seed. A declaration is a modeled input only. */
|
|
182
|
+
export function boundaryAttribution(binding) {
|
|
183
|
+
return {
|
|
184
|
+
contractId: binding.contractId, contractVersion: BOUNDARY_CONTRACT_VERSION, digest: binding.digest, kind: binding.kind,
|
|
185
|
+
persistence: binding.persistence, channel: binding.channel, schema: binding.schema, upstream: binding.upstream,
|
|
186
|
+
consumerService: binding.consumerService, param: binding.param, mappedFields: binding.mappedFields,
|
|
187
|
+
modeledInput: true, observed: false, exploited: false, evidence: binding.evidence,
|
|
188
|
+
attribution: attributeEvidence(binding.evidence),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Storage correlation. A write and a later read join only under a declared storage contract that names the same store and
|
|
194
|
+
* table, and (when both sites state it) the same key and schema version. Field names alone never join two sites.
|
|
195
|
+
* @param {object[]} contracts
|
|
196
|
+
* @param {Array<{store,table,key?,schemaVersion?,fields:string[],site?:any}>} writes
|
|
197
|
+
* @param {Array<{store,table,key?,schemaVersion?,fields:string[],site?:any}>} reads
|
|
198
|
+
*/
|
|
199
|
+
export function correlateStorage(contracts, writes, reads) {
|
|
200
|
+
const storage = (contracts || []).filter((c) => c.kind === 'storage' && validateBoundaryContract(c).valid);
|
|
201
|
+
const joins = []; const unjoined = [];
|
|
202
|
+
const matches = (c, s) => c.channel.store === s.store && c.channel.table === s.table
|
|
203
|
+
&& (!s.key || s.key === c.channel.key) && (!s.schemaVersion || s.schemaVersion === c.schema.version);
|
|
204
|
+
const used = new Set();
|
|
205
|
+
for (const w of writes || []) {
|
|
206
|
+
const c = storage.find((x) => matches(x, w));
|
|
207
|
+
if (!c) { unjoined.push({ side: 'write', site: w.site || null, reason: 'no-storage-contract', store: w.store, table: w.table }); continue; }
|
|
208
|
+
for (const r of reads || []) {
|
|
209
|
+
if (!matches(c, r)) continue;
|
|
210
|
+
used.add(r);
|
|
211
|
+
const map = new Map(c.fieldMappings.map((m) => [m.from, m.to]));
|
|
212
|
+
const fields = (w.fields || []).filter((f) => map.has(f) && (r.fields || []).includes(map.get(f))).map((f) => ({ from: f, to: map.get(f) }));
|
|
213
|
+
joins.push({ contractId: c.id, persistence: 'persisted', immediate: false, store: w.store, table: w.table, key: c.channel.key, schemaVersion: c.schema.version, write: w.site || null, read: r.site || null, fields });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const r of reads || []) if (!used.has(r)) unjoined.push({ side: 'read', site: r.site || null, reason: 'no-storage-contract', store: r.store, table: r.table });
|
|
217
|
+
return { joins, unjoined };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const DEFAULT_SERVICE_ROUNDS = 16;
|
|
221
|
+
export const DEFAULT_SERVICE_FACTS = 2000;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Which fields are modeled-tainted in which service, through contract edges. The service graph may contain cycles: it is
|
|
225
|
+
* condensed with SCC (graph/scc.js) and each recursive component is solved by a worklist under a round and fact budget.
|
|
226
|
+
* A budget hit is reported (`truncated`), never silently treated as convergence.
|
|
227
|
+
*/
|
|
228
|
+
export function analyzeServiceBoundaries(contracts, { entry = {}, maxRounds = DEFAULT_SERVICE_ROUNDS, maxFacts = DEFAULT_SERVICE_FACTS } = {}) {
|
|
229
|
+
const valid = (contracts || []).filter((c) => validateBoundaryContract(c).valid);
|
|
230
|
+
const nodes = sorted([...new Set(valid.flatMap((c) => [c.producer.service, c.consumer.service]))]);
|
|
231
|
+
const edges = valid.map((c) => [c.producer.service, c.consumer.service]);
|
|
232
|
+
const scc = computeScc({ nodes, edges });
|
|
233
|
+
const tainted = new Map(nodes.map((n) => [n, new Set(entry[n] || [])]));
|
|
234
|
+
const origins = new Map(); // service\0field -> {contractId, from}
|
|
235
|
+
let facts = nodes.reduce((a, n) => a + tainted.get(n).size, 0);
|
|
236
|
+
let truncated = false; let rounds = 0; let converged = true;
|
|
237
|
+
const out = new Map();
|
|
238
|
+
for (const c of valid) { if (!out.has(c.producer.service)) out.set(c.producer.service, []); out.get(c.producer.service).push(c); }
|
|
239
|
+
|
|
240
|
+
const step = (svc) => {
|
|
241
|
+
let changed = false;
|
|
242
|
+
for (const c of out.get(svc) || []) {
|
|
243
|
+
const have = tainted.get(svc);
|
|
244
|
+
const dst = tainted.get(c.consumer.service);
|
|
245
|
+
for (const m of c.fieldMappings) {
|
|
246
|
+
if (!(have.has(m.from) || (c.producer.untrustedFields || []).includes(m.from))) continue;
|
|
247
|
+
if (dst.has(m.to)) continue;
|
|
248
|
+
if (facts >= maxFacts) { truncated = true; continue; }
|
|
249
|
+
dst.add(m.to); facts++; changed = true;
|
|
250
|
+
origins.set(`${c.consumer.service}\0${m.to}`, { contractId: c.id, from: `${svc}.${m.from}` });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return changed;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for (const id of scc.order) {
|
|
257
|
+
const comp = scc.components[id];
|
|
258
|
+
if (!comp.recursive) { for (const s of comp.members) step(s); continue; }
|
|
259
|
+
let r = 0; let again = true;
|
|
260
|
+
while (again && r < maxRounds && !truncated) { again = false; r++; for (const s of comp.members) if (step(s)) again = true; }
|
|
261
|
+
rounds = Math.max(rounds, r);
|
|
262
|
+
if (again) { converged = false; }
|
|
263
|
+
}
|
|
264
|
+
if (truncated) converged = false;
|
|
265
|
+
return {
|
|
266
|
+
services: nodes,
|
|
267
|
+
cycles: scc.components.filter((c) => c.recursive).map((c) => c.members),
|
|
268
|
+
taintedFields: Object.fromEntries(nodes.map((n) => [n, sorted([...tainted.get(n)])])),
|
|
269
|
+
origins: Object.fromEntries([...origins].map(([k, v]) => [k.replace('\0', '.'), v])),
|
|
270
|
+
rounds, converged, truncated,
|
|
271
|
+
note: 'Fields are modeled from declarations; none is an observed runtime flow.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// completion.js: the shared completion envelope (DAG-07 R07.1/R07.2, PRD 5.2).
|
|
2
|
+
//
|
|
3
|
+
// One small, pure vocabulary that every long-running or bounded step uses to
|
|
4
|
+
// say how far it got: field analysis, summary computation, source seeding,
|
|
5
|
+
// graph construction, reverse selection and path queries. It imports nothing
|
|
6
|
+
// from any analysis engine, so both the lineage package and the executable
|
|
7
|
+
// taint package can produce and consume it.
|
|
8
|
+
//
|
|
9
|
+
// Two facts are kept apart on purpose:
|
|
10
|
+
// - queryExhausted: did THIS step run to the end of its own frontier?
|
|
11
|
+
// - modelCoverage: does the MODEL it ran over describe the whole software?
|
|
12
|
+
// Exhausting a modeled graph never proves the software has no missing edges,
|
|
13
|
+
// so `negativeClaimAllowed` (the only license to say "no modeled path") needs
|
|
14
|
+
// a complete status, an exhausted query and an empty frontier, and even then
|
|
15
|
+
// the claim is scoped to the modeled graph, never to "no vulnerability".
|
|
16
|
+
|
|
17
|
+
export const COMPLETION_SCHEMA_VERSION = '1.0.0';
|
|
18
|
+
|
|
19
|
+
export const COMPLETION_STATUSES = Object.freeze(['complete', 'partial', 'cancelled', 'unavailable', 'invalid']);
|
|
20
|
+
export const MODEL_COVERAGE = Object.freeze(['complete', 'partial', 'unknown']);
|
|
21
|
+
|
|
22
|
+
// Higher rank wins when records are combined: the combined answer is only as
|
|
23
|
+
// good as its worst part.
|
|
24
|
+
const RANK = { complete: 0, partial: 1, unavailable: 2, cancelled: 3, invalid: 4 };
|
|
25
|
+
|
|
26
|
+
export const NEGATIVE_CLAIM_SCOPE = 'modeled-graph-only';
|
|
27
|
+
|
|
28
|
+
function nonNegInt(v) {
|
|
29
|
+
return Number.isInteger(v) && v >= 0 ? v : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function uniqSorted(list) {
|
|
33
|
+
return [...new Set((list ?? []).filter((x) => typeof x === 'string' && x.length > 0))].sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a validated completion envelope. Unknown statuses become `invalid`
|
|
38
|
+
* (with a reason) rather than being passed through, so a consumer can trust
|
|
39
|
+
* the vocabulary. `negativeClaimAllowed` is always derived here, never taken
|
|
40
|
+
* from the caller.
|
|
41
|
+
*/
|
|
42
|
+
export function makeCompletion(fields = {}) {
|
|
43
|
+
const reasons = uniqSorted(fields.reasons);
|
|
44
|
+
let status = fields.status;
|
|
45
|
+
if (!COMPLETION_STATUSES.includes(status)) {
|
|
46
|
+
status = 'invalid';
|
|
47
|
+
reasons.push('unknown-completion-status');
|
|
48
|
+
}
|
|
49
|
+
const unexplored = nonNegInt(fields.unexploredFrontierCount);
|
|
50
|
+
const modelCoverage = MODEL_COVERAGE.includes(fields.modelCoverage)
|
|
51
|
+
? fields.modelCoverage
|
|
52
|
+
: (status === 'complete' ? 'complete' : 'partial');
|
|
53
|
+
// Partial work is never an exhausted query, whatever the caller says.
|
|
54
|
+
const queryExhausted = status === 'complete' && fields.queryExhausted !== false && unexplored === 0;
|
|
55
|
+
const env = {
|
|
56
|
+
schemaVersion: COMPLETION_SCHEMA_VERSION,
|
|
57
|
+
status,
|
|
58
|
+
queryExhausted,
|
|
59
|
+
modelCoverage,
|
|
60
|
+
negativeClaimAllowed: status === 'complete' && queryExhausted && unexplored === 0 && reasons.length === 0,
|
|
61
|
+
reasons: uniqSorted(reasons),
|
|
62
|
+
limits: fields.limits && typeof fields.limits === 'object' ? { ...fields.limits } : {},
|
|
63
|
+
unexploredFrontierCount: unexplored,
|
|
64
|
+
};
|
|
65
|
+
if (env.negativeClaimAllowed) env.negativeClaimScope = NEGATIVE_CLAIM_SCOPE;
|
|
66
|
+
if (typeof fields.unit === 'string') env.unit = fields.unit;
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Combine several envelopes: worst status wins, reasons union, frontier sums, limits namespaced by unit. */
|
|
71
|
+
export function combineCompletions(list, unit) {
|
|
72
|
+
const items = (list ?? []).filter(Boolean);
|
|
73
|
+
if (items.length === 0) return makeCompletion({ status: 'unavailable', reasons: ['no-completion-records'], unit });
|
|
74
|
+
let status = 'complete';
|
|
75
|
+
let modelCoverage = 'complete';
|
|
76
|
+
let unexplored = 0;
|
|
77
|
+
const reasons = [];
|
|
78
|
+
const limits = {};
|
|
79
|
+
for (const c of items) {
|
|
80
|
+
if ((RANK[c.status] ?? 4) > RANK[status]) status = c.status;
|
|
81
|
+
if (c.modelCoverage === 'partial' || (c.modelCoverage === 'unknown' && modelCoverage === 'complete')) modelCoverage = c.modelCoverage;
|
|
82
|
+
unexplored += nonNegInt(c.unexploredFrontierCount);
|
|
83
|
+
reasons.push(...(c.reasons ?? []));
|
|
84
|
+
if (c.unit) limits[c.unit] = c.limits ?? {};
|
|
85
|
+
}
|
|
86
|
+
return makeCompletion({ status, modelCoverage, unexploredFrontierCount: unexplored, reasons, limits, unit });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adapter for the roll-up the field-identity driver / graph builder produce
|
|
91
|
+
* (`analysisCompletion`): {status, functionsAnalyzed, functionsNotAnalyzed,
|
|
92
|
+
* incomplete[], degraded[], storeLimits?, seeding?}.
|
|
93
|
+
*/
|
|
94
|
+
export function fromAnalysisCompletion(ac) {
|
|
95
|
+
if (!ac || typeof ac !== 'object') return makeCompletion({ status: 'unavailable', reasons: ['no-analysis-completion-record'], unit: 'analysis' });
|
|
96
|
+
const reasons = [];
|
|
97
|
+
for (const u of ac.incomplete ?? []) reasons.push(...(u.reasons ?? ['analysis-incomplete']));
|
|
98
|
+
for (const d of ac.degraded ?? []) reasons.push(d.reason === 'context-cap' ? 'context-cap' : d.reason === 'recursion-nonconverged' ? 'recursion-nonconverged' : 'degraded-context');
|
|
99
|
+
if (ac.status === 'cancelled') reasons.push('cancelled');
|
|
100
|
+
if (ac.storeLimits?.hit) reasons.push('construction-limit');
|
|
101
|
+
if (ac.seeding && ac.seeding.status && ac.seeding.status !== 'complete') reasons.push(...(ac.seeding.reasons ?? ['seeding-incomplete']));
|
|
102
|
+
if (ac.incremental && ac.incremental.status && ac.incremental.status !== 'complete') reasons.push(...(ac.incremental.reasons ?? []));
|
|
103
|
+
const unfinished = (ac.incomplete ?? []).reduce((n, u) => n + nonNegInt(u.unfinishedWork), 0) + nonNegInt(ac.functionsNotAnalyzed);
|
|
104
|
+
const status = COMPLETION_STATUSES.includes(ac.status) ? ac.status : 'invalid';
|
|
105
|
+
const limits = {
|
|
106
|
+
functionsAnalyzed: nonNegInt(ac.functionsAnalyzed),
|
|
107
|
+
functionsNotAnalyzed: nonNegInt(ac.functionsNotAnalyzed),
|
|
108
|
+
incompleteUnits: (ac.incomplete ?? []).length,
|
|
109
|
+
degradedContexts: (ac.degraded ?? []).length,
|
|
110
|
+
};
|
|
111
|
+
if (ac.storeLimits?.hit) {
|
|
112
|
+
limits.hopsRefused = nonNegInt(ac.storeLimits.hopsRefused);
|
|
113
|
+
limits.groupsSkipped = (ac.storeLimits.groupsSkipped ?? []).length;
|
|
114
|
+
limits.edgesRefused = nonNegInt(ac.storeLimits.edgesRefused);
|
|
115
|
+
}
|
|
116
|
+
return makeCompletion({
|
|
117
|
+
status: status === 'complete' && reasons.length ? 'partial' : status,
|
|
118
|
+
modelCoverage: status === 'complete' && reasons.length === 0 ? 'complete' : 'partial',
|
|
119
|
+
unexploredFrontierCount: unfinished,
|
|
120
|
+
reasons, limits, unit: 'analysis',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Adapter for a `reconstructPaths` result (path-query.js). */
|
|
125
|
+
export function fromQueryResult(r) {
|
|
126
|
+
if (!r || typeof r !== 'object') return makeCompletion({ status: 'unavailable', reasons: ['no-query-result'], unit: 'query' });
|
|
127
|
+
const reasons = [...(r.truncationReasons ?? [])];
|
|
128
|
+
if (r.status === 'invalid') reasons.push('invalid-budget');
|
|
129
|
+
if (r.unknownStartNode) reasons.push('unknown-start-node');
|
|
130
|
+
if (r.analysisTruncated) reasons.push('analysis-truncated');
|
|
131
|
+
const status = r.status === 'invalid' ? 'invalid' : (r.status === 'cancelled' ? 'cancelled' : (r.status === 'complete' ? 'complete' : 'partial'));
|
|
132
|
+
return makeCompletion({
|
|
133
|
+
status,
|
|
134
|
+
modelCoverage: r.analysisTruncated ? 'partial' : undefined,
|
|
135
|
+
unexploredFrontierCount: nonNegInt(r.unexploredFrontierCount) || nonNegInt(r.droppedPathCount),
|
|
136
|
+
reasons,
|
|
137
|
+
limits: { ...(r.budget ?? {}) },
|
|
138
|
+
unit: 'query',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Completion of a whole DataFlowGraph, derived from what the graph already
|
|
144
|
+
* carries. A graph with no completion record at all is NOT assumed complete
|
|
145
|
+
* unless its own scanHealth says so; zero projected flows next to an
|
|
146
|
+
* incomplete analysis is called out explicitly, because an empty result is
|
|
147
|
+
* the case where the limit is easiest to miss.
|
|
148
|
+
*/
|
|
149
|
+
export function completionOfGraph(graph) {
|
|
150
|
+
if (!graph || typeof graph !== 'object') return makeCompletion({ status: 'unavailable', reasons: ['no-graph'], unit: 'graph' });
|
|
151
|
+
const ac = graph.coverage?.analysisCompletion ?? graph.scanHealth?.analysisCompletion ?? null;
|
|
152
|
+
let env;
|
|
153
|
+
if (ac) {
|
|
154
|
+
env = fromAnalysisCompletion(ac);
|
|
155
|
+
} else if (graph.scanHealth?.status === 'complete') {
|
|
156
|
+
env = makeCompletion({ status: 'complete', modelCoverage: 'complete', unit: 'graph' });
|
|
157
|
+
} else if (graph.scanHealth?.status === 'partial' || graph.scanHealth?.status === 'cancelled') {
|
|
158
|
+
env = makeCompletion({ status: graph.scanHealth.status, reasons: [graph.scanHealth.reason ?? 'scan-health-not-complete'], unit: 'graph' });
|
|
159
|
+
} else {
|
|
160
|
+
env = makeCompletion({ status: 'unavailable', reasons: ['no-completion-record'], unit: 'graph' });
|
|
161
|
+
}
|
|
162
|
+
const flows = Array.isArray(graph.flows) ? graph.flows.length : null;
|
|
163
|
+
if (flows === 0 && env.status !== 'complete') {
|
|
164
|
+
env = makeCompletion({ ...env, reasons: [...env.reasons, 'zero-flows-with-incomplete-analysis'], limits: { ...env.limits, flowsProjected: 0 } });
|
|
165
|
+
} else if (flows !== null) {
|
|
166
|
+
env.limits = { ...env.limits, flowsProjected: flows };
|
|
167
|
+
}
|
|
168
|
+
env.unit = 'graph';
|
|
169
|
+
return env;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const REASON_TEXT = {
|
|
173
|
+
'analysis-iteration-budget': 'a function hit its analysis iteration budget',
|
|
174
|
+
'context-cap': 'a call context fell back to a coarser summary (context cap)',
|
|
175
|
+
'recursion-nonconverged': 'recursion did not converge',
|
|
176
|
+
'degraded-context': 'a call context was degraded',
|
|
177
|
+
cancelled: 'the analysis was cancelled',
|
|
178
|
+
'construction-limit': 'a provenance construction limit was reached',
|
|
179
|
+
'zero-flows-with-incomplete-analysis': 'zero flows were projected from an incomplete analysis',
|
|
180
|
+
'seeding-limit': 'source seeding stopped at its limit',
|
|
181
|
+
'incremental-empty-entry-reuse': 'a cached empty-entry summary stood in for a tainted entry',
|
|
182
|
+
'caller-depth-limit': 'the reverse call-graph walk stopped at its depth limit',
|
|
183
|
+
'worklist-limit': 'the reverse selection stopped at its worklist limit',
|
|
184
|
+
'slice-node-budget': 'a backward slice stopped at its node budget',
|
|
185
|
+
'no-completion-record': 'no completion record is available',
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Human-readable lines for CLI output. Empty for a complete envelope. */
|
|
189
|
+
export function describeCompletion(env) {
|
|
190
|
+
if (!env || env.status === 'complete') return [];
|
|
191
|
+
const lines = [`analysis ${env.status}: absence of a flow is not evidence that none exists`];
|
|
192
|
+
for (const r of env.reasons ?? []) lines.push(` - ${REASON_TEXT[r] ?? r}`);
|
|
193
|
+
if (env.unexploredFrontierCount > 0) lines.push(` - ${env.unexploredFrontierCount} unit(s) of work were left unexplored`);
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Wire digest-neutral summary used by scan health. */
|
|
198
|
+
export function summarizeForHealth(env) {
|
|
199
|
+
return {
|
|
200
|
+
status: env.status,
|
|
201
|
+
reasons: env.reasons,
|
|
202
|
+
unexploredFrontierCount: env.unexploredFrontierCount,
|
|
203
|
+
negativeClaimAllowed: env.negativeClaimAllowed,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// continuation.js: resumable path queries (DAG-07 R07.9).
|
|
2
|
+
//
|
|
3
|
+
// A path query that stopped at a budget leaves a frontier. This module keeps
|
|
4
|
+
// that frontier in memory under an opaque token so the next page can pick up
|
|
5
|
+
// where the previous one stopped, instead of repeating the work.
|
|
6
|
+
//
|
|
7
|
+
// Resume is a convenience and never a source of soundness:
|
|
8
|
+
// - a token is bound to the EXACT graph digest and to the digest of the
|
|
9
|
+
// query configuration it was issued under; a different graph or a
|
|
10
|
+
// different configuration is rejected, never silently re-based;
|
|
11
|
+
// - tokens expire (TTL) and the store is bounded in entries and bytes, so a
|
|
12
|
+
// client cannot make it grow without limit; the oldest token is evicted
|
|
13
|
+
// first and an evicted token reports `evicted`, never a wrong answer;
|
|
14
|
+
// - a resumed page is always marked `resumed` and can never license a
|
|
15
|
+
// negative claim (path-query.js enforces that), so partial results stay
|
|
16
|
+
// partial however many times they are resumed.
|
|
17
|
+
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
19
|
+
import { reconstructPaths, DEFAULTS } from './path-query.js';
|
|
20
|
+
|
|
21
|
+
export const CONTINUATION_DEFAULTS = Object.freeze({
|
|
22
|
+
maxEntries: 64,
|
|
23
|
+
maxBytes: 64 * 1024 * 1024,
|
|
24
|
+
ttlMs: 10 * 60 * 1000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function stableJson(v) {
|
|
28
|
+
if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
|
|
29
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
|
|
30
|
+
return JSON.stringify(v);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Digest of the query configuration that affects results (budgets and start). */
|
|
34
|
+
export function configDigest(startNodeId, opts = {}) {
|
|
35
|
+
const budget = {};
|
|
36
|
+
for (const k of Object.keys(DEFAULTS)) budget[k] = opts[k] ?? DEFAULTS[k];
|
|
37
|
+
return createHash('sha256').update(stableJson({ startNodeId, budget })).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function approxBytes(state) {
|
|
41
|
+
// Frames and edges are small objects; a coarse, deliberately generous estimate.
|
|
42
|
+
return 256 + (state.stack?.length ?? 0) * 160 + (state.edgesRev?.length ?? 0) * 320 + (state.nodesRev?.length ?? 0) * 96;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ContinuationStore {
|
|
46
|
+
constructor(limits = {}) {
|
|
47
|
+
const pick = (k) => (Number.isInteger(limits[k]) && limits[k] > 0 ? limits[k] : CONTINUATION_DEFAULTS[k]);
|
|
48
|
+
this.limits = { maxEntries: pick('maxEntries'), maxBytes: pick('maxBytes'), ttlMs: pick('ttlMs') };
|
|
49
|
+
this._now = typeof limits.now === 'function' ? limits.now : Date.now;
|
|
50
|
+
this._entries = new Map(); // token -> {graphDigest, configDigest, state, bytes, expiresAt}
|
|
51
|
+
this._evicted = new Set();
|
|
52
|
+
this._bytes = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
size() { return this._entries.size; }
|
|
56
|
+
bytes() { return this._bytes; }
|
|
57
|
+
|
|
58
|
+
_drop(token, evicted) {
|
|
59
|
+
const e = this._entries.get(token);
|
|
60
|
+
if (!e) return;
|
|
61
|
+
this._bytes -= e.bytes;
|
|
62
|
+
this._entries.delete(token);
|
|
63
|
+
if (evicted) {
|
|
64
|
+
this._evicted.add(token);
|
|
65
|
+
if (this._evicted.size > 1024) this._evicted.delete(this._evicted.values().next().value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_sweep() {
|
|
70
|
+
const now = this._now();
|
|
71
|
+
for (const [t, e] of this._entries) if (e.expiresAt <= now) this._drop(t, false);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Store a frontier. Returns {ok, token} or {ok:false, reason:'too-large'}. */
|
|
75
|
+
issue({ graphDigest, configDigest: cfg, state }) {
|
|
76
|
+
this._sweep();
|
|
77
|
+
const bytes = approxBytes(state);
|
|
78
|
+
if (bytes > this.limits.maxBytes) return { ok: false, reason: 'too-large' };
|
|
79
|
+
while (this._entries.size >= this.limits.maxEntries || this._bytes + bytes > this.limits.maxBytes) {
|
|
80
|
+
const oldest = this._entries.keys().next().value;
|
|
81
|
+
if (oldest === undefined) break;
|
|
82
|
+
this._drop(oldest, true);
|
|
83
|
+
}
|
|
84
|
+
const token = `cont:${randomBytes(12).toString('hex')}`;
|
|
85
|
+
this._entries.set(token, { graphDigest, configDigest: cfg, state, bytes, expiresAt: this._now() + this.limits.ttlMs });
|
|
86
|
+
this._bytes += bytes;
|
|
87
|
+
return { ok: true, token };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Redeem a token against the CURRENT graph and configuration digests.
|
|
92
|
+
* One-shot: a successful redeem removes the entry (the next page issues a
|
|
93
|
+
* new token), so a token cannot be replayed to fork the frontier.
|
|
94
|
+
*/
|
|
95
|
+
redeem(token, { graphDigest, configDigest: cfg }) {
|
|
96
|
+
this._sweep();
|
|
97
|
+
const e = this._entries.get(token);
|
|
98
|
+
if (!e) return { ok: false, reason: this._evicted.has(token) ? 'evicted' : 'unknown-or-expired' };
|
|
99
|
+
if (e.graphDigest !== graphDigest) return { ok: false, reason: 'graph-changed' };
|
|
100
|
+
if (e.configDigest !== cfg) return { ok: false, reason: 'config-changed' };
|
|
101
|
+
this._drop(token, false);
|
|
102
|
+
return { ok: true, state: e.state };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run (or resume) a path query. `continuationToken` in `opts` resumes.
|
|
108
|
+
* Returns the reconstructPaths result plus `continuation`:
|
|
109
|
+
* { token|null, rejected: reason|null }. A token is issued only when the
|
|
110
|
+
* query stopped with work left.
|
|
111
|
+
*/
|
|
112
|
+
export function queryWithContinuation(store, continuations, startNodeId, opts = {}) {
|
|
113
|
+
const { continuationToken, ...queryOpts } = opts;
|
|
114
|
+
const graphDigest = store.digest();
|
|
115
|
+
const cfg = configDigest(startNodeId, queryOpts);
|
|
116
|
+
let resumeState;
|
|
117
|
+
if (continuationToken !== undefined) {
|
|
118
|
+
const r = continuations.redeem(continuationToken, { graphDigest, configDigest: cfg });
|
|
119
|
+
if (!r.ok) {
|
|
120
|
+
// Rejected: report invalid, never fall back to a fresh query that
|
|
121
|
+
// would look like the continuation worked.
|
|
122
|
+
return {
|
|
123
|
+
status: 'invalid', paths: [], truncated: true, truncationReasons: [], negativeClaimAllowed: false,
|
|
124
|
+
budgetErrors: [`continuation rejected: ${r.reason}`],
|
|
125
|
+
continuation: { token: null, rejected: r.reason },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
resumeState = r.state;
|
|
129
|
+
}
|
|
130
|
+
const result = reconstructPaths(store, startNodeId, { ...queryOpts, ...(resumeState ? { resumeState } : {}) });
|
|
131
|
+
let token = null;
|
|
132
|
+
let note = null;
|
|
133
|
+
if (result.resumeState) {
|
|
134
|
+
const issued = continuations.issue({ graphDigest, configDigest: cfg, state: result.resumeState });
|
|
135
|
+
if (issued.ok) token = issued.token; else note = issued.reason;
|
|
136
|
+
delete result.resumeState;
|
|
137
|
+
}
|
|
138
|
+
result.continuation = { token, rejected: null, ...(note ? { notIssued: note } : {}) };
|
|
139
|
+
return result;
|
|
140
|
+
}
|