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.
- package/dist/auth.d.ts +11 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +102 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +316 -32
- package/dist/harden.d.ts +28 -0
- package/dist/harden.d.ts.map +1 -0
- package/dist/harden.js +309 -0
- package/dist/mcp/jsonc.d.ts +3 -0
- package/dist/mcp/jsonc.d.ts.map +1 -0
- package/dist/mcp/jsonc.js +119 -0
- package/dist/mcp/wrapConfig.d.ts +22 -0
- package/dist/mcp/wrapConfig.d.ts.map +1 -0
- package/dist/mcp/wrapConfig.js +192 -0
- package/dist/policyYaml.d.ts +3 -0
- package/dist/policyYaml.d.ts.map +1 -0
- package/dist/policyYaml.js +27 -0
- package/dist/postinstall.d.ts.map +1 -1
- package/dist/postinstall.js +11 -2
- package/dist/quarantine.d.ts +13 -0
- package/dist/quarantine.d.ts.map +1 -0
- package/dist/quarantine.js +22 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +1061 -59
- package/dist/scan.d.ts +15 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +179 -178
- package/dist/utils/env.d.ts +3 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +25 -0
- package/dist/utils/format.d.ts +22 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +97 -0
- package/dist/utils/fs.d.ts +7 -0
- package/dist/utils/fs.d.ts.map +1 -0
- package/dist/utils/fs.js +47 -0
- package/dist/utils/repoRoot.d.ts +2 -0
- package/dist/utils/repoRoot.d.ts.map +1 -0
- package/dist/utils/repoRoot.js +20 -0
- package/dist/utils/semver.d.ts +2 -0
- package/dist/utils/semver.d.ts.map +1 -0
- package/dist/utils/semver.js +7 -0
- 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>;
|
package/dist/scan.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"
|
|
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
|
|
44
|
-
const
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
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
|
|
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, ...
|
|
204
|
-
const
|
|
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
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
const pattern =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
351
|
+
let apiKey = await (0, auth_1.loadOpenAiApiKey)();
|
|
351
352
|
if (!apiKey) {
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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({
|
|
409
|
+
scannedFindings.push({
|
|
410
|
+
filePath,
|
|
411
|
+
decision: result.decision,
|
|
412
|
+
quarantinedId: result.quarantinedId,
|
|
413
|
+
source: aiEnabled ? 'rules' : undefined,
|
|
414
|
+
});
|
|
414
415
|
}
|
|
415
416
|
}
|
|
416
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
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(
|
|
516
|
-
console.log(
|
|
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
|
-
|
|
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
|
|
557
|
+
return 1;
|
|
557
558
|
}
|
|
@@ -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
|