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.
Files changed (119) hide show
  1. package/CHANGELOG.md +83 -9
  2. package/README.md +38 -1
  3. package/dist/agents/config.d.ts +7 -0
  4. package/dist/agents/config.d.ts.map +1 -1
  5. package/dist/agents/config.js.map +1 -1
  6. package/dist/agents/index.d.ts +1 -1
  7. package/dist/agents/index.d.ts.map +1 -1
  8. package/dist/agents/index.js +1 -1
  9. package/dist/agents/index.js.map +1 -1
  10. package/dist/agents/prompts.d.ts +14 -0
  11. package/dist/agents/prompts.d.ts.map +1 -1
  12. package/dist/agents/prompts.js +445 -2
  13. package/dist/agents/prompts.js.map +1 -1
  14. package/dist/analyze/format.d.ts +72 -0
  15. package/dist/analyze/format.d.ts.map +1 -0
  16. package/dist/analyze/format.js +176 -0
  17. package/dist/analyze/format.js.map +1 -0
  18. package/dist/analyze/index.d.ts +76 -0
  19. package/dist/analyze/index.d.ts.map +1 -1
  20. package/dist/analyze/index.js +165 -2
  21. package/dist/analyze/index.js.map +1 -1
  22. package/dist/analyze/prompts.d.ts +3 -2
  23. package/dist/analyze/prompts.d.ts.map +1 -1
  24. package/dist/analyze/prompts.js +16 -2
  25. package/dist/analyze/prompts.js.map +1 -1
  26. package/dist/analyzer/sarif.d.ts +3 -2
  27. package/dist/analyzer/sarif.d.ts.map +1 -1
  28. package/dist/analyzer/sarif.js +29 -3
  29. package/dist/analyzer/sarif.js.map +1 -1
  30. package/dist/cli/index.d.ts +2 -0
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +380 -28
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/dashboard/data.d.ts +11 -0
  35. package/dist/dashboard/data.d.ts.map +1 -1
  36. package/dist/dashboard/data.js +12 -0
  37. package/dist/dashboard/data.js.map +1 -1
  38. package/dist/dashboard/diagrams.d.ts +81 -12
  39. package/dist/dashboard/diagrams.d.ts.map +1 -1
  40. package/dist/dashboard/diagrams.js +750 -362
  41. package/dist/dashboard/diagrams.js.map +1 -1
  42. package/dist/dashboard/generate.d.ts +5 -2
  43. package/dist/dashboard/generate.d.ts.map +1 -1
  44. package/dist/dashboard/generate.js +2516 -244
  45. package/dist/dashboard/generate.js.map +1 -1
  46. package/dist/diff/engine.d.ts +2 -1
  47. package/dist/diff/engine.d.ts.map +1 -1
  48. package/dist/diff/engine.js +3 -2
  49. package/dist/diff/engine.js.map +1 -1
  50. package/dist/init/index.d.ts.map +1 -1
  51. package/dist/init/index.js +24 -5
  52. package/dist/init/index.js.map +1 -1
  53. package/dist/init/migrate.d.ts +39 -0
  54. package/dist/init/migrate.d.ts.map +1 -0
  55. package/dist/init/migrate.js +45 -0
  56. package/dist/init/migrate.js.map +1 -0
  57. package/dist/init/templates.d.ts +8 -0
  58. package/dist/init/templates.d.ts.map +1 -1
  59. package/dist/init/templates.js +71 -9
  60. package/dist/init/templates.js.map +1 -1
  61. package/dist/mcp/lookup.d.ts +1 -0
  62. package/dist/mcp/lookup.d.ts.map +1 -1
  63. package/dist/mcp/lookup.js +138 -10
  64. package/dist/mcp/lookup.js.map +1 -1
  65. package/dist/mcp/server.d.ts +2 -1
  66. package/dist/mcp/server.d.ts.map +1 -1
  67. package/dist/mcp/server.js +20 -8
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/parser/clear.js +1 -1
  70. package/dist/parser/clear.js.map +1 -1
  71. package/dist/parser/feature-filter.d.ts +42 -0
  72. package/dist/parser/feature-filter.d.ts.map +1 -0
  73. package/dist/parser/feature-filter.js +109 -0
  74. package/dist/parser/feature-filter.js.map +1 -0
  75. package/dist/parser/format.d.ts +24 -0
  76. package/dist/parser/format.d.ts.map +1 -0
  77. package/dist/parser/format.js +29 -0
  78. package/dist/parser/format.js.map +1 -0
  79. package/dist/parser/index.d.ts +2 -0
  80. package/dist/parser/index.d.ts.map +1 -1
  81. package/dist/parser/index.js +1 -0
  82. package/dist/parser/index.js.map +1 -1
  83. package/dist/parser/parse-file.d.ts.map +1 -1
  84. package/dist/parser/parse-file.js +3 -1
  85. package/dist/parser/parse-file.js.map +1 -1
  86. package/dist/parser/parse-line.d.ts +3 -0
  87. package/dist/parser/parse-line.d.ts.map +1 -1
  88. package/dist/parser/parse-line.js +78 -22
  89. package/dist/parser/parse-line.js.map +1 -1
  90. package/dist/parser/parse-project.js +19 -0
  91. package/dist/parser/parse-project.js.map +1 -1
  92. package/dist/parser/validate.d.ts +3 -0
  93. package/dist/parser/validate.d.ts.map +1 -1
  94. package/dist/parser/validate.js +7 -0
  95. package/dist/parser/validate.js.map +1 -1
  96. package/dist/report/index.d.ts +1 -0
  97. package/dist/report/index.d.ts.map +1 -1
  98. package/dist/report/index.js +1 -0
  99. package/dist/report/index.js.map +1 -1
  100. package/dist/report/report.d.ts.map +1 -1
  101. package/dist/report/report.js +924 -24
  102. package/dist/report/report.js.map +1 -1
  103. package/dist/report/sequence.d.ts +11 -0
  104. package/dist/report/sequence.d.ts.map +1 -0
  105. package/dist/report/sequence.js +140 -0
  106. package/dist/report/sequence.js.map +1 -0
  107. package/dist/tui/commands.d.ts +1 -0
  108. package/dist/tui/commands.d.ts.map +1 -1
  109. package/dist/tui/commands.js +83 -4
  110. package/dist/tui/commands.js.map +1 -1
  111. package/dist/tui/index.d.ts.map +1 -1
  112. package/dist/tui/index.js +7 -2
  113. package/dist/tui/index.js.map +1 -1
  114. package/dist/types/index.d.ts +57 -3
  115. package/dist/types/index.d.ts.map +1 -1
  116. package/dist/workspace/merge.d.ts.map +1 -1
  117. package/dist/workspace/merge.js +6 -2
  118. package/dist/workspace/merge.js.map +1 -1
  119. package/package.json +1 -1
