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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/agents/config.d.ts +46 -0
- package/dist/agents/config.d.ts.map +1 -0
- package/dist/agents/config.js +189 -0
- package/dist/agents/config.js.map +1 -0
- package/dist/agents/index.d.ts +24 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +42 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/launcher.d.ts +54 -0
- package/dist/agents/launcher.d.ts.map +1 -0
- package/dist/agents/launcher.js +152 -0
- package/dist/agents/launcher.js.map +1 -0
- package/dist/agents/prompts.d.ts +14 -0
- package/dist/agents/prompts.d.ts.map +1 -0
- package/dist/agents/prompts.js +120 -0
- package/dist/agents/prompts.js.map +1 -0
- package/dist/analyze/index.d.ts +80 -0
- package/dist/analyze/index.d.ts.map +1 -0
- package/dist/analyze/index.js +306 -0
- package/dist/analyze/index.js.map +1 -0
- package/dist/analyze/llm.d.ts +52 -0
- package/dist/analyze/llm.d.ts.map +1 -0
- package/dist/analyze/llm.js +295 -0
- package/dist/analyze/llm.js.map +1 -0
- package/dist/analyze/prompts.d.ts +14 -0
- package/dist/analyze/prompts.d.ts.map +1 -0
- package/dist/analyze/prompts.js +205 -0
- package/dist/analyze/prompts.js.map +1 -0
- package/dist/analyzer/index.d.ts +5 -0
- package/dist/analyzer/index.d.ts.map +1 -0
- package/dist/analyzer/index.js +5 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/analyzer/sarif.d.ts +84 -0
- package/dist/analyzer/sarif.d.ts.map +1 -0
- package/dist/analyzer/sarif.js +149 -0
- package/dist/analyzer/sarif.js.map +1 -0
- package/dist/cli/index.d.ts +25 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +821 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/dashboard/data.d.ts +52 -0
- package/dist/dashboard/data.d.ts.map +1 -0
- package/dist/dashboard/data.js +93 -0
- package/dist/dashboard/data.js.map +1 -0
- package/dist/dashboard/diagrams.d.ts +25 -0
- package/dist/dashboard/diagrams.d.ts.map +1 -0
- package/dist/dashboard/diagrams.js +243 -0
- package/dist/dashboard/diagrams.js.map +1 -0
- package/dist/dashboard/generate.d.ts +17 -0
- package/dist/dashboard/generate.d.ts.map +1 -0
- package/dist/dashboard/generate.js +1258 -0
- package/dist/dashboard/generate.js.map +1 -0
- package/dist/dashboard/index.d.ts +7 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +7 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/diff/engine.d.ts +51 -0
- package/dist/diff/engine.d.ts.map +1 -0
- package/dist/diff/engine.js +153 -0
- package/dist/diff/engine.js.map +1 -0
- package/dist/diff/format.d.ts +10 -0
- package/dist/diff/format.d.ts.map +1 -0
- package/dist/diff/format.js +111 -0
- package/dist/diff/format.js.map +1 -0
- package/dist/diff/git.d.ts +24 -0
- package/dist/diff/git.d.ts.map +1 -0
- package/dist/diff/git.js +85 -0
- package/dist/diff/git.js.map +1 -0
- package/dist/diff/index.d.ts +7 -0
- package/dist/diff/index.d.ts.map +1 -0
- package/dist/diff/index.js +7 -0
- package/dist/diff/index.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/init/detect.d.ts +42 -0
- package/dist/init/detect.d.ts.map +1 -0
- package/dist/init/detect.js +185 -0
- package/dist/init/detect.js.map +1 -0
- package/dist/init/index.d.ts +39 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +228 -0
- package/dist/init/index.js.map +1 -0
- package/dist/init/picker.d.ts +32 -0
- package/dist/init/picker.d.ts.map +1 -0
- package/dist/init/picker.js +105 -0
- package/dist/init/picker.js.map +1 -0
- package/dist/init/templates.d.ts +25 -0
- package/dist/init/templates.d.ts.map +1 -0
- package/dist/init/templates.js +263 -0
- package/dist/init/templates.js.map +1 -0
- package/dist/mcp/index.d.ts +12 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +18 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/lookup.d.ts +27 -0
- package/dist/mcp/lookup.d.ts.map +1 -0
- package/dist/mcp/lookup.js +282 -0
- package/dist/mcp/lookup.js.map +1 -0
- package/dist/mcp/server.d.ts +41 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +388 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/suggest.d.ts +35 -0
- package/dist/mcp/suggest.d.ts.map +1 -0
- package/dist/mcp/suggest.js +268 -0
- package/dist/mcp/suggest.js.map +1 -0
- package/dist/parser/comment-strip.d.ts +15 -0
- package/dist/parser/comment-strip.d.ts.map +1 -0
- package/dist/parser/comment-strip.js +76 -0
- package/dist/parser/comment-strip.js.map +1 -0
- package/dist/parser/index.d.ts +10 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +9 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/normalize.d.ts +22 -0
- package/dist/parser/normalize.d.ts.map +1 -0
- package/dist/parser/normalize.js +42 -0
- package/dist/parser/normalize.js.map +1 -0
- package/dist/parser/parse-file.d.ts +18 -0
- package/dist/parser/parse-file.d.ts.map +1 -0
- package/dist/parser/parse-file.js +68 -0
- package/dist/parser/parse-file.js.map +1 -0
- package/dist/parser/parse-line.d.ts +21 -0
- package/dist/parser/parse-line.d.ts.map +1 -0
- package/dist/parser/parse-line.js +230 -0
- package/dist/parser/parse-line.js.map +1 -0
- package/dist/parser/parse-project.d.ts +31 -0
- package/dist/parser/parse-project.d.ts.map +1 -0
- package/dist/parser/parse-project.js +281 -0
- package/dist/parser/parse-project.js.map +1 -0
- package/dist/report/index.d.ts +6 -0
- package/dist/report/index.d.ts.map +1 -0
- package/dist/report/index.js +6 -0
- package/dist/report/index.js.map +1 -0
- package/dist/report/mermaid.d.ts +15 -0
- package/dist/report/mermaid.d.ts.map +1 -0
- package/dist/report/mermaid.js +260 -0
- package/dist/report/mermaid.js.map +1 -0
- package/dist/report/report.d.ts +16 -0
- package/dist/report/report.d.ts.map +1 -0
- package/dist/report/report.js +211 -0
- package/dist/report/report.js.map +1 -0
- package/dist/tui/commands.d.ts +42 -0
- package/dist/tui/commands.d.ts.map +1 -0
- package/dist/tui/commands.js +1216 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/config.d.ts +27 -0
- package/dist/tui/config.d.ts.map +1 -0
- package/dist/tui/config.js +27 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/format.d.ts +63 -0
- package/dist/tui/format.d.ts.map +1 -0
- package/dist/tui/format.js +253 -0
- package/dist/tui/format.js.map +1 -0
- package/dist/tui/index.d.ts +18 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +470 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/input.d.ts +63 -0
- package/dist/tui/input.d.ts.map +1 -0
- package/dist/tui/input.js +454 -0
- package/dist/tui/input.js.map +1 -0
- package/dist/types/index.d.ts +254 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- 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
|