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
|
@@ -1,37 +1,44 @@
|
|
|
1
1
|
import { concretePath } from '../../endpoints.js';
|
|
2
|
-
import { isErr, request, sleep, unreliable } from '../../net/http.js';
|
|
2
|
+
import { classifyBody, isErr, request, sleep, unreliable } from '../../net/http.js';
|
|
3
3
|
function bearer(token) {
|
|
4
4
|
return { Authorization: `Bearer ${token}`, accept: 'application/json' };
|
|
5
5
|
}
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
6
|
+
/**
|
|
7
|
+
* What one (A, B) pair proves. Only a pair where at least one side gave a
|
|
8
|
+
* definite answer AND the other side did not leak is "protected"; anything
|
|
9
|
+
* where the guessed id did not exist, a token was rejected, or a body could
|
|
10
|
+
* not be read proves nothing.
|
|
11
|
+
*/
|
|
12
|
+
function classifyPair(a, b, aStatus, bStatus) {
|
|
13
|
+
if (a.kind === 'data' && b.kind === 'data')
|
|
14
|
+
return { kind: 'cross-user', reason: 'both accounts read data' };
|
|
15
|
+
if (a.kind === 'unknown' || b.kind === 'unknown')
|
|
16
|
+
return { kind: 'unknown', reason: a.kind === 'unknown' ? `A: ${a.reason}` : `B: ${b.reason}` };
|
|
17
|
+
if (a.kind === 'absent' || b.kind === 'absent')
|
|
18
|
+
return { kind: 'unknown', reason: `guessed id not found (HTTP ${a.kind === 'absent' ? aStatus : bStatus})` };
|
|
19
|
+
// A 401 means that token itself was rejected, so that account's access was never tested.
|
|
20
|
+
if (aStatus === 401 || bStatus === 401)
|
|
21
|
+
return { kind: 'unknown', reason: `token ${aStatus === 401 ? 'A' : 'B'} rejected (HTTP 401)` };
|
|
22
|
+
// Neither side got data and neither answer was an error/404: no object behind
|
|
23
|
+
// the guessed id (or both denied) — nothing leaked, but ownership was not exercised.
|
|
24
|
+
if (a.kind !== 'data' && b.kind !== 'data') {
|
|
25
|
+
if (a.kind === 'denied' && b.kind === 'denied')
|
|
26
|
+
return { kind: 'protected', reason: 'both accounts denied' };
|
|
27
|
+
return { kind: 'unknown', reason: 'no data for the guessed id from either account' };
|
|
25
28
|
}
|
|
29
|
+
// Exactly one side read data, the other was denied or got nothing: scoped correctly.
|
|
30
|
+
return { kind: 'protected', reason: 'only one account read data' };
|
|
26
31
|
}
|
|
27
32
|
/**
|
|
28
33
|
* Differential IDOR/BOLA probe. For each object-scoped endpoint, requests the
|
|
29
34
|
* same resource as two different users and classifies the PAIR:
|
|
30
35
|
* - either side unreliable (timeout/5xx/429) → inconclusive, not a verdict
|
|
31
|
-
* - both
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
36
|
+
* - both 2xx with data → candidate cross-user access
|
|
37
|
+
* - one side data, other denied/empty → protected (a verified pair)
|
|
38
|
+
* - 404 / rejected tokens / unreadable body → unknown (not verified)
|
|
39
|
+
* Any pair that could not be verified makes the run `partial`, so one good
|
|
40
|
+
* pair never hides the others. Harvesting real object IDs and judging
|
|
41
|
+
* ambiguous cases is left to the operator or the AI-driven skill.
|
|
35
42
|
*/
|
|
36
43
|
export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLimitMs = 120) {
|
|
37
44
|
const base = appUrl.replace(/\/$/, '');
|
|
@@ -50,9 +57,14 @@ export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLi
|
|
|
50
57
|
}
|
|
51
58
|
let inconclusive = 0; // a side timed out / 5xx / 429 — we learned nothing
|
|
52
59
|
let evaluated = 0; // both sides answered reliably
|
|
53
|
-
let
|
|
60
|
+
let verifiedPairs = 0; // protected or cross-user: a real verdict
|
|
61
|
+
const unknown = []; // "GET /path (reason)"
|
|
54
62
|
for (const e of idEndpoints) {
|
|
55
63
|
const path = concretePath(e.path).replace(/^\/?/, '/');
|
|
64
|
+
if (e.unresolved) {
|
|
65
|
+
unknown.push(`GET ${path} (${e.note ?? 'unresolved route prefix'})`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
56
68
|
await sleep(rateLimitMs);
|
|
57
69
|
const a = await request(base + path, { headers: bearer(tokenA) });
|
|
58
70
|
await sleep(rateLimitMs);
|
|
@@ -62,40 +74,44 @@ export async function idorDifferential(appUrl, endpoints, tokenA, tokenB, rateLi
|
|
|
62
74
|
continue;
|
|
63
75
|
}
|
|
64
76
|
evaluated++;
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (aOk && bOk) {
|
|
70
|
-
const identical = a.body === b.body;
|
|
71
|
-
findings.push({
|
|
72
|
-
id: 'idor_cross_user',
|
|
73
|
-
severity: 'warning',
|
|
74
|
-
title: `Two accounts both read ${path}`,
|
|
75
|
-
detail: identical
|
|
76
|
-
? `Both accounts received the identical object at ${path}. If this resource is meant to be private/per-user, that is an IDOR; if it is public/shared, it is fine — confirm which.`
|
|
77
|
-
: `Both accounts got a 200 with data at ${path}. Verify each only ever sees their own record.`,
|
|
78
|
-
fix: 'Enforce ownership server-side: check the authenticated user owns the requested id before returning it (or apply RLS). To confirm exploitability, request an id you know belongs to account A while authenticated as account B.',
|
|
79
|
-
checker: 'idor',
|
|
80
|
-
level: 2,
|
|
81
|
-
endpoint: `GET ${path}`,
|
|
82
|
-
});
|
|
77
|
+
const pair = classifyPair(classifyBody(a, 'json'), classifyBody(b, 'json'), a.status, b.status);
|
|
78
|
+
if (pair.kind === 'unknown') {
|
|
79
|
+
unknown.push(`GET ${path} (${pair.reason})`);
|
|
80
|
+
continue;
|
|
83
81
|
}
|
|
82
|
+
verifiedPairs++;
|
|
83
|
+
if (pair.kind !== 'cross-user')
|
|
84
|
+
continue;
|
|
85
|
+
const identical = a.body === b.body;
|
|
86
|
+
findings.push({
|
|
87
|
+
id: 'idor_cross_user',
|
|
88
|
+
severity: 'warning',
|
|
89
|
+
title: `Two accounts both read ${path}`,
|
|
90
|
+
detail: identical
|
|
91
|
+
? `Both accounts received the identical object at ${path}. If this resource is meant to be private/per-user, that is an IDOR; if it is public/shared, it is fine — confirm which.`
|
|
92
|
+
: `Both accounts got a 200 with data at ${path}. Verify each only ever sees their own record.`,
|
|
93
|
+
fix: 'Enforce ownership server-side: check the authenticated user owns the requested id before returning it (or apply RLS). To confirm exploitability, request an id you know belongs to account A while authenticated as account B.',
|
|
94
|
+
checker: 'idor',
|
|
95
|
+
level: 2,
|
|
96
|
+
endpoint: `GET ${path}`,
|
|
97
|
+
});
|
|
84
98
|
}
|
|
85
99
|
const total = idEndpoints.length;
|
|
86
|
-
if (evaluated === 0) {
|
|
100
|
+
if (evaluated === 0 && unknown.length === 0) {
|
|
87
101
|
return { findings, run: { id: 'idor', level: 2, status: 'failed', note: `all ${total} pair(s) were inconclusive (timeout/5xx/429)` } };
|
|
88
102
|
}
|
|
89
|
-
if (inconclusive > 0 || droppedPairs > 0) {
|
|
103
|
+
if (inconclusive > 0 || droppedPairs > 0 || unknown.length > 0) {
|
|
90
104
|
const n = [];
|
|
91
105
|
if (inconclusive > 0)
|
|
92
106
|
n.push(`${inconclusive}/${total} pair(s) inconclusive (timeout/5xx/429)`);
|
|
107
|
+
if (unknown.length > 0) {
|
|
108
|
+
n.push(`${unknown.length}/${total} pair(s) not verified: ${unknown.slice(0, 5).join(', ')}${unknown.length > 5 ? ', …' : ''}` +
|
|
109
|
+
' — provide real object ids to confirm ownership');
|
|
110
|
+
}
|
|
93
111
|
if (droppedPairs > 0)
|
|
94
112
|
n.push(`only ${MAX}/${idCandidates.length} object endpoints probed (cap)`);
|
|
95
113
|
return { findings, run: { id: 'idor', level: 2, status: 'partial', note: n.join('; ') } };
|
|
96
114
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
return { findings, run: { id: 'idor', level: 2, status: 'completed' } };
|
|
115
|
+
// Reaching here means every pair got a real verdict (protected or cross-user).
|
|
116
|
+
return { findings, run: { id: 'idor', level: 2, status: verifiedPairs === total ? 'completed' : 'partial' } };
|
|
101
117
|
}
|
|
@@ -1,20 +1,75 @@
|
|
|
1
1
|
import { lineAt, looksLikePlaceholder, redact } from '../../util/text.js';
|
|
2
|
+
import { classifyKey } from '../backend/supabase.js';
|
|
2
3
|
// Public env prefixes are inlined into the browser bundle by the bundler.
|
|
3
4
|
const PUBLIC_PREFIX = '(?:NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|GATSBY_|PUBLIC_)';
|
|
5
|
+
// Every public-prefixed assignment; what it carries is decided by the VALUE
|
|
6
|
+
// first and the NAME second (below).
|
|
7
|
+
const PUBLIC_ASSIGN = new RegExp(`\\b(${PUBLIC_PREFIX}[A-Z0-9_]*)\\s*[:=]\\s*["']?([^"'\\s]{6,})`, 'gi');
|
|
4
8
|
// A public var whose NAME implies a real secret (not an anon/publishable key).
|
|
5
|
-
const
|
|
9
|
+
const SECRET_NAME = /(SERVICE_ROLE|SECRET|PRIVATE|PASSWORD|PASSWD|TOKEN|CREDENTIAL|API_KEY|ACCESS_KEY)/i;
|
|
10
|
+
// Value shapes that are server secrets no matter what the variable is called.
|
|
11
|
+
const SERVER_SECRET_VALUE = /^(?:sk_live_|rk_live_|sk-ant-|sk-(?:proj-)?[A-Za-z0-9_-]{20,}$)/;
|
|
12
|
+
const FIREBASE_WEB_KEY = /^AIza[0-9A-Za-z_-]{35}$/;
|
|
13
|
+
/**
|
|
14
|
+
* A key is public or secret by what it IS, not by what the variable is named.
|
|
15
|
+
* A Supabase publishable key or a Firebase Web API key stored under a public
|
|
16
|
+
* *_API_KEY name was reported as a leaked server secret with "rotate it"
|
|
17
|
+
* advice, although both are designed to ship to the browser. Conversely a
|
|
18
|
+
* service_role JWT behind a public *_KEY name (no secret word) went unreported.
|
|
19
|
+
*/
|
|
20
|
+
function classifyValue(value) {
|
|
21
|
+
const sb = classifyKey(value);
|
|
22
|
+
if (sb === 'publishable' || sb === 'jwt-anon')
|
|
23
|
+
return 'client-key';
|
|
24
|
+
if (sb === 'secret' || sb === 'jwt-service' || sb === 'jwt-authenticated')
|
|
25
|
+
return 'server-secret';
|
|
26
|
+
if (FIREBASE_WEB_KEY.test(value))
|
|
27
|
+
return 'client-key';
|
|
28
|
+
if (SERVER_SECRET_VALUE.test(value))
|
|
29
|
+
return 'server-secret';
|
|
30
|
+
return 'unknown';
|
|
31
|
+
}
|
|
6
32
|
export const clientExposureChecker = {
|
|
7
33
|
id: 'client-exposure',
|
|
8
34
|
title: 'Secrets exposed to the browser',
|
|
9
35
|
level: 0,
|
|
10
36
|
run(ctx) {
|
|
11
37
|
const findings = [];
|
|
38
|
+
// The same public key is typically repeated across .env, .env.local and
|
|
39
|
+
// config; one advisory per file+value is enough.
|
|
40
|
+
const advised = new Set();
|
|
12
41
|
for (const file of ctx.files) {
|
|
13
42
|
const { content, rel } = file;
|
|
14
|
-
for (const m of content.matchAll(
|
|
43
|
+
for (const m of content.matchAll(PUBLIC_ASSIGN)) {
|
|
44
|
+
const name = m[1] ?? '';
|
|
15
45
|
const value = m[2] ?? '';
|
|
16
46
|
if (looksLikePlaceholder(value))
|
|
17
47
|
continue;
|
|
48
|
+
const kind = classifyValue(value);
|
|
49
|
+
const line = lineAt(content, m.index ?? 0);
|
|
50
|
+
if (kind === 'client-key') {
|
|
51
|
+
const key = `${rel}:${value}`;
|
|
52
|
+
if (advised.has(key))
|
|
53
|
+
continue;
|
|
54
|
+
advised.add(key);
|
|
55
|
+
findings.push({
|
|
56
|
+
id: 'public_key_client',
|
|
57
|
+
severity: 'advisory',
|
|
58
|
+
title: 'Public client key behind a public env var',
|
|
59
|
+
detail: `${name} carries a key designed for the browser (${redact(value)}) — an anon/publishable Supabase key or a Firebase Web API key. It is not a leak by itself: its safety depends entirely on Row Level Security / Firebase security rules / API key restrictions.`,
|
|
60
|
+
fix: 'Keep it public, and make the server side enforce access: enable RLS with policies on every table (Supabase), lock down security rules (Firebase), and restrict the key by API and HTTP referrer (Google Cloud console).',
|
|
61
|
+
checker: 'client-exposure',
|
|
62
|
+
level: 0,
|
|
63
|
+
file: rel,
|
|
64
|
+
line,
|
|
65
|
+
evidence: redact(value),
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// A proven server secret is critical regardless of the name; an
|
|
70
|
+
// unknown value is critical only when the NAME says it is a secret.
|
|
71
|
+
if (kind !== 'server-secret' && !SECRET_NAME.test(name))
|
|
72
|
+
continue;
|
|
18
73
|
findings.push({
|
|
19
74
|
id: 'public_env_secret',
|
|
20
75
|
severity: 'critical',
|
|
@@ -24,7 +79,7 @@ export const clientExposureChecker = {
|
|
|
24
79
|
checker: 'client-exposure',
|
|
25
80
|
level: 0,
|
|
26
81
|
file: rel,
|
|
27
|
-
line
|
|
82
|
+
line,
|
|
28
83
|
evidence: redact(value),
|
|
29
84
|
});
|
|
30
85
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lineAt } from '../../util/text.js';
|
|
2
|
-
import {
|
|
2
|
+
import { looksBundledPath, maskTokens } from '../../util/mask.js';
|
|
3
|
+
import { langForFile, lexCode } from '../../util/code-lex.js';
|
|
3
4
|
const RULES = [
|
|
4
5
|
{
|
|
5
6
|
id: 'cors_star',
|
|
@@ -22,7 +23,8 @@ const RULES = [
|
|
|
22
23
|
{
|
|
23
24
|
id: 'eval_use',
|
|
24
25
|
title: 'Use of eval / new Function',
|
|
25
|
-
|
|
26
|
+
// `(?<![\w.])` — `model.eval()` (PyTorch) and `obj.eval(` are methods, not the global.
|
|
27
|
+
re: /(?<![\w.])eval\s*\(|\bnew\s+Function\s*\(/g,
|
|
26
28
|
severity: 'warning',
|
|
27
29
|
detail: 'eval on any untrusted input is a code-execution risk.',
|
|
28
30
|
fix: 'Replace eval with explicit parsing/logic; never eval user-supplied data.',
|
|
@@ -51,30 +53,84 @@ const RULES = [
|
|
|
51
53
|
maskStrings: false,
|
|
52
54
|
},
|
|
53
55
|
];
|
|
56
|
+
/**
|
|
57
|
+
* Template tags that parameterize `${…}` instead of splicing it into the query
|
|
58
|
+
* text (Postgres.js / slonik / drizzle `sql`, Prisma `$queryRaw` / `Prisma.sql`).
|
|
59
|
+
* Matched against the code just before the backtick. `sql.unsafe` and
|
|
60
|
+
* `$queryRawUnsafe` end differently and so stay reported, as do `.query(`…`)`
|
|
61
|
+
* and untagged templates.
|
|
62
|
+
*/
|
|
63
|
+
const SAFE_SQL_TAG = /(?:^|[^\w$.])(?:sql|SQL|[\w$]+\.sql|(?:[\w$]+\.)?\$(?:queryRaw|executeRaw))\s*$/;
|
|
64
|
+
function isParameterizedTemplate(scan, backtickAt) {
|
|
65
|
+
if (scan[backtickAt] !== '`')
|
|
66
|
+
return false;
|
|
67
|
+
return SAFE_SQL_TAG.test(scan.slice(Math.max(0, backtickAt - 64), backtickAt));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Where an interpolation sits decides what it can do. `WHERE id = ${id}` splices
|
|
71
|
+
* a VALUE — injection. `FROM ${table}`, `SET ${col} = ?`, `ORDER BY ${dir}` splice
|
|
72
|
+
* STRUCTURE with the values still parameterized — risky only if the names come
|
|
73
|
+
* from request data, so it is reported as info, not as an injection. About half
|
|
74
|
+
* of the real-world hits were structural.
|
|
75
|
+
*/
|
|
76
|
+
const VALUE_POSITION = /(=|<>|!=|<=|>=|<|>|\bLIKE|\bILIKE|\bIN\s*\(|\bVALUES\s*\([^)]*|\bBETWEEN|\bAND|\bOR|\bLIMIT|\bOFFSET|\bTHEN|\bELSE)\s*$/i;
|
|
77
|
+
const PLACEHOLDER_EXPR = /^\s*(?:\w+\.)?(placeholders?|params?|marks|questions|qs|values|binds?)\b/i;
|
|
78
|
+
function onlyStructuralInterpolation(scan, at) {
|
|
79
|
+
// The literal: from the opening delimiter to its close (bounded).
|
|
80
|
+
const open = scan[at] === '`' ? '`' : scan[at + 1] ?? '"';
|
|
81
|
+
const start = scan[at] === '`' ? at + 1 : at + 2;
|
|
82
|
+
const close = scan.indexOf(open, start);
|
|
83
|
+
const body = scan.slice(start, close === -1 ? Math.min(scan.length, start + 2000) : close);
|
|
84
|
+
const holes = [...body.matchAll(/\$\{([^}]*)\}|(?<!\$)\{([^}]*)\}/g)];
|
|
85
|
+
if (holes.length === 0)
|
|
86
|
+
return false;
|
|
87
|
+
for (const h of holes) {
|
|
88
|
+
const expr = h[1] ?? h[2] ?? '';
|
|
89
|
+
if (PLACEHOLDER_EXPR.test(expr))
|
|
90
|
+
continue;
|
|
91
|
+
const before = body.slice(Math.max(0, (h.index ?? 0) - 60), h.index ?? 0);
|
|
92
|
+
if (VALUE_POSITION.test(before))
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
54
97
|
export const configRisksChecker = {
|
|
55
98
|
id: 'config-risks',
|
|
56
99
|
title: 'Dangerous configuration',
|
|
57
100
|
level: 0,
|
|
58
101
|
run(ctx) {
|
|
59
102
|
const findings = [];
|
|
103
|
+
const skipped = [];
|
|
60
104
|
for (const file of ctx.files) {
|
|
61
105
|
// Config risks in prose docs are examples, not live config — skip them.
|
|
62
106
|
if (file.rel.endsWith('.md') || file.rel.endsWith('.txt'))
|
|
63
107
|
continue;
|
|
64
|
-
|
|
65
|
-
|
|
108
|
+
// Generated output is skipped by PATH only and reported as missing
|
|
109
|
+
// coverage below. A "looks minified" content heuristic used to skip a
|
|
110
|
+
// file silently, so one long data line disabled every rule for it. The
|
|
111
|
+
// regexes are length-bounded, so long lines are safe to scan instead.
|
|
112
|
+
if (looksBundledPath(file.rel)) {
|
|
113
|
+
skipped.push(file.rel);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
66
116
|
// Comments never trigger a rule. Strings are a per-rule decision: a rule
|
|
67
117
|
// matching a literal value must still see it (see Rule.maskStrings).
|
|
68
|
-
const
|
|
69
|
-
const
|
|
118
|
+
const tokens = lexCode(file.content, langForFile(file.rel));
|
|
119
|
+
const noComments = maskTokens(file.content, tokens);
|
|
120
|
+
const noStrings = maskTokens(file.content, tokens, { strings: true });
|
|
70
121
|
for (const rule of RULES) {
|
|
71
122
|
const scan = rule.maskStrings ? noStrings : noComments;
|
|
72
123
|
for (const m of scan.matchAll(rule.re)) {
|
|
124
|
+
if (rule.id === 'sql_interpolation' && isParameterizedTemplate(scan, m.index ?? 0))
|
|
125
|
+
continue;
|
|
126
|
+
const structural = rule.id === 'sql_interpolation' && onlyStructuralInterpolation(scan, m.index ?? 0);
|
|
73
127
|
findings.push({
|
|
74
128
|
id: rule.id,
|
|
75
|
-
severity: rule.severity,
|
|
76
|
-
title: rule.title,
|
|
77
|
-
detail:
|
|
129
|
+
severity: structural ? 'info' : rule.severity,
|
|
130
|
+
title: structural ? 'SQL built with interpolated identifiers' : rule.title,
|
|
131
|
+
detail: structural
|
|
132
|
+
? 'Table/column names or operators are spliced into the query while the values use placeholders. That is safe only if those names come from a fixed allowlist in code — never from request data (`Object.keys(body)` is the classic hole).'
|
|
133
|
+
: rule.detail,
|
|
78
134
|
fix: rule.fix,
|
|
79
135
|
checker: 'config-risks',
|
|
80
136
|
level: 0,
|
|
@@ -84,6 +140,10 @@ export const configRisksChecker = {
|
|
|
84
140
|
}
|
|
85
141
|
}
|
|
86
142
|
}
|
|
87
|
-
|
|
143
|
+
// Skipped is not clean: name what was not reviewed so the gate stays incomplete.
|
|
144
|
+
if (skipped.length === 0)
|
|
145
|
+
return { findings };
|
|
146
|
+
const shown = skipped.slice(0, 5).join(', ') + (skipped.length > 5 ? `, … (+${skipped.length - 5})` : '');
|
|
147
|
+
return { findings, partial: `${skipped.length} generated/minified file(s) not reviewed: ${shown}` };
|
|
88
148
|
},
|
|
89
149
|
};
|