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.
- dashboard/__init__.py +10 -0
- dashboard/backend/__init__.py +11 -0
- dashboard/backend/__main__.py +6 -0
- dashboard/backend/ai/__init__.py +6 -0
- dashboard/backend/ai/provider.py +102 -0
- dashboard/backend/ai/service.py +757 -0
- dashboard/backend/api/__init__.py +5 -0
- dashboard/backend/api/ai.py +142 -0
- dashboard/backend/api/dashboard.py +34 -0
- dashboard/backend/api/deps.py +51 -0
- dashboard/backend/api/findings.py +102 -0
- dashboard/backend/api/intel.py +41 -0
- dashboard/backend/api/repositories.py +62 -0
- dashboard/backend/api/scans.py +168 -0
- dashboard/backend/api/system.py +69 -0
- dashboard/backend/config.py +180 -0
- dashboard/backend/main.py +183 -0
- dashboard/backend/persistence/__init__.py +5 -0
- dashboard/backend/persistence/database.py +278 -0
- dashboard/backend/persistence/store.py +600 -0
- dashboard/backend/profiles.py +141 -0
- dashboard/backend/schemas.py +452 -0
- dashboard/backend/security.py +158 -0
- dashboard/backend/services/__init__.py +7 -0
- dashboard/backend/services/dashboard.py +172 -0
- dashboard/backend/services/intel.py +341 -0
- dashboard/backend/services/paths.py +38 -0
- dashboard/backend/services/repositories.py +123 -0
- dashboard/backend/services/scans.py +328 -0
- dashboard/backend/services/settings.py +49 -0
- dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
- dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
- dashboard/frontend/dist/index.html +29 -0
- dashboard/frontend/dist/phone-mascots.gif +0 -0
- dashboard/launcher.py +48 -0
- vibe_coding_qa/__init__.py +140 -0
- vibe_coding_qa/__main__.py +5 -0
- vibe_coding_qa/ai_review.py +165 -0
- vibe_coding_qa/cli.py +157 -0
- vibe_coding_qa/engine.py +860 -0
- vibe_coding_qa/fixes.py +222 -0
- vibe_coding_qa/gates.py +300 -0
- vibe_coding_qa/profile.py +471 -0
- vibe_coding_qa/repo.py +188 -0
- vibe_coding_qa/reporters/__init__.py +253 -0
- vibe_coding_qa/scanners/__init__.py +195 -0
- vibe_coding_qa/scanners/compliance.py +167 -0
- vibe_coding_qa/scanners/database.py +316 -0
- vibe_coding_qa/scanners/dependencies.py +595 -0
- vibe_coding_qa/scanners/infrastructure.py +125 -0
- vibe_coding_qa/scanners/performance.py +84 -0
- vibe_coding_qa/scanners/quality.py +129 -0
- vibe_coding_qa/scanners/security.py +521 -0
- vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
- vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
- vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
- vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|