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 +1 -1
- package/dist/cli.js +71 -29
- package/dist/report.d.ts +3 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +651 -0
- package/dist/scan.d.ts +27 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +251 -25
- package/package.json +2 -1
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
213
|
+
return { ...common, system: true, ai, scopeLabel: 'AI system scan' };
|
|
172
214
|
}
|
|
173
215
|
if (scope === 'shallow') {
|
|
174
|
-
return { targets: [cwd], deep: false,
|
|
216
|
+
return { ...common, targets: [cwd], deep: false, ai, scopeLabel: 'Current directory only' };
|
|
175
217
|
}
|
|
176
|
-
return { targets: [cwd], deep: true,
|
|
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;
|
package/dist/report.d.ts
ADDED
|
@@ -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, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/'/g, ''');
|
|
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, '&')
|
|
287
|
+
.replace(/</g, '<')
|
|
288
|
+
.replace(/>/g, '>')
|
|
289
|
+
.replace(/\"/g, '"')
|
|
290
|
+
.replace(/'/g, ''');
|
|
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
|
package/dist/scan.d.ts.map
CHANGED
|
@@ -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;
|
|
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,
|
|
275
|
+
return { scanned: true, decision: bestDecision ?? decision, quarantinedId: record.id };
|
|
239
276
|
}
|
|
240
277
|
catch {
|
|
241
278
|
}
|
|
242
279
|
}
|
|
243
280
|
}
|
|
244
|
-
if (!
|
|
245
|
-
return { scanned:
|
|
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
|
|
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
|
-
|
|
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.
|
|
303
|
-
|
|
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 (
|
|
310
|
-
const
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
"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
|
},
|