@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
//
|
|
2
|
+
// handling-analyzer.js — Milestone 2, Sub-project D ("handling analyzer +
|
|
3
|
+
// FR-307 control-credit"), increment 1.
|
|
4
|
+
//
|
|
5
|
+
// FR-403's single-path handling TAXONOMY classifier — see
|
|
6
|
+
// DESIGN_HANDLING_ANALYZER.md for the full design record: the exact
|
|
7
|
+
// `transform-catalog.js` `kind` -> `schema.js` `HANDLING_VALUES` mapping
|
|
8
|
+
// table, the load-bearing naming note distinguishing `flow.handling` (this
|
|
9
|
+
// module's output) from `protection.js`'s own `PROTECTION_DIMENSIONS`
|
|
10
|
+
// `handling` dimension (a different field, a different vocabulary, a
|
|
11
|
+
// different question), and everything explicitly deferred (FR-307's
|
|
12
|
+
// multi-path control-credit rule, `aggregate`'s own `'aggregated'` verdict,
|
|
13
|
+
// any UI/display concern).
|
|
14
|
+
//
|
|
15
|
+
// Reuse boundary: imports ONLY `recognizeTransformation` from
|
|
16
|
+
// `./transform-catalog.js` and `exprRoots`/`walkExpr` from
|
|
17
|
+
// `./source-seeding.js` — both already-shipped, read-only consumers, per
|
|
18
|
+
// this package's established isolation convention. Never
|
|
19
|
+
// `dataflow/engine.js`, never `dataflow/summaries.js`, never
|
|
20
|
+
// `dataflow/catalog.js`/`dataflow/privacy-catalog.js` directly.
|
|
21
|
+
//
|
|
22
|
+
// A pure, one-path-at-a-time classifier. `classifyHandling(path, callGraph)`
|
|
23
|
+
// takes ONE `path-query.js#reconstructPaths` result path — the caller picks
|
|
24
|
+
// which; this module never iterates multiple paths to one sink, that is
|
|
25
|
+
// FR-307's job, explicitly deferred to D2 — and never throws on malformed
|
|
26
|
+
// input.
|
|
27
|
+
|
|
28
|
+
import { recognizeTransformation } from './transform-catalog.js';
|
|
29
|
+
import { exprRoots, walkExpr } from './source-seeding.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `transform-catalog.js` `kind` -> FR-403 `HANDLING_VALUES` mapping
|
|
33
|
+
* (DESIGN_HANDLING_ANALYZER.md §2). Five kinds map to their own protective
|
|
34
|
+
* handling state (`mask`/`redact`/`hash`/`tokenize`/`encrypt`); five more
|
|
35
|
+
* (`decrypt`/`encode`/`decode`/`truncate`/`normalize`) map to `'unknown'`
|
|
36
|
+
* because none of them is itself a PROTECTIVE handling state for FR-403's
|
|
37
|
+
* purposes — decoding/truncating/normalizing doesn't protect a field, and
|
|
38
|
+
* a `decrypt` immediately before a sink is actively the OPPOSITE of
|
|
39
|
+
* protection. `aggregate` ALSO maps to `'unknown'` here, for a different,
|
|
40
|
+
* disclosed reason: `HANDLING_VALUES` carries its own `'aggregated'`
|
|
41
|
+
* value, but awarding it needs shape-level reasoning about a WHOLE
|
|
42
|
+
* collection that a single-hop, single-path classifier cannot do soundly —
|
|
43
|
+
* deferred to D2/later, never faked from a hop-level recognition alone.
|
|
44
|
+
* `custom`/`unknown` never appear as keys here because
|
|
45
|
+
* `recognizeTransformation` never emits them as a `kind`
|
|
46
|
+
* (`transform-catalog.js`'s own documented, load-time-enforced contract).
|
|
47
|
+
*/
|
|
48
|
+
const KIND_TO_HANDLING = Object.freeze({
|
|
49
|
+
mask: 'masked',
|
|
50
|
+
redact: 'redacted',
|
|
51
|
+
hash: 'hashed',
|
|
52
|
+
tokenize: 'tokenized',
|
|
53
|
+
encrypt: 'encrypted',
|
|
54
|
+
decrypt: 'unknown',
|
|
55
|
+
encode: 'unknown',
|
|
56
|
+
decode: 'unknown',
|
|
57
|
+
truncate: 'unknown',
|
|
58
|
+
normalize: 'unknown',
|
|
59
|
+
aggregate: 'unknown',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The honest empty answer: no hop on this path yielded a recognized
|
|
63
|
+
* transform. Frozen and shared — this module never mutates its own
|
|
64
|
+
* return values. */
|
|
65
|
+
const RAW_RESULT = Object.freeze({ handling: 'raw', recognizedTransform: null, hopIndex: null });
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Mirrors `graph-builder.js`'s own private (unexported) `calleeDescriptor`
|
|
69
|
+
* helper. Deliberately NOT imported from there — see
|
|
70
|
+
* DESIGN_HANDLING_ANALYZER.md §3 for why a third copy of this ~10-line
|
|
71
|
+
* shape was chosen over introducing a `graph-builder.js` <->
|
|
72
|
+
* `handling-analyzer.js` module cycle (this module is wired INTO
|
|
73
|
+
* `graph-builder.js`, the reverse direction). Never throws; returns `null`
|
|
74
|
+
* for anything that cannot become a `transform-catalog.js` descriptor.
|
|
75
|
+
*/
|
|
76
|
+
function calleeDescriptorOf(calleeExpr) {
|
|
77
|
+
if (typeof calleeExpr === 'string') return { type: 'call', callee: calleeExpr };
|
|
78
|
+
if (!calleeExpr || typeof calleeExpr !== 'object') return null;
|
|
79
|
+
if (calleeExpr.kind === 'ident' && typeof calleeExpr.name === 'string') return { type: 'call', callee: calleeExpr.name };
|
|
80
|
+
if (calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
|
|
81
|
+
const obj = calleeExpr.object && calleeExpr.object.kind === 'ident' ? calleeExpr.object.name : null;
|
|
82
|
+
return obj ? { type: 'member-call', object: obj, method: calleeExpr.prop } : { type: 'call', callee: calleeExpr.prop };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every `{kind: 'call', ...}` expression reachable from one CFG node, in
|
|
89
|
+
* expression-tree order — mirrors `graph-builder.js`'s own per-hop
|
|
90
|
+
* transformation-extraction loop exactly (`exprRoots`/`walkExpr` over the
|
|
91
|
+
* node, plus the node's OWN call when the node itself is `kind: 'call'` —
|
|
92
|
+
* `exprRoots` only ever yields a call NODE's `callee`/`args`, never a
|
|
93
|
+
* `{kind:'call'}` shape for the node itself, so that case needs the same
|
|
94
|
+
* explicit unshift `graph-builder.js` already uses). This is what makes an
|
|
95
|
+
* `assign`-kind CFG node whose `.source` IS a call (`const maskedPan =
|
|
96
|
+
* maskCard(cardNumber);`) resolvable too, not just a bare call statement —
|
|
97
|
+
* see DESIGN_HANDLING_ANALYZER.md §3 item 2 for why that distinction is
|
|
98
|
+
* load-bearing for this increment's own required AC-02 fixture. Defensive:
|
|
99
|
+
* a malformed `node` yields `[]`, never throws.
|
|
100
|
+
*/
|
|
101
|
+
function callsAt(node) {
|
|
102
|
+
if (!node || typeof node !== 'object') return [];
|
|
103
|
+
const calls = [];
|
|
104
|
+
for (const root of exprRoots(node)) walkExpr(root, (e) => { if (e && typeof e === 'object' && e.kind === 'call') calls.push(e); });
|
|
105
|
+
if (node.kind === 'call' && node.callee) calls.unshift({ kind: 'call', callee: node.callee, args: node.args ?? [] });
|
|
106
|
+
return calls;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* FR-403's single-path handling classifier. `path` is one
|
|
111
|
+
* `path-query.js#reconstructPaths` result path (the caller picks which —
|
|
112
|
+
* this module never iterates multiple paths to one sink, per FR-307's own
|
|
113
|
+
* deferred multi-path control-credit rule). `callGraph` is the same
|
|
114
|
+
* `scanner/src/ir/callgraph.js#buildCallGraph`-shaped object (a real
|
|
115
|
+
* `Map` at `.functions`) `graph-builder.js` already consumes, or an
|
|
116
|
+
* equivalent hand-built fixture exposing the same shape.
|
|
117
|
+
*
|
|
118
|
+
* Walks `path.hops` in the SOURCE-TO-SINK order `path-query.js` already
|
|
119
|
+
* materializes paths in. For each hop, resolves
|
|
120
|
+
* `callGraph.functions.get(hop.scope)?.cfg?.nodes?.[hop.siteNodeId]`
|
|
121
|
+
* defensively — never throws on a missing/malformed lookup, mirroring
|
|
122
|
+
* every other lineage module's defensiveness — and, when that CFG node
|
|
123
|
+
* yields one or more call expressions (via `callsAt`, above), tries
|
|
124
|
+
* `recognizeTransformation` on each in expression order. Returns the FIRST
|
|
125
|
+
* recognized transform found walking from source to sink.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} path a `reconstructPaths` result path
|
|
128
|
+
* @param {object} callGraph `{functions: Map<qid, {cfg: {nodes}}>, ...}`
|
|
129
|
+
* @returns {{handling: string, recognizedTransform: object|null, hopIndex: number|null}}
|
|
130
|
+
* `handling` is always a `schema.js` `HANDLING_VALUES` member.
|
|
131
|
+
* `recognizedTransform` is `recognizeTransformation`'s own decision
|
|
132
|
+
* object (kind/reversibility/algorithm/confidence/evidence), unmodified,
|
|
133
|
+
* when one was found — else `null`. `hopIndex` is the index into
|
|
134
|
+
* `path.hops` the transform was found at, else `null`. When no hop
|
|
135
|
+
* yields a recognized transform, returns
|
|
136
|
+
* `{handling: 'raw', recognizedTransform: null, hopIndex: null}` —
|
|
137
|
+
* never a guess.
|
|
138
|
+
*/
|
|
139
|
+
export function classifyHandling(path, callGraph) {
|
|
140
|
+
const hops = Array.isArray(path?.hops) ? path.hops : [];
|
|
141
|
+
const fns = callGraph && callGraph.functions && typeof callGraph.functions.get === 'function' ? callGraph.functions : null;
|
|
142
|
+
if (!fns) return RAW_RESULT;
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < hops.length; i++) {
|
|
145
|
+
const hop = hops[i];
|
|
146
|
+
if (!hop || typeof hop !== 'object') continue;
|
|
147
|
+
const fn = fns.get(hop.scope);
|
|
148
|
+
const node = fn?.cfg?.nodes?.[hop.siteNodeId];
|
|
149
|
+
if (!node) continue;
|
|
150
|
+
for (const call of callsAt(node)) {
|
|
151
|
+
const descriptor = calleeDescriptorOf(call.callee);
|
|
152
|
+
if (!descriptor) continue;
|
|
153
|
+
const rec = recognizeTransformation(descriptor);
|
|
154
|
+
if (!rec) continue;
|
|
155
|
+
// Defensive fallback only — every non-fallback `TRANSFORM_KINDS`
|
|
156
|
+
// value `recognizeTransformation` can actually emit is a key in
|
|
157
|
+
// `KIND_TO_HANDLING` above (11 kinds, both tables measured against
|
|
158
|
+
// the same live `transform-catalog.js`), so `?? 'unknown'` is never
|
|
159
|
+
// exercised by real catalog output today. Kept rather than omitted
|
|
160
|
+
// so a future catalog kind this table hasn't been updated for fails
|
|
161
|
+
// safe (an honest `'unknown'`) instead of writing `undefined` onto
|
|
162
|
+
// `flow.handling`.
|
|
163
|
+
const handling = KIND_TO_HANDLING[rec.kind] ?? 'unknown';
|
|
164
|
+
return { handling, recognizedTransform: rec, hopIndex: i };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return RAW_RESULT;
|
|
168
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Stable-ID spec for DataFlowGraph v1 (PRD 10.1: "Stable within the
|
|
3
|
+
// repository/commit; independent of visual layout"). Mirrors the shape
|
|
4
|
+
// scanner/src/posture/stable-id.js already established for findings:
|
|
5
|
+
// sha256 over a canonicalized, pipe-joined material string, truncated to
|
|
6
|
+
// a fixed hex length, prefixed by the entity kind. Same rationale — a
|
|
7
|
+
// content hash survives reordering and re-emission, unlike an
|
|
8
|
+
// incrementing counter.
|
|
9
|
+
|
|
10
|
+
import * as crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const ID_HEX_LEN = 12;
|
|
13
|
+
|
|
14
|
+
function _hash(material, len = ID_HEX_LEN) {
|
|
15
|
+
return crypto.createHash('sha256').update(material).digest('hex').slice(0, len);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function _canon(parts) {
|
|
19
|
+
return parts.map((p) => (p === undefined || p === null ? '' : String(p))).join('|');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** PRD 10.2's literal example shape: `dfg:<repository>:<commit>:<configuration-hash>`. */
|
|
23
|
+
export function graphId({ repository, commit, configHash } = {}) {
|
|
24
|
+
const repo = repository || 'unknown-repo';
|
|
25
|
+
const c = commit || 'uncommitted';
|
|
26
|
+
const cfg = configHash || 'default';
|
|
27
|
+
return `dfg:${repo}:${c}:${cfg}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** discriminatorParts should include enough of {system, location, destination} to be unique within the graph. */
|
|
31
|
+
export function nodeId(kind, discriminatorParts = []) {
|
|
32
|
+
return `node:${kind}:${_hash(_canon([kind, ...discriminatorParts]))}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** discriminatorParts should include the owning service/schema so the same field name in two services never collides (PRD 10.4). */
|
|
36
|
+
export function dataElementId(canonicalName, discriminatorParts = []) {
|
|
37
|
+
return `data:${_hash(_canon([canonicalName, ...discriminatorParts]))}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function edgeId(fromId, toId, relationship, discriminatorParts = []) {
|
|
41
|
+
return `edge:${_hash(_canon([fromId, toId, relationship, ...discriminatorParts]))}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A node in `path-store.js`'s provenance DAG (Sub-project C, increment 4;
|
|
46
|
+
* DESIGN_PATH_PROVENANCE.md §14.2/§14.5). `pnode:`/`pedge:` are deliberately
|
|
47
|
+
* distinct prefixes from `node:`/`edge:` above: a provenance node is NOT a
|
|
48
|
+
* `DataFlowGraph v1` node, and `validate.js`'s id-prefix regexes must never
|
|
49
|
+
* be able to confuse the two namespaces.
|
|
50
|
+
*
|
|
51
|
+
* Object-argument, not this file's usual positional-plus-discriminatorParts
|
|
52
|
+
* form (a deliberate, narrow divergence — `graphId` is the in-file
|
|
53
|
+
* precedent) — the discriminator is wide enough that a positional array is
|
|
54
|
+
* exactly the shape a future field addition would silently omit from.
|
|
55
|
+
*/
|
|
56
|
+
export function provenanceNodeId(
|
|
57
|
+
{ kind, scope, context, path, siteNodeId, dataElementId },
|
|
58
|
+
discriminatorParts = [],
|
|
59
|
+
) {
|
|
60
|
+
return `pnode:${kind}:${_hash(_canon([kind, scope, context, path, siteNodeId, dataElementId, ...discriminatorParts]))}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An edge in `path-store.js`'s provenance DAG: one (in-half, out-half) pair
|
|
65
|
+
* at one join group (§14.5). The discriminator carries the SITE (`scope`,
|
|
66
|
+
* `context`, `siteNodeId`) as well as both endpoint ids — two structurally
|
|
67
|
+
* identical hops at two different program points are two materially
|
|
68
|
+
* different edges (FR-305), each needing its own `line` for display and
|
|
69
|
+
* §9.2's hop-ordering lever; omitting `siteNodeId` would silently collide
|
|
70
|
+
* them into one edge carrying one arbitrary line. It also carries both
|
|
71
|
+
* halves' `kind`/`subKind` and their reason strings — the
|
|
72
|
+
* `flagship-fixture.mjs` lesson (see this package's own CLAUDE.md row)
|
|
73
|
+
* applied deliberately: under-specifying a content-hash discriminator is a
|
|
74
|
+
* silent merge. NOT in the discriminator: `syntacticPath` and `line`
|
|
75
|
+
* (display material), edge `annotations[]`, and `ambiguousCorrelation`
|
|
76
|
+
* (both are functions of the group and the endpoints already in the id).
|
|
77
|
+
*/
|
|
78
|
+
export function provenanceEdgeId(
|
|
79
|
+
{
|
|
80
|
+
fromNodeId, toNodeId, dataElementId,
|
|
81
|
+
scope, context, siteNodeId,
|
|
82
|
+
inKind, inSubKind, outKind, outSubKind,
|
|
83
|
+
widenReasons = [], lossReasons = [],
|
|
84
|
+
},
|
|
85
|
+
discriminatorParts = [],
|
|
86
|
+
) {
|
|
87
|
+
return `pedge:${_hash(_canon([
|
|
88
|
+
fromNodeId, toNodeId, dataElementId,
|
|
89
|
+
scope, context, siteNodeId,
|
|
90
|
+
inKind, inSubKind, outKind, outSubKind,
|
|
91
|
+
[...widenReasons].sort().join(','), [...lossReasons].sort().join(','),
|
|
92
|
+
...discriminatorParts,
|
|
93
|
+
]))}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A reconstructed path (Sub-project C, increment 5;
|
|
98
|
+
* DESIGN_PATH_PROVENANCE.md §15.6). `pnode:`/`pedge:`'s own header left this
|
|
99
|
+
* name deliberately unclaimed (§14.5: "the thing C5 reconstructs *is* a
|
|
100
|
+
* path, and it will plausibly want that name") — increment 5 claims it,
|
|
101
|
+
* with a `ppath:` prefix joining the same family. A reconstructed path is
|
|
102
|
+
* not a `DataFlowGraph v1` entity either, so `validate.js` needs no change.
|
|
103
|
+
*
|
|
104
|
+
* The discriminator is the EDGE id SEQUENCE, never the node id sequence
|
|
105
|
+
* (§15.6/FR-305 — two paths can share a node sequence while differing in
|
|
106
|
+
* the edges that join it, e.g. two assignments at two different program
|
|
107
|
+
* points, and that difference must not be hidden by dedup) — order matters
|
|
108
|
+
* for a path, unlike a node/edge discriminator's set-like fields, so
|
|
109
|
+
* `edgeIds` is NOT sorted before hashing. `startNodeId` is strictly
|
|
110
|
+
* redundant today (a path always has at least one hop, so the last edge id
|
|
111
|
+
* already determines it) but is kept per §14.5's own lesson: over-specifying
|
|
112
|
+
* a content hash costs nothing, under-specifying one is a silent merge.
|
|
113
|
+
*/
|
|
114
|
+
export function pathId({ startNodeId, edgeIds }, discriminatorParts = []) {
|
|
115
|
+
return `ppath:${_hash(_canon([startNodeId, ...edgeIds, ...discriminatorParts]))}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* dataElementIds is treated as a SET (sorted before hashing) — a flow
|
|
120
|
+
* carrying {card_number, cvv} has one identity regardless of the order the
|
|
121
|
+
* builder discovered them in. `discriminatorParts` is the escape hatch for
|
|
122
|
+
* two flows sharing source/sink/fields that must still be distinct paths
|
|
123
|
+
* (e.g. a masked branch vs. a raw branch to the same log sink).
|
|
124
|
+
*/
|
|
125
|
+
export function flowId(sourceNodeId, sinkNodeId, dataElementIds = [], discriminatorParts = []) {
|
|
126
|
+
const sorted = [...dataElementIds].sort();
|
|
127
|
+
return `flow:${_hash(_canon([sourceNodeId, sinkNodeId, ...sorted, ...discriminatorParts]))}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function transformationId(anchorId, calleeName, discriminatorParts = []) {
|
|
131
|
+
return `transform:${_hash(_canon([anchorId, calleeName, ...discriminatorParts]))}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function evidenceId(claim, location, discriminatorParts = []) {
|
|
135
|
+
return `evidence:${_hash(_canon([claim, location, ...discriminatorParts]))}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* An ObligationMapping record's id (FR-504 §7.12, sub-project 6a) — NOT a
|
|
140
|
+
* DataFlowGraph v1 entity, so validate.js's id-prefix regexes and
|
|
141
|
+
* json-schema-parity.test.js's $defs audit need zero change, mirroring
|
|
142
|
+
* why provenanceNodeId/provenanceEdgeId are prefixed outside the
|
|
143
|
+
* node:/edge:/flow: family. Discriminated by (framework, frameworkVersion,
|
|
144
|
+
* requirementId, graphId, graphDigest) — graphId ALONE is not enough:
|
|
145
|
+
* real callers never supply graphId's own configHash component (only
|
|
146
|
+
* `graph-builder.js` reads it, via `opts.configHash ?? 'default'`, and
|
|
147
|
+
* nothing calls it with one), so every real scan at one commit produces
|
|
148
|
+
* the identical graphId regardless of the graph's actual CONTENT — found
|
|
149
|
+
* by this sub-project's own final whole-branch review, reproduced live:
|
|
150
|
+
* two records against genuinely different base graphs (same repo/commit,
|
|
151
|
+
* different analyzer output) collided onto one id without graphDigest in
|
|
152
|
+
* the material. graphDigest is the field §10.10 already requires every
|
|
153
|
+
* extension record to carry precisely so it can be distinguished from a
|
|
154
|
+
* same-graphId, different-content graph — this discriminator is what
|
|
155
|
+
* makes that requirement actually load-bearing for uniqueness, not just
|
|
156
|
+
* a payload field alongside an otherwise-collidable id.
|
|
157
|
+
*/
|
|
158
|
+
export function obligationId(
|
|
159
|
+
{ framework, frameworkVersion, requirementId, graphId, graphDigest },
|
|
160
|
+
discriminatorParts = [],
|
|
161
|
+
) {
|
|
162
|
+
return `obligation:${_hash(_canon([framework, frameworkVersion, requirementId, graphId, graphDigest, ...discriminatorParts]))}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A RecipientProfile record's id (FR-506 §7.12, "Third-Party and
|
|
167
|
+
* Cross-Border Intelligence", Milestone 4) — NOT a DataFlowGraph v1
|
|
168
|
+
* entity, mirrors obligationId's own precedent exactly (a real,
|
|
169
|
+
* stable-ID'd extension record that is deliberately not a base-graph
|
|
170
|
+
* entity). Discriminated by (graphId, graphDigest, recipientKey) —
|
|
171
|
+
* graphDigest is required in the discriminator for the identical reason
|
|
172
|
+
* obligationId's own comment gives (no real caller supplies graphId's own
|
|
173
|
+
* configHash component, so graphId alone never distinguishes two
|
|
174
|
+
* same-commit graphs with genuinely different content); recipientKey is
|
|
175
|
+
* the stable string a later task keys one recipient by (e.g. a
|
|
176
|
+
* normalized hostname or provider id), so two profiles for the same
|
|
177
|
+
* recipient across two different graphs never collide, and two DIFFERENT
|
|
178
|
+
* recipients within the same graph never collide either.
|
|
179
|
+
*/
|
|
180
|
+
export function recipientProfileId(
|
|
181
|
+
{ graphId, graphDigest, recipientKey },
|
|
182
|
+
discriminatorParts = [],
|
|
183
|
+
) {
|
|
184
|
+
return `recipient:${_hash(_canon([graphId, graphDigest, recipientKey, ...discriminatorParts]))}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A DecisionStory record's id (M4 deliverable #7, FR-501 §14, DFG-035) —
|
|
189
|
+
* NOT a DataFlowGraph v1 entity, mirrors obligationId's own precedent
|
|
190
|
+
* exactly (a real, stable-ID'd extension record that is deliberately not
|
|
191
|
+
* a base-graph entity). Discriminated by (graphDigest, audienceMode,
|
|
192
|
+
* scopeQuery) rather than graphId alone, for the identical reason
|
|
193
|
+
* obligationId's own comment gives: two stories over the same graphId but
|
|
194
|
+
* genuinely different graph CONTENT (or a different filter/audience
|
|
195
|
+
* scope) must not collide onto one id. `scopeQuery` is passed pre-
|
|
196
|
+
* serialized by the caller (a plain object is not itself hashable
|
|
197
|
+
* material) so this function stays a thin, generic hasher rather than
|
|
198
|
+
* embedding export-briefing.js's own scopeQuery shape.
|
|
199
|
+
*/
|
|
200
|
+
export function storyId(
|
|
201
|
+
{ graphDigest, audienceMode, scopeQuery },
|
|
202
|
+
discriminatorParts = [],
|
|
203
|
+
) {
|
|
204
|
+
const scope = typeof scopeQuery === 'string' ? scopeQuery : JSON.stringify(scopeQuery ?? null);
|
|
205
|
+
return `story:${_hash(_canon([graphDigest, audienceMode, scope, ...discriminatorParts]))}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A GraphSnapshot record's id (M4 deliverable #8, FR-503 §14, DFG-022,
|
|
210
|
+
* sub-project 8a) — NOT a DataFlowGraph v1 entity, mirrors obligationId's/
|
|
211
|
+
* storyId's own precedent exactly (a real, stable-ID'd extension record
|
|
212
|
+
* that is deliberately not a base-graph entity). Discriminated by
|
|
213
|
+
* (graphId, commit, capturedAt) — `commit` is the REAL git HEAD resolved
|
|
214
|
+
* by graph-snapshot.js's own persistence layer, never `graphId`'s own
|
|
215
|
+
* embedded commit component (which is the literal string 'uncommitted' on
|
|
216
|
+
* every real scan today — see graph-snapshot.js's own header comment).
|
|
217
|
+
*/
|
|
218
|
+
export function snapshotId(
|
|
219
|
+
{ graphId, commit, capturedAt },
|
|
220
|
+
discriminatorParts = [],
|
|
221
|
+
) {
|
|
222
|
+
return `snapshot:${_hash(_canon([graphId, commit, capturedAt, ...discriminatorParts]))}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A GraphDiff record's id (M4 deliverable #9, FR-503 §14, DFG-022,
|
|
227
|
+
* sub-project 8b) — NOT a DataFlowGraph v1 entity, mirrors snapshotId's
|
|
228
|
+
* own precedent exactly (a real, stable-ID'd extension record that is
|
|
229
|
+
* deliberately not a base-graph entity). Discriminated by
|
|
230
|
+
* (beforeSnapshotId, afterSnapshotId) — unlike snapshotId, no separate
|
|
231
|
+
* timestamp component is needed: each snapshot id is itself
|
|
232
|
+
* content-derived (its own graphId/commit/capturedAt), so the pair alone
|
|
233
|
+
* already makes a diff between two SPECIFIC snapshots deterministic, and
|
|
234
|
+
* a diff is not itself a new capture event the way a snapshot is.
|
|
235
|
+
*/
|
|
236
|
+
export function diffId(
|
|
237
|
+
{ beforeSnapshotId, afterSnapshotId },
|
|
238
|
+
discriminatorParts = [],
|
|
239
|
+
) {
|
|
240
|
+
return `diff:${_hash(_canon([beforeSnapshotId, afterSnapshotId, ...discriminatorParts]))}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A Scenario record's id (M5 deliverable #3a, FR-502 §10.10) — NOT a
|
|
245
|
+
* DataFlowGraph v1 entity, mirrors recipientProfileId's own precedent
|
|
246
|
+
* exactly (a real, stable-ID'd extension record deliberately not a
|
|
247
|
+
* base-graph entity). Discriminated by (graphId, graphDigest) plus
|
|
248
|
+
* caller-supplied discriminatorParts — unlike recipientProfileId, a
|
|
249
|
+
* Scenario has no single natural key of its own (two saved scenarios
|
|
250
|
+
* over the identical base graph with identical operations are still
|
|
251
|
+
* two different records, since FR-502 requires author/time as real,
|
|
252
|
+
* always-present, non-deduplicating fields), so a caller building a
|
|
253
|
+
* scenario record supplies (author, createdAt) as discriminatorParts to
|
|
254
|
+
* make repeat calls collide only when they are genuinely the same
|
|
255
|
+
* scenario.
|
|
256
|
+
*/
|
|
257
|
+
export function scenarioId(
|
|
258
|
+
{ graphId, graphDigest },
|
|
259
|
+
discriminatorParts = [],
|
|
260
|
+
) {
|
|
261
|
+
return `scenario:${_hash(_canon([graphId, graphDigest, ...discriminatorParts]))}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* An ImpactAssessment record's id (M5 deliverable #4, FR-507 §10.10) —
|
|
266
|
+
* NOT a DataFlowGraph v1 entity, mirrors recipientProfileId's/
|
|
267
|
+
* scenarioId's own precedent exactly. Discriminated by (graphId,
|
|
268
|
+
* graphDigest, targetId) plus caller-supplied discriminatorParts —
|
|
269
|
+
* graphDigest is required for the identical reason every other
|
|
270
|
+
* extension-contract id in this file requires it (graphId alone never
|
|
271
|
+
* distinguishes two same-commit graphs with genuinely different
|
|
272
|
+
* content); targetId is the compromised entity the assessment was
|
|
273
|
+
* computed FROM, so two assessments over the same graph but different
|
|
274
|
+
* targets never collide. Mirrors scenarioId's own (author, createdAt)
|
|
275
|
+
* precedent for discriminatorParts: the only real caller
|
|
276
|
+
* (impact-engine.js) always supplies `[generatedAt]`, so two
|
|
277
|
+
* back-to-back assessments of the SAME target over an UNCHANGED graph
|
|
278
|
+
* get distinct ids per run (a content-addressed id that collided
|
|
279
|
+
* across runs would be strictly worse than a per-run one — graphDigest
|
|
280
|
+
* already covers "the graph changed", generatedAt is purely a per-run
|
|
281
|
+
* nonce, the same role snapshotId's own capturedAt plays).
|
|
282
|
+
*/
|
|
283
|
+
export function impactAssessmentId(
|
|
284
|
+
{ graphId, graphDigest, targetId },
|
|
285
|
+
discriminatorParts = [],
|
|
286
|
+
) {
|
|
287
|
+
return `impact:${_hash(_canon([graphId, graphDigest, targetId, ...discriminatorParts]))}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A RuntimeObservation record's id (M5 deliverable #7, FR-505 §10.10) —
|
|
292
|
+
* NOT a DataFlowGraph v1 entity, mirrors impactAssessmentId's/scenarioId's
|
|
293
|
+
* own precedent exactly. Discriminated by (adapter, environment,
|
|
294
|
+
* windowStart, windowEnd) plus caller-supplied discriminatorParts, which
|
|
295
|
+
* the adapter fills with the observation's own attribute fingerprint —
|
|
296
|
+
* two observations of the SAME destination in the SAME environment and
|
|
297
|
+
* window are the same observation and must collapse to one id, which is
|
|
298
|
+
* what makes an accidental double-import idempotent at the record level.
|
|
299
|
+
*/
|
|
300
|
+
export function observationId(
|
|
301
|
+
{ adapter, environment, windowStart, windowEnd },
|
|
302
|
+
discriminatorParts = [],
|
|
303
|
+
) {
|
|
304
|
+
return `observation:${_hash(_canon([adapter, environment, windowStart, windowEnd, ...discriminatorParts]))}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One adapter IMPORT's id (M5 deliverable #7, FR-505 §10.10) — the store's
|
|
309
|
+
* own file key, mirroring graph-snapshot.js's commit key one dimension
|
|
310
|
+
* over (import, not commit; see that sub-project's scoping doc §4.4 for
|
|
311
|
+
* why commit-keying cannot express many observations per graph entity).
|
|
312
|
+
* `importedAt` is a per-run nonce, exactly the role snapshotId's own
|
|
313
|
+
* capturedAt plays: re-importing the same file must produce a NEW import
|
|
314
|
+
* file, never silently overwrite the earlier one, because the store is a
|
|
315
|
+
* directory of IMMUTABLE whole files.
|
|
316
|
+
*/
|
|
317
|
+
export function observationImportId(
|
|
318
|
+
{ adapter, source, environment, windowStart, windowEnd, importedAt },
|
|
319
|
+
discriminatorParts = [],
|
|
320
|
+
) {
|
|
321
|
+
return `obsimport:${_hash(_canon([adapter, source, environment, windowStart, windowEnd, importedAt, ...discriminatorParts]))}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A CrossRepoLink record's id (M5 deliverable #8, FR-304 "declared"
|
|
326
|
+
* half, §10.10) — NOT a DataFlowGraph v1 entity, mirrors
|
|
327
|
+
* `recipientProfileId`'s own precedent exactly: `(graphId, graphDigest,
|
|
328
|
+
* ...)` doubled for both endpoints. This is the concrete mechanism that
|
|
329
|
+
* makes "id collision is impossible by construction" real rather than
|
|
330
|
+
* aspirational: two links between structurally identical node shapes in
|
|
331
|
+
* two different repo pairs cannot collide, because both graphs' own
|
|
332
|
+
* `graphId`+`graphDigest` are baked into the id material on both sides —
|
|
333
|
+
* a bare node id string is never looked up against a merged or ambiguous
|
|
334
|
+
* set.
|
|
335
|
+
*/
|
|
336
|
+
export function crossRepoLinkId(
|
|
337
|
+
{
|
|
338
|
+
localGraphId, localGraphDigest, localNodeId,
|
|
339
|
+
remoteGraphId, remoteGraphDigest, remoteNodeId,
|
|
340
|
+
relationship,
|
|
341
|
+
},
|
|
342
|
+
discriminatorParts = [],
|
|
343
|
+
) {
|
|
344
|
+
return `crosslink:${_hash(_canon([
|
|
345
|
+
localGraphId, localGraphDigest, localNodeId,
|
|
346
|
+
remoteGraphId, remoteGraphDigest, remoteNodeId,
|
|
347
|
+
relationship, ...discriminatorParts,
|
|
348
|
+
]))}`;
|
|
349
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// impact-assessment.js — M5 deliverable #4 (FR-507 §10.10): the
|
|
2
|
+
// ImpactAssessment extension contract — the result of asking "what is
|
|
3
|
+
// reachable from this compromised node/edge/flow/data element, per the
|
|
4
|
+
// graph's own already-scanned evidence." NOT a DataFlowGraph v1 entity,
|
|
5
|
+
// mirrors recipient-profile.js's/scenario.js's own contract shape
|
|
6
|
+
// exactly (structural-only {valid, errors} validator, zero graph
|
|
7
|
+
// access at construction time).
|
|
8
|
+
//
|
|
9
|
+
// See docs/superpowers/plans/2026-09-02-data-flow-explorer-m5-blast-radius-impact-scoping.md
|
|
10
|
+
// for the full design reasoning, including why there is no
|
|
11
|
+
// affectedObligationIds field (ObligationMapping records are built on
|
|
12
|
+
// demand per compliance framework, not stored on the graph) and why
|
|
13
|
+
// `scope` is always 'possible' today (no runtime-corroboration layer
|
|
14
|
+
// exists yet).
|
|
15
|
+
|
|
16
|
+
export const IMPACT_VERSION = '1.0.0';
|
|
17
|
+
|
|
18
|
+
export const IMPACT_TARGET_KINDS = Object.freeze(['node', 'edge', 'flow', 'dataElement']);
|
|
19
|
+
|
|
20
|
+
// 'possible' is the only value any producer emits today — 'observed'
|
|
21
|
+
// is reserved for a future Digital Twin (M5 #7) increment with a real
|
|
22
|
+
// runtime-corroboration signal. Both are valid schema values now so
|
|
23
|
+
// that increment needs no breaking change to this contract later.
|
|
24
|
+
export const IMPACT_SCOPE_VALUES = Object.freeze(['possible', 'observed']);
|
|
25
|
+
|
|
26
|
+
// Discloses WHICH of the two genuinely different traversal semantics
|
|
27
|
+
// produced this record (final-review I2 fix) — `scope` alone cannot
|
|
28
|
+
// distinguish them, since both families emit `scope: 'possible'`.
|
|
29
|
+
// 'topology_reachable': a `node` target's topology-wide showAllPaths
|
|
30
|
+
// BFS — "everything this compromised node could push to." 'flow_
|
|
31
|
+
// restricted': an `edge`/`flow`/`dataElement` target's direct trace
|
|
32
|
+
// over the flows that actually carry it — never the topology-wide BFS.
|
|
33
|
+
// Without this field a JSON consumer cannot tell "exact carrier trace"
|
|
34
|
+
// from "topological presumption" apart, the exact silent-conflation
|
|
35
|
+
// this codebase's own disclosure discipline exists to prevent.
|
|
36
|
+
export const IMPACT_TRACE_KINDS = Object.freeze(['topology_reachable', 'flow_restricted']);
|
|
37
|
+
|
|
38
|
+
function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
|
|
39
|
+
function _isStringArray(v) { return Array.isArray(v) && v.every((x) => typeof x === 'string'); }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Structural validation only — mirrors validateRecipientProfile's/
|
|
43
|
+
* validateScenario's own {valid, errors} shape and "never throws"
|
|
44
|
+
* contract.
|
|
45
|
+
*/
|
|
46
|
+
export function validateImpactAssessment(record) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
const err = (p, message) => errors.push({ path: p, message });
|
|
49
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
50
|
+
err('$', 'ImpactAssessment record must be an object');
|
|
51
|
+
return { valid: false, errors };
|
|
52
|
+
}
|
|
53
|
+
if (!_isNonEmptyString(record.id) || !record.id.startsWith('impact:')) {
|
|
54
|
+
err('$.id', 'id is required and must start with "impact:"');
|
|
55
|
+
}
|
|
56
|
+
if (!_isNonEmptyString(record.version)) err('$.version', 'version is required');
|
|
57
|
+
if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
|
|
58
|
+
if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
|
|
59
|
+
if (!_isNonEmptyString(record.targetId)) err('$.targetId', 'targetId is required');
|
|
60
|
+
if (!IMPACT_TARGET_KINDS.includes(record.targetKind)) {
|
|
61
|
+
err('$.targetKind', `targetKind must be one of ${IMPACT_TARGET_KINDS.join('|')}`);
|
|
62
|
+
}
|
|
63
|
+
if (!IMPACT_SCOPE_VALUES.includes(record.scope)) {
|
|
64
|
+
err('$.scope', `scope must be one of ${IMPACT_SCOPE_VALUES.join('|')}`);
|
|
65
|
+
}
|
|
66
|
+
if (!IMPACT_TRACE_KINDS.includes(record.traceKind)) {
|
|
67
|
+
err('$.traceKind', `traceKind must be one of ${IMPACT_TRACE_KINDS.join('|')}`);
|
|
68
|
+
}
|
|
69
|
+
if (!_isStringArray(record.affectedNodeIds ?? [])) err('$.affectedNodeIds', 'affectedNodeIds must be an array of strings');
|
|
70
|
+
if (!_isStringArray(record.affectedEdgeIds ?? [])) err('$.affectedEdgeIds', 'affectedEdgeIds must be an array of strings');
|
|
71
|
+
if (!_isStringArray(record.affectedDataClasses ?? [])) err('$.affectedDataClasses', 'affectedDataClasses must be an array of strings');
|
|
72
|
+
if (!_isStringArray(record.affectedRecipientProfileIds ?? [])) err('$.affectedRecipientProfileIds', 'affectedRecipientProfileIds must be an array of strings');
|
|
73
|
+
if (!_isStringArray(record.coverageLimitations ?? [])) err('$.coverageLimitations', 'coverageLimitations must be an array of strings');
|
|
74
|
+
if (!_isNonEmptyString(record.generatedAt)) err('$.generatedAt', 'generatedAt is required');
|
|
75
|
+
return { valid: errors.length === 0, errors };
|
|
76
|
+
}
|