guardlink 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/dist/agents/config.d.ts +46 -0
  5. package/dist/agents/config.d.ts.map +1 -0
  6. package/dist/agents/config.js +189 -0
  7. package/dist/agents/config.js.map +1 -0
  8. package/dist/agents/index.d.ts +24 -0
  9. package/dist/agents/index.d.ts.map +1 -0
  10. package/dist/agents/index.js +42 -0
  11. package/dist/agents/index.js.map +1 -0
  12. package/dist/agents/launcher.d.ts +54 -0
  13. package/dist/agents/launcher.d.ts.map +1 -0
  14. package/dist/agents/launcher.js +152 -0
  15. package/dist/agents/launcher.js.map +1 -0
  16. package/dist/agents/prompts.d.ts +14 -0
  17. package/dist/agents/prompts.d.ts.map +1 -0
  18. package/dist/agents/prompts.js +120 -0
  19. package/dist/agents/prompts.js.map +1 -0
  20. package/dist/analyze/index.d.ts +80 -0
  21. package/dist/analyze/index.d.ts.map +1 -0
  22. package/dist/analyze/index.js +306 -0
  23. package/dist/analyze/index.js.map +1 -0
  24. package/dist/analyze/llm.d.ts +52 -0
  25. package/dist/analyze/llm.d.ts.map +1 -0
  26. package/dist/analyze/llm.js +295 -0
  27. package/dist/analyze/llm.js.map +1 -0
  28. package/dist/analyze/prompts.d.ts +14 -0
  29. package/dist/analyze/prompts.d.ts.map +1 -0
  30. package/dist/analyze/prompts.js +205 -0
  31. package/dist/analyze/prompts.js.map +1 -0
  32. package/dist/analyzer/index.d.ts +5 -0
  33. package/dist/analyzer/index.d.ts.map +1 -0
  34. package/dist/analyzer/index.js +5 -0
  35. package/dist/analyzer/index.js.map +1 -0
  36. package/dist/analyzer/sarif.d.ts +84 -0
  37. package/dist/analyzer/sarif.d.ts.map +1 -0
  38. package/dist/analyzer/sarif.js +149 -0
  39. package/dist/analyzer/sarif.js.map +1 -0
  40. package/dist/cli/index.d.ts +25 -0
  41. package/dist/cli/index.d.ts.map +1 -0
  42. package/dist/cli/index.js +821 -0
  43. package/dist/cli/index.js.map +1 -0
  44. package/dist/dashboard/data.d.ts +52 -0
  45. package/dist/dashboard/data.d.ts.map +1 -0
  46. package/dist/dashboard/data.js +93 -0
  47. package/dist/dashboard/data.js.map +1 -0
  48. package/dist/dashboard/diagrams.d.ts +25 -0
  49. package/dist/dashboard/diagrams.d.ts.map +1 -0
  50. package/dist/dashboard/diagrams.js +243 -0
  51. package/dist/dashboard/diagrams.js.map +1 -0
  52. package/dist/dashboard/generate.d.ts +17 -0
  53. package/dist/dashboard/generate.d.ts.map +1 -0
  54. package/dist/dashboard/generate.js +1258 -0
  55. package/dist/dashboard/generate.js.map +1 -0
  56. package/dist/dashboard/index.d.ts +7 -0
  57. package/dist/dashboard/index.d.ts.map +1 -0
  58. package/dist/dashboard/index.js +7 -0
  59. package/dist/dashboard/index.js.map +1 -0
  60. package/dist/diff/engine.d.ts +51 -0
  61. package/dist/diff/engine.d.ts.map +1 -0
  62. package/dist/diff/engine.js +153 -0
  63. package/dist/diff/engine.js.map +1 -0
  64. package/dist/diff/format.d.ts +10 -0
  65. package/dist/diff/format.d.ts.map +1 -0
  66. package/dist/diff/format.js +111 -0
  67. package/dist/diff/format.js.map +1 -0
  68. package/dist/diff/git.d.ts +24 -0
  69. package/dist/diff/git.d.ts.map +1 -0
  70. package/dist/diff/git.js +85 -0
  71. package/dist/diff/git.js.map +1 -0
  72. package/dist/diff/index.d.ts +7 -0
  73. package/dist/diff/index.d.ts.map +1 -0
  74. package/dist/diff/index.js +7 -0
  75. package/dist/diff/index.js.map +1 -0
  76. package/dist/index.d.ts +20 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +17 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/init/detect.d.ts +42 -0
  81. package/dist/init/detect.d.ts.map +1 -0
  82. package/dist/init/detect.js +185 -0
  83. package/dist/init/detect.js.map +1 -0
  84. package/dist/init/index.d.ts +39 -0
  85. package/dist/init/index.d.ts.map +1 -0
  86. package/dist/init/index.js +228 -0
  87. package/dist/init/index.js.map +1 -0
  88. package/dist/init/picker.d.ts +32 -0
  89. package/dist/init/picker.d.ts.map +1 -0
  90. package/dist/init/picker.js +105 -0
  91. package/dist/init/picker.js.map +1 -0
  92. package/dist/init/templates.d.ts +25 -0
  93. package/dist/init/templates.d.ts.map +1 -0
  94. package/dist/init/templates.js +263 -0
  95. package/dist/init/templates.js.map +1 -0
  96. package/dist/mcp/index.d.ts +12 -0
  97. package/dist/mcp/index.d.ts.map +1 -0
  98. package/dist/mcp/index.js +18 -0
  99. package/dist/mcp/index.js.map +1 -0
  100. package/dist/mcp/lookup.d.ts +27 -0
  101. package/dist/mcp/lookup.d.ts.map +1 -0
  102. package/dist/mcp/lookup.js +282 -0
  103. package/dist/mcp/lookup.js.map +1 -0
  104. package/dist/mcp/server.d.ts +41 -0
  105. package/dist/mcp/server.d.ts.map +1 -0
  106. package/dist/mcp/server.js +388 -0
  107. package/dist/mcp/server.js.map +1 -0
  108. package/dist/mcp/suggest.d.ts +35 -0
  109. package/dist/mcp/suggest.d.ts.map +1 -0
  110. package/dist/mcp/suggest.js +268 -0
  111. package/dist/mcp/suggest.js.map +1 -0
  112. package/dist/parser/comment-strip.d.ts +15 -0
  113. package/dist/parser/comment-strip.d.ts.map +1 -0
  114. package/dist/parser/comment-strip.js +76 -0
  115. package/dist/parser/comment-strip.js.map +1 -0
  116. package/dist/parser/index.d.ts +10 -0
  117. package/dist/parser/index.d.ts.map +1 -0
  118. package/dist/parser/index.js +9 -0
  119. package/dist/parser/index.js.map +1 -0
  120. package/dist/parser/normalize.d.ts +22 -0
  121. package/dist/parser/normalize.d.ts.map +1 -0
  122. package/dist/parser/normalize.js +42 -0
  123. package/dist/parser/normalize.js.map +1 -0
  124. package/dist/parser/parse-file.d.ts +18 -0
  125. package/dist/parser/parse-file.d.ts.map +1 -0
  126. package/dist/parser/parse-file.js +68 -0
  127. package/dist/parser/parse-file.js.map +1 -0
  128. package/dist/parser/parse-line.d.ts +21 -0
  129. package/dist/parser/parse-line.d.ts.map +1 -0
  130. package/dist/parser/parse-line.js +230 -0
  131. package/dist/parser/parse-line.js.map +1 -0
  132. package/dist/parser/parse-project.d.ts +31 -0
  133. package/dist/parser/parse-project.d.ts.map +1 -0
  134. package/dist/parser/parse-project.js +281 -0
  135. package/dist/parser/parse-project.js.map +1 -0
  136. package/dist/report/index.d.ts +6 -0
  137. package/dist/report/index.d.ts.map +1 -0
  138. package/dist/report/index.js +6 -0
  139. package/dist/report/index.js.map +1 -0
  140. package/dist/report/mermaid.d.ts +15 -0
  141. package/dist/report/mermaid.d.ts.map +1 -0
  142. package/dist/report/mermaid.js +260 -0
  143. package/dist/report/mermaid.js.map +1 -0
  144. package/dist/report/report.d.ts +16 -0
  145. package/dist/report/report.d.ts.map +1 -0
  146. package/dist/report/report.js +211 -0
  147. package/dist/report/report.js.map +1 -0
  148. package/dist/tui/commands.d.ts +42 -0
  149. package/dist/tui/commands.d.ts.map +1 -0
  150. package/dist/tui/commands.js +1216 -0
  151. package/dist/tui/commands.js.map +1 -0
  152. package/dist/tui/config.d.ts +27 -0
  153. package/dist/tui/config.d.ts.map +1 -0
  154. package/dist/tui/config.js +27 -0
  155. package/dist/tui/config.js.map +1 -0
  156. package/dist/tui/format.d.ts +63 -0
  157. package/dist/tui/format.d.ts.map +1 -0
  158. package/dist/tui/format.js +253 -0
  159. package/dist/tui/format.js.map +1 -0
  160. package/dist/tui/index.d.ts +18 -0
  161. package/dist/tui/index.d.ts.map +1 -0
  162. package/dist/tui/index.js +470 -0
  163. package/dist/tui/index.js.map +1 -0
  164. package/dist/tui/input.d.ts +63 -0
  165. package/dist/tui/input.d.ts.map +1 -0
  166. package/dist/tui/input.js +454 -0
  167. package/dist/tui/input.js.map +1 -0
  168. package/dist/types/index.d.ts +254 -0
  169. package/dist/types/index.d.ts.map +1 -0
  170. package/dist/types/index.js +6 -0
  171. package/dist/types/index.js.map +1 -0
  172. package/package.json +97 -0
