guardlink 1.4.2 → 1.4.3
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 +83 -9
- package/README.md +38 -1
- package/dist/agents/config.d.ts +7 -0
- package/dist/agents/config.d.ts.map +1 -1
- package/dist/agents/config.js.map +1 -1
- package/dist/agents/index.d.ts +1 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +1 -1
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/prompts.d.ts +14 -0
- package/dist/agents/prompts.d.ts.map +1 -1
- package/dist/agents/prompts.js +445 -2
- package/dist/agents/prompts.js.map +1 -1
- package/dist/analyze/format.d.ts +72 -0
- package/dist/analyze/format.d.ts.map +1 -0
- package/dist/analyze/format.js +176 -0
- package/dist/analyze/format.js.map +1 -0
- package/dist/analyze/index.d.ts +76 -0
- package/dist/analyze/index.d.ts.map +1 -1
- package/dist/analyze/index.js +165 -2
- package/dist/analyze/index.js.map +1 -1
- package/dist/analyze/prompts.d.ts +3 -2
- package/dist/analyze/prompts.d.ts.map +1 -1
- package/dist/analyze/prompts.js +16 -2
- package/dist/analyze/prompts.js.map +1 -1
- package/dist/analyzer/sarif.d.ts +3 -2
- package/dist/analyzer/sarif.d.ts.map +1 -1
- package/dist/analyzer/sarif.js +29 -3
- package/dist/analyzer/sarif.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +380 -28
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/data.d.ts +11 -0
- package/dist/dashboard/data.d.ts.map +1 -1
- package/dist/dashboard/data.js +12 -0
- package/dist/dashboard/data.js.map +1 -1
- package/dist/dashboard/diagrams.d.ts +81 -12
- package/dist/dashboard/diagrams.d.ts.map +1 -1
- package/dist/dashboard/diagrams.js +750 -362
- package/dist/dashboard/diagrams.js.map +1 -1
- package/dist/dashboard/generate.d.ts +5 -2
- package/dist/dashboard/generate.d.ts.map +1 -1
- package/dist/dashboard/generate.js +2516 -244
- package/dist/dashboard/generate.js.map +1 -1
- package/dist/diff/engine.d.ts +2 -1
- package/dist/diff/engine.d.ts.map +1 -1
- package/dist/diff/engine.js +3 -2
- package/dist/diff/engine.js.map +1 -1
- package/dist/init/index.d.ts.map +1 -1
- package/dist/init/index.js +24 -5
- package/dist/init/index.js.map +1 -1
- package/dist/init/migrate.d.ts +39 -0
- package/dist/init/migrate.d.ts.map +1 -0
- package/dist/init/migrate.js +45 -0
- package/dist/init/migrate.js.map +1 -0
- package/dist/init/templates.d.ts +8 -0
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +71 -9
- package/dist/init/templates.js.map +1 -1
- package/dist/mcp/lookup.d.ts +1 -0
- package/dist/mcp/lookup.d.ts.map +1 -1
- package/dist/mcp/lookup.js +138 -10
- package/dist/mcp/lookup.js.map +1 -1
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +20 -8
- package/dist/mcp/server.js.map +1 -1
- package/dist/parser/clear.js +1 -1
- package/dist/parser/clear.js.map +1 -1
- package/dist/parser/feature-filter.d.ts +42 -0
- package/dist/parser/feature-filter.d.ts.map +1 -0
- package/dist/parser/feature-filter.js +109 -0
- package/dist/parser/feature-filter.js.map +1 -0
- package/dist/parser/format.d.ts +24 -0
- package/dist/parser/format.d.ts.map +1 -0
- package/dist/parser/format.js +29 -0
- package/dist/parser/format.js.map +1 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/parse-file.d.ts.map +1 -1
- package/dist/parser/parse-file.js +3 -1
- package/dist/parser/parse-file.js.map +1 -1
- package/dist/parser/parse-line.d.ts +3 -0
- package/dist/parser/parse-line.d.ts.map +1 -1
- package/dist/parser/parse-line.js +78 -22
- package/dist/parser/parse-line.js.map +1 -1
- package/dist/parser/parse-project.js +19 -0
- package/dist/parser/parse-project.js.map +1 -1
- package/dist/parser/validate.d.ts +3 -0
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +7 -0
- package/dist/parser/validate.js.map +1 -1
- package/dist/report/index.d.ts +1 -0
- package/dist/report/index.d.ts.map +1 -1
- package/dist/report/index.js +1 -0
- package/dist/report/index.js.map +1 -1
- package/dist/report/report.d.ts.map +1 -1
- package/dist/report/report.js +924 -24
- package/dist/report/report.js.map +1 -1
- package/dist/report/sequence.d.ts +11 -0
- package/dist/report/sequence.d.ts.map +1 -0
- package/dist/report/sequence.js +140 -0
- package/dist/report/sequence.js.map +1 -0
- package/dist/tui/commands.d.ts +1 -0
- package/dist/tui/commands.d.ts.map +1 -1
- package/dist/tui/commands.js +83 -4
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +7 -2
- package/dist/tui/index.js.map +1 -1
- package/dist/types/index.d.ts +57 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/workspace/merge.d.ts.map +1 -1
- package/dist/workspace/merge.js +6 -2
- package/dist/workspace/merge.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GuardLink Dashboard —
|
|
2
|
+
* GuardLink Dashboard — diagram generators.
|
|
3
3
|
*
|
|
4
|
-
* Three
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Three Mermaid diagrams and one structured topology dataset.
|
|
5
|
+
* - generateThreatGraph — LR flowchart of assets, threats, controls, mitigations
|
|
6
|
+
* - generateDataFlowDiagram — LR flow graph with trust boundary groupings
|
|
7
|
+
* - generateAttackSurface — TB grouping of exposures per asset, severity-coloured
|
|
8
|
+
* - generateTopologyData — structured graph data powering the interactive D3 view
|
|
9
|
+
*
|
|
10
|
+
* All four generators share a single alias map so that #id, bare id, name, and
|
|
11
|
+
* path.join() forms of an asset/threat/control collapse onto the same node.
|
|
12
|
+
* This removes the long-standing duplicate-node bug that made the Mermaid
|
|
13
|
+
* diagrams render the same asset twice whenever sources mixed ref forms.
|
|
14
|
+
*
|
|
15
|
+
* @flows ThreatModel -> #dashboard via generateThreatGraph -- "Threat model relationships rendered as Mermaid source"
|
|
16
|
+
* @flows ThreatModel -> #dashboard via generateTopologyData -- "Threat model relationships rendered as structured D3 graph data"
|
|
17
|
+
* @mitigates #dashboard against #xss using #output-encoding -- "Diagram labels are sanitized for Mermaid and emitted to D3 as text data"
|
|
18
|
+
* @comment -- "Alias map collapses #id / name / path-joined ref forms so Mermaid and D3 views agree on identity"
|
|
8
19
|
*/
|
|
9
|
-
|
|
20
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
* Shared sanitizers and ranking utilities
|
|
22
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
23
|
+
/** Sanitize IDs for Mermaid (no dots, spaces, hashes). */
|
|
10
24
|
function mid(s) {
|
|
11
25
|
return s.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
12
26
|
}
|
|
13
|
-
/** Truncate long labels and sanitize for Mermaid (strip syntax-breaking characters) */
|
|
27
|
+
/** Truncate long labels and sanitize for Mermaid (strip syntax-breaking characters). */
|
|
14
28
|
function label(s, max = 40) {
|
|
15
29
|
const clean = s.replace(/"/g, "'").replace(/[\[\]{}()|`;]/g, '');
|
|
16
30
|
return clean.length > max ? clean.slice(0, max - 1) + '…' : clean;
|
|
@@ -23,6 +37,349 @@ function labelFull(s) {
|
|
|
23
37
|
function normalizeRef(ref) {
|
|
24
38
|
return ref.startsWith('#') ? ref.slice(1) : ref;
|
|
25
39
|
}
|
|
40
|
+
function refKey(ref) {
|
|
41
|
+
return normalizeRef(ref).trim().toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
const severityRank = { critical: 0, p0: 0, high: 1, p1: 1, medium: 2, p2: 2, low: 3, p3: 3, unset: 4 };
|
|
44
|
+
const statusRank = { confirmed: 0, open: 1, accepted: 2, mitigated: 3, none: 4 };
|
|
45
|
+
function normalizeSeverity(severity) {
|
|
46
|
+
const s = (severity || '').toLowerCase();
|
|
47
|
+
return s && severityRank[s] !== undefined ? s : 'unset';
|
|
48
|
+
}
|
|
49
|
+
function strongerSeverity(a, b) {
|
|
50
|
+
return (severityRank[b] ?? 4) < (severityRank[a] ?? 4) ? b : a;
|
|
51
|
+
}
|
|
52
|
+
function strongerStatus(a, b) {
|
|
53
|
+
return (statusRank[b] ?? 4) < (statusRank[a] ?? 4) ? b : a;
|
|
54
|
+
}
|
|
55
|
+
function buildAliases(model) {
|
|
56
|
+
const byKey = { asset: new Map(), threat: new Map(), control: new Map() };
|
|
57
|
+
const byRef = { asset: new Map(), threat: new Map(), control: new Map() };
|
|
58
|
+
const register = (kind, node, refs) => {
|
|
59
|
+
byKey[kind].set(node.key, node);
|
|
60
|
+
for (const r of refs) {
|
|
61
|
+
if (!r)
|
|
62
|
+
continue;
|
|
63
|
+
const k = refKey(r);
|
|
64
|
+
if (!k)
|
|
65
|
+
continue;
|
|
66
|
+
const existing = byRef[kind].get(k);
|
|
67
|
+
if (!existing)
|
|
68
|
+
byRef[kind].set(k, node);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const upsert = (kind, key, displayLabel, refs, opts) => {
|
|
72
|
+
let node = byKey[kind].get(key);
|
|
73
|
+
if (!node) {
|
|
74
|
+
node = {
|
|
75
|
+
key,
|
|
76
|
+
label: displayLabel || key,
|
|
77
|
+
id: opts?.id,
|
|
78
|
+
kind,
|
|
79
|
+
severity: normalizeSeverity(opts?.severity),
|
|
80
|
+
externalRefs: opts?.externalRefs ? [...opts.externalRefs] : [],
|
|
81
|
+
declared: opts?.declared ?? false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (displayLabel && displayLabel.length > node.label.length)
|
|
86
|
+
node.label = displayLabel;
|
|
87
|
+
if (opts?.id && !node.id)
|
|
88
|
+
node.id = opts.id;
|
|
89
|
+
if (opts?.severity)
|
|
90
|
+
node.severity = strongerSeverity(node.severity, normalizeSeverity(opts.severity));
|
|
91
|
+
if (opts?.externalRefs)
|
|
92
|
+
for (const r of opts.externalRefs)
|
|
93
|
+
if (!node.externalRefs.includes(r))
|
|
94
|
+
node.externalRefs.push(r);
|
|
95
|
+
// Once declared, always declared — a later synthesis pass shouldn't downgrade.
|
|
96
|
+
if (opts?.declared)
|
|
97
|
+
node.declared = true;
|
|
98
|
+
}
|
|
99
|
+
register(kind, node, refs);
|
|
100
|
+
return node;
|
|
101
|
+
};
|
|
102
|
+
// Pre-register defined entities. #id takes priority over display label as the canonical key
|
|
103
|
+
// so that any later ref using the id resolves to the same node.
|
|
104
|
+
for (const a of model.assets) {
|
|
105
|
+
const display = a.path.join('.');
|
|
106
|
+
const key = a.id ? refKey(a.id) : refKey(display);
|
|
107
|
+
upsert('asset', key, display, [a.id, a.id ? `#${a.id}` : undefined, display, ...a.path], { id: a.id, declared: true });
|
|
108
|
+
}
|
|
109
|
+
for (const t of model.threats) {
|
|
110
|
+
const key = t.id ? refKey(t.id) : refKey(t.name);
|
|
111
|
+
upsert('threat', key, t.name, [t.id, t.id ? `#${t.id}` : undefined, t.name, t.canonical_name], {
|
|
112
|
+
id: t.id,
|
|
113
|
+
severity: t.severity,
|
|
114
|
+
externalRefs: t.external_refs,
|
|
115
|
+
declared: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const c of model.controls) {
|
|
119
|
+
const key = c.id ? refKey(c.id) : refKey(c.name);
|
|
120
|
+
upsert('control', key, c.name, [c.id, c.id ? `#${c.id}` : undefined, c.name, c.canonical_name], { id: c.id, declared: true });
|
|
121
|
+
}
|
|
122
|
+
// Sweep exposures/mitigations to pick up severities for undefined threats and external refs.
|
|
123
|
+
for (const e of model.exposures) {
|
|
124
|
+
const threat = byRef.threat.get(refKey(e.threat));
|
|
125
|
+
if (threat) {
|
|
126
|
+
if (e.severity)
|
|
127
|
+
threat.severity = strongerSeverity(threat.severity, normalizeSeverity(e.severity));
|
|
128
|
+
for (const r of e.external_refs)
|
|
129
|
+
if (!threat.externalRefs.includes(r))
|
|
130
|
+
threat.externalRefs.push(r);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const resolve = (kind, ref) => {
|
|
134
|
+
const k = refKey(ref);
|
|
135
|
+
const existing = byRef[kind].get(k);
|
|
136
|
+
if (existing)
|
|
137
|
+
return existing;
|
|
138
|
+
// Cross-kind fallback: an undeclared ref like `#login-sqli` may be referenced
|
|
139
|
+
// as an asset by @exposes AND as a threat by @confirmed. Without this check,
|
|
140
|
+
// we'd synthesize two separate nodes with the same identifier in different
|
|
141
|
+
// clusters of the topology graph. If the ref already exists under a different
|
|
142
|
+
// kind, return that node — a single canonical identity is more useful than
|
|
143
|
+
// two duplicates the user can't tell apart visually.
|
|
144
|
+
if (k) {
|
|
145
|
+
for (const otherKind of ['asset', 'threat', 'control']) {
|
|
146
|
+
if (otherKind === kind)
|
|
147
|
+
continue;
|
|
148
|
+
const cross = byRef[otherKind].get(k);
|
|
149
|
+
if (cross)
|
|
150
|
+
return cross;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const display = normalizeRef(ref) || 'unknown';
|
|
154
|
+
return upsert(kind, k || display.toLowerCase(), display, [ref, display], { declared: false });
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
resolve,
|
|
158
|
+
getExisting: (kind, ref) => byRef[kind].get(refKey(ref)),
|
|
159
|
+
getAll: (kind) => [...byKey[kind].values()],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const topoId = (kind, key) => `${kind}:${key || 'unknown'}`;
|
|
163
|
+
/**
|
|
164
|
+
* Build the structured graph consumed by the dashboard's native D3 topology.
|
|
165
|
+
* Shares an alias map with the Mermaid generators so #id/name/path forms agree.
|
|
166
|
+
*/
|
|
167
|
+
export function generateTopologyData(model) {
|
|
168
|
+
const aliases = buildAliases(model);
|
|
169
|
+
const nodes = new Map();
|
|
170
|
+
const links = new Map();
|
|
171
|
+
const materialize = (alias) => {
|
|
172
|
+
const id = topoId(alias.kind, alias.key);
|
|
173
|
+
let node = nodes.get(id);
|
|
174
|
+
if (!node) {
|
|
175
|
+
node = {
|
|
176
|
+
id,
|
|
177
|
+
label: alias.label,
|
|
178
|
+
kind: alias.kind,
|
|
179
|
+
severity: alias.severity,
|
|
180
|
+
status: 'none',
|
|
181
|
+
exposures: 0,
|
|
182
|
+
openExposures: 0,
|
|
183
|
+
mitigations: 0,
|
|
184
|
+
flows: 0,
|
|
185
|
+
confirmed: 0,
|
|
186
|
+
riskScore: 0,
|
|
187
|
+
classifications: [],
|
|
188
|
+
refs: [alias.label, alias.id, alias.id ? `#${alias.id}` : undefined].filter(Boolean),
|
|
189
|
+
declared: alias.declared,
|
|
190
|
+
};
|
|
191
|
+
nodes.set(id, node);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
node.severity = strongerSeverity(node.severity, alias.severity);
|
|
195
|
+
if (alias.label.length > node.label.length)
|
|
196
|
+
node.label = alias.label;
|
|
197
|
+
// Once declared (alias was upgraded by a later upsert), reflect that
|
|
198
|
+
// on the topology node too — never downgrade.
|
|
199
|
+
if (alias.declared)
|
|
200
|
+
node.declared = true;
|
|
201
|
+
}
|
|
202
|
+
return node;
|
|
203
|
+
};
|
|
204
|
+
const resolve = (kind, ref) => materialize(aliases.resolve(kind, ref));
|
|
205
|
+
const addLink = (source, target, kind, labelText, severity = 'unset', status = 'none') => {
|
|
206
|
+
if (!source || !target || source === target)
|
|
207
|
+
return;
|
|
208
|
+
const key = `${source}|${target}|${kind}|${labelText}`;
|
|
209
|
+
const sev = normalizeSeverity(severity);
|
|
210
|
+
let link = links.get(key);
|
|
211
|
+
if (!link) {
|
|
212
|
+
link = { source, target, kind, label: labelText, severity: sev, status, count: 0 };
|
|
213
|
+
links.set(key, link);
|
|
214
|
+
}
|
|
215
|
+
link.count++;
|
|
216
|
+
link.severity = strongerSeverity(link.severity, sev);
|
|
217
|
+
link.status = strongerStatus(link.status, status);
|
|
218
|
+
};
|
|
219
|
+
const markNode = (node, severity, status) => {
|
|
220
|
+
node.severity = strongerSeverity(node.severity, normalizeSeverity(severity));
|
|
221
|
+
node.status = strongerStatus(node.status, status);
|
|
222
|
+
};
|
|
223
|
+
// Pre-seed nodes for every defined entity so the graph reflects the full model,
|
|
224
|
+
// not just what relationships happen to reference.
|
|
225
|
+
for (const a of aliases.getAll('asset'))
|
|
226
|
+
materialize(a);
|
|
227
|
+
for (const t of aliases.getAll('threat'))
|
|
228
|
+
materialize(t);
|
|
229
|
+
for (const c of aliases.getAll('control'))
|
|
230
|
+
materialize(c);
|
|
231
|
+
// Classifications + ownership
|
|
232
|
+
for (const h of model.data_handling) {
|
|
233
|
+
const asset = resolve('asset', h.asset);
|
|
234
|
+
const classification = h.classification.toUpperCase();
|
|
235
|
+
if (!asset.classifications.includes(classification))
|
|
236
|
+
asset.classifications.push(classification);
|
|
237
|
+
}
|
|
238
|
+
for (const o of model.ownership) {
|
|
239
|
+
resolve('asset', o.asset).owner = o.owner;
|
|
240
|
+
}
|
|
241
|
+
// Compute per-pair resolution status for exposure links
|
|
242
|
+
const mitigatedPairs = new Set();
|
|
243
|
+
const acceptedPairs = new Set();
|
|
244
|
+
for (const m of model.mitigations) {
|
|
245
|
+
const asset = resolve('asset', m.asset);
|
|
246
|
+
const threat = resolve('threat', m.threat);
|
|
247
|
+
mitigatedPairs.add(`${asset.id}::${threat.id}`);
|
|
248
|
+
}
|
|
249
|
+
for (const a of model.acceptances) {
|
|
250
|
+
const asset = resolve('asset', a.asset);
|
|
251
|
+
const threat = resolve('threat', a.threat);
|
|
252
|
+
acceptedPairs.add(`${asset.id}::${threat.id}`);
|
|
253
|
+
}
|
|
254
|
+
for (const e of model.exposures) {
|
|
255
|
+
const asset = resolve('asset', e.asset);
|
|
256
|
+
const threat = resolve('threat', e.threat);
|
|
257
|
+
const pair = `${asset.id}::${threat.id}`;
|
|
258
|
+
const status = acceptedPairs.has(pair) ? 'accepted' : mitigatedPairs.has(pair) ? 'mitigated' : 'open';
|
|
259
|
+
const severity = normalizeSeverity(e.severity);
|
|
260
|
+
asset.exposures++;
|
|
261
|
+
threat.exposures++;
|
|
262
|
+
if (status === 'open') {
|
|
263
|
+
asset.openExposures++;
|
|
264
|
+
threat.openExposures++;
|
|
265
|
+
}
|
|
266
|
+
markNode(asset, severity, status);
|
|
267
|
+
markNode(threat, severity, status);
|
|
268
|
+
addLink(asset.id, threat.id, 'exposes', 'exposes', severity, status);
|
|
269
|
+
}
|
|
270
|
+
for (const c of model.confirmed || []) {
|
|
271
|
+
const asset = resolve('asset', c.asset);
|
|
272
|
+
const threat = resolve('threat', c.threat);
|
|
273
|
+
const severity = normalizeSeverity(c.severity);
|
|
274
|
+
asset.confirmed++;
|
|
275
|
+
threat.confirmed++;
|
|
276
|
+
markNode(asset, severity, 'confirmed');
|
|
277
|
+
markNode(threat, severity, 'confirmed');
|
|
278
|
+
addLink(asset.id, threat.id, 'confirmed', 'confirmed', severity, 'confirmed');
|
|
279
|
+
}
|
|
280
|
+
for (const m of model.mitigations) {
|
|
281
|
+
const asset = resolve('asset', m.asset);
|
|
282
|
+
const threat = resolve('threat', m.threat);
|
|
283
|
+
asset.mitigations++;
|
|
284
|
+
threat.mitigations++;
|
|
285
|
+
markNode(asset, 'unset', 'mitigated');
|
|
286
|
+
markNode(threat, 'unset', 'mitigated');
|
|
287
|
+
if (m.control) {
|
|
288
|
+
const control = resolve('control', m.control);
|
|
289
|
+
control.mitigations++;
|
|
290
|
+
addLink(control.id, threat.id, 'mitigates', 'mitigates', threat.severity, 'mitigated');
|
|
291
|
+
addLink(control.id, asset.id, 'protects', 'protects', 'unset', 'mitigated');
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
addLink(asset.id, threat.id, 'mitigates', 'mitigates', threat.severity, 'mitigated');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const a of model.acceptances) {
|
|
298
|
+
const asset = resolve('asset', a.asset);
|
|
299
|
+
const threat = resolve('threat', a.threat);
|
|
300
|
+
markNode(asset, threat.severity, 'accepted');
|
|
301
|
+
markNode(threat, threat.severity, 'accepted');
|
|
302
|
+
addLink(asset.id, threat.id, 'accepts', 'accepts', threat.severity, 'accepted');
|
|
303
|
+
}
|
|
304
|
+
for (const t of model.transfers) {
|
|
305
|
+
const source = resolve('asset', t.source);
|
|
306
|
+
const target = resolve('asset', t.target);
|
|
307
|
+
const threat = resolve('threat', t.threat);
|
|
308
|
+
addLink(source.id, target.id, 'transfers', `transfers ${threat.label}`, threat.severity, 'none');
|
|
309
|
+
}
|
|
310
|
+
for (const f of model.flows) {
|
|
311
|
+
const source = resolve('asset', f.source);
|
|
312
|
+
const target = resolve('asset', f.target);
|
|
313
|
+
source.flows++;
|
|
314
|
+
target.flows++;
|
|
315
|
+
addLink(source.id, target.id, 'flows', f.mechanism || 'flows', 'unset', 'none');
|
|
316
|
+
}
|
|
317
|
+
for (const b of model.boundaries) {
|
|
318
|
+
const a = resolve('asset', b.asset_a);
|
|
319
|
+
const z = resolve('asset', b.asset_b);
|
|
320
|
+
addLink(a.id, z.id, 'boundary', b.description || b.id || 'trust boundary', 'unset', 'none');
|
|
321
|
+
}
|
|
322
|
+
for (const v of model.validations) {
|
|
323
|
+
const control = resolve('control', v.control);
|
|
324
|
+
const asset = resolve('asset', v.asset);
|
|
325
|
+
addLink(control.id, asset.id, 'validates', 'validates', 'unset', 'mitigated');
|
|
326
|
+
}
|
|
327
|
+
// Risk score: exposures weighted by severity, amplified by confirmed hits.
|
|
328
|
+
const sevWeight = { critical: 10, p0: 10, high: 6, p1: 6, medium: 3, p2: 3, low: 1, p3: 1, unset: 1 };
|
|
329
|
+
for (const n of nodes.values()) {
|
|
330
|
+
n.riskScore = n.openExposures * (sevWeight[n.severity] ?? 1) + n.confirmed * 12;
|
|
331
|
+
}
|
|
332
|
+
const nodeList = [...nodes.values()]
|
|
333
|
+
.map(n => ({ ...n, classifications: [...n.classifications].sort(), refs: [...n.refs].sort() }))
|
|
334
|
+
.sort((a, b) => {
|
|
335
|
+
const kindOrder = { asset: 0, threat: 1, control: 2 };
|
|
336
|
+
const byKind = kindOrder[a.kind] - kindOrder[b.kind];
|
|
337
|
+
if (byKind !== 0)
|
|
338
|
+
return byKind;
|
|
339
|
+
const byRisk = b.riskScore - a.riskScore;
|
|
340
|
+
if (byRisk !== 0)
|
|
341
|
+
return byRisk;
|
|
342
|
+
const bySeverity = (severityRank[a.severity] ?? 4) - (severityRank[b.severity] ?? 4);
|
|
343
|
+
if (bySeverity !== 0)
|
|
344
|
+
return bySeverity;
|
|
345
|
+
return a.label.localeCompare(b.label);
|
|
346
|
+
});
|
|
347
|
+
const linkList = [...links.values()].sort((a, b) => a.kind.localeCompare(b.kind) || b.count - a.count || a.label.localeCompare(b.label));
|
|
348
|
+
const openCount = model.exposures.filter(e => {
|
|
349
|
+
const assetId = topoId('asset', aliases.resolve('asset', e.asset).key);
|
|
350
|
+
const threatId = topoId('threat', aliases.resolve('threat', e.threat).key);
|
|
351
|
+
const pair = `${assetId}::${threatId}`;
|
|
352
|
+
return !mitigatedPairs.has(pair) && !acceptedPairs.has(pair);
|
|
353
|
+
}).length;
|
|
354
|
+
const mitigatedCount = model.exposures.filter(e => {
|
|
355
|
+
const assetId = topoId('asset', aliases.resolve('asset', e.asset).key);
|
|
356
|
+
const threatId = topoId('threat', aliases.resolve('threat', e.threat).key);
|
|
357
|
+
return mitigatedPairs.has(`${assetId}::${threatId}`);
|
|
358
|
+
}).length;
|
|
359
|
+
const acceptedCount = model.exposures.filter(e => {
|
|
360
|
+
const assetId = topoId('asset', aliases.resolve('asset', e.asset).key);
|
|
361
|
+
const threatId = topoId('threat', aliases.resolve('threat', e.threat).key);
|
|
362
|
+
return acceptedPairs.has(`${assetId}::${threatId}`);
|
|
363
|
+
}).length;
|
|
364
|
+
return {
|
|
365
|
+
nodes: nodeList,
|
|
366
|
+
links: linkList,
|
|
367
|
+
summary: {
|
|
368
|
+
assets: nodeList.filter(n => n.kind === 'asset').length,
|
|
369
|
+
threats: nodeList.filter(n => n.kind === 'threat').length,
|
|
370
|
+
controls: nodeList.filter(n => n.kind === 'control').length,
|
|
371
|
+
links: linkList.length,
|
|
372
|
+
open: openCount,
|
|
373
|
+
mitigated: mitigatedCount,
|
|
374
|
+
accepted: acceptedCount,
|
|
375
|
+
confirmed: (model.confirmed || []).length,
|
|
376
|
+
criticalAssets: nodeList.filter(n => n.kind === 'asset' && (n.severity === 'critical' || n.severity === 'p0') && n.status !== 'mitigated').length,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
381
|
+
* Mermaid helpers
|
|
382
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
26
383
|
/** Heuristic icon for data-flow assets to make diagrams easier to scan. */
|
|
27
384
|
function assetIcon(name) {
|
|
28
385
|
const n = normalizeRef(name).toLowerCase();
|
|
@@ -51,466 +408,497 @@ function flowIcon(mechanism) {
|
|
|
51
408
|
return '🗄️';
|
|
52
409
|
return '📡';
|
|
53
410
|
}
|
|
411
|
+
function severityIcon(sev) {
|
|
412
|
+
if (sev === 'critical' || sev === 'p0')
|
|
413
|
+
return '🔴';
|
|
414
|
+
if (sev === 'high' || sev === 'p1')
|
|
415
|
+
return '🟠';
|
|
416
|
+
if (sev === 'medium' || sev === 'p2')
|
|
417
|
+
return '🟡';
|
|
418
|
+
if (sev === 'low' || sev === 'p3')
|
|
419
|
+
return '🔵';
|
|
420
|
+
return '⚪';
|
|
421
|
+
}
|
|
422
|
+
function severityClass(sev) {
|
|
423
|
+
if (sev === 'critical' || sev === 'p0')
|
|
424
|
+
return 'sev_crit';
|
|
425
|
+
if (sev === 'high' || sev === 'p1')
|
|
426
|
+
return 'sev_high';
|
|
427
|
+
if (sev === 'medium' || sev === 'p2')
|
|
428
|
+
return 'sev_med';
|
|
429
|
+
if (sev === 'low' || sev === 'p3')
|
|
430
|
+
return 'sev_low';
|
|
431
|
+
return 'sev_unset';
|
|
432
|
+
}
|
|
54
433
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
434
|
+
* LR flowchart: assets (boxes), threats (red), controls (green), and relationships.
|
|
435
|
+
* All refs are canonicalized through buildAliases so mixed #id/name usage collapses.
|
|
57
436
|
*/
|
|
58
|
-
export function generateThreatGraph(model) {
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
437
|
+
export function generateThreatGraph(model, opts = {}) {
|
|
438
|
+
const aliases = buildAliases(model);
|
|
439
|
+
const resolve = (kind, ref) => aliases.resolve(kind, ref);
|
|
440
|
+
// Auto-filter to high/critical if the diagram would otherwise be overwhelming.
|
|
441
|
+
const distinctThreats = new Set();
|
|
442
|
+
for (const e of model.exposures)
|
|
443
|
+
distinctThreats.add(resolve('threat', e.threat).key);
|
|
444
|
+
const filterHigh = opts.showAll ? false : distinctThreats.size > 12;
|
|
445
|
+
const isHighSev = (sev) => sev === 'critical' || sev === 'p0' || sev === 'high' || sev === 'p1';
|
|
446
|
+
const usedAssetKeys = new Set(); // key → seen
|
|
447
|
+
const usedThreatKeys = new Set();
|
|
448
|
+
const usedControlKeys = new Set();
|
|
449
|
+
const registerAsset = (ref) => {
|
|
450
|
+
const n = resolve('asset', ref);
|
|
451
|
+
usedAssetKeys.add(n.key);
|
|
452
|
+
return n;
|
|
73
453
|
};
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const existing = threatLabelMap.get(ref) || threatLabelMap.get(norm);
|
|
79
|
-
// Prefer richer labels (e.g., canonical threat name) over terse id-like refs.
|
|
80
|
-
if (!existing || display.length > existing.length) {
|
|
81
|
-
threatLabelMap.set(ref, display);
|
|
82
|
-
threatLabelMap.set(norm, display);
|
|
83
|
-
}
|
|
454
|
+
const registerThreat = (ref) => {
|
|
455
|
+
const n = resolve('threat', ref);
|
|
456
|
+
usedThreatKeys.add(n.key);
|
|
457
|
+
return n;
|
|
84
458
|
};
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
setThreatLabel(t.id, t.name);
|
|
92
|
-
}
|
|
93
|
-
setThreatSeverity(t.name, s);
|
|
94
|
-
setThreatLabel(t.name, t.name);
|
|
95
|
-
if (s === 'critical' || s === 'p0' || s === 'high' || s === 'p1') {
|
|
96
|
-
if (t.id) {
|
|
97
|
-
highSevThreats.add(`#${t.id}`);
|
|
98
|
-
highSevThreats.add(t.id);
|
|
99
|
-
}
|
|
100
|
-
highSevThreats.add(t.name);
|
|
101
|
-
highSevThreats.add(normalizeRef(t.name));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// Exposure-level severity can exist even when a threat definition doesn't.
|
|
105
|
-
for (const e of model.exposures) {
|
|
106
|
-
const s = (e.severity || '').toLowerCase();
|
|
107
|
-
setThreatSeverity(e.threat, s);
|
|
108
|
-
setThreatLabel(e.threat, e.threat.replace('#', ''));
|
|
109
|
-
if (s === 'critical' || s === 'p0' || s === 'high' || s === 'p1') {
|
|
110
|
-
highSevThreats.add(e.threat);
|
|
111
|
-
highSevThreats.add(normalizeRef(e.threat));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const isHighThreat = (ref) => highSevThreats.has(ref) || highSevThreats.has(normalizeRef(ref));
|
|
115
|
-
// If very many threats, filter to critical+high only
|
|
116
|
-
const totalThreats = new Set();
|
|
117
|
-
for (const e of model.exposures)
|
|
118
|
-
totalThreats.add(e.threat);
|
|
119
|
-
const filterHigh = totalThreats.size > 12;
|
|
120
|
-
const lines = ['graph LR'];
|
|
121
|
-
const usedAssets = new Set();
|
|
122
|
-
const usedThreats = new Set();
|
|
123
|
-
const usedControls = new Set();
|
|
124
|
-
const edges = new Set();
|
|
459
|
+
const registerControl = (ref) => {
|
|
460
|
+
const n = resolve('control', ref);
|
|
461
|
+
usedControlKeys.add(n.key);
|
|
462
|
+
return n;
|
|
463
|
+
};
|
|
464
|
+
// Walk relationships and canonicalize usage
|
|
125
465
|
for (const e of model.exposures) {
|
|
126
|
-
|
|
466
|
+
const threat = resolve('threat', e.threat);
|
|
467
|
+
const effectiveSev = e.severity ? normalizeSeverity(e.severity) : threat.severity;
|
|
468
|
+
if (filterHigh && !isHighSev(effectiveSev))
|
|
127
469
|
continue;
|
|
128
|
-
|
|
129
|
-
|
|
470
|
+
registerAsset(e.asset);
|
|
471
|
+
registerThreat(e.threat);
|
|
130
472
|
}
|
|
131
473
|
for (const m of model.mitigations) {
|
|
132
|
-
|
|
474
|
+
const threat = resolve('threat', m.threat);
|
|
475
|
+
if (filterHigh && !isHighSev(threat.severity))
|
|
133
476
|
continue;
|
|
134
|
-
|
|
135
|
-
|
|
477
|
+
registerAsset(m.asset);
|
|
478
|
+
registerThreat(m.threat);
|
|
136
479
|
if (m.control)
|
|
137
|
-
|
|
480
|
+
registerControl(m.control);
|
|
138
481
|
}
|
|
139
482
|
for (const a of model.acceptances) {
|
|
140
|
-
|
|
483
|
+
const threat = resolve('threat', a.threat);
|
|
484
|
+
if (filterHigh && !isHighSev(threat.severity))
|
|
141
485
|
continue;
|
|
142
|
-
|
|
143
|
-
|
|
486
|
+
registerAsset(a.asset);
|
|
487
|
+
registerThreat(a.threat);
|
|
144
488
|
}
|
|
145
489
|
for (const t of model.transfers) {
|
|
146
|
-
|
|
490
|
+
const threat = resolve('threat', t.threat);
|
|
491
|
+
if (filterHigh && !isHighSev(threat.severity))
|
|
147
492
|
continue;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
493
|
+
registerAsset(t.source);
|
|
494
|
+
registerAsset(t.target);
|
|
495
|
+
registerThreat(t.threat);
|
|
151
496
|
}
|
|
152
497
|
for (const v of model.validations) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
// ── Build data-classification map (asset → classification badges) ──
|
|
157
|
-
const dataClassMap = new Map();
|
|
158
|
-
for (const h of model.data_handling) {
|
|
159
|
-
const norm = normalizeRef(h.asset);
|
|
160
|
-
if (!dataClassMap.has(h.asset))
|
|
161
|
-
dataClassMap.set(h.asset, []);
|
|
162
|
-
dataClassMap.get(h.asset).push(h.classification.toUpperCase());
|
|
163
|
-
if (norm !== h.asset) {
|
|
164
|
-
if (!dataClassMap.has(norm))
|
|
165
|
-
dataClassMap.set(norm, []);
|
|
166
|
-
dataClassMap.get(norm).push(h.classification.toUpperCase());
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// ── Build ownership map (asset → owner) ──
|
|
170
|
-
const ownerMap = new Map();
|
|
171
|
-
for (const o of model.ownership) {
|
|
172
|
-
ownerMap.set(o.asset, o.owner);
|
|
173
|
-
ownerMap.set(normalizeRef(o.asset), o.owner);
|
|
174
|
-
}
|
|
175
|
-
// ── Build external-refs map (threat → CWE/refs) ──
|
|
176
|
-
const extRefMap = new Map();
|
|
177
|
-
const addExtRefs = (ref, refs) => {
|
|
178
|
-
if (!refs || refs.length === 0)
|
|
179
|
-
return;
|
|
180
|
-
const norm = normalizeRef(ref);
|
|
181
|
-
for (const r of [ref, norm]) {
|
|
182
|
-
if (!extRefMap.has(r))
|
|
183
|
-
extRefMap.set(r, []);
|
|
184
|
-
for (const er of refs) {
|
|
185
|
-
if (!extRefMap.get(r).includes(er))
|
|
186
|
-
extRefMap.get(r).push(er);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
for (const t of model.threats) {
|
|
191
|
-
if (t.external_refs.length > 0) {
|
|
192
|
-
addExtRefs(t.name, t.external_refs);
|
|
193
|
-
if (t.id) {
|
|
194
|
-
addExtRefs(`#${t.id}`, t.external_refs);
|
|
195
|
-
addExtRefs(t.id, t.external_refs);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
498
|
+
registerAsset(v.asset);
|
|
499
|
+
registerControl(v.control);
|
|
198
500
|
}
|
|
199
|
-
for (const
|
|
200
|
-
|
|
201
|
-
|
|
501
|
+
for (const c of model.confirmed || []) {
|
|
502
|
+
registerAsset(c.asset);
|
|
503
|
+
registerThreat(c.threat);
|
|
202
504
|
}
|
|
203
|
-
//
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
505
|
+
// Classifications + ownership lookup (keyed on asset key)
|
|
506
|
+
const dataClassByKey = new Map();
|
|
507
|
+
for (const h of model.data_handling) {
|
|
508
|
+
const node = resolve('asset', h.asset);
|
|
509
|
+
const list = dataClassByKey.get(node.key) ?? [];
|
|
510
|
+
const cls = h.classification.toUpperCase();
|
|
511
|
+
if (!list.includes(cls))
|
|
512
|
+
list.push(cls);
|
|
513
|
+
dataClassByKey.set(node.key, list);
|
|
514
|
+
}
|
|
515
|
+
const ownerByKey = new Map();
|
|
516
|
+
for (const o of model.ownership)
|
|
517
|
+
ownerByKey.set(resolve('asset', o.asset).key, o.owner);
|
|
518
|
+
// Trust-zone grouping: pair up each boundary's two sides into a shared subgraph
|
|
519
|
+
// titled by the boundary description. An asset may appear in multiple zones, but
|
|
520
|
+
// we dedupe by putting it into its first-seen zone to keep Mermaid valid.
|
|
521
|
+
const zoneById = new Map();
|
|
522
|
+
const assetZoneByKey = new Map();
|
|
207
523
|
let zIdx = 0;
|
|
208
524
|
for (const b of model.boundaries) {
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
if (!aUsed && !bUsed)
|
|
525
|
+
const aKey = resolve('asset', b.asset_a).key;
|
|
526
|
+
const bKey = resolve('asset', b.asset_b).key;
|
|
527
|
+
if (!usedAssetKeys.has(aKey) && !usedAssetKeys.has(bKey))
|
|
213
528
|
continue;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
529
|
+
const zoneId = `TZ${zIdx++}`;
|
|
530
|
+
const desc = b.description || b.id || 'trust boundary';
|
|
531
|
+
const zone = { label: desc, members: new Set() };
|
|
532
|
+
if (usedAssetKeys.has(aKey) && !assetZoneByKey.has(aKey)) {
|
|
533
|
+
zone.members.add(aKey);
|
|
534
|
+
assetZoneByKey.set(aKey, zoneId);
|
|
535
|
+
}
|
|
536
|
+
if (usedAssetKeys.has(bKey) && !assetZoneByKey.has(bKey)) {
|
|
537
|
+
zone.members.add(bKey);
|
|
538
|
+
assetZoneByKey.set(bKey, zoneId);
|
|
223
539
|
}
|
|
540
|
+
if (zone.members.size > 0)
|
|
541
|
+
zoneById.set(zoneId, zone);
|
|
224
542
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
543
|
+
const lines = [
|
|
544
|
+
// rankSpacing controls horizontal distance between columns in LR graphs — bump it
|
|
545
|
+
// to keep the diagram wide instead of cramming everything into a narrow strip.
|
|
546
|
+
'%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 150, "curve": "monotoneX", "htmlLabels": false, "padding": 24}}}%%',
|
|
547
|
+
'graph LR',
|
|
548
|
+
];
|
|
549
|
+
const assetLabelFor = (node) => {
|
|
550
|
+
const classes = dataClassByKey.get(node.key);
|
|
551
|
+
const owner = ownerByKey.get(node.key);
|
|
552
|
+
let suffix = '';
|
|
553
|
+
if (classes && classes.length > 0)
|
|
554
|
+
suffix += ` [${classes.join(', ')}]`;
|
|
555
|
+
if (owner)
|
|
556
|
+
suffix += ` (${label(owner, 15)})`;
|
|
557
|
+
return `🔷 ${label(node.label)}${suffix}`;
|
|
558
|
+
};
|
|
559
|
+
// Emit subgraphs first
|
|
560
|
+
const emittedAssets = new Set();
|
|
561
|
+
for (const [zoneId, zone] of zoneById) {
|
|
562
|
+
lines.push(` subgraph ${zoneId}["🧱 ${label(zone.label, 40)}"]`);
|
|
563
|
+
for (const key of zone.members) {
|
|
564
|
+
const node = [...aliases.getAll('asset')].find(n => n.key === key);
|
|
565
|
+
if (!node)
|
|
232
566
|
continue;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
let suffix = '';
|
|
236
|
-
if (badges && badges.length > 0)
|
|
237
|
-
suffix += ` [${badges.join(', ')}]`;
|
|
238
|
-
if (owner)
|
|
239
|
-
suffix += ` (${label(owner, 15)})`;
|
|
240
|
-
lines.push(` ${mid(m)}["🔷 ${label(m)}${suffix}"]`);
|
|
241
|
-
inSubgraph.add(m);
|
|
242
|
-
inSubgraph.add(normalizeRef(m));
|
|
567
|
+
lines.push(` ${mid(node.key)}["${assetLabelFor(node)}"]`);
|
|
568
|
+
emittedAssets.add(key);
|
|
243
569
|
}
|
|
244
570
|
lines.push(' end');
|
|
245
571
|
}
|
|
246
|
-
//
|
|
247
|
-
for (const
|
|
248
|
-
if (
|
|
572
|
+
// Standalone assets
|
|
573
|
+
for (const key of usedAssetKeys) {
|
|
574
|
+
if (emittedAssets.has(key))
|
|
249
575
|
continue;
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
suffix += ` [${badges.join(', ')}]`;
|
|
255
|
-
if (owner)
|
|
256
|
-
suffix += ` (${label(owner, 15)})`;
|
|
257
|
-
lines.push(` ${mid(a)}["🔷 ${label(a)}${suffix}"]`);
|
|
576
|
+
const node = [...aliases.getAll('asset')].find(n => n.key === key);
|
|
577
|
+
if (!node)
|
|
578
|
+
continue;
|
|
579
|
+
lines.push(` ${mid(node.key)}["${assetLabelFor(node)}"]`);
|
|
258
580
|
}
|
|
259
|
-
// Threat nodes
|
|
260
|
-
for (const
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const refSuffix =
|
|
266
|
-
lines.push(` ${mid(
|
|
581
|
+
// Threat nodes
|
|
582
|
+
for (const key of usedThreatKeys) {
|
|
583
|
+
const node = [...aliases.getAll('threat')].find(n => n.key === key);
|
|
584
|
+
if (!node)
|
|
585
|
+
continue;
|
|
586
|
+
const icon = severityIcon(node.severity);
|
|
587
|
+
const refSuffix = node.externalRefs.length > 0 ? ` (${node.externalRefs.slice(0, 2).join(', ')})` : '';
|
|
588
|
+
lines.push(` ${mid(node.key)}["${icon} ${label(node.label, 35)}${refSuffix}"]:::threat`);
|
|
267
589
|
}
|
|
268
590
|
// Control nodes
|
|
269
|
-
for (const
|
|
270
|
-
|
|
591
|
+
for (const key of usedControlKeys) {
|
|
592
|
+
const node = [...aliases.getAll('control')].find(n => n.key === key);
|
|
593
|
+
if (!node)
|
|
594
|
+
continue;
|
|
595
|
+
lines.push(` ${mid(node.key)}["🛡️ ${label(node.label)}"]:::control`);
|
|
271
596
|
}
|
|
272
|
-
//
|
|
597
|
+
// Edge emission (deduped)
|
|
598
|
+
const edgeKeys = new Set();
|
|
599
|
+
const edge = (sourceKey, targetKey, kind, syntax) => {
|
|
600
|
+
const k = `${sourceKey}|${targetKey}|${kind}`;
|
|
601
|
+
if (edgeKeys.has(k))
|
|
602
|
+
return;
|
|
603
|
+
edgeKeys.add(k);
|
|
604
|
+
lines.push(` ${syntax}`);
|
|
605
|
+
};
|
|
273
606
|
for (const e of model.exposures) {
|
|
274
|
-
|
|
607
|
+
const threat = resolve('threat', e.threat);
|
|
608
|
+
const asset = resolve('asset', e.asset);
|
|
609
|
+
const effectiveSev = e.severity ? normalizeSeverity(e.severity) : threat.severity;
|
|
610
|
+
if (filterHigh && !isHighSev(effectiveSev))
|
|
275
611
|
continue;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
612
|
+
edge(asset.key, threat.key, 'exp', `${mid(asset.key)} -. exposes .-> ${mid(threat.key)}`);
|
|
613
|
+
}
|
|
614
|
+
for (const c of model.confirmed || []) {
|
|
615
|
+
const asset = resolve('asset', c.asset);
|
|
616
|
+
const threat = resolve('threat', c.threat);
|
|
617
|
+
edge(asset.key, threat.key, 'conf', `${mid(asset.key)} == "💥 confirmed" ==> ${mid(threat.key)}`);
|
|
281
618
|
}
|
|
282
|
-
// Mitigation edges
|
|
283
619
|
for (const m of model.mitigations) {
|
|
284
|
-
|
|
620
|
+
const threat = resolve('threat', m.threat);
|
|
621
|
+
const asset = resolve('asset', m.asset);
|
|
622
|
+
if (filterHigh && !isHighSev(threat.severity))
|
|
285
623
|
continue;
|
|
286
624
|
if (m.control) {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
lines.push(` ${mid(m.control)} -- mitigates --> ${mid(m.threat)}`);
|
|
291
|
-
}
|
|
292
|
-
const k2 = `${mid(m.control)}->on->${mid(m.asset)}`;
|
|
293
|
-
if (!edges.has(k2)) {
|
|
294
|
-
edges.add(k2);
|
|
295
|
-
lines.push(` ${mid(m.control)} -.- ${mid(m.asset)}`);
|
|
296
|
-
}
|
|
625
|
+
const control = resolve('control', m.control);
|
|
626
|
+
edge(control.key, threat.key, 'mit', `${mid(control.key)} -- mitigates --> ${mid(threat.key)}`);
|
|
627
|
+
edge(control.key, asset.key, 'prot', `${mid(control.key)} -. protects .-> ${mid(asset.key)}`);
|
|
297
628
|
}
|
|
298
629
|
else {
|
|
299
|
-
|
|
300
|
-
if (!edges.has(key)) {
|
|
301
|
-
edges.add(key);
|
|
302
|
-
lines.push(` ${mid(m.asset)} -. mitigates .-> ${mid(m.threat)}`);
|
|
303
|
-
}
|
|
630
|
+
edge(asset.key, threat.key, 'mit', `${mid(asset.key)} -. mitigates .-> ${mid(threat.key)}`);
|
|
304
631
|
}
|
|
305
632
|
}
|
|
306
|
-
// Acceptance edges
|
|
307
633
|
for (const a of model.acceptances) {
|
|
308
|
-
|
|
634
|
+
const threat = resolve('threat', a.threat);
|
|
635
|
+
const asset = resolve('asset', a.asset);
|
|
636
|
+
if (filterHigh && !isHighSev(threat.severity))
|
|
309
637
|
continue;
|
|
310
|
-
|
|
311
|
-
if (!edges.has(key)) {
|
|
312
|
-
edges.add(key);
|
|
313
|
-
lines.push(` ${mid(a.asset)} -- accepts --> ${mid(a.threat)}`);
|
|
314
|
-
}
|
|
638
|
+
edge(asset.key, threat.key, 'acc', `${mid(asset.key)} -- accepts --> ${mid(threat.key)}`);
|
|
315
639
|
}
|
|
316
|
-
// Transfer edges (risk moved between parties for a specific threat)
|
|
317
640
|
for (const t of model.transfers) {
|
|
318
|
-
|
|
641
|
+
const threat = resolve('threat', t.threat);
|
|
642
|
+
const source = resolve('asset', t.source);
|
|
643
|
+
const target = resolve('asset', t.target);
|
|
644
|
+
if (filterHigh && !isHighSev(threat.severity))
|
|
319
645
|
continue;
|
|
320
|
-
|
|
321
|
-
const key = `${mid(t.source)}->xfer->${mid(t.target)}::${mid(t.threat)}`;
|
|
322
|
-
if (!edges.has(key)) {
|
|
323
|
-
edges.add(key);
|
|
324
|
-
lines.push(` ${mid(t.source)} -- "transfers risk: ${label(threatDisplay, 26)}" --> ${mid(t.target)}`);
|
|
325
|
-
}
|
|
646
|
+
edge(source.key, target.key, `xfer:${threat.key}`, `${mid(source.key)} -- "transfers risk: ${label(threat.label, 26)}" --> ${mid(target.key)}`);
|
|
326
647
|
}
|
|
327
|
-
// Validation edges (controls validating assets)
|
|
328
648
|
for (const v of model.validations) {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
lines.push(` ${mid(v.control)} -. validates .-> ${mid(v.asset)}`);
|
|
333
|
-
}
|
|
649
|
+
const control = resolve('control', v.control);
|
|
650
|
+
const asset = resolve('asset', v.asset);
|
|
651
|
+
edge(control.key, asset.key, 'val', `${mid(control.key)} -. validates .-> ${mid(asset.key)}`);
|
|
334
652
|
}
|
|
335
|
-
// Data-flow edges (only between assets already in the graph)
|
|
336
653
|
for (const f of model.flows) {
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
if (!
|
|
654
|
+
const source = resolve('asset', f.source);
|
|
655
|
+
const target = resolve('asset', f.target);
|
|
656
|
+
if (!usedAssetKeys.has(source.key) || !usedAssetKeys.has(target.key))
|
|
340
657
|
continue;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
lines.push(` ${mid(f.source)} --> ${mid(f.target)}`);
|
|
349
|
-
}
|
|
658
|
+
if (f.mechanism) {
|
|
659
|
+
edge(source.key, target.key, `flow:${f.mechanism}`, `${mid(source.key)} -- "${flowIcon(f.mechanism)} ${label(f.mechanism, 22)}" --> ${mid(target.key)}`);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
edge(source.key, target.key, 'flow', `${mid(source.key)} --> ${mid(target.key)}`);
|
|
350
663
|
}
|
|
351
664
|
}
|
|
352
|
-
// Trust-boundary crossing edges (dashed purple line between zones)
|
|
353
665
|
for (const b of model.boundaries) {
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
if (!
|
|
666
|
+
const a = resolve('asset', b.asset_a);
|
|
667
|
+
const z = resolve('asset', b.asset_b);
|
|
668
|
+
if (!usedAssetKeys.has(a.key) || !usedAssetKeys.has(z.key))
|
|
357
669
|
continue;
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
edges.add(key);
|
|
361
|
-
const desc = b.description ? label(b.description, 26) : 'trust boundary';
|
|
362
|
-
lines.push(` ${mid(b.asset_a)} -.-|🧱 ${desc}| ${mid(b.asset_b)}`);
|
|
363
|
-
}
|
|
670
|
+
const desc = b.description ? label(b.description, 26) : 'trust boundary';
|
|
671
|
+
edge(a.key, z.key, 'bnd', `${mid(a.key)} -.-|🧱 ${desc}| ${mid(z.key)}`);
|
|
364
672
|
}
|
|
365
|
-
lines.push(' classDef threat fill:#
|
|
366
|
-
lines.push(' classDef control fill:#
|
|
673
|
+
lines.push(' classDef threat fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.3px');
|
|
674
|
+
lines.push(' classDef control fill:#102a24,stroke:#33d49d,color:#f0f0f0,stroke-width:1.3px');
|
|
367
675
|
return lines.join('\n');
|
|
368
676
|
}
|
|
369
|
-
|
|
677
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
370
678
|
* Diagram 2: Data Flow Diagram
|
|
371
|
-
*
|
|
679
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
680
|
+
/**
|
|
681
|
+
* LR flow graph. Each @boundary produces a paired subgraph (both sides appear
|
|
682
|
+
* inside a single zone) labelled by the boundary description; the boundary
|
|
683
|
+
* itself is drawn as a purple dashed edge between the two sides. Assets that
|
|
684
|
+
* are not touched by any boundary render as standalone nodes.
|
|
372
685
|
*/
|
|
373
686
|
export function generateDataFlowDiagram(model) {
|
|
374
687
|
if (model.flows.length === 0)
|
|
375
688
|
return '';
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
689
|
+
const aliases = buildAliases(model);
|
|
690
|
+
const resolve = (ref) => aliases.resolve('asset', ref);
|
|
691
|
+
// Dynamic spacing based on longest mechanism label so mermaid doesn't crush long edges.
|
|
692
|
+
const maxMechanismLen = model.flows.reduce((max, f) => Math.max(max, (f.mechanism || '').length), 0);
|
|
380
693
|
const spacingBoost = Math.max(0, Math.min(140, (maxMechanismLen - 24) * 3));
|
|
381
|
-
const nodeSpacing =
|
|
382
|
-
const rankSpacing =
|
|
694
|
+
const nodeSpacing = 44 + Math.floor(spacingBoost * 0.4);
|
|
695
|
+
const rankSpacing = 58 + spacingBoost;
|
|
383
696
|
const lines = [
|
|
384
|
-
`%%{init: {"flowchart": {"nodeSpacing": ${nodeSpacing}, "rankSpacing": ${rankSpacing}, "curve": "basis"}}}%%`,
|
|
697
|
+
`%%{init: {"flowchart": {"nodeSpacing": ${nodeSpacing}, "rankSpacing": ${rankSpacing}, "curve": "basis", "htmlLabels": false}}}%%`,
|
|
385
698
|
'graph LR',
|
|
386
699
|
];
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
700
|
+
// Data handling badges keyed on canonical asset key
|
|
701
|
+
const handlingByKey = new Map();
|
|
702
|
+
for (const h of model.data_handling) {
|
|
703
|
+
const node = resolve(h.asset);
|
|
704
|
+
const list = handlingByKey.get(node.key) ?? [];
|
|
705
|
+
if (!list.includes(h.classification))
|
|
706
|
+
list.push(h.classification);
|
|
707
|
+
handlingByKey.set(node.key, list);
|
|
708
|
+
}
|
|
709
|
+
const nodeLabel = (n) => {
|
|
710
|
+
const badges = handlingByKey.get(n.key);
|
|
711
|
+
const suffix = badges && badges.length > 0 ? ` · ${badges.join(', ')}` : '';
|
|
712
|
+
return `${assetIcon(n.label)} ${labelFull(n.label)}${suffix}`;
|
|
713
|
+
};
|
|
714
|
+
// Collect the set of assets actually used by flows (or boundaries)
|
|
715
|
+
const usedAssets = new Map();
|
|
716
|
+
for (const f of model.flows) {
|
|
717
|
+
const s = resolve(f.source);
|
|
718
|
+
const t = resolve(f.target);
|
|
719
|
+
usedAssets.set(s.key, s);
|
|
720
|
+
usedAssets.set(t.key, t);
|
|
721
|
+
}
|
|
722
|
+
// Emit one subgraph PER SIDE of each boundary (A and B live in different trust zones).
|
|
723
|
+
// Label combines the boundary description with the side's asset so the visual
|
|
724
|
+
// cleanly conveys "this zone is on one side of <boundary>".
|
|
725
|
+
const placedAssets = new Set();
|
|
392
726
|
let zIdx = 0;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
if (!assetZone.has(b.asset_b)) {
|
|
402
|
-
const zoneLabel = `Z${zIdx++}`;
|
|
403
|
-
assetZone.set(b.asset_b, zoneLabel);
|
|
404
|
-
zones.set(zoneLabel, new Set([b.asset_b]));
|
|
405
|
-
}
|
|
406
|
-
boundaryEdges.push({ a: b.asset_a, b: b.asset_b, desc });
|
|
407
|
-
}
|
|
408
|
-
// Emit zone subgraphs
|
|
409
|
-
const inBoundary = new Set();
|
|
410
|
-
for (const [zoneId, members] of zones) {
|
|
411
|
-
const representative = [...members][0];
|
|
412
|
-
lines.push(` subgraph ${zoneId}["🧱 Trust Zone · ${labelFull(representative)}"]`);
|
|
413
|
-
for (const m of members) {
|
|
414
|
-
lines.push(` ${mid(m)}["${assetIcon(m)} ${labelFull(m)}"]`);
|
|
415
|
-
inBoundary.add(m);
|
|
416
|
-
}
|
|
727
|
+
const emitSide = (node, desc) => {
|
|
728
|
+
if (placedAssets.has(node.key))
|
|
729
|
+
return;
|
|
730
|
+
const zoneId = `Z${zIdx++}`;
|
|
731
|
+
const zoneLabel = desc === node.label ? node.label : `${node.label} · ${desc}`;
|
|
732
|
+
lines.push(` subgraph ${zoneId}["🧱 ${labelFull(zoneLabel)}"]`);
|
|
733
|
+
lines.push(` ${mid(node.key)}["${nodeLabel(node)}"]`);
|
|
417
734
|
lines.push(' end');
|
|
735
|
+
placedAssets.add(node.key);
|
|
736
|
+
usedAssets.set(node.key, node);
|
|
737
|
+
};
|
|
738
|
+
for (const b of model.boundaries) {
|
|
739
|
+
const a = resolve(b.asset_a);
|
|
740
|
+
const z = resolve(b.asset_b);
|
|
741
|
+
if (!usedAssets.has(a.key) && !usedAssets.has(z.key))
|
|
742
|
+
continue;
|
|
743
|
+
const desc = b.description || b.id || 'trust boundary';
|
|
744
|
+
emitSide(a, desc);
|
|
745
|
+
emitSide(z, desc);
|
|
418
746
|
}
|
|
419
|
-
//
|
|
420
|
-
for (const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
for (const h of model.data_handling) {
|
|
426
|
-
if (!handling.has(h.asset))
|
|
427
|
-
handling.set(h.asset, []);
|
|
428
|
-
handling.get(h.asset).push(h.classification);
|
|
747
|
+
// Standalone nodes (flow endpoints not inside any boundary zone)
|
|
748
|
+
for (const node of usedAssets.values()) {
|
|
749
|
+
if (placedAssets.has(node.key))
|
|
750
|
+
continue;
|
|
751
|
+
lines.push(` ${mid(node.key)}["${nodeLabel(node)}"]`);
|
|
752
|
+
placedAssets.add(node.key);
|
|
429
753
|
}
|
|
430
|
-
//
|
|
431
|
-
const
|
|
432
|
-
for (const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
754
|
+
// Boundary edges: a visual connector between the two sides
|
|
755
|
+
const emittedBoundaries = new Set();
|
|
756
|
+
for (const b of model.boundaries) {
|
|
757
|
+
const a = resolve(b.asset_a);
|
|
758
|
+
const z = resolve(b.asset_b);
|
|
759
|
+
const k = `${a.key}|${z.key}`;
|
|
760
|
+
if (emittedBoundaries.has(k))
|
|
761
|
+
continue;
|
|
762
|
+
emittedBoundaries.add(k);
|
|
763
|
+
const desc = b.description ? labelFull(b.description) : 'trust boundary';
|
|
764
|
+
lines.push(` ${mid(a.key)} -.-|🧱 ${desc}| ${mid(z.key)}`);
|
|
442
765
|
}
|
|
443
766
|
// Flow edges
|
|
767
|
+
const emittedFlows = new Set();
|
|
444
768
|
for (const f of model.flows) {
|
|
769
|
+
const s = resolve(f.source);
|
|
770
|
+
const t = resolve(f.target);
|
|
771
|
+
const k = `${s.key}|${t.key}|${f.mechanism || ''}`;
|
|
772
|
+
if (emittedFlows.has(k))
|
|
773
|
+
continue;
|
|
774
|
+
emittedFlows.add(k);
|
|
445
775
|
if (f.mechanism) {
|
|
446
|
-
lines.push(` ${mid(
|
|
776
|
+
lines.push(` ${mid(s.key)} -- "${flowIcon(f.mechanism)} ${labelFull(f.mechanism)}" --> ${mid(t.key)}`);
|
|
447
777
|
}
|
|
448
778
|
else {
|
|
449
|
-
lines.push(` ${mid(
|
|
779
|
+
lines.push(` ${mid(s.key)} --> ${mid(t.key)}`);
|
|
450
780
|
}
|
|
451
781
|
}
|
|
452
782
|
return lines.join('\n');
|
|
453
783
|
}
|
|
454
784
|
/**
|
|
455
|
-
*
|
|
456
|
-
*
|
|
785
|
+
* TB grouping: exposures per asset, coloured by severity and marked by status.
|
|
786
|
+
* - ⚠️ open
|
|
787
|
+
* - ✅ mitigated
|
|
788
|
+
* - 🟦 accepted
|
|
789
|
+
* - 💥 confirmed (raises severity to critical)
|
|
457
790
|
*/
|
|
458
791
|
export function generateAttackSurface(model) {
|
|
459
|
-
if (model.exposures.length === 0)
|
|
792
|
+
if (model.exposures.length === 0 && (!model.confirmed || model.confirmed.length === 0))
|
|
460
793
|
return '';
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
const
|
|
794
|
+
const aliases = buildAliases(model);
|
|
795
|
+
const resolveAsset = (ref) => aliases.resolve('asset', ref);
|
|
796
|
+
const resolveThreat = (ref) => aliases.resolve('threat', ref);
|
|
797
|
+
// Compute per-pair resolution using canonical keys
|
|
798
|
+
const mitigatedPairs = new Set();
|
|
799
|
+
const acceptedPairs = new Set();
|
|
464
800
|
for (const m of model.mitigations)
|
|
465
|
-
|
|
801
|
+
mitigatedPairs.add(`${resolveAsset(m.asset).key}::${resolveThreat(m.threat).key}`);
|
|
466
802
|
for (const a of model.acceptances)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
803
|
+
acceptedPairs.add(`${resolveAsset(a.asset).key}::${resolveThreat(a.threat).key}`);
|
|
804
|
+
const confirmedPairs = new Set();
|
|
805
|
+
for (const c of model.confirmed || [])
|
|
806
|
+
confirmedPairs.add(`${resolveAsset(c.asset).key}::${resolveThreat(c.threat).key}`);
|
|
470
807
|
const byAsset = new Map();
|
|
808
|
+
const getGroup = (assetRef) => {
|
|
809
|
+
const node = resolveAsset(assetRef);
|
|
810
|
+
let g = byAsset.get(node.key);
|
|
811
|
+
if (!g) {
|
|
812
|
+
g = { label: node.label, threats: new Map(), openCount: 0, mitigatedCount: 0, confirmedCount: 0 };
|
|
813
|
+
byAsset.set(node.key, g);
|
|
814
|
+
}
|
|
815
|
+
return g;
|
|
816
|
+
};
|
|
471
817
|
for (const e of model.exposures) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
818
|
+
const group = getGroup(e.asset);
|
|
819
|
+
const threatNode = resolveThreat(e.threat);
|
|
820
|
+
const pair = `${resolveAsset(e.asset).key}::${threatNode.key}`;
|
|
821
|
+
const sev = normalizeSeverity(e.severity || threatNode.severity);
|
|
822
|
+
const existing = group.threats.get(threatNode.key);
|
|
823
|
+
const isConfirmed = confirmedPairs.has(pair);
|
|
824
|
+
const status = isConfirmed ? 'confirmed' : acceptedPairs.has(pair) ? 'accepted' : mitigatedPairs.has(pair) ? 'mitigated' : 'open';
|
|
825
|
+
const escalated = isConfirmed ? 'critical' : sev;
|
|
826
|
+
if (!existing) {
|
|
827
|
+
group.threats.set(threatNode.key, { threatLabel: threatNode.label, severity: escalated, count: 1, status });
|
|
480
828
|
}
|
|
481
829
|
else {
|
|
482
830
|
existing.count++;
|
|
831
|
+
existing.severity = strongerSeverity(existing.severity, escalated);
|
|
832
|
+
existing.status = status === 'confirmed' ? 'confirmed' : status === 'open' && existing.status === 'mitigated' ? 'open' : existing.status;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// Make sure confirmed-only rows (no matching exposure) still appear
|
|
836
|
+
for (const c of model.confirmed || []) {
|
|
837
|
+
const group = getGroup(c.asset);
|
|
838
|
+
const threatNode = resolveThreat(c.threat);
|
|
839
|
+
const existing = group.threats.get(threatNode.key);
|
|
840
|
+
const sev = normalizeSeverity(c.severity || 'critical');
|
|
841
|
+
if (!existing) {
|
|
842
|
+
group.threats.set(threatNode.key, { threatLabel: threatNode.label, severity: sev, count: 1, status: 'confirmed' });
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
existing.status = 'confirmed';
|
|
846
|
+
existing.severity = strongerSeverity(existing.severity, sev);
|
|
483
847
|
}
|
|
484
848
|
}
|
|
849
|
+
// Roll up counts per group
|
|
850
|
+
for (const g of byAsset.values()) {
|
|
851
|
+
for (const t of g.threats.values()) {
|
|
852
|
+
if (t.status === 'open')
|
|
853
|
+
g.openCount++;
|
|
854
|
+
else if (t.status === 'mitigated' || t.status === 'accepted')
|
|
855
|
+
g.mitigatedCount++;
|
|
856
|
+
if (t.status === 'confirmed')
|
|
857
|
+
g.confirmedCount++;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Sort assets: confirmed first, then by open count desc, then by label
|
|
861
|
+
const assetsSorted = [...byAsset.entries()].sort(([, a], [, b]) => {
|
|
862
|
+
if (a.confirmedCount !== b.confirmedCount)
|
|
863
|
+
return b.confirmedCount - a.confirmedCount;
|
|
864
|
+
if (a.openCount !== b.openCount)
|
|
865
|
+
return b.openCount - a.openCount;
|
|
866
|
+
return a.label.localeCompare(b.label);
|
|
867
|
+
});
|
|
868
|
+
const lines = [
|
|
869
|
+
'%%{init: {"flowchart": {"nodeSpacing": 38, "rankSpacing": 48, "curve": "linear", "htmlLabels": false}}}%%',
|
|
870
|
+
'graph TB',
|
|
871
|
+
];
|
|
485
872
|
let eIdx = 0;
|
|
486
|
-
for (const [
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
873
|
+
for (const [assetKey, group] of assetsSorted) {
|
|
874
|
+
const totalThreats = group.threats.size;
|
|
875
|
+
const coverage = totalThreats === 0 ? 0 : Math.round((group.mitigatedCount / totalThreats) * 100);
|
|
876
|
+
const statusSuffix = group.confirmedCount > 0
|
|
877
|
+
? ` · 💥 ${group.confirmedCount} confirmed`
|
|
878
|
+
: group.openCount > 0
|
|
879
|
+
? ` · ⚠ ${group.openCount} open`
|
|
880
|
+
: ` · ✅ ${coverage}% covered`;
|
|
881
|
+
lines.push(` subgraph A_${mid(assetKey)}["${label(group.label)}${statusSuffix}"]`);
|
|
490
882
|
lines.push(` direction TB`);
|
|
883
|
+
const sorted = [...group.threats.values()].sort((a, b) => (severityRank[a.severity] ?? 4) - (severityRank[b.severity] ?? 4));
|
|
491
884
|
for (const entry of sorted) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
else if (entry.severity === 'low' || entry.severity === 'p3')
|
|
500
|
-
cls = 'sev_low';
|
|
501
|
-
const icon = entry.resolved ? '✅' : '⚠️';
|
|
502
|
-
const threatLabel = label(entry.threat.replace('#', ''), 30);
|
|
503
|
-
const countSuffix = entry.count > 1 ? ` x${entry.count}` : '';
|
|
885
|
+
const cls = severityClass(entry.severity);
|
|
886
|
+
const icon = entry.status === 'confirmed' ? '💥'
|
|
887
|
+
: entry.status === 'mitigated' ? '✅'
|
|
888
|
+
: entry.status === 'accepted' ? '🟦'
|
|
889
|
+
: '⚠️';
|
|
890
|
+
const threatLabel = label(entry.threatLabel, 30);
|
|
891
|
+
const countSuffix = entry.count > 1 ? ` ×${entry.count}` : '';
|
|
504
892
|
lines.push(` E${eIdx}["${icon} ${threatLabel}${countSuffix}"]:::${cls}`);
|
|
505
893
|
eIdx++;
|
|
506
894
|
}
|
|
507
895
|
lines.push(' end');
|
|
508
896
|
}
|
|
509
|
-
lines.push(' classDef sev_crit fill:#
|
|
510
|
-
lines.push(' classDef sev_high fill:#
|
|
511
|
-
lines.push(' classDef sev_med fill:#
|
|
512
|
-
lines.push(' classDef sev_low fill:#
|
|
513
|
-
lines.push(' classDef sev_unset fill:#
|
|
897
|
+
lines.push(' classDef sev_crit fill:#3a1010,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.4px');
|
|
898
|
+
lines.push(' classDef sev_high fill:#402019,stroke:#ea1d1d,color:#f0f0f0,stroke-width:1.2px');
|
|
899
|
+
lines.push(' classDef sev_med fill:#1f3943,stroke:#55899e,color:#f0f0f0');
|
|
900
|
+
lines.push(' classDef sev_low fill:#10263b,stroke:#0360a2,color:#f0f0f0');
|
|
901
|
+
lines.push(' classDef sev_unset fill:#223942,stroke:#3b6779,color:#f0f0f0');
|
|
514
902
|
return lines.join('\n');
|
|
515
903
|
}
|
|
516
904
|
//# sourceMappingURL=diagrams.js.map
|