vibe-coding-qa 0.5.0__py3-none-any.whl

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.
Files changed (58) hide show
  1. dashboard/__init__.py +10 -0
  2. dashboard/backend/__init__.py +11 -0
  3. dashboard/backend/__main__.py +6 -0
  4. dashboard/backend/ai/__init__.py +6 -0
  5. dashboard/backend/ai/provider.py +102 -0
  6. dashboard/backend/ai/service.py +757 -0
  7. dashboard/backend/api/__init__.py +5 -0
  8. dashboard/backend/api/ai.py +142 -0
  9. dashboard/backend/api/dashboard.py +34 -0
  10. dashboard/backend/api/deps.py +51 -0
  11. dashboard/backend/api/findings.py +102 -0
  12. dashboard/backend/api/intel.py +41 -0
  13. dashboard/backend/api/repositories.py +62 -0
  14. dashboard/backend/api/scans.py +168 -0
  15. dashboard/backend/api/system.py +69 -0
  16. dashboard/backend/config.py +180 -0
  17. dashboard/backend/main.py +183 -0
  18. dashboard/backend/persistence/__init__.py +5 -0
  19. dashboard/backend/persistence/database.py +278 -0
  20. dashboard/backend/persistence/store.py +600 -0
  21. dashboard/backend/profiles.py +141 -0
  22. dashboard/backend/schemas.py +452 -0
  23. dashboard/backend/security.py +158 -0
  24. dashboard/backend/services/__init__.py +7 -0
  25. dashboard/backend/services/dashboard.py +172 -0
  26. dashboard/backend/services/intel.py +341 -0
  27. dashboard/backend/services/paths.py +38 -0
  28. dashboard/backend/services/repositories.py +123 -0
  29. dashboard/backend/services/scans.py +328 -0
  30. dashboard/backend/services/settings.py +49 -0
  31. dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
  32. dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
  33. dashboard/frontend/dist/index.html +29 -0
  34. dashboard/frontend/dist/phone-mascots.gif +0 -0
  35. dashboard/launcher.py +48 -0
  36. vibe_coding_qa/__init__.py +140 -0
  37. vibe_coding_qa/__main__.py +5 -0
  38. vibe_coding_qa/ai_review.py +165 -0
  39. vibe_coding_qa/cli.py +157 -0
  40. vibe_coding_qa/engine.py +860 -0
  41. vibe_coding_qa/fixes.py +222 -0
  42. vibe_coding_qa/gates.py +300 -0
  43. vibe_coding_qa/profile.py +471 -0
  44. vibe_coding_qa/repo.py +188 -0
  45. vibe_coding_qa/reporters/__init__.py +253 -0
  46. vibe_coding_qa/scanners/__init__.py +195 -0
  47. vibe_coding_qa/scanners/compliance.py +167 -0
  48. vibe_coding_qa/scanners/database.py +316 -0
  49. vibe_coding_qa/scanners/dependencies.py +595 -0
  50. vibe_coding_qa/scanners/infrastructure.py +125 -0
  51. vibe_coding_qa/scanners/performance.py +84 -0
  52. vibe_coding_qa/scanners/quality.py +129 -0
  53. vibe_coding_qa/scanners/security.py +521 -0
  54. vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
  55. vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
  56. vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
  57. vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
  58. vibe_coding_qa-0.5.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,521 @@
