sapper-ai 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/auth.d.ts +11 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +102 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +316 -32
  6. package/dist/harden.d.ts +28 -0
  7. package/dist/harden.d.ts.map +1 -0
  8. package/dist/harden.js +309 -0
  9. package/dist/mcp/jsonc.d.ts +3 -0
  10. package/dist/mcp/jsonc.d.ts.map +1 -0
  11. package/dist/mcp/jsonc.js +119 -0
  12. package/dist/mcp/wrapConfig.d.ts +22 -0
  13. package/dist/mcp/wrapConfig.d.ts.map +1 -0
  14. package/dist/mcp/wrapConfig.js +192 -0
  15. package/dist/policyYaml.d.ts +3 -0
  16. package/dist/policyYaml.d.ts.map +1 -0
  17. package/dist/policyYaml.js +27 -0
  18. package/dist/postinstall.d.ts.map +1 -1
  19. package/dist/postinstall.js +11 -2
  20. package/dist/quarantine.d.ts +13 -0
  21. package/dist/quarantine.d.ts.map +1 -0
  22. package/dist/quarantine.js +22 -0
  23. package/dist/report.d.ts.map +1 -1
  24. package/dist/report.js +1061 -59
  25. package/dist/scan.d.ts +15 -0
  26. package/dist/scan.d.ts.map +1 -1
  27. package/dist/scan.js +179 -178
  28. package/dist/utils/env.d.ts +3 -0
  29. package/dist/utils/env.d.ts.map +1 -0
  30. package/dist/utils/env.js +25 -0
  31. package/dist/utils/format.d.ts +22 -0
  32. package/dist/utils/format.d.ts.map +1 -0
  33. package/dist/utils/format.js +97 -0
  34. package/dist/utils/fs.d.ts +7 -0
  35. package/dist/utils/fs.d.ts.map +1 -0
  36. package/dist/utils/fs.js +47 -0
  37. package/dist/utils/repoRoot.d.ts +2 -0
  38. package/dist/utils/repoRoot.d.ts.map +1 -0
  39. package/dist/utils/repoRoot.js +20 -0
  40. package/dist/utils/semver.d.ts +2 -0
  41. package/dist/utils/semver.d.ts.map +1 -0
  42. package/dist/utils/semver.js +7 -0
  43. package/package.json +5 -7