@@ -0,0 +1,821 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GuardLink CLI — Reference Implementation
4
+ *
5
+ * Usage:
6
+ * guardlink init [dir] Initialize GuardLink in a project
7
+ * guardlink parse [dir] Parse annotations, output ThreatModel JSON
8
+ * guardlink status [dir] Show annotation coverage summary
9
+ * guardlink validate [dir] Check for syntax errors and dangling refs
10
+ * guardlink analyze [framework] AI-powered threat analysis (STRIDE, DREAD, etc.)
11
+ * guardlink annotate <prompt> Launch coding agent for annotation
12
+ * guardlink config <action> Manage LLM provider configuration
13
+ *
14
+ * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "Accepts directory paths from command line arguments"
15
+ * @exposes #cli to #arbitrary-write [high] cwe:CWE-73 -- "Writes reports and SARIF to user-specified output paths"
16
+ * @accepts #arbitrary-write on #cli -- "Intentional feature: users specify output paths for reports"
17
+ * @mitigates #cli against #path-traversal using #path-validation -- "resolve() normalizes paths before passing to submodules"
18
+ * @boundary between #cli and #parser (#cli-parser-boundary) -- "CLI is the primary user input trust boundary"
19
+ * @flows User -> #cli via argv -- "User provides directory paths and options via command line"
20
+ * @flows #cli -> #parser via parseProject -- "CLI dispatches parsed commands to parser"
21
+ * @flows #cli -> #report via generateReport -- "CLI writes report output"
22
+ * @flows #cli -> #init via initProject -- "CLI initializes project structure"
23
+ */
24
+ import { Command } from 'commander';
25
+ import { resolve, basename } from 'node:path';
26
+ import { readFileSync, existsSync } from 'node:fs';
27
+ import { parseProject } from '../parser/index.js';
28
+ import { initProject, detectProject, promptAgentSelection } from '../init/index.js';
29
+ import { generateReport, generateMermaid } from '../report/index.js';
30
+ import { diffModels, formatDiff, formatDiffMarkdown, parseAtRef } from '../diff/index.js';
31
+ import { generateSarif } from '../analyzer/index.js';
32
+ import { startStdioServer } from '../mcp/index.js';
33
+ import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, buildConfig, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, serializeModel, buildUserMessage } from '../analyze/index.js';
34
+ import { generateDashboardHTML } from '../dashboard/index.js';
35
+ import { AGENTS, agentFromOpts, launchAgent, buildAnnotatePrompt } from '../agents/index.js';
36
+ import { resolveConfig, saveProjectConfig, saveGlobalConfig, loadProjectConfig, loadGlobalConfig, maskKey, describeConfigSource } from '../agents/config.js';
37
+ import gradient from 'gradient-string';
38
+ const program = new Command();
39
+ const ASCII_LOGO = `
40
+ ██████ ██ ██ █████ ██████ ██████ ██ ██ ███ ██ ██ ██
41
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██
42
+ ██ ███ ██ ██ ███████ ██████ ██ ██ ██ ██ ██ ██ ██ █████
43
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
44
+ ██████ ██████ ██ ██ ██ ██ ██████ ███████ ██ ██ ████ ██ ██
45
+ `;
46
+ /** Generic placeholder names produced by scaffolding tools (v0, CRA, Vite, etc.) */
47
+ const GENERIC_PKG_NAMES = new Set([
48
+ 'my-v0-project', 'my-app', 'my-project', 'my-next-app', 'vite-project',
49
+ 'react-app', 'create-react-app', 'starter', 'app', 'project', 'unknown',
50
+ ]);
51
+ /** Auto-detect project name from git remote, package.json, Cargo.toml, or directory name */
52
+ function detectProjectName(root, explicit) {
53
+ if (explicit && explicit !== 'unknown')
54
+ return explicit;
55
+ try {
56
+ const gitConfigPath = resolve(root, '.git', 'config');
57
+ if (existsSync(gitConfigPath)) {
58
+ const gitConfig = readFileSync(gitConfigPath, 'utf-8');
59
+ const m = gitConfig.match(/url\s*=\s*.*[/:]([^/\s]+?)(?:\.git)?\s*$/m);
60
+ if (m)
61
+ return m[1];
62
+ }
63
+ }
64
+ catch { }
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf-8'));
67
+ if (pkg.name && !GENERIC_PKG_NAMES.has(pkg.name))
68
+ return pkg.name;
69
+ }
70
+ catch { }
71
+ try {
72
+ const cargo = readFileSync(resolve(root, 'Cargo.toml'), 'utf-8');
73
+ const m = cargo.match(/^name\s*=\s*"([^"]+)"/m);
74
+ if (m)
75
+ return m[1];
76
+ }
77
+ catch { }
78
+ return basename(root) || 'unknown';
79
+ }
80
+ program
81
+ .name('guardlink')
82
+ .description('GuardLink — Security annotations for code. Threat modeling that lives in your codebase.')
83
+ .version('1.0.0')
84
+ .addHelpText('before', gradient(['#00ff41', '#00d4ff'])(ASCII_LOGO));
85
+ // ─── init ────────────────────────────────────────────────────────────
86
+ program
87
+ .command('init')
88
+ .description('Initialize GuardLink in a project — creates .guardlink/ and updates agent instruction files')
89
+ .argument('[dir]', 'Project directory', '.')
90
+ .option('-p, --project <n>', 'Override project name')
91
+ .option('-a, --agent <agents>', 'Agent(s) to create files for: claude,cursor,codex,copilot,windsurf,cline,none (comma-separated)')
92
+ .option('--skip-agent-files', 'Only create .guardlink/, skip agent file updates')
93
+ .option('--force', 'Overwrite existing GuardLink config and instructions')
94
+ .option('--dry-run', 'Show what would be created without writing files')
95
+ .action(async (dir, opts) => {
96
+ const root = resolve(dir);
97
+ // Show detection results first
98
+ const info = detectProject(root);
99
+ console.log(`Detected: ${info.language} project "${info.name}"`);
100
+ const existingAgentFiles = info.agentFiles.filter(f => f.exists);
101
+ if (existingAgentFiles.length > 0) {
102
+ console.log(`Found: ${existingAgentFiles.map(f => f.path).join(', ')}`);
103
+ }
104
+ if (info.alreadyInitialized && !opts.force) {
105
+ console.log(`\n.guardlink/ already exists. Use --force to reinitialize.`);
106
+ }
107
+ // Determine agent IDs — always show picker in interactive mode
108
+ let agentIds;
109
+ if (!opts.skipAgentFiles) {
110
+ if (opts.agent) {
111
+ // From --agent flag
112
+ agentIds = opts.agent.split(',').map(s => s.trim().toLowerCase());
113
+ }
114
+ else if (process.stdin.isTTY) {
115
+ // Interactive picker — shows detected + optional agents
116
+ agentIds = await promptAgentSelection(info.agentFiles);
117
+ }
118
+ else {
119
+ // Non-interactive (CI), default to claude
120
+ agentIds = ['claude'];
121
+ }
122
+ }
123
+ console.log('');
124
+ // Run init
125
+ const result = initProject({
126
+ root,
127
+ project: opts.project,
128
+ skipAgentFiles: opts.skipAgentFiles,
129
+ force: opts.force,
130
+ dryRun: opts.dryRun,
131
+ agentIds,
132
+ });
133
+ const prefix = opts.dryRun ? '(dry run) ' : '';
134
+ for (const f of result.created) {
135
+ console.log(`${prefix}Created: ${f}`);
136
+ }
137
+ for (const f of result.updated) {
138
+ console.log(`${prefix}Updated: ${f}`);
139
+ }
140
+ for (const f of result.skipped) {
141
+ console.log(`${prefix}Skipped: ${f}`);
142
+ }
143
+ if (!opts.dryRun && (result.created.length > 0 || result.updated.length > 0)) {
144
+ console.log(`\n✓ GuardLink initialized. Next steps:`);
145
+ console.log(` 1. Review .guardlink/definitions${info.definitionsExt} — remove threats/controls not relevant to your project`);
146
+ console.log(` 2. Add annotations to your source files (or ask your coding agent to do it)`);
147
+ console.log(` 3. Run: guardlink validate .`);
148
+ }
149
+ });
150
+ // ─── parse ───────────────────────────────────────────────────────────
151
+ program
152
+ .command('parse')
153
+ .description('Parse all GuardLink annotations and output the threat model as JSON')
154
+ .argument('[dir]', 'Project directory to scan', '.')
155
+ .option('-p, --project <name>', 'Project name', 'unknown')
156
+ .option('-o, --output <file>', 'Write JSON to file instead of stdout')
157
+ .option('--pretty', 'Pretty-print JSON output', true)
158
+ .action(async (dir, opts) => {
159
+ const root = resolve(dir);
160
+ const { model, diagnostics } = await parseProject({ root, project: opts.project });
161
+ // Print diagnostics to stderr
162
+ printDiagnostics(diagnostics);
163
+ // Output model
164
+ const json = JSON.stringify(model, null, opts.pretty ? 2 : 0);
165
+ if (opts.output) {
166
+ const { writeFile } = await import('node:fs/promises');
167
+ await writeFile(opts.output, json + '\n');
168
+ console.error(`Wrote threat model to ${opts.output}`);
169
+ }
170
+ else {
171
+ console.log(json);
172
+ }
173
+ process.exit(diagnostics.some(d => d.level === 'error') ? 1 : 0);
174
+ });
175
+ // ─── status ──────────────────────────────────────────────────────────
176
+ program
177
+ .command('status')
178
+ .description('Show annotation coverage summary')
179
+ .argument('[dir]', 'Project directory to scan', '.')
180
+ .option('-p, --project <n>', 'Project name', 'unknown')
181
+ .action(async (dir, opts) => {
182
+ const root = resolve(dir);
183
+ const { model, diagnostics } = await parseProject({ root, project: opts.project });
184
+ printDiagnostics(diagnostics);
185
+ printStatus(model);
186
+ });
187
+ // ─── validate ────────────────────────────────────────────────────────
188
+ program
189
+ .command('validate')
190
+ .description('Check annotations for syntax errors and dangling references')
191
+ .argument('[dir]', 'Project directory to scan', '.')
192
+ .option('-p, --project <n>', 'Project name', 'unknown')
193
+ .option('--strict', 'Also fail on unmitigated exposures (for CI gates)')
194
+ .action(async (dir, opts) => {
195
+ const root = resolve(dir);
196
+ const { model, diagnostics } = await parseProject({ root, project: opts.project });
197
+ // Check for dangling refs
198
+ const danglingDiags = findDanglingRefs(model);
199
+ const allDiags = [...diagnostics, ...danglingDiags];
200
+ // Check for unmitigated exposures
201
+ const unmitigated = findUnmitigatedExposures(model);
202
+ printDiagnostics(allDiags);
203
+ if (unmitigated.length > 0) {
204
+ console.error(`\n⚠ ${unmitigated.length} unmitigated exposure(s):`);
205
+ for (const u of unmitigated) {
206
+ console.error(` ${u.asset} → ${u.threat} [${u.severity || 'unset'}] (${u.location.file}:${u.location.line})`);
207
+ }
208
+ }
209
+ const errorCount = allDiags.filter(d => d.level === 'error').length;
210
+ const hasUnmitigated = unmitigated.length > 0;
211
+ if (errorCount === 0 && !hasUnmitigated) {
212
+ console.error('\n✓ All annotations valid, no unmitigated exposures.');
213
+ }
214
+ else if (errorCount === 0 && hasUnmitigated) {
215
+ console.error(`\nValidation passed with ${unmitigated.length} unmitigated exposure(s).`);
216
+ }
217
+ // Exit 1 on errors always; also on unmitigated if --strict
218
+ process.exit(errorCount > 0 || (opts.strict && hasUnmitigated) ? 1 : 0);
219
+ });
220
+ // ─── report ──────────────────────────────────────────────────────────
221
+ program
222
+ .command('report')
223
+ .description('Generate a threat model report with Mermaid diagram')
224
+ .argument('[dir]', 'Project directory to scan', '.')
225
+ .option('-p, --project <n>', 'Project name', 'unknown')
226
+ .option('-o, --output <file>', 'Write report to file (default: threat-model.md)')
227
+ .option('--diagram-only', 'Output only the Mermaid diagram, no report wrapper')
228
+ .option('--json', 'Also output threat-model.json alongside the report')
229
+ .action(async (dir, opts) => {
230
+ const root = resolve(dir);
231
+ const { model, diagnostics } = await parseProject({ root, project: opts.project });
232
+ // Show errors if any
233
+ const errors = diagnostics.filter(d => d.level === 'error');
234
+ if (errors.length > 0) {
235
+ printDiagnostics(errors);
236
+ console.error(`Fix errors above before generating report.\n`);
237
+ }
238
+ if (opts.diagramOnly) {
239
+ // Just output Mermaid
240
+ const mermaid = generateMermaid(model);
241
+ if (opts.output) {
242
+ const { writeFile } = await import('node:fs/promises');
243
+ await writeFile(opts.output, mermaid + '\n');
244
+ console.error(`Wrote Mermaid diagram to ${opts.output}`);
245
+ }
246
+ else {
247
+ console.log(mermaid);
248
+ }
249
+ }
250
+ else {
251
+ // Full report
252
+ const report = generateReport(model);
253
+ const outFile = opts.output || 'threat-model.md';
254
+ const { writeFile } = await import('node:fs/promises');
255
+ await writeFile(resolve(root, outFile), report + '\n');
256
+ console.error(`✓ Wrote threat model report to ${outFile}`);
257
+ if (opts.json) {
258
+ const jsonFile = outFile.replace(/\.md$/, '.json');
259
+ await writeFile(resolve(root, jsonFile), JSON.stringify(model, null, 2) + '\n');
260
+ console.error(`✓ Wrote threat model JSON to ${jsonFile}`);
261
+ }
262
+ }
263
+ });
264
+ // ─── diff ────────────────────────────────────────────────────────────
265
+ program
266
+ .command('diff')
267
+ .description('Compare threat model against a git ref — find what changed')
268
+ .argument('[ref]', 'Git ref to compare against (commit, branch, tag, HEAD~1)', 'HEAD~1')
269
+ .option('-d, --dir <dir>', 'Project directory', '.')
270
+ .option('-p, --project <n>', 'Project name', 'unknown')
271
+ .option('--markdown', 'Output as markdown (for PR comments)')
272
+ .option('--json', 'Output as JSON')
273
+ .option('--fail-on-new', 'Exit 1 if new unmitigated exposures found (CI mode)')
274
+ .action(async (ref, opts) => {
275
+ const root = resolve(opts.dir);
276
+ // Parse current state
277
+ console.error(`Parsing current threat model...`);
278
+ const { model: current } = await parseProject({ root, project: opts.project });
279
+ // Parse at ref
280
+ console.error(`Parsing threat model at ${ref}...`);
281
+ let previous;
282
+ try {
283
+ previous = await parseAtRef(root, ref, opts.project);
284
+ }
285
+ catch (err) {
286
+ console.error(`Error: ${err.message}`);
287
+ process.exit(1);
288
+ }
289
+ // Compute diff
290
+ const diff = diffModels(previous, current);
291
+ // Output
292
+ if (opts.json) {
293
+ console.log(JSON.stringify(diff, null, 2));
294
+ }
295
+ else if (opts.markdown) {
296
+ console.log(formatDiffMarkdown(diff));
297
+ }
298
+ else {
299
+ console.log(formatDiff(diff));
300
+ }
301
+ // CI gate
302
+ if (opts.failOnNew && diff.newUnmitigatedExposures.length > 0) {
303
+ process.exit(1);
304
+ }
305
+ });
306
+ // ─── sarif ───────────────────────────────────────────────────────────
307
+ program
308
+ .command('sarif')
309
+ .description('Export findings as SARIF 2.1.0 for GitHub Advanced Security, VS Code, etc.')
310
+ .argument('[dir]', 'Project directory to scan', '.')
311
+ .option('-p, --project <n>', 'Project name', 'unknown')
312
+ .option('-o, --output <file>', 'Write SARIF to file (default: stdout)')
313
+ .option('--min-severity <sev>', 'Only include exposures at or above this severity (critical|high|medium|low)')
314
+ .option('--no-diagnostics', 'Exclude parse errors from SARIF output')
315
+ .action(async (dir, opts) => {
316
+ const root = resolve(dir);
317
+ const { model, diagnostics } = await parseProject({ root, project: opts.project });
318
+ // Compute dangling refs (reuse validate logic)
319
+ const danglingDiags = findDanglingRefs(model);
320
+ const sarif = generateSarif(model, diagnostics, danglingDiags, {
321
+ includeDiagnostics: opts.diagnostics !== false,
322
+ includeDanglingRefs: true,
323
+ minSeverity: opts.minSeverity,
324
+ });
325
+ const json = JSON.stringify(sarif, null, 2);
326
+ if (opts.output) {
327
+ const { writeFile } = await import('node:fs/promises');
328
+ await writeFile(resolve(root, opts.output), json + '\n');
329
+ console.error(`✓ Wrote SARIF to ${opts.output}`);
330
+ }
331
+ else {
332
+ console.log(json);
333
+ }
334
+ // Summary to stderr
335
+ const resultCount = sarif.runs[0]?.results.length ?? 0;
336
+ const errors = sarif.runs[0]?.results.filter(r => r.level === 'error').length ?? 0;
337
+ const warnings = sarif.runs[0]?.results.filter(r => r.level === 'warning').length ?? 0;
338
+ console.error(`SARIF: ${resultCount} result(s) — ${errors} error(s), ${warnings} warning(s)`);
339
+ });
340
+ // ─── threat-report ───────────────────────────────────────────────────
341
+ program
342
+ .command('threat-report')
343
+ .description('Generate an AI threat report using a security framework (STRIDE, DREAD, PASTA, etc.)')
344
+ .argument('[framework]', 'Framework: stride, dread, pasta, attacker, rapid, general', 'general')
345
+ .argument('[dir]', 'Project directory', '.')
346
+ .option('-p, --project <n>', 'Project name', 'unknown')
347
+ .option('--provider <provider>', 'LLM provider: anthropic, openai, openrouter, deepseek (auto-detected from env)')
348
+ .option('--model <model>', 'Model name (default: provider-specific)')
349
+ .option('--api-key <key>', 'API key (default: from env variable)')
350
+ .option('--no-stream', 'Disable streaming output')
351
+ .option('--custom <prompt>', 'Custom analysis prompt (replaces framework prompt header)')
352
+ .option('--claude-code', 'Launch Claude Code in foreground')
353
+ .option('--codex', 'Launch Codex CLI in foreground')
354
+ .option('--gemini', 'Launch Gemini CLI in foreground')
355
+ .option('--cursor', 'Open Cursor IDE with prompt on clipboard')
356
+ .option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
357
+ .option('--clipboard', 'Copy threat report prompt to clipboard only')
358
+ .action(async (framework, dir, opts) => {
359
+ const root = resolve(dir);
360
+ const project = detectProjectName(root, opts.project);
361
+ // Validate framework
362
+ const validFrameworks = ['stride', 'dread', 'pasta', 'attacker', 'rapid', 'general'];
363
+ if (!validFrameworks.includes(framework)) {
364
+ console.error(`Unknown framework: ${framework}`);
365
+ console.error(`Available: ${validFrameworks.join(', ')}`);
366
+ process.exit(1);
367
+ }
368
+ const fw = framework;
369
+ // Parse project
370
+ const { model, diagnostics } = await parseProject({ root, project });
371
+ const errors = diagnostics.filter(d => d.level === 'error');
372
+ if (errors.length > 0)
373
+ printDiagnostics(errors);
374
+ if (model.annotations_parsed === 0) {
375
+ console.error('No annotations found. Run: guardlink init . && add annotations first.');
376
+ process.exit(1);
377
+ }
378
+ // Resolve agent (same pattern as annotate)
379
+ const agent = agentFromOpts(opts);
380
+ // ── Agent path: build prompt, launch agent ──
381
+ if (agent) {
382
+ const serialized = serializeModel(model);
383
+ const systemPrompt = FRAMEWORK_PROMPTS[fw] || FRAMEWORK_PROMPTS.general;
384
+ const userMessage = buildUserMessage(serialized, fw, opts.custom);
385
+ const fullPrompt = `${systemPrompt}\n\n${userMessage}\n\nAlso read the source files to understand code context. Save the report to .guardlink/threat-reports/ as a markdown file.`;
386
+ console.log(`Generating ${FRAMEWORK_LABELS[fw]} via ${agent.name}...`);
387
+ if (agent.cmd) {
388
+ console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
389
+ }
390
+ const result = launchAgent(agent, fullPrompt, root);
391
+ if (result.clipboardCopied) {
392
+ console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
393
+ }
394
+ if (result.error) {
395
+ console.error(`✗ ${result.error}`);
396
+ if (result.clipboardCopied) {
397
+ console.log('Prompt is on your clipboard — paste it manually.');
398
+ }
399
+ process.exit(1);
400
+ }
401
+ if (agent.cmd && result.launched) {
402
+ console.log(`\n✓ ${agent.name} session ended.`);
403
+ console.log(' Run: guardlink threat-reports to see saved reports.');
404
+ }
405
+ else if (agent.app && result.launched) {
406
+ console.log(`✓ ${agent.name} launched with project: ${project}`);
407
+ console.log('\nPaste (Cmd+V) the prompt in the AI chat panel.');
408
+ console.log('When done, run: guardlink threat-reports');
409
+ }
410
+ else if (agent.id === 'clipboard') {
411
+ console.log('\nPaste the prompt into your preferred AI tool.');
412
+ console.log('When done, run: guardlink threat-reports');
413
+ }
414
+ return;
415
+ }
416
+ // ── API path: direct LLM call (no agent flag) ──
417
+ const llmConfig = buildConfig({
418
+ provider: opts.provider,
419
+ model: opts.model,
420
+ apiKey: opts.apiKey,
421
+ });
422
+ if (!llmConfig) {
423
+ // No agent, no API key — show usage like annotate does
424
+ console.error('No agent or API key specified. Use one of:');
425
+ for (const a of AGENTS) {
426
+ console.error(` ${a.flag.padEnd(16)} ${a.name}`);
427
+ }
428
+ console.error('');
429
+ console.error('Or set an API key: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.');
430
+ console.error('Or use: --provider anthropic --api-key sk-...');
431
+ process.exit(1);
432
+ }
433
+ console.error(`\n🔍 ${FRAMEWORK_LABELS[fw]}`);
434
+ console.error(` Provider: ${llmConfig.provider} | Model: ${llmConfig.model}`);
435
+ console.error(` Annotations: ${model.annotations_parsed} | Exposures: ${model.exposures.length}\n`);
436
+ try {
437
+ const result = await generateThreatReport({
438
+ root,
439
+ model,
440
+ framework: fw,
441
+ llmConfig,
442
+ customPrompt: opts.custom,
443
+ stream: opts.stream !== false,
444
+ onChunk: opts.stream !== false ? (text) => process.stdout.write(text) : undefined,
445
+ });
446
+ if (opts.stream !== false) {
447
+ process.stdout.write('\n');
448
+ }
449
+ else {
450
+ console.log(result.content);
451
+ }
452
+ console.error(`\n✓ Report saved to ${result.savedTo}`);
453
+ if (result.inputTokens || result.outputTokens) {
454
+ console.error(` Tokens: ${result.inputTokens || '?'} in / ${result.outputTokens || '?'} out`);
455
+ }
456
+ }
457
+ catch (err) {
458
+ console.error(`\n✗ Threat report generation failed: ${err.message}`);
459
+ process.exit(1);
460
+ }
461
+ });
462
+ // ─── threat-reports (list) ───────────────────────────────────────────
463
+ program
464
+ .command('threat-reports')
465
+ .description('List saved AI threat reports')
466
+ .option('-d, --dir <dir>', 'Project directory', '.')
467
+ .action(async (opts) => {
468
+ const root = resolve(opts.dir);
469
+ const reports = listThreatReports(root);
470
+ if (reports.length === 0) {
471
+ console.log('No saved threat reports found.');
472
+ console.log('Run: guardlink threat-report <framework> (e.g., guardlink threat-report stride --claude-code)');
473
+ return;
474
+ }
475
+ console.log('Saved threat reports:\n');
476
+ for (const r of reports) {
477
+ const dirLabel = r.dirName || 'threat-reports';
478
+ console.log(` ${r.timestamp} ${r.label.padEnd(28)} ${r.model || ''}`);
479
+ console.log(` ${' '.repeat(21)}→ .guardlink/${dirLabel}/${r.filename}`);
480
+ }
481
+ console.log(`\n${reports.length} report(s)`);
482
+ });
483
+ // ─── annotate ────────────────────────────────────────────────────────
484
+ program
485
+ .command('annotate')
486
+ .description('Launch a coding agent to add GuardLink security annotations')
487
+ .argument('<prompt>', 'Annotation instructions (e.g., "annotate auth endpoints for OWASP Top 10")')
488
+ .argument('[dir]', 'Project directory', '.')
489
+ .option('-p, --project <n>', 'Project name', 'unknown')
490
+ .option('--claude-code', 'Launch Claude Code in foreground')
491
+ .option('--codex', 'Launch Codex CLI in foreground')
492
+ .option('--gemini', 'Launch Gemini CLI in foreground')
493
+ .option('--cursor', 'Open Cursor IDE with prompt on clipboard')
494
+ .option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
495
+ .option('--clipboard', 'Copy annotation prompt to clipboard only')
496
+ .action(async (prompt, dir, opts) => {
497
+ const root = resolve(dir);
498
+ const project = detectProjectName(root, opts.project);
499
+ // Resolve agent
500
+ const agent = agentFromOpts(opts);
501
+ if (!agent) {
502
+ console.error('No agent specified. Use one of:');
503
+ for (const a of AGENTS) {
504
+ console.error(` ${a.flag.padEnd(16)} ${a.name}`);
505
+ }
506
+ process.exit(1);
507
+ }
508
+ // Parse model (optional — annotations may not exist yet)
509
+ let model = null;
510
+ try {
511
+ const result = await parseProject({ root, project });
512
+ if (result.model.annotations_parsed > 0) {
513
+ model = result.model;
514
+ }
515
+ }
516
+ catch { /* no model yet — that's fine */ }
517
+ // Build prompt
518
+ const fullPrompt = buildAnnotatePrompt(prompt, root, model);
519
+ // Launch agent
520
+ console.log(`Launching ${agent.name} for annotation...`);
521
+ if (agent.cmd) {
522
+ console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
523
+ }
524
+ const result = launchAgent(agent, fullPrompt, root);
525
+ if (result.clipboardCopied) {
526
+ console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
527
+ }
528
+ if (result.error) {
529
+ console.error(`✗ ${result.error}`);
530
+ if (result.clipboardCopied) {
531
+ console.log('Prompt is on your clipboard — paste it manually.');
532
+ }
533
+ process.exit(1);
534
+ }
535
+ if (agent.cmd && result.launched) {
536
+ // Agent exited — suggest next step
537
+ console.log(`\n✓ ${agent.name} session ended.`);
538
+ console.log(' Run: guardlink parse to update the threat model.');
539
+ }
540
+ else if (agent.app && result.launched) {
541
+ console.log(`✓ ${agent.name} launched with project: ${project}`);
542
+ console.log('\nPaste (Cmd+V) the prompt in the AI chat panel.');
543
+ console.log('When done, run: guardlink parse');
544
+ }
545
+ else if (agent.id === 'clipboard') {
546
+ console.log('\nPaste the prompt into your preferred AI tool.');
547
+ console.log('When done, run: guardlink parse');
548
+ }
549
+ });
550
+ // ─── config ──────────────────────────────────────────────────────────
551
+ program
552
+ .command('config')
553
+ .description('Manage LLM provider configuration')
554
+ .argument('<action>', 'Action: set, show, clear')
555
+ .argument('[key]', 'Config key: provider, api-key, model')
556
+ .argument('[value]', 'Value to set')
557
+ .option('--global', 'Use global config (~/.config/guardlink/) instead of project')
558
+ .action(async (action, key, value, opts) => {
559
+ const root = resolve('.');
560
+ const isGlobal = opts?.global ?? false;
561
+ switch (action) {
562
+ case 'show': {
563
+ const config = resolveConfig(root);
564
+ const source = describeConfigSource(root);
565
+ if (config) {
566
+ console.log(`Provider: ${config.provider}`);
567
+ console.log(`Model: ${config.model}`);
568
+ console.log(`API Key: ${maskKey(config.apiKey)}`);
569
+ console.log(`Source: ${source}`);
570
+ }
571
+ else {
572
+ console.log('No LLM configuration found.');
573
+ console.log('\nSet one with:');
574
+ console.log(' guardlink config set provider anthropic');
575
+ console.log(' guardlink config set api-key sk-ant-...');
576
+ console.log('\nOr set environment variables:');
577
+ console.log(' export GUARDLINK_LLM_KEY=sk-ant-...');
578
+ console.log(' export GUARDLINK_LLM_PROVIDER=anthropic');
579
+ }
580
+ break;
581
+ }
582
+ case 'set': {
583
+ if (!key || !value) {
584
+ console.error('Usage: guardlink config set <key> <value>');
585
+ console.error('Keys: provider, api-key, model');
586
+ process.exit(1);
587
+ }
588
+ const existing = isGlobal
589
+ ? loadGlobalConfig() || {}
590
+ : loadProjectConfig(root) || {};
591
+ switch (key) {
592
+ case 'provider':
593
+ if (!['anthropic', 'openai', 'openrouter', 'deepseek'].includes(value)) {
594
+ console.error(`Unknown provider: ${value}`);
595
+ console.error('Available: anthropic, openai, openrouter, deepseek');
596
+ process.exit(1);
597
+ }
598
+ existing.provider = value;
599
+ break;
600
+ case 'api-key':
601
+ existing.apiKey = value;
602
+ break;
603
+ case 'model':
604
+ existing.model = value;
605
+ break;
606
+ default:
607
+ console.error(`Unknown config key: ${key}. Use: provider, api-key, model`);
608
+ process.exit(1);
609
+ }
610
+ if (isGlobal) {
611
+ saveGlobalConfig(existing);
612
+ console.log(`✓ Saved to ~/.config/guardlink/config.json`);
613
+ }
614
+ else {
615
+ saveProjectConfig(root, existing);
616
+ console.log(`✓ Saved to .guardlink/config.json`);
617
+ }
618
+ break;
619
+ }
620
+ case 'clear': {
621
+ const emptyConfig = {};
622
+ if (isGlobal) {
623
+ saveGlobalConfig(emptyConfig);
624
+ console.log('✓ Global config cleared.');
625
+ }
626
+ else {
627
+ saveProjectConfig(root, emptyConfig);
628
+ console.log('✓ Project config cleared.');
629
+ }
630
+ break;
631
+ }
632
+ default:
633
+ console.error(`Unknown action: ${action}. Use: show, set, clear`);
634
+ process.exit(1);
635
+ }
636
+ });
637
+ // ─── dashboard ───────────────────────────────────────────────────────
638
+ program
639
+ .command('dashboard')
640
+ .description('Generate an interactive HTML threat model dashboard with diagrams')
641
+ .argument('[dir]', 'Project directory to scan', '.')
642
+ .option('-p, --project <n>', 'Project name', 'unknown')
643
+ .option('-o, --output <file>', 'Output file (default: threat-dashboard.html)')
644
+ .option('--light', 'Default to light theme instead of dark')
645
+ .action(async (dir, opts) => {
646
+ const root = resolve(dir);
647
+ const project = detectProjectName(root, opts.project);
648
+ const { model, diagnostics } = await parseProject({ root, project });
649
+ const errors = diagnostics.filter(d => d.level === 'error');
650
+ if (errors.length > 0)
651
+ printDiagnostics(errors);
652
+ if (model.annotations_parsed === 0) {
653
+ console.error('No annotations found. Add GuardLink annotations first.');
654
+ process.exit(1);
655
+ }
656
+ const analyses = loadThreatReportsForDashboard(root);
657
+ let html = generateDashboardHTML(model, root, analyses);
658
+ // Switch default theme if requested
659
+ if (opts.light) {
660
+ html = html.replace('data-theme="dark"', 'data-theme="light"');
661
+ }
662
+ const outFile = opts.output || 'threat-dashboard.html';
663
+ const { writeFile } = await import('node:fs/promises');
664
+ await writeFile(resolve(root, outFile), html);
665
+ console.error(`✓ Dashboard generated: ${outFile}`);
666
+ console.error(` Open in browser to view. Toggle ☀️/🌙 for light/dark mode.`);
667
+ });
668
+ // ─── mcp ─────────────────────────────────────────────────────────────
669
+ program
670
+ .command('mcp')
671
+ .description('Start GuardLink MCP server (stdio transport) — for Claude Code, Cursor, etc.')
672
+ .action(async () => {
673
+ await startStdioServer();
674
+ });
675
+ program
676
+ .command('tui')
677
+ .description('Interactive TUI — slash commands, AI chat, exposure triage')
678
+ .argument('[dir]', 'project directory', '.')
679
+ .option('--provider <provider>', 'LLM provider for this session (anthropic, openai, openrouter, deepseek)')
680
+ .option('--api-key <key>', 'LLM API key for this session (not persisted)')
681
+ .option('--model <model>', 'LLM model override')
682
+ .action(async (dir, opts) => {
683
+ // Pass session-level LLM config to TUI via environment
684
+ if (opts.apiKey)
685
+ process.env.GUARDLINK_LLM_KEY = opts.apiKey;
686
+ if (opts.provider)
687
+ process.env.GUARDLINK_LLM_PROVIDER = opts.provider;
688
+ const { startTui } = await import('../tui/index.js');
689
+ await startTui(dir);
690
+ });
691
+ program
692
+ .command('gal')
693
+ .description('Display GuardLink Annotation Language (GAL) quick reference')
694
+ .action(() => {
695
+ import('chalk').then(({ default: c }) => {
696
+ console.log(gradient(['#00ff41', '#00d4ff'])(ASCII_LOGO));
697
+ console.log(`${c.bold.bgCyan.black(' GUARDLINK ANNOTATION LANGUAGE (GAL) ')}\n`);
698
+ console.log(`${c.bold('Syntax:')}`);
699
+ console.log(` // @verb <args> [qualifiers] [refs] -- "description"\n`);
700
+ console.log(`${c.bold('Definition Verbs:')}`);
701
+ console.log(` ${c.green('@asset')} <path> (#id) ${c.gray('Declare a component')}`);
702
+ console.log(` ${c.green('@threat')} <name> (#id) [sev] ${c.gray('Declare a threat')}`);
703
+ console.log(` ${c.green('@control')} <name> (#id) ${c.gray('Declare a security control')}\n`);
704
+ console.log(`${c.bold('Relationship Verbs:')}`);
705
+ console.log(` ${c.green('@mitigates')} <asset> against <threat> using <control>`);
706
+ console.log(` ${c.green('@exposes')} <asset> to <threat> [severity]`);
707
+ console.log(` ${c.green('@flows')} <source> -> <target> via <mechanism>`);
708
+ console.log(` ${c.green('@boundary')} between <asset-a> and <asset-b> (#id)\n`);
709
+ console.log(`${c.bold('Lifecycle & Metadata:')}`);
710
+ console.log(` ${c.green('@handles')} <data> on <asset> ${c.gray('Data classification')}`);
711
+ console.log(` ${c.green('@owns')} <owner> for <asset> ${c.gray('Security ownership')}`);
712
+ console.log(` ${c.green('@assumes')} <asset> ${c.gray('Security assumption')}`);
713
+ console.log(` ${c.green('@shield')} [-- "reason"] ${c.gray('AI exclusion marker')}\n`);
714
+ console.log(`${c.bold('Severity Levels:')}`);
715
+ console.log(` [critical] | [high] | [medium] | [low]`);
716
+ console.log(` [P0] | [P1] | [P2] | [P3]\n`);
717
+ console.log(`${c.bold('Data Classifications:')}`);
718
+ console.log(` pii | secrets | financial | phi | internal | public\n`);
719
+ console.log(`${c.bold('Example:')}`);
720
+ console.log(` ${c.gray('// @mitigates #api against #sqli using #prepared-stmts -- "Parameterized query"')}\n`);
721
+ });
722
+ });
723
+ // If no subcommand given, launch TUI
724
+ if (process.argv.length <= 2) {
725
+ import('../tui/index.js').then(({ startTui }) => startTui('.'));
726
+ }
727
+ else {
728
+ program.parse();
729
+ }
730
+ // ─── Helpers ─────────────────────────────────────────────────────────
731
+ function printDiagnostics(diagnostics) {
732
+ for (const d of diagnostics) {
733
+ const prefix = d.level === 'error' ? '✗' : '⚠';
734
+ console.error(`${prefix} ${d.file}:${d.line}: ${d.message}`);
735
+ if (d.raw)
736
+ console.error(` → ${d.raw}`);
737
+ }
738
+ if (diagnostics.length > 0) {
739
+ const errors = diagnostics.filter(d => d.level === 'error').length;
740
+ const warnings = diagnostics.filter(d => d.level === 'warning').length;
741
+ console.error(`\n${errors} error(s), ${warnings} warning(s)\n`);
742
+ }
743
+ }
744
+ function printStatus(model) {
745
+ console.log(`GuardLink Status: ${model.project}`);
746
+ console.log(`${'─'.repeat(40)}`);
747
+ console.log(`Files scanned: ${model.source_files}`);
748
+ console.log(`Annotations: ${model.annotations_parsed}`);
749
+ console.log(`${'─'.repeat(40)}`);
750
+ console.log(`Assets: ${model.assets.length}`);
751
+ console.log(`Threats: ${model.threats.length}`);
752
+ console.log(`Controls: ${model.controls.length}`);
753
+ console.log(`Mitigations: ${model.mitigations.length}`);
754
+ console.log(`Exposures: ${model.exposures.length}`);
755
+ console.log(`Acceptances: ${model.acceptances.length}`);
756
+ console.log(`Transfers: ${model.transfers.length}`);
757
+ console.log(`Flows: ${model.flows.length}`);
758
+ console.log(`Boundaries: ${model.boundaries.length}`);
759
+ console.log(`Validations: ${model.validations.length}`);
760
+ console.log(`Audits: ${model.audits.length}`);
761
+ console.log(`Ownership: ${model.ownership.length}`);
762
+ console.log(`Data handling: ${model.data_handling.length}`);
763
+ console.log(`Assumptions: ${model.assumptions.length}`);
764
+ console.log(`Comments: ${model.comments.length}`);
765
+ console.log(`Shields: ${model.shields.length}`);
766
+ }
767
+ function findDanglingRefs(model) {
768
+ const diagnostics = [];
769
+ // Collect all defined IDs
770
+ const definedIds = new Set();
771
+ for (const a of model.assets)
772
+ if (a.id)
773
+ definedIds.add(a.id);
774
+ for (const t of model.threats)
775
+ if (t.id)
776
+ definedIds.add(t.id);
777
+ for (const c of model.controls)
778
+ if (c.id)
779
+ definedIds.add(c.id);
780
+ for (const b of model.boundaries)
781
+ if (b.id)
782
+ definedIds.add(b.id);
783
+ // Check all references
784
+ const checkRef = (ref, loc) => {
785
+ if (ref.startsWith('#')) {
786
+ const id = ref.slice(1);
787
+ if (!definedIds.has(id)) {
788
+ diagnostics.push({
789
+ level: 'warning',
790
+ message: `Dangling reference: #${id} is never defined`,
791
+ file: loc.file,
792
+ line: loc.line,
793
+ });
794
+ }
795
+ }
796
+ };
797
+ for (const m of model.mitigations) {
798
+ checkRef(m.threat, m.location);
799
+ if (m.control)
800
+ checkRef(m.control, m.location);
801
+ }
802
+ for (const e of model.exposures)
803
+ checkRef(e.threat, e.location);
804
+ for (const a of model.acceptances)
805
+ checkRef(a.threat, a.location);
806
+ for (const t of model.transfers)
807
+ checkRef(t.threat, t.location);
808
+ for (const v of model.validations)
809
+ checkRef(v.control, v.location);
810
+ return diagnostics;
811
+ }
812
+ function findUnmitigatedExposures(model) {
813
+ // Build set of (asset, threat) pairs that are mitigated or accepted
814
+ const mitigated = new Set();
815
+ for (const m of model.mitigations)
816
+ mitigated.add(`${m.asset}::${m.threat}`);
817
+ for (const a of model.acceptances)
818
+ mitigated.add(`${a.asset}::${a.threat}`);
819
+ return model.exposures.filter(e => !mitigated.has(`${e.asset}::${e.threat}`));
820
+ }
821
+ //# sourceMappingURL=index.js.map