@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1
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 +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16182
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
export const id = 1665;
|
|
2
|
+
export const ids = [1665,4547];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 61665:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ planCuts: () => (/* binding */ planCuts)
|
|
10
|
+
/* harmony export */ });
|
|
11
|
+
/* unused harmony exports UNCUTTABLE, CONTROL_SEMANTICS, maxFlowMinCut, screenCandidates, buildCutNetwork, candidatesToOperations, validateCutSet, planSharedControls */
|
|
12
|
+
/* harmony import */ var _scenario_engine_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(24547);
|
|
13
|
+
// cut-planner.js — DAG-09: graph cuts and control-placement planning.
|
|
14
|
+
//
|
|
15
|
+
// Pure module. Given a DataFlowGraph v1 and a set of CANDIDATE control
|
|
16
|
+
// locations (each with semantics, owner, estimated cost and feasibility),
|
|
17
|
+
// it builds a query-specific source-to-sink flow network, computes a
|
|
18
|
+
// minimum-cost cut with iterative Dinic max-flow (no recursion; cycles are
|
|
19
|
+
// fine), and validates the proposed set by applying it as a hypothetical
|
|
20
|
+
// scenario through scenario-engine.js's `applyScenario` and searching the
|
|
21
|
+
// RESULT graph for remaining bypasses.
|
|
22
|
+
//
|
|
23
|
+
// What this module is and is not:
|
|
24
|
+
// - Every placement it returns is HYPOTHETICAL. Nothing here reads or
|
|
25
|
+
// writes remediation state, and this module deliberately does not
|
|
26
|
+
// import remediation.js: only a real rescan feeding the existing
|
|
27
|
+
// verification gate (scan_verification / manual_attestation) can ever
|
|
28
|
+
// move a remediation item to `verified`. Results carry `verified:false`.
|
|
29
|
+
// - The cut is exact only for the finite, fixed-capacity network it
|
|
30
|
+
// builds from the MODELED edge set. Multi-query shared-control planning
|
|
31
|
+
// (`planSharedControls`) is a greedy weighted set cover and is labelled
|
|
32
|
+
// approximate; it makes no optimality claim.
|
|
33
|
+
// - A cut certificate is derived from the full modeled edge set
|
|
34
|
+
// (graph.edges), never from a capped display-path list. When the graph
|
|
35
|
+
// is not known to be complete (coverage.analysisCompletion absent or
|
|
36
|
+
// not complete, or non-modeled nodes inside the network) the result is
|
|
37
|
+
// scoped to the modeled subgraph and `totalCoverage` is 'unknown'.
|
|
38
|
+
// - Controls are modelled only as the scenario engine can express them:
|
|
39
|
+
// transit protection or handling on an edge, or removal of an entity.
|
|
40
|
+
// A node control is vertex-split: it guards every path through the node.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/** Capacity standing in for "no feasible control here" (cannot be cut). */
|
|
45
|
+
const UNCUTTABLE = 1e12;
|
|
46
|
+
const MAX_COST = 1e6;
|
|
47
|
+
const EPS = 1e-9;
|
|
48
|
+
|
|
49
|
+
const CONTROL_SEMANTICS = Object.freeze([
|
|
50
|
+
'transit_protection', 'handling', 'remove_entity',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// ── Max-flow / min-cut (Dinic, iterative) ─────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Max-flow on `n` vertices (0..n-1) with directed `arcs` of
|
|
57
|
+
* `{from, to, cap}`. Returns `{maxFlow, reachable, cutArcs}` where
|
|
58
|
+
* `cutArcs` are indices into `arcs` crossing the source-side residual
|
|
59
|
+
* reachable set (a minimum cut). Iterative; handles cycles and parallel arcs.
|
|
60
|
+
*/
|
|
61
|
+
function maxFlowMinCut(n, arcs, s, t) {
|
|
62
|
+
const head = new Array(n).fill(-1);
|
|
63
|
+
const to = []; const cap = []; const next = [];
|
|
64
|
+
const arcEdge = [];
|
|
65
|
+
const addEdge = (u, v, c) => {
|
|
66
|
+
to.push(v); cap.push(c); next.push(head[u]); head[u] = to.length - 1;
|
|
67
|
+
to.push(u); cap.push(0); next.push(head[v]); head[v] = to.length - 1;
|
|
68
|
+
};
|
|
69
|
+
arcs.forEach((a, i) => { arcEdge[i] = to.length; addEdge(a.from, a.to, a.cap); });
|
|
70
|
+
|
|
71
|
+
let flow = 0;
|
|
72
|
+
if (s === t) return { maxFlow: 0, reachable: new Array(n).fill(false), cutArcs: [] };
|
|
73
|
+
const level = new Array(n);
|
|
74
|
+
for (;;) {
|
|
75
|
+
level.fill(-1);
|
|
76
|
+
level[s] = 0;
|
|
77
|
+
const queue = [s];
|
|
78
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
79
|
+
const u = queue[qi];
|
|
80
|
+
for (let e = head[u]; e !== -1; e = next[e]) {
|
|
81
|
+
if (cap[e] > EPS && level[to[e]] < 0) { level[to[e]] = level[u] + 1; queue.push(to[e]); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (level[t] < 0) break;
|
|
85
|
+
const it = head.slice();
|
|
86
|
+
const stack = [];
|
|
87
|
+
let u = s;
|
|
88
|
+
blocking: for (;;) {
|
|
89
|
+
while (u !== t) {
|
|
90
|
+
let advanced = false;
|
|
91
|
+
for (; it[u] !== -1; it[u] = next[it[u]]) {
|
|
92
|
+
const e = it[u];
|
|
93
|
+
if (cap[e] > EPS && level[to[e]] === level[u] + 1) { stack.push(e); u = to[e]; advanced = true; break; }
|
|
94
|
+
}
|
|
95
|
+
if (!advanced) {
|
|
96
|
+
if (u === s) break blocking;
|
|
97
|
+
level[u] = -1; // dead end
|
|
98
|
+
const e = stack.pop();
|
|
99
|
+
u = to[e ^ 1];
|
|
100
|
+
it[u] = next[it[u]];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let b = Infinity;
|
|
104
|
+
for (const e of stack) if (cap[e] < b) b = cap[e];
|
|
105
|
+
for (const e of stack) { cap[e] -= b; cap[e ^ 1] += b; }
|
|
106
|
+
flow += b;
|
|
107
|
+
stack.length = 0;
|
|
108
|
+
u = s;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const reachable = new Array(n).fill(false);
|
|
112
|
+
reachable[s] = true;
|
|
113
|
+
const q = [s];
|
|
114
|
+
for (let qi = 0; qi < q.length; qi++) {
|
|
115
|
+
const u = q[qi];
|
|
116
|
+
for (let e = head[u]; e !== -1; e = next[e]) {
|
|
117
|
+
if (cap[e] > EPS && !reachable[to[e]]) { reachable[to[e]] = true; q.push(to[e]); }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const cutArcs = [];
|
|
121
|
+
arcs.forEach((a, i) => {
|
|
122
|
+
if (a.cap > 0 && reachable[a.from] && !reachable[a.to]) cutArcs.push(i);
|
|
123
|
+
});
|
|
124
|
+
return { maxFlow: flow, reachable, cutArcs };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Candidates ────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function _locKey(loc) { return `${loc.type}:${loc.id}`; }
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Normalizes and screens candidate interventions. A candidate:
|
|
133
|
+
* { id?, location: {type:'edge'|'node', id}, semantics, owner, cost,
|
|
134
|
+
* feasible?, disallowed?, families?, handling? }
|
|
135
|
+
* Returns {eligible: Map<locKey, candidate>, excluded: [{candidate, reason}]}.
|
|
136
|
+
* Eligible = valid shape, feasible, not disallowed, family-compatible.
|
|
137
|
+
*/
|
|
138
|
+
function screenCandidates(candidates, query = {}) {
|
|
139
|
+
const excluded = [];
|
|
140
|
+
const best = new Map();
|
|
141
|
+
for (const raw of candidates ?? []) {
|
|
142
|
+
const c = raw && typeof raw === 'object' ? raw : {};
|
|
143
|
+
const loc = c.location;
|
|
144
|
+
const reject = (reason) => excluded.push({ candidate: raw, reason });
|
|
145
|
+
if (!loc || (loc.type !== 'edge' && loc.type !== 'node') || typeof loc.id !== 'string' || !loc.id) {
|
|
146
|
+
reject('location must be {type: edge|node, id}'); continue;
|
|
147
|
+
}
|
|
148
|
+
if (!CONTROL_SEMANTICS.includes(c.semantics)) { reject(`semantics must be one of ${CONTROL_SEMANTICS.join('|')}`); continue; }
|
|
149
|
+
if (c.semantics === 'remove_entity' && loc.type !== 'node') { reject('remove_entity applies to a node location'); continue; }
|
|
150
|
+
if (typeof c.owner !== 'string' || !c.owner) { reject('owner is required'); continue; }
|
|
151
|
+
if (typeof c.cost !== 'number' || !Number.isFinite(c.cost) || c.cost <= 0 || c.cost > MAX_COST) {
|
|
152
|
+
reject(`cost must be a finite number in (0, ${MAX_COST}]`); continue;
|
|
153
|
+
}
|
|
154
|
+
if (c.disallowed === true) { reject('intervention site is disallowed'); continue; }
|
|
155
|
+
if (c.feasible === false) { reject('intervention is marked infeasible'); continue; }
|
|
156
|
+
if (query.family && Array.isArray(c.families) && c.families.length > 0 && !c.families.includes(query.family)) {
|
|
157
|
+
reject(`control does not support rule family "${query.family}"`); continue;
|
|
158
|
+
}
|
|
159
|
+
const cand = { ...c, id: c.id ?? `cand:${_locKey(loc)}:${c.semantics}` };
|
|
160
|
+
const key = _locKey(loc);
|
|
161
|
+
const cur = best.get(key);
|
|
162
|
+
if (!cur || cand.cost < cur.cost || (cand.cost === cur.cost && cand.id < cur.id)) best.set(key, cand);
|
|
163
|
+
}
|
|
164
|
+
return { eligible: best, excluded };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Network construction ──────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function _edgePreProtected(e) {
|
|
170
|
+
const p = e.protection;
|
|
171
|
+
if (!p) return false;
|
|
172
|
+
return ['transit', 'atRest', 'handling'].some((d) => p[d]?.verdict === 'protected');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _assessCompleteness(graph, usedNodeIds) {
|
|
176
|
+
const reasons = [];
|
|
177
|
+
const ac = graph.coverage?.analysisCompletion ?? graph.scanHealth?.analysisCompletion;
|
|
178
|
+
if (ac === undefined || ac === null) reasons.push('graph carries no analysisCompletion field, so completeness is unknown');
|
|
179
|
+
else if (!(ac === 'complete' || ac === 'full' || ac === true || ac === 1)) reasons.push(`analysisCompletion is ${JSON.stringify(ac)}`);
|
|
180
|
+
const unresolved = graph.coverage?.unresolvedDestinations;
|
|
181
|
+
if (typeof unresolved === 'number' && unresolved > 0) reasons.push(`${unresolved} unresolved destination(s)`);
|
|
182
|
+
let nonModeled = 0;
|
|
183
|
+
for (const n of graph.nodes ?? []) {
|
|
184
|
+
if (usedNodeIds.has(n.id) && n.coverageStatus !== undefined && n.coverageStatus !== 'modeled') nonModeled++;
|
|
185
|
+
}
|
|
186
|
+
if (nonModeled > 0) reasons.push(`${nonModeled} network node(s) are not fully modeled`);
|
|
187
|
+
const complete = reasons.length === 0;
|
|
188
|
+
return {
|
|
189
|
+
complete,
|
|
190
|
+
totalCoverage: complete ? 'modeled-complete' : 'unknown',
|
|
191
|
+
reasons,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Builds the query-specific network. Query (all optional):
|
|
197
|
+
* dataElementIds | dataElementNames restrict SOURCES/SINKS to flows that carry them
|
|
198
|
+
* sources / sinks explicit node ids
|
|
199
|
+
* trustBoundary {from, to} node.trustZone values for sources / sinks
|
|
200
|
+
* nodeIds restrict the network to these nodes
|
|
201
|
+
* family rule family (filters candidates)
|
|
202
|
+
* Edges always come from the full graph.edges set, never a capped path list.
|
|
203
|
+
*/
|
|
204
|
+
function buildCutNetwork(graph, query = {}, candidates = []) {
|
|
205
|
+
const limitations = [];
|
|
206
|
+
const nodes = graph.nodes ?? [];
|
|
207
|
+
const allowed = query.nodeIds ? new Set(query.nodeIds) : null;
|
|
208
|
+
const nodeIds = nodes.map((n) => n.id).filter((id) => !allowed || allowed.has(id));
|
|
209
|
+
const idx = new Map(nodeIds.map((id, i) => [id, i]));
|
|
210
|
+
const N = nodeIds.length;
|
|
211
|
+
const IN = (i) => 2 * i; const OUT = (i) => 2 * i + 1;
|
|
212
|
+
const S = 2 * N; const T = 2 * N + 1;
|
|
213
|
+
|
|
214
|
+
const { eligible, excluded } = screenCandidates(candidates, query);
|
|
215
|
+
|
|
216
|
+
// Sources and sinks.
|
|
217
|
+
let deIds = null;
|
|
218
|
+
if (query.dataElementIds || query.dataElementNames) {
|
|
219
|
+
deIds = new Set(query.dataElementIds ?? []);
|
|
220
|
+
for (const de of graph.dataElements ?? []) {
|
|
221
|
+
if (query.dataElementNames?.includes(de.name)) deIds.add(de.id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const carrying = (graph.flows ?? []).filter((f) => !deIds || (f.dataElementIds ?? []).some((d) => deIds.has(d)));
|
|
225
|
+
if (deIds && carrying.some((f) => (f.alternatePathCount ?? 0) > 0)) {
|
|
226
|
+
limitations.push('data-element restriction narrows sources and sinks only; edges come from the full modeled edge set because flow path lists may hide alternates');
|
|
227
|
+
}
|
|
228
|
+
let sourceIds = query.sources ?? [...new Set(carrying.map((f) => f.source))];
|
|
229
|
+
let sinkIds = query.sinks ?? [...new Set(carrying.map((f) => f.sink))];
|
|
230
|
+
if (query.trustBoundary) {
|
|
231
|
+
const { from, to } = query.trustBoundary;
|
|
232
|
+
const zone = new Map(nodes.map((n) => [n.id, n.trustZone]));
|
|
233
|
+
if (from) sourceIds = nodeIds.filter((id) => zone.get(id) === from);
|
|
234
|
+
if (to) sinkIds = nodeIds.filter((id) => zone.get(id) === to);
|
|
235
|
+
if (nodes.every((n) => n.trustZone === undefined)) limitations.push('trust boundary requested but no node carries trustZone; no sources or sinks matched');
|
|
236
|
+
}
|
|
237
|
+
sourceIds = sourceIds.filter((id) => idx.has(id)).sort();
|
|
238
|
+
sinkIds = sinkIds.filter((id) => idx.has(id)).sort();
|
|
239
|
+
|
|
240
|
+
const arcs = [];
|
|
241
|
+
const arcLoc = []; // parallel to arcs: {type,id} or null for structural arcs
|
|
242
|
+
const addArc = (from, to, cap, loc) => { arcs.push({ from, to, cap }); arcLoc.push(loc); };
|
|
243
|
+
|
|
244
|
+
nodeIds.forEach((id, i) => {
|
|
245
|
+
const cand = eligible.get(`node:${id}`);
|
|
246
|
+
addArc(IN(i), OUT(i), cand ? cand.cost : UNCUTTABLE, { type: 'node', id });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const existingControls = [];
|
|
250
|
+
let skippedEdges = 0;
|
|
251
|
+
for (const e of [...(graph.edges ?? [])].sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
|
252
|
+
if (!idx.has(e.from) || !idx.has(e.to)) { if (!allowed) skippedEdges++; continue; }
|
|
253
|
+
if (_edgePreProtected(e)) { existingControls.push(e.id); continue; }
|
|
254
|
+
const cand = eligible.get(`edge:${e.id}`);
|
|
255
|
+
addArc(OUT(idx.get(e.from)), IN(idx.get(e.to)), cand ? cand.cost : UNCUTTABLE, { type: 'edge', id: e.id });
|
|
256
|
+
}
|
|
257
|
+
if (skippedEdges > 0) limitations.push(`${skippedEdges} edge(s) reference nodes missing from the graph and were not modeled`);
|
|
258
|
+
for (const id of sourceIds) addArc(S, IN(idx.get(id)), UNCUTTABLE, null);
|
|
259
|
+
for (const id of sinkIds) addArc(OUT(idx.get(id)), T, UNCUTTABLE, null);
|
|
260
|
+
|
|
261
|
+
const usedNodeIds = new Set(nodeIds);
|
|
262
|
+
return {
|
|
263
|
+
nodeIds, idx, arcs, arcLoc, S, T, sourceIds, sinkIds,
|
|
264
|
+
eligible, excludedCandidates: excluded, existingControls, limitations,
|
|
265
|
+
completeness: _assessCompleteness(graph, usedNodeIds),
|
|
266
|
+
edgeCount: arcs.filter((_, i) => arcLoc[i]?.type === 'edge').length,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Reachability (BFS) in the network with `removed` locations cut. Returns
|
|
271
|
+
// parent map for witness paths. Iterative.
|
|
272
|
+
function _reach(net, removedLocKeys, onlySink = null) {
|
|
273
|
+
const adj = new Map();
|
|
274
|
+
net.arcs.forEach((a, i) => {
|
|
275
|
+
const loc = net.arcLoc[i];
|
|
276
|
+
if (loc && removedLocKeys.has(_locKey(loc))) return;
|
|
277
|
+
if (!adj.has(a.from)) adj.set(a.from, []);
|
|
278
|
+
adj.get(a.from).push({ to: a.to, loc });
|
|
279
|
+
});
|
|
280
|
+
const parent = new Map([[net.S, null]]);
|
|
281
|
+
const queue = [net.S];
|
|
282
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
283
|
+
const u = queue[qi];
|
|
284
|
+
for (const nx of adj.get(u) ?? []) {
|
|
285
|
+
if (!parent.has(nx.to)) { parent.set(nx.to, { prev: u, loc: nx.loc }); queue.push(nx.to); }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const sinkReach = net.sinkIds.filter((id) => parent.has(2 * net.idx.get(id) + 1));
|
|
289
|
+
return { parent, reachableSinks: onlySink ? sinkReach.filter((s) => s === onlySink) : sinkReach };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _witness(net, parent, sinkId) {
|
|
293
|
+
const nodePath = []; const edgePath = [];
|
|
294
|
+
let cur = 2 * net.idx.get(sinkId) + 1;
|
|
295
|
+
while (cur !== net.S && cur !== undefined) {
|
|
296
|
+
const p = parent.get(cur);
|
|
297
|
+
if (!p) break;
|
|
298
|
+
if (p.loc?.type === 'edge') edgePath.push(p.loc.id);
|
|
299
|
+
const v = cur >> 1;
|
|
300
|
+
if (cur < 2 * net.nodeIds.length && (nodePath.length === 0 || nodePath[nodePath.length - 1] !== net.nodeIds[v])) nodePath.push(net.nodeIds[v]);
|
|
301
|
+
cur = p.prev;
|
|
302
|
+
}
|
|
303
|
+
return { nodes: nodePath.reverse(), edges: edgePath.reverse() };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Scenario mapping ──────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** Translate chosen candidates into scenario-engine operations. */
|
|
309
|
+
function candidatesToOperations(graph, candidates) {
|
|
310
|
+
const ops = [];
|
|
311
|
+
for (const c of candidates) {
|
|
312
|
+
const { type, id } = c.location;
|
|
313
|
+
if (type === 'edge') {
|
|
314
|
+
if (c.semantics === 'transit_protection') ops.push({ kind: 'require_transit_protection', targetEdgeId: id });
|
|
315
|
+
else if (c.semantics === 'handling') ops.push({ kind: 'apply_handling', targetEdgeId: id, handling: c.handling ?? 'sanitized' });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (c.semantics === 'remove_entity') { ops.push({ kind: 'remove_entity', targetNodeId: id }); continue; }
|
|
319
|
+
// Node control: guards every path through the node, so apply it to the
|
|
320
|
+
// edges a path must use to pass the node (out-edges of a source node,
|
|
321
|
+
// in-edges otherwise).
|
|
322
|
+
const node = (graph.nodes ?? []).find((n) => n.id === id);
|
|
323
|
+
const isSource = node?.kind === 'source';
|
|
324
|
+
for (const e of graph.edges ?? []) {
|
|
325
|
+
if (isSource ? e.from !== id : e.to !== id) continue;
|
|
326
|
+
if (c.semantics === 'transit_protection') ops.push({ kind: 'require_transit_protection', targetEdgeId: e.id });
|
|
327
|
+
else ops.push({ kind: 'apply_handling', targetEdgeId: e.id, handling: c.handling ?? 'sanitized' });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return ops;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Validation through the scenario engine ────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Validates a proposed set of candidates by applying it as a hypothetical
|
|
337
|
+
* scenario and searching the RESULT graph for remaining source-to-sink
|
|
338
|
+
* paths over the full modeled edge set. Returns residual witness paths
|
|
339
|
+
* (display-capped, with the exact reachable-sink count alongside), the
|
|
340
|
+
* certificate, limitations and modeled coverage. Never changes remediation
|
|
341
|
+
* state; `verified` is always false.
|
|
342
|
+
*/
|
|
343
|
+
function validateCutSet(graph, query, chosen, opts = {}) {
|
|
344
|
+
const maxPaths = opts.maxResidualPaths ?? 10;
|
|
345
|
+
const limitations = [];
|
|
346
|
+
const ops = candidatesToOperations(graph, chosen);
|
|
347
|
+
let scenarioGraph = graph;
|
|
348
|
+
let skippedOperations = [];
|
|
349
|
+
if (ops.length > 0) {
|
|
350
|
+
try {
|
|
351
|
+
const r = (0,_scenario_engine_js__WEBPACK_IMPORTED_MODULE_0__.applyScenario)(graph, { operations: ops }, opts.scenarioOpts ?? {});
|
|
352
|
+
scenarioGraph = r.graph; skippedOperations = r.skippedOperations;
|
|
353
|
+
} catch (e) {
|
|
354
|
+
limitations.push(`scenario engine could not apply the proposed controls: ${e.message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const s of skippedOperations) limitations.push(`scenario operation skipped: ${s.reason}`);
|
|
358
|
+
|
|
359
|
+
// Rebuild the network on the scenario graph with NO candidates: any edge
|
|
360
|
+
// the scenario protected is dropped as a pre-existing control, any removed
|
|
361
|
+
// entity is gone, so what remains is exactly the bypass surface.
|
|
362
|
+
const net = buildCutNetwork(scenarioGraph, query, []);
|
|
363
|
+
const { parent, reachableSinks } = _reach(net, new Set());
|
|
364
|
+
const residualPaths = reachableSinks.slice(0, maxPaths).map((s) => ({ sink: s, ..._witness(net, parent, s) }));
|
|
365
|
+
const bypassFound = reachableSinks.length > 0;
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
hypothetical: true,
|
|
369
|
+
verified: false,
|
|
370
|
+
bypassFound,
|
|
371
|
+
residualSinkCount: reachableSinks.length,
|
|
372
|
+
residualPaths,
|
|
373
|
+
residualPathsTruncated: reachableSinks.length > maxPaths,
|
|
374
|
+
appliedOperationCount: ops.length - skippedOperations.length,
|
|
375
|
+
skippedOperations,
|
|
376
|
+
certificate: {
|
|
377
|
+
kind: 'all-modeled-paths',
|
|
378
|
+
basis: 'full modeled edge set of the scenario graph',
|
|
379
|
+
usedDisplayPathList: false,
|
|
380
|
+
modeledEdgeCount: (scenarioGraph.edges ?? []).length,
|
|
381
|
+
allModeledPathsCut: !bypassFound,
|
|
382
|
+
totalCoverage: net.completeness.totalCoverage,
|
|
383
|
+
scope: net.completeness.complete ? 'complete-modeled-graph' : 'modeled-subgraph-only',
|
|
384
|
+
},
|
|
385
|
+
completeness: net.completeness,
|
|
386
|
+
limitations: [...limitations, ...net.limitations],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Planner ───────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
function _locationsFromCut(net, cutArcs) {
|
|
393
|
+
const chosen = [];
|
|
394
|
+
const infinite = [];
|
|
395
|
+
for (const i of cutArcs) {
|
|
396
|
+
const loc = net.arcLoc[i];
|
|
397
|
+
if (!loc) { infinite.push({ type: 'structural' }); continue; }
|
|
398
|
+
const cand = net.eligible.get(_locKey(loc));
|
|
399
|
+
if (cand && net.arcs[i].cap < UNCUTTABLE) chosen.push(cand);
|
|
400
|
+
else infinite.push(loc);
|
|
401
|
+
}
|
|
402
|
+
chosen.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
403
|
+
return { chosen, uncuttable: infinite };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Plan a minimum-cost control placement for one query.
|
|
408
|
+
* status: 'complete-cut' | 'partial' | 'no-flow' | 'no-feasible-cut'.
|
|
409
|
+
* 'partial' means some paths cannot be cut by any eligible control; the
|
|
410
|
+
* returned set is the best finite portion and the residual bypass is
|
|
411
|
+
* reported explicitly. A graph that is not known complete downgrades the
|
|
412
|
+
* certificate to modeled-subgraph scope with unknown total coverage.
|
|
413
|
+
*/
|
|
414
|
+
function planCuts(graph, query = {}, candidates = [], opts = {}) {
|
|
415
|
+
const net = buildCutNetwork(graph, query, candidates);
|
|
416
|
+
const base = {
|
|
417
|
+
hypothetical: true,
|
|
418
|
+
verified: false,
|
|
419
|
+
query,
|
|
420
|
+
network: {
|
|
421
|
+
nodeCount: net.nodeIds.length, edgeCount: net.edgeCount,
|
|
422
|
+
sources: net.sourceIds, sinks: net.sinkIds,
|
|
423
|
+
preExistingProtectedEdges: net.existingControls,
|
|
424
|
+
},
|
|
425
|
+
completeness: net.completeness,
|
|
426
|
+
excludedCandidates: net.excludedCandidates,
|
|
427
|
+
limitations: [...net.limitations],
|
|
428
|
+
note: 'Proposed control placement is hypothetical. Only a real rescan through the remediation verification gate can verify a fix.',
|
|
429
|
+
};
|
|
430
|
+
if (net.sourceIds.length === 0 || net.sinkIds.length === 0) {
|
|
431
|
+
return { ...base, status: 'no-flow', cut: { cost: 0, controls: [] }, validation: null,
|
|
432
|
+
limitations: [...base.limitations, 'query selected no sources or no sinks'] };
|
|
433
|
+
}
|
|
434
|
+
const { maxFlow, cutArcs } = maxFlowMinCut(net.arcs.length ? 2 * net.nodeIds.length + 2 : 2, net.arcs, net.S, net.T);
|
|
435
|
+
if (maxFlow <= EPS) {
|
|
436
|
+
return { ...base, status: 'no-flow', cut: { cost: 0, controls: [] },
|
|
437
|
+
validation: validateCutSet(graph, query, [], opts) };
|
|
438
|
+
}
|
|
439
|
+
const { chosen, uncuttable } = _locationsFromCut(net, cutArcs);
|
|
440
|
+
const finiteCost = chosen.reduce((a, c) => a + c.cost, 0);
|
|
441
|
+
const hasUncuttable = maxFlow >= UNCUTTABLE / 2;
|
|
442
|
+
const validation = validateCutSet(graph, query, chosen, opts);
|
|
443
|
+
|
|
444
|
+
// Per-sink minimum cut cost (informational; the joint cut above is what
|
|
445
|
+
// separates every sink at once).
|
|
446
|
+
const perSink = net.sinkIds.map((sid) => {
|
|
447
|
+
const arcs = net.arcs.filter((a) => a.to !== net.T || a.from === 2 * net.idx.get(sid) + 1);
|
|
448
|
+
const r = maxFlowMinCut(2 * net.nodeIds.length + 2, arcs, net.S, net.T);
|
|
449
|
+
return { sink: sid, minCutCost: r.maxFlow >= UNCUTTABLE / 2 ? null : r.maxFlow, cuttable: r.maxFlow < UNCUTTABLE / 2 };
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
let status;
|
|
453
|
+
const limitations = [...base.limitations, ...validation.limitations];
|
|
454
|
+
if (hasUncuttable || validation.bypassFound) {
|
|
455
|
+
status = chosen.length ? 'partial' : 'no-feasible-cut';
|
|
456
|
+
limitations.push('no complete cut exists using eligible controls; residual bypass paths are listed');
|
|
457
|
+
} else {
|
|
458
|
+
status = 'complete-cut';
|
|
459
|
+
}
|
|
460
|
+
if (!net.completeness.complete) {
|
|
461
|
+
limitations.push('graph is not known complete: suggestions are scoped to the modeled subgraph and total coverage is unknown');
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
...base,
|
|
465
|
+
status,
|
|
466
|
+
cut: { cost: finiteCost, flowValue: hasUncuttable ? null : maxFlow, controls: chosen, optimal: !hasUncuttable,
|
|
467
|
+
optimality: hasUncuttable ? 'not applicable: no complete finite cut exists' : 'minimum cost for this single-query network under the declared capacities' },
|
|
468
|
+
uncuttableLocations: uncuttable,
|
|
469
|
+
perSink,
|
|
470
|
+
validation,
|
|
471
|
+
limitations,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── Shared / multi-family planning (approximate) ──────────────────────────
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Greedy weighted set cover over per-query minimum cuts. Queries are
|
|
479
|
+
* `{id, ...query}`. Elements are (query, sink) pairs; a set of locations
|
|
480
|
+
* covers an element when removing it disconnects that sink in that query's
|
|
481
|
+
* network (a control only counts in a query where it is eligible, so
|
|
482
|
+
* incompatible families do not leak coverage). APPROXIMATE: no optimality
|
|
483
|
+
* claim. Each selected set is then validated per query.
|
|
484
|
+
*/
|
|
485
|
+
function planSharedControls(graph, queries, candidates, opts = {}) {
|
|
486
|
+
const nets = queries.map((q) => ({ q, net: buildCutNetwork(graph, q, candidates) }));
|
|
487
|
+
const elements = [];
|
|
488
|
+
for (const { q, net } of nets) {
|
|
489
|
+
const { reachableSinks } = _reach(net, new Set());
|
|
490
|
+
for (const s of reachableSinks) elements.push({ query: q.id, sink: s });
|
|
491
|
+
}
|
|
492
|
+
const covers = (net, locKeys, sink) => !_reach(net, locKeys, sink).reachableSinks.includes(sink);
|
|
493
|
+
const eligibleKeyIn = (net, key) => net.eligible.has(key);
|
|
494
|
+
|
|
495
|
+
// Candidate sets: per-query joint cut and per-(query,sink) cuts.
|
|
496
|
+
const sets = [];
|
|
497
|
+
for (const { q, net } of nets) {
|
|
498
|
+
const { maxFlow, cutArcs } = maxFlowMinCut(2 * net.nodeIds.length + 2, net.arcs, net.S, net.T);
|
|
499
|
+
if (maxFlow > EPS && maxFlow < UNCUTTABLE / 2) sets.push(_locationsFromCut(net, cutArcs).chosen);
|
|
500
|
+
for (const sid of net.sinkIds) {
|
|
501
|
+
const arcs = net.arcs.filter((a) => a.to !== net.T || a.from === 2 * net.idx.get(sid) + 1);
|
|
502
|
+
const r = maxFlowMinCut(2 * net.nodeIds.length + 2, arcs, net.S, net.T);
|
|
503
|
+
if (r.maxFlow > EPS && r.maxFlow < UNCUTTABLE / 2) {
|
|
504
|
+
const subnet = { ...net, arcs };
|
|
505
|
+
sets.push(_locationsFromCut(subnet, r.cutArcs).chosen);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const costOf = new Map();
|
|
510
|
+
for (const s of sets) for (const c of s) costOf.set(_locKey(c.location), c);
|
|
511
|
+
|
|
512
|
+
const coveredBy = (chosenKeys) => {
|
|
513
|
+
const keys = new Set(chosenKeys);
|
|
514
|
+
return elements.filter((el) => {
|
|
515
|
+
const { net } = nets.find((n) => n.q.id === el.query);
|
|
516
|
+
const effective = new Set([...keys].filter((k) => eligibleKeyIn(net, k)));
|
|
517
|
+
return covers(net, effective, el.sink);
|
|
518
|
+
});
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const chosenKeys = new Set();
|
|
522
|
+
let covered = [];
|
|
523
|
+
for (;;) {
|
|
524
|
+
let bestSet = null; let bestScore = 0; let bestCovered = covered;
|
|
525
|
+
for (const s of sets) {
|
|
526
|
+
const newKeys = s.map((c) => _locKey(c.location)).filter((k) => !chosenKeys.has(k));
|
|
527
|
+
if (newKeys.length === 0) continue;
|
|
528
|
+
const cov = coveredBy([...chosenKeys, ...newKeys]);
|
|
529
|
+
const gain = cov.length - covered.length;
|
|
530
|
+
if (gain <= 0) continue;
|
|
531
|
+
const cost = newKeys.reduce((a, k) => a + costOf.get(k).cost, 0);
|
|
532
|
+
const score = gain / cost;
|
|
533
|
+
if (score > bestScore + 1e-12) { bestScore = score; bestSet = newKeys; bestCovered = cov; }
|
|
534
|
+
}
|
|
535
|
+
if (!bestSet) break;
|
|
536
|
+
for (const k of bestSet) chosenKeys.add(k);
|
|
537
|
+
covered = bestCovered;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const controls = [...chosenKeys].sort().map((k) => costOf.get(k));
|
|
541
|
+
const coveredIds = new Set(covered.map((e) => `${e.query}|${e.sink}`));
|
|
542
|
+
const uncovered = elements.filter((e) => !coveredIds.has(`${e.query}|${e.sink}`));
|
|
543
|
+
const perQueryValidation = queries.map((q) => {
|
|
544
|
+
const eligibleHere = controls.filter((c) => nets.find((n) => n.q.id === q.id).net.eligible.has(_locKey(c.location)));
|
|
545
|
+
return { query: q.id, validation: validateCutSet(graph, q, eligibleHere, opts) };
|
|
546
|
+
});
|
|
547
|
+
return {
|
|
548
|
+
hypothetical: true,
|
|
549
|
+
verified: false,
|
|
550
|
+
method: 'greedy weighted set cover over per-query minimum cuts',
|
|
551
|
+
approximate: true,
|
|
552
|
+
optimalityClaim: 'none: the result is not claimed to be globally optimal',
|
|
553
|
+
controls,
|
|
554
|
+
totalCost: controls.reduce((a, c) => a + c.cost, 0),
|
|
555
|
+
coveredElements: covered,
|
|
556
|
+
uncoveredElements: uncovered,
|
|
557
|
+
perQueryValidation,
|
|
558
|
+
limitations: ['approximate shared-control selection; compare against per-query plans before relying on the total'],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
/***/ }),
|
|
564
|
+
|
|
565
|
+
/***/ 24547:
|
|
566
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
567
|
+
|
|
568
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
569
|
+
/* harmony export */ applyScenario: () => (/* binding */ applyScenario)
|
|
570
|
+
/* harmony export */ });
|
|
571
|
+
/* harmony import */ var _protection_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(80965);
|
|
572
|
+
/* harmony import */ var _dataflow_privacy_sink_policy_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3138);
|
|
573
|
+
// scenario-engine.js — M5 deliverable #3a (FR-502): the clone-and-
|
|
574
|
+
// override engine. Deep-clones the base graph, applies each declared
|
|
575
|
+
// Scenario operation, then re-runs the SAME two pure aggregators
|
|
576
|
+
// graph-builder.js's own real pipeline uses (aggregateVerdicts,
|
|
577
|
+
// isSinkPermitted) over only the flows an operation actually touched —
|
|
578
|
+
// never re-running the taint/path pipeline, per this sub-project's own
|
|
579
|
+
// scoping doc.
|
|
580
|
+
//
|
|
581
|
+
// The exact recomputation this module mirrors, confirmed by direct
|
|
582
|
+
// read of graph-builder.js's own real edge/flow minting pass:
|
|
583
|
+
// flow.protectionSummary = aggregateVerdicts([
|
|
584
|
+
// edge.protection.transit.verdict,
|
|
585
|
+
// edge.protection.atRest.verdict,
|
|
586
|
+
// edge.protection.handling.verdict,
|
|
587
|
+
// ])
|
|
588
|
+
// flow.policyVerdict = isSinkPermitted(dataElement.dataClasses, sinkNode.subtype,
|
|
589
|
+
// opts.privacySinkPolicy, { environment: opts.environment, destination: sinkNode.destination?.literalValue ?? null })
|
|
590
|
+
// ? 'permitted' : 'prohibited' (or 'not_evaluated' if no policy/classes/sinkKind)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
function _deepClone(v) { return JSON.parse(JSON.stringify(v)); }
|
|
596
|
+
|
|
597
|
+
function _byId(arr) { return new Map((arr ?? []).map((x) => [x.id, x])); }
|
|
598
|
+
|
|
599
|
+
// Aggregates protectionSummary across EVERY edge belonging to `flow`, not
|
|
600
|
+
// just one of them — a flow's `edgeIds` can in principle name more than
|
|
601
|
+
// one edge (graph-builder.js's own real flows are always single-edge
|
|
602
|
+
// today, but the schema and this module both accept a multi-edge array
|
|
603
|
+
// with no restriction), and aggregateVerdicts's own documented purpose is
|
|
604
|
+
// exactly this cross-branch/cross-edge reduction (PRD §8.4). Picking a
|
|
605
|
+
// single arbitrary edge here would silently go stale the moment a
|
|
606
|
+
// scenario is built against a genuinely multi-hop flow.
|
|
607
|
+
function _recomputeProtectionSummaryForFlow(flow, graph) {
|
|
608
|
+
const edges = graph.edges.filter((e) => flow.edgeIds.includes(e.id));
|
|
609
|
+
const verdicts = edges.flatMap((e) => [
|
|
610
|
+
e.protection.transit.verdict,
|
|
611
|
+
e.protection.atRest.verdict,
|
|
612
|
+
e.protection.handling.verdict,
|
|
613
|
+
]);
|
|
614
|
+
return (0,_protection_js__WEBPACK_IMPORTED_MODULE_0__/* .aggregateVerdicts */ .SX)(verdicts);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function _recomputePolicyVerdict(flow, graph, opts) {
|
|
618
|
+
if (opts.privacySinkPolicy == null) return null; // signal: leave flow.policyVerdict untouched
|
|
619
|
+
const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
620
|
+
const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
|
|
621
|
+
const classes = de?.dataClasses ?? [];
|
|
622
|
+
const sinkKind = sinkNode?.subtype ?? null;
|
|
623
|
+
if (!classes.length || !sinkKind) return 'not_evaluated';
|
|
624
|
+
const ctx = {
|
|
625
|
+
environment: opts.environment ?? process.env.AGENTIC_SECURITY_ENVIRONMENT ?? null,
|
|
626
|
+
destination: sinkNode?.destination?.literalValue ?? null,
|
|
627
|
+
};
|
|
628
|
+
return (0,_dataflow_privacy_sink_policy_js__WEBPACK_IMPORTED_MODULE_1__/* .isSinkPermitted */ .i$)(classes, sinkKind, opts.privacySinkPolicy, ctx) ? 'permitted' : 'prohibited';
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Every flow using this edge (by edgeIds membership) gets protectionSummary
|
|
632
|
+
// recomputed; policyVerdict only for flows whose sink node's destination
|
|
633
|
+
// or the flow's own dataClasses could plausibly have changed — but since
|
|
634
|
+
// applyScenario always calls this after ANY node/edge touch to be safe
|
|
635
|
+
// (recomputation is cheap and idempotent), scope is simply "every flow
|
|
636
|
+
// touching this edge or this node".
|
|
637
|
+
function _touchedFlows(graph, { edgeId, nodeId }) {
|
|
638
|
+
return graph.flows.filter((f) =>
|
|
639
|
+
(edgeId && f.edgeIds.includes(edgeId)) ||
|
|
640
|
+
(nodeId && (f.source === nodeId || f.sink === nodeId)));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function _recomputeTouchedFlows(graph, touch, opts) {
|
|
644
|
+
for (const flow of _touchedFlows(graph, touch)) {
|
|
645
|
+
flow.protectionSummary = _recomputeProtectionSummaryForFlow(flow, graph);
|
|
646
|
+
const newPolicyVerdict = _recomputePolicyVerdict(flow, graph, opts);
|
|
647
|
+
if (newPolicyVerdict !== null) flow.policyVerdict = newPolicyVerdict;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function _applyRequireTransitProtection(graph, op) {
|
|
652
|
+
const edge = _byId(graph.edges).get(op.targetEdgeId);
|
|
653
|
+
if (!edge) return { ok: false, reason: `targetEdgeId "${op.targetEdgeId}" not found in graph.edges` };
|
|
654
|
+
edge.protection.transit = { verdict: 'protected', evidenceGrade: 'assumed' };
|
|
655
|
+
_recomputeTouchedFlows(graph, { edgeId: edge.id }, op._opts);
|
|
656
|
+
return { ok: true };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function _applyHandling(graph, op) {
|
|
660
|
+
const edge = _byId(graph.edges).get(op.targetEdgeId);
|
|
661
|
+
if (!edge) return { ok: false, reason: `targetEdgeId "${op.targetEdgeId}" not found in graph.edges` };
|
|
662
|
+
// Mirrors graph-builder.js's own gate (~line 757): 'encrypted' handling
|
|
663
|
+
// only earns the atRest 'protected' verdict when the edge's sink node
|
|
664
|
+
// is store-kind (database/file/object-storage/cache/client-storage/
|
|
665
|
+
// backup/export). For any other sink kind (e.g. 'external-api',
|
|
666
|
+
// 'queue') "at rest" isn't even a meaningful concept — falls through to
|
|
667
|
+
// the same 'handling' dimension the non-encrypted case already sets.
|
|
668
|
+
const sinkNode = graph.nodes.find((n) => n.id === edge.to);
|
|
669
|
+
if (op.handling === 'encrypted' && sinkNode?.kind === 'store') {
|
|
670
|
+
edge.protection.atRest = { verdict: 'protected', evidenceGrade: 'assumed' };
|
|
671
|
+
} else {
|
|
672
|
+
edge.protection.handling = { verdict: 'protected', evidenceGrade: 'assumed' };
|
|
673
|
+
}
|
|
674
|
+
_recomputeTouchedFlows(graph, { edgeId: edge.id }, op._opts);
|
|
675
|
+
return { ok: true };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function _applyRemoveEntity(graph, op) {
|
|
679
|
+
const node = _byId(graph.nodes).get(op.targetNodeId);
|
|
680
|
+
if (!node) return { ok: false, reason: `targetNodeId "${op.targetNodeId}" not found in graph.nodes` };
|
|
681
|
+
const removedEdgeIds = new Set(graph.edges.filter((e) => e.from === node.id || e.to === node.id).map((e) => e.id));
|
|
682
|
+
graph.edges = graph.edges.filter((e) => !removedEdgeIds.has(e.id));
|
|
683
|
+
graph.flows = graph.flows.filter((f) => f.source !== node.id && f.sink !== node.id
|
|
684
|
+
&& !f.edgeIds.some((id) => removedEdgeIds.has(id)));
|
|
685
|
+
graph.nodes = graph.nodes.filter((n) => n.id !== node.id);
|
|
686
|
+
// Cascade-prune graph.recipientProfiles[] too — a RecipientProfile whose
|
|
687
|
+
// contributingGraphIds referenced ONLY this now-removed node must not
|
|
688
|
+
// survive dangling (a downstream `dataflow export --format recipients`
|
|
689
|
+
// consumer filters by exactly contributingGraphIds against surviving
|
|
690
|
+
// node ids); a profile with OTHER surviving contributing ids just loses
|
|
691
|
+
// this one id, never the whole record. Guarded on the array existing —
|
|
692
|
+
// the base graph shape used throughout this sub-project's own tests
|
|
693
|
+
// doesn't always include it. A profile with NO contributingGraphIds at
|
|
694
|
+
// all (null/undefined — real graph-builder.js output always mints a
|
|
695
|
+
// non-empty array, so this is defensive against a hand-built/external
|
|
696
|
+
// graph only) is left untouched rather than normalized to `[]` and
|
|
697
|
+
// dropped, since a missing array is not evidence the profile referenced
|
|
698
|
+
// this node.
|
|
699
|
+
// NOTE (final-review re-review N2, disclosed not fixed): this cascade
|
|
700
|
+
// is real graph mutation, but scenario-diff.js's WATCHED_SCENARIO_FIELDS
|
|
701
|
+
// has no `recipientProfile` kind, so diffScenarioGraph's own delta
|
|
702
|
+
// report stays silent about a recipient record that disappeared or lost
|
|
703
|
+
// a contributing id here — a future increment's fuller fix, not scope
|
|
704
|
+
// creep for this pass.
|
|
705
|
+
if (graph.recipientProfiles?.length) {
|
|
706
|
+
graph.recipientProfiles = graph.recipientProfiles
|
|
707
|
+
.map((rp) => (rp.contributingGraphIds == null ? rp
|
|
708
|
+
: { ...rp, contributingGraphIds: rp.contributingGraphIds.filter((id) => id !== node.id) }))
|
|
709
|
+
.filter((rp) => rp.contributingGraphIds == null || rp.contributingGraphIds.length > 0);
|
|
710
|
+
}
|
|
711
|
+
return { ok: true };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Scoped, at the applier level, to exactly `node.destination` — this
|
|
715
|
+
// sub-project's own scoping doc maps "replace a provider or move region"
|
|
716
|
+
// onto node.destination, the graph-level equivalent of a recipient's
|
|
717
|
+
// technical endpoint, and nothing else. scenario.js's own
|
|
718
|
+
// SCENARIO_OPERATION_REQUIRED_FIELDS only requires `field` be a non-empty
|
|
719
|
+
// string, never restricts its value — a real applied write to any other
|
|
720
|
+
// top-level node field (e.g. 'subtype') would land on the node but be
|
|
721
|
+
// completely invisible to scenario-diff.js's WATCHED_SCENARIO_FIELDS.node
|
|
722
|
+
// (which only watches 'destination'/'storeDetail'), since that list can
|
|
723
|
+
// only stay exhaustive if this applier never writes outside it. Rejecting
|
|
724
|
+
// any other field here — never widening the watch list — is what keeps
|
|
725
|
+
// that list exhaustive by construction. Mirrors the existing "skip a
|
|
726
|
+
// target id it can't find" pattern: reported in skippedOperations, never
|
|
727
|
+
// thrown.
|
|
728
|
+
function _applyReplaceRecipientFact(graph, op) {
|
|
729
|
+
const node = _byId(graph.nodes).get(op.targetNodeId);
|
|
730
|
+
if (!node) return { ok: false, reason: `targetNodeId "${op.targetNodeId}" not found in graph.nodes` };
|
|
731
|
+
if (op.field !== 'destination') {
|
|
732
|
+
return { ok: false, reason: `replace_recipient_fact only supports field "destination" (got "${op.field}") — this operation is scoped to a node's recipient/destination fact only` };
|
|
733
|
+
}
|
|
734
|
+
node[op.field] = _deepClone(op.value);
|
|
735
|
+
// The destination just changed — any edge.protection.transit verdict INTO
|
|
736
|
+
// this node was derived from the OLD destination (transit-protection.js's
|
|
737
|
+
// resolveTransitProtectionForSite is keyed on the sink's own destination)
|
|
738
|
+
// and is now stale. Never let a stale verdict survive with a real (non-
|
|
739
|
+
// 'assumed') evidence grade — reset to the honest "we don't know anymore"
|
|
740
|
+
// state rather than re-derive (re-deriving needs transitEvidenceByFile,
|
|
741
|
+
// IR-level data unavailable post-build) or silently keep the old verdict.
|
|
742
|
+
// NOTE (final-review re-review N1, disclosed not fixed): this demotion is
|
|
743
|
+
// per-EDGE and is always honest at that level (and always visible in
|
|
744
|
+
// diffScenarioGraph's own delta, since edge.protection.transit is a
|
|
745
|
+
// WATCHED_SCENARIO_FIELDS.edge entry) — but flow.protectionSummary
|
|
746
|
+
// aggregates transit/atRest/handling via aggregateVerdicts, whose
|
|
747
|
+
// precedence ranks not_assessed LAST, so a scenario that ALSO sets this
|
|
748
|
+
// edge's own handling dimension to 'protected' (via apply_handling) in
|
|
749
|
+
// the SAME operations list can mask the demotion at the flow-summary
|
|
750
|
+
// level, even though the masking verdict itself is honestly graded
|
|
751
|
+
// 'assumed'. graph-builder.js's own cross-dimension aggregation is
|
|
752
|
+
// documented safe only because transit/atRest are mutually exclusive by
|
|
753
|
+
// construction and handling is "never written" by any real analyzer —
|
|
754
|
+
// this module (scenario-engine.js) is the one exception to that last
|
|
755
|
+
// clause, which is exactly what makes the masking reachable here.
|
|
756
|
+
for (const edge of graph.edges) {
|
|
757
|
+
if (edge.to === node.id) {
|
|
758
|
+
edge.protection.transit = { verdict: 'not_assessed', evidenceGrade: 'none' };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
_recomputeTouchedFlows(graph, { nodeId: node.id }, op._opts);
|
|
762
|
+
return { ok: true };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function _applyChangeStorageFact(graph, op) {
|
|
766
|
+
const node = _byId(graph.nodes).get(op.targetNodeId);
|
|
767
|
+
if (!node) return { ok: false, reason: `targetNodeId "${op.targetNodeId}" not found in graph.nodes` };
|
|
768
|
+
node.storeDetail = { ...(node.storeDetail ?? {}), [op.field]: _deepClone(op.value) };
|
|
769
|
+
return { ok: true };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function _applyChangeGovernanceFact(graph, op) {
|
|
773
|
+
const flow = _byId(graph.flows).get(op.targetFlowId);
|
|
774
|
+
if (!flow) return { ok: false, reason: `targetFlowId "${op.targetFlowId}" not found in graph.flows` };
|
|
775
|
+
flow.governanceRefs = { ...(flow.governanceRefs ?? {}), [op.field]: _deepClone(op.value) };
|
|
776
|
+
return { ok: true };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const _APPLIERS = {
|
|
780
|
+
require_transit_protection: _applyRequireTransitProtection,
|
|
781
|
+
apply_handling: _applyHandling,
|
|
782
|
+
remove_entity: _applyRemoveEntity,
|
|
783
|
+
replace_recipient_fact: _applyReplaceRecipientFact,
|
|
784
|
+
change_storage_fact: _applyChangeStorageFact,
|
|
785
|
+
change_governance_fact: _applyChangeGovernanceFact,
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Apply `scenario.operations` to a deep clone of `baseGraph`. Never
|
|
790
|
+
* mutates `baseGraph`. An operation whose target id does not exist in
|
|
791
|
+
* the graph is skipped (reported in `skippedOperations`), never thrown —
|
|
792
|
+
* a Scenario written against an older snapshot must degrade honestly.
|
|
793
|
+
* `opts.privacySinkPolicy`/`opts.environment` mirror graph-builder.js's
|
|
794
|
+
* own opts; omitting privacySinkPolicy means policyVerdict
|
|
795
|
+
* recomputation is skipped entirely (base value kept on every touched
|
|
796
|
+
* flow) rather than guessed.
|
|
797
|
+
*
|
|
798
|
+
* Scenario-shape validation (`validateScenario`, `scenario.js`) is the
|
|
799
|
+
* CALLER's responsibility, not this module's — this function does no
|
|
800
|
+
* upfront shape checking beyond each operation's own per-target `_byId`
|
|
801
|
+
* lookup. An unrecognized `op.kind` is skipped via the `_APPLIERS` map
|
|
802
|
+
* lookup below rather than validated against `SCENARIO_OPERATION_KINDS`
|
|
803
|
+
* ahead of time. This mirrors `recipient-profile.js`'s own precedent,
|
|
804
|
+
* where `validateRecipientProfile` is never called internally by any
|
|
805
|
+
* producer either — the CLI layer (Task 4) calls `validateScenario`
|
|
806
|
+
* before `applyScenario`, by design.
|
|
807
|
+
*/
|
|
808
|
+
function applyScenario(baseGraph, scenario, opts = {}) {
|
|
809
|
+
const graph = _deepClone(baseGraph);
|
|
810
|
+
const appliedOperations = [];
|
|
811
|
+
const skippedOperations = [];
|
|
812
|
+
for (const op of scenario.operations ?? []) {
|
|
813
|
+
const applier = _APPLIERS[op.kind];
|
|
814
|
+
if (!applier) {
|
|
815
|
+
skippedOperations.push({ operation: op, reason: `unrecognized operation kind "${op.kind}"` });
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const result = applier(graph, { ...op, _opts: opts });
|
|
819
|
+
if (result.ok) appliedOperations.push({ operation: op });
|
|
820
|
+
else skippedOperations.push({ operation: op, reason: result.reason });
|
|
821
|
+
}
|
|
822
|
+
return { graph, appliedOperations, skippedOperations };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
/***/ })
|
|
827
|
+
|
|
828
|
+
};
|