@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,400 @@
|
|
|
1
|
+
//
|
|
2
|
+
// path-store.js — the compact provenance DAG (Sub-project C, increment 4).
|
|
3
|
+
//
|
|
4
|
+
// Binding spec: DESIGN_PATH_PROVENANCE.md §14 (§14.1-§14.11). This module
|
|
5
|
+
// is a PURE CONSUMER of a hop-record stream — the 14-field shape §3/§13.0
|
|
6
|
+
// define, exactly as C1-C3 ship it. It does not run analysis, and it must
|
|
7
|
+
// NEVER import `engine.js`, `summaries.js`, or `driver.js` (§14.1): its
|
|
8
|
+
// only dependency is `ids.js`. This is a stronger, additional isolation
|
|
9
|
+
// boundary on top of the pre-existing "`src/lineage/` may import pure
|
|
10
|
+
// utilities from `src/dataflow/`, never that package's `engine.js`/
|
|
11
|
+
// `summaries.js`" rule, and it is what makes the store testable against a
|
|
12
|
+
// hand-built hop array with zero dependency on a real analysis run — a
|
|
13
|
+
// real project-wide driver run still emits ZERO hops today (no source
|
|
14
|
+
// registry, Sub-project D/E).
|
|
15
|
+
//
|
|
16
|
+
// Construction is cycle-safe BY CONSTRUCTION, not by discipline (§9.3,
|
|
17
|
+
// §14.6): one linear pass over the offered hops plus a per-group cross
|
|
18
|
+
// product, never a graph walk. There is no recursion anywhere in this
|
|
19
|
+
// file. The read API is deliberately traversal-free — every method below
|
|
20
|
+
// is an O(1)/O(degree) index lookup. Bounded backward reconstruction is
|
|
21
|
+
// C5's job and C5's alone.
|
|
22
|
+
//
|
|
23
|
+
|
|
24
|
+
import { provenanceNodeId, provenanceEdgeId } from './ids.js';
|
|
25
|
+
|
|
26
|
+
// §3 + §13.0's exact 14-field hop shape, in a FIXED order. Never derive a
|
|
27
|
+
// dedupe/discriminator key via `Object.keys(h)` — that is order-dependent,
|
|
28
|
+
// and would let two differently-key-ordered-but-identical hop objects hash
|
|
29
|
+
// to different keys. Listed explicitly so a hop with an ABSENT key (§3
|
|
30
|
+
// warns this is reachable for any emission path that bypasses
|
|
31
|
+
// `analyzeFunctionFieldIdentity`'s progressive stamping) is DETECTED here
|
|
32
|
+
// (`diagnostics().malformed`) instead of silently hashing to a different
|
|
33
|
+
// dedupe key than its fully-stamped twin — this is where §3's completeness
|
|
34
|
+
// guarantee becomes checkable rather than merely asserted (§14.6).
|
|
35
|
+
export const HOP_FIELDS = [
|
|
36
|
+
'kind', 'subKind', 'scope', 'dataElementId', 'fromPath', 'toPath',
|
|
37
|
+
'syntacticPath', 'nodeId', 'line', 'widenReason', 'lossReason',
|
|
38
|
+
'context', 'peerScope', 'peerContext',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const IN_KINDS = new Set(['production', 'selection']);
|
|
42
|
+
|
|
43
|
+
function hopDedupeKey(h) {
|
|
44
|
+
return HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function joinKeyOf(h) {
|
|
48
|
+
// §13.3's four-part join key, superseding §2.2's original three-part form.
|
|
49
|
+
return [h.scope, h.nodeId, h.dataElementId, h.context]
|
|
50
|
+
.map((p) => JSON.stringify(p ?? null)).join('|');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scopeKeyOf(scope, context) {
|
|
54
|
+
return `${JSON.stringify(scope ?? null)}|${JSON.stringify(context ?? null)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* §14.3: which node does an inbound half (`production`/`selection`) start
|
|
59
|
+
* at?
|
|
60
|
+
*
|
|
61
|
+
* Rules, in order:
|
|
62
|
+
* 1. `fromPath !== null` -> sourced, at `(scope, context, path, id)`.
|
|
63
|
+
* 2. `fromPath === null && peerScope !== null && lossReason === null` ->
|
|
64
|
+
* peer-sourced, at `(peerScope, peerContext, <return>, id)`. See §14.4
|
|
65
|
+
* — this is the single most load-bearing rule in the whole module.
|
|
66
|
+
* 3. otherwise -> annotation.
|
|
67
|
+
*/
|
|
68
|
+
export function classifyIn(h) {
|
|
69
|
+
if (h.fromPath !== null && h.fromPath !== undefined) {
|
|
70
|
+
return {
|
|
71
|
+
role: 'source',
|
|
72
|
+
crossScope: false,
|
|
73
|
+
node: {
|
|
74
|
+
kind: 'path', scope: h.scope, context: h.context,
|
|
75
|
+
path: h.fromPath, siteNodeId: null, dataElementId: h.dataElementId,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// §14.4, THE CORRECTION TO §2.2: a null `fromPath` with a non-null
|
|
80
|
+
// `peerScope` is NOT source-less. It is PEER-ADDRESSED — its source is
|
|
81
|
+
// the callee's own function-exit node. `lossReason === null` is the
|
|
82
|
+
// discriminator that keeps a §13.6 context-cap-degraded hop out of this
|
|
83
|
+
// branch: that hop names a callee whose body was never analyzed, so an
|
|
84
|
+
// edge from its (non-existent) exit node would be a fabricated origin —
|
|
85
|
+
// it falls through to annotation instead, exactly what §13.6 asked for.
|
|
86
|
+
if (h.peerScope !== null && h.peerScope !== undefined && h.lossReason == null) {
|
|
87
|
+
return {
|
|
88
|
+
role: 'source',
|
|
89
|
+
crossScope: true,
|
|
90
|
+
node: {
|
|
91
|
+
kind: 'return', scope: h.peerScope, context: h.peerContext,
|
|
92
|
+
path: null, siteNodeId: null, dataElementId: h.dataElementId,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { role: 'annotation', crossScope: false, node: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* §14.3: which node does an outbound half (`write-out`) land at?
|
|
101
|
+
*
|
|
102
|
+
* Rules, in order:
|
|
103
|
+
* 1. `toPath !== null && peerScope !== null` -> peer-targeted, at
|
|
104
|
+
* `(peerScope, peerContext, toPath, id)` — Q1's answer.
|
|
105
|
+
* 2. `toPath !== null` -> targeted, at `(scope, context, toPath, id)`.
|
|
106
|
+
* 3. `subKind === 'return'` -> the `return` terminal (per function-context).
|
|
107
|
+
* 4. `subKind === 'call-arg'` -> the `escape` terminal (per CFG node).
|
|
108
|
+
* 5. `lossReason !== null` -> the `loss` terminal (per CFG node).
|
|
109
|
+
* 6. otherwise -> unclassified, recorded and never silently dropped.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyOut(h) {
|
|
112
|
+
if (h.toPath !== null && h.toPath !== undefined) {
|
|
113
|
+
const cross = h.peerScope !== null && h.peerScope !== undefined;
|
|
114
|
+
return {
|
|
115
|
+
role: 'target',
|
|
116
|
+
crossScope: cross,
|
|
117
|
+
node: {
|
|
118
|
+
kind: 'path',
|
|
119
|
+
scope: cross ? h.peerScope : h.scope,
|
|
120
|
+
context: cross ? h.peerContext : h.context,
|
|
121
|
+
path: h.toPath, siteNodeId: null, dataElementId: h.dataElementId,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Terminal out-halves. `return` is per-(scope, context) — it must be,
|
|
126
|
+
// because a `call-resolved` hop addresses it with only
|
|
127
|
+
// (peerScope, peerContext) and no node id, so every return site of a
|
|
128
|
+
// context must aggregate into one exit node. `escape`/`loss` are
|
|
129
|
+
// per-CFG-node, since nothing addresses them from elsewhere and the
|
|
130
|
+
// extra precision tells a reader WHICH call/assignment ended the path.
|
|
131
|
+
if (h.subKind === 'return') {
|
|
132
|
+
return {
|
|
133
|
+
role: 'target', crossScope: false,
|
|
134
|
+
node: { kind: 'return', scope: h.scope, context: h.context, path: null, siteNodeId: null, dataElementId: h.dataElementId },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (h.subKind === 'call-arg') {
|
|
138
|
+
return {
|
|
139
|
+
role: 'target', crossScope: false,
|
|
140
|
+
node: { kind: 'escape', scope: h.scope, context: h.context, path: null, siteNodeId: h.nodeId, dataElementId: h.dataElementId },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (h.lossReason != null) {
|
|
144
|
+
return {
|
|
145
|
+
role: 'target', crossScope: false,
|
|
146
|
+
node: { kind: 'loss', scope: h.scope, context: h.context, path: null, siteNodeId: h.nodeId, dataElementId: h.dataElementId },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { role: 'unclassified', crossScope: false, node: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class PathStore {
|
|
153
|
+
constructor() {
|
|
154
|
+
this._groups = new Map();
|
|
155
|
+
this._seen = new Set();
|
|
156
|
+
this._truncations = new Map();
|
|
157
|
+
this._malformed = [];
|
|
158
|
+
this._unclassified = [];
|
|
159
|
+
// Every peer-sourced in-half accepted so far (crossScope === true from
|
|
160
|
+
// classifyIn), tracked so diagnostics() can report §14.4's disclosed
|
|
161
|
+
// stream-completeness gap (`orphanedPeerSources`) at build-finalize
|
|
162
|
+
// time without a second pass over the raw hops.
|
|
163
|
+
this._peerSourced = [];
|
|
164
|
+
this._stats = { hopsOffered: 0, hopsAccepted: 0 };
|
|
165
|
+
this._built = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Accepts one hop record. Returns `true` if it was newly accepted
|
|
170
|
+
* (added to a group), `false` if it was an exact repeat of an
|
|
171
|
+
* already-accepted hop (Decision 8's worklist re-emission, collapsed
|
|
172
|
+
* here — §14.6 dedup boundary 1). A malformed hop (missing a required
|
|
173
|
+
* field) is still recorded in `diagnostics().malformed`, never thrown.
|
|
174
|
+
*/
|
|
175
|
+
addHop(hop) {
|
|
176
|
+
this._stats.hopsOffered += 1;
|
|
177
|
+
const missing = HOP_FIELDS.filter((f) => !Object.prototype.hasOwnProperty.call(hop, f));
|
|
178
|
+
if (missing.length > 0) {
|
|
179
|
+
this._malformed.push({ hop, missing });
|
|
180
|
+
}
|
|
181
|
+
const k = hopDedupeKey(hop);
|
|
182
|
+
if (this._seen.has(k)) return false;
|
|
183
|
+
this._seen.add(k);
|
|
184
|
+
this._stats.hopsAccepted += 1;
|
|
185
|
+
|
|
186
|
+
const jk = joinKeyOf(hop);
|
|
187
|
+
let g = this._groups.get(jk);
|
|
188
|
+
if (!g) {
|
|
189
|
+
g = {
|
|
190
|
+
scope: hop.scope, nodeId: hop.nodeId, dataElementId: hop.dataElementId,
|
|
191
|
+
context: hop.context, line: hop.line ?? null,
|
|
192
|
+
in: [], out: [], annotations: [],
|
|
193
|
+
};
|
|
194
|
+
this._groups.set(jk, g);
|
|
195
|
+
}
|
|
196
|
+
if (IN_KINDS.has(hop.kind)) {
|
|
197
|
+
const c = classifyIn(hop);
|
|
198
|
+
if (c.role === 'source') {
|
|
199
|
+
g.in.push({ hop, ...c });
|
|
200
|
+
if (c.crossScope) this._peerSourced.push({ hop, node: c.node });
|
|
201
|
+
} else {
|
|
202
|
+
g.annotations.push(hop);
|
|
203
|
+
}
|
|
204
|
+
} else if (hop.kind === 'write-out') {
|
|
205
|
+
const c = classifyOut(hop);
|
|
206
|
+
if (c.role === 'target') g.out.push({ hop, ...c });
|
|
207
|
+
else this._unclassified.push(hop);
|
|
208
|
+
} else {
|
|
209
|
+
this._unclassified.push(hop);
|
|
210
|
+
}
|
|
211
|
+
this._built = null;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Bulk `addHop`. Returns the count newly accepted. */
|
|
216
|
+
addHops(hops) {
|
|
217
|
+
let n = 0;
|
|
218
|
+
for (const h of hops) if (this.addHop(h)) n += 1;
|
|
219
|
+
return n;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* §9.5's reserved out-of-band channel. Analysis-level (not per-hop)
|
|
224
|
+
* truncation — an `ITER_BUDGET` break in `analyzeFunctionFieldIdentity`
|
|
225
|
+
* — has no hop representation and deliberately must not get one (§2.2's
|
|
226
|
+
* three-kind taxonomy is closed, per §14.8). Marks every node and edge
|
|
227
|
+
* in `(scope, context)` as `truncated: true`.
|
|
228
|
+
*/
|
|
229
|
+
markTruncated(scope, context, reason) {
|
|
230
|
+
this._truncations.set(scopeKeyOf(scope, context), reason);
|
|
231
|
+
this._built = null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_build() {
|
|
235
|
+
if (this._built) return this._built;
|
|
236
|
+
const nodes = new Map();
|
|
237
|
+
const edges = new Map();
|
|
238
|
+
const outIndex = new Map();
|
|
239
|
+
const inIndex = new Map();
|
|
240
|
+
|
|
241
|
+
const intern = (desc) => {
|
|
242
|
+
const id = provenanceNodeId(desc);
|
|
243
|
+
let n = nodes.get(id);
|
|
244
|
+
if (!n) {
|
|
245
|
+
n = {
|
|
246
|
+
id, ...desc,
|
|
247
|
+
truncated: this._truncations.has(scopeKeyOf(desc.scope, desc.context)),
|
|
248
|
+
};
|
|
249
|
+
nodes.set(id, n);
|
|
250
|
+
}
|
|
251
|
+
return n;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
for (const g of this._groups.values()) {
|
|
255
|
+
let sources = g.in;
|
|
256
|
+
let originated = false;
|
|
257
|
+
// Populated only when `originated` below — the union of every
|
|
258
|
+
// annotation's widen/loss reason at this key, so an edge originating
|
|
259
|
+
// here never loses a reason just because it wasn't the first
|
|
260
|
+
// annotation delivered (final whole-branch review finding 6: picking
|
|
261
|
+
// bare `g.annotations[0]` made both the representative kind/subKind
|
|
262
|
+
// AND the edge's own widenReasons/lossReasons depend on hop delivery
|
|
263
|
+
// order, contradicting §14.6's own "order-insensitive" guarantee —
|
|
264
|
+
// unreachable via any real fixture today, since `origin` is
|
|
265
|
+
// hand-fixture-only per §14.2, but §14.2 also names this as the
|
|
266
|
+
// exact shape a Sub-project D source registry will produce first).
|
|
267
|
+
let originWidenReasons = [];
|
|
268
|
+
let originLossReasons = [];
|
|
269
|
+
if (sources.length === 0 && g.annotations.length > 0) {
|
|
270
|
+
// §2.2's annotation rule, in the ONE direction it is still right:
|
|
271
|
+
// with no non-null in-half anywhere at this key, the annotation is
|
|
272
|
+
// itself the origin of the value ("no prior aliasing source").
|
|
273
|
+
originated = true;
|
|
274
|
+
originWidenReasons = [...new Set(g.annotations.map((a) => a.widenReason).filter((r) => r != null))].sort();
|
|
275
|
+
originLossReasons = [...new Set(g.annotations.map((a) => a.lossReason).filter((r) => r != null))].sort();
|
|
276
|
+
// The representative annotation for `kind`/`subKind` display is
|
|
277
|
+
// chosen by a content-derived sort key, not delivery position, so
|
|
278
|
+
// it is stable across re-deliveries in any order.
|
|
279
|
+
const representative = [...g.annotations].sort((a, b) => {
|
|
280
|
+
const ka = `${a.kind}|${a.subKind}`;
|
|
281
|
+
const kb = `${b.kind}|${b.subKind}`;
|
|
282
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
283
|
+
})[0];
|
|
284
|
+
sources = [{
|
|
285
|
+
hop: representative, role: 'source', crossScope: false,
|
|
286
|
+
node: { kind: 'origin', scope: g.scope, context: g.context, path: null, siteNodeId: g.nodeId, dataElementId: g.dataElementId },
|
|
287
|
+
}];
|
|
288
|
+
}
|
|
289
|
+
// §9.1's correlation ambiguity, extended to the call boundary by
|
|
290
|
+
// §13.2's own disclosure — measured PER PAIRING, not per group
|
|
291
|
+
// (§14.7's correction to §9.1's original group-level measure, which
|
|
292
|
+
// over-marks: it fires on every edge at a resolved call site,
|
|
293
|
+
// including the two that are exactly right, because the argument's
|
|
294
|
+
// in-half and the return's in-half share one join key). Counting
|
|
295
|
+
// only the pairings the store would ACTUALLY form (after the peer x
|
|
296
|
+
// peer exclusion) recovers the distinction at zero cost.
|
|
297
|
+
const pairable = (s, o) => !(s.crossScope && o.crossScope);
|
|
298
|
+
const sourcesFor = (o) => new Set(sources.filter((s) => pairable(s, o)).map((s) => provenanceNodeId(s.node)));
|
|
299
|
+
const targetsFor = (s) => new Set(g.out.filter((o) => pairable(s, o)).map((o) => provenanceNodeId(o.node)));
|
|
300
|
+
|
|
301
|
+
const annotations = g.annotations.map((a) => ({
|
|
302
|
+
kind: a.kind, subKind: a.subKind,
|
|
303
|
+
widenReason: a.widenReason ?? null, lossReason: a.lossReason ?? null,
|
|
304
|
+
peerScope: a.peerScope ?? null, peerContext: a.peerContext ?? null,
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
for (const s of sources) {
|
|
308
|
+
for (const o of g.out) {
|
|
309
|
+
// §14.3: an edge with BOTH endpoints in the peer's namespace is
|
|
310
|
+
// never a caller-side fact — it is a transition entirely inside
|
|
311
|
+
// the callee, which the callee's own hops already record, and
|
|
312
|
+
// materializing it manufactures a callee-return -> callee-param
|
|
313
|
+
// cycle that no program ever executed.
|
|
314
|
+
if (!pairable(s, o)) continue;
|
|
315
|
+
const from = intern(s.node);
|
|
316
|
+
const to = intern(o.node);
|
|
317
|
+
const widenReasons = originated
|
|
318
|
+
? [...new Set([...originWidenReasons, o.hop.widenReason].filter((r) => r != null))].sort()
|
|
319
|
+
: [...new Set([s.hop.widenReason, o.hop.widenReason].filter((r) => r != null))].sort();
|
|
320
|
+
const lossReasons = originated
|
|
321
|
+
? [...new Set([...originLossReasons, o.hop.lossReason].filter((r) => r != null))].sort()
|
|
322
|
+
: [...new Set([s.hop.lossReason, o.hop.lossReason].filter((r) => r != null))].sort();
|
|
323
|
+
const desc = {
|
|
324
|
+
fromNodeId: from.id, toNodeId: to.id, dataElementId: g.dataElementId,
|
|
325
|
+
scope: g.scope, context: g.context, siteNodeId: g.nodeId,
|
|
326
|
+
inKind: s.hop.kind, inSubKind: s.hop.subKind,
|
|
327
|
+
outKind: o.hop.kind, outSubKind: o.hop.subKind,
|
|
328
|
+
widenReasons, lossReasons,
|
|
329
|
+
};
|
|
330
|
+
const id = provenanceEdgeId(desc);
|
|
331
|
+
if (!edges.has(id)) {
|
|
332
|
+
edges.set(id, {
|
|
333
|
+
id, ...desc, line: g.line,
|
|
334
|
+
crossScope: s.crossScope || o.crossScope,
|
|
335
|
+
originated,
|
|
336
|
+
ambiguousCorrelation: sourcesFor(o).size >= 2 && targetsFor(s).size >= 2,
|
|
337
|
+
annotations,
|
|
338
|
+
truncated: this._truncations.has(scopeKeyOf(g.scope, g.context)),
|
|
339
|
+
});
|
|
340
|
+
if (!outIndex.has(from.id)) outIndex.set(from.id, new Set());
|
|
341
|
+
if (!inIndex.has(to.id)) inIndex.set(to.id, new Set());
|
|
342
|
+
outIndex.get(from.id).add(id);
|
|
343
|
+
inIndex.get(to.id).add(id);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// §14.4's disclosed stream-completeness gap / §14.10 item 10: a
|
|
350
|
+
// peer-sourced hop (lossReason: null, non-null peerScope) whose named
|
|
351
|
+
// (peerScope, peerContext, <return>, dataElementId) node has ZERO real
|
|
352
|
+
// in-edges once the whole stream has been ingested — reachable via a
|
|
353
|
+
// cache warmed by a no-recorder run and reused by a later
|
|
354
|
+
// recorder-attached run (driver.js's own returned cache). Detected here
|
|
355
|
+
// via the store's own inIndex at build-finalize time. Recorded, never
|
|
356
|
+
// fabricated into an origin, never dropped.
|
|
357
|
+
const orphanedPeerSources = [];
|
|
358
|
+
for (const { hop, node } of this._peerSourced) {
|
|
359
|
+
const id = provenanceNodeId(node);
|
|
360
|
+
const inEdges = inIndex.get(id);
|
|
361
|
+
if (!inEdges || inEdges.size === 0) {
|
|
362
|
+
orphanedPeerSources.push({ hop, nodeId: id });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this._built = { nodes, edges, outIndex, inIndex, orphanedPeerSources };
|
|
367
|
+
return this._built;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- minimal read API (§14.6/§14.9). C4 ships NO traversal: every
|
|
371
|
+
// method below is an O(1)/O(degree) index lookup, so no code in this
|
|
372
|
+
// increment can recurse into a cycle. Bounded backward reconstruction is
|
|
373
|
+
// C5's job and C5's alone.
|
|
374
|
+
nodes() { return [...this._build().nodes.values()]; }
|
|
375
|
+
edges() { return [...this._build().edges.values()]; }
|
|
376
|
+
getNode(id) { return this._build().nodes.get(id) ?? null; }
|
|
377
|
+
getEdge(id) { return this._build().edges.get(id) ?? null; }
|
|
378
|
+
nodeIdFor(desc) { return provenanceNodeId(desc); }
|
|
379
|
+
edgesFrom(nodeId) { return [...(this._build().outIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
|
|
380
|
+
edgesTo(nodeId) { return [...(this._build().inIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
|
|
381
|
+
hasEdge(fromId, toId) { return this.edgesFrom(fromId).some((e) => e.toNodeId === toId); }
|
|
382
|
+
stats() { return { ...this._stats, groups: this._groups.size, nodes: this._build().nodes.size, edges: this._build().edges.size }; }
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Four buckets, all "recorded, never thrown, never silently dropped"
|
|
386
|
+
* (§14.9/§14.10 item 10):
|
|
387
|
+
* - `malformed`: hops missing a required field.
|
|
388
|
+
* - `unclassified`: out-halves matching none of §14.3's rules.
|
|
389
|
+
* - `truncations`: `(scope, context)` pairs marked via `markTruncated`.
|
|
390
|
+
* - `orphanedPeerSources`: §14.4's disclosed stream-completeness gap.
|
|
391
|
+
*/
|
|
392
|
+
diagnostics() {
|
|
393
|
+
return {
|
|
394
|
+
malformed: this._malformed,
|
|
395
|
+
unclassified: this._unclassified,
|
|
396
|
+
truncations: [...this._truncations.entries()],
|
|
397
|
+
orphanedPeerSources: this._build().orphanedPeerSources,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Protection verdict model (Data Flow Explorer PRD section 14.1 + 8.4).
|
|
3
|
+
// Every protection dimension carries two INDEPENDENT fields: a verdict
|
|
4
|
+
// and an evidence grade — "declared protected" must never render
|
|
5
|
+
// identically to code-and-configuration-proven protection (PRD 14.1).
|
|
6
|
+
//
|
|
7
|
+
// This module defines the model only (enums, the empty/default shape,
|
|
8
|
+
// and the pure aggregation function). The analyzers that actually DECIDE
|
|
9
|
+
// a verdict per edge (transit/at-rest/handling — PRD FR-401 through
|
|
10
|
+
// FR-403) are Milestone 2 (DFG-010), not this module.
|
|
11
|
+
|
|
12
|
+
export const PROTECTION_VERDICTS = Object.freeze(['protected', 'unprotected', 'unknown', 'not_applicable', 'not_assessed']);
|
|
13
|
+
|
|
14
|
+
export const EVIDENCE_GRADES = Object.freeze(['runtime', 'code_and_config', 'code', 'config', 'declared', 'assumed', 'manual', 'none']);
|
|
15
|
+
|
|
16
|
+
export const PROTECTION_DIMENSIONS = Object.freeze(['transit', 'atRest', 'handling']);
|
|
17
|
+
|
|
18
|
+
export function emptyProtection() {
|
|
19
|
+
const dim = () => ({ verdict: 'not_assessed', evidenceGrade: 'none' });
|
|
20
|
+
return { transit: dim(), atRest: dim(), handling: dim() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isValidProtectionDimension(d) {
|
|
24
|
+
if (!d || typeof d !== 'object') return false;
|
|
25
|
+
return PROTECTION_VERDICTS.includes(d.verdict) && EVIDENCE_GRADES.includes(d.evidenceGrade);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// PRD section 8.4: "For an aggregated path, visible risk precedence is
|
|
29
|
+
// unprotected/prohibited -> mixed -> unknown/manual_required ->
|
|
30
|
+
// protected/permitted -> not_assessed." Lower index = higher precedence
|
|
31
|
+
// (wins the aggregation). 'mixed' is not itself in PROTECTION_VERDICTS —
|
|
32
|
+
// it is a caller-supplied aggregate state from an upstream step (e.g. "one
|
|
33
|
+
// branch protected, one branch unprotected") that this function's own
|
|
34
|
+
// ranking table must still place correctly among the five base verdicts.
|
|
35
|
+
const _PRECEDENCE = ['unprotected', 'mixed', 'unknown', 'protected', 'not_applicable', 'not_assessed'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reduce a set of verdicts (protection verdicts, or 'mixed') to the single
|
|
39
|
+
* highest-precedence one. Never guesses: an empty array is 'not_assessed',
|
|
40
|
+
* and an unrecognized verdict throws rather than silently sorting last —
|
|
41
|
+
* a typo here must not quietly rank as "safest".
|
|
42
|
+
*/
|
|
43
|
+
export function aggregateVerdicts(verdicts) {
|
|
44
|
+
if (!Array.isArray(verdicts) || verdicts.length === 0) return 'not_assessed';
|
|
45
|
+
let best = null;
|
|
46
|
+
let bestRank = Infinity;
|
|
47
|
+
for (const v of verdicts) {
|
|
48
|
+
const rank = _PRECEDENCE.indexOf(v);
|
|
49
|
+
if (rank === -1) throw new Error(`aggregateVerdicts: unrecognized verdict "${v}"`);
|
|
50
|
+
if (rank < bestRank) { bestRank = rank; best = v; }
|
|
51
|
+
}
|
|
52
|
+
return best;
|
|
53
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// recipient-profile.js — Milestone 4 sub-project: the RecipientProfile
|
|
2
|
+
// extension contract (FR-506 §7.12, "Third-Party and Cross-Border
|
|
3
|
+
// Intelligence", PRD §10.10's own field list, PRD lines 541-548).
|
|
4
|
+
//
|
|
5
|
+
// A PURE schema/validation module for RecipientProfile records — mirrors
|
|
6
|
+
// obligation-mapping.js's own STRUCTURE closely: a §10.10 extension
|
|
7
|
+
// record ("associated with, but not required inside" the immutable base
|
|
8
|
+
// graph, per PRD §10.10) — never added to dataflow-graph.schema.json,
|
|
9
|
+
// never routed through validate.js's validateGraph(), never given a
|
|
10
|
+
// node:/edge:/flow:/data: canonical ID. See ids.js's recipientProfileId()
|
|
11
|
+
// for the id scheme, mirroring obligationId()'s own precedent exactly (a
|
|
12
|
+
// real, stable-ID'd extension record deliberately outside the
|
|
13
|
+
// node:/edge:/flow: family).
|
|
14
|
+
//
|
|
15
|
+
// This task does NOT populate real records from real data — that is two
|
|
16
|
+
// later, separate tasks (a small curated technical-provider catalog,
|
|
17
|
+
// code-derived; and an operator-declared config file, for facts code can
|
|
18
|
+
// never determine on its own, like legal entity/jurisdiction/DPA status).
|
|
19
|
+
// This file only defines what a valid record looks like.
|
|
20
|
+
//
|
|
21
|
+
// Two deliberate, disclosed departures from obligation-mapping.js's own
|
|
22
|
+
// precedent, both real architectural decisions, not oversights:
|
|
23
|
+
//
|
|
24
|
+
// 1. PER-FIELD evidence typing, not ObligationMapping's single
|
|
25
|
+
// record-level `factType`. ObligationMapping is one predicate
|
|
26
|
+
// evaluated once, so one factType describing how that predicate's
|
|
27
|
+
// truth was established is enough. A RecipientProfile is a bag of
|
|
28
|
+
// independent facts about one external recipient, populated from TWO
|
|
29
|
+
// genuinely different sources on the SAME record: a curated technical
|
|
30
|
+
// catalog can code-derive `provider`/`serviceType`/`technicalEndpoint`
|
|
31
|
+
// from what the code actually calls, but `legalEntity`/`jurisdiction`-
|
|
32
|
+
// adjacent facts like `dpaStatus`/`transferMechanism`/etc. can only
|
|
33
|
+
// ever be operator-declared — no code path can ever discover a
|
|
34
|
+
// vendor's DPA status by reading source. Collapsing that onto one
|
|
35
|
+
// record-level factType would force every profile to claim either
|
|
36
|
+
// "everything here is code-derived" (false for the declared fields) or
|
|
37
|
+
// "everything here is declared" (throws away the real code-derived
|
|
38
|
+
// provenance the catalog module DOES have) — so `fieldEvidence` gives
|
|
39
|
+
// each populated field its own `{factType, source}` pair instead. This
|
|
40
|
+
// is what makes "never fabricate a fact with no disclosed source" a
|
|
41
|
+
// STRUCTURALLY enforced property of this record shape, not merely a
|
|
42
|
+
// convention repeated from ObligationMapping without adaptation.
|
|
43
|
+
//
|
|
44
|
+
// 2. This file imports `OBLIGATION_FACT_TYPES` from `./obligation-mapping.js`
|
|
45
|
+
// — the first time one §10.10 extension-contract module imports from
|
|
46
|
+
// another. (obligation-mapping.js itself has zero imports, by design,
|
|
47
|
+
// per its own header comment — that is a property of THAT module, not
|
|
48
|
+
// a rule this file is bound by.) The fact-type vocabulary a graph fact
|
|
49
|
+
// claim can carry (code_inferred/config_correlated/runtime_observed/
|
|
50
|
+
// declared/manual/hypothetical) is one general concept with nothing
|
|
51
|
+
// obligation-specific about it — it is already this codebase's real,
|
|
52
|
+
// shipped vocabulary for "how was this fact established." Reusing it
|
|
53
|
+
// here is exactly what this package's own CLAUDE.md "Conventions"
|
|
54
|
+
// section already requires ("every enum here is a single source of
|
|
55
|
+
// truth for its concept") — minting a second, equivalent enum under a
|
|
56
|
+
// new name would violate that rule, not respect it.
|
|
57
|
+
|
|
58
|
+
import { OBLIGATION_FACT_TYPES } from './obligation-mapping.js';
|
|
59
|
+
|
|
60
|
+
export const RECIPIENT_PROCESSOR_ROLES = Object.freeze(['processor', 'controller', 'joint_controller', 'unknown']);
|
|
61
|
+
export const RECIPIENT_DPA_STATUSES = Object.freeze(['in_place', 'not_in_place', 'unknown']);
|
|
62
|
+
export const RECIPIENT_CONFIDENCE_LEVELS = Object.freeze(['high', 'medium', 'low']);
|
|
63
|
+
|
|
64
|
+
// The complete set of "real recipient fact" fields, per the task brief's
|
|
65
|
+
// own field list (PRD lines 541-548 — no fewer). Every field in this list
|
|
66
|
+
// whose value is non-null (scalar) or non-empty (array) MUST have a
|
|
67
|
+
// matching `fieldEvidence` key naming a real factType. Record-level
|
|
68
|
+
// metadata — `contributingGraphIds`/`confidence`/`owner`/`reviewDate`/
|
|
69
|
+
// `conflicts`/`expiration` — is deliberately NOT in this list: it
|
|
70
|
+
// describes the RECORD, not a recipient fact, and needs no per-field
|
|
71
|
+
// provenance tag of its own (mirrors ObligationMapping's own
|
|
72
|
+
// `contributingGraphIds` field, which carries no factType either).
|
|
73
|
+
export const RECIPIENT_FACT_FIELDS = Object.freeze([
|
|
74
|
+
'technicalEndpoint', 'provider', 'serviceType', 'legalEntity',
|
|
75
|
+
'processorRole', 'servicePurpose', 'subprocessorChain',
|
|
76
|
+
'processingCountries', 'dataResidencyCommitment', 'observedRegion',
|
|
77
|
+
'dpaStatus', 'transferMechanism', 'transferImpactReviewStatus',
|
|
78
|
+
'retentionCommitment',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const _ARRAY_FACT_FIELDS = new Set(['subprocessorChain', 'processingCountries']);
|
|
82
|
+
|
|
83
|
+
function _isNonEmptyString(v) {
|
|
84
|
+
return typeof v === 'string' && v.length > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _isStringOrNull(v) {
|
|
88
|
+
return v === null || v === undefined || typeof v === 'string';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _isStringArray(v) {
|
|
92
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _isPopulated(field, value) {
|
|
96
|
+
if (_ARRAY_FACT_FIELDS.has(field)) return Array.isArray(value) && value.length > 0;
|
|
97
|
+
return value !== null && value !== undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Structural validation only — no cross-reference into any real graph
|
|
102
|
+
* (this module reuses only a shared enum from obligation-mapping.js; it
|
|
103
|
+
* has zero graph access by design). Returns {valid, errors} — errors is
|
|
104
|
+
* an array of {path, message}, mirroring validateObligationMapping's own
|
|
105
|
+
* shape exactly. Never throws.
|
|
106
|
+
*/
|
|
107
|
+
export function validateRecipientProfile(record) {
|
|
108
|
+
const errors = [];
|
|
109
|
+
const err = (path, message) => errors.push({ path, message });
|
|
110
|
+
|
|
111
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
112
|
+
err('$', 'recipient profile record must be an object');
|
|
113
|
+
return { valid: false, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!_isNonEmptyString(record.id) || !record.id.startsWith('recipient:')) {
|
|
117
|
+
err('$.id', 'id is required and must start with "recipient:"');
|
|
118
|
+
}
|
|
119
|
+
if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
|
|
120
|
+
if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
|
|
121
|
+
if (!_isNonEmptyString(record.recipientKey)) err('$.recipientKey', 'recipientKey is required');
|
|
122
|
+
|
|
123
|
+
if (!_isStringOrNull(record.technicalEndpoint)) err('$.technicalEndpoint', 'technicalEndpoint must be a string or null');
|
|
124
|
+
if (!_isStringOrNull(record.provider)) err('$.provider', 'provider must be a string or null');
|
|
125
|
+
if (!_isStringOrNull(record.serviceType)) err('$.serviceType', 'serviceType must be a string or null');
|
|
126
|
+
if (!_isStringOrNull(record.legalEntity)) err('$.legalEntity', 'legalEntity must be a string or null');
|
|
127
|
+
if (!_isStringOrNull(record.servicePurpose)) err('$.servicePurpose', 'servicePurpose must be a string or null');
|
|
128
|
+
if (!_isStringOrNull(record.dataResidencyCommitment)) err('$.dataResidencyCommitment', 'dataResidencyCommitment must be a string or null');
|
|
129
|
+
if (!_isStringOrNull(record.observedRegion)) err('$.observedRegion', 'observedRegion must be a string or null');
|
|
130
|
+
if (!_isStringOrNull(record.transferMechanism)) err('$.transferMechanism', 'transferMechanism must be a string or null');
|
|
131
|
+
if (!_isStringOrNull(record.transferImpactReviewStatus)) err('$.transferImpactReviewStatus', 'transferImpactReviewStatus must be a string or null');
|
|
132
|
+
if (!_isStringOrNull(record.retentionCommitment)) err('$.retentionCommitment', 'retentionCommitment must be a string or null');
|
|
133
|
+
if (!_isStringOrNull(record.owner)) err('$.owner', 'owner must be a string or null');
|
|
134
|
+
if (!_isStringOrNull(record.reviewDate)) err('$.reviewDate', 'reviewDate must be a string or null');
|
|
135
|
+
if (!_isStringOrNull(record.expiration)) err('$.expiration', 'expiration must be a string or null');
|
|
136
|
+
|
|
137
|
+
if (record.processorRole !== null && record.processorRole !== undefined && !RECIPIENT_PROCESSOR_ROLES.includes(record.processorRole)) {
|
|
138
|
+
err('$.processorRole', `unrecognized processorRole "${record.processorRole}" — must be null or one of ${RECIPIENT_PROCESSOR_ROLES.join('|')}`);
|
|
139
|
+
}
|
|
140
|
+
if (record.dpaStatus !== null && record.dpaStatus !== undefined && !RECIPIENT_DPA_STATUSES.includes(record.dpaStatus)) {
|
|
141
|
+
err('$.dpaStatus', `unrecognized dpaStatus "${record.dpaStatus}" — must be null or one of ${RECIPIENT_DPA_STATUSES.join('|')}`);
|
|
142
|
+
}
|
|
143
|
+
if (record.confidence !== null && record.confidence !== undefined && !RECIPIENT_CONFIDENCE_LEVELS.includes(record.confidence)) {
|
|
144
|
+
err('$.confidence', `unrecognized confidence "${record.confidence}" — must be null or one of ${RECIPIENT_CONFIDENCE_LEVELS.join('|')}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!_isStringArray(record.subprocessorChain ?? [])) err('$.subprocessorChain', 'subprocessorChain must be an array of strings');
|
|
148
|
+
if (!_isStringArray(record.contributingGraphIds ?? [])) err('$.contributingGraphIds', 'contributingGraphIds must be an array of strings');
|
|
149
|
+
if (!_isStringArray(record.conflicts ?? [])) err('$.conflicts', 'conflicts must be an array of strings');
|
|
150
|
+
|
|
151
|
+
const countries = record.processingCountries ?? [];
|
|
152
|
+
if (!_isStringArray(countries)) {
|
|
153
|
+
err('$.processingCountries', 'processingCountries must be an array of strings');
|
|
154
|
+
} else {
|
|
155
|
+
countries.forEach((c, i) => {
|
|
156
|
+
if (!/^[A-Z]{2}$/.test(c)) {
|
|
157
|
+
err(`$.processingCountries[${i}]`, `processingCountries entry "${c}" must be a 2-uppercase-letter ISO-3166-alpha-2-shaped string`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The load-bearing structural rule: every populated fact field must
|
|
163
|
+
// have a matching fieldEvidence entry naming a real factType from
|
|
164
|
+
// OBLIGATION_FACT_TYPES, and every fieldEvidence key must correspond to
|
|
165
|
+
// a real fact field — a typo'd key would otherwise silently produce an
|
|
166
|
+
// unenforced, orphaned evidence entry.
|
|
167
|
+
if (!record.fieldEvidence || typeof record.fieldEvidence !== 'object' || Array.isArray(record.fieldEvidence)) {
|
|
168
|
+
err('$.fieldEvidence', 'fieldEvidence is required and must be an object');
|
|
169
|
+
} else {
|
|
170
|
+
for (const field of RECIPIENT_FACT_FIELDS) {
|
|
171
|
+
if (!_isPopulated(field, record[field])) continue; // null/empty needs no evidence — nothing to attribute
|
|
172
|
+
const ev = record.fieldEvidence[field];
|
|
173
|
+
if (!ev || typeof ev !== 'object' || Array.isArray(ev)) {
|
|
174
|
+
err(`$.fieldEvidence.${field}`, `fieldEvidence.${field} is required because "${field}" is populated`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!OBLIGATION_FACT_TYPES.includes(ev.factType)) {
|
|
178
|
+
err(`$.fieldEvidence.${field}.factType`, `unrecognized factType "${ev.factType}" — must be one of ${OBLIGATION_FACT_TYPES.join('|')}`);
|
|
179
|
+
}
|
|
180
|
+
if (!_isStringOrNull(ev.source)) {
|
|
181
|
+
err(`$.fieldEvidence.${field}.source`, `fieldEvidence.${field}.source must be a string or null`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const key of Object.keys(record.fieldEvidence)) {
|
|
185
|
+
if (!RECIPIENT_FACT_FIELDS.includes(key)) {
|
|
186
|
+
err(`$.fieldEvidence.${key}`, `fieldEvidence has an orphaned key "${key}" — not a real recipient-profile fact field`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { valid: errors.length === 0, errors };
|
|
192
|
+
}
|