easyvibegate 0.4.4 → 0.6.2
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/README.en.md +5 -9
- package/README.md +30 -12
- package/dist/cli/index.js +40 -39
- package/dist/cli/wizard.js +51 -35
- package/dist/engine/checkers/backend/firebase.js +79 -55
- package/dist/engine/checkers/backend/supabase.js +117 -91
- package/dist/engine/checkers/deep/deps.js +87 -29
- package/dist/engine/checkers/live/endpoint-probe.js +29 -33
- package/dist/engine/checkers/live/http-checks.js +23 -15
- package/dist/engine/checkers/live/idor.js +65 -49
- package/dist/engine/checkers/static/client-exposure.js +58 -3
- package/dist/engine/checkers/static/config-risks.js +70 -10
- package/dist/engine/checkers/static/rls-migrations.js +706 -192
- package/dist/engine/checkers/static/secrets.js +259 -15
- package/dist/engine/config.js +131 -28
- package/dist/engine/detect.js +24 -12
- package/dist/engine/endpoints.js +219 -13
- package/dist/engine/i18n.js +6 -2
- package/dist/engine/net/http.js +68 -0
- package/dist/engine/report.js +11 -1
- package/dist/engine/scan.js +16 -7
- package/dist/engine/util/code-lex.js +260 -0
- package/dist/engine/util/git-exposure.js +33 -0
- package/dist/engine/util/mask.js +32 -44
- package/dist/engine/util/sql-lex.js +172 -0
- package/dist/engine/util/text.js +12 -0
- package/dist/engine/walk.js +52 -7
- package/dist/orchestrator/flow.js +137 -82
- package/package.json +1 -1
package/dist/engine/endpoints.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
// Any receiver: app/router/api/server/v1/`this.x` … .get('/path')
|
|
2
|
-
|
|
1
|
+
// Any receiver: app/router/api/server/v1/`this.x` … .get('/path'). Group 1 is the
|
|
2
|
+
// receiver identifier when it is a plain name (so a router can be tied to its mount).
|
|
3
|
+
const JS_ROUTE = /(?:\b([\w$]+)|[)\]])\s*\.(get|post|put|patch|delete|all|options|head)\s*\(\s*["'`](\/[^"'`]*)["'`]/gi;
|
|
3
4
|
const PY_ROUTE = /@\w+\.(get|post|put|patch|delete|route)\s*\(\s*["']([^"']+)["']/gi;
|
|
4
|
-
// NestJS: @Get('users') on a controller method.
|
|
5
|
-
const NEST_ROUTE = /@(Get|Post|Put|Patch|Delete|All)\s*\(\s*["'`]([^"'`]*)["'`]?\s*\)/g;
|
|
5
|
+
// NestJS: @Get('users') on a controller method, @Controller('api/orders') on the class.
|
|
6
|
+
const NEST_ROUTE = /@(Get|Post|Put|Patch|Delete|All)\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)/g;
|
|
7
|
+
const NEST_CONTROLLER = /@Controller\s*\(([^)]*)\)/g;
|
|
8
|
+
const NEST_GLOBAL_PREFIX = /\.setGlobalPrefix\s*\(\s*["'`]([^"'`]*)["'`]/g;
|
|
6
9
|
// Django: path('users/', ...) / re_path(r'^users/$', ...)
|
|
7
10
|
const DJANGO_ROUTE = /\b(?:re_)?path\s*\(\s*r?["']([^"']+)["']/gi;
|
|
11
|
+
// Express/Koa mounts: app.use('/api', router) / app.use('/api', auth, router) /
|
|
12
|
+
// app.use(router) / app.use(router.routes()). Single-line only — that is the
|
|
13
|
+
// common shape and a missed mount degrades to "unresolved", never to a wrong path.
|
|
14
|
+
const JS_MOUNT = /\b([\w$]+)\s*\.use\s*\(\s*(?:["'`](\/[^"'`]*)["'`]\s*,\s*)?([^;\n]*?)\)\s*;?\s*$/gm;
|
|
15
|
+
// const router = Router() / express.Router() / new Router({ prefix: '/api' }) (koa).
|
|
16
|
+
// `new Hono()` / `new Elysia()` are NOT here: those are usually the app itself.
|
|
17
|
+
const JS_ROUTER_DECL = /\b(?:const|let|var)\s+([\w$]+)(?:\s*:\s*[\w$.<>]+)?\s*=\s*(?:new\s+)?(?:express\.)?Router\s*\(\s*(\{[^}]*\})?/g;
|
|
18
|
+
const JS_IMPORT_DEFAULT = /\bimport\s+(?:([\w$]+)|\*\s+as\s+([\w$]+))\s+from\s+["']([^"']+)["']|\b(?:const|let|var)\s+([\w$]+)\s*=\s*require\(\s*["']([^"']+)["']\s*\)/g;
|
|
19
|
+
const JS_IMPORT_NAMED = /\bimport\s+\{([^}]*)\}\s+from\s+["']([^"']+)["']|\b(?:const|let|var)\s+\{([^}]*)\}\s*=\s*require\(\s*["']([^"']+)["']\s*\)/g;
|
|
20
|
+
// Next.js route handlers: export async function GET / export const POST = / export { GET, POST }
|
|
21
|
+
const NEXT_HANDLER = /\bexport\s+(?:async\s+)?(?:function|const|let)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b|\bexport\s*(?:(?:const|let)\s*)?\{([^}]*)\}/g;
|
|
22
|
+
const HTTP_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
23
|
+
// Documentation is not code: `app.get('/delete-everything')` in a README must
|
|
24
|
+
// never become a live target.
|
|
25
|
+
const DOC_FILE = /\.(md|mdx|markdown|txt|rst|adoc)$/i;
|
|
8
26
|
function normalizeMethod(m) {
|
|
9
27
|
const up = m.toUpperCase();
|
|
10
28
|
// Flask's @app.route and Express's .all cover every verb → treat as ANY so
|
|
@@ -13,11 +31,78 @@ function normalizeMethod(m) {
|
|
|
13
31
|
return 'ANY';
|
|
14
32
|
return up;
|
|
15
33
|
}
|
|
34
|
+
/** Join a mount/controller prefix and a route path without doubling slashes. */
|
|
35
|
+
function joinPath(prefix, path) {
|
|
36
|
+
const joined = `/${prefix}/${path}`.replace(/\/+/g, '/');
|
|
37
|
+
return joined.length > 1 ? joined.replace(/\/$/, '') : joined;
|
|
38
|
+
}
|
|
39
|
+
/** Flask converters: `<int:id>` / `<string:x>` / `<path:p>` / `<id>` → `{id}` (probe-able). */
|
|
40
|
+
function flaskToTemplate(path) {
|
|
41
|
+
return path.replace(/<(?:\w+(?:\([^)]*\))?:)?(\w+)>/g, '{$1}');
|
|
42
|
+
}
|
|
43
|
+
/** Resolve a relative import specifier to one of the scanned files, if present. */
|
|
44
|
+
function resolveModule(fromRel, spec, rels) {
|
|
45
|
+
if (!spec.startsWith('.'))
|
|
46
|
+
return undefined;
|
|
47
|
+
const dir = fromRel.split('/').slice(0, -1);
|
|
48
|
+
for (const part of spec.split('/')) {
|
|
49
|
+
if (part === '.' || part === '')
|
|
50
|
+
continue;
|
|
51
|
+
if (part === '..')
|
|
52
|
+
dir.pop();
|
|
53
|
+
else
|
|
54
|
+
dir.push(part);
|
|
55
|
+
}
|
|
56
|
+
const base = dir.join('/').replace(/\.(m|c)?jsx?$/, '').replace(/\.tsx?$/, '');
|
|
57
|
+
const candidates = [base, ...['.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx'].map((e) => base + e),
|
|
58
|
+
...['/index.ts', '/index.tsx', '/index.js', '/index.mjs'].map((e) => base + e)];
|
|
59
|
+
return candidates.find((c) => rels.has(c));
|
|
60
|
+
}
|
|
61
|
+
/** Every `x.use([prefix,] …, target)` in a file — the target is its last argument. */
|
|
62
|
+
function findMounts(content) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const m of content.matchAll(JS_MOUNT)) {
|
|
65
|
+
const args = (m[3] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
66
|
+
const last = args[args.length - 1];
|
|
67
|
+
if (!last)
|
|
68
|
+
continue;
|
|
69
|
+
const mount = { onIdent: m[1] ?? '', prefix: m[2] ?? '' };
|
|
70
|
+
const req = last.match(/^require\(\s*["']([^"']+)["']\s*\)$/);
|
|
71
|
+
const koa = last.match(/^([\w$]+)\.(?:routes|middleware|allowedMethods)\(\)$/);
|
|
72
|
+
if (req)
|
|
73
|
+
mount.targetModule = req[1];
|
|
74
|
+
else if (koa)
|
|
75
|
+
mount.targetIdent = koa[1];
|
|
76
|
+
else if (/^[\w$]+$/.test(last))
|
|
77
|
+
mount.targetIdent = last;
|
|
78
|
+
else
|
|
79
|
+
continue; // a call/expression — nothing we can tie a router to
|
|
80
|
+
out.push(mount);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** The prefix a NestJS @Controller(...) argument declares, or null if unreadable. */
|
|
85
|
+
function nestControllerPrefix(rawArg) {
|
|
86
|
+
const arg = rawArg.trim();
|
|
87
|
+
if (arg === '')
|
|
88
|
+
return '';
|
|
89
|
+
const str = arg.match(/^["'`]([^"'`]*)["'`]$/);
|
|
90
|
+
if (str)
|
|
91
|
+
return str[1] ?? '';
|
|
92
|
+
if (arg.startsWith('{')) {
|
|
93
|
+
const p = arg.match(/\bpath\s*:\s*["'`]([^"'`]*)["'`]/);
|
|
94
|
+
if (p)
|
|
95
|
+
return p[1] ?? '';
|
|
96
|
+
// Options without `path` (e.g. host-only) mean no prefix; a computed path is unreadable.
|
|
97
|
+
return /\bpath\s*:/.test(arg) ? null : '';
|
|
98
|
+
}
|
|
99
|
+
return null; // array / variable / anything computed
|
|
100
|
+
}
|
|
16
101
|
/** Extract HTTP endpoints declared across the project, any framework. */
|
|
17
102
|
export function collectEndpoints(files) {
|
|
18
103
|
const out = [];
|
|
19
104
|
const seen = new Set();
|
|
20
|
-
const add = (method, path, where) => {
|
|
105
|
+
const add = (method, path, where, unresolvedNote) => {
|
|
21
106
|
if (!path)
|
|
22
107
|
return;
|
|
23
108
|
// A path built from variables cannot be probed — reporting it is noise.
|
|
@@ -27,13 +112,96 @@ export function collectEndpoints(files) {
|
|
|
27
112
|
if (seen.has(key))
|
|
28
113
|
return;
|
|
29
114
|
seen.add(key);
|
|
30
|
-
out.push({ method, path, where });
|
|
115
|
+
out.push(unresolvedNote ? { method, path, where, unresolved: true, note: unresolvedNote } : { method, path, where });
|
|
116
|
+
};
|
|
117
|
+
const code = files.filter((f) => !DOC_FILE.test(f.rel));
|
|
118
|
+
const rels = new Set(code.map((f) => f.rel));
|
|
119
|
+
// Pass 1 — routers and where they are mounted. A router imported from another
|
|
120
|
+
// file is followed through its import so `app.use('/api', ordersRouter)` reaches
|
|
121
|
+
// `router.get('/')` in routes/orders.ts.
|
|
122
|
+
const mountsByFile = new Map();
|
|
123
|
+
const importsByFile = new Map(); // ident → resolved module rel
|
|
124
|
+
const routerDecls = new Map(); // `${file}#${ident}` → prefix declared inline (koa)
|
|
125
|
+
for (const f of code) {
|
|
126
|
+
mountsByFile.set(f.rel, findMounts(f.content));
|
|
127
|
+
const imports = new Map();
|
|
128
|
+
for (const m of f.content.matchAll(JS_IMPORT_DEFAULT)) {
|
|
129
|
+
const ident = m[1] ?? m[2] ?? m[4];
|
|
130
|
+
const spec = m[3] ?? m[5];
|
|
131
|
+
const target = spec ? resolveModule(f.rel, spec, rels) : undefined;
|
|
132
|
+
if (ident && target)
|
|
133
|
+
imports.set(ident, target);
|
|
134
|
+
}
|
|
135
|
+
for (const m of f.content.matchAll(JS_IMPORT_NAMED)) {
|
|
136
|
+
const spec = m[2] ?? m[4];
|
|
137
|
+
const target = spec ? resolveModule(f.rel, spec, rels) : undefined;
|
|
138
|
+
if (!target)
|
|
139
|
+
continue;
|
|
140
|
+
for (const name of (m[1] ?? m[3] ?? '').split(',')) {
|
|
141
|
+
const ident = name.trim().split(/\s+as\s+/).pop()?.trim();
|
|
142
|
+
if (ident)
|
|
143
|
+
imports.set(ident, target);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
importsByFile.set(f.rel, imports);
|
|
147
|
+
for (const m of f.content.matchAll(JS_ROUTER_DECL)) {
|
|
148
|
+
const inline = m[2]?.match(/\bprefix\s*:\s*["'`]([^"'`]*)["'`]/)?.[1] ?? '';
|
|
149
|
+
routerDecls.set(`${f.rel}#${m[1]}`, inline);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Prefixes at which router `ident` (declared in `file`) is reachable, following
|
|
153
|
+
// nested mounts a few levels up. null = not mounted anywhere we can see, or a
|
|
154
|
+
// parent in the chain is not — either way the prefix is unknown.
|
|
155
|
+
const prefixesOf = (file, ident, depth) => {
|
|
156
|
+
if (depth > 4)
|
|
157
|
+
return null;
|
|
158
|
+
const found = [];
|
|
159
|
+
let mounted = false;
|
|
160
|
+
for (const [mFile, mounts] of mountsByFile) {
|
|
161
|
+
for (const mt of mounts) {
|
|
162
|
+
const hits = (mFile === file && mt.targetIdent === ident)
|
|
163
|
+
|| (mt.targetIdent !== undefined && importsByFile.get(mFile)?.get(mt.targetIdent) === file)
|
|
164
|
+
|| (mt.targetModule !== undefined && resolveModule(mFile, mt.targetModule, rels) === file);
|
|
165
|
+
if (!hits)
|
|
166
|
+
continue;
|
|
167
|
+
mounted = true;
|
|
168
|
+
// Whatever the receiver is mounted at applies on top (router.use inside a router).
|
|
169
|
+
const parents = routerDecls.has(`${mFile}#${mt.onIdent}`) ? prefixesOf(mFile, mt.onIdent, depth + 1) : [''];
|
|
170
|
+
if (parents === null)
|
|
171
|
+
return null;
|
|
172
|
+
for (const parent of parents)
|
|
173
|
+
found.push(joinPath(parent, mt.prefix));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return mounted ? found : null;
|
|
31
177
|
};
|
|
32
|
-
|
|
33
|
-
|
|
178
|
+
// NestJS global prefix (app.setGlobalPrefix('api')) applies to every controller.
|
|
179
|
+
let nestGlobal = '';
|
|
180
|
+
for (const f of code) {
|
|
181
|
+
const g = [...f.content.matchAll(NEST_GLOBAL_PREFIX)];
|
|
182
|
+
if (g.length === 1)
|
|
183
|
+
nestGlobal = g[0]?.[1] ?? '';
|
|
184
|
+
}
|
|
185
|
+
for (const f of code) {
|
|
186
|
+
// Next.js App Router: app/**/route.ts — the file path is the endpoint, the
|
|
187
|
+
// exported handler names are the methods (a POST-only route must not be GET-probed).
|
|
34
188
|
if (/(^|\/)app\/.*\/route\.(t|j)sx?$/.test(f.rel)) {
|
|
35
189
|
const p = '/' + f.rel.replace(/^.*?app\//, '').replace(/\/route\.(t|j)sx?$/, '');
|
|
36
|
-
|
|
190
|
+
const clean = p.replace(/\/\((?:[^)]+)\)/g, ''); // strip Next route groups
|
|
191
|
+
const methods = new Set();
|
|
192
|
+
for (const m of f.content.matchAll(NEXT_HANDLER)) {
|
|
193
|
+
if (m[1])
|
|
194
|
+
methods.add(m[1]);
|
|
195
|
+
for (const name of (m[2] ?? '').split(',')) {
|
|
196
|
+
const ident = name.trim().split(/\s+as\s+/).pop()?.trim() ?? '';
|
|
197
|
+
if (HTTP_VERBS.has(ident))
|
|
198
|
+
methods.add(ident);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (methods.size === 0)
|
|
202
|
+
add('ANY', clean, f.rel); // re-exported / not parseable → as before
|
|
203
|
+
for (const method of methods)
|
|
204
|
+
add(method, clean, f.rel);
|
|
37
205
|
continue;
|
|
38
206
|
}
|
|
39
207
|
// Next.js Pages API: strip extension first, then a trailing /index.
|
|
@@ -42,12 +210,50 @@ export function collectEndpoints(files) {
|
|
|
42
210
|
add('ANY', p, f.rel);
|
|
43
211
|
continue;
|
|
44
212
|
}
|
|
45
|
-
|
|
46
|
-
|
|
213
|
+
// Python decorators (`@app.get("/x/<int:id>")`) also look like JS calls to
|
|
214
|
+
// JS_ROUTE; only PY_ROUTE knows how to template their converters.
|
|
215
|
+
const isPython = /\.py$/i.test(f.rel);
|
|
216
|
+
for (const m of isPython ? [] : f.content.matchAll(JS_ROUTE)) {
|
|
217
|
+
const method = normalizeMethod(m[2] ?? 'any');
|
|
218
|
+
const path = m[3] ?? '';
|
|
219
|
+
const ident = m[1];
|
|
220
|
+
const inline = ident !== undefined ? routerDecls.get(`${f.rel}#${ident}`) : undefined;
|
|
221
|
+
// Routes on a Router() live under its mount point; `app.get` (no Router
|
|
222
|
+
// declaration) is top-level, as before.
|
|
223
|
+
if (ident !== undefined && inline !== undefined) {
|
|
224
|
+
const prefixes = prefixesOf(f.rel, ident, 0);
|
|
225
|
+
if (prefixes === null) {
|
|
226
|
+
add(method, joinPath(inline, path), f.rel, `router "${ident}" (${f.rel}) is not mounted anywhere in the scanned code — its URL prefix is unknown`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
for (const prefix of prefixes)
|
|
230
|
+
add(method, joinPath(joinPath(prefix, inline), path), f.rel);
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
add(method, path, f.rel);
|
|
235
|
+
}
|
|
47
236
|
for (const m of f.content.matchAll(PY_ROUTE))
|
|
48
|
-
add(normalizeMethod(m[1] ?? 'any'), m[2] ?? '', f.rel);
|
|
237
|
+
add(normalizeMethod(m[1] ?? 'any'), flaskToTemplate(m[2] ?? ''), f.rel);
|
|
238
|
+
// NestJS: walk decorators in source order so each @Get sits under the nearest
|
|
239
|
+
// preceding @Controller prefix.
|
|
240
|
+
const events = [];
|
|
241
|
+
for (const m of f.content.matchAll(NEST_CONTROLLER))
|
|
242
|
+
events.push({ at: m.index ?? 0, ctrl: nestControllerPrefix(m[1] ?? '') });
|
|
49
243
|
for (const m of f.content.matchAll(NEST_ROUTE))
|
|
50
|
-
|
|
244
|
+
events.push({ at: m.index ?? 0, method: m[1] ?? 'any', path: m[2] ?? '' });
|
|
245
|
+
events.sort((x, y) => x.at - y.at);
|
|
246
|
+
let ctrl = '';
|
|
247
|
+
for (const ev of events) {
|
|
248
|
+
if (ev.ctrl !== undefined) {
|
|
249
|
+
ctrl = ev.ctrl;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const full = joinPath(joinPath(nestGlobal, ctrl ?? ''), ev.path ?? '');
|
|
253
|
+
add(normalizeMethod(ev.method ?? 'any'), full, f.rel, ctrl === null
|
|
254
|
+
? `@Controller(...) argument in ${f.rel} could not be read — its URL prefix is unknown`
|
|
255
|
+
: undefined);
|
|
256
|
+
}
|
|
51
257
|
if (/(^|\/)urls?\.py$/.test(f.rel) || /urlpatterns/.test(f.content)) {
|
|
52
258
|
for (const m of f.content.matchAll(DJANGO_ROUTE))
|
|
53
259
|
add('ANY', '/' + (m[1] ?? '').replace(/^\^/, '').replace(/^\//, '').replace(/\$$/, ''), f.rel);
|
package/dist/engine/i18n.js
CHANGED
|
@@ -68,6 +68,8 @@ const EN = {
|
|
|
68
68
|
'wiz.urlInvalid': '"{input}" is not a valid address. Use e.g. https://myapp.com, or press Enter to skip.',
|
|
69
69
|
'wiz.answerUnclear': 'Please answer y or n (got "{input}").',
|
|
70
70
|
'wiz.qOwn': ' Confirm {url} is YOUR app and you allow live requests to it?',
|
|
71
|
+
'wiz.sbFromFlag': ' Supabase target from the command line: {url}',
|
|
72
|
+
'wiz.notRun': ' Not confirmed — {target} will be listed as a requested check that did not run.',
|
|
71
73
|
// ai-fix
|
|
72
74
|
'aifix.title': '# Security fix task for this codebase',
|
|
73
75
|
'aifix.intro': 'You are a senior application-security engineer. EasyVibeGate scanned this project and found {crit} critical and {warn} warning issue(s) (score {score}/100, gate {gate}). Fix them one by one, most severe first, without breaking existing functionality.',
|
|
@@ -87,7 +89,7 @@ const EN = {
|
|
|
87
89
|
'aifix.done': 'When done',
|
|
88
90
|
'aifix.done1': '- List which secrets I must rotate myself.',
|
|
89
91
|
'aifix.done2': '- List any migration I must run and where.',
|
|
90
|
-
'aifix.done3': '- Summarize what is fixed and what still needs a live re-scan (`npx
|
|
92
|
+
'aifix.done3': '- Summarize what is fixed and what still needs a live re-scan (`npx easyvibegate . --url <app> --i-own-this`).',
|
|
91
93
|
};
|
|
92
94
|
const RU = {
|
|
93
95
|
'console.filesLine': 'файлов: {n}',
|
|
@@ -154,6 +156,8 @@ const RU = {
|
|
|
154
156
|
'wiz.urlInvalid': '«{input}» — это не похоже на адрес. Например: https://myapp.com, или Enter чтобы пропустить.',
|
|
155
157
|
'wiz.answerUnclear': 'Ответь «д» или «н» (получено «{input}»).',
|
|
156
158
|
'wiz.qOwn': ' Подтверди: {url} — ТВОЁ приложение и ты разрешаешь слать к нему запросы?',
|
|
159
|
+
'wiz.sbFromFlag': ' Supabase из командной строки: {url}',
|
|
160
|
+
'wiz.notRun': ' Не подтверждено — {target} попадёт в отчёт как запрошенная, но не выполненная проверка.',
|
|
157
161
|
'aifix.title': '# Задача: починить безопасность этого проекта',
|
|
158
162
|
'aifix.intro': 'Ты — старший инженер по безопасности приложений. EasyVibeGate просканировал проект и нашёл {crit} критичных и {warn} предупреждений (оценка {score}/100, итог {gate}). Чини их по одному, начиная с самых серьёзных, не ломая существующую функциональность.',
|
|
159
163
|
'aifix.rules': 'Правила',
|
|
@@ -172,7 +176,7 @@ const RU = {
|
|
|
172
176
|
'aifix.done': 'Когда закончишь',
|
|
173
177
|
'aifix.done1': '- Перечисли, какие секреты я должен сменить сам.',
|
|
174
178
|
'aifix.done2': '- Перечисли, какие миграции нужно прогнать и где.',
|
|
175
|
-
'aifix.done3': '- Подведи итог: что починено и что ещё требует живой перепроверки (`npx
|
|
179
|
+
'aifix.done3': '- Подведи итог: что починено и что ещё требует живой перепроверки (`npx easyvibegate . --url <app> --i-own-this`).',
|
|
176
180
|
};
|
|
177
181
|
const TABLES = { en: EN, ru: RU };
|
|
178
182
|
export function t(lang, key, vars = {}) {
|
package/dist/engine/net/http.js
CHANGED
|
@@ -106,3 +106,71 @@ export async function requestFollow(url, init = {}, timeoutMs = 8000, maxHops =
|
|
|
106
106
|
export function sleep(ms) {
|
|
107
107
|
return new Promise((r) => setTimeout(r, ms));
|
|
108
108
|
}
|
|
109
|
+
export function looksLikeHtml(body) {
|
|
110
|
+
return /<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]/i.test(body.slice(0, 600));
|
|
111
|
+
}
|
|
112
|
+
/** A parsed JSON value with content. Error envelopes are handled by the caller. */
|
|
113
|
+
function jsonHasContent(v) {
|
|
114
|
+
if (v === null || v === undefined)
|
|
115
|
+
return false;
|
|
116
|
+
if (Array.isArray(v))
|
|
117
|
+
return v.length > 0;
|
|
118
|
+
if (typeof v === 'object')
|
|
119
|
+
return Object.keys(v).length > 0;
|
|
120
|
+
if (typeof v === 'string')
|
|
121
|
+
return v.length > 0;
|
|
122
|
+
return true; // a bare number/boolean is content
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The ONE place a probe response is turned into a verdict, so every live check
|
|
126
|
+
* applies the same rules (a truncated or unparseable body is never "clean").
|
|
127
|
+
* `expect: 'json'` requires a parsable JSON body; `'any'` accepts any bytes.
|
|
128
|
+
*/
|
|
129
|
+
export function classifyBody(res, expect) {
|
|
130
|
+
if (isErr(res))
|
|
131
|
+
return { kind: 'unknown', reason: res.error };
|
|
132
|
+
const { status } = res;
|
|
133
|
+
if (status === 429 || status >= 500)
|
|
134
|
+
return { kind: 'unknown', reason: `HTTP ${status}` };
|
|
135
|
+
// A redirect is never evidence of absence — the resource may sit behind the hop.
|
|
136
|
+
if (status >= 300 && status < 400)
|
|
137
|
+
return { kind: 'unknown', reason: `HTTP ${status} redirect not followed` };
|
|
138
|
+
if (status === 401 || status === 403)
|
|
139
|
+
return { kind: 'denied', reason: `HTTP ${status}` };
|
|
140
|
+
if (status === 404 || status === 405 || status === 410)
|
|
141
|
+
return { kind: 'absent', reason: `HTTP ${status}` };
|
|
142
|
+
if (status < 200 || status >= 300)
|
|
143
|
+
return { kind: 'unknown', reason: `HTTP ${status}` };
|
|
144
|
+
// Anything past the cap was not seen, so neither "no data" nor "no signature" holds.
|
|
145
|
+
if (res.truncated)
|
|
146
|
+
return { kind: 'unknown', reason: 'body truncated at the 2 MB cap' };
|
|
147
|
+
if (status === 204)
|
|
148
|
+
return { kind: 'empty', reason: 'HTTP 204' };
|
|
149
|
+
const body = res.body;
|
|
150
|
+
if (expect === 'any') {
|
|
151
|
+
return body.trim().length > 0 ? { kind: 'data', reason: `HTTP ${status}` } : { kind: 'empty', reason: 'empty body' };
|
|
152
|
+
}
|
|
153
|
+
const ctype = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
154
|
+
if (ctype.includes('text/html') || looksLikeHtml(body))
|
|
155
|
+
return { kind: 'unknown', reason: 'HTML page where JSON was expected' };
|
|
156
|
+
const t = body.trim();
|
|
157
|
+
if (t.length === 0)
|
|
158
|
+
return { kind: 'empty', reason: 'empty body' };
|
|
159
|
+
let json;
|
|
160
|
+
try {
|
|
161
|
+
json = JSON.parse(t);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { kind: 'unknown', reason: 'body is not JSON' };
|
|
165
|
+
}
|
|
166
|
+
// `{"error": …}` with a 2xx is the server telling us something went wrong,
|
|
167
|
+
// not an (empty) resource — it cannot be counted as checked.
|
|
168
|
+
if (json && typeof json === 'object' && !Array.isArray(json)) {
|
|
169
|
+
const o = json;
|
|
170
|
+
if ('error' in o || 'errors' in o)
|
|
171
|
+
return { kind: 'unknown', reason: 'error envelope in a 2xx body', json };
|
|
172
|
+
}
|
|
173
|
+
return jsonHasContent(json)
|
|
174
|
+
? { kind: 'data', reason: `HTTP ${status}`, json }
|
|
175
|
+
: { kind: 'empty', reason: 'empty JSON', json };
|
|
176
|
+
}
|
package/dist/engine/report.js
CHANGED
|
@@ -3,6 +3,13 @@ import { color } from './util/color.js';
|
|
|
3
3
|
import { t } from './i18n.js';
|
|
4
4
|
import { VERSION } from './version.js';
|
|
5
5
|
const WEIGHTS = { critical: 25, warning: 8, info: 2, advisory: 0 };
|
|
6
|
+
/**
|
|
7
|
+
* How many findings of a severity may move the score. Beyond the cap the count
|
|
8
|
+
* still shows in the report, but the grade stops falling: 562 warnings once
|
|
9
|
+
* produced 0/100 next to a project with real critical leaks at the same 0/100,
|
|
10
|
+
* which told the reader nothing. Criticals can still zero the score.
|
|
11
|
+
*/
|
|
12
|
+
const SCORE_CAP = { critical: 4, warning: 5, info: 5, advisory: 0 };
|
|
6
13
|
const EMOJI = { critical: '🔴', warning: '🟡', info: '🔵', advisory: '⚪' };
|
|
7
14
|
export function coverage(runs) {
|
|
8
15
|
const c = { completed: 0, partial: 0, failed: 0, skipped: 0, unsupported: 0 };
|
|
@@ -18,9 +25,12 @@ export function coverage(runs) {
|
|
|
18
25
|
export function summarize(findings, runs = []) {
|
|
19
26
|
const counts = { critical: 0, warning: 0, info: 0, advisory: 0 };
|
|
20
27
|
let score = 100;
|
|
28
|
+
const counted = { critical: 0, warning: 0, info: 0, advisory: 0 };
|
|
21
29
|
for (const f of findings) {
|
|
22
30
|
counts[f.severity]++;
|
|
23
|
-
|
|
31
|
+
if (counted[f.severity] < SCORE_CAP[f.severity])
|
|
32
|
+
score -= WEIGHTS[f.severity];
|
|
33
|
+
counted[f.severity]++;
|
|
24
34
|
}
|
|
25
35
|
const cov = coverage(runs);
|
|
26
36
|
const gate = counts.critical > 0 ? 'fail'
|
package/dist/engine/scan.js
CHANGED
|
@@ -4,7 +4,7 @@ import { staticCheckers } from './checkers/index.js';
|
|
|
4
4
|
import { applyIgnores, loadConfig } from './config.js';
|
|
5
5
|
/** Run all Level 0 (static, read-only) checkers over a project directory. */
|
|
6
6
|
export async function scanStatic(root, opts = {}) {
|
|
7
|
-
const { files, skippedOversized, skippedUnreadable } = walk(root);
|
|
7
|
+
const { files, skippedOversized, skippedUnreadable, skippedDirs, skippedSymlinks } = walk(root, { excludeAbs: opts.excludeAbs });
|
|
8
8
|
const detection = detect(root, files);
|
|
9
9
|
const ctx = { root, files, detection };
|
|
10
10
|
let findings = [];
|
|
@@ -20,19 +20,28 @@ export async function scanStatic(root, opts = {}) {
|
|
|
20
20
|
return { root, detection, findings, fileCount: 0, files, runs };
|
|
21
21
|
}
|
|
22
22
|
// Files we could not read are missing coverage, not a clean result.
|
|
23
|
-
const lost = skippedOversized + skippedUnreadable;
|
|
23
|
+
const lost = skippedOversized + skippedUnreadable + skippedDirs + skippedSymlinks;
|
|
24
24
|
if (lost > 0) {
|
|
25
25
|
const parts = [];
|
|
26
26
|
if (skippedOversized)
|
|
27
|
-
parts.push(`${skippedOversized} over the 1 MB limit`);
|
|
27
|
+
parts.push(`${skippedOversized} file(s) over the 1 MB limit`);
|
|
28
28
|
if (skippedUnreadable)
|
|
29
|
-
parts.push(`${skippedUnreadable} unreadable`);
|
|
30
|
-
|
|
29
|
+
parts.push(`${skippedUnreadable} unreadable file(s)`);
|
|
30
|
+
if (skippedDirs)
|
|
31
|
+
parts.push(`${skippedDirs} unreadable director(y/ies) — their whole subtree went unchecked`);
|
|
32
|
+
if (skippedSymlinks)
|
|
33
|
+
parts.push(`${skippedSymlinks} symlink(s) skipped — their targets were not scanned`);
|
|
34
|
+
runs.push({ id: 'walk', level: 0, status: 'partial', note: `not scanned: ${parts.join(', ')}` });
|
|
31
35
|
}
|
|
32
36
|
for (const checker of staticCheckers) {
|
|
33
37
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const res = await checker.run(ctx);
|
|
39
|
+
const { findings: got, partial } = Array.isArray(res) ? { findings: res, partial: undefined } : res;
|
|
40
|
+
findings.push(...got);
|
|
41
|
+
// A checker that could not interpret part of its input did not fully run.
|
|
42
|
+
runs.push(partial
|
|
43
|
+
? { id: `static:${checker.id}`, level: 0, status: 'partial', note: partial }
|
|
44
|
+
: { id: `static:${checker.id}`, level: 0, status: 'completed' });
|
|
36
45
|
}
|
|
37
46
|
catch (err) {
|
|
38
47
|
// A broken checker is a failed check, not a clean pass.
|