sapper-ai 0.3.0 → 0.4.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/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAWA,wBAAsB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAiCpF"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAaA,wBAAsB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAiCpF"}
package/dist/cli.js CHANGED
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  return result;
34
34
  };
35
35
  })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
36
39
  Object.defineProperty(exports, "__esModule", { value: true });
37
40
  exports.runCli = runCli;
38
41
  const node_fs_1 = require("node:fs");
@@ -40,6 +43,7 @@ const node_child_process_1 = require("node:child_process");
40
43
  const node_os_1 = require("node:os");
41
44
  const node_path_1 = require("node:path");
42
45
  const readline = __importStar(require("node:readline"));
46
+ const select_1 = __importDefault(require("@inquirer/select"));
43
47
  const presets_1 = require("./presets");
44
48
  const scan_1 = require("./scan");
45
49
  async function runCli(argv = process.argv.slice(2)) {
@@ -81,6 +85,9 @@ Usage:
81
85
  sapper-ai scan --system AI system paths (~/.claude, ~/.cursor, ...)
82
86
  sapper-ai scan ./path Scan a specific file/directory
83
87
  sapper-ai scan --fix Quarantine blocked files
88
+ sapper-ai scan --ai Deep scan with AI analysis (requires OPENAI_API_KEY)
89
+ sapper-ai scan --report Generate HTML report and open in browser
90
+ sapper-ai scan --no-save Skip saving scan results to ~/.sapperai/scans/
84
91
  sapper-ai init Interactive setup wizard
85
92
  sapper-ai dashboard Launch web dashboard
86
93
  sapper-ai --help Show this help
@@ -93,6 +100,9 @@ function parseScanArgs(argv) {
93
100
  let fix = false;
94
101
  let deep = false;
95
102
  let system = false;
103
+ let ai = false;
104
+ let report = false;
105
+ let noSave = false;
96
106
  for (const arg of argv) {
97
107
  if (arg === '--fix') {
98
108
  fix = true;
@@ -106,12 +116,24 @@ function parseScanArgs(argv) {
106
116
  system = true;
107
117
  continue;
108
118
  }
119
+ if (arg === '--ai') {
120
+ ai = true;
121
+ continue;
122
+ }
123
+ if (arg === '--report') {
124
+ report = true;
125
+ continue;
126
+ }
127
+ if (arg === '--no-save') {
128
+ noSave = true;
129
+ continue;
130
+ }
109
131
  if (arg.startsWith('-')) {
110
132
  return null;
111
133
  }
112
134
  targets.push(arg);
113
135
  }
114
- return { targets, fix, deep, system };
136
+ return { targets, fix, deep, system, ai, report, noSave };
115
137
  }
116
138
  function displayPath(path) {
117
139
  const home = (0, node_os_1.homedir)();
@@ -120,60 +142,80 @@ function displayPath(path) {
120
142
  return path.startsWith(home + '/') ? `~/${path.slice(home.length + 1)}` : path;
121
143
  }
122
144
  async function promptScanScope(cwd) {
123
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
124
- const ask = (q) => new Promise((res) => rl.question(q, res));
125
- try {
126
- console.log('\n SapperAI Security Scanner\n');
127
- console.log(' ? Scan scope:');
128
- console.log(` ❯ 1) Current directory only ${displayPath(cwd)}`);
129
- console.log(` 2) Current + subdirectories ${displayPath((0, node_path_1.join)(cwd, '**'))}`);
130
- console.log(' 3) AI system scan ~/.claude, ~/.cursor, ~/.vscode ...');
131
- console.log();
132
- const answer = await ask(' Choose [1-3] (default: 2): ');
133
- const picked = Number.parseInt(answer.trim(), 10);
134
- if (picked === 1)
135
- return 'shallow';
136
- if (picked === 3)
137
- return 'system';
138
- return 'deep';
139
- }
140
- finally {
141
- rl.close();
142
- }
145
+ const answer = await (0, select_1.default)({
146
+ message: 'Scan scope:',
147
+ choices: [
148
+ { name: `Current directory only ${displayPath(cwd)}`, value: 'shallow' },
149
+ { name: `Current + subdirectories ${displayPath((0, node_path_1.join)(cwd, '**'))}`, value: 'deep' },
150
+ {
151
+ name: 'AI system scan ~/.claude, ~/.cursor, ~/.vscode ...',
152
+ value: 'system',
153
+ },
154
+ ],
155
+ default: 'deep',
156
+ });
157
+ return answer;
158
+ }
159
+ async function promptScanDepth() {
160
+ const answer = await (0, select_1.default)({
161
+ message: 'Scan depth:',
162
+ choices: [
163
+ { name: 'Quick scan (rules only) Fast regex pattern matching', value: false },
164
+ {
165
+ name: 'Deep scan (rules + AI) AI-powered analysis (requires OPENAI_API_KEY)',
166
+ value: true,
167
+ },
168
+ ],
169
+ default: false,
170
+ });
171
+ return answer;
143
172
  }
144
173
  async function resolveScanOptions(args) {
145
174
  const cwd = process.cwd();
175
+ const common = {
176
+ fix: args.fix,
177
+ report: args.report,
178
+ noSave: args.noSave,
179
+ };
146
180
  if (args.system) {
147
181
  if (args.targets.length > 0) {
148
182
  return null;
149
183
  }
150
- return { system: true, fix: args.fix, scopeLabel: 'AI system scan' };
184
+ return { ...common, system: true, ai: args.ai, scopeLabel: 'AI system scan' };
151
185
  }
152
186
  if (args.targets.length > 0) {
153
187
  if (args.targets.length === 1 && args.targets[0] === '.' && !args.deep) {
154
- return { targets: [cwd], deep: false, fix: args.fix, scopeLabel: 'Current directory only' };
188
+ return {
189
+ ...common,
190
+ targets: [cwd],
191
+ deep: false,
192
+ ai: args.ai,
193
+ scopeLabel: 'Current directory only',
194
+ };
155
195
  }
156
196
  return {
197
+ ...common,
157
198
  targets: args.targets,
158
199
  deep: true,
159
- fix: args.fix,
200
+ ai: args.ai,
160
201
  scopeLabel: args.targets.length === 1 && args.targets[0] === '.' ? 'Current + subdirectories' : 'Custom path',
161
202
  };
162
203
  }
163
204
  if (args.deep) {
164
- return { targets: [cwd], deep: true, fix: args.fix, scopeLabel: 'Current + subdirectories' };
205
+ return { ...common, targets: [cwd], deep: true, ai: args.ai, scopeLabel: 'Current + subdirectories' };
165
206
  }
166
207
  if (process.stdout.isTTY !== true) {
167
- return { targets: [cwd], deep: true, fix: args.fix, scopeLabel: 'Current + subdirectories' };
208
+ return { ...common, targets: [cwd], deep: true, ai: false, scopeLabel: 'Current + subdirectories' };
168
209
  }
169
210
  const scope = await promptScanScope(cwd);
211
+ const ai = args.ai ? true : await promptScanDepth();
170
212
  if (scope === 'system') {
171
- return { system: true, fix: args.fix, scopeLabel: 'AI system scan' };
213
+ return { ...common, system: true, ai, scopeLabel: 'AI system scan' };
172
214
  }
173
215
  if (scope === 'shallow') {
174
- return { targets: [cwd], deep: false, fix: args.fix, scopeLabel: 'Current directory only' };
216
+ return { ...common, targets: [cwd], deep: false, ai, scopeLabel: 'Current directory only' };
175
217
  }
176
- return { targets: [cwd], deep: true, fix: args.fix, scopeLabel: 'Current + subdirectories' };
218
+ return { ...common, targets: [cwd], deep: true, ai, scopeLabel: 'Current + subdirectories' };
177
219
  }
178
220
  async function runDashboard() {
179
221
  const configuredPort = process.env.PORT;
@@ -0,0 +1,3 @@
1
+ import type { ScanResult } from './scan';
2
+ export declare function generateHtmlReport(result: ScanResult): string;
3
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AA2nBxC,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAoB7D"}
package/dist/report.js ADDED
@@ -0,0 +1,651 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateHtmlReport = generateHtmlReport;
4
+ function escapeHtml(text) {
5
+ return text
6
+ .replace(/&/g, '&')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&#39;');
11
+ }
12
+ function generateCss() {
13
+ return `
14
+ :root, [data-theme="dark"] {
15
+ --bg-primary: #0a0a0a;
16
+ --bg-secondary: #1a1a1a;
17
+ --bg-tertiary: #2a2a2a;
18
+ --border: #333333;
19
+ --text-primary: #f5f5f5;
20
+ --text-secondary: #a0a0a0;
21
+ --text-muted: #666666;
22
+ --accent: #00d9ff;
23
+ --accent-glow: rgba(0, 217, 255, 0.15);
24
+ --risk-critical: #ef4444;
25
+ --risk-high: #f59e0b;
26
+ --risk-low: #22c55e;
27
+ }
28
+
29
+ [data-theme="light"] {
30
+ --bg-primary: #ffffff;
31
+ --bg-secondary: #f9fafb;
32
+ --bg-tertiary: #f3f4f6;
33
+ --border: #e5e7eb;
34
+ --text-primary: #0a0a0a;
35
+ --text-secondary: #4b5563;
36
+ --text-muted: #9ca3af;
37
+ --accent: #0284c7;
38
+ --accent-glow: rgba(2, 132, 199, 0.1);
39
+ }
40
+
41
+ * { box-sizing: border-box; }
42
+ html, body { height: 100%; }
43
+ body {
44
+ margin: 0;
45
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
46
+ background: var(--bg-primary);
47
+ color: var(--text-primary);
48
+ }
49
+
50
+ header {
51
+ position: sticky;
52
+ top: 0;
53
+ z-index: 10;
54
+ height: 72px;
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ padding: 0 20px;
59
+ background: var(--bg-secondary);
60
+ border-bottom: 1px solid var(--border);
61
+ }
62
+
63
+ .logo {
64
+ font-weight: 700;
65
+ letter-spacing: 0.2px;
66
+ }
67
+
68
+ .meta {
69
+ font-size: 12px;
70
+ color: var(--text-secondary);
71
+ text-align: right;
72
+ }
73
+
74
+ #theme-toggle {
75
+ border: 1px solid var(--border);
76
+ background: transparent;
77
+ color: var(--text-primary);
78
+ padding: 8px 12px;
79
+ border-radius: 10px;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .container {
84
+ padding: 18px 20px 28px;
85
+ max-width: 1200px;
86
+ margin: 0 auto;
87
+ }
88
+
89
+ .summary {
90
+ display: grid;
91
+ grid-template-columns: repeat(4, minmax(0, 1fr));
92
+ gap: 12px;
93
+ }
94
+
95
+ .metric-card {
96
+ background: var(--bg-secondary);
97
+ border: 1px solid var(--border);
98
+ border-radius: 14px;
99
+ padding: 12px;
100
+ }
101
+
102
+ .metric-card .label {
103
+ display: block;
104
+ font-size: 12px;
105
+ color: var(--text-secondary);
106
+ }
107
+
108
+ .metric-card .value {
109
+ display: block;
110
+ margin-top: 6px;
111
+ font-size: 20px;
112
+ font-variant-numeric: tabular-nums;
113
+ }
114
+
115
+ .metric-card .value.danger { color: var(--risk-critical); }
116
+
117
+ .chart {
118
+ margin-top: 12px;
119
+ background: var(--bg-secondary);
120
+ border: 1px solid var(--border);
121
+ border-radius: 14px;
122
+ padding: 12px;
123
+ }
124
+
125
+ .chart-bars { display: flex; gap: 10px; align-items: flex-end; height: 48px; }
126
+ .bar {
127
+ flex: 1;
128
+ border-radius: 10px;
129
+ background: var(--bg-tertiary);
130
+ border: 1px solid var(--border);
131
+ cursor: pointer;
132
+ position: relative;
133
+ overflow: hidden;
134
+ }
135
+ .bar > span { position: absolute; inset: 0; display: block; }
136
+ .bar .critical { background: var(--risk-critical); }
137
+ .bar .high { background: var(--risk-high); }
138
+ .bar .clean { background: var(--risk-low); }
139
+ .chart-legend { display: flex; gap: 14px; margin-top: 10px; color: var(--text-secondary); font-size: 12px; }
140
+
141
+ .main {
142
+ margin-top: 14px;
143
+ display: grid;
144
+ grid-template-columns: 30% 70%;
145
+ gap: 12px;
146
+ min-height: 520px;
147
+ }
148
+
149
+ .panel {
150
+ background: var(--bg-secondary);
151
+ border: 1px solid var(--border);
152
+ border-radius: 14px;
153
+ overflow: hidden;
154
+ }
155
+
156
+ .file-tree {
157
+ display: flex;
158
+ flex-direction: column;
159
+ }
160
+
161
+ .file-tree .controls {
162
+ padding: 12px;
163
+ border-bottom: 1px solid var(--border);
164
+ }
165
+
166
+ #tree-search {
167
+ width: 100%;
168
+ padding: 10px 12px;
169
+ border-radius: 10px;
170
+ border: 1px solid var(--border);
171
+ background: var(--bg-tertiary);
172
+ color: var(--text-primary);
173
+ }
174
+
175
+ .toggle {
176
+ margin-top: 10px;
177
+ font-size: 12px;
178
+ color: var(--text-secondary);
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 8px;
182
+ }
183
+
184
+ #tree {
185
+ padding: 10px;
186
+ overflow: auto;
187
+ flex: 1;
188
+ }
189
+
190
+ details {
191
+ border-radius: 10px;
192
+ }
193
+ summary {
194
+ cursor: pointer;
195
+ padding: 8px 10px;
196
+ border-radius: 10px;
197
+ color: var(--text-primary);
198
+ }
199
+ summary:hover { background: var(--bg-tertiary); }
200
+
201
+ .file-btn {
202
+ width: 100%;
203
+ text-align: left;
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 10px;
207
+ padding: 8px 10px;
208
+ border: 1px solid transparent;
209
+ background: transparent;
210
+ color: var(--text-primary);
211
+ border-radius: 10px;
212
+ cursor: pointer;
213
+ }
214
+
215
+ .file-btn:hover { background: var(--bg-tertiary); }
216
+ .file-btn.active {
217
+ background: var(--accent-glow);
218
+ border-color: var(--accent);
219
+ border-left: 3px solid var(--accent);
220
+ }
221
+
222
+ .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
223
+ .dot.critical { background: var(--risk-critical); }
224
+ .dot.high { background: var(--risk-high); }
225
+ .dot.clean { background: var(--risk-low); }
226
+
227
+ .detail-panel { display: flex; flex-direction: column; }
228
+ .detail-inner {
229
+ padding: 14px;
230
+ overflow: auto;
231
+ flex: 1;
232
+ }
233
+
234
+ .file-header { padding-bottom: 12px; border-bottom: 1px solid var(--border); }
235
+ .file-path { font-size: 12px; color: var(--text-secondary); word-break: break-all; }
236
+ .file-name { margin-top: 6px; font-size: 18px; font-weight: 700; }
237
+ .metrics { margin-top: 10px; display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); }
238
+ .badge { padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); }
239
+ .badge.block { border-color: var(--risk-critical); color: var(--risk-critical); }
240
+ .badge.allow { border-color: var(--risk-low); color: var(--risk-low); }
241
+
242
+ .section { margin-top: 14px; }
243
+ .section h3 { margin: 0 0 8px; font-size: 13px; color: var(--text-secondary); }
244
+ .patterns ul { margin: 0; padding-left: 18px; }
245
+
246
+ pre {
247
+ margin: 0;
248
+ padding: 12px;
249
+ border-radius: 12px;
250
+ background: var(--bg-tertiary);
251
+ border: 1px solid var(--border);
252
+ overflow: auto;
253
+ }
254
+ code {
255
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
256
+ font-size: 12px;
257
+ color: var(--text-primary);
258
+ white-space: pre;
259
+ }
260
+
261
+ @media (max-width: 1024px) {
262
+ .main { grid-template-columns: 40% 60%; }
263
+ }
264
+
265
+ @media (max-width: 768px) {
266
+ header { height: auto; padding: 12px 14px; gap: 10px; flex-wrap: wrap; }
267
+ .container { padding: 14px; }
268
+ .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
269
+ .main { grid-template-columns: 1fr; }
270
+ #tree { max-height: 260px; }
271
+ }
272
+
273
+ @media print {
274
+ * { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
275
+ header { position: static; }
276
+ .main { grid-template-columns: 1fr; }
277
+ }
278
+ `.trim();
279
+ }
280
+ function generateJs() {
281
+ return `
282
+ const el = (sel) => document.querySelector(sel);
283
+
284
+ function escapeHtml(text) {
285
+ return String(text)
286
+ .replace(/&/g, '&amp;')
287
+ .replace(/</g, '&lt;')
288
+ .replace(/>/g, '&gt;')
289
+ .replace(/\"/g, '&quot;')
290
+ .replace(/'/g, '&#39;');
291
+ }
292
+
293
+ function riskLevel(risk) {
294
+ if (risk >= 0.8) return 'critical';
295
+ if (risk >= 0.5) return 'high';
296
+ return 'clean';
297
+ }
298
+
299
+ function debounce(fn, ms) {
300
+ let t;
301
+ return (...args) => {
302
+ clearTimeout(t);
303
+ t = setTimeout(() => fn(...args), ms);
304
+ };
305
+ }
306
+
307
+ function buildFileTree(findings) {
308
+ const root = { files: [], dirs: new Map() };
309
+ for (const f of findings) {
310
+ const parts = String(f.filePath).split('/').filter(Boolean);
311
+ let node = root;
312
+ for (let i = 0; i < parts.length; i++) {
313
+ const part = parts[i];
314
+ const isFile = i === parts.length - 1;
315
+ if (isFile) {
316
+ node.files.push(f);
317
+ } else {
318
+ if (!node.dirs.has(part)) {
319
+ node.dirs.set(part, { name: part, files: [], dirs: new Map() });
320
+ }
321
+ node = node.dirs.get(part);
322
+ }
323
+ }
324
+ }
325
+ return root;
326
+ }
327
+
328
+ function renderTreeNode(node, opts) {
329
+ const container = document.createElement('div');
330
+
331
+ const dirEntries = Array.from(node.dirs.values()).sort((a, b) => a.name.localeCompare(b.name));
332
+ for (const dir of dirEntries) {
333
+ const details = document.createElement('details');
334
+ details.open = true;
335
+ const summary = document.createElement('summary');
336
+ summary.textContent = dir.name;
337
+ details.appendChild(summary);
338
+ details.appendChild(renderTreeNode(dir, opts));
339
+ container.appendChild(details);
340
+ }
341
+
342
+ const files = node.files.slice().sort((a, b) => String(a.filePath).localeCompare(String(b.filePath)));
343
+ for (const f of files) {
344
+ const threat = Number(f.risk) >= 0.5;
345
+ if (opts.threatsOnly && !threat) continue;
346
+ if (opts.query) {
347
+ const name = String(f.filePath).split('/').pop() || '';
348
+ if (!name.toLowerCase().includes(opts.query.toLowerCase())) continue;
349
+ }
350
+
351
+ const btn = document.createElement('button');
352
+ btn.className = 'file-btn';
353
+ btn.type = 'button';
354
+ btn.setAttribute('data-file', String(f.filePath));
355
+ btn.setAttribute('role', 'treeitem');
356
+ btn.tabIndex = -1;
357
+
358
+ const dot = document.createElement('span');
359
+ dot.className = 'dot ' + riskLevel(Number(f.risk));
360
+ btn.appendChild(dot);
361
+
362
+ const label = document.createElement('span');
363
+ label.textContent = String(f.filePath).split('/').pop() || String(f.filePath);
364
+ btn.appendChild(label);
365
+
366
+ btn.addEventListener('click', () => handleFileClick(String(f.filePath)));
367
+ container.appendChild(btn);
368
+ }
369
+
370
+ return container;
371
+ }
372
+
373
+ function handleFileClick(filePath) {
374
+ const finding = (SCAN_DATA.findings || []).find((f) => f.filePath === filePath);
375
+ if (!finding) return;
376
+
377
+ document.querySelectorAll('.file-btn').forEach((b) => b.classList.remove('active'));
378
+ const active = document.querySelector('.file-btn[data-file="' + CSS.escape(filePath) + '"]');
379
+ if (active) active.classList.add('active');
380
+
381
+ const detail = el('#detail');
382
+ const name = String(filePath).split('/').pop() || filePath;
383
+ const patterns = Array.isArray(finding.patterns) ? finding.patterns : [];
384
+ const detectors = Array.isArray(finding.detectors) ? finding.detectors : [];
385
+ const badgeClass = finding.action === 'block' ? 'block' : 'allow';
386
+ const badgeText = String(finding.action || '').toUpperCase();
387
+
388
+ const patternsHtml = patterns.length
389
+ ? patterns.map((p) => '<li>' + escapeHtml(p) + '</li>').join('')
390
+ : '<li>None</li>';
391
+ const detectorsHtml = detectors.length
392
+ ? detectors.map((d) => '<span class="badge">' + escapeHtml(d) + '</span>').join('')
393
+ : '<span class="badge">none</span>';
394
+
395
+ detail.innerHTML =
396
+ '<div class="detail-inner">' +
397
+ '<div class="file-header">' +
398
+ '<div class="file-path">' + escapeHtml(filePath) + '</div>' +
399
+ '<div class="file-name">' + escapeHtml(name) + '</div>' +
400
+ '<div class="metrics">' +
401
+ '<span>Risk: <span class="badge">' + Number(finding.risk).toFixed(2) + '</span></span>' +
402
+ '<span>Confidence: <span class="badge">' + Math.round(Number(finding.confidence) * 100) + '%</span></span>' +
403
+ '<span class="badge ' + badgeClass + '">' + escapeHtml(badgeText) + '</span>' +
404
+ '</div>' +
405
+ '</div>' +
406
+ '<div class="section patterns">' +
407
+ '<h3>Detected Patterns</h3>' +
408
+ '<ul>' + patternsHtml + '</ul>' +
409
+ '</div>' +
410
+ '<div class="section snippet">' +
411
+ '<h3>Code Snippet</h3>' +
412
+ '<pre><code>' + escapeHtml(finding.snippet || '') + '</code></pre>' +
413
+ '</div>' +
414
+ '<div class="section detectors">' +
415
+ '<h3>Detectors</h3>' +
416
+ detectorsHtml +
417
+ '</div>' +
418
+ '<div class="section ai-analysis">' +
419
+ '<h3>AI Analysis</h3>' +
420
+ '<p>' + escapeHtml(finding.aiAnalysis || '') + '</p>' +
421
+ '</div>' +
422
+ '</div>';
423
+ }
424
+
425
+ function handleSearch(query) {
426
+ renderTree({ query });
427
+ }
428
+
429
+ function handleThreatsOnlyToggle() {
430
+ renderTree({});
431
+ }
432
+
433
+ function handleThemeToggle() {
434
+ const root = document.documentElement;
435
+ const current = root.getAttribute('data-theme') || 'dark';
436
+ const next = current === 'dark' ? 'light' : 'dark';
437
+ root.setAttribute('data-theme', next);
438
+ try { localStorage.setItem('sapper-theme', next); } catch {}
439
+ }
440
+
441
+ function handleChartBarClick(level) {
442
+ const state = window.__TREE_STATE || {};
443
+ state.riskFilter = level;
444
+ window.__TREE_STATE = state;
445
+ renderTree({});
446
+ }
447
+
448
+ function renderChart() {
449
+ const findings = Array.isArray(SCAN_DATA.findings) ? SCAN_DATA.findings : [];
450
+ const critical = findings.filter((f) => Number(f.risk) >= 0.8).length;
451
+ const high = findings.filter((f) => Number(f.risk) >= 0.5 && Number(f.risk) < 0.8).length;
452
+ const clean = findings.filter((f) => Number(f.risk) < 0.5).length;
453
+ const total = Math.max(1, findings.length);
454
+
455
+ const bars = el('#chart-bars');
456
+ if (!bars) return;
457
+ bars.innerHTML = '';
458
+
459
+ function mk(cls, count, level) {
460
+ const wrap = document.createElement('div');
461
+ wrap.className = 'bar';
462
+ wrap.title = level + ': ' + count;
463
+ wrap.addEventListener('click', () => handleChartBarClick(level));
464
+ const span = document.createElement('span');
465
+ span.className = cls;
466
+ span.style.height = Math.round((count / total) * 100) + '%';
467
+ span.style.position = 'absolute';
468
+ span.style.bottom = '0';
469
+ wrap.appendChild(span);
470
+ return wrap;
471
+ }
472
+
473
+ bars.appendChild(mk('critical', critical, 'critical'));
474
+ bars.appendChild(mk('high', high, 'high'));
475
+ bars.appendChild(mk('clean', clean, 'clean'));
476
+
477
+ const legend = el('#chart-legend');
478
+ if (legend) {
479
+ legend.innerHTML =
480
+ '<span>Critical (>=0.8): ' + critical + '</span>' +
481
+ '<span>High (>=0.5): ' + high + '</span>' +
482
+ '<span>Clean (<0.5): ' + clean + '</span>';
483
+ }
484
+ }
485
+
486
+ function renderTree(partial) {
487
+ const state = window.__TREE_STATE || { query: '', riskFilter: null };
488
+ window.__TREE_STATE = Object.assign({}, state, partial);
489
+
490
+ const findings = Array.isArray(SCAN_DATA.findings) ? SCAN_DATA.findings : [];
491
+ const threatsOnlyEl = el('#threats-only');
492
+ const threatsOnly = threatsOnlyEl ? !!threatsOnlyEl.checked : true;
493
+
494
+ let visible = findings;
495
+ if (window.__TREE_STATE.riskFilter) {
496
+ const level = window.__TREE_STATE.riskFilter;
497
+ visible = visible.filter((f) => {
498
+ const r = Number(f.risk);
499
+ if (level === 'critical') return r >= 0.8;
500
+ if (level === 'high') return r >= 0.5 && r < 0.8;
501
+ if (level === 'clean') return r < 0.5;
502
+ return true;
503
+ });
504
+ }
505
+
506
+ const treeRoot = buildFileTree(visible);
507
+ const tree = el('#tree');
508
+ if (!tree) return;
509
+ tree.innerHTML = '';
510
+ const query = window.__TREE_STATE.query || '';
511
+ const node = renderTreeNode(treeRoot, { query, threatsOnly });
512
+ tree.appendChild(node);
513
+
514
+ const first = tree.querySelector('.file-btn');
515
+ if (first) first.tabIndex = 0;
516
+ }
517
+
518
+ function installKeyboardNav() {
519
+ document.addEventListener('keydown', (e) => {
520
+ const items = Array.from(document.querySelectorAll('.file-btn'));
521
+ if (items.length === 0) return;
522
+ const active = document.activeElement;
523
+ const idx = items.indexOf(active);
524
+
525
+ if (e.key === 'ArrowDown') {
526
+ e.preventDefault();
527
+ const next = items[Math.min(items.length - 1, Math.max(0, idx + 1))] || items[0];
528
+ next.focus();
529
+ return;
530
+ }
531
+ if (e.key === 'ArrowUp') {
532
+ e.preventDefault();
533
+ const next = items[Math.max(0, idx - 1)] || items[0];
534
+ next.focus();
535
+ return;
536
+ }
537
+ if (e.key === 'Enter') {
538
+ if (active && active.classList && active.classList.contains('file-btn')) {
539
+ active.click();
540
+ }
541
+ }
542
+ });
543
+ }
544
+
545
+ function bootstrap() {
546
+ try {
547
+ const saved = localStorage.getItem('sapper-theme');
548
+ if (saved === 'light' || saved === 'dark') {
549
+ document.documentElement.setAttribute('data-theme', saved);
550
+ }
551
+ } catch {}
552
+
553
+ const toggle = el('#theme-toggle');
554
+ if (toggle) toggle.addEventListener('click', handleThemeToggle);
555
+
556
+ const search = el('#tree-search');
557
+ if (search) {
558
+ search.addEventListener('input', debounce((e) => handleSearch(e.target.value || ''), 300));
559
+ }
560
+
561
+ const threatsOnly = el('#threats-only');
562
+ if (threatsOnly) threatsOnly.addEventListener('change', handleThreatsOnlyToggle);
563
+
564
+ renderChart();
565
+ renderTree({ query: '' });
566
+ installKeyboardNav();
567
+
568
+ const detail = el('#detail');
569
+ if (detail) {
570
+ detail.innerHTML = '<div class="detail-inner"><div class="file-path">Select a file to view details</div></div>';
571
+ }
572
+ }
573
+
574
+ bootstrap();
575
+ `.trim();
576
+ }
577
+ function renderHeader(result) {
578
+ return `
579
+ <header>
580
+ <div class="logo">SapperAI Scan Report</div>
581
+ <div class="meta">Scanned: ${escapeHtml(result.timestamp)} | Scope: ${escapeHtml(result.scope)}</div>
582
+ <button id="theme-toggle" type="button">Dark/Light</button>
583
+ </header>
584
+ `.trim();
585
+ }
586
+ function renderSummary(result) {
587
+ const total = result.summary.totalFiles;
588
+ const threats = result.summary.threats;
589
+ const maxRisk = result.findings.reduce((m, f) => Math.max(m, f.risk), 0);
590
+ return `
591
+ <section class="summary">
592
+ <div class="metric-card">
593
+ <span class="label">Total Files</span>
594
+ <span class="value">${total.toLocaleString()}</span>
595
+ </div>
596
+ <div class="metric-card">
597
+ <span class="label">Threats</span>
598
+ <span class="value danger">${threats.toLocaleString()}</span>
599
+ </div>
600
+ <div class="metric-card">
601
+ <span class="label">Max Risk</span>
602
+ <span class="value">${maxRisk.toFixed(2)}</span>
603
+ </div>
604
+ <div class="metric-card">
605
+ <span class="label">AI Scan</span>
606
+ <span class="value">${result.ai ? 'Enabled' : 'Disabled'}</span>
607
+ </div>
608
+ </section>
609
+
610
+ <section class="chart">
611
+ <div class="chart-bars" id="chart-bars"></div>
612
+ <div class="chart-legend" id="chart-legend"></div>
613
+ </section>
614
+ `.trim();
615
+ }
616
+ function renderMainContent(result) {
617
+ const threatsCount = result.findings.filter((f) => f.risk >= 0.5).length;
618
+ return `
619
+ <section class="main">
620
+ <aside class="panel file-tree">
621
+ <div class="controls">
622
+ <input type="text" placeholder="Search files..." id="tree-search" />
623
+ <label class="toggle"><input type="checkbox" id="threats-only" checked /> Threats only (${threatsCount})</label>
624
+ </div>
625
+ <div id="tree" role="tree"></div>
626
+ </aside>
627
+ <main class="panel detail-panel" id="detail"></main>
628
+ </section>
629
+ `.trim();
630
+ }
631
+ function generateHtmlReport(result) {
632
+ const safeJson = JSON.stringify(result).replace(/<\//g, '<\\/');
633
+ return `<!DOCTYPE html>
634
+ <html lang="ko" data-theme="dark">
635
+ <head>
636
+ <meta charset="UTF-8">
637
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
638
+ <title>SapperAI Scan Report - ${escapeHtml(result.timestamp)}</title>
639
+ <style>${generateCss()}</style>
640
+ </head>
641
+ <body>
642
+ ${renderHeader(result)}
643
+ <div class="container">
644
+ ${renderSummary(result)}
645
+ ${renderMainContent(result)}
646
+ </div>
647
+ <script>const SCAN_DATA = ${safeJson};</script>
648
+ <script>${generateJs()}</script>
649
+ </body>
650
+ </html>`;
651
+ }
package/dist/scan.d.ts CHANGED
@@ -4,6 +4,33 @@ export interface ScanOptions {
4
4
  deep?: boolean;
5
5
  system?: boolean;
6
6
  scopeLabel?: string;
7
+ ai?: boolean;
8
+ report?: boolean;
9
+ noSave?: boolean;
10
+ }
11
+ export interface ScanResult {
12
+ version: '1.0';
13
+ timestamp: string;
14
+ scope: string;
15
+ target: string;
16
+ ai: boolean;
17
+ summary: {
18
+ totalFiles: number;
19
+ scannedFiles: number;
20
+ skippedFiles: number;
21
+ threats: number;
22
+ };
23
+ findings: Array<{
24
+ filePath: string;
25
+ risk: number;
26
+ confidence: number;
27
+ action: string;
28
+ patterns: string[];
29
+ reasons: string[];
30
+ snippet: string;
31
+ detectors: string[];
32
+ aiAnalysis: string | null;
33
+ }>;
7
34
  }
8
35
  export declare function runScan(options?: ScanOptions): Promise<number>;
9
36
  //# sourceMappingURL=scan.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA6TD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGxE"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,EAAE,CAAC,EAAE,OAAO,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAeD,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,KAAK,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAA;QAClB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,MAAM,CAAA;QACpB,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,QAAQ,EAAE,KAAK,CAAC;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,EAAE,MAAM,CAAA;QACZ,UAAU,EAAE,MAAM,CAAA;QAClB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,EAAE,CAAA;QAClB,OAAO,EAAE,MAAM,EAAE,CAAA;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,SAAS,EAAE,MAAM,EAAE,CAAA;QACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAC1B,CAAC,CAAA;CACH;AAqYD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyPxE"}
package/dist/scan.js CHANGED
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.runScan = runScan;
4
37
  const node_fs_1 = require("node:fs");
@@ -223,9 +256,13 @@ async function scanFile(filePath, policy, scanner, detectors, fix, quarantineMan
223
256
  catch {
224
257
  }
225
258
  }
259
+ let bestDecision = null;
226
260
  let bestThreat = null;
227
261
  for (const target of targets) {
228
262
  const decision = await scanner.scanTool(target.id, target.surface, policy, detectors);
263
+ if (!bestDecision || decision.risk > bestDecision.risk) {
264
+ bestDecision = decision;
265
+ }
229
266
  if (!isThreat(decision, policy)) {
230
267
  continue;
231
268
  }
@@ -235,21 +272,88 @@ async function scanFile(filePath, policy, scanner, detectors, fix, quarantineMan
235
272
  if (fix && decision.action === 'block') {
236
273
  try {
237
274
  const record = await quarantineManager.quarantine(filePath, decision);
238
- return { scanned: true, finding: { filePath, decision, quarantinedId: record.id } };
275
+ return { scanned: true, decision: bestDecision ?? decision, quarantinedId: record.id };
239
276
  }
240
277
  catch {
241
278
  }
242
279
  }
243
280
  }
244
- if (!bestThreat) {
245
- return { scanned: true };
281
+ if (!bestDecision) {
282
+ return { scanned: false };
283
+ }
284
+ return { scanned: true, decision: bestThreat ?? bestDecision };
285
+ }
286
+ function uniq(values) {
287
+ return Array.from(new Set(values));
288
+ }
289
+ function extractPatternsFromReasons(reasons) {
290
+ const prefix = 'Detected pattern: ';
291
+ const patterns = [];
292
+ for (const r of reasons) {
293
+ if (r.startsWith(prefix)) {
294
+ patterns.push(r.slice(prefix.length));
295
+ }
296
+ }
297
+ return patterns;
298
+ }
299
+ function toDetectorsList(decision) {
300
+ return uniq(decision.evidence.map((e) => e.detectorId));
301
+ }
302
+ function truncateSnippet(text, maxChars) {
303
+ if (text.length <= maxChars) {
304
+ return text;
246
305
  }
247
- return { scanned: true, finding: { filePath, decision: bestThreat } };
306
+ return text.slice(0, maxChars);
307
+ }
308
+ async function buildScanResult(params) {
309
+ const timestamp = new Date().toISOString();
310
+ const findings = await Promise.all(params.findings.map(async (f) => {
311
+ const raw = await readFileIfPresent(f.filePath);
312
+ const snippet = raw ? truncateSnippet(raw, 400) : '';
313
+ const reasons = f.decision.reasons;
314
+ const patterns = extractPatternsFromReasons(reasons);
315
+ const detectors = toDetectorsList(f.decision);
316
+ return {
317
+ filePath: f.filePath,
318
+ risk: f.decision.risk,
319
+ confidence: f.decision.confidence,
320
+ action: f.decision.action,
321
+ patterns,
322
+ reasons,
323
+ snippet,
324
+ detectors,
325
+ aiAnalysis: f.aiAnalysis ?? null,
326
+ };
327
+ }));
328
+ return {
329
+ version: '1.0',
330
+ timestamp,
331
+ scope: params.scope,
332
+ target: params.target,
333
+ ai: params.ai,
334
+ summary: {
335
+ totalFiles: params.totalFiles,
336
+ scannedFiles: params.scannedFiles,
337
+ skippedFiles: params.skippedFiles,
338
+ threats: params.threats,
339
+ },
340
+ findings,
341
+ };
248
342
  }
249
343
  async function runScan(options = {}) {
250
344
  const cwd = process.cwd();
251
345
  const policy = resolvePolicy(cwd);
252
346
  const fix = options.fix === true;
347
+ const aiEnabled = options.ai === true;
348
+ let llmConfig = null;
349
+ if (aiEnabled) {
350
+ const apiKey = process.env.OPENAI_API_KEY;
351
+ if (!apiKey) {
352
+ console.log('\n Error: OPENAI_API_KEY environment variable is required for --ai mode.\n');
353
+ return 1;
354
+ }
355
+ llmConfig = { provider: 'openai', apiKey, model: 'gpt-4.1-mini' };
356
+ }
253
357
  const deep = options.system ? true : options.deep !== false;
254
358
  const targets = options.system === true
255
359
  ? SYSTEM_SCAN_PATHS
@@ -257,7 +361,7 @@ async function runScan(options = {}) {
257
361
  ? options.targets
258
362
  : [cwd];
259
363
  const scanner = new core_1.Scanner();
260
- const detectors = (0, core_1.createDetectors)({ policy });
364
+ const detectors = (0, core_1.createDetectors)({ policy, preferredDetectors: ['rules'] });
261
365
  const quarantineDir = process.env.SAPPERAI_QUARANTINE_DIR;
262
366
  const quarantineManager = quarantineDir ? new core_1.QuarantineManager({ quarantineDir }) : new core_1.QuarantineManager();
263
367
  const isTTY = process.stdout.isTTY === true;
@@ -281,7 +385,12 @@ async function runScan(options = {}) {
281
385
  const files = Array.from(fileSet).sort();
282
386
  console.log(` Collecting files... ${files.length} files found`);
283
387
  console.log();
284
- const findings = [];
388
+ if (aiEnabled) {
389
+ console.log(' Phase 1/2: Rules scan');
390
+ console.log();
391
+ }
392
+ const scannedFindings = [];
393
+ let scannedFiles = 0;
285
394
  const total = files.length;
286
395
  const progressWidth = Math.max(10, Math.min(30, (process.stdout.columns ?? 80) - 30));
287
396
  for (let i = 0; i < files.length; i += 1) {
@@ -299,34 +408,151 @@ async function runScan(options = {}) {
299
408
  }
300
409
  }
301
410
  const result = await scanFile(filePath, policy, scanner, detectors, fix, quarantineManager);
302
- if (result.finding) {
303
- findings.push(result.finding);
411
+ if (result.scanned && result.decision) {
412
+ scannedFiles += 1;
413
+ scannedFindings.push({ filePath, decision: result.decision, quarantinedId: result.quarantinedId });
304
414
  }
305
415
  }
306
416
  if (isTTY && total > 0) {
307
417
  process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
308
418
  }
309
- if (findings.length === 0) {
310
- const msg = ` ✓ All clear ${total} files scanned, 0 threats detected`;
419
+ if (aiEnabled && llmConfig) {
420
+ const suspiciousFindings = scannedFindings.filter((f) => f.decision.risk >= 0.5);
421
+ const maxAiFiles = 50;
422
+ if (suspiciousFindings.length > 0) {
423
+ const aiTargets = suspiciousFindings.slice(0, maxAiFiles);
424
+ if (suspiciousFindings.length > maxAiFiles) {
425
+ console.log(` Note: AI scan limited to ${maxAiFiles} files (${suspiciousFindings.length} suspicious)`);
426
+ }
427
+ console.log();
428
+ console.log(` Phase 2/2: AI deep scan (${aiTargets.length} files)`);
429
+ console.log();
430
+ const detectorsList = (policy.detectors ?? ['rules']).slice();
431
+ if (!detectorsList.includes('llm')) {
432
+ detectorsList.push('llm');
433
+ }
434
+ const aiPolicy = { ...policy, llm: llmConfig, detectors: detectorsList };
435
+ const aiDetectors = (0, core_1.createDetectors)({ policy: aiPolicy, preferredDetectors: ['rules', 'llm'] });
436
+ for (let i = 0; i < aiTargets.length; i += 1) {
437
+ const finding = aiTargets[i];
438
+ if (isTTY) {
439
+ const bar = renderProgressBar(i + 1, aiTargets.length, progressWidth);
440
+ const label = ' Analyzing: ';
441
+ const maxPath = Math.max(10, (process.stdout.columns ?? 80) - stripAnsi(bar).length - label.length);
442
+ const scanning = `${label}${truncateToWidth(finding.filePath, maxPath)}`;
443
+ if (i === 0) {
444
+ process.stdout.write(`${bar}\n${scanning}\n`);
445
+ }
446
+ else {
447
+ process.stdout.write(`\x1b[2A\x1b[2K\r${bar}\n\x1b[2K\r${scanning}\n`);
448
+ }
449
+ }
450
+ try {
451
+ const raw = await readFileIfPresent(finding.filePath);
452
+ if (!raw)
453
+ continue;
454
+ const surface = (0, core_1.normalizeSurfaceText)(raw);
455
+ const targetType = (0, core_1.classifyTargetType)(finding.filePath);
456
+ const id = `${targetType}:${(0, core_1.buildEntryName)(finding.filePath)}`;
457
+ const aiDecision = await scanner.scanTool(id, surface, aiPolicy, aiDetectors);
458
+ const mergedReasons = uniq([...finding.decision.reasons, ...aiDecision.reasons]);
459
+ const existingEvidence = finding.decision.evidence;
460
+ const mergedEvidence = [...existingEvidence];
461
+ for (const ev of aiDecision.evidence) {
462
+ if (!mergedEvidence.some((e) => e.detectorId === ev.detectorId)) {
463
+ mergedEvidence.push(ev);
464
+ }
465
+ }
466
+ const nextDecision = {
467
+ ...finding.decision,
468
+ reasons: mergedReasons,
469
+ evidence: mergedEvidence,
470
+ };
471
+ if (aiDecision.risk > finding.decision.risk) {
472
+ finding.decision = {
473
+ ...nextDecision,
474
+ action: aiDecision.action,
475
+ risk: aiDecision.risk,
476
+ confidence: aiDecision.confidence,
477
+ };
478
+ }
479
+ else {
480
+ finding.decision = nextDecision;
481
+ }
482
+ finding.aiAnalysis =
483
+ aiDecision.reasons.find((r) => !r.startsWith('Detected pattern:')) ?? null;
484
+ }
485
+ catch {
486
+ }
487
+ }
488
+ if (isTTY && aiTargets.length > 0) {
489
+ process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
490
+ }
491
+ }
492
+ }
493
+ const skippedFiles = total - scannedFiles;
494
+ const threats = scannedFindings.filter((f) => isThreat(f.decision, policy));
495
+ const scanResult = await buildScanResult({
496
+ scope: scopeLabel,
497
+ target: targets.join(', '),
498
+ ai: aiEnabled,
499
+ totalFiles: total,
500
+ scannedFiles,
501
+ skippedFiles,
502
+ threats: threats.length,
503
+ findings: scannedFindings,
504
+ });
505
+ if (options.noSave !== true) {
506
+ const scanDir = (0, node_path_1.join)((0, node_os_1.homedir)(), '.sapperai', 'scans');
507
+ await (0, promises_1.mkdir)(scanDir, { recursive: true });
508
+ const filename = `${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
509
+ const savedPath = (0, node_path_1.join)(scanDir, filename);
510
+ await (0, promises_1.writeFile)(savedPath, JSON.stringify(scanResult, null, 2), 'utf8');
511
+ console.log(` Saved to ${savedPath}`);
512
+ console.log();
513
+ }
514
+ if (threats.length === 0) {
515
+ const msg = ` ✓ All clear — ${scannedFiles} files scanned, 0 threats detected`;
311
516
  console.log(color ? `${GREEN}${msg}${RESET}` : msg);
312
517
  console.log();
313
- return 0;
314
518
  }
315
- const warn = ` ⚠ ${total} files scanned, ${findings.length} threats detected`;
316
- console.log(color ? `${RED}${warn}${RESET}` : warn);
317
- console.log();
318
- const tableLines = renderFindingsTable(findings, {
319
- cwd,
320
- columns: process.stdout.columns ?? 80,
321
- color,
322
- });
323
- for (const line of tableLines) {
324
- console.log(line);
519
+ else {
520
+ const warn = ` ⚠ ${scannedFiles} files scanned, ${threats.length} threats detected`;
521
+ console.log(color ? `${RED}${warn}${RESET}` : warn);
522
+ console.log();
523
+ const tableLines = renderFindingsTable(threats, {
524
+ cwd,
525
+ columns: process.stdout.columns ?? 80,
526
+ color,
527
+ });
528
+ for (const line of tableLines) {
529
+ console.log(line);
530
+ }
531
+ console.log();
532
+ if (!fix) {
533
+ console.log(" Run 'npx sapper-ai scan --fix' to quarantine blocked files.");
534
+ console.log();
535
+ }
325
536
  }
326
- console.log();
327
- if (!fix) {
328
- console.log(" Run 'npx sapper-ai scan --fix' to quarantine blocked files.");
537
+ if (options.report) {
538
+ const { generateHtmlReport } = await Promise.resolve().then(() => __importStar(require('./report')));
539
+ const html = generateHtmlReport(scanResult);
540
+ const reportPath = (0, node_path_1.join)(process.cwd(), 'sapper-report.html');
541
+ await (0, promises_1.writeFile)(reportPath, html, 'utf8');
542
+ console.log(` Report saved to ${reportPath}`);
543
+ try {
544
+ const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
545
+ if (process.platform === 'win32') {
546
+ execFile('cmd', ['/c', 'start', '', reportPath]);
547
+ }
548
+ else {
549
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
550
+ execFile(openCmd, [reportPath]);
551
+ }
552
+ }
553
+ catch {
554
+ }
329
555
  console.log();
330
556
  }
331
- return 1;
557
+ return threats.length > 0 ? 1 : 0;
332
558
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-ai",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI security guardrails - single install, sensible defaults",
5
5
  "keywords": [
6
6
  "security",
@@ -39,6 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
+ "@inquirer/select": "^4.0.0",
42
43
  "@sapper-ai/core": "0.2.1",
43
44
  "@sapper-ai/types": "0.2.1"
44
45
  },