launchprep 0.0.1 → 0.1.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.
@@ -0,0 +1,272 @@
1
+ // Deployment and transport. Mechanically detectable, high volume.
2
+ const finding = (id, title, severity, file, line, detail, fix) =>
3
+ ({ id, title, severity, file, line, detail, fix });
4
+ const lineOf = (t, i) => t.slice(0, i).split('\n').length;
5
+
6
+ export const DEPLOY_CHECKS = [
7
+
8
+ // ---- a credential pasted into a CI workflow ------------------------------
9
+ { id: 'DEP-002', run(repo, profile) {
10
+ if (!profile.has_ci) return [];
11
+ const out = [];
12
+ const SECRET_VALUE = /(sk-ant-[\w-]{15,}|sk-[A-Za-z0-9]{24,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[\w-]{10,}|postgres(?:ql)?:\/\/[^\s"']*:[^\s"'@]+@)/;
13
+ for (const f of repo.files) {
14
+ if (!f.text || !/\.(github|gitlab|circleci)|\.ya?ml$/.test(f.path)) continue;
15
+ if (!/workflows?\/|\.gitlab-ci|circleci/.test(f.path)) continue;
16
+ f.text.split('\n').forEach((line, i) => {
17
+ if (/\$\{\{\s*secrets\./.test(line)) return; // the correct pattern
18
+ const m = line.match(SECRET_VALUE);
19
+ if (m) out.push(finding('DEP-002', 'Credential written into the CI config', 'critical',
20
+ f.path, i + 1,
21
+ 'This value is committed to the repository and printed in build logs. Anyone with read access to either has it.',
22
+ 'Move it to encrypted CI secrets, reference it as ${{ secrets.NAME }}, and rotate the current value.'));
23
+ });
24
+ }
25
+ return out;
26
+ }},
27
+
28
+ // ---- verbose errors left on in production --------------------------------
29
+ { id: 'DEP-015', run(repo, profile) {
30
+ if (profile.stage !== 'production') return [];
31
+ const out = [];
32
+ for (const f of repo.files) {
33
+ if (!f.text) continue;
34
+ if (!/(next\.config|nuxt\.config|vite\.config|settings\.py|config\/environments?\/production|app\.py|main\.py)/.test(f.path)) continue;
35
+ const re = /\bDEBUG\s*[:=]\s*True\b|\bdebug\s*:\s*true\b|productionBrowserSourceMaps\s*:\s*true/g;
36
+ let m;
37
+ while ((m = re.exec(f.text))) {
38
+ const isSourcemap = /SourceMaps/.test(m[0]);
39
+ out.push(finding(isSourcemap ? 'DEP-014' : 'DEP-015',
40
+ isSourcemap ? 'Source maps published in production' : 'Debug mode enabled in production',
41
+ 'high', f.path, lineOf(f.text, m.index),
42
+ isSourcemap
43
+ ? 'Source maps let anyone reconstruct your original source from the deployed bundle, including comments and internal logic.'
44
+ : 'Debug mode returns stack traces, settings and query fragments to whoever triggers an error.',
45
+ isSourcemap ? 'Disable source maps for production builds.' : 'Set debug off in the production configuration.'));
46
+ }
47
+ }
48
+ return out;
49
+ }},
50
+
51
+ // ---- third-party script with no integrity check --------------------------
52
+ { id: 'DEP-013', run(repo, profile) {
53
+ if (!profile.is_public || !['web-app', 'web-site'].includes(profile.surface)) return [];
54
+ const out = [];
55
+ for (const f of repo.files) {
56
+ if (!f.text || !/\.(html|tsx|jsx|vue|svelte|ejs|hbs)$/.test(f.path)) continue;
57
+ const re = /<script[^>]+src=["']https?:\/\/[^"']+["'][^>]*>/g;
58
+ let m;
59
+ while ((m = re.exec(f.text))) {
60
+ if (/integrity=/.test(m[0])) continue;
61
+ if (/(googletagmanager|google-analytics|gtag\/js)/.test(m[0])) continue; // no stable hash published
62
+ out.push(finding('DEP-013', 'Third-party script loaded without an integrity check', 'medium',
63
+ f.path, lineOf(f.text, m.index),
64
+ 'If that host is ever compromised, the replacement script runs with full access to your page and your users\' sessions.',
65
+ 'Add an integrity="sha384-..." attribute, or self-host the file.'));
66
+ }
67
+ }
68
+ return out;
69
+ }},
70
+
71
+ // ---- CORS open to everyone -----------------------------------------------
72
+ { id: 'INF-007', run(repo, profile) {
73
+ if (!['web-app', 'api-only'].includes(profile.surface)) return [];
74
+ const out = [];
75
+ for (const f of repo.files) {
76
+ if (!f.text || !/\.(ts|js|mjs|py)$/.test(f.path)) continue;
77
+ const re = /Access-Control-Allow-Origin["']?\s*[:,]\s*["']\*["']|cors\s*\(\s*\{[^}]{0,200}origin\s*:\s*["']\*["']|allow_origins\s*=\s*\[\s*["']\*["']/g;
78
+ let m;
79
+ while ((m = re.exec(f.text))) {
80
+ const around = f.text.slice(Math.max(0, m.index - 300), m.index + 300);
81
+ const credentialed = /credentials\s*:\s*true|allow_credentials\s*=\s*True|withCredentials/.test(around);
82
+ out.push(finding('INF-007',
83
+ credentialed ? 'CORS allows any origin with credentials' : 'CORS allows any origin',
84
+ credentialed ? 'critical' : 'medium',
85
+ f.path, lineOf(f.text, m.index),
86
+ credentialed
87
+ ? 'Any website can call your API with the visitor\'s cookies attached and read the response. That is a cross-site account takeover.'
88
+ : 'Any website can call this API from a browser. Fine for a genuinely public API, wrong for anything user-specific.',
89
+ 'List the origins you actually serve instead of "*".'));
90
+ }
91
+ }
92
+ return out;
93
+ }},
94
+
95
+ // ---- redirect target taken from the URL ----------------------------------
96
+ { id: 'API-013', run(repo, profile) {
97
+ if (!profile.is_public) return [];
98
+ const out = [];
99
+ for (const f of repo.files) {
100
+ if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
101
+ const re = /redirect\s*\(\s*(?:req\.query\.\w+|searchParams\.get\(['"][^'"]+['"]\)|params\.\w*(?:url|next|return|redirect)\w*)/gi;
102
+ let m;
103
+ while ((m = re.exec(f.text))) {
104
+ const around = f.text.slice(Math.max(0, m.index - 300), m.index);
105
+ if (/startsWith\s*\(\s*['"]\//.test(around) || /allowlist|ALLOWED_|new URL\([^)]*,\s*origin/i.test(around)) continue;
106
+ out.push(finding('API-013', 'Redirect target comes from the request', 'high',
107
+ f.path, lineOf(f.text, m.index),
108
+ 'An attacker can send a link that passes through your domain and lands on theirs. It reads as your site in the address bar, which is what makes phishing work.',
109
+ 'Only redirect to paths on your own site, or check the destination against an allowlist.'));
110
+ }
111
+ }
112
+ return out;
113
+ }},
114
+
115
+ // ---- server fetches a URL the user supplied ------------------------------
116
+ { id: 'API-014', run(repo, profile) {
117
+ if (!profile.is_public) return [];
118
+ const out = [];
119
+ for (const f of repo.files) {
120
+ if (!f.text || !/\.(ts|js|mjs|py)$/.test(f.path)) continue;
121
+ if (!/\/(api|routes?|server|actions?)\//.test(f.path)) continue;
122
+ const re = /\b(?:fetch|axios\.get|axios\.post|requests\.get|httpx\.get)\s*\(\s*(?:req\.body\.\w+|body\.\w*url\w*|params\.\w*url\w*|searchParams\.get\([^)]*\)|\w*userUrl\w*)/gi;
123
+ let m;
124
+ while ((m = re.exec(f.text))) {
125
+ const around = f.text.slice(Math.max(0, m.index - 400), m.index);
126
+ if (/allowlist|ALLOWED_HOST|isPrivateIp|new URL\([^)]*\)\.hostname\s*===/i.test(around)) continue;
127
+ out.push(finding('API-014', 'Server fetches a URL chosen by the caller', 'critical',
128
+ f.path, lineOf(f.text, m.index),
129
+ 'Your server will fetch whatever address it is given, including internal ones. On a cloud host that reaches the metadata service, which hands out credentials.',
130
+ 'Check the host against an allowlist and refuse private or link-local addresses.'));
131
+ }
132
+ }
133
+ return out;
134
+ }},
135
+
136
+ // ---- no ceiling on how much a caller can request -------------------------
137
+ { id: 'API-004', run(repo, profile) {
138
+ if (!profile.is_public) return [];
139
+ const out = [];
140
+ for (const f of repo.files) {
141
+ if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
142
+ if (!/\/(api|routes?|server)\//.test(f.path)) continue;
143
+ const re = /\b(?:take|limit|pageSize|per_page|perPage)\s*:\s*(?:Number\()?(?:req\.query\.\w+|searchParams\.get\([^)]*\)|body\.\w+|params\.\w+)/g;
144
+ let m;
145
+ while ((m = re.exec(f.text))) {
146
+ const around = f.text.slice(Math.max(0, m.index - 400), m.index + 300);
147
+ if (/Math\.min|MAX_(PAGE|LIMIT)|\.max\(|z\.number\(\)[^;]*\.max\(/.test(around)) continue;
148
+ out.push(finding('API-004', 'Page size taken from the request with no maximum', 'medium',
149
+ f.path, lineOf(f.text, m.index),
150
+ 'A caller can ask for every row in one request. That is a slow query, a large response and an easy way to knock the app over.',
151
+ 'Clamp it, e.g. Math.min(requested ?? 20, 100).'));
152
+ }
153
+ }
154
+ return out;
155
+ }},
156
+
157
+ // ---- no body size limit ---------------------------------------------------
158
+ { id: 'DATA-008', run(repo, profile) {
159
+ if (!profile.is_public) return [];
160
+ const express = repo.grep(/express\.json\s*\(|bodyParser\.json\s*\(/);
161
+ if (!express.length) return [];
162
+ const limited = express.some(f => /json\s*\(\s*\{[^}]*limit\s*:/.test(f.text));
163
+ if (limited) return [];
164
+ const f = express[0];
165
+ return [finding('DATA-008', 'No limit on request body size', 'medium',
166
+ f.path, lineOf(f.text, f.text.search(/express\.json|bodyParser\.json/)),
167
+ 'A single request can send a body large enough to exhaust memory. No account needed.',
168
+ 'Pass a limit, e.g. express.json({ limit: "1mb" }).')];
169
+ }},
170
+
171
+ // ---- SQL built by string concatenation -----------------------------------
172
+ { id: 'DATA-007', run(repo) {
173
+ const out = [];
174
+ for (const f of repo.files) {
175
+ if (!f.text || !/\.(ts|js|mjs|py)$/.test(f.path)) continue;
176
+ const re = /(?:query|execute|raw|exec)\s*\(\s*(?:`[^`]*\$\{[^}]+\}[^`]*`|['"][^'"]*['"]\s*\+\s*\w+)/g;
177
+ let m;
178
+ while ((m = re.exec(f.text))) {
179
+ if (!/\b(select|insert|update|delete|from|where|drop|alter)\b/i.test(m[0])) continue;
180
+ const interp = m[0].match(/\$\{([^}]+)\}/)?.[1] || '';
181
+ if (/^(sql|Prisma\.|schema|table)/i.test(interp.trim())) continue;
182
+ out.push(finding('DATA-007', 'SQL query built by joining strings', 'critical',
183
+ f.path, lineOf(f.text, m.index),
184
+ 'A value is being pasted into the query text. If any part of it comes from a user, they can change what the query does — that is SQL injection.',
185
+ 'Use parameterised queries, e.g. db.query("... WHERE id = $1", [id]).'));
186
+ }
187
+ }
188
+ return out;
189
+ }},
190
+
191
+ // ---- money stored as a float ---------------------------------------------
192
+ { id: 'RU-003', run(repo, profile) {
193
+ if (profile.handles_payments === 'none' && profile.data_sensitivity !== 'financial') return [];
194
+ const out = [];
195
+ const MONEY = /\b(price|amount|total|balance|cost|fee|subtotal|revenue|salary)\w*\b/i;
196
+ for (const f of repo.files) {
197
+ if (!f.text || !/\.(sql|prisma|ts)$/.test(f.path)) continue;
198
+ const re = /(?:^|[(,])\s*["`]?(\w*(?:price|amount|total|balance|cost|fee|subtotal)\w*)["`]?\s+(float|double precision|real|Float)\b|(\w*(?:price|amount|total|balance|cost|fee)\w*)\s*:\s*(?:doublePrecision|real)\s*\(/gim;
199
+ let m;
200
+ while ((m = re.exec(f.text))) {
201
+ const col = m[1] || m[3];
202
+ if (!MONEY.test(col)) continue;
203
+ out.push(finding('RU-003', `Money column "${col}" stored as a floating point number`, 'high',
204
+ f.path, lineOf(f.text, m.index),
205
+ 'Floats cannot represent decimal money exactly. 0.1 + 0.2 is not 0.3, and those fractions of a cent accumulate into totals that do not reconcile.',
206
+ 'Store money as integer minor units (cents) or as a decimal/numeric type.'));
207
+ }
208
+ }
209
+ return out;
210
+ }},
211
+
212
+ // ---- timestamps with no timezone -----------------------------------------
213
+ { id: 'RU-001', run(repo, profile) {
214
+ if (profile.stack?.database === 'none') return [];
215
+ const out = [];
216
+ for (const f of repo.files) {
217
+ if (!f.text || !/\.(sql|ts)$/.test(f.path)) continue;
218
+ if (!/CREATE TABLE|pgTable\s*\(/i.test(f.text)) continue;
219
+ const re = /\btimestamp\s+without\s+time\s+zone\b|\btimestamp\s*\(\s*["'`]\w+["'`]\s*\)(?!\s*\.?\s*\{?[^)]*withTimezone)/gi;
220
+ let m, count = 0;
221
+ while ((m = re.exec(f.text)) && count < 1) {
222
+ count++;
223
+ out.push(finding('RU-001', 'Timestamps stored without a timezone', 'high',
224
+ f.path, lineOf(f.text, m.index),
225
+ 'The app works perfectly until a user is in a different timezone from the server. Then times drift, "today" is wrong, and scheduled things fire at the wrong hour.',
226
+ 'Use timestamptz (timestamp with time zone), and store UTC.'));
227
+ }
228
+ }
229
+ return out;
230
+ }},
231
+
232
+ // ---- 3-byte utf8 that emoji will break -----------------------------------
233
+ { id: 'DATA-009', run(repo, profile) {
234
+ if (!['mysql', 'planetscale'].includes(profile.stack?.database)) return [];
235
+ const out = [];
236
+ for (const f of repo.files) {
237
+ if (!f.text || !/\.sql$/.test(f.path)) continue;
238
+ const re = /CHARACTER SET\s+utf8\b(?!mb4)|CHARSET\s*=\s*utf8\b(?!mb4)/gi;
239
+ let m;
240
+ while ((m = re.exec(f.text))) {
241
+ out.push(finding('DATA-009', 'Text columns cannot hold 4-byte characters', 'medium',
242
+ f.path, lineOf(f.text, m.index),
243
+ 'MySQL\'s "utf8" only stores 3-byte characters. An emoji is 4 bytes, so the insert fails and the request errors — which is exactly the crash in your research.',
244
+ 'Use utf8mb4 for the charset and collation.'));
245
+ }
246
+ }
247
+ return out;
248
+ }},
249
+
250
+ // ---- upload path built from the caller's filename ------------------------
251
+ { id: 'UP-003', run(repo, profile) {
252
+ if (!profile.has_file_uploads) return [];
253
+ const out = [];
254
+ for (const f of repo.files) {
255
+ if (!f.text || !/\.(ts|js|mjs|py)$/.test(f.path)) continue;
256
+ // only a path handed to a filesystem write - a filename inside a log
257
+ // message or a URL is not path traversal
258
+ const re = /(?:path\.)?(?:join|resolve)\s*\([^)]*\b(?:file\.originalname|file\.filename|originalname)\b[^)]*\)/g;
259
+ let m;
260
+ while ((m = re.exec(f.text))) {
261
+ const around = f.text.slice(Math.max(0, m.index - 500), m.index + 500);
262
+ if (/basename\s*\(|sanitize|randomUUID|nanoid|crypto\.random|uuidv4/.test(around)) continue;
263
+ if (!/(writeFile|createWriteStream|rename|copyFile|mkdir|\.save\(|fs\.promises)/.test(around)) continue;
264
+ out.push(finding('UP-003', 'Storage path built from the uploaded filename', 'critical',
265
+ f.path, lineOf(f.text, m.index),
266
+ 'A filename like ../../.env writes outside the upload folder. The caller chooses that name.',
267
+ 'Generate your own filename (a UUID) and keep the original only as a display label.'));
268
+ }
269
+ }
270
+ return out;
271
+ }},
272
+ ];
@@ -0,0 +1,337 @@
1
+ // Django and Rails. Both ship deliberately permissive defaults for local
2
+ // development, and both document exactly which ones must change before deploying.
3
+ const finding = (id, title, severity, file, line, detail, fix) =>
4
+ ({ id, title, severity, file, line, detail, fix });
5
+ const lineOf = (t, i) => t.slice(0, i).split('\n').length;
6
+
7
+ // a settings file that is meant for production, not somebody's laptop
8
+ const isProdSettings = (p) =>
9
+ /settings/i.test(p) && !/local|dev|development|test|example|template/i.test(p);
10
+ const prodEnvRb = (p) => /config\/environments\/production\.rb$/.test(p);
11
+
12
+ export const FRAMEWORK_CHECKS = [
13
+
14
+ // ============================ DJANGO ============================
15
+
16
+ { id: 'DJ-001', run(repo, profile) {
17
+ if (profile.stack?.framework !== 'django') return [];
18
+ if (!['pre-launch', 'production'].includes(profile.stage)) return [];
19
+ const out = [];
20
+ for (const f of repo.files) {
21
+ if (!f.text || !/\.py$/.test(f.path) || !isProdSettings(f.path)) continue;
22
+ const m = /^\s*DEBUG\s*=\s*True\b/m.exec(f.text);
23
+ if (!m) continue;
24
+ out.push(finding('DJ-001', 'Django DEBUG is on', 'critical',
25
+ f.path, lineOf(f.text, m.index),
26
+ 'With DEBUG on, any error returns a page listing your settings, your installed apps, the SQL that ran and a full traceback — to whoever triggered it. Django\'s own documentation calls deploying this way a security problem.',
27
+ 'Set DEBUG = False for production and populate ALLOWED_HOSTS.'));
28
+ }
29
+ return out;
30
+ }},
31
+
32
+ { id: 'DJ-002', run(repo, profile) {
33
+ if (profile.stack?.framework !== 'django') return [];
34
+ const out = [];
35
+ for (const f of repo.files) {
36
+ if (!f.text || !/\.py$/.test(f.path) || !/settings/i.test(f.path)) continue;
37
+ const re = /^\s*SECRET_KEY\s*=\s*(['"])([^'"]{16,})\1/gm;
38
+ let m;
39
+ while ((m = re.exec(f.text))) {
40
+ if (/^(django-insecure-)?(changeme|your|xxx|placeholder|secret)/i.test(m[2])) continue;
41
+ out.push(finding('DJ-002', 'Django SECRET_KEY is written into the source', 'critical',
42
+ f.path, lineOf(f.text, m.index),
43
+ 'SECRET_KEY signs session cookies, password-reset links and CSRF tokens. Anyone holding it can forge a session for any account, including staff.',
44
+ 'Read it from an environment variable, and rotate the key that is currently committed.'));
45
+ }
46
+ }
47
+ return out;
48
+ }},
49
+
50
+ { id: 'DJ-003', run(repo, profile) {
51
+ if (profile.stack?.framework !== 'django' || !profile.is_public) return [];
52
+ const out = [];
53
+ for (const f of repo.files) {
54
+ if (!f.text || !/\.py$/.test(f.path) || !isProdSettings(f.path)) continue;
55
+ const m = /^\s*ALLOWED_HOSTS\s*=\s*\[[^\]]*['"]\*['"]/m.exec(f.text);
56
+ if (!m) continue;
57
+ out.push(finding('DJ-003', 'ALLOWED_HOSTS accepts any host', 'high',
58
+ f.path, lineOf(f.text, m.index),
59
+ 'Accepting any Host header lets an attacker poison the links Django generates. Password-reset emails then arrive pointing at their domain instead of yours.',
60
+ 'List your real domains.'));
61
+ }
62
+ return out;
63
+ }},
64
+
65
+ { id: 'DJ-004', run(repo, profile) {
66
+ if (profile.stack?.framework !== 'django' || !profile.has_accounts) return [];
67
+ const settings = repo.files.filter(f => f.text && /\.py$/.test(f.path) &&
68
+ isProdSettings(f.path) && /MIDDLEWARE\s*=/.test(f.text));
69
+ if (!settings.length) return [];
70
+ const missing = settings.filter(f => !/CsrfViewMiddleware/.test(f.text));
71
+ if (!missing.length) return [];
72
+ const f = missing[0];
73
+ return [finding('DJ-004', 'CSRF middleware has been removed', 'critical',
74
+ f.path, lineOf(f.text, f.text.search(/MIDDLEWARE\s*=/)),
75
+ 'Django enables CSRF protection by default, so its absence means it was deliberately taken out. Without it, another site can make state-changing requests using your users\' logged-in sessions.',
76
+ 'Restore django.middleware.csrf.CsrfViewMiddleware to MIDDLEWARE.')];
77
+ }},
78
+
79
+ { id: 'DJ-006', run(repo, profile) {
80
+ if (profile.stack?.framework !== 'django') return [];
81
+ const out = [];
82
+ for (const f of repo.files) {
83
+ if (!f.text || !/\.py$/.test(f.path)) continue;
84
+ const re = /\.(raw|extra)\s*\(\s*(f?['"])/g;
85
+ let m;
86
+ while ((m = re.exec(f.text))) {
87
+ const call = f.text.slice(m.index, m.index + 400);
88
+ const interpolated = m[2].startsWith('f') || /%\s*\(|\.format\s*\(|['"]\s*\+\s*\w/.test(call);
89
+ if (!interpolated) continue;
90
+ out.push(finding('DJ-006', `SQL built by interpolation in .${m[1]}()`, 'critical',
91
+ f.path, lineOf(f.text, m.index),
92
+ 'A value is being pasted into the query text rather than passed as a parameter. If any part of it comes from a request, the caller can rewrite the query.',
93
+ "Use the params argument: .raw('SELECT … WHERE id = %s', [id])"));
94
+ }
95
+ }
96
+ return out;
97
+ }},
98
+
99
+ { id: 'DJ-008', run(repo, profile) {
100
+ if (profile.stack?.framework !== 'django') return [];
101
+ if (!profile.has_accounts || profile.stage !== 'production') return [];
102
+ const settings = repo.files.filter(f => f.text && /\.py$/.test(f.path) && isProdSettings(f.path));
103
+ if (!settings.length) return [];
104
+ const all = settings.map(f => f.text).join('\n');
105
+ const missing = [];
106
+ if (!/SESSION_COOKIE_SECURE\s*=\s*True/.test(all)) missing.push('SESSION_COOKIE_SECURE');
107
+ if (!/CSRF_COOKIE_SECURE\s*=\s*True/.test(all)) missing.push('CSRF_COOKIE_SECURE');
108
+ if (!missing.length) return [];
109
+ return [finding('DJ-008', `${missing.join(' and ')} not enabled`, 'high',
110
+ settings[0].path, 1,
111
+ 'Without these, the session and CSRF cookies are sent over plain HTTP as well as HTTPS — readable by anyone sharing the network on the first request.',
112
+ 'Set both to True in production settings.')];
113
+ }},
114
+
115
+ { id: 'DJ-010', run(repo, profile) {
116
+ if (profile.stack?.framework !== 'django') return [];
117
+ const out = [];
118
+ for (const f of repo.files) {
119
+ if (!f.text || !/\.py$/.test(f.path)) continue;
120
+ const re = /^\s*fields\s*=\s*['"]__all__['"]/gm;
121
+ let m;
122
+ while ((m = re.exec(f.text))) {
123
+ const around = f.text.slice(Math.max(0, m.index - 900), m.index);
124
+ if (!/class\s+\w+\s*\((?:[^)]*\b(ModelForm|ModelSerializer|HyperlinkedModelSerializer)\b[^)]*)\)/.test(around)) continue;
125
+ out.push(finding('DJ-010', "Form or serializer accepts every field", 'critical',
126
+ f.path, lineOf(f.text, m.index),
127
+ "fields = '__all__' makes every column on the model writable from the request — including is_staff, is_superuser, and any credit or plan field you keep there.",
128
+ 'List the fields you actually intend to accept.'));
129
+ }
130
+ }
131
+ return out;
132
+ }},
133
+
134
+ { id: 'DJ-014', run(repo, profile) {
135
+ if (profile.stack?.framework !== 'django') return [];
136
+ const out = [];
137
+ for (const f of repo.files) {
138
+ if (!f.text || !/\.py$/.test(f.path)) continue;
139
+ const m = /SESSION_SERIALIZER\s*=\s*['"][^'"]*PickleSerializer/.exec(f.text);
140
+ if (!m) continue;
141
+ out.push(finding('DJ-014', 'Sessions are serialised with pickle', 'critical',
142
+ f.path, lineOf(f.text, m.index),
143
+ 'Pickle reconstructs arbitrary Python objects. Combined with a leaked SECRET_KEY this is remote code execution, not merely session forgery.',
144
+ 'Use the default JSONSerializer.'));
145
+ }
146
+ return out;
147
+ }},
148
+
149
+ { id: 'DJ-016', run(repo, profile) {
150
+ if (profile.stack?.framework !== 'django' || profile.stage !== 'production') return [];
151
+ const out = [];
152
+ for (const f of repo.files) {
153
+ if (!f.text || !/\.py$/.test(f.path) || !isProdSettings(f.path)) continue;
154
+ const m = /['"](debug_toolbar|django_extensions|silk)['"]/.exec(f.text);
155
+ if (!m) continue;
156
+ out.push(finding('DJ-016', `${m[1]} is installed in production settings`, 'high',
157
+ f.path, lineOf(f.text, m.index),
158
+ 'These development tools expose SQL, settings, and in the case of django_extensions a Python shell. They are not meant to be reachable in production.',
159
+ 'Move it into your development settings only.'));
160
+ }
161
+ return out;
162
+ }},
163
+
164
+ // ============================ RAILS ============================
165
+
166
+ { id: 'RB-001', run(repo, profile) {
167
+ if (profile.stack?.framework !== 'rails') return [];
168
+ const out = [];
169
+ for (const f of repo.files) {
170
+ if (!f.text || !/\.(rb|ya?ml)$/.test(f.path)) continue;
171
+ const re = /secret_key_base\s*[:=]\s*['"]?([a-f0-9]{40,})/gi;
172
+ let m;
173
+ while ((m = re.exec(f.text))) {
174
+ out.push(finding('RB-001', 'secret_key_base is committed', 'critical',
175
+ f.path, lineOf(f.text, m.index),
176
+ 'This key signs every session cookie. Anyone who has it can mint a valid session for any user, including an administrator.',
177
+ 'Move it to encrypted credentials or the environment, and rotate it — treat the committed value as public.'));
178
+ }
179
+ }
180
+ return out;
181
+ }},
182
+
183
+ { id: 'RB-002', run(repo, profile) {
184
+ if (profile.stack?.framework !== 'rails') return [];
185
+ if (profile.stage !== 'production' || !profile.is_public) return [];
186
+ const f = repo.files.find(x => x.text && prodEnvRb(x.path));
187
+ if (!f) return [];
188
+ if (/^\s*config\.force_ssl\s*=\s*true/m.test(f.text)) return [];
189
+ return [finding('RB-002', 'config.force_ssl is not enabled', 'high',
190
+ f.path, 1,
191
+ 'One line turns on the HTTPS redirect, secure cookies and HSTS together. Without it, a first request over plain HTTP carries the session cookie in clear text.',
192
+ 'config.force_ssl = true')];
193
+ }},
194
+
195
+ { id: 'RB-003', run(repo, profile) {
196
+ if (profile.stack?.framework !== 'rails' || !profile.has_accounts) return [];
197
+ const out = [];
198
+ for (const f of repo.files) {
199
+ if (!f.text || !/app\/controllers\/.*\.rb$/.test(f.path)) continue;
200
+ const re = /skip_before_action\s+:verify_authenticity_token|protect_from_forgery\s+with:\s*:null_session/g;
201
+ let m;
202
+ while ((m = re.exec(f.text))) {
203
+ out.push(finding('RB-003', 'CSRF protection switched off', 'critical',
204
+ f.path, lineOf(f.text, m.index),
205
+ 'Any site your users visit can now make state-changing requests to this controller using their logged-in session.',
206
+ 'Remove the skip. If this is a machine-to-machine endpoint, authenticate it with a token and keep it out of the session-based controllers.'));
207
+ }
208
+ }
209
+ return out;
210
+ }},
211
+
212
+ { id: 'RB-004', run(repo, profile) {
213
+ if (profile.stack?.framework !== 'rails') return [];
214
+ const out = [];
215
+ for (const f of repo.files) {
216
+ if (!f.text || !/\.(rb|erb|haml|slim)$/.test(f.path)) continue;
217
+ const re = /(?:raw\s*\(|\.html_safe|<%==)/g;
218
+ let m;
219
+ while ((m = re.exec(f.text))) {
220
+ // judge the line itself, not a 200-character neighbourhood - in Rails an
221
+ // instance variable appears on every other line and proves nothing
222
+ const from = f.text.lastIndexOf('\n', m.index) + 1;
223
+ const to = f.text.indexOf('\n', m.index);
224
+ const line = f.text.slice(from, to === -1 ? f.text.length : to);
225
+
226
+ // `.to_json.html_safe` is the documented, safe way to embed JSON in a script tag
227
+ if (/\.to_json\s*\.html_safe/.test(line)) continue;
228
+ // app-controlled values, not user content
229
+ if (/\bflash\b|_config|locale|_urls?\b|asset|csrf|javascript_tag|stylesheet|image_tag|\bt\(|I18n|sanitize\s*\(|\.svg|icon/i.test(line)) continue;
230
+ // require something that actually looks like it came from a person
231
+ if (!/params\[|\b(comment|post|review|bio|about|description|body|content|message|note|answer|signature|profile)\b/i.test(line)) continue;
232
+ out.push(finding('RB-004', 'HTML escaping turned off on dynamic content', 'critical',
233
+ f.path, lineOf(f.text, m.index),
234
+ 'ERB escapes output by default. This switches that off for a value that appears to come from your data, so anything a user stored can execute as script in another user\'s browser.',
235
+ 'Render it normally, or pass it through sanitize() with an allowlist.'));
236
+ }
237
+ }
238
+ return out;
239
+ }},
240
+
241
+ { id: 'RB-005', run(repo, profile) {
242
+ if (profile.stack?.framework !== 'rails') return [];
243
+ const out = [];
244
+ for (const f of repo.files) {
245
+ if (!f.text || !/\.rb$/.test(f.path)) continue;
246
+ const re = /\.(where|find_by_sql|order|group|having|pluck)\s*\(\s*"[^"]*#\{/g;
247
+ let m;
248
+ while ((m = re.exec(f.text))) {
249
+ out.push(finding('RB-005', `SQL interpolation in .${m[1]}()`, 'critical',
250
+ f.path, lineOf(f.text, m.index),
251
+ 'The value is being pasted into the SQL text. If it derives from a request, the caller controls the query.',
252
+ "Use a placeholder: where('email = ?', email)"));
253
+ }
254
+ }
255
+ return out;
256
+ }},
257
+
258
+ { id: 'RB-006', run(repo, profile) {
259
+ if (profile.stack?.framework !== 'rails') return [];
260
+ const out = [];
261
+ for (const f of repo.files) {
262
+ if (!f.text || !/\.rb$/.test(f.path)) continue;
263
+ const re = /params(?:\.require\([^)]*\))?\.permit!/g;
264
+ let m;
265
+ while ((m = re.exec(f.text))) {
266
+ out.push(finding('RB-006', 'Strong parameters bypassed with permit!', 'critical',
267
+ f.path, lineOf(f.text, m.index),
268
+ 'permit! accepts every attribute the caller sent, not the ones your form shows. That is how a user sets their own role, plan, or account balance.',
269
+ 'List the attributes explicitly: params.require(:user).permit(:name, :email)'));
270
+ }
271
+ }
272
+ return out;
273
+ }},
274
+
275
+ { id: 'RB-008', run(repo, profile) {
276
+ if (profile.stack?.framework !== 'rails') return [];
277
+ const out = [];
278
+ for (const f of repo.files) {
279
+ if (!f.text || !/\.rb$/.test(f.path)) continue;
280
+ const re = /(Marshal\.load|YAML\.load(?!_file|_safe)|Oj\.load)\s*\(/g;
281
+ let m;
282
+ while ((m = re.exec(f.text))) {
283
+ const around = f.text.slice(Math.max(0, m.index - 260), m.index + 160);
284
+ if (!/params|request|body|payload|cookie|@\w+/.test(around)) continue;
285
+ out.push(finding('RB-008', `Unsafe deserialisation via ${m[1]}`, 'critical',
286
+ f.path, lineOf(f.text, m.index),
287
+ 'These reconstruct arbitrary Ruby objects from the input. Given attacker-controlled data that has historically meant remote code execution.',
288
+ 'Use YAML.safe_load, or JSON.parse for data you did not create.'));
289
+ }
290
+ }
291
+ return out;
292
+ }},
293
+
294
+ { id: 'RB-009', run(repo, profile) {
295
+ if (profile.stack?.framework !== 'rails') return [];
296
+ const out = [];
297
+ for (const f of repo.files) {
298
+ if (!f.text || !/\.rb$/.test(f.path)) continue;
299
+ const re = /(send_file|send_data|render\s+file:)\s*[^,\n]*params\[/g;
300
+ let m;
301
+ while ((m = re.exec(f.text))) {
302
+ const around = f.text.slice(Math.max(0, m.index - 300), m.index + 200);
303
+ if (/File\.basename|sanitize_filename|allowlist|ALLOWED/.test(around)) continue;
304
+ out.push(finding('RB-009', 'File served from a caller-supplied path', 'critical',
305
+ f.path, lineOf(f.text, m.index),
306
+ 'The filesystem is happy to accept ../../config/master.key as a filename. The caller chooses this path.',
307
+ 'Look the file up by database id and derive the path yourself, never from the request.'));
308
+ }
309
+ }
310
+ return out;
311
+ }},
312
+
313
+ { id: 'RB-011', run(repo, profile) {
314
+ if (profile.stack?.framework !== 'rails' || !profile.has_accounts) return [];
315
+ const f = repo.files.find(x => x.text && /config\/initializers\/filter_parameter_logging\.rb$/.test(x.path));
316
+ const app = repo.files.find(x => x.text && /config\/application\.rb$/.test(x.path));
317
+ const text = (f?.text || '') + (app?.text || '');
318
+ if (!text) return [];
319
+ if (/filter_parameters\s*\+?=\s*\[[^\]]*password/i.test(text)) return [];
320
+ return [finding('RB-011', 'Passwords are not filtered out of the logs', 'high',
321
+ (f || app).path, 1,
322
+ 'Rails logs request parameters. Without a filter, every sign-in writes a plaintext password into production logs — which get shipped, backed up and searched.',
323
+ 'config.filter_parameters += [:password, :password_confirmation, :token, :secret]')];
324
+ }},
325
+
326
+ { id: 'RB-012', run(repo, profile) {
327
+ if (profile.stack?.framework !== 'rails' || profile.stage !== 'production') return [];
328
+ const f = repo.files.find(x => x.text && prodEnvRb(x.path));
329
+ if (!f) return [];
330
+ const m = /^\s*config\.consider_all_requests_local\s*=\s*true/m.exec(f.text);
331
+ if (!m) return [];
332
+ return [finding('RB-012', 'Detailed exception pages enabled in production', 'high',
333
+ f.path, lineOf(f.text, m.index),
334
+ 'Every 500 returns a full stack trace with source lines and local variable values to whoever triggered it.',
335
+ 'config.consider_all_requests_local = false')];
336
+ }},
337
+ ];