1
+ """Security scanner: secrets, auth, RLS, injection, client/server boundaries."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from . import BaseScanner, SECRET_PATTERNS, SQL_INJECTION_HINTS, is_ignored, is_likely_client_file, is_test_or_fixture, load_ignore_patterns, normalize_line
8
+ from vibe_coding_qa import Finding, Severity
9
+
10
+
11
+ class SecurityScanner(BaseScanner):
12
+ module = "security"
13
+
14
+ # Files that are expected to contain keys only server-side.
15
+ SERVER_PATHS = {"server", "api", "backend", "middleware", "routes", "functions", "supabase/functions"}
16
+
17
+ def scan(self) -> list[Finding]:
18
+ files = self.iter_source_files((".ts", ".tsx", ".js", ".jsx", ".py", ".sql", ".md", ".json", ".yml", ".yaml", ".env"))
19
+ for path in files:
20
+ self._scan_for_secrets(path)
21
+ self._scan_for_insecure_eval(path)
22
+ self._scan_for_client_secrets(path)
23
+ self._scan_for_insecure_cors(path)
24
+ self._scan_for_missing_auth(path)
25
+ self._scan_for_sql_injection(path)
26
+ self._scan_for_insecure_uploads(path)
27
+ self._scan_for_path_traversal(path)
28
+ self._scan_for_ssrf(path)
29
+ self._scan_for_insecure_cookies(path)
30
+ self._scan_for_debug_endpoints(path)
31
+ self._scan_for_shell_exec(path)
32
+ self._scan_for_rate_limits(path)
33
+ self._scan_for_insecure_sessions(path)
34
+ self._scan_env_files()
35
+ self._scan_firebase_rules()
36
+ return self.findings
37
+
38
+ def _scan_for_secrets(self, path: Path) -> None:
39
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
40
+ for i, raw in enumerate(lines, 1):
41
+ line = raw.strip()
42
+ if line.startswith("//") or line.startswith("#"):
43
+ if "PRIVATE KEY" not in line.upper():
44
+ continue
45
+ for rule, pattern, severity in SECRET_PATTERNS:
46
+ for match in pattern.finditer(raw):
47
+ if self._is_likely_synthetic(match):
48
+ continue
49
+ self.add(
50
+ rule=rule,
51
+ severity=severity,
52
+ file=path,
53
+ line=i,
54
+ column=match.start(),
55
+ message=f"Possible {rule.replace('-', ' ')} exposed in source.",
56
+ suggestion="Move secrets to environment variables or a vault; never commit keys.",
57
+ confidence="high",
58
+ )
59
+
60
+ @staticmethod
61
+ def _credential_value(match: re.Match) -> str:
62
+ """Extract only the credential literal from a match.
63
+
64
+ Assignment patterns such as ``api_key = "value"`` match the whole
65
+ statement; the surrounding variable name must never influence the
66
+ synthetic/placeholder decision, so only the value portion is returned.
67
+ For bare-token rules (``sk-...``) the match itself is the value.
68
+ """
69
+ text = match.group(0)
70
+ quoted = re.search(r"['\"]([^'\"]+)['\"]", text)
71
+ if quoted:
72
+ return quoted.group(1)
73
+ assignment = re.match(r"[A-Za-z0-9_\-.]+\s*[:=]\s*(.+)$", text)
74
+ if assignment:
75
+ return assignment.group(1).strip().strip("'\"")
76
+ return text
77
+
78
+ #: Values that are placeholders whenever they appear verbatim as the credential.
79
+ _PLACEHOLDER_LITERALS = {
80
+ "test", "tests", "example", "sample", "demo", "dummy", "fake", "mock",
81
+ "placeholder", "changeme", "change-me", "change_me", "replace-me",
82
+ "replace_me", "your-key", "your_key", "yourkey", "your-api-key-here",
83
+ "your-api-key", "your_api_key", "your-secret", "your_secret",
84
+ "your-token", "your_token", "insert-key", "insert_key", "your_key_here",
85
+ "secret", "password", "token", "apikey", "api-key", "none", "null",
86
+ "undefined", "todo", "fixme",
87
+ }
88
+
89
+ #: Unambiguous substrings that only make sense inside a placeholder value.
90
+ _PLACEHOLDER_SUBSTRINGS = (
91
+ "not-real", "not_real", "notreal",
92
+ "replace-me", "replace_me", "replaceme",
93
+ "change-me", "change_me", "changeme", "changeit",
94
+ "your-api-key", "your_api_key", "yourapikey",
95
+ "your-secret", "your_secret", "yoursecret",
96
+ "your-token", "your_token", "yourtoken",
97
+ "insert-your", "insert_your",
98
+ "insert-key", "insert_key",
99
+ "placeholder",
100
+ "example-key", "sample-key", "test-key", "dummy-key", "fake-key",
101
+ "mock-key", "example_key", "sample_key", "test_key",
102
+ )
103
+
104
+ #: Standalone tokens that mark a value as an obvious placeholder.
105
+ _PLACEHOLDER_TOKENS = {
106
+ "example", "sample", "dummy", "fake", "mock", "placeholder",
107
+ "changeme", "notreal", "yourkey",
108
+ }
109
+
110
+ @classmethod
111
+ def _placeholder_reason(cls, value: str) -> str | None:
112
+ """Return a human-readable reason when ``value`` is clearly synthetic.
113
+
114
+ Recognition is deliberately narrow and operates only on the credential
115
+ value. A *variable name* containing ``TEST``, ``EXAMPLE`` or ``MOCK``
116
+ must never suppress detection, and a real token that merely contains the
117
+ substring ``test`` must still be reported.
118
+ """
119
+ if not value or not value.strip():
120
+ return "empty value"
121
+ lowered = value.strip().strip("'\"")
122
+ if not lowered:
123
+ return "empty value"
124
+ lowered = lowered.lower()
125
+
126
+ if lowered in cls._PLACEHOLDER_LITERALS:
127
+ return "known placeholder literal"
128
+ if lowered.startswith("<") and lowered.endswith(">"):
129
+ return "angle-bracket placeholder"
130
+ if lowered in {"*" * len(lowered), "x" * len(lowered)}:
131
+ return "masked placeholder"
132
+ if re.fullmatch(r"(abcdef)+", lowered):
133
+ return "sequential placeholder"
134
+ if re.search(r"x{4,}", lowered):
135
+ return "repeated placeholder character"
136
+ if len(set(lowered)) == 1 and len(lowered) >= 4:
137
+ return "single repeated character"
138
+ for indicator in cls._PLACEHOLDER_SUBSTRINGS:
139
+ if indicator in lowered:
140
+ return f"placeholder marker '{indicator}'"
141
+ tokens = [token for token in re.split(r"[^a-z0-9]+", lowered) if token]
142
+ for token in tokens:
143
+ if token in cls._PLACEHOLDER_TOKENS:
144
+ return f"placeholder token '{token}'"
145
+ return None
146
+
147
+ @classmethod
148
+ def _is_likely_synthetic(cls, match: re.Match) -> bool:
149
+ """Decide whether a matched credential is a synthetic placeholder.
150
+
151
+ Only the matched credential value is inspected; surrounding source text
152
+ (variable names, comments, file paths) is intentionally ignored.
153
+ """
154
+ return cls._placeholder_reason(cls._credential_value(match)) is not None
155
+
156
+ def _scan_for_insecure_eval(self, path: Path) -> None:
157
+ text = path.read_text(encoding="utf-8", errors="ignore")
158
+ for i, raw in enumerate(text.splitlines(), 1):
159
+ if re.search(r"(?<!\w)(eval|Function|setTimeout|setInterval)\s*\(\s*['\"`]|innerHTML\s*=", raw):
160
+ self.add(
161
+ rule="dangerous-runtime-code",
162
+ severity=Severity.HIGH,
163
+ file=path,
164
+ line=i,
165
+ message="Dangerous runtime code execution or raw HTML assignment detected.",
166
+ suggestion="Avoid eval/Function with string sources and use safe DOM methods instead of innerHTML.",
167
+ confidence="medium",
168
+ )
169
+
170
+ def _scan_for_client_secrets(self, path: Path) -> None:
171
+ if not is_likely_client_file(path, self.repo):
172
+ return
173
+ text = path.read_text(encoding="utf-8", errors="ignore")
174
+ for i, raw in enumerate(text.splitlines(), 1):
175
+ for rule, pattern, severity in SECRET_PATTERNS:
176
+ if severity != Severity.CRITICAL:
177
+ continue
178
+ for match in pattern.finditer(raw):
179
+ if self._is_likely_synthetic(match):
180
+ continue
181
+ self.add(
182
+ rule="client-side-secret",
183
+ severity=Severity.CRITICAL,
184
+ file=path,
185
+ line=i,
186
+ column=match.start(),
187
+ message="Secret appears in a client-side file; client bundles can leak it.",
188
+ suggestion="Move the secret to a server route and call it from the client.",
189
+ confidence="high",
190
+ )
191
+
192
+ def _scan_for_insecure_cors(self, path: Path) -> None:
193
+ if path.suffix not in {".ts", ".js", ".py"}:
194
+ return
195
+ text = path.read_text(encoding="utf-8", errors="ignore")
196
+ for i, raw in enumerate(text.splitlines(), 1):
197
+ if re.search(r"Access-Control-Allow-Origin\s*[:=]\s*['\"]\*['\"]|cors\(.*origin:\s*['\"]\*['\"]", raw, re.IGNORECASE):
198
+ self.add(
199
+ rule="wildcard-cors",
200
+ severity=Severity.MEDIUM,
201
+ file=path,
202
+ line=i,
203
+ message="Wildcard CORS origin allows any website to call this backend.",
204
+ suggestion="Restrict CORS to known production origins and include credentials only with explicit origins.",
205
+ confidence="medium",
206
+ )
207
+
208
+ def _scan_for_missing_auth(self, path: Path) -> None:
209
+ if path.suffix not in {".ts", ".js", ".py"}:
210
+ return
211
+ if not any(part in self.SERVER_PATHS for part in {p.lower() for p in path.parts}):
212
+ return
213
+ text = path.read_text(encoding="utf-8", errors="ignore")
214
+ lines = text.splitlines()
215
+ in_handler = False
216
+ handler_start = 0
217
+ for i, raw in enumerate(lines, 1):
218
+ if re.search(r"(app\.|router\.|route\.|export\s+(default\s+)?(async\s+)?function|def\s+).*(post|put|delete|patch)", raw, re.IGNORECASE):
219
+ in_handler = True
220
+ handler_start = i
221
+ continue
222
+ if in_handler and raw.strip() == "":
223
+ in_handler = False
224
+ if in_handler and i - handler_start < 25:
225
+ lowered = raw.lower()
226
+ if any(term in lowered for term in ("auth", "verify", "session", "jwt", "middleware", "protect", "requireauth")):
227
+ in_handler = False
228
+ # This is a coarse heuristic; we do not raise findings here because it
229
+ # produces too many false positives without runtime knowledge. Instead,
230
+ # we rely on explicit route inventories from tests or the database scanner.
231
+
232
+ def _scan_for_sql_injection(self, path: Path) -> None:
233
+ if path.suffix not in {".ts", ".js", ".py"}:
234
+ return
235
+ text = path.read_text(encoding="utf-8", errors="ignore")
236
+ for i, raw in enumerate(text.splitlines(), 1):
237
+ if "%s" in raw or "${" in raw:
238
+ for pattern in SQL_INJECTION_HINTS:
239
+ if pattern.search(raw):
240
+ self.add(
241
+ rule="sql-injection-risk",
242
+ severity=Severity.HIGH,
243
+ file=path,
244
+ line=i,
245
+ message="Raw SQL/query construction may interpolate user input.",
246
+ suggestion="Use parameterized queries, an ORM, or a query builder.",
247
+ confidence="low",
248
+ )
249
+ break
250
+
251
+ def _scan_supabase_rls(self) -> None:
252
+ ignore = load_ignore_patterns(self.repo)
253
+ sql_files = [p for p in self.repo.rglob("*.sql") if not is_ignored(p, self.repo, ignore)]
254
+ sql_files.sort(key=lambda path: path.as_posix().lower())
255
+ if not sql_files and not (self.repo / "supabase").is_dir():
256
+ return
257
+ rls_enabled_tables: set[str] = set()
258
+ tables_with_policies: dict[str, tuple[Path, int]] = {}
259
+ tables_seen: dict[str, tuple[Path, int]] = {}
260
+ for path in sql_files:
261
+ text = path.read_text(encoding="utf-8", errors="ignore")
262
+ for i, raw in enumerate(text.splitlines(), 1):
263
+ norm = normalize_line(raw)
264
+ create_match = re.search(
265
+ r"create\s+table\s+(?:if\s+not\s+exists\s+)?(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)",
266
+ norm, re.IGNORECASE
267
+ )
268
+ if create_match:
269
+ schema = create_match.group(1).lower() if create_match.group(1) else "public"
270
+ table = create_match.group(2).lower()
271
+ fq_table = f"{schema}.{table}"
272
+ tables_seen.setdefault(fq_table, (path, i))
273
+ if re.search(r"alter\s+table\s+.*enable\s+row\s+level\s+security", norm, re.IGNORECASE):
274
+ table_match = re.search(
275
+ r"alter\s+table\s+(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)",
276
+ norm, re.IGNORECASE
277
+ )
278
+ if table_match:
279
+ schema = table_match.group(1).lower() if table_match.group(1) else "public"
280
+ table = table_match.group(2).lower()
281
+ fq_table = f"{schema}.{table}"
282
+ rls_enabled_tables.add(fq_table)
283
+ elif re.search(r"alter\s+table\s+.*disable\s+row\s+level\s+security", norm, re.IGNORECASE):
284
+ table_match = re.search(
285
+ r"alter\s+table\s+(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)",
286
+ norm, re.IGNORECASE
287
+ )
288
+ if table_match:
289
+ schema = table_match.group(1).lower() if table_match.group(1) else "public"
290
+ table = table_match.group(2).lower()
291
+ rls_enabled_tables.discard(f"{schema}.{table}")
292
+ if re.search(r"create\s+policy\s+.*on\s+(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)", norm, re.IGNORECASE):
293
+ policy_match = re.search(
294
+ r"create\s+policy\s+.*on\s+(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)",
295
+ norm, re.IGNORECASE
296
+ )
297
+ if policy_match:
298
+ schema = policy_match.group(1).lower() if policy_match.group(1) else "public"
299
+ table = policy_match.group(2).lower()
300
+ fq_table = f"{schema}.{table}"
301
+ tables_with_policies.setdefault(fq_table, (path, i))
302
+ for table, (table_path, table_line) in tables_seen.items():
303
+ if table not in rls_enabled_tables:
304
+ if table in tables_with_policies:
305
+ policy_path, policy_line = tables_with_policies[table]
306
+ self.add(
307
+ rule="policy-without-rls-enabled",
308
+ severity=Severity.CRITICAL,
309
+ file=policy_path,
310
+ line=policy_line,
311
+ message=f"Table '{table}' has policies but Row Level Security is not enabled. Policies have no effect until RLS is enabled with 'ALTER TABLE ... ENABLE ROW LEVEL SECURITY'.",
312
+ suggestion="Add 'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;' before creating policies.",
313
+ confidence="high",
314
+ )
315
+ else:
316
+ self.add(
317
+ rule="missing-rls",
318
+ severity=Severity.CRITICAL,
319
+ file=table_path,
320
+ line=table_line,
321
+ message=f"Table '{table}' does not appear to have Row Level Security (RLS) enabled or policies defined.",
322
+ suggestion="Enable RLS on every table and write explicit policies with auth checks.",
323
+ confidence="medium",
324
+ )
325
+
326
+ def _scan_env_files(self) -> None:
327
+ ignore = load_ignore_patterns(self.repo)
328
+ try:
329
+ tracked_result = subprocess.run(
330
+ ["git", "-C", str(self.repo.resolve()), "ls-files", "-z", "--", "."],
331
+ capture_output=True, timeout=10, check=False,
332
+ )
333
+ tracked = set(tracked_result.stdout.decode("utf-8", errors="surrogateescape").split("\0")) if tracked_result.returncode == 0 else None
334
+ except (OSError, subprocess.TimeoutExpired):
335
+ tracked = None
336
+ for path in self.repo.rglob(".env*"):
337
+ if is_ignored(path, self.repo, ignore) or path.name == ".env.example" or not path.is_file():
338
+ continue
339
+ relative = path.relative_to(self.repo).as_posix()
340
+ if tracked is not None and relative in tracked:
341
+ self.add(
342
+ rule="committed-env-file", severity=Severity.HIGH, file=path, line=1,
343
+ message=f"{path.name} is tracked by Git; environment files may expose credentials.",
344
+ suggestion="Remove the file from Git, add it to .gitignore, and rotate any exposed secrets.",
345
+ confidence="high",
346
+ )
347
+ elif tracked is None:
348
+ self.add(
349
+ rule="env-file-tracking-unverified", severity=Severity.LOW, file=path, line=1,
350
+ message=f"{path.name} is present; Git tracking could not be verified.",
351
+ suggestion="Check whether the file is tracked and ensure it contains no exposed secrets.",
352
+ confidence="medium",
353
+ )
354
+
355
+ def _scan_for_insecure_uploads(self, path: Path) -> None:
356
+ if path.suffix not in {".ts", ".js", ".py"}:
357
+ return
358
+ text = path.read_text(encoding="utf-8", errors="ignore")
359
+ for i, raw in enumerate(text.splitlines(), 1):
360
+ if re.search(r"(?:multer|upload|busboy|formidable|receive|save)[A-Za-z0-9_]*\s*(?:\(|=).*?(?:file|req\.files|multipart)", raw, re.IGNORECASE):
361
+ if not re.search(r"(mimetype|extension|magic|size|limit|validate|sanitize)", raw, re.IGNORECASE):
362
+ self.add(
363
+ rule="unvalidated-file-upload",
364
+ severity=Severity.HIGH,
365
+ file=path,
366
+ line=i,
367
+ message="File upload handler may not validate file type or size.",
368
+ suggestion="Validate MIME type, extension, and size; store uploads outside the web root and scan for malware.",
369
+ confidence="low",
370
+ )
371
+
372
+ def _scan_for_path_traversal(self, path: Path) -> None:
373
+ if path.suffix not in {".ts", ".js", ".py"}:
374
+ return
375
+ text = path.read_text(encoding="utf-8", errors="ignore")
376
+ risky = [
377
+ r"(readFileSync|writeFileSync|sendFile|createReadStream|open)\s*\(\s*[^)]*(req\.|request\.|params|query|body)",
378
+ r"path\.join\s*\(\s*__dirname\s*,\s*[^)]*(req\.|request\.|params|query|body)",
379
+ r"open\s*\(\s*[^)]*(req\.|request\.|params|query|body)",
380
+ ]
381
+ for i, raw in enumerate(text.splitlines(), 1):
382
+ for pattern in risky:
383
+ if re.search(pattern, raw, re.IGNORECASE):
384
+ self.add(
385
+ rule="path-traversal-risk",
386
+ severity=Severity.HIGH,
387
+ file=path,
388
+ line=i,
389
+ message="File-system access appears to use user-controlled path components.",
390
+ suggestion="Resolve the path, enforce an allowlist directory, and reject traversal sequences.",
391
+ confidence="low",
392
+ )
393
+ break
394
+
395
+ def _scan_for_ssrf(self, path: Path) -> None:
396
+ if path.suffix not in {".ts", ".js", ".py"}:
397
+ return
398
+ text = path.read_text(encoding="utf-8", errors="ignore")
399
+ for i, raw in enumerate(text.splitlines(), 1):
400
+ if re.search(r"(fetch|axios|request|curl|urllib|http\.get)\s*\(\s*(req\.|request\.|params|query|body)", raw, re.IGNORECASE):
401
+ self.add(
402
+ rule="ssrf-risk",
403
+ severity=Severity.HIGH,
404
+ file=path,
405
+ line=i,
406
+ message="Outgoing request URL may be controlled by user input, creating SSRF risk.",
407
+ suggestion="Use an allowlist of permitted URLs/hosts and never forward raw user input to backend requests.",
408
+ confidence="low",
409
+ )
410
+
411
+ def _scan_for_insecure_cookies(self, path: Path) -> None:
412
+ if path.suffix not in {".ts", ".js", ".py"}:
413
+ return
414
+ text = path.read_text(encoding="utf-8", errors="ignore")
415
+ for i, raw in enumerate(text.splitlines(), 1):
416
+ if re.search(r"res\.cookie\s*\(|set-cookie|cookie\s*\(", raw, re.IGNORECASE):
417
+ lowered = raw.lower()
418
+ if "secure" not in lowered or "httponly" not in lowered or "samesite" not in lowered:
419
+ self.add(
420
+ rule="insecure-cookie-settings",
421
+ severity=Severity.MEDIUM,
422
+ file=path,
423
+ line=i,
424
+ message="Cookie/session setter is missing Secure, HttpOnly, or SameSite attributes.",
425
+ suggestion="Set Secure, HttpOnly, and SameSite=Strict/Lax on session cookies.",
426
+ confidence="low",
427
+ )
428
+
429
+ def _scan_for_debug_endpoints(self, path: Path) -> None:
430
+ if path.suffix not in {".ts", ".js", ".py"}:
431
+ return
432
+ text = path.read_text(encoding="utf-8", errors="ignore")
433
+ for i, raw in enumerate(text.splitlines(), 1):
434
+ if re.search(r"['\"](/debug(?:/[^'\"]*)?|/env(?:/[^'\"]*)?|/config(?:/[^'\"]*)?|/internal(?:/[^'\"]*)?|/admin(?:/[^'\"]*)?|/trace(?:/[^'\"]*)?)['\"]", raw, re.IGNORECASE):
435
+ if not re.search(r"auth|verify|admin|require", raw, re.IGNORECASE):
436
+ self.add(
437
+ rule="exposed-debug-endpoint",
438
+ severity=Severity.MEDIUM,
439
+ file=path,
440
+ line=i,
441
+ message="Endpoint path looks like a debug/internal route without obvious protection.",
442
+ suggestion="Protect debug endpoints with authentication, IP allowlists, or disable them in production.",
443
+ confidence="low",
444
+ )
445
+
446
+ def _scan_for_shell_exec(self, path: Path) -> None:
447
+ if path.suffix not in {".ts", ".js", ".py"}:
448
+ return
449
+ text = path.read_text(encoding="utf-8", errors="ignore")
450
+ for i, raw in enumerate(text.splitlines(), 1):
451
+ if re.search(r"(?:^|[^a-zA-Z0-9_.])(exec|execSync|execFile|spawn|child_process|subprocess\.call|os\.system)\s*\(", raw, re.IGNORECASE):
452
+ if re.search(r"(req\.|request\.|params|query|body)", raw, re.IGNORECASE):
453
+ self.add(
454
+ rule="unsafe-shell-execution",
455
+ severity=Severity.CRITICAL,
456
+ file=path,
457
+ line=i,
458
+ message="Shell command appears to execute user-controlled input.",
459
+ suggestion="Never pass user input to shell commands; use parameterized APIs or explicit allowlists.",
460
+ confidence="medium",
461
+ )
462
+
463
+ def _scan_for_rate_limits(self, path: Path) -> None:
464
+ if path.suffix not in {".ts", ".js", ".py"}:
465
+ return
466
+ if not any(part in self.SERVER_PATHS for part in {p.lower() for p in path.parts}):
467
+ return
468
+ text = path.read_text(encoding="utf-8", errors="ignore")
469
+ has_rate_limit = bool(re.search(r"(rate.?limit|throttle|slow.?down|@limiter)", text, re.IGNORECASE))
470
+ if has_rate_limit:
471
+ return
472
+ for i, raw in enumerate(text.splitlines(), 1):
473
+ if re.search(r"\.(post|put|delete|patch)\s*\(\s*['\"](login|signin|auth|register|password|reset|api/)", raw, re.IGNORECASE):
474
+ self.add(
475
+ rule="missing-rate-limit",
476
+ severity=Severity.MEDIUM,
477
+ file=path,
478
+ line=i,
479
+ message="Sensitive endpoint does not appear to have rate limiting.",
480
+ suggestion="Add rate limiting to authentication and password-reset endpoints.",
481
+ confidence="low",
482
+ )
483
+ break
484
+
485
+ def _scan_for_insecure_sessions(self, path: Path) -> None:
486
+ if path.suffix not in {".ts", ".js", ".py"}:
487
+ return
488
+ text = path.read_text(encoding="utf-8", errors="ignore")
489
+ for i, raw in enumerate(text.splitlines(), 1):
490
+ if re.search(r"session\s*\(\s*\{|express-session|cookie-session", raw, re.IGNORECASE):
491
+ lowered = raw.lower()
492
+ if "secret" in lowered and ("process.env" not in lowered and "os.environ" not in lowered and "SECRET" not in raw):
493
+ self.add(
494
+ rule="session-secret-hardcoded",
495
+ severity=Severity.HIGH,
496
+ file=path,
497
+ line=i,
498
+ message="Session secret may be hardcoded.",
499
+ suggestion="Load session secrets from environment variables or a vault.",
500
+ confidence="low",
501
+ )
502
+
503
+ def _scan_firebase_rules(self) -> None:
504
+ ignore = load_ignore_patterns(self.repo)
505
+ for path in self.repo.rglob("*.rules"):
506
+ if is_ignored(path, self.repo, ignore):
507
+ continue
508
+ if "firebase" not in path.name.lower():
509
+ continue
510
+ text = path.read_text(encoding="utf-8", errors="ignore")
511
+ lowered = text.lower()
512
+ if '".read": true' in lowered or '".write": true' in lowered or "$uid == null" in lowered or "auth == null" in lowered:
513
+ self.add(
514
+ rule="firebase-insecure-rules",
515
+ severity=Severity.CRITICAL,
516
+ file=path,
517
+ line=1,
518
+ message="Firebase rules appear to allow unauthenticated read or write access.",
519
+ suggestion="Require auth.uid() != null in rules and validate ownership conditions.",
520
+ confidence="medium",
521
+ )