@@ -1,16 +1,30 @@
1
1
  /**
2
- * GuardLink Dashboard — Mermaid diagram generators.
2
+ * GuardLink Dashboard — diagram generators.
3
3
  *
4
- * Three diagram types:
5
- * 1. Threat Model Graph assets, threats, controls, relationships
6
- * 2. Data Flow Diagram — @flows with trust boundaries
7
- * 3. Attack Surface exposures grouped by severity
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
- /** Sanitize IDs for Mermaid (no dots, spaces, hashes) */
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
- * Diagram 1: Threat Model Graph
56
- * Shows assets (boxes), threats (red), controls (green), and relationships.
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
- // Filter to critical+high severity threats to keep diagram readable
60
- const highSevThreats = new Set();
61
- const sevMap = new Map();
62
- const threatLabelMap = new Map();
63
- const sevRank = { critical: 0, p0: 0, high: 1, p1: 1, medium: 2, p2: 2, low: 3, p3: 3, unset: 4 };
64
- const setThreatSeverity = (ref, severity) => {
65
- if (!ref || !severity)
66
- return;
67
- const norm = normalizeRef(ref);
68
- const current = sevMap.get(ref) || sevMap.get(norm);
69
- if (!current || (sevRank[severity] ?? 4) < (sevRank[current] ?? 4)) {
70
- sevMap.set(ref, severity);
71
- sevMap.set(norm, severity);
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 setThreatLabel = (ref, display) => {
75
- if (!ref || !display)
76
- return;
77
- const norm = normalizeRef(ref);
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
- for (const t of model.threats) {
86
- const s = (t.severity || '').toLowerCase();
87
- if (t.id) {
88
- setThreatSeverity(`#${t.id}`, s);
89
- setThreatSeverity(t.id, s);
90
- setThreatLabel(`#${t.id}`, t.name);
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
- if (filterHigh && !isHighThreat(e.threat))
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
- usedAssets.add(e.asset);
129
- usedThreats.add(e.threat);
470
+ registerAsset(e.asset);
471
+ registerThreat(e.threat);
130
472
  }
131
473
  for (const m of model.mitigations) {
132
- if (filterHigh && !isHighThreat(m.threat))
474
+ const threat = resolve('threat', m.threat);
475
+ if (filterHigh && !isHighSev(threat.severity))
133
476
  continue;
134
- usedAssets.add(m.asset);
135
- usedThreats.add(m.threat);
477
+ registerAsset(m.asset);
478
+ registerThreat(m.threat);
136
479
  if (m.control)
137
- usedControls.add(m.control);
480
+ registerControl(m.control);
138
481
  }
139
482
  for (const a of model.acceptances) {
140
- if (filterHigh && !isHighThreat(a.threat))
483
+ const threat = resolve('threat', a.threat);
484
+ if (filterHigh && !isHighSev(threat.severity))
141
485
  continue;
142
- usedAssets.add(a.asset);
143
- usedThreats.add(a.threat);
486
+ registerAsset(a.asset);
487
+ registerThreat(a.threat);
144
488
  }
145
489
  for (const t of model.transfers) {
146
- if (filterHigh && !isHighThreat(t.threat))
490
+ const threat = resolve('threat', t.threat);
491
+ if (filterHigh && !isHighSev(threat.severity))
147
492
  continue;
148
- usedAssets.add(t.source);
149
- usedAssets.add(t.target);
150
- usedThreats.add(t.threat);
493
+ registerAsset(t.source);
494
+ registerAsset(t.target);
495
+ registerThreat(t.threat);
151
496
  }
152
497
  for (const v of model.validations) {
153
- usedAssets.add(v.asset);
154
- usedControls.add(v.control);
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 e of model.exposures) {
200
- if (e.external_refs.length > 0)
201
- addExtRefs(e.threat, e.external_refs);
501
+ for (const c of model.confirmed || []) {
502
+ registerAsset(c.asset);
503
+ registerThreat(c.threat);
202
504
  }
203
- // ── Determine trust-boundary groupings for used assets ──
204
- const assetZone = new Map(); // asset → zone id
205
- const zoneAssets = new Map(); // zone id → assets
206
- const zoneLabel = new Map(); // zone id → label
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
- // Only include boundaries where at least one side is a used asset
210
- const aUsed = usedAssets.has(b.asset_a) || usedAssets.has(normalizeRef(b.asset_a));
211
- const bUsed = usedAssets.has(b.asset_b) || usedAssets.has(normalizeRef(b.asset_b));
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
- // Assign each side to a zone if not already assigned
215
- for (const side of [b.asset_a, b.asset_b]) {
216
- if (!assetZone.has(side) && !assetZone.has(normalizeRef(side))) {
217
- const zId = `TZ${zIdx++}`;
218
- assetZone.set(side, zId);
219
- assetZone.set(normalizeRef(side), zId);
220
- zoneAssets.set(zId, new Set([side]));
221
- zoneLabel.set(zId, side);
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
- // ── Emit trust-boundary subgraphs ──
226
- const inSubgraph = new Set();
227
- for (const [zId, members] of zoneAssets) {
228
- const rep = zoneLabel.get(zId) || [...members][0];
229
- lines.push(` subgraph ${zId}["🧱 ${label(rep)}"]`);
230
- for (const m of members) {
231
- if (!usedAssets.has(m) && !usedAssets.has(normalizeRef(m)))
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
- const badges = dataClassMap.get(m) || dataClassMap.get(normalizeRef(m));
234
- const owner = ownerMap.get(m) || ownerMap.get(normalizeRef(m));
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
- // ── Asset nodes (not already in a subgraph) ──
247
- for (const a of usedAssets) {
248
- if (inSubgraph.has(a) || inSubgraph.has(normalizeRef(a)))
572
+ // Standalone assets
573
+ for (const key of usedAssetKeys) {
574
+ if (emittedAssets.has(key))
249
575
  continue;
250
- const badges = dataClassMap.get(a) || dataClassMap.get(normalizeRef(a));
251
- const owner = ownerMap.get(a) || ownerMap.get(normalizeRef(a));
252
- let suffix = '';
253
- if (badges && badges.length > 0)
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 (with CWE/external-ref badges)
260
- for (const t of usedThreats) {
261
- const sev = sevMap.get(t) || sevMap.get(normalizeRef(t)) || '';
262
- const display = threatLabelMap.get(t) || threatLabelMap.get(normalizeRef(t)) || t.replace('#', '');
263
- const icon = sev === 'critical' || sev === 'p0' ? '🔴' : sev === 'high' || sev === 'p1' ? '🟠' : '🟡';
264
- const refs = extRefMap.get(t) || extRefMap.get(normalizeRef(t));
265
- const refSuffix = refs && refs.length > 0 ? ` (${refs.slice(0, 2).join(', ')})` : '';
266
- lines.push(` ${mid(t)}["${icon} ${label(display, 35)}${refSuffix}"]:::threat`);
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 c of usedControls) {
270
- lines.push(` ${mid(c)}["🛡️ ${label(c.replace('#', ''))}"]:::control`);
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
- // Exposure edges (deduplicated)
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
- if (filterHigh && !isHighThreat(e.threat))
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
- const key = `${mid(e.asset)}->exp->${mid(e.threat)}`;
277
- if (!edges.has(key)) {
278
- edges.add(key);
279
- lines.push(` ${mid(e.asset)} -. exposed .-> ${mid(e.threat)}`);
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
- if (filterHigh && !isHighThreat(m.threat))
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 k1 = `${mid(m.control)}->mit->${mid(m.threat)}`;
288
- if (!edges.has(k1)) {
289
- edges.add(k1);
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
- const key = `${mid(m.asset)}->mit->${mid(m.threat)}`;
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
- if (filterHigh && !isHighThreat(a.threat))
634
+ const threat = resolve('threat', a.threat);
635
+ const asset = resolve('asset', a.asset);
636
+ if (filterHigh && !isHighSev(threat.severity))
309
637
  continue;
310
- const key = `${mid(a.asset)}->acc->${mid(a.threat)}`;
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
- if (filterHigh && !isHighThreat(t.threat))
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
- const threatDisplay = threatLabelMap.get(t.threat) || threatLabelMap.get(normalizeRef(t.threat)) || t.threat.replace('#', '');
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 key = `${mid(v.control)}->val->${mid(v.asset)}`;
330
- if (!edges.has(key)) {
331
- edges.add(key);
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 srcIn = usedAssets.has(f.source) || usedAssets.has(normalizeRef(f.source));
338
- const tgtIn = usedAssets.has(f.target) || usedAssets.has(normalizeRef(f.target));
339
- if (!srcIn || !tgtIn)
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
- const key = `${mid(f.source)}->flow->${mid(f.target)}`;
342
- if (!edges.has(key)) {
343
- edges.add(key);
344
- if (f.mechanism) {
345
- lines.push(` ${mid(f.source)} -- "${flowIcon(f.mechanism)} ${label(f.mechanism, 22)}" --> ${mid(f.target)}`);
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 aIn = usedAssets.has(b.asset_a) || usedAssets.has(normalizeRef(b.asset_a));
355
- const bIn = usedAssets.has(b.asset_b) || usedAssets.has(normalizeRef(b.asset_b));
356
- if (!aIn || !bIn)
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 key = `${mid(b.asset_a)}->boundary->${mid(b.asset_b)}`;
359
- if (!edges.has(key)) {
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:#991b1b,stroke:#ef4444,color:#fecaca');
366
- lines.push(' classDef control fill:#065f46,stroke:#10b981,color:#a7f3d0');
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
- * Shows @flows between components with @boundary as subgraphs.
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 maxMechanismLen = model.flows.reduce((max, f) => {
377
- const len = (f.mechanism || '').length;
378
- return len > max ? len : max;
379
- }, 0);
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 = 40 + Math.floor(spacingBoost * 0.4);
382
- const rankSpacing = 50 + spacingBoost;
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
- // Collect boundary zones: each side of a boundary is a separate zone
388
- // An asset may appear in multiple boundaries, so track zone membership
389
- const assetZone = new Map(); // asset -> zone label
390
- const zones = new Map(); // zone label -> members
391
- const boundaryEdges = [];
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
- for (const b of model.boundaries) {
394
- const desc = b.description || b.id || `${b.asset_a}/${b.asset_b}`;
395
- // Assign each side to its own zone if not already in one
396
- if (!assetZone.has(b.asset_a)) {
397
- const zoneLabel = `Z${zIdx++}`;
398
- assetZone.set(b.asset_a, zoneLabel);
399
- zones.set(zoneLabel, new Set([b.asset_a]));
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
- // Emit boundary edges between zones (thick dashed line)
420
- for (const be of boundaryEdges) {
421
- lines.push(` ${mid(be.a)} -.-|🧱 ${labelFull(be.desc)}| ${mid(be.b)}`);
422
- }
423
- // Data handling badges
424
- const handling = new Map();
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
- // Standalone nodes (not in any boundary)
431
- const allNodes = new Set();
432
- for (const f of model.flows) {
433
- allNodes.add(f.source);
434
- allNodes.add(f.target);
435
- }
436
- for (const n of allNodes) {
437
- if (!inBoundary.has(n)) {
438
- const badges = handling.get(n);
439
- const suffix = badges ? ` · ${badges.join(', ')}` : '';
440
- lines.push(` ${mid(n)}["${assetIcon(n)} ${labelFull(n)}${suffix}"]`);
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(f.source)} -- "${flowIcon(f.mechanism)} ${labelFull(f.mechanism)}" --> ${mid(f.target)}`);
776
+ lines.push(` ${mid(s.key)} -- "${flowIcon(f.mechanism)} ${labelFull(f.mechanism)}" --> ${mid(t.key)}`);
447
777
  }
448
778
  else {
449
- lines.push(` ${mid(f.source)} --> ${mid(f.target)}`);
779
+ lines.push(` ${mid(s.key)} --> ${mid(t.key)}`);
450
780
  }
451
781
  }
452
782
  return lines.join('\n');
453
783
  }
454
784
  /**
455
- * Diagram 3: Attack Surface Map
456
- * Groups exposures by asset, colored by severity.
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 lines = ['graph LR'];
462
- // Build set of mitigated/accepted (normalize refs for consistent matching)
463
- const resolved = new Set();
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
- resolved.add(`${normalizeRef(m.asset)}::${normalizeRef(m.threat)}`);
801
+ mitigatedPairs.add(`${resolveAsset(m.asset).key}::${resolveThreat(m.threat).key}`);
466
802
  for (const a of model.acceptances)
467
- resolved.add(`${normalizeRef(a.asset)}::${normalizeRef(a.threat)}`);
468
- // Group exposures by asset, deduplicate by threat, keep highest severity
469
- const sevOrder = { critical: 0, p0: 0, high: 1, p1: 1, medium: 2, p2: 2, low: 3, p3: 3, unset: 4 };
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
- if (!byAsset.has(e.asset))
473
- byAsset.set(e.asset, new Map());
474
- const assetMap = byAsset.get(e.asset);
475
- const existing = assetMap.get(e.threat);
476
- const sev = (e.severity || 'unset').toLowerCase();
477
- const isResolved = resolved.has(`${normalizeRef(e.asset)}::${normalizeRef(e.threat)}`);
478
- if (!existing || (sevOrder[sev] ?? 4) < (sevOrder[existing.severity] ?? 4)) {
479
- assetMap.set(e.threat, { threat: e.threat, severity: sev, count: (existing?.count || 0) + 1, resolved: isResolved });
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 [asset, threatMap] of byAsset) {
487
- // Sort threats by severity (critical first)
488
- const sorted = [...threatMap.values()].sort((a, b) => (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4));
489
- lines.push(` subgraph A_${mid(asset)}["${label(asset)}"]`);
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
- let cls = 'sev_unset';
493
- if (entry.severity === 'critical' || entry.severity === 'p0')
494
- cls = 'sev_crit';
495
- else if (entry.severity === 'high' || entry.severity === 'p1')
496
- cls = 'sev_high';
497
- else if (entry.severity === 'medium' || entry.severity === 'p2')
498
- cls = 'sev_med';
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:#7f1d1d,stroke:#ef4444,color:#fecaca');
510
- lines.push(' classDef sev_high fill:#7c2d12,stroke:#f97316,color:#fed7aa');
511
- lines.push(' classDef sev_med fill:#78350f,stroke:#f59e0b,color:#fef3c7');
512
- lines.push(' classDef sev_low fill:#1e3a5f,stroke:#3b82f6,color:#bfdbfe');
513
- lines.push(' classDef sev_unset fill:#374151,stroke:#9ca3af,color:#e5e7eb');
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