@clear-capabilities/agentic-security-scanner 0.145.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 +139 -0
- package/bin/agentic-security.js +3508 -69
- 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} +2 -2
- 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/{271.index.js → 2271.index.js} +2 -2
- 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} +5 -5
- 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} +5 -5
- 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} +2 -2
- 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} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- 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} +3 -3
- 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} +560 -189
- 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 +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- 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/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 +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- 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/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,866 @@
|
|
|
1
|
+
export const id = 5350;
|
|
2
|
+
export const ids = [5350];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 5350:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
AUDIENCE_MODES: () => (/* binding */ AUDIENCE_MODES),
|
|
12
|
+
emitDecisionStory: () => (/* binding */ emitDecisionStory)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// EXTERNAL MODULE: ./src/dataflow/privacy-taxonomy.js
|
|
16
|
+
var privacy_taxonomy = __webpack_require__(7451);
|
|
17
|
+
;// CONCATENATED MODULE: ./src/lineage/decision-story.js
|
|
18
|
+
// decision-story.js — M4 deliverable #7 (FR-501 §14, DFG-035): the
|
|
19
|
+
// DecisionStory extension contract (§10.10) + a transparent 9-factor
|
|
20
|
+
// ranking engine over real flows.
|
|
21
|
+
//
|
|
22
|
+
// Mirrors obligation-mapping.js's own shape exactly: a record is
|
|
23
|
+
// explicitly NOT a DataFlowGraph v1 entity (§10.10 — "associated with,
|
|
24
|
+
// but not required inside, the immutable base graph"), never added to
|
|
25
|
+
// dataflow-graph.schema.json, never routed through validate.js.
|
|
26
|
+
//
|
|
27
|
+
// Ranking-factor availability, grounded in real investigation (see this
|
|
28
|
+
// sub-project's own scoping doc): 7 of 9 factors are direct reads off
|
|
29
|
+
// the graph, 1 (breadth) is a small new aggregation defined here, and 2
|
|
30
|
+
// (recipientJurisdiction, changeRecency) are honestly `available: false`
|
|
31
|
+
// on every flow — never fabricated, never silently dropped from the
|
|
32
|
+
// factor list. recipientJurisdiction needs a RecipientProfile extension
|
|
33
|
+
// (capability #6, not yet built); changeRecency needs GraphSnapshot/
|
|
34
|
+
// GraphDiff (capability #3, Data-Flow Time Machine, not yet built).
|
|
35
|
+
//
|
|
36
|
+
// PRD's own binding constraint: "never represents an uncalibrated score
|
|
37
|
+
// as expected financial loss or breach probability." This module NEVER
|
|
38
|
+
// blends factors into a single opaque float — rankFlows performs a
|
|
39
|
+
// documented, transparent, CONFIGURABLE (opts.factorOrder) multi-key
|
|
40
|
+
// sort over each factor's own ordinal tier. Every factor stays
|
|
41
|
+
// individually inspectable on the returned record.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
// The sink-registry categories (sink-registry.js's own CATEGORY_NODE_KIND
|
|
46
|
+
// vocabulary) that count as an AI destination for the aiUse factor.
|
|
47
|
+
// Exported so export-briefing.js's own "AI providers" chapter-3 listing
|
|
48
|
+
// reads the SAME vocabulary rather than keeping a second, independently
|
|
49
|
+
// drifting copy (Task 2 review finding, fixed).
|
|
50
|
+
const AI_SINK_SUBTYPES = Object.freeze(['ai-model-provider', 'ai-agent', 'ai-tool']);
|
|
51
|
+
|
|
52
|
+
const RANKING_FACTORS = Object.freeze([
|
|
53
|
+
'sensitivity', 'externality', 'controlVerdict', 'recipientJurisdiction',
|
|
54
|
+
'aiUse', 'breadth', 'evidenceConfidence', 'policyState', 'changeRecency',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// Ordinal tier ranks, worst-first, used both to score a flow's own
|
|
58
|
+
// factor and to compare two flows during rankFlows's own sort. Every
|
|
59
|
+
// factor uses a SMALL, real, disclosed vocabulary — never a blended
|
|
60
|
+
// number.
|
|
61
|
+
const _TIER_RANK = {
|
|
62
|
+
sensitivity: { critical: 4, high: 3, medium: 2, low: 1, none: 0 },
|
|
63
|
+
externality: { external: 2, unknown: 1, internal: 0 },
|
|
64
|
+
controlVerdict: { unprotected: 4, mixed: 3, unknown: 2, not_assessed: 1, protected: 0, not_applicable: 0 },
|
|
65
|
+
recipientJurisdiction: { unknown: 0 },
|
|
66
|
+
aiUse: { ai_destination: 1, none: 0 },
|
|
67
|
+
breadth: { high: 2, medium: 1, low: 0 },
|
|
68
|
+
// Lower confidence is MORE attention-worthy (an uncertain flow needs
|
|
69
|
+
// review), so the tier rank is inverted relative to the raw score —
|
|
70
|
+
// disclosed here, not left implicit.
|
|
71
|
+
evidenceConfidence: { low: 2, medium: 1, high: 0 },
|
|
72
|
+
policyState: { prohibited: 4, manual_review_required: 3, conditionally_permitted: 2, not_evaluated: 1, permitted: 0 },
|
|
73
|
+
changeRecency: { unknown: 0 },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
|
|
77
|
+
function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
78
|
+
function _isArray(v) { return Array.isArray(v); }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Structural validation only — mirrors
|
|
82
|
+
* obligation-mapping.js#validateObligationMapping's own {valid, errors}
|
|
83
|
+
* shape and "never throws" contract.
|
|
84
|
+
*/
|
|
85
|
+
function validateDecisionStory(record) {
|
|
86
|
+
const errors = [];
|
|
87
|
+
const err = (path, message) => errors.push({ path, message });
|
|
88
|
+
|
|
89
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
90
|
+
err('$', 'DecisionStory record must be an object');
|
|
91
|
+
return { valid: false, errors };
|
|
92
|
+
}
|
|
93
|
+
if (!_isNonEmptyString(record.id) || !record.id.startsWith('story:')) {
|
|
94
|
+
err('$.id', 'id is required and must start with "story:"');
|
|
95
|
+
}
|
|
96
|
+
if (!_isNonEmptyString(record.version)) err('$.version', 'version is required');
|
|
97
|
+
if (!_isNonEmptyString(record.audienceMode)) err('$.audienceMode', 'audienceMode is required');
|
|
98
|
+
if (!_isPlainObject(record.scopeQuery)) err('$.scopeQuery', 'scopeQuery is required and must be an object');
|
|
99
|
+
if (!_isArray(record.chapters)) err('$.chapters', 'chapters must be an array');
|
|
100
|
+
if (!_isArray(record.contributingGraphIds)) err('$.contributingGraphIds', 'contributingGraphIds must be an array');
|
|
101
|
+
if (!_isArray(record.rankingFactors)) err('$.rankingFactors', 'rankingFactors must be an array');
|
|
102
|
+
if (!_isNonEmptyString(record.evidenceGrade)) err('$.evidenceGrade', 'evidenceGrade is required');
|
|
103
|
+
if (!_isPlainObject(record.coverage)) err('$.coverage', 'coverage is required and must be an object');
|
|
104
|
+
if (!_isArray(record.decisions)) err('$.decisions', 'decisions must be an array');
|
|
105
|
+
if (!_isNonEmptyString(record.generatedAt)) err('$.generatedAt', 'generatedAt is required');
|
|
106
|
+
if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
|
|
107
|
+
|
|
108
|
+
return { valid: errors.length === 0, errors };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function _sensitivityFactor(flow, graph) {
|
|
112
|
+
const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
113
|
+
const classes = de?.dataClasses ?? [];
|
|
114
|
+
if (classes.length === 0) return { available: true, tier: 'none', evidence: [] };
|
|
115
|
+
let worst = 'low';
|
|
116
|
+
for (const cls of classes) {
|
|
117
|
+
const sev = privacy_taxonomy/* DEFAULT_TAXONOMY */.Qb[cls]?.severity ?? 'medium';
|
|
118
|
+
if ((privacy_taxonomy/* SEVERITY_RANK */.f3[sev] ?? 0) > (privacy_taxonomy/* SEVERITY_RANK */.f3[worst] ?? 0)) worst = sev;
|
|
119
|
+
}
|
|
120
|
+
return { available: true, tier: worst, evidence: [de.id] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _externalityFactor(flow, nodesById) {
|
|
124
|
+
const sink = nodesById.get(flow.sink);
|
|
125
|
+
const value = sink?.externality?.value ?? 'unknown';
|
|
126
|
+
return { available: true, tier: value, evidence: [flow.sink] };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _controlVerdictFactor(flow) {
|
|
130
|
+
return { available: true, tier: flow.protectionSummary ?? 'not_assessed', evidence: flow.edgeIds ?? [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _recipientJurisdictionFactor() {
|
|
134
|
+
// Honest gap — see this file's own header. Never fabricated.
|
|
135
|
+
return { available: false, tier: 'unknown', evidence: [], unavailableReason: 'RecipientProfile extension not yet built (capability #6, Third-Party/Cross-Border Intelligence)' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _aiUseFactor(flow, nodesById) {
|
|
139
|
+
const sink = nodesById.get(flow.sink);
|
|
140
|
+
const isAi = AI_SINK_SUBTYPES.includes(sink?.subtype);
|
|
141
|
+
return { available: true, tier: isAi ? 'ai_destination' : 'none', evidence: isAi ? [flow.sink] : [] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* New small aggregation (the scoping doc's own "smallest real addition"
|
|
146
|
+
* for breadth/blast-radius): how many OTHER flows in the same graph
|
|
147
|
+
* share this flow's own sink node or dataElement. A crude but real,
|
|
148
|
+
* non-fabricated proxy for "how widely does this exposure reach" —
|
|
149
|
+
* never presented as a calibrated blast-radius count, just an ordinal
|
|
150
|
+
* tier over a real, disclosed count.
|
|
151
|
+
*/
|
|
152
|
+
function _breadthFactor(flow, graph) {
|
|
153
|
+
let sharedCount = 0;
|
|
154
|
+
for (const other of graph.flows) {
|
|
155
|
+
if (other.id === flow.id) continue;
|
|
156
|
+
const sharesSink = other.sink === flow.sink;
|
|
157
|
+
const sharesData = other.dataElementIds.some((id) => flow.dataElementIds.includes(id));
|
|
158
|
+
if (sharesSink || sharesData) sharedCount++;
|
|
159
|
+
}
|
|
160
|
+
const tier = sharedCount >= 5 ? 'high' : sharedCount >= 1 ? 'medium' : 'low';
|
|
161
|
+
return { available: true, tier, evidence: [], sharedFlowCount: sharedCount };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _evidenceConfidenceFactor(flow) {
|
|
165
|
+
const tier = flow.confidence?.tier ?? 'medium';
|
|
166
|
+
return { available: true, tier, evidence: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _policyStateFactor(flow) {
|
|
170
|
+
return { available: true, tier: flow.policyVerdict ?? 'not_evaluated', evidence: flow.evidenceRefs ?? [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _changeRecencyFactor() {
|
|
174
|
+
// Honest gap — see this file's own header. Never fabricated.
|
|
175
|
+
return { available: false, tier: 'unknown', evidence: [], unavailableReason: 'GraphSnapshot/GraphDiff not yet built (capability #3, Data-Flow Time Machine)' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Score ONE flow on all 9 factors. Never throws on a well-formed graph. */
|
|
179
|
+
function scoreFlow(flow, graph, nodesById) {
|
|
180
|
+
return {
|
|
181
|
+
flowId: flow.id,
|
|
182
|
+
factors: {
|
|
183
|
+
sensitivity: _sensitivityFactor(flow, graph),
|
|
184
|
+
externality: _externalityFactor(flow, nodesById),
|
|
185
|
+
controlVerdict: _controlVerdictFactor(flow),
|
|
186
|
+
recipientJurisdiction: _recipientJurisdictionFactor(),
|
|
187
|
+
aiUse: _aiUseFactor(flow, nodesById),
|
|
188
|
+
breadth: _breadthFactor(flow, graph),
|
|
189
|
+
evidenceConfidence: _evidenceConfidenceFactor(flow),
|
|
190
|
+
policyState: _policyStateFactor(flow),
|
|
191
|
+
changeRecency: _changeRecencyFactor(),
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Score and rank every flow in the graph. opts.factorOrder (default
|
|
198
|
+
* RANKING_FACTORS) is the PRD's own "transparent configurable factors"
|
|
199
|
+
* requirement, made real: a lexicographic multi-key sort over each
|
|
200
|
+
* factor's own ordinal tier rank, in the given priority order — NEVER a
|
|
201
|
+
* blended single score. Ties within all factors preserve original flow
|
|
202
|
+
* order (stable sort).
|
|
203
|
+
*/
|
|
204
|
+
function rankFlows(graph, opts = {}) {
|
|
205
|
+
const factorOrder = opts.factorOrder ?? RANKING_FACTORS;
|
|
206
|
+
const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
207
|
+
const scored = graph.flows.map((flow) => ({
|
|
208
|
+
flow,
|
|
209
|
+
...scoreFlow(flow, graph, nodesById),
|
|
210
|
+
factorOrderUsed: factorOrder,
|
|
211
|
+
}));
|
|
212
|
+
const rankOf = (scoredFlow, factor) => {
|
|
213
|
+
const f = scoredFlow.factors[factor];
|
|
214
|
+
return _TIER_RANK[factor]?.[f.tier] ?? 0;
|
|
215
|
+
};
|
|
216
|
+
return scored.sort((a, b) => {
|
|
217
|
+
for (const factor of factorOrder) {
|
|
218
|
+
const diff = rankOf(b, factor) - rankOf(a, factor);
|
|
219
|
+
if (diff !== 0) return diff;
|
|
220
|
+
}
|
|
221
|
+
return 0;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// EXTERNAL MODULE: ./src/lineage/export-json.js
|
|
226
|
+
var export_json = __webpack_require__(859);
|
|
227
|
+
// EXTERNAL MODULE: ./src/lineage/ids.js
|
|
228
|
+
var ids = __webpack_require__(5034);
|
|
229
|
+
// EXTERNAL MODULE: ./src/lineage/protection.js
|
|
230
|
+
var protection = __webpack_require__(965);
|
|
231
|
+
// EXTERNAL MODULE: ./src/dataflow/privacy-governance.js
|
|
232
|
+
var privacy_governance = __webpack_require__(3308);
|
|
233
|
+
;// CONCATENATED MODULE: ./src/lineage/export-briefing.js
|
|
234
|
+
// export-briefing.js — M4 deliverable #7 (FR-501 §14, DFG-035): Executive
|
|
235
|
+
// Risk Story Mode. Turns Task 1's decision-story.js#rankFlows output into a
|
|
236
|
+
// 5-chapter Markdown narrative (`emitDecisionStory`), covering 6 audience
|
|
237
|
+
// modes that change wording/verbosity only — never the underlying ranking,
|
|
238
|
+
// facts, or chapter order (decision-story.js's own binding constraint:
|
|
239
|
+
// "never represents an uncalibrated score as expected financial loss or
|
|
240
|
+
// breach probability" extends here to "never represents a re-worded fact
|
|
241
|
+
// as a different fact").
|
|
242
|
+
//
|
|
243
|
+
// Chapter design (grounded in decision-story.js's real factor vocabulary
|
|
244
|
+
// and the DataFlowGraph v1 schema's real top-level fields — see this
|
|
245
|
+
// file's own review notes below for exactly which fields each chapter
|
|
246
|
+
// reads):
|
|
247
|
+
// 1. Scope & Confidence — graph.scope/scanHealth/coverage/limitations
|
|
248
|
+
// 2. Sensitive-Data Footprint — rankFlows grouped by sensitivity tier
|
|
249
|
+
// 3. External Exposure — externality.tier === 'external' flows
|
|
250
|
+
// 4. Control & Governance Gaps — controlVerdict.tier !== 'protected' flows
|
|
251
|
+
// 5. Change & Decisions Needed — NO new/worsened-flow claims (changeRecency
|
|
252
|
+
// is always unavailable per decision-story.js); an explicit "no
|
|
253
|
+
// historical baseline" disclosure instead, plus every flow whose
|
|
254
|
+
// policyState is manual_review_required/prohibited as a real,
|
|
255
|
+
// currently-decision-relevant fact.
|
|
256
|
+
//
|
|
257
|
+
// Markdown-escaping discipline (BLOCKING-1 precedent, export-privacy.js):
|
|
258
|
+
// chapter 4 interpolates the exact same flow.governanceRefs operator prose
|
|
259
|
+
// DPIA/RoPA does, so the exact same injection risk applies. _mdInline/
|
|
260
|
+
// _mdCell/_mdCode are reimplemented LOCALLY here (not imported from
|
|
261
|
+
// export-privacy.js, which does not export them either) per this
|
|
262
|
+
// codebase's established per-module-owns-its-own-escaping-helpers
|
|
263
|
+
// precedent, and are applied to every governance value and every
|
|
264
|
+
// graph-derived label/name this file interpolates.
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
const DECISION_STORY_VERSION = '1.0.0';
|
|
273
|
+
|
|
274
|
+
const AUDIENCE_MODES = Object.freeze(['board', 'ciso', 'privacy', 'compliance', 'regulator', 'technical']);
|
|
275
|
+
|
|
276
|
+
// --- Local Markdown-escaping helpers (mirror export-privacy.js's own
|
|
277
|
+
// _mdInline/_mdCell/_mdCode verbatim — see this file's header for why they
|
|
278
|
+
// are not imported across modules). ---
|
|
279
|
+
|
|
280
|
+
/** Collapse embedded newlines to spaces — an unescaped newline in an
|
|
281
|
+
* interpolated value (operator-supplied governance prose, or a
|
|
282
|
+
* source-derived label) would otherwise break out of its Markdown line and
|
|
283
|
+
* inject arbitrary content (e.g. a fake heading) mid-document. */
|
|
284
|
+
function _mdInline(value) {
|
|
285
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** _mdInline, plus pipe-escaping for a Markdown table cell — an unescaped
|
|
289
|
+
* `|` in a cell value shifts every later column in that row. Backslashes
|
|
290
|
+
* are escaped FIRST: a value already containing a literal `\|` would
|
|
291
|
+
* otherwise become `\\|` — an escaped backslash followed by a still-live
|
|
292
|
+
* `|` column delimiter. */
|
|
293
|
+
function _mdCell(value) {
|
|
294
|
+
return _mdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Wrap a value in a Markdown inline-code span, safe even when the value
|
|
298
|
+
* itself contains backticks — CommonMark's rule: the fence must be one
|
|
299
|
+
* backtick longer than the longest run of consecutive backticks anywhere
|
|
300
|
+
* in the content, padded with a space on each side. */
|
|
301
|
+
function _mdCode(value) {
|
|
302
|
+
const s = _mdInline(value);
|
|
303
|
+
const runs = s.match(/`+/g);
|
|
304
|
+
const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
|
|
305
|
+
if (maxRun === 0) return `\`${s}\``;
|
|
306
|
+
const fence = '`'.repeat(maxRun + 1);
|
|
307
|
+
return `${fence} ${s} ${fence}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- Audience-mode wording table. Controls prose register and verbosity
|
|
311
|
+
// ONLY — see this file's own header. `chapter2Cap` is FR-501's own "maximum
|
|
312
|
+
// of seven primary observations" requirement, scoped to Chapter 2's
|
|
313
|
+
// per-flow observation list (never to the chapter's own aggregate
|
|
314
|
+
// summaries, which always report the full group). ---
|
|
315
|
+
const _AUDIENCE_WORDING = Object.freeze({
|
|
316
|
+
board: {
|
|
317
|
+
label: 'Board Briefing',
|
|
318
|
+
registerNote: 'Written for board-level review: plain language, decision-focused, capped at the primary observations that matter most.',
|
|
319
|
+
chapter2Cap: 7,
|
|
320
|
+
verbose: false,
|
|
321
|
+
},
|
|
322
|
+
ciso: {
|
|
323
|
+
label: 'CISO Briefing',
|
|
324
|
+
registerNote: 'Written for security leadership: risk-prioritized, control- and evidence-focused.',
|
|
325
|
+
chapter2Cap: null,
|
|
326
|
+
verbose: true,
|
|
327
|
+
},
|
|
328
|
+
privacy: {
|
|
329
|
+
label: 'Privacy Officer Briefing',
|
|
330
|
+
registerNote: 'Written for privacy/DPO review: data-class, AI-processing-context, and governance-gap focused.',
|
|
331
|
+
chapter2Cap: null,
|
|
332
|
+
verbose: true,
|
|
333
|
+
},
|
|
334
|
+
compliance: {
|
|
335
|
+
label: 'Compliance Briefing',
|
|
336
|
+
registerNote: 'Written for compliance review: policy-state and control-gap focused.',
|
|
337
|
+
chapter2Cap: null,
|
|
338
|
+
verbose: true,
|
|
339
|
+
},
|
|
340
|
+
regulator: {
|
|
341
|
+
label: 'Regulator-Facing Briefing',
|
|
342
|
+
registerNote: 'Written for external regulatory review: formal register, full evidentiary caveats preserved throughout.',
|
|
343
|
+
chapter2Cap: null,
|
|
344
|
+
verbose: true,
|
|
345
|
+
},
|
|
346
|
+
technical: {
|
|
347
|
+
label: 'Technical Briefing',
|
|
348
|
+
// Review finding (RECOMMENDED, fixed): the previous wording ("every
|
|
349
|
+
// factor tier shown") was false — only sensitivity/externality/
|
|
350
|
+
// controlVerdict are ever rendered per-flow; aiUse/breadth/
|
|
351
|
+
// evidenceConfidence affect ranking ORDER only and are never shown
|
|
352
|
+
// per item in any mode. Reworded to describe what is actually true.
|
|
353
|
+
registerNote: 'Full technical detail: no observation cap, verbose tables shown. Sensitivity, externality, and control-verdict tiers are shown per flow; all nine ranking factors (see the list at the end of this report) are considered when ordering flows, whether or not each is individually displayed.',
|
|
354
|
+
chapter2Cap: null,
|
|
355
|
+
verbose: true,
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const SENSITIVITY_TIER_ORDER = ['critical', 'high', 'medium', 'low', 'none'];
|
|
360
|
+
const SENSITIVITY_LABELS = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', none: 'None / unclassified' };
|
|
361
|
+
|
|
362
|
+
const POLICY_LABELS = {
|
|
363
|
+
prohibited: 'prohibited by policy',
|
|
364
|
+
permitted: 'permitted',
|
|
365
|
+
conditionally_permitted: 'conditionally permitted',
|
|
366
|
+
manual_review_required: 'needs manual review',
|
|
367
|
+
not_evaluated: 'not yet evaluated against policy',
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
function _primaryDataElement(flow, graph) {
|
|
371
|
+
return (graph.dataElements ?? []).find((d) => (flow.dataElementIds ?? []).includes(d.id)) ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function _policyLabel(verdict, wording) {
|
|
375
|
+
return wording.verbose ? `\`${verdict}\`` : (POLICY_LABELS[verdict] ?? verdict);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function _flowHasUnencryptedTransit(flow, edgesById) {
|
|
379
|
+
return (flow.edgeIds ?? []).some((id) => edgesById.get(id)?.protection?.transit?.verdict === 'unprotected');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function _flowHasAtRestUnknown(flow, edgesById) {
|
|
383
|
+
return (flow.edgeIds ?? []).some((id) => {
|
|
384
|
+
const v = edgesById.get(id)?.protection?.atRest?.verdict;
|
|
385
|
+
return v === 'not_assessed' || v === 'unknown';
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- Chapter 1: Scope & Confidence — direct reads off the graph's own
|
|
390
|
+
// required top-level envelope fields (scope/scanHealth/coverage/
|
|
391
|
+
// limitations; dataflow-graph.schema.json's own top-level `required` list).
|
|
392
|
+
function _chapter1ScopeConfidence(graph, wording, hasFilter) {
|
|
393
|
+
const lines = [];
|
|
394
|
+
lines.push('## Chapter 1: Scope & Confidence');
|
|
395
|
+
lines.push('');
|
|
396
|
+
const scope = graph.scope ?? {};
|
|
397
|
+
const scanHealth = graph.scanHealth ?? {};
|
|
398
|
+
const coverage = graph.coverage ?? {};
|
|
399
|
+
const limitations = graph.limitations ?? [];
|
|
400
|
+
|
|
401
|
+
lines.push(`This report is derived from a **${_mdInline(scope.source ?? 'unknown')}**-sourced data flow graph${scope.source === 'fixture' ? ' — illustrative demo data, not a real scan.' : '.'}`);
|
|
402
|
+
lines.push('');
|
|
403
|
+
lines.push(`Scan health: **${_mdInline(scanHealth.status ?? 'unknown')}**${scanHealth.reason ? ` (${_mdInline(scanHealth.reason)})` : ''}.`);
|
|
404
|
+
lines.push('');
|
|
405
|
+
if (graph.graphId) { lines.push(`Graph identity: \`${_mdInline(graph.graphId)}\`.`); lines.push(''); }
|
|
406
|
+
|
|
407
|
+
// Review finding (RECOMMENDED, fixed): --filter narrows every chapter's
|
|
408
|
+
// own flow content (via _filterGraph), but the coverage NUMBERS below
|
|
409
|
+
// still come from the SOURCE graph's own coverage ledger, which
|
|
410
|
+
// _filterGraph never touches (narrowing coverage counts to "how much of
|
|
411
|
+
// a filtered subgraph was covered" is not a well-defined question — the
|
|
412
|
+
// ledger is a whole-scan artifact). Left unlabeled, this read as an
|
|
413
|
+
// unscoped whole-scan ledger sitting beside a scoped, filtered chapter
|
|
414
|
+
// set with no indication either way — AC-25's own "coverage limitations
|
|
415
|
+
// remain prominent" requirement.
|
|
416
|
+
if (hasFilter) {
|
|
417
|
+
lines.push('**This report is scoped to a filtered subset of the graph** (`--filter`). The coverage figures below describe the WHOLE underlying scan, not just this filtered scope — Chapters 2 through 5 report only the filtered flows.');
|
|
418
|
+
lines.push('');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const sources = coverage.sources ?? {};
|
|
422
|
+
const sinks = coverage.sinks ?? {};
|
|
423
|
+
lines.push('**Coverage at a glance:**');
|
|
424
|
+
lines.push('');
|
|
425
|
+
lines.push(`- Sources matched: ${sources.matched ?? 0} (${sources.unseedable ?? 0} unseedable)`);
|
|
426
|
+
lines.push(`- Sink call sites: ${sinks.callStatementSites ?? 0} (${sinks.connected ?? 0} connected, ${sinks.disconnected ?? 0} disconnected, ${sinks.unsupportedSites ?? 0} unsupported)`);
|
|
427
|
+
if (coverage.degradedTerminals) lines.push(`- Degraded analysis terminals: ${coverage.degradedTerminals}`);
|
|
428
|
+
if (coverage.unresolvedDestinations) lines.push(`- Unresolved destinations: ${coverage.unresolvedDestinations}`);
|
|
429
|
+
if (coverage.pathBudgetTruncation) lines.push('- One or more path reconstructions hit a budget limit — treat affected flows as a lower bound, not a complete picture.');
|
|
430
|
+
lines.push('');
|
|
431
|
+
|
|
432
|
+
if (wording.verbose && Array.isArray(coverage.languages) && coverage.languages.length) {
|
|
433
|
+
lines.push('**Languages analyzed:**');
|
|
434
|
+
lines.push('');
|
|
435
|
+
for (const l of coverage.languages) lines.push(`- ${_mdInline(l.language)}: ${l.filesAnalyzed ?? 0}/${l.filesExpected ?? 0} file(s) analyzed`);
|
|
436
|
+
lines.push('');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (limitations.length) {
|
|
440
|
+
lines.push('**Known limitations of this analysis:**');
|
|
441
|
+
lines.push('');
|
|
442
|
+
for (const l of limitations) lines.push(`- ${_mdInline(l)}`);
|
|
443
|
+
lines.push('');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
lines.push('**Ranking factors honestly unavailable in this milestone:** `recipientJurisdiction` (needs a recipient-profile registry, not yet built) and `changeRecency` (needs the Data-Flow Time Machine, not yet built) are disclosed as unavailable on every flow scored below — never fabricated, never silently dropped from the factor list.');
|
|
447
|
+
lines.push('');
|
|
448
|
+
|
|
449
|
+
return { id: 'scope-confidence', number: 1, title: 'Scope & Confidence', itemCount: limitations.length, markdown: lines.join('\n') };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Chapter 2: Sensitive-Data Footprint — rankFlows grouped by the
|
|
453
|
+
// sensitivity factor's own tier. Every ranked flow is pushed into ITS OWN
|
|
454
|
+
// tier's bucket unconditionally, including 'none' — mirrors
|
|
455
|
+
// export-privacy.js#_groupRowsByClass's own never-silently-drop-a-flow
|
|
456
|
+
// discipline (a bare `if (tier !== 'none') continue` would make an
|
|
457
|
+
// unclassified flow genuinely invisible from the whole chapter, not merely
|
|
458
|
+
// ungrouped).
|
|
459
|
+
function _chapter2SensitiveFootprint(ranked, graph, nodesById, wording) {
|
|
460
|
+
const lines = [];
|
|
461
|
+
lines.push('## Chapter 2: Sensitive-Data Footprint');
|
|
462
|
+
lines.push('');
|
|
463
|
+
if (ranked.length === 0) {
|
|
464
|
+
lines.push('No flows were identified in this graph scope — nothing to report.');
|
|
465
|
+
return { id: 'sensitive-footprint', number: 2, title: 'Sensitive-Data Footprint', itemCount: 0, markdown: lines.join('\n') };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const grouped = new Map();
|
|
469
|
+
for (const rf of ranked) {
|
|
470
|
+
const tier = rf.factors.sensitivity.tier;
|
|
471
|
+
if (!grouped.has(tier)) grouped.set(tier, []);
|
|
472
|
+
grouped.get(tier).push(rf);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const cap = wording.chapter2Cap;
|
|
476
|
+
let shown = 0;
|
|
477
|
+
let truncated = false;
|
|
478
|
+
|
|
479
|
+
for (const tier of SENSITIVITY_TIER_ORDER) {
|
|
480
|
+
const group = grouped.get(tier);
|
|
481
|
+
if (!group || group.length === 0) continue;
|
|
482
|
+
if (cap != null && shown >= cap) { truncated = true; break; }
|
|
483
|
+
|
|
484
|
+
lines.push(`### ${SENSITIVITY_LABELS[tier] ?? tier} (${group.length} flow(s))`);
|
|
485
|
+
lines.push('');
|
|
486
|
+
|
|
487
|
+
// Group-level aggregates always reflect the FULL group, regardless of
|
|
488
|
+
// the per-item cap below — the cap narrows the observation LIST, never
|
|
489
|
+
// the honest summary counts.
|
|
490
|
+
const dataClasses = new Set();
|
|
491
|
+
const destinations = new Set();
|
|
492
|
+
const aiContexts = new Set();
|
|
493
|
+
for (const rf of group) {
|
|
494
|
+
const de = _primaryDataElement(rf.flow, graph);
|
|
495
|
+
for (const c of de?.dataClasses ?? []) dataClasses.add(c);
|
|
496
|
+
for (const c of de?.aiContexts ?? []) aiContexts.add(c);
|
|
497
|
+
const snk = nodesById.get(rf.flow.sink);
|
|
498
|
+
if (snk) destinations.add(snk.label || snk.id);
|
|
499
|
+
}
|
|
500
|
+
if (dataClasses.size) lines.push(`- Data classes: ${[...dataClasses].sort().map(_mdCode).join(', ')}`);
|
|
501
|
+
if (destinations.size) lines.push(`- Destinations reached: ${[...destinations].sort().map(_mdCode).join(', ')}`);
|
|
502
|
+
if (wording.verbose && aiContexts.size) lines.push(`- AI processing contexts: ${[...aiContexts].sort().map(_mdCode).join(', ')}`);
|
|
503
|
+
lines.push('');
|
|
504
|
+
|
|
505
|
+
for (const rf of group) {
|
|
506
|
+
if (cap != null && shown >= cap) { truncated = true; break; }
|
|
507
|
+
const de = _primaryDataElement(rf.flow, graph);
|
|
508
|
+
const snk = nodesById.get(rf.flow.sink);
|
|
509
|
+
const controlNote = wording.verbose ? ` (control: \`${rf.factors.controlVerdict.tier}\`)` : '';
|
|
510
|
+
lines.push(`- ${_mdCode(de?.name ?? '(unnamed field)')} -> ${_mdCode(snk?.label ?? snk?.id ?? 'unknown destination')}${controlNote}`);
|
|
511
|
+
shown++;
|
|
512
|
+
}
|
|
513
|
+
lines.push('');
|
|
514
|
+
if (truncated) break;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (truncated) {
|
|
518
|
+
lines.push(`_${ranked.length - shown} additional flow(s) not shown — capped at ${cap} primary observations for this audience mode._`);
|
|
519
|
+
lines.push('');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return { id: 'sensitive-footprint', number: 2, title: 'Sensitive-Data Footprint', itemCount: shown, markdown: lines.join('\n') };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- Chapter 3: External Exposure — externality.tier === 'external' flows,
|
|
526
|
+
// PLUS any flow whose sink node is genuinely FR-203-unresolved
|
|
527
|
+
// (node.kind === 'unresolved'). Final whole-branch review finding
|
|
528
|
+
// (BLOCKING, fixed): this chapter originally filtered on 'external' alone,
|
|
529
|
+
// but FR-203's unresolved-destination path (sink-registry.js) sets
|
|
530
|
+
// externality:'unknown' on the SAME return object as kind:'unresolved' —
|
|
531
|
+
// never 'external' — so EVERY unresolved-destination flow (including
|
|
532
|
+
// every real AI-provider flow in this JS catalog, since every AI SDK entry
|
|
533
|
+
// is a member-chain receiver that always triggers FR-203) was silently
|
|
534
|
+
// dropped from the whole chapter, and the chapter's own "unresolved
|
|
535
|
+
// destinations"/"AI providers" bullets were unreachable dead code.
|
|
536
|
+
//
|
|
537
|
+
// Deliberately narrower than "any externality:'unknown' flow": a plain
|
|
538
|
+
// resolved store-kind sink (a local database write) ALSO carries
|
|
539
|
+
// externality:'unknown' by category design (sink-registry.js's
|
|
540
|
+
// CATEGORY_EXTERNALITY — "could be local or third-party-managed, the
|
|
541
|
+
// registry can't tell"), which is a genuinely different, non-security
|
|
542
|
+
// concept from FR-203's "the destination itself could not be statically
|
|
543
|
+
// determined." Gating on node.kind === 'unresolved' targets the real
|
|
544
|
+
// FR-203 gap the review found without also pulling in every ordinary
|
|
545
|
+
// database/file/object-storage write in the graph.
|
|
546
|
+
function _chapter3ExternalExposure(ranked, graph, nodesById, wording) {
|
|
547
|
+
const lines = [];
|
|
548
|
+
lines.push('## Chapter 3: External Exposure');
|
|
549
|
+
lines.push('');
|
|
550
|
+
const exposureFlows = ranked.filter((rf) => {
|
|
551
|
+
if (rf.factors.externality.tier === 'external') return true;
|
|
552
|
+
return nodesById.get(rf.flow.sink)?.kind === 'unresolved';
|
|
553
|
+
});
|
|
554
|
+
if (exposureFlows.length === 0) {
|
|
555
|
+
lines.push('No flows in this graph scope reach an external or unresolved destination.');
|
|
556
|
+
return { id: 'external-exposure', number: 3, title: 'External Exposure', itemCount: 0, markdown: lines.join('\n') };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const resolvedExternal = exposureFlows.filter((rf) => rf.factors.externality.tier === 'external');
|
|
560
|
+
const unresolvedTier = exposureFlows.filter((rf) => nodesById.get(rf.flow.sink)?.kind === 'unresolved');
|
|
561
|
+
|
|
562
|
+
const destinations = new Set();
|
|
563
|
+
const aiProviders = new Set();
|
|
564
|
+
const unresolved = new Set();
|
|
565
|
+
for (const rf of exposureFlows) {
|
|
566
|
+
const snk = nodesById.get(rf.flow.sink);
|
|
567
|
+
if (!snk) continue;
|
|
568
|
+
destinations.add(snk.label || snk.id);
|
|
569
|
+
if (AI_SINK_SUBTYPES.includes(snk.subtype)) aiProviders.add(snk.label || snk.id);
|
|
570
|
+
if (snk.kind === 'unresolved') unresolved.add(snk.label || snk.id);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
lines.push(`${exposureFlows.length} flow(s) reach an external or unresolved destination — ${resolvedExternal.length} resolved external, ${unresolvedTier.length} destination not statically resolved.`);
|
|
574
|
+
lines.push('');
|
|
575
|
+
if (destinations.size) { lines.push(`**Destinations:** ${[...destinations].sort().map(_mdCode).join(', ')}`); lines.push(''); }
|
|
576
|
+
if (aiProviders.size) { lines.push(`**AI providers/agents/tools among them:** ${[...aiProviders].sort().map(_mdCode).join(', ')}`); lines.push(''); }
|
|
577
|
+
if (unresolved.size) { lines.push(`**Destination not statically resolved (could not be determined by analysis):** ${[...unresolved].sort().map(_mdCode).join(', ')}`); lines.push(''); }
|
|
578
|
+
|
|
579
|
+
if (wording.verbose) {
|
|
580
|
+
lines.push('| Data element | Destination | Externality | Sensitivity | Control |');
|
|
581
|
+
lines.push('|---|---|---|---|---|');
|
|
582
|
+
for (const rf of exposureFlows) {
|
|
583
|
+
const de = _primaryDataElement(rf.flow, graph);
|
|
584
|
+
const snk = nodesById.get(rf.flow.sink);
|
|
585
|
+
const cells = [de?.name ?? '(unnamed field)', snk?.label ?? snk?.id ?? 'unknown', rf.factors.externality.tier, rf.factors.sensitivity.tier, rf.factors.controlVerdict.tier];
|
|
586
|
+
lines.push(`| ${cells.map(_mdCell).join(' | ')} |`);
|
|
587
|
+
}
|
|
588
|
+
lines.push('');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return { id: 'external-exposure', number: 3, title: 'External Exposure', itemCount: exposureFlows.length, markdown: lines.join('\n') };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// --- Chapter 4: Control & Governance Gaps — controlVerdict.tier !==
|
|
595
|
+
// 'protected' flows, sub-categorized into raw-logging / unencrypted-
|
|
596
|
+
// transit / at-rest-unknown / governance-field gaps / policy conflicts.
|
|
597
|
+
function _chapter4ControlGovernanceGaps(ranked, graph, nodesById, edgesById, wording) {
|
|
598
|
+
const lines = [];
|
|
599
|
+
lines.push('## Chapter 4: Control & Governance Gaps');
|
|
600
|
+
lines.push('');
|
|
601
|
+
const gaps = ranked.filter((rf) => rf.factors.controlVerdict.tier !== 'protected');
|
|
602
|
+
if (gaps.length === 0) {
|
|
603
|
+
lines.push('No control or governance gaps were identified in this graph scope.');
|
|
604
|
+
return { id: 'control-governance-gaps', number: 4, title: 'Control & Governance Gaps', itemCount: 0, markdown: lines.join('\n') };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const rawLogging = [];
|
|
608
|
+
const unencryptedTransit = [];
|
|
609
|
+
const atRestUnknown = [];
|
|
610
|
+
const governanceGapFlows = [];
|
|
611
|
+
// Final whole-branch review finding (BLOCKING, fixed): this used to be
|
|
612
|
+
// ONE bucket, `flow.policyVerdict !== 'permitted'`, rendered under
|
|
613
|
+
// "policy conflict (not permitted)". With no
|
|
614
|
+
// .agentic-security/privacy-policy.json on disk — the default for
|
|
615
|
+
// essentially every user — every flow reads `not_evaluated`, so every
|
|
616
|
+
// flow was reported as being in policy conflict: an unsupported
|
|
617
|
+
// compliance claim (AC-25's own "no unsupported ... compliance claim
|
|
618
|
+
// appears") about a flow no policy was ever applied to, AND a direct
|
|
619
|
+
// contradiction of Chapter 5, which correctly treats not_evaluated as
|
|
620
|
+
// "no decision needed" (decision-story.js's own _TIER_RANK already
|
|
621
|
+
// distinguishes these 4 states; this chapter was the one place that
|
|
622
|
+
// collapsed them). Split into three honestly-labeled buckets.
|
|
623
|
+
// manualReviewNeeded and conditionally_permitted have no real producer
|
|
624
|
+
// in the current pipeline (confirmed directly against
|
|
625
|
+
// graph-builder.js's own policyVerdict assignment site, Task 2's own
|
|
626
|
+
// disclosed finding) — only not_evaluated/permitted/prohibited are
|
|
627
|
+
// ever emitted from real code today. This branch's own correctness
|
|
628
|
+
// rests on the hand-traced symmetry with policyConflicts/notEvaluated
|
|
629
|
+
// below, not a real-graph regression test, for the same reason Task 2
|
|
630
|
+
// never fabricated a hand-built graph carrying an
|
|
631
|
+
// unreachable-in-practice verdict just to exercise this one branch
|
|
632
|
+
// (scoped re-review of the final-review fix round, noted but
|
|
633
|
+
// deliberately not "fixed" with a fake fixture).
|
|
634
|
+
const policyConflicts = [];
|
|
635
|
+
const manualReviewNeeded = [];
|
|
636
|
+
const notEvaluated = [];
|
|
637
|
+
|
|
638
|
+
for (const rf of gaps) {
|
|
639
|
+
const { flow } = rf;
|
|
640
|
+
const snk = nodesById.get(flow.sink);
|
|
641
|
+
if (flow.handling === 'raw' && snk?.kind === 'log') rawLogging.push(rf);
|
|
642
|
+
if (_flowHasUnencryptedTransit(flow, edgesById)) unencryptedTransit.push(rf);
|
|
643
|
+
if (_flowHasAtRestUnknown(flow, edgesById)) atRestUnknown.push(rf);
|
|
644
|
+
const gapFields = privacy_governance/* GOVERNANCE_FIELDS */.n4.filter((f) => (flow.governanceRefs?.[f]?.source ?? 'manual_required') === 'manual_required');
|
|
645
|
+
if (gapFields.length) governanceGapFlows.push({ rf, gapFields });
|
|
646
|
+
if (flow.policyVerdict === 'prohibited' || flow.policyVerdict === 'conditionally_permitted') policyConflicts.push(rf);
|
|
647
|
+
else if (flow.policyVerdict === 'manual_review_required') manualReviewNeeded.push(rf);
|
|
648
|
+
else if (flow.policyVerdict === 'not_evaluated') notEvaluated.push(rf);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
lines.push(`${gaps.length} flow(s) do not carry a fully protected control verdict.`);
|
|
652
|
+
lines.push('');
|
|
653
|
+
|
|
654
|
+
const section = (title, items, render) => {
|
|
655
|
+
if (!items.length) return;
|
|
656
|
+
lines.push(`**${title} (${items.length}):**`);
|
|
657
|
+
lines.push('');
|
|
658
|
+
for (const item of items) lines.push(`- ${render(item)}`);
|
|
659
|
+
lines.push('');
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Every bullet includes its destination (N-3, review finding): the same
|
|
663
|
+
// field reaching two different sinks previously rendered two
|
|
664
|
+
// byte-identical bullets in a list, indistinguishable from a
|
|
665
|
+
// duplication bug.
|
|
666
|
+
const withDestination = (rf) => {
|
|
667
|
+
const de = _primaryDataElement(rf.flow, graph);
|
|
668
|
+
const snk = nodesById.get(rf.flow.sink);
|
|
669
|
+
return `${_mdCode(de?.name ?? '(unnamed field)')} -> ${_mdCode(snk?.label ?? snk?.id ?? 'unknown destination')}`;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
section('Raw data reaching a log sink', rawLogging, withDestination);
|
|
673
|
+
section('Flows with unencrypted transit', unencryptedTransit, withDestination);
|
|
674
|
+
section('Flows with at-rest protection unknown', atRestUnknown, withDestination);
|
|
675
|
+
|
|
676
|
+
// A real Markdown TABLE (not a bullet list), deliberately — this is the
|
|
677
|
+
// one place in this chapter that interpolates the operator-supplied
|
|
678
|
+
// governance PROSE VALUES DPIA/RoPA read from flow.governanceRefs
|
|
679
|
+
// (export-privacy.js's own BLOCKING-1 precedent: an unescaped `|` or
|
|
680
|
+
// embedded newline in one of these values corrupts a table's column
|
|
681
|
+
// alignment or injects a fake heading). Every cell goes through _mdCell.
|
|
682
|
+
if (governanceGapFlows.length) {
|
|
683
|
+
lines.push(`**Flows with governance fields requiring manual input (${governanceGapFlows.length}):**`);
|
|
684
|
+
lines.push('');
|
|
685
|
+
const header = ['Data element', 'Destination', 'Missing fields', 'Provided values'];
|
|
686
|
+
lines.push(`| ${header.join(' | ')} |`);
|
|
687
|
+
lines.push(`|${header.map(() => '---').join('|')}|`);
|
|
688
|
+
for (const { rf, gapFields } of governanceGapFlows) {
|
|
689
|
+
const de = _primaryDataElement(rf.flow, graph);
|
|
690
|
+
const snk = nodesById.get(rf.flow.sink);
|
|
691
|
+
const provided = privacy_governance/* GOVERNANCE_FIELDS */.n4
|
|
692
|
+
.filter((f) => rf.flow.governanceRefs?.[f]?.source === 'operator_provided')
|
|
693
|
+
.map((f) => `${f}: ${rf.flow.governanceRefs[f].value}`)
|
|
694
|
+
.join('; ');
|
|
695
|
+
const cells = [de?.name ?? '(unnamed field)', snk?.label ?? snk?.id ?? 'unknown destination', gapFields.join(', '), provided || '(none)'];
|
|
696
|
+
lines.push(`| ${cells.map(_mdCell).join(' | ')} |`);
|
|
697
|
+
}
|
|
698
|
+
lines.push('');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const withPolicyLabel = (rf) => `${withDestination(rf)} — ${_mdInline(_policyLabel(rf.flow.policyVerdict, wording))}`;
|
|
702
|
+
section('Flows prohibited or conditionally permitted by policy', policyConflicts, withPolicyLabel);
|
|
703
|
+
section('Flows requiring manual policy review', manualReviewNeeded, withPolicyLabel);
|
|
704
|
+
section('Flows not yet evaluated against policy (no policy configured for this scan)', notEvaluated, withPolicyLabel);
|
|
705
|
+
|
|
706
|
+
return { id: 'control-governance-gaps', number: 4, title: 'Control & Governance Gaps', itemCount: gaps.length, markdown: lines.join('\n') };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// --- Chapter 5: Change & Decisions Needed — NO new/worsened-flow claims
|
|
710
|
+
// (changeRecency is always unavailable, decision-story.js's own header).
|
|
711
|
+
// A prominent disclosure instead (AC-25's "coverage limitations remain
|
|
712
|
+
// prominent"), plus every flow whose policyState is manual_review_required
|
|
713
|
+
// or prohibited, presented as a real, currently-decision-relevant fact.
|
|
714
|
+
function _chapter5ChangeAndDecisions(ranked, graph, wording) {
|
|
715
|
+
const lines = [];
|
|
716
|
+
lines.push('## Chapter 5: Change & Decisions Needed');
|
|
717
|
+
lines.push('');
|
|
718
|
+
lines.push('**No historical baseline is available in this milestone.** Change-over-time claims (a flow being new or having gotten worse since a prior scan) require the Data-Flow Time Machine, which has not shipped yet — nothing below claims a flow is new or has regressed. Every item is a real, currently-decision-relevant fact about the graph as it stands today.');
|
|
719
|
+
lines.push('');
|
|
720
|
+
|
|
721
|
+
const decisions = ranked.filter((rf) => rf.factors.policyState.tier === 'manual_review_required' || rf.factors.policyState.tier === 'prohibited');
|
|
722
|
+
if (decisions.length === 0) {
|
|
723
|
+
lines.push('No flows currently require a manual policy decision.');
|
|
724
|
+
lines.push('');
|
|
725
|
+
} else {
|
|
726
|
+
lines.push(`**Decisions needed now (${decisions.length}):**`);
|
|
727
|
+
lines.push('');
|
|
728
|
+
for (const rf of decisions) {
|
|
729
|
+
const de = _primaryDataElement(rf.flow, graph);
|
|
730
|
+
lines.push(`- ${_mdCode(de?.name ?? '(unnamed field)')} — ${_mdInline(_policyLabel(rf.flow.policyVerdict, wording))}`);
|
|
731
|
+
}
|
|
732
|
+
lines.push('');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
id: 'change-and-decisions', number: 5, title: 'Change & Decisions Needed', itemCount: decisions.length,
|
|
737
|
+
markdown: lines.join('\n'), decisions,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function _renderMarkdown(record, wording, chapters) {
|
|
742
|
+
const lines = [];
|
|
743
|
+
lines.push(`# Executive Risk Story — ${wording.label}`);
|
|
744
|
+
lines.push('');
|
|
745
|
+
// Full digest, never truncated (review finding, RECOMMENDED, fixed) —
|
|
746
|
+
// AC-25's own "preserves graph digest and reproducibility metadata"
|
|
747
|
+
// requirement; a truncated digest is a weaker reproducibility claim
|
|
748
|
+
// than the graph itself makes.
|
|
749
|
+
lines.push(`Generated ${_mdInline(record.generatedAt)} · graph digest \`${_mdInline(record.graphDigest)}\` · audience mode \`${record.audienceMode}\`.`);
|
|
750
|
+
lines.push('');
|
|
751
|
+
lines.push(wording.registerNote);
|
|
752
|
+
lines.push('');
|
|
753
|
+
lines.push('This report is generated from real, code-derived data flow analysis. It is a decision-support artifact, not a compliance certification — see Chapter 1 for scope and coverage limitations.');
|
|
754
|
+
lines.push('');
|
|
755
|
+
lines.push('---');
|
|
756
|
+
lines.push('');
|
|
757
|
+
for (const ch of chapters) {
|
|
758
|
+
lines.push(ch.markdown);
|
|
759
|
+
lines.push('');
|
|
760
|
+
}
|
|
761
|
+
lines.push('---');
|
|
762
|
+
lines.push('');
|
|
763
|
+
lines.push(`Ranking factors considered, in priority order: ${record.rankingFactors.map((f) => `\`${f}\``).join(', ')}.`);
|
|
764
|
+
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Emit the 5-chapter Executive Risk Story (FR-501 §14). Returns
|
|
769
|
+
* {record, markdown} — `record` is a DecisionStory (§10.10 extension
|
|
770
|
+
* contract, validated before return, never a DataFlowGraph v1 entity),
|
|
771
|
+
* `markdown` is the human-readable narrative.
|
|
772
|
+
*
|
|
773
|
+
* @param {object} graph a real DataFlowGraph v1 document.
|
|
774
|
+
* @param {object} [opts]
|
|
775
|
+
* @param {{nodeIds:string[], edgeIds:string[]}} [opts.filter] the same
|
|
776
|
+
* {nodeIds, edgeIds} shape export-json.js/export-csv.js/export-privacy.js
|
|
777
|
+
* all use — narrows the GRAPH (via export-json.js's own _filterGraph)
|
|
778
|
+
* before flows are ranked. The graph DIGEST always identifies the
|
|
779
|
+
* SOURCE graph, never the filtered view — export-json.js's own
|
|
780
|
+
* established precedent (two different filters of one scan must report
|
|
781
|
+
* the same digest).
|
|
782
|
+
* @param {string} [opts.generatedAt] falls back to `graph.generatedAt` —
|
|
783
|
+
* NEVER wall-clock, so the record's own generatedAt/id stay deterministic
|
|
784
|
+
* under AGENTIC_SECURITY_DETERMINISTIC=1 test fixtures the same way
|
|
785
|
+
* every other M4 exporter's regression tests already rely on.
|
|
786
|
+
* @param {string} [opts.audienceMode] one of AUDIENCE_MODES, default
|
|
787
|
+
* 'technical'. Controls prose register/verbosity only — see this file's
|
|
788
|
+
* own header for the binding "never change facts" constraint.
|
|
789
|
+
* @param {string[]} [opts.factorOrder] overrides decision-story.js's own
|
|
790
|
+
* default RANKING_FACTORS priority sequence for THIS story — threaded
|
|
791
|
+
* straight through to rankFlows, satisfying the PRD's own "transparent
|
|
792
|
+
* CONFIGURABLE factors" requirement at the API level (review finding,
|
|
793
|
+
* RECOMMENDED, fixed — this was previously never threaded, so nothing
|
|
794
|
+
* external could reach rankFlows's own opts.factorOrder at all). Not yet
|
|
795
|
+
* exposed as its own `dataflow export` CLI flag — see commands/
|
|
796
|
+
* dataflow.md's own disclosure of that narrower, still-open gap.
|
|
797
|
+
*/
|
|
798
|
+
function emitDecisionStory(graph, opts = {}) {
|
|
799
|
+
const audienceMode = opts.audienceMode ?? 'technical';
|
|
800
|
+
if (!AUDIENCE_MODES.includes(audienceMode)) {
|
|
801
|
+
throw new Error(`emitDecisionStory: unrecognized audienceMode "${audienceMode}" — must be one of ${AUDIENCE_MODES.join('|')}`);
|
|
802
|
+
}
|
|
803
|
+
const wording = _AUDIENCE_WORDING[audienceMode];
|
|
804
|
+
|
|
805
|
+
const scopedGraph = opts.filter ? (0,export_json/* _filterGraph */.e)(graph, opts.filter) : graph;
|
|
806
|
+
const generatedAt = opts.generatedAt ?? graph.generatedAt;
|
|
807
|
+
const nodesById = new Map((scopedGraph.nodes ?? []).map((n) => [n.id, n]));
|
|
808
|
+
const edgesById = new Map((scopedGraph.edges ?? []).map((e) => [e.id, e]));
|
|
809
|
+
const ranked = rankFlows(scopedGraph, opts.factorOrder ? { factorOrder: opts.factorOrder } : undefined);
|
|
810
|
+
|
|
811
|
+
const ch1 = _chapter1ScopeConfidence(scopedGraph, wording, Boolean(opts.filter));
|
|
812
|
+
const ch2 = _chapter2SensitiveFootprint(ranked, scopedGraph, nodesById, wording);
|
|
813
|
+
const ch3 = _chapter3ExternalExposure(ranked, scopedGraph, nodesById, wording);
|
|
814
|
+
const ch4 = _chapter4ControlGovernanceGaps(ranked, scopedGraph, nodesById, edgesById, wording);
|
|
815
|
+
const ch5 = _chapter5ChangeAndDecisions(ranked, scopedGraph, wording);
|
|
816
|
+
const chapters = [ch1, ch2, ch3, ch4, ch5];
|
|
817
|
+
|
|
818
|
+
// Deliberate: the digest always identifies the SOURCE graph this story
|
|
819
|
+
// was taken from, never the filtered `scopedGraph` — mirrors
|
|
820
|
+
// export-json.js#exportGraphJSON's own established rule (AC-25's
|
|
821
|
+
// "preserves graph digest" requirement, satisfied against the graph the
|
|
822
|
+
// caller actually handed in, not a view of it).
|
|
823
|
+
const graphDigest = (0,export_json.computeGraphDigest)(graph);
|
|
824
|
+
|
|
825
|
+
const evidenceGrade = ranked.length === 0 ? 'none' : 'code';
|
|
826
|
+
if (!protection/* EVIDENCE_GRADES */.xE.includes(evidenceGrade)) {
|
|
827
|
+
throw new Error(`emitDecisionStory: internal error — evidenceGrade "${evidenceGrade}" is not a member of EVIDENCE_GRADES`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const scopeQuery = { filter: opts.filter ?? null };
|
|
831
|
+
|
|
832
|
+
const record = {
|
|
833
|
+
id: (0,ids/* storyId */.xj)({ graphDigest, audienceMode, scopeQuery }),
|
|
834
|
+
version: DECISION_STORY_VERSION,
|
|
835
|
+
audienceMode,
|
|
836
|
+
scopeQuery,
|
|
837
|
+
// Strip ch5's internal-only `decisions` array (used below to build
|
|
838
|
+
// record.decisions) before it lands twice on the public record.
|
|
839
|
+
chapters: chapters.map(({ decisions: _decisions, ...c }) => c),
|
|
840
|
+
contributingGraphIds: graph.graphId ? [graph.graphId] : [],
|
|
841
|
+
rankingFactors: RANKING_FACTORS,
|
|
842
|
+
evidenceGrade,
|
|
843
|
+
coverage: scopedGraph.coverage ?? {},
|
|
844
|
+
decisions: ch5.decisions.map((rf) => ({
|
|
845
|
+
flowId: rf.flow.id,
|
|
846
|
+
policyVerdict: rf.flow.policyVerdict,
|
|
847
|
+
dataElementName: _primaryDataElement(rf.flow, scopedGraph)?.name ?? null,
|
|
848
|
+
sinkLabel: nodesById.get(rf.flow.sink)?.label ?? null,
|
|
849
|
+
})),
|
|
850
|
+
generatedAt,
|
|
851
|
+
graphDigest,
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const { valid, errors } = validateDecisionStory(record);
|
|
855
|
+
if (!valid) {
|
|
856
|
+
throw new Error(`emitDecisionStory: internal error — produced an invalid DecisionStory record: ${JSON.stringify(errors)}`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const markdown = _renderMarkdown(record, wording, chapters);
|
|
860
|
+
return { record, markdown };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
/***/ })
|
|
865
|
+
|
|
866
|
+
};
|