openpitstop 1.2.0 → 1.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/PRIVACY.md +9 -3
- package/README.md +42 -4
- package/dist/analyzers/security.js +6 -1
- package/dist/analyzers/security.js.map +1 -1
- package/dist/analyzers/securityStatic.d.ts +35 -0
- package/dist/analyzers/securityStatic.js +786 -0
- package/dist/analyzers/securityStatic.js.map +1 -0
- package/dist/analyzers/types.d.ts +4 -0
- package/dist/analyzers/util.d.ts +1 -1
- package/dist/analyzers/util.js +2 -1
- package/dist/analyzers/util.js.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/scan.d.ts +5 -0
- package/dist/commands/scan.js +22 -1
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/test.d.ts +41 -0
- package/dist/commands/test.js +297 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/commands/try.js +7 -3
- package/dist/commands/try.js.map +1 -1
- package/package.json +1 -1
- package/templates/pitstop.prompt.md +1 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lineOf } from "./util.js";
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic, fully-offline static security scan.
|
|
6
|
+
*
|
|
7
|
+
* Every finding here is labeled `[indicated]`: static analysis can point at
|
|
8
|
+
* the line and the class of bug, but it cannot *prove* an exploit — the
|
|
9
|
+
* dynamic phases (`pen`, `ledger`) exist for that. What it CAN do is never
|
|
10
|
+
* miss a known pattern, and every finding carries a concrete `fix`, so the
|
|
11
|
+
* report is an identify-and-solve checklist, not a scare.
|
|
12
|
+
*
|
|
13
|
+
* Covered (see docs/security.md):
|
|
14
|
+
* - SQL injection (JS + Python)
|
|
15
|
+
* - Command injection, path traversal, SSRF, XSS
|
|
16
|
+
* - Secret management: known credential formats, generic secret
|
|
17
|
+
* assignments, committed `.env` files
|
|
18
|
+
* - Authentication: weak password compare, plaintext storage, missing
|
|
19
|
+
* hashing, predictable tokens, inline JWT secrets, missing rate limits
|
|
20
|
+
* - Authorization: routes without authn/authz checks, admin routes
|
|
21
|
+
* without role checks
|
|
22
|
+
* - Input validation: file uploads without limits, unvalidated money
|
|
23
|
+
* fields, eval/Function sinks, prototype pollution
|
|
24
|
+
* - Rate limiting: missing limiters on state-changing endpoints, limits
|
|
25
|
+
* set so high they are decorations, disabled limiters
|
|
26
|
+
* - Database lockdown: privileged accounts in committed connection
|
|
27
|
+
* strings, GRANT ALL/SUPERUSER, TLS-free connections, hardcoded DB
|
|
28
|
+
* passwords, missing row-level security
|
|
29
|
+
* - Data exposure: credentials/PII in API responses, full DB rows sent
|
|
30
|
+
* to the client, PII in logs, SELECT *
|
|
31
|
+
* - Hidden vulnerabilities: disabled TLS verification, alg:none JWTs,
|
|
32
|
+
* eval-atob deobfuscation, security TODOs, lint/type bypasses on
|
|
33
|
+
* sensitive code, tokens in localStorage, committed minified bundles,
|
|
34
|
+
* backup/editor files and .htpasswd in the tree
|
|
35
|
+
* - Config & transport: CORS+credentials, missing security headers,
|
|
36
|
+
* cleartext HTTP, insecure cookies, CSRF exposure, stack leaks,
|
|
37
|
+
* sensitive logging, weak hashing
|
|
38
|
+
*/
|
|
39
|
+
const MAX_FILES = 600;
|
|
40
|
+
const MAX_BYTES_PER_FILE = 512 * 1024;
|
|
41
|
+
const MAX_FINDINGS = 40;
|
|
42
|
+
const SKIP_DIRS = new Set([
|
|
43
|
+
"node_modules",
|
|
44
|
+
"dist",
|
|
45
|
+
"build",
|
|
46
|
+
".git",
|
|
47
|
+
".pitstop",
|
|
48
|
+
"coverage",
|
|
49
|
+
".next",
|
|
50
|
+
".nuxt",
|
|
51
|
+
".venv",
|
|
52
|
+
"venv",
|
|
53
|
+
"__pycache__",
|
|
54
|
+
".cache",
|
|
55
|
+
"vendor",
|
|
56
|
+
"target",
|
|
57
|
+
"out",
|
|
58
|
+
"demo-repo",
|
|
59
|
+
"templates",
|
|
60
|
+
]);
|
|
61
|
+
const CODE_EXTS = new Set([
|
|
62
|
+
".js",
|
|
63
|
+
".jsx",
|
|
64
|
+
".ts",
|
|
65
|
+
".tsx",
|
|
66
|
+
".mjs",
|
|
67
|
+
".cjs",
|
|
68
|
+
".py",
|
|
69
|
+
".go",
|
|
70
|
+
".rb",
|
|
71
|
+
".php",
|
|
72
|
+
".java",
|
|
73
|
+
".cs",
|
|
74
|
+
".html",
|
|
75
|
+
".vue",
|
|
76
|
+
".svelte",
|
|
77
|
+
".sql",
|
|
78
|
+
]);
|
|
79
|
+
/** Paths that a sane app treats as needing protection. */
|
|
80
|
+
const PROTECTED_PATH = "(?:admin|account|profile|settings|dashboard|orders?|checkout|payment|charge|billing|subscription|private|internal|api(?:/|)|users?|sessions?|transactions?|transfer|refund)";
|
|
81
|
+
/** Unambiguous user-controlled data sources (no bare `body`/`input`/`data`). */
|
|
82
|
+
const USERISH = "req\\.(?:query|params|body|headers|cookies|files)|userInput|userUrl|targetUrl|webhookUrl|callbackUrl|redirectUrl|uploadedFile|fileName|filePath|pathVar|target";
|
|
83
|
+
/**
|
|
84
|
+
* Rules are tried per file, in order. Match offsets are mapped to line
|
|
85
|
+
* numbers; matches are deduped by (category, file, line) so overlapping
|
|
86
|
+
* patterns never double-report the same line.
|
|
87
|
+
*/
|
|
88
|
+
export const STATIC_SECURITY_RULES = [
|
|
89
|
+
/* ----------------------- SQL injection ----------------------- */
|
|
90
|
+
{
|
|
91
|
+
category: "sql-injection",
|
|
92
|
+
severity: "high",
|
|
93
|
+
re: /\b(?:db|client|pool|connection|mysql|pg|sqlite|sqlite3)\.(?:query|execute|exec|run|all|get)\s*\([`"']?[\s\S]{0,140}?(?:\$\{[\s\S]{0,40}?\}|["'`]\s*\+)/g,
|
|
94
|
+
describe: () => "SQL built by string concatenation or interpolation — user input reaches the query text",
|
|
95
|
+
fix: 'use parameterized queries, never string-built SQL: db.query("SELECT * FROM users WHERE id = $1", [id])',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
category: "sql-injection",
|
|
99
|
+
severity: "high",
|
|
100
|
+
re: /\b(?:whereRaw|orderByRaw|groupByRaw|joinRaw|havingRaw|fromRaw|raw|literal)\s*\(\s*[`"']?[\s\S]{0,140}?(?:\$\{[\s\S]{0,40}?\}|["'`]\s*\+)/g,
|
|
101
|
+
describe: () => "raw SQL builder receives interpolated/concatenated input — injection into an ORM raw call",
|
|
102
|
+
fix: 'raw SQL builders must never receive user input: use the ORM\'s parameterized API: .where("id", id) instead of .whereRaw(`id = ${id}`)',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
category: "sql-injection",
|
|
106
|
+
severity: "high",
|
|
107
|
+
re: /\$\s*where\s*:/g,
|
|
108
|
+
describe: () => "Mongoose $where query — runs JS on the DB server with injected conditions",
|
|
109
|
+
fix: "never use $where: it evaluates strings server-side. Use $eq/$in with validated values: { _id: { $eq: id } }",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
category: "sql-injection",
|
|
113
|
+
severity: "high",
|
|
114
|
+
re: /\bqueryRawUnsafe\s*\(/g,
|
|
115
|
+
describe: () => "Prisma $queryRawUnsafe — explicitly marked unsafe by Prisma, intended for constant SQL only",
|
|
116
|
+
fix: "use Prisma's parameterized queryRaw with $1 placeholders, or better the typed findUnique/findMany API",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
category: "sql-injection",
|
|
120
|
+
severity: "high",
|
|
121
|
+
re: /\b(?:cursor|conn|db)\.(?:execute|executemany)\s*\(\s*f["']|\.execute\s*\(\s*["'][^"'\n]{0,120}["']\s*%\s*[\(\[]/g,
|
|
122
|
+
describe: () => "SQL built with f-strings or %-formatting — Python driver-level injection",
|
|
123
|
+
fix: 'always pass parameters separately: cursor.execute("SELECT * FROM users WHERE id = ?", (id,)) — never f-strings',
|
|
124
|
+
},
|
|
125
|
+
/* --------------------- Command injection --------------------- */
|
|
126
|
+
{
|
|
127
|
+
category: "command-injection",
|
|
128
|
+
severity: "high",
|
|
129
|
+
re: /\b(?:exec|execSync|execFileSync)\s*\(\s*[`"']?[\s\S]{0,160}?(?:\$\{[\s\S]{0,40}?\}|["'`]\s*\+)/g,
|
|
130
|
+
describe: () => "shell command built with interpolation/concatenation — user input executes as code",
|
|
131
|
+
fix: 'never put user input in shell strings: execFile("git", ["log", commit]) with an args array and shell:false',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
category: "command-injection",
|
|
135
|
+
severity: "high",
|
|
136
|
+
re: /\bos\.system\s*\(\s*f["']|subprocess\.(?:run|Popen|call|check_output)\s*\([\s\S]{0,200}?shell\s*=\s*True/g,
|
|
137
|
+
describe: () => "Python shell=True with a formatted command — RCE via user input",
|
|
138
|
+
fix: 'drop shell=True and pass an argv list: subprocess.run(["git", "log", commit]) — never a formatted string',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
category: "command-injection",
|
|
142
|
+
severity: "medium",
|
|
143
|
+
re: /\bshell\s*:\s*true[\s\S]{0,60}?(?:req\.|userInput|payload|commit|targetUrl|filename|filePath)/g,
|
|
144
|
+
describe: () => "spawn/exec with shell:true near user-controlled data — quoting mistakes become RCE",
|
|
145
|
+
fix: "shell:true disables argument safety. Pass argv arrays without a shell unless you control every byte",
|
|
146
|
+
},
|
|
147
|
+
/* --------------------- Path traversal ------------------------ */
|
|
148
|
+
{
|
|
149
|
+
category: "path-traversal",
|
|
150
|
+
severity: "medium",
|
|
151
|
+
re: /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|sendFile|unlink|unlinkSync|rmSync|realpath|access)\w*\s*\(\s*[`"']?[\s\S]{0,120}?(?:req\.(?:query|params|body|files)|userInput|uploadedFile|pathVar)[\s\S]{0,60}?\)/g,
|
|
152
|
+
describe: () => "user-controlled path reaches the filesystem — ../ escapes read arbitrary files",
|
|
153
|
+
fix: 'confine user paths: const root = path.resolve("uploads"); const safe = path.resolve(root, name); if (!safe.startsWith(root + path.sep)) throw new Error("bad path")',
|
|
154
|
+
skipWhenInMatch: /path\.basename\s*\(/,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
category: "path-traversal",
|
|
158
|
+
severity: "medium",
|
|
159
|
+
re: /\bpath\.(?:join|resolve)\s*\(\s*[`"']?[\s\S]{0,100}?(?:req\.(?:query|params|body)|userInput|uploadedFile|pathVar)[\s\S]{0,40}?\)/g,
|
|
160
|
+
describe: () => "user input joined into a path — ../ traversal unless confined to a root",
|
|
161
|
+
fix: "basename the user part and verify the result stays inside an allowed root: path.basename(name) + prefix check",
|
|
162
|
+
skipWhenInMatch: /path\.basename\s*\(/,
|
|
163
|
+
},
|
|
164
|
+
/* -------------------------- SSRF ----------------------------- */
|
|
165
|
+
{
|
|
166
|
+
category: "ssrf",
|
|
167
|
+
severity: "medium",
|
|
168
|
+
re: /\b(?:fetch|axios\.(?:get|post|put|patch|request)|got|request|superagent|http\.(?:get|post)|https\.(?:get|post))\s*\(\s*[`"']?[\s\S]{0,140}?(?:req\.(?:query|params|body|headers)|userInput|targetUrl|webhookUrl|callbackUrl|redirectUrl|userUrl|target)[\s\S]{0,40}?\)/g,
|
|
169
|
+
describe: () => "user-supplied URL reaches an HTTP client — SSRF lets attackers hit internal services (169.254.169.254)",
|
|
170
|
+
fix: "allowlist destinations: only https + your domain, or an explicit allowlist; never fetch a user-supplied URL",
|
|
171
|
+
accept: (_m, content) => {
|
|
172
|
+
// A loopback/localhost guard immediately before the call is a real
|
|
173
|
+
// (partial) mitigation for internal-probe scenarios — skip it.
|
|
174
|
+
const before = content.slice(Math.max(0, _m.index - 200), _m.index);
|
|
175
|
+
return !/\b(?:isLoopback|isLocalhost|isPrivate)\s*\(/.test(before);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
/* --------------------------- XSS ----------------------------- */
|
|
179
|
+
{
|
|
180
|
+
category: "xss",
|
|
181
|
+
severity: "high",
|
|
182
|
+
re: /\b(?:innerHTML|outerHTML|insertAdjacentHTML|document\.write)\s*[:=]\s*[`"'(\s]*[\s\S]{0,100}?(?:req\.(?:query|params|body)|userInput|username|userTitle|userContent|userMessage|userComment|userSearch|userUrl|payload)|\bdangerouslySetInnerHTML\s*=|\bv-html\s*=/g,
|
|
183
|
+
describe: () => "user data flows into an HTML sink — script injection runs in every visitor's browser",
|
|
184
|
+
fix: 'never write user data into HTML sinks: use textContent / {expression} / v-text, or escape HTML on every path in',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
category: "xss",
|
|
188
|
+
severity: "medium",
|
|
189
|
+
re: /\b(?:innerHTML|outerHTML|insertAdjacentHTML)\s*[:=]\s*(?![`"'])([A-Za-z_$][\w$.]*|\$\{)/g,
|
|
190
|
+
describe: (m) => `dynamic value (${m[1].slice(0, 40)}) written into an HTML sink — if it can be user-derived, this is stored/reflected XSS`,
|
|
191
|
+
fix: "write text with textContent; if you must write HTML, escape every interpolated value first",
|
|
192
|
+
},
|
|
193
|
+
/* --------------------- Secret management --------------------- */
|
|
194
|
+
{
|
|
195
|
+
category: "secret",
|
|
196
|
+
severity: "high",
|
|
197
|
+
re: /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z\-_]{35}|ghp_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{20,}|xox[baprs]-[0-9A-Za-z\-]{10,}|sk_live_[0-9A-Za-z]{16,}|sk-ant-[0-9A-Za-z\-_]{20,}|sk-[A-Za-z0-9]{20,})/g,
|
|
198
|
+
describe: () => "known credential format committed in source — anyone with repo access has a live key",
|
|
199
|
+
fix: "rotate the leaked key NOW, then move it to .env (gitignored): process.env.API_KEY — never in source",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
category: "secret",
|
|
203
|
+
severity: "medium",
|
|
204
|
+
re: /\b(?:api[_-]?key|apikey|client[_-]?secret|secret[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|jwt[_-]?secret|session[_-]?secret|password|passwd|pwd)\s*[:=]\s*["']([^"']{4,})["']/g,
|
|
205
|
+
describe: (m) => `secret-looking value "${m[1].slice(0, 12)}…" assigned inline instead of read from the environment`,
|
|
206
|
+
fix: "secrets live in .env (gitignored) + process.env.NAME — never as string literals in source",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
category: "secret",
|
|
210
|
+
severity: "medium",
|
|
211
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
212
|
+
describe: () => "a signed JWT committed in source (leaked token or test fixture — either way it must not be here)",
|
|
213
|
+
fix: "remove committed JWTs; issue tokens at runtime and keep the signing secret in the environment",
|
|
214
|
+
},
|
|
215
|
+
/* ---------------------- Authentication ----------------------- */
|
|
216
|
+
{
|
|
217
|
+
category: "authentication",
|
|
218
|
+
severity: "high",
|
|
219
|
+
re: /\b(?:password|passwd|pwd)\b[^;\n]{0,80}?(?:===|!==|==|!=)[^;\n]{0,80}/g,
|
|
220
|
+
describe: () => "password value compared directly with ==/=== — no key-derivation function in sight; cleartext compares are trivially leaked",
|
|
221
|
+
fix: "compare only salted hashes: await bcrypt.compare(input, user.hash) — never plaintext ===",
|
|
222
|
+
accept: (m) => {
|
|
223
|
+
const s = m[0];
|
|
224
|
+
if (/\b(?:bcrypt|compare|hash|argon2|scrypt)\b/.test(s))
|
|
225
|
+
return false;
|
|
226
|
+
const reqRefs = (s.match(/req\./g) ?? []).length;
|
|
227
|
+
// password === confirmPassword (both from the request) is a shape check, not a leak.
|
|
228
|
+
return reqRefs < 2;
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
category: "authentication",
|
|
233
|
+
severity: "medium",
|
|
234
|
+
re: /\b(?:INSERT|insert|UPDATE|update|create|save|set)\s*\([^)]{0,120}\bpassword\b[^)]{0,60}\)/g,
|
|
235
|
+
describe: () => "password written straight into a store with no hash call nearby",
|
|
236
|
+
fix: "store only salted hashes: await bcrypt.hash(password, 12), keep the hash, discard the plaintext",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
category: "authentication",
|
|
240
|
+
severity: "high",
|
|
241
|
+
re: /\b(?:token|otp|code|nonce|sessionId|session_id|verificationCode|resetCode|authCode|csrf|secret)\w*\s*[:=]\s*[^;\n]{0,40}?\bMath\.random\(\)/g,
|
|
242
|
+
describe: () => "Math.random() for a token/OTP/session id — predictable output, one guess away from account takeover",
|
|
243
|
+
fix: 'use crypto.randomBytes(32).toString("hex") — Math.random is not cryptographic',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
category: "authentication",
|
|
247
|
+
severity: "medium",
|
|
248
|
+
re: /\bjwt\.(?:sign|verify)\s*\([\s\S]{0,140}?["'][A-Za-z0-9!@#$%^&*\-_=+]{1,16}["']\s*\)/g,
|
|
249
|
+
describe: () => "JWT signed/verified with a short inline literal secret — brute-forceable, and it lives in source",
|
|
250
|
+
fix: "jwt.sign(payload, process.env.JWT_SECRET) with a long random env secret — never an inline literal",
|
|
251
|
+
},
|
|
252
|
+
/* ---------------------- Authorization ------------------------ */
|
|
253
|
+
{
|
|
254
|
+
category: "authorization",
|
|
255
|
+
severity: "medium",
|
|
256
|
+
pathFilter: new RegExp(PROTECTED_PATH),
|
|
257
|
+
guard: /\b(?:req\.(?:user|session|auth|headers\.authorization)|verifyToken|requireAuth|isAuthenticated|jwt\.verify|authMiddleware|passport\.authenticate|apiKey|middleware)/,
|
|
258
|
+
re: /\b(?:app|router)\.(?:get|post|put|delete|patch|use)\s*\(\s*["']([^"']+)["'],\s*[\s\S]{0,700}/g,
|
|
259
|
+
describe: (m) => `route ${m[1]} handles data with no authn/authz check visible (no req.user/req.session/JWT verify) — who is allowed in?`,
|
|
260
|
+
fix: 'every protected route needs a guard: router.get("/api/account", requireAuth, handler) — the guard must verify the token/session server-side, per request',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
category: "authorization",
|
|
264
|
+
severity: "medium",
|
|
265
|
+
pathFilter: /(?:admin|moderator|manager|staff|dashboard)/,
|
|
266
|
+
guard: /\b(?:role|roles|permission|isAdmin|isStaff|requireRole|can\b|scopes?|acl|rbac)/,
|
|
267
|
+
re: /\b(?:app|router)\.(?:get|post|put|delete|patch|use)\s*\(\s*["']([^"']+)["'],\s*[\s\S]{0,700}/g,
|
|
268
|
+
describe: (m) => `privileged route ${m[1]} never references role/permission/isAdmin — broken access control: any logged-in user can reach it`,
|
|
269
|
+
fix: 'enforce roles server-side on every privileged route and every page: if (req.user.role !== "admin") return 403 — never rely on hiding UI',
|
|
270
|
+
},
|
|
271
|
+
/* ---------------------- Input validation --------------------- */
|
|
272
|
+
{
|
|
273
|
+
category: "input-validation",
|
|
274
|
+
severity: "medium",
|
|
275
|
+
re: /\bmulter\s*\(\s*\)|\.single\s*\(\s*["'][^"']+["']\s*\)|\.array\s*\(|\.fields\s*\(|express\.fileupload\s*\(\s*\)/g,
|
|
276
|
+
describe: () => "file upload with no size limit and no type filter — attacker uploads a 10 GB exe or an executable .js served by the static host",
|
|
277
|
+
fix: 'const upload = multer({ storage, limits: { fileSize: 1 * 1024 * 1024 }, fileFilter: (_, f, cb) => cb(null, ALLOWED_TYPES.has(f.mimetype)) })',
|
|
278
|
+
accept: (_m, content) => !/(?:limits\s*:\s*\{[^}]{0,120}fileSize|fileFilter)/.test(content),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
category: "input-validation",
|
|
282
|
+
severity: "medium",
|
|
283
|
+
re: /\b(?:req\.(?:body|query|params)|payload)\.(?:amount|price|total|quantity|fee|qty|subtotal|priceCents)\b/g,
|
|
284
|
+
describe: () => "money field read straight off the request without a Number/NaN/finite/range check — strings and negatives reach arithmetic and the ledger",
|
|
285
|
+
fix: 'validate money as integers of the smallest unit: const v = Number(req.body.amount); if (!Number.isSafeInteger(v) || v <= 0) return 400',
|
|
286
|
+
accept: (m, content) => {
|
|
287
|
+
// Already wrapped in a sanitizer (Number/parseInt/parseFloat)? The fix
|
|
288
|
+
// is applied — don't re-flag the same field.
|
|
289
|
+
const before = content.slice(Math.max(0, m.index - 40), m.index);
|
|
290
|
+
if (/\b(?:Number|parseFloat|parseInt)\s*\($/.test(before))
|
|
291
|
+
return false;
|
|
292
|
+
const lineStart = content.lastIndexOf("\n", m.index);
|
|
293
|
+
const after = content.slice(m.index, content.indexOf("\n", m.index) === -1 ? content.length : content.indexOf("\n", m.index));
|
|
294
|
+
if (/\b(?:isSafeInteger|isFinite|isNaN)\b/.test(after) || /(?:\|\||&&)\s*[^;]{0,40}\b(?:return|throw)/.test(after))
|
|
295
|
+
return false;
|
|
296
|
+
return true;
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
/* ---------------------- Code injection ----------------------- */
|
|
300
|
+
{
|
|
301
|
+
category: "input-validation",
|
|
302
|
+
severity: "high",
|
|
303
|
+
re: /\beval\s*\([^)]{0,120}\$\{|new\s+Function\s*\([^)]{0,100}(?:\$\{|["'`]\s*\+)/g,
|
|
304
|
+
describe: () => "eval or dynamic Function construction with interpolated content — arbitrary code execution if any part is user-controlled",
|
|
305
|
+
fix: "never eval: JSON.parse for data, and precompiled functions for logic — eval(anything) is RCE by default",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
category: "input-validation",
|
|
309
|
+
severity: "medium",
|
|
310
|
+
re: /\beval\s*\(/g,
|
|
311
|
+
describe: () => "eval() call — review that its argument can never be user-controlled; even then it is a code smell",
|
|
312
|
+
fix: "replace eval with JSON.parse / precompiled functions; if you truly must eval, allowlist the exact inputs",
|
|
313
|
+
},
|
|
314
|
+
/* -------------------- Prototype pollution -------------------- */
|
|
315
|
+
{
|
|
316
|
+
category: "prototype-pollution",
|
|
317
|
+
severity: "medium",
|
|
318
|
+
re: /\bObject\.assign\s*\(\s*[^)]{0,80}(?:req\.body|req\.query|req\.params|userInput|payload)[^)]{0,40}\)|\.\.\.\s*(?:req\.body|req\.query|req\.params|userInput|payload)[^)\n]{0,40}/g,
|
|
319
|
+
describe: () => "user input merged into objects — a crafted __proto__ key rewrites Object.prototype and breaks auth checks app-wide",
|
|
320
|
+
fix: 'never merge/spread raw request bodies: pick only whitelisted keys, or JSON.parse(body, (k, v) => (k === "__proto__" ? undefined : v))',
|
|
321
|
+
},
|
|
322
|
+
/* ----------------------- Config & transport ------------------ */
|
|
323
|
+
{
|
|
324
|
+
category: "cors",
|
|
325
|
+
severity: "high",
|
|
326
|
+
re: /[\s\S]{0,160}?\borigin\s*:\s*["']\*["'][\s\S]{0,160}?\bcredentials\s*:\s*true[\s\S]{0,160}?\)|Access-Control-Allow-Origin\s*:\s*\*[\s\S]{0,120}?Access-Control-Allow-Credentials\s*:\s*true/g,
|
|
327
|
+
describe: () => "Access-Control-Allow-Origin: * combined with credentials — any website can make authenticated requests from a victim's browser",
|
|
328
|
+
fix: 'allowlist the exact frontend origin(s): cors({ origin: ["https://app.example.com"], credentials: true }) — never * with cookies',
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
category: "transport",
|
|
332
|
+
severity: "medium",
|
|
333
|
+
re: /\b(?:fetch|axios\.(?:get|post|put|patch)|got|request|http\.(?:get|post|request))\s*\(\s*["']http:\/\//g,
|
|
334
|
+
describe: () => "outbound call uses cleartext http:// — credentials and data travel unencrypted, MITM-able",
|
|
335
|
+
fix: "use https:// for every outbound call — http only for localhost dev sandboxes",
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
category: "transport",
|
|
339
|
+
severity: "medium",
|
|
340
|
+
re: /\bres\.cookie\s*\([^)]{0,160}?\bsecure\s*:\s*false/g,
|
|
341
|
+
describe: () => "session cookie sent without the secure flag — leaked in cleartext on any http page",
|
|
342
|
+
fix: 'res.cookie("session", token, { httpOnly: true, secure: true, sameSite: "lax" })',
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
category: "transport",
|
|
346
|
+
severity: "low",
|
|
347
|
+
re: /\bws:\/\//g,
|
|
348
|
+
describe: () => "plain ws:// websocket — unencrypted traffic",
|
|
349
|
+
fix: "use wss:// (TLS) for websockets in production",
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
category: "transport",
|
|
353
|
+
severity: "low",
|
|
354
|
+
re: /\bres\.redirect\s*\(\s*["']http:\/\//g,
|
|
355
|
+
describe: () => "redirect target is hardcoded to cleartext http",
|
|
356
|
+
fix: "redirect to https:// URLs",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
category: "logging",
|
|
360
|
+
severity: "medium",
|
|
361
|
+
re: /\bconsole\.(?:log|info|debug|warn)\s*\([\s\S]{0,120}?(?:password|passwd|pwd|api[_-]?key|secret|token|authorization)[\s\S]{0,40}?\)/g,
|
|
362
|
+
describe: () => "credentials reach the console/log pipeline — they end up in CI logs and support tickets",
|
|
363
|
+
fix: 'never log credentials: log identifiers only, or pass values through a redact({ password: "***" }) helper',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
category: "logging",
|
|
367
|
+
severity: "medium",
|
|
368
|
+
re: /\b(?:res|response)\.(?:send|json|write|end)\s*\(\s*[\s\S]{0,80}?\berr\b[\s\S]{0,60}?\bstack\b|\bstack\b[\s\S]{0,40}?\b(?:send|json)\s*\(/g,
|
|
369
|
+
describe: () => "error.stack is shipped to the client — file paths, library versions and internals leak to attackers",
|
|
370
|
+
fix: 'log the stack server-side; respond with { error: "internal", id } and keep a correlation id',
|
|
371
|
+
},
|
|
372
|
+
/* ---------------------- Rate limiting ----------------------- */
|
|
373
|
+
{
|
|
374
|
+
category: "rate-limiting",
|
|
375
|
+
severity: "medium",
|
|
376
|
+
re: /\b(?:rateLimit|rate-limit|limiter)\w*\s*\(\s*\{[^}]{0,300}?\bmax\s*:\s*(\d{3,})\b/g,
|
|
377
|
+
describe: (m) => `rate limiter allows ${m[1]} requests per window — that's a decoration, not a defense: brute-force and abuse still fit through`,
|
|
378
|
+
fix: "tighten to 5–10 per window per IP+account on auth and state-changing routes (express-rate-limit: windowMs: 60_000, max: 5)",
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
category: "rate-limiting",
|
|
382
|
+
severity: "low",
|
|
383
|
+
re: /\bwindowMs\s*:\s*0\b|\bmax\s*:\s*0\b/g,
|
|
384
|
+
describe: () => "rate limiter configured with a zero window or zero max — effectively disabled middleware",
|
|
385
|
+
fix: "give the limiter a real window and max (5–10 per minute), or delete it until you can",
|
|
386
|
+
},
|
|
387
|
+
/* --------------------- Database lockdown -------------------- */
|
|
388
|
+
{
|
|
389
|
+
category: "database",
|
|
390
|
+
severity: "high",
|
|
391
|
+
re: /\b(?:DATABASE_URL|DB_URL|CONNECTION_STRING|connectionString|connection_?string|dsn|db_?uri|mongo(?:db)?_?uri|jdbc_?url|MYSQL_URL|PG_URL)\s*[:=]\s*["'][a-z0-9+]+:\/\/\s*(postgres|root|sa|admin|superuser):([^@\s/]{0,40})@/gi,
|
|
392
|
+
describe: (m) => `connection string uses the privileged account ${m[1]} with a committed password — every database in the fleet shares one superuser credential`,
|
|
393
|
+
fix: 'create a scoped role with only the privileges the app needs (SELECT/INSERT/UPDATE/DELETE, no DDL), a strong generated password in .env, and connect as it: postgres://app_user:${process.env.DB_PASSWORD}@db.internal:5432/app',
|
|
394
|
+
accept: (m) => Boolean(m[2]),
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
category: "database",
|
|
398
|
+
severity: "high",
|
|
399
|
+
re: /\bGRANT\s+ALL\s+PRIVILEGES\b|(?:ALTER|CREATE)\s+(?:USER|ROLE)\b[^;\n]{0,80}\b(?:SUPERUSER|SYSADMIN|DBA)\b/gi,
|
|
400
|
+
describe: () => "database grants hand out ALL PRIVILEGES / SUPERUSER — any compromise of this app is now a compromise of every database",
|
|
401
|
+
fix: "grant only what the app needs: GRANT SELECT, INSERT, UPDATE, DELETE ON <tables> TO app_user; run migrations with a separate CI-only credential",
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
category: "database",
|
|
405
|
+
severity: "medium",
|
|
406
|
+
re: /\bsslmode\s*[:=]\s*["']?(?:disable|allow)["']?|\bssl\s*:\s*false\b|\buseSSL\s*:\s*false\b|\bencrypt\s*:\s*false\b|\btls\s*:\s*false\b/gi,
|
|
407
|
+
describe: () => "database connection without enforced TLS — queries, rows and credentials travel cleartext",
|
|
408
|
+
fix: 'require TLS on every connection: sslmode=require (or ssl: { rejectUnauthorized: true }); disable only for a localhost dev sandbox',
|
|
409
|
+
accept: (_m, content) => {
|
|
410
|
+
const before = content.slice(Math.max(0, _m.index - 300), _m.index);
|
|
411
|
+
return !/(?:localhost|127\.0\.0\.1|192\.168\.)/.test(before);
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
category: "database",
|
|
416
|
+
severity: "high",
|
|
417
|
+
re: /\b(?:db[_-]?password|database[_-]?password|pg[_-]?password|mysql[_-]?password|mongo(?:db)?[_-]?password|redis[_-]?password|jdbc[^;"]{0,30}password)\s*[:=]\s*["']([^"']{3,})["']/gi,
|
|
418
|
+
describe: (m) => `database password hardcoded in config (${m[1].length} chars) — every clone of the repo holds the key to the database`,
|
|
419
|
+
fix: "rotate it now, then read from the environment: password: process.env.DB_PASSWORD with DB_PASSWORD in .env (gitignored) and the CI secret store",
|
|
420
|
+
},
|
|
421
|
+
/* ---------------------- Data exposure ----------------------- */
|
|
422
|
+
{
|
|
423
|
+
category: "data-exposure",
|
|
424
|
+
severity: "high",
|
|
425
|
+
re: /\b(?:res|response)\.(?:json|send)\s*\(\s*\{[^{}]{0,200}\b(?:password|passwd|hash|apiKey|api_key|client_secret|secret|cardNumber|card_number|ssn|cvv|iban)\b[^{}]{0,40}\}/g,
|
|
426
|
+
describe: () => "credentials or PII in the API response body — the client never needed them, and now every log, XSS and third-party script can read them",
|
|
427
|
+
fix: 'return only what the UI needs: res.json({ id: user.id, name: user.name }) — never password/hash/apiKey; or a toJSON() that strips secrets',
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
category: "data-exposure",
|
|
431
|
+
severity: "medium",
|
|
432
|
+
re: /\b(?:res|response)\.(?:json|send)\s*\(\s*(user|users|account|accounts|profile|customer|order|orders)\b[\s\S]{0,120}/g,
|
|
433
|
+
describe: (m) => `full ${m[1]} object(s) returned to the client — if that's a DB row, password/hash/internal fields ride along`,
|
|
434
|
+
fix: "strip before sending: const { password, hash, ...safe } = user; res.json(safe) — or map explicit fields",
|
|
435
|
+
accept: (m) => !/\b(?:toJSON|pick|omit|select|strip|safeUser|sanitize)\b/.test(m[0]),
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
category: "data-exposure",
|
|
439
|
+
severity: "medium",
|
|
440
|
+
re: /\bconsole\.(?:log|info|debug|warn)\s*\([\s\S]{0,120}?(?:cardNumber|card_number|ssn|nationalId|bankAccount|iban|cvv|creditCard|passportNumber|dateOfBirth)\b[\s\S]{0,40}?\)/g,
|
|
441
|
+
describe: () => "PII (card/SSN/IBAN…) reaches the log pipeline — a data breach becomes searchable in log storage",
|
|
442
|
+
fix: "log a masked token, never the value: console.log({ cardLast4: card.slice(-4) })",
|
|
443
|
+
accept: (m) => !/\b(?:last4|last_?4|mask|redact|slice\(\s*-4\s*\)|\*{3})/i.test(m[0]),
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
category: "data-exposure",
|
|
447
|
+
severity: "medium",
|
|
448
|
+
re: /\bSELECT\s+\*\s+FROM\b/gi,
|
|
449
|
+
describe: () => "SELECT * — every column (password, hash, internal flags) ships to every caller; one careless response leaks the whole row",
|
|
450
|
+
fix: "name the columns you need: SELECT id, email, name FROM users — narrower columns, narrower blast radius",
|
|
451
|
+
},
|
|
452
|
+
/* ------------------ Hidden vulnerabilities ------------------ */
|
|
453
|
+
{
|
|
454
|
+
category: "hidden-vulnerabilities",
|
|
455
|
+
severity: "high",
|
|
456
|
+
re: /\brejectUnauthorized\s*:\s*false\b|\bNODE_TLS_REJECT_UNAUTHORIZED\s*[:=]\s*["']?0["']?\b|\bcurl\s+[^;\n]{0,60}-[kK]\b|\bwget\s+[^;\n]{0,60}--no-check-certificate\b/g,
|
|
457
|
+
describe: () => "TLS verification silently disabled — every 'secure' connection is MITM-able and nothing will ever complain",
|
|
458
|
+
fix: "remove the override: rejectUnauthorized: true (default), drop NODE_TLS_REJECT_UNAUTHORIZED, never curl -k in scripts — pin the CA if it's a private cert",
|
|
459
|
+
accept: (_m, content) => {
|
|
460
|
+
const before = content.slice(Math.max(0, _m.index - 300), _m.index);
|
|
461
|
+
return !/(?:localhost|127\.0\.0\.1|192\.168\.)/.test(before);
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
category: "hidden-vulnerabilities",
|
|
466
|
+
severity: "high",
|
|
467
|
+
re: /\b(?:algorithms?|alg)\s*[:=]\s*\[?\s*["']none["']\s*\]?/gi,
|
|
468
|
+
describe: () => "JWT accepts alg:none — a forged unsigned token validates as a real session",
|
|
469
|
+
fix: "verify with an explicit allowlist: jwt.verify(token, secret, { algorithms: ['HS256'] }) — 'none' is never an option",
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
category: "hidden-vulnerabilities",
|
|
473
|
+
severity: "medium",
|
|
474
|
+
re: /\beval\s*\(\s*(?:atob|Buffer\.from|btoa|decodeURIComponent|unescape)/g,
|
|
475
|
+
describe: () => "encoded payload decoded straight into eval — obfuscation exists to hide the logic from review",
|
|
476
|
+
fix: "delete the decoder-eval chain and replace the payload with the actual logic, plainly; if it's an external artifact, pin and hash it",
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
category: "hidden-vulnerabilities",
|
|
480
|
+
severity: "medium",
|
|
481
|
+
re: /\/\/\s*(?:TODO|FIXME|HACK|XXX|BUG)\b[^\n]{0,120}?(?:secur|auth|password|token|bypass|insecure|vuln|backdoor|ssl|encrypt|key|secret|csrf)/gi,
|
|
482
|
+
describe: () => "a comment acknowledges an unfinished security gap (TODO/FIXME/HACK) — 'we'll fix it later' is how breaches ship",
|
|
483
|
+
fix: "resolve it now or track it as a blocking ticket with an owner; security TODOs never make a release",
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
category: "hidden-vulnerabilities",
|
|
487
|
+
severity: "medium",
|
|
488
|
+
re: /\b(?:eslint-disable(?:-next-line|-line)?|@ts-ignore|@ts-nocheck|@ts-expect-error)\b[^\n]{0,80}?(?:password|token|secret|auth|sql|eval|innerHTML|key|cookie)\b/gi,
|
|
489
|
+
describe: () => "lint/type checks bypassed on security-sensitive code — the guardrail was switched off right where it mattered",
|
|
490
|
+
fix: "fix the underlying issue instead of suppressing it; a suppression comment hides the danger, it doesn't remove it",
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
category: "hidden-vulnerabilities",
|
|
494
|
+
severity: "medium",
|
|
495
|
+
re: /\blocalStorage\.(?:setItem|getItem)\s*\(\s*["'][^"']{0,40}(?:token|jwt|auth|session|refresh|access)[^"']{0,20}["']/gi,
|
|
496
|
+
describe: () => "session/access token kept in localStorage — readable by any XSS, exfiltrated by the next compromised script tag",
|
|
497
|
+
fix: "httpOnly + secure + SameSite cookie for the session; never localStorage for credentials",
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
/**
|
|
501
|
+
* Repo-level findings that only make sense over the whole tree (e.g. "no
|
|
502
|
+
* rate limiter anywhere"). Cheap to compute, always honest about being
|
|
503
|
+
* indicated.
|
|
504
|
+
*/
|
|
505
|
+
export function analyzeSecurityRepoLevel(repo) {
|
|
506
|
+
const issues = [];
|
|
507
|
+
const text = [];
|
|
508
|
+
for (const f of walkTextFiles(repo)) {
|
|
509
|
+
const content = readText(f);
|
|
510
|
+
if (content)
|
|
511
|
+
text.push(content);
|
|
512
|
+
}
|
|
513
|
+
const all = text.join("\n");
|
|
514
|
+
const hasAuthFlow = /\b(?:login|signup|register|signin|sign_in|createAccount|forgotPassword|resetPassword|otp)\b/i.test(all);
|
|
515
|
+
const hasPasswordFlow = /\b(?:password|passwd|pwd)\b/i.test(all);
|
|
516
|
+
if (hasAuthFlow &&
|
|
517
|
+
hasPasswordFlow &&
|
|
518
|
+
!/\b(?:bcrypt|argon2|scrypt|pbkdf2|hashSync|\.hash\s*\()/i.test(all)) {
|
|
519
|
+
issues.push({
|
|
520
|
+
type: "code",
|
|
521
|
+
category: "authentication",
|
|
522
|
+
severity: "medium",
|
|
523
|
+
description: "[indicated] auth + password flow present, but no bcrypt/argon2/scrypt/pbkdf2 anywhere — passwords are handled without a key-derivation function",
|
|
524
|
+
fix: "hash on write, compare on read: bcrypt.hash(password, 12) / bcrypt.compare(input, stored)",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
if (hasAuthFlow &&
|
|
528
|
+
!/\b(?:rateLimit|rate-limit|express-rate-limit|limiter|throttle)\b/i.test(all)) {
|
|
529
|
+
issues.push({
|
|
530
|
+
type: "code",
|
|
531
|
+
category: "authentication",
|
|
532
|
+
severity: "low",
|
|
533
|
+
description: "[indicated] auth endpoints present but no rate limiter appears anywhere — credential stuffing and OTP brute-force are wide open",
|
|
534
|
+
fix: "express-rate-limit on every auth endpoint: 5 req/min per IP+user on /login, /otp, /reset",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const hasCookieSession = /express-session|express\.session|cookie[_-]?session|cookieSession/i.test(all);
|
|
538
|
+
const hasStateChanging = /\b(?:app|router)\.(?:post|put|delete|patch)\s*\(/g.test(all);
|
|
539
|
+
if (hasCookieSession && hasStateChanging && !/\b(?:csrf|xsrf|csrfToken|csrf-sync|csurf)\b/i.test(all)) {
|
|
540
|
+
issues.push({
|
|
541
|
+
type: "code",
|
|
542
|
+
category: "csrf",
|
|
543
|
+
severity: "medium",
|
|
544
|
+
description: "[indicated] cookie-based sessions with state-changing routes and no CSRF token anywhere — a victim's browser can be tricked into POSTing",
|
|
545
|
+
fix: "add a CSRF token to every state-changing request: csrf-sync middleware, token in a form header, verify on POST/PUT/DELETE",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (/\bexpress\s*\(\s*\)|\bfastify\s*\(\s*\)/g.test(all) &&
|
|
549
|
+
!/\bhelmet\b/i.test(all)) {
|
|
550
|
+
issues.push({
|
|
551
|
+
type: "code",
|
|
552
|
+
category: "headers",
|
|
553
|
+
severity: "low",
|
|
554
|
+
description: "[indicated] web framework present but helmet (CSP, HSTS, X-Content-Type-Options, frameguard) is never applied",
|
|
555
|
+
fix: "app.use(helmet()) — one line for CSP, HSTS, X-Frame-Options, X-Content-Type-Options and more",
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (hasStateChanging && !/\b(?:rateLimit|rate-limit|express-rate-limit|limiter|throttle)\b/i.test(all)) {
|
|
559
|
+
issues.push({
|
|
560
|
+
type: "code",
|
|
561
|
+
category: "rate-limiting",
|
|
562
|
+
severity: "medium",
|
|
563
|
+
description: "[indicated] state-changing endpoints exist but no rate limiter appears anywhere — scripted abuse, brute-force and spam are wide open",
|
|
564
|
+
fix: "express-rate-limit on auth and every state-changing route: 5–10 req/min per IP+account; tune the window for legit bursts",
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const hasSqlLayer = /\b(?:CREATE\s+TABLE|ALTER\s+TABLE|SELECT\s+\w+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|\.findMany\s*\(|\.findAll\s*\(|queryRaw|\.query\s*\()/i.test(all);
|
|
568
|
+
const hasRoutes = /\b(?:app|router)\.(?:get|post|put|delete|patch)\s*\(/g.test(all);
|
|
569
|
+
if (hasSqlLayer &&
|
|
570
|
+
hasRoutes &&
|
|
571
|
+
!/\b(?:ROW\s+LEVEL\s+SECURITY|ENABLE\s+ROW|CREATE\s+POLICY|FORCE\s+ROW\s+LEVEL|security_invoker)\b/i.test(all)) {
|
|
572
|
+
issues.push({
|
|
573
|
+
type: "code",
|
|
574
|
+
category: "database",
|
|
575
|
+
severity: "medium",
|
|
576
|
+
description: "[indicated] backend with direct DB access but no row-level security — tenant isolation is one missing WHERE clause away from leaking everyone's data",
|
|
577
|
+
fix: 'enable RLS on every table and add policies: ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting("app.tenant_id")); set app.tenant_id once per request',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return issues;
|
|
581
|
+
}
|
|
582
|
+
function walkTextFiles(root) {
|
|
583
|
+
const out = [];
|
|
584
|
+
const stack = [root];
|
|
585
|
+
while (stack.length && out.length < MAX_FILES) {
|
|
586
|
+
const dir = stack.pop();
|
|
587
|
+
let entries;
|
|
588
|
+
try {
|
|
589
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
for (const e of entries) {
|
|
595
|
+
const p = path.join(dir, e.name);
|
|
596
|
+
if (e.isDirectory()) {
|
|
597
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith("."))
|
|
598
|
+
continue;
|
|
599
|
+
stack.push(p);
|
|
600
|
+
}
|
|
601
|
+
else if (e.isFile()) {
|
|
602
|
+
if (e.name === ".gitignore" ||
|
|
603
|
+
e.name.startsWith(".env") ||
|
|
604
|
+
CODE_EXTS.has(path.extname(e.name)) ||
|
|
605
|
+
/(?:\.(?:bak|old|orig|swp)$|~$|^\.htpasswd$)/.test(e.name))
|
|
606
|
+
out.push(p);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return out.sort();
|
|
611
|
+
}
|
|
612
|
+
function readText(file) {
|
|
613
|
+
try {
|
|
614
|
+
const st = fs.statSync(file);
|
|
615
|
+
if (st.size > MAX_BYTES_PER_FILE)
|
|
616
|
+
return null;
|
|
617
|
+
const buf = fs.readFileSync(file);
|
|
618
|
+
if (buf.includes(0))
|
|
619
|
+
return null; // binary
|
|
620
|
+
return buf.toString("utf8");
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function fileGitignoresEnv(root) {
|
|
627
|
+
const stack = [root];
|
|
628
|
+
while (stack.length) {
|
|
629
|
+
const dir = stack.pop();
|
|
630
|
+
let entries;
|
|
631
|
+
try {
|
|
632
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
for (const e of entries) {
|
|
638
|
+
if (e.isDirectory()) {
|
|
639
|
+
if (SKIP_DIRS.has(e.name))
|
|
640
|
+
continue;
|
|
641
|
+
stack.push(path.join(dir, e.name));
|
|
642
|
+
}
|
|
643
|
+
else if (e.isFile() && e.name === ".gitignore") {
|
|
644
|
+
const gi = readText(path.join(dir, e.name)) ?? "";
|
|
645
|
+
if (/(?:^|\n)\s*\.env(?:\$|(?:\.|\s|$))/m.test(gi))
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Scan a repo for the static vulnerability classes. Deterministic: files are
|
|
654
|
+
* walked in sorted order, findings are ordered by (severity, file, line),
|
|
655
|
+
* deduped by (category, file, line), capped. All findings are labeled
|
|
656
|
+
* `[indicated]` — static analysis points, it does not prove.
|
|
657
|
+
*/
|
|
658
|
+
export function analyzeSecurityStatic(repo) {
|
|
659
|
+
const issues = [];
|
|
660
|
+
const seen = new Set();
|
|
661
|
+
const push = (file, content, m, rule) => {
|
|
662
|
+
// De-noise: never flag documentation or rule metadata. Lines that are
|
|
663
|
+
// comments, quote a fix ("fix: …"), describe a rule ("describe: …"), or
|
|
664
|
+
// contain regex-alternation syntax ("(?:") are how scanners and docs talk
|
|
665
|
+
// ABOUT these bugs — not app code containing them.
|
|
666
|
+
const lineStart = content.lastIndexOf("\n", m.index) + 1;
|
|
667
|
+
const lineEnd = content.indexOf("\n", m.index);
|
|
668
|
+
const lineText = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
|
669
|
+
// Comment lines are skipped EXCEPT for the hidden-vulnerabilities rules —
|
|
670
|
+
// a "TODO: fix insecure auth" comment IS the finding there.
|
|
671
|
+
if (/^(?:\/\/|\/\*|\*|#|<!--|"""|''')/.test(lineText) &&
|
|
672
|
+
rule.category !== "hidden-vulnerabilities")
|
|
673
|
+
return;
|
|
674
|
+
if (/\b(?:fix:|describe:|title:|re:)/.test(lineText) || /\(\?:/.test(lineText))
|
|
675
|
+
return;
|
|
676
|
+
// Multi-line metadata strings: the marker sits on the previous line
|
|
677
|
+
// ("describe: () =>" then the quoted text on the next line).
|
|
678
|
+
const prevStart = content.lastIndexOf("\n", lineStart - 2) + 1;
|
|
679
|
+
const prevLine = content.slice(prevStart, lineStart - 1).trim();
|
|
680
|
+
if (/\b(?:describe|fix|title|re)\s*:/.test(prevLine))
|
|
681
|
+
return;
|
|
682
|
+
// Same-line adjacency false positive: `something.exec(fn) + \`…${…}\`` —
|
|
683
|
+
// a closing paren BEFORE the interpolated/concatenated part means the
|
|
684
|
+
// danger isn't the SQL/shell call on this line.
|
|
685
|
+
if (/\)[\s\S]{0,200}?(?:\$\{|["'`]\s*\+)/.test(m[0]))
|
|
686
|
+
return;
|
|
687
|
+
const rel = path.relative(repo, file);
|
|
688
|
+
const line = lineOf(content, m.index);
|
|
689
|
+
const key = `${rule.category}|${rel}|${line}`;
|
|
690
|
+
if (seen.has(key))
|
|
691
|
+
return;
|
|
692
|
+
seen.add(key);
|
|
693
|
+
issues.push({
|
|
694
|
+
type: "code",
|
|
695
|
+
category: rule.category,
|
|
696
|
+
severity: rule.severity,
|
|
697
|
+
file: rel,
|
|
698
|
+
line,
|
|
699
|
+
description: `[indicated] ${rule.describe(m)}`,
|
|
700
|
+
fix: rule.fix,
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
const pushNamed = (file, category, severity, description, fix) => {
|
|
704
|
+
const rel = path.relative(repo, file);
|
|
705
|
+
const key = `${category}|${rel}|1`;
|
|
706
|
+
if (seen.has(key))
|
|
707
|
+
return;
|
|
708
|
+
seen.add(key);
|
|
709
|
+
issues.push({ type: "code", category, severity, file: rel, line: 1, description, fix });
|
|
710
|
+
};
|
|
711
|
+
for (const file of walkTextFiles(repo)) {
|
|
712
|
+
const content = readText(file);
|
|
713
|
+
if (!content)
|
|
714
|
+
continue;
|
|
715
|
+
// .env files are where secrets belong — the dedicated .env check below
|
|
716
|
+
// (gitignore protection) is the rule that governs them, not the
|
|
717
|
+
// hardcoded-secret patterns.
|
|
718
|
+
if (path.basename(file).startsWith(".env"))
|
|
719
|
+
continue;
|
|
720
|
+
const name = path.basename(file);
|
|
721
|
+
if (/\.min\.js$/.test(name)) {
|
|
722
|
+
pushNamed(file, "hidden-vulnerabilities", "low", "[indicated] minified bundle committed — logic hidden from review; it can silently carry secrets or backdoor payloads", "commit the source and build the bundle at release time; if a bundle must ship, keep its sourcemap and sign it");
|
|
723
|
+
}
|
|
724
|
+
if (/(?:\.(?:bak|old|orig|swp)$|~$)/.test(name)) {
|
|
725
|
+
pushNamed(file, "hidden-vulnerabilities", "medium", "[indicated] backup/editor file committed — old snapshots commonly contain earlier (secret-bearing) versions of the code", "delete it and add *.bak, *.old, *.orig, *.swp, *~ to .gitignore");
|
|
726
|
+
}
|
|
727
|
+
if (name === ".htpasswd") {
|
|
728
|
+
pushNamed(file, "hidden-vulnerabilities", "medium", "[indicated] .htpasswd credential file committed — a password store living in the repository", "rotate the passwords, move auth to the app layer or a secret manager, and delete the file");
|
|
729
|
+
}
|
|
730
|
+
for (const rule of STATIC_SECURITY_RULES) {
|
|
731
|
+
rule.re.lastIndex = 0;
|
|
732
|
+
let m;
|
|
733
|
+
let count = 0;
|
|
734
|
+
while ((m = rule.re.exec(content)) !== null && count < 3) {
|
|
735
|
+
if (rule.skipWhenInMatch && rule.skipWhenInMatch.test(m[0])) {
|
|
736
|
+
count++;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (rule.accept && !rule.accept(m, content)) {
|
|
740
|
+
count++;
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (rule.pathFilter) {
|
|
744
|
+
const pathMatch = /\(\s*["']([^"']+)["']/.exec(m[0]);
|
|
745
|
+
if (!pathMatch || !rule.pathFilter.test(pathMatch[1])) {
|
|
746
|
+
count++;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (rule.guard && rule.guard.test(m[0])) {
|
|
750
|
+
count++;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
push(file, content, m, rule);
|
|
755
|
+
count++;
|
|
756
|
+
if (m[0].length === 0)
|
|
757
|
+
rule.re.lastIndex++;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// .env committed risk (only when a .env file actually exists).
|
|
762
|
+
for (const file of walkTextFiles(repo)) {
|
|
763
|
+
if (!/^\.env(\.[\w-]+)?$/.test(path.basename(file)))
|
|
764
|
+
continue;
|
|
765
|
+
if (!fileGitignoresEnv(repo)) {
|
|
766
|
+
issues.push({
|
|
767
|
+
type: "code",
|
|
768
|
+
category: "secret",
|
|
769
|
+
severity: "medium",
|
|
770
|
+
file: path.relative(repo, file),
|
|
771
|
+
line: 1,
|
|
772
|
+
description: "[indicated] .env exists but nothing in .gitignore protects it — one careless commit publishes every secret",
|
|
773
|
+
fix: 'add ".env" and ".env.*" (keep ".env.example") to .gitignore, then confirm with git status',
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
issues.push(...analyzeSecurityRepoLevel(repo));
|
|
779
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
780
|
+
return issues
|
|
781
|
+
.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3) ||
|
|
782
|
+
(a.file ?? "").localeCompare(b.file ?? "") ||
|
|
783
|
+
(a.line ?? 0) - (b.line ?? 0))
|
|
784
|
+
.slice(0, MAX_FINDINGS);
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=securityStatic.js.map
|