package/dist/scan.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export interface ScanOptions {
2
2
  targets?: string[];
3
+ policyPath?: string;
3
4
  fix?: boolean;
4
5
  deep?: boolean;
5
6
  system?: boolean;
@@ -7,6 +8,8 @@ export interface ScanOptions {
7
8
  ai?: boolean;
8
9
  noSave?: boolean;
9
10
  noOpen?: boolean;
11
+ noPrompt?: boolean;
12
+ noColor?: boolean;
10
13
  }
11
14
  export interface ScanResult {
12
15
  version: '1.0';
@@ -14,10 +17,16 @@ export interface ScanResult {
14
17
  scope: string;
15
18
  target: string;
16
19
  ai: boolean;
20
+ filters: {
21
+ configLikeOnly: boolean;
22
+ };
17
23
  summary: {
18
24
  totalFiles: number;
25
+ eligibleFiles: number;
19
26
  scannedFiles: number;
20
27
  skippedFiles: number;
28
+ skippedNotEligible: number;
29
+ skippedEmptyOrUnreadable: number;
21
30
  threats: number;
22
31
  };
23
32
  findings: Array<{
@@ -30,6 +39,12 @@ export interface ScanResult {
30
39
  snippet: string;
31
40
  detectors: string[];
32
41
  aiAnalysis: string | null;
42
+ ruleMatches: Array<{
43
+ label: string;
44
+ severity: 'high' | 'medium';
45
+ matchText: string;
46
+ context: string;
47
+ }>;
33
48
  }>;
34
49
  }
35
50
  export declare function runScan(options?: ScanOptions): Promise<number>;
@@ -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;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"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,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;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAiBD,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,cAAc,EAAE,OAAO,CAAA;KACxB,CAAA;IACD,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;QACrB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,MAAM,CAAA;QACpB,kBAAkB,EAAE,MAAM,CAAA;QAC1B,wBAAwB,EAAE,MAAM,CAAA;QAChC,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;QACzB,WAAW,EAAE,KAAK,CAAC;YACjB,KAAK,EAAE,MAAM,CAAA;YACb,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAA;YAC3B,SAAS,EAAE,MAAM,CAAA;YACjB,OAAO,EAAE,MAAM,CAAA;SAChB,CAAC,CAAA;KACH,CAAC,CAAA;CACH;AA8XD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiQxE"}
package/dist/scan.js CHANGED
@@ -34,17 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runScan = runScan;
37
- const node_fs_1 = require("node:fs");
38
37
  const promises_1 = require("node:fs/promises");
39
38
  const node_os_1 = require("node:os");
40
39
  const node_path_1 = require("node:path");
41
40
  const core_1 = require("@sapper-ai/core");
41
+ const auth_1 = require("./auth");
42
42
  const presets_1 = require("./presets");
43
- const CONFIG_FILE_NAMES = ['sapperai.config.yaml', 'sapperai.config.yml'];
44
- const GREEN = '\x1b[32m';
45
- const YELLOW = '\x1b[33m';
46
- const RED = '\x1b[31m';
47
- const RESET = '\x1b[0m';
43
+ const format_1 = require("./utils/format");
44
+ const repoRoot_1 = require("./utils/repoRoot");
48
45
  const SYSTEM_SCAN_PATHS = (() => {
49
46
  const home = (0, node_os_1.homedir)();
50
47
  return [
@@ -55,21 +52,18 @@ const SYSTEM_SCAN_PATHS = (() => {
55
52
  (0, node_path_1.join)(home, 'Library', 'Application Support', 'Claude'),
56
53
  ];
57
54
  })();
58
- function findConfigFile(cwd) {
59
- for (const name of CONFIG_FILE_NAMES) {
60
- const fullPath = (0, node_path_1.resolve)(cwd, name);
61
- if ((0, node_fs_1.existsSync)(fullPath)) {
62
- return fullPath;
63
- }
55
+ function resolvePolicy(cwd, options) {
56
+ const manager = new core_1.PolicyManager();
57
+ const explicitPath = options.policyPath ?? process.env.SAPPERAI_POLICY_PATH;
58
+ if (explicitPath) {
59
+ return manager.loadFromFile(explicitPath);
64
60
  }
65
- return null;
66
- }
67
- function resolvePolicy(cwd) {
68
- const configPath = findConfigFile(cwd);
69
- if (!configPath) {
61
+ const repoRoot = (0, repoRoot_1.findRepoRoot)(cwd);
62
+ const resolved = (0, core_1.resolvePolicyPath)({ repoRoot, homeDir: (0, node_os_1.homedir)() });
63
+ if (!resolved) {
70
64
  return { ...presets_1.presets.standard.policy };
71
65
  }
72
- return new core_1.PolicyManager().loadFromFile(configPath);
66
+ return manager.loadFromFile(resolved.path);
73
67
  }
74
68
  function getThresholds(policy) {
75
69
  const extended = policy;
@@ -138,84 +132,37 @@ async function collectFiles(targetPath, deep) {
138
132
  }
139
133
  return results;
140
134
  }
141
- function riskColor(risk) {
142
- if (risk >= 0.8)
143
- return RED;
144
- if (risk >= 0.5)
145
- return YELLOW;
146
- return GREEN;
147
- }
148
- function stripAnsi(text) {
149
- return text.replace(/\x1b\[[0-9;]*m/g, '');
150
- }
151
- function truncateToWidth(text, maxWidth) {
152
- if (maxWidth <= 0) {
153
- return '';
154
- }
155
- if (text.length <= maxWidth) {
156
- return text;
157
- }
158
- if (maxWidth <= 3) {
159
- return '.'.repeat(maxWidth);
160
- }
161
- return `...${text.slice(text.length - (maxWidth - 3))}`;
162
- }
163
- function renderProgressBar(current, total, width) {
164
- const safeTotal = Math.max(1, total);
165
- const pct = Math.floor((current / safeTotal) * 100);
166
- const filled = Math.floor((current / safeTotal) * width);
167
- const bar = '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
168
- return ` ${bar} ${pct}% │ ${current}/${total} files`;
169
- }
170
135
  function extractPatternLabel(decision) {
171
136
  const reason = decision.reasons[0];
172
137
  if (!reason)
173
138
  return 'threat';
174
139
  return reason.startsWith('Detected pattern: ') ? reason.slice('Detected pattern: '.length) : reason;
175
140
  }
176
- function padRight(text, width) {
177
- if (text.length >= width)
178
- return text;
179
- return text + ' '.repeat(width - text.length);
180
- }
181
- function padRightVisual(text, width) {
182
- const visLen = stripAnsi(text).length;
183
- if (visLen >= width)
184
- return text;
185
- return text + ' '.repeat(width - visLen);
186
- }
187
- function padLeft(text, width) {
188
- if (text.length >= width)
189
- return text;
190
- return ' '.repeat(width - text.length) + text;
191
- }
192
141
  function renderFindingsTable(findings, opts) {
193
- const rows = findings.map((f, i) => {
194
- const file = f.filePath.startsWith(opts.cwd + '/') ? f.filePath.slice(opts.cwd.length + 1) : f.filePath;
195
- const pattern = extractPatternLabel(f.decision);
196
- const riskValue = f.decision.risk.toFixed(2);
197
- const riskPlain = padLeft(riskValue, 4);
198
- const risk = opts.color ? `${riskColor(f.decision.risk)}${riskPlain}${RESET}` : riskPlain;
199
- return { idx: String(i + 1), file, risk, pattern };
200
- });
201
- const idxWidth = Math.max(1, ...rows.map((r) => r.idx.length));
202
142
  const riskWidth = 4;
203
- const patternWidth = Math.min(20, Math.max('Pattern'.length, ...rows.map((r) => r.pattern.length)));
204
- const baseWidth = 2 + idxWidth + 2 + 2 + riskWidth + 2 + 2 + patternWidth + 2;
143
+ const patternWidth = Math.min(20, Math.max('Pattern'.length, ...findings.map((f) => extractPatternLabel(f.decision).length)));
144
+ const sourceWidth = opts.includeSource ? Math.max('Source'.length, 5) : 0;
205
145
  const maxTableWidth = Math.max(60, Math.min(opts.columns || 80, 120));
146
+ const sepWidth = 2;
147
+ const baseWidth = 'File'.length + sepWidth + riskWidth + sepWidth + patternWidth + (opts.includeSource ? sepWidth + sourceWidth : 0);
206
148
  const fileWidth = Math.max(20, Math.min(50, maxTableWidth - baseWidth));
207
- const top = ` ┌${'─'.repeat(idxWidth + 2)}┬${''.repeat(fileWidth + 2)}┬${''.repeat(riskWidth + 2)}┬${''.repeat(patternWidth + 2)}┐`;
208
- const header = ` │ ${padRight('#', idxWidth)} │ ${padRight('File', fileWidth)} ${padRight('Risk', riskWidth)} │ ${padRight('Pattern', patternWidth)} │`;
209
- const sep = ` ├${'─'.repeat(idxWidth + 2)}┼${''.repeat(fileWidth + 2)}┼${'─'.repeat(riskWidth + 2)}┼${'─'.repeat(patternWidth + 2)}┤`;
210
- const lines = [top, header, sep];
211
- for (const r of rows) {
212
- const file = truncateToWidth(r.file, fileWidth);
213
- const pattern = truncateToWidth(r.pattern, patternWidth);
214
- lines.push(` │ ${padRight(r.idx, idxWidth)} │ ${padRight(file, fileWidth)} │ ${padRightVisual(r.risk, riskWidth)} │ ${padRight(pattern, patternWidth)} │`);
215
- }
216
- const bottom = ` └${'─'.repeat(idxWidth + 2)}┴${'─'.repeat(fileWidth + 2)}┴${'─'.repeat(riskWidth + 2)}┴${'─'.repeat(patternWidth + 2)}┘`;
217
- lines.push(bottom);
218
- return lines;
149
+ const headers = opts.includeSource ? ['File', 'Risk', 'Pattern', 'Source'] : ['File', 'Risk', 'Pattern'];
150
+ const rows = findings.map((f) => {
151
+ const relative = f.filePath.startsWith(opts.cwd + '/') ? f.filePath.slice(opts.cwd.length + 1) : f.filePath;
152
+ const file = (0, format_1.truncateToWidth)(relative, fileWidth);
153
+ const label = extractPatternLabel(f.decision);
154
+ const patternPlain = (0, format_1.truncateToWidth)(label, patternWidth);
155
+ const pattern = `${opts.colors.dim}${patternPlain}${opts.colors.reset}`;
156
+ const riskValue = f.decision.risk.toFixed(2);
157
+ const riskPlain = (0, format_1.padLeft)(riskValue, riskWidth);
158
+ const risk = `${(0, format_1.riskColor)(f.decision.risk, opts.colors)}${riskPlain}${opts.colors.reset}`;
159
+ if (!opts.includeSource) {
160
+ return [file, risk, pattern];
161
+ }
162
+ const src = f.source === 'ai' ? `${opts.colors.olive}ai${opts.colors.reset}` : `${opts.colors.dim}rules${opts.colors.reset}`;
163
+ return [file, risk, pattern, src];
164
+ });
165
+ return (0, format_1.table)(headers, rows, opts.colors);
219
166
  }
220
167
  function isThreat(decision, policy) {
221
168
  const { riskThreshold, blockMinConfidence } = getThresholds(policy);
@@ -231,11 +178,11 @@ async function readFileIfPresent(filePath) {
231
178
  }
232
179
  async function scanFile(filePath, policy, scanner, detectors, fix, quarantineManager) {
233
180
  if (!(0, core_1.isConfigLikeFile)(filePath)) {
234
- return { scanned: false };
181
+ return { scanned: false, skipReason: 'not_eligible' };
235
182
  }
236
183
  const raw = await readFileIfPresent(filePath);
237
184
  if (raw === null || raw.trim().length === 0) {
238
- return { scanned: false };
185
+ return { scanned: false, skipReason: 'empty_or_unreadable' };
239
186
  }
240
187
  const fileSurface = (0, core_1.normalizeSurfaceText)(raw);
241
188
  const targetType = (0, core_1.classifyTargetType)(filePath);
@@ -243,6 +190,11 @@ async function scanFile(filePath, policy, scanner, detectors, fix, quarantineMan
243
190
  {
244
191
  id: `${targetType}:${(0, core_1.buildEntryName)(filePath)}`,
245
192
  surface: fileSurface,
193
+ meta: {
194
+ scanSource: 'file_surface',
195
+ sourcePath: filePath,
196
+ sourceType: targetType,
197
+ },
246
198
  },
247
199
  ];
248
200
  if (filePath.endsWith('.json')) {
@@ -250,7 +202,15 @@ async function scanFile(filePath, policy, scanner, detectors, fix, quarantineMan
250
202
  const parsed = JSON.parse(raw);
251
203
  const mcpTargets = (0, core_1.collectMcpTargetsFromJson)(filePath, parsed);
252
204
  for (const t of mcpTargets) {
253
- targets.push({ id: `${t.type}:${t.name}`, surface: t.surface });
205
+ targets.push({
206
+ id: `${t.type}:${t.name}`,
207
+ surface: t.surface,
208
+ meta: {
209
+ scanSource: 'file_surface',
210
+ sourcePath: t.source,
211
+ sourceType: t.type,
212
+ },
213
+ });
254
214
  }
255
215
  }
256
216
  catch {
@@ -259,7 +219,7 @@ async function scanFile(filePath, policy, scanner, detectors, fix, quarantineMan
259
219
  let bestDecision = null;
260
220
  let bestThreat = null;
261
221
  for (const target of targets) {
262
- const decision = await scanner.scanTool(target.id, target.surface, policy, detectors);
222
+ const decision = await scanner.scanTool(target.id, target.surface, policy, detectors, target.meta);
263
223
  if (!bestDecision || decision.risk > bestDecision.risk) {
264
224
  bestDecision = decision;
265
225
  }
@@ -299,6 +259,37 @@ function extractPatternsFromReasons(reasons) {
299
259
  function toDetectorsList(decision) {
300
260
  return uniq(decision.evidence.map((e) => e.detectorId));
301
261
  }
262
+ function extractRuleMatches(decision) {
263
+ const evidence = Array.isArray(decision.evidence) ? decision.evidence : [];
264
+ const output = evidence.find((e) => e && e.detectorId === 'rules');
265
+ if (!output || !output.evidence || typeof output.evidence !== 'object') {
266
+ return [];
267
+ }
268
+ const maybeMatches = output.evidence.matches;
269
+ if (!Array.isArray(maybeMatches)) {
270
+ return [];
271
+ }
272
+ const results = [];
273
+ for (const m of maybeMatches) {
274
+ if (!m || typeof m !== 'object')
275
+ continue;
276
+ const match = m;
277
+ const label = typeof match.label === 'string' ? match.label : '';
278
+ const severity = match.severity === 'high' ? 'high' : 'medium';
279
+ const matchText = typeof match.matchText === 'string' ? match.matchText : '';
280
+ const context = typeof match.context === 'string'
281
+ ? match.context
282
+ : typeof match.sample === 'string'
283
+ ? match.sample
284
+ : '';
285
+ if (!label || !matchText)
286
+ continue;
287
+ results.push({ label, severity, matchText, context });
288
+ if (results.length >= 24)
289
+ break;
290
+ }
291
+ return results;
292
+ }
302
293
  function truncateSnippet(text, maxChars) {
303
294
  if (text.length <= maxChars) {
304
295
  return text;
@@ -313,6 +304,7 @@ async function buildScanResult(params) {
313
304
  const reasons = f.decision.reasons;
314
305
  const patterns = extractPatternsFromReasons(reasons);
315
306
  const detectors = toDetectorsList(f.decision);
307
+ const ruleMatches = extractRuleMatches(f.decision);
316
308
  return {
317
309
  filePath: f.filePath,
318
310
  risk: f.decision.risk,
@@ -323,6 +315,7 @@ async function buildScanResult(params) {
323
315
  snippet,
324
316
  detectors,
325
317
  aiAnalysis: f.aiAnalysis ?? null,
318
+ ruleMatches,
326
319
  };
327
320
  }));
328
321
  return {
@@ -331,10 +324,16 @@ async function buildScanResult(params) {
331
324
  scope: params.scope,
332
325
  target: params.target,
333
326
  ai: params.ai,
327
+ filters: {
328
+ configLikeOnly: true,
329
+ },
334
330
  summary: {
335
331
  totalFiles: params.totalFiles,
332
+ eligibleFiles: params.eligibleFiles,
336
333
  scannedFiles: params.scannedFiles,
337
334
  skippedFiles: params.skippedFiles,
335
+ skippedNotEligible: params.skippedNotEligible,
336
+ skippedEmptyOrUnreadable: params.skippedEmptyOrUnreadable,
338
337
  threats: params.threats,
339
338
  },
340
339
  findings,
@@ -342,15 +341,34 @@ async function buildScanResult(params) {
342
341
  }
343
342
  async function runScan(options = {}) {
344
343
  const cwd = process.cwd();
345
- const policy = resolvePolicy(cwd);
344
+ const colors = (0, format_1.createColors)({ noColor: options.noColor });
345
+ const policy = resolvePolicy(cwd, { policyPath: options.policyPath });
346
346
  const fix = options.fix === true;
347
+ console.log(`\n${(0, format_1.header)('scan', colors)}\n`);
347
348
  const aiEnabled = options.ai === true;
348
349
  let llmConfig = null;
349
350
  if (aiEnabled) {
350
- const apiKey = process.env.OPENAI_API_KEY;
351
+ let apiKey = await (0, auth_1.loadOpenAiApiKey)();
351
352
  if (!apiKey) {
352
- console.log('\n Error: OPENAI_API_KEY environment variable is required for --ai mode.\n');
353
- return 1;
353
+ const canPrompt = options.noPrompt !== true && process.stdout.isTTY === true && process.stdin.isTTY === true;
354
+ if (!canPrompt) {
355
+ console.log(' Error: OPENAI_API_KEY environment variable is required for --ai mode.\n');
356
+ return 1;
357
+ }
358
+ console.log(' No OpenAI API key found.\n');
359
+ console.log(` ${colors.olive}Get one at https://platform.openai.com/api-keys${colors.reset}`);
360
+ console.log();
361
+ apiKey = await (0, auth_1.promptAndSaveOpenAiApiKey)();
362
+ if (!apiKey) {
363
+ console.log('\n Error: API key is required for --ai mode.\n');
364
+ return 1;
365
+ }
366
+ const authPath = (0, auth_1.getAuthPath)();
367
+ const home = (0, node_os_1.homedir)();
368
+ const displayAuthPath = authPath === home ? '~' : authPath.startsWith(home + '/') ? `~/${authPath.slice(home.length + 1)}` : authPath;
369
+ console.log();
370
+ console.log(`${colors.dim} Key saved to ${displayAuthPath}${colors.reset}`);
371
+ console.log();
354
372
  }
355
373
  llmConfig = { provider: 'openai', apiKey, model: 'gpt-4.1-mini' };
356
374
  }
@@ -364,17 +382,6 @@ async function runScan(options = {}) {
364
382
  const detectors = (0, core_1.createDetectors)({ policy, preferredDetectors: ['rules'] });
365
383
  const quarantineDir = process.env.SAPPERAI_QUARANTINE_DIR;
366
384
  const quarantineManager = quarantineDir ? new core_1.QuarantineManager({ quarantineDir }) : new core_1.QuarantineManager();
367
- const isTTY = process.stdout.isTTY === true;
368
- const color = isTTY;
369
- const scopeLabel = options.scopeLabel ??
370
- (options.system
371
- ? 'AI system scan'
372
- : deep
373
- ? 'Current + subdirectories'
374
- : 'Current directory only');
375
- console.log('\n SapperAI Security Scanner\n');
376
- console.log(` Scope: ${scopeLabel}`);
377
- console.log();
378
385
  const fileSet = new Set();
379
386
  for (const target of targets) {
380
387
  const files = await collectFiles(target, deep);
@@ -383,70 +390,44 @@ async function runScan(options = {}) {
383
390
  }
384
391
  }
385
392
  const files = Array.from(fileSet).sort();
386
- console.log(` Collecting files... ${files.length} files found`);
393
+ const eligibleFiles = files.filter((f) => (0, core_1.isConfigLikeFile)(f));
394
+ const eligibleByName = eligibleFiles.length;
395
+ const skippedNotEligible = Math.max(0, files.length - eligibleByName);
396
+ console.log(`${colors.dim} Scanning ${eligibleByName} files...${colors.reset}`);
387
397
  console.log();
388
- if (aiEnabled) {
389
- console.log(' Phase 1/2: Rules scan');
390
- console.log();
391
- }
392
398
  const scannedFindings = [];
393
399
  let scannedFiles = 0;
394
- const total = files.length;
395
- const progressWidth = Math.max(10, Math.min(30, (process.stdout.columns ?? 80) - 30));
396
- for (let i = 0; i < files.length; i += 1) {
397
- const filePath = files[i];
398
- if (isTTY && total > 0) {
399
- const bar = renderProgressBar(i + 1, total, progressWidth);
400
- const label = ' Scanning: ';
401
- const maxPath = Math.max(10, (process.stdout.columns ?? 80) - stripAnsi(bar).length - label.length);
402
- const scanning = `${label}${truncateToWidth(filePath, maxPath)}`;
403
- if (i === 0) {
404
- process.stdout.write(`${bar}\n${scanning}\n`);
405
- }
406
- else {
407
- process.stdout.write(`\x1b[2A\x1b[2K\r${bar}\n\x1b[2K\r${scanning}\n`);
408
- }
409
- }
400
+ let skippedEmptyOrUnreadable = 0;
401
+ for (const filePath of eligibleFiles) {
410
402
  const result = await scanFile(filePath, policy, scanner, detectors, fix, quarantineManager);
403
+ if (result.skipReason === 'empty_or_unreadable') {
404
+ skippedEmptyOrUnreadable += 1;
405
+ continue;
406
+ }
411
407
  if (result.scanned && result.decision) {
412
408
  scannedFiles += 1;
413
- scannedFindings.push({ filePath, decision: result.decision, quarantinedId: result.quarantinedId });
409
+ scannedFindings.push({
410
+ filePath,
411
+ decision: result.decision,
412
+ quarantinedId: result.quarantinedId,
413
+ source: aiEnabled ? 'rules' : undefined,
414
+ });
414
415
  }
415
416
  }
416
- if (isTTY && total > 0) {
417
- process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
418
- }
417
+ let aiTargetsCount = 0;
419
418
  if (aiEnabled && llmConfig) {
420
419
  const suspiciousFindings = scannedFindings.filter((f) => f.decision.risk >= 0.5);
421
420
  const maxAiFiles = 50;
422
421
  if (suspiciousFindings.length > 0) {
423
422
  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();
423
+ aiTargetsCount = aiTargets.length;
430
424
  const detectorsList = (policy.detectors ?? ['rules']).slice();
431
425
  if (!detectorsList.includes('llm')) {
432
426
  detectorsList.push('llm');
433
427
  }
434
428
  const aiPolicy = { ...policy, llm: llmConfig, detectors: detectorsList };
435
429
  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
- }
430
+ for (const finding of aiTargets) {
450
431
  try {
451
432
  const raw = await readFileIfPresent(finding.filePath);
452
433
  if (!raw)
@@ -454,8 +435,15 @@ async function runScan(options = {}) {
454
435
  const surface = (0, core_1.normalizeSurfaceText)(raw);
455
436
  const targetType = (0, core_1.classifyTargetType)(finding.filePath);
456
437
  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]);
438
+ const aiDecision = await scanner.scanTool(id, surface, aiPolicy, aiDetectors, {
439
+ scanSource: 'file_surface',
440
+ sourcePath: finding.filePath,
441
+ sourceType: targetType,
442
+ });
443
+ const aiDominates = aiDecision.risk > finding.decision.risk;
444
+ const mergedReasons = aiDominates
445
+ ? uniq([...aiDecision.reasons, ...finding.decision.reasons])
446
+ : uniq([...finding.decision.reasons, ...aiDecision.reasons]);
459
447
  const existingEvidence = finding.decision.evidence;
460
448
  const mergedEvidence = [...existingEvidence];
461
449
  for (const ev of aiDecision.evidence) {
@@ -468,7 +456,8 @@ async function runScan(options = {}) {
468
456
  reasons: mergedReasons,
469
457
  evidence: mergedEvidence,
470
458
  };
471
- if (aiDecision.risk > finding.decision.risk) {
459
+ if (aiDominates) {
460
+ finding.source = 'ai';
472
461
  finding.decision = {
473
462
  ...nextDecision,
474
463
  action: aiDecision.action,
@@ -477,6 +466,7 @@ async function runScan(options = {}) {
477
466
  };
478
467
  }
479
468
  else {
469
+ finding.source = finding.source ?? 'rules';
480
470
  finding.decision = nextDecision;
481
471
  }
482
472
  finding.aiAnalysis =
@@ -485,20 +475,26 @@ async function runScan(options = {}) {
485
475
  catch {
486
476
  }
487
477
  }
488
- if (isTTY && aiTargets.length > 0) {
489
- process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
490
- }
491
478
  }
492
479
  }
493
- const skippedFiles = total - scannedFiles;
480
+ const scopeLabel = options.scopeLabel ??
481
+ (options.system
482
+ ? 'AI system scan'
483
+ : deep
484
+ ? 'Current + subdirectories'
485
+ : 'Current directory only');
486
+ const skippedFiles = skippedNotEligible + skippedEmptyOrUnreadable;
494
487
  const threats = scannedFindings.filter((f) => isThreat(f.decision, policy));
495
488
  const scanResult = await buildScanResult({
496
489
  scope: scopeLabel,
497
490
  target: targets.join(', '),
498
491
  ai: aiEnabled,
499
- totalFiles: total,
492
+ totalFiles: files.length,
493
+ eligibleFiles: eligibleByName,
500
494
  scannedFiles,
501
495
  skippedFiles,
496
+ skippedNotEligible,
497
+ skippedEmptyOrUnreadable,
502
498
  threats: threats.length,
503
499
  findings: scannedFindings,
504
500
  });
@@ -512,8 +508,8 @@ async function runScan(options = {}) {
512
508
  const html = generateHtmlReport(scanResult);
513
509
  const htmlPath = (0, node_path_1.join)(scanDir, `${ts}.html`);
514
510
  await (0, promises_1.writeFile)(htmlPath, html, 'utf8');
515
- console.log(` Saved to ${jsonPath}`);
516
- console.log(` Report: ${htmlPath}`);
511
+ console.log(`${colors.dim} Saved to ${jsonPath}${colors.reset}`);
512
+ console.log(`${colors.dim} Report: ${htmlPath}${colors.reset}`);
517
513
  console.log();
518
514
  if (options.noOpen !== true) {
519
515
  try {
@@ -530,28 +526,33 @@ async function runScan(options = {}) {
530
526
  }
531
527
  }
532
528
  }
529
+ if (aiEnabled) {
530
+ const countWidth = Math.max(String(eligibleByName).length, String(aiTargetsCount).length);
531
+ const rulesName = `${colors.dim}rules${colors.reset}`;
532
+ const aiName = `${colors.olive}ai${colors.reset}`;
533
+ console.log(` Phase 1 ${(0, format_1.padRightVisual)(rulesName, 5)} ${(0, format_1.padLeft)(String(eligibleByName), countWidth)} files`);
534
+ console.log(` Phase 2 ${(0, format_1.padRightVisual)(aiName, 5)} ${(0, format_1.padLeft)(String(aiTargetsCount), countWidth)} files`);
535
+ console.log();
536
+ }
533
537
  if (threats.length === 0) {
534
- const msg = ` All clear — ${scannedFiles} files scanned, 0 threats detected`;
535
- console.log(color ? `${GREEN}${msg}${RESET}` : msg);
538
+ console.log(` ${colors.olive}All clear — 0 threats in ${eligibleByName} files${colors.reset}`);
536
539
  console.log();
540
+ return 0;
541
+ }
542
+ console.log(renderFindingsTable(threats, {
543
+ cwd,
544
+ columns: process.stdout.columns ?? 80,
545
+ colors,
546
+ includeSource: aiEnabled,
547
+ }));
548
+ console.log();
549
+ console.log(` ${threats.length} threats found in ${eligibleByName} files (${files.length} total)`);
550
+ console.log();
551
+ if (!fix) {
552
+ console.log(' Run npx sapper-ai scan --fix to quarantine.\n');
537
553
  }
538
554
  else {
539
- const warn = ` ⚠ ${scannedFiles} files scanned, ${threats.length} threats detected`;
540
- console.log(color ? `${RED}${warn}${RESET}` : warn);
541
- console.log();
542
- const tableLines = renderFindingsTable(threats, {
543
- cwd,
544
- columns: process.stdout.columns ?? 80,
545
- color,
546
- });
547
- for (const line of tableLines) {
548
- console.log(line);
549
- }
550
555
  console.log();
551
- if (!fix) {
552
- console.log(" Run 'npx sapper-ai scan --fix' to quarantine blocked files.");
553
- console.log();
554
- }
555
556
  }
556
- return threats.length > 0 ? 1 : 0;
557
+ return 1;
557
558
  }
@@ -0,0 +1,3 @@
1
+ export declare function parseBoolean(value: string | undefined, fallback: boolean): boolean;
2
+ export declare function isCiEnv(env?: NodeJS.ProcessEnv): boolean;
3
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/utils/env.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAQlF;AAED,wBAAgB,OAAO,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAYrE"}
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseBoolean = parseBoolean;
4
+ exports.isCiEnv = isCiEnv;
5
+ function parseBoolean(value, fallback) {
6
+ if (value === undefined)
7
+ return fallback;
8
+ const normalized = value.trim().toLowerCase();
9
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes')
10
+ return true;
11
+ if (normalized === 'false' || normalized === '0' || normalized === 'no')
12
+ return false;
13
+ return fallback;
14
+ }
15
+ function isCiEnv(env = process.env) {
16
+ // CI providers generally set CI=true; also treat GitHub Actions as CI even if CI is unset.
17
+ const ci = env.CI;
18
+ if (ci && ci.trim().length > 0 && ci !== '0' && ci.toLowerCase() !== 'false') {
19
+ return true;
20
+ }
21
+ if (env.GITHUB_ACTIONS && env.GITHUB_ACTIONS !== 'false') {
22
+ return true;
23
+ }
24
+ return false;
25
+ }
@@ -0,0 +1,22 @@
1
+ export interface Colors {
2
+ olive: string;
3
+ dim: string;
4
+ bold: string;
5
+ red: string;
6
+ yellow: string;
7
+ reset: string;
8
+ }
9
+ export declare function createColors(options?: {
10
+ noColor?: boolean;
11
+ env?: NodeJS.ProcessEnv;
12
+ stdoutIsTTY?: boolean;
13
+ }): Colors;
14
+ export declare function header(command: string, colors: Colors): string;
15
+ export declare function riskColor(risk: number, colors: Colors): string;
16
+ export declare function stripAnsi(text: string): string;
17
+ export declare function truncateToWidth(text: string, maxWidth: number): string;
18
+ export declare function padRight(text: string, width: number): string;
19
+ export declare function padRightVisual(text: string, width: number): string;
20
+ export declare function padLeft(text: string, width: number): string;
21
+ export declare function table(headers: string[], rows: string[][], colors: Colors): string;
22
+ //# sourceMappingURL=format.d.ts.map