loki-mode 7.85.0 → 7.87.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,652 @@
1
+ #!/usr/bin/env python3
2
+ """Secure-by-default scan engine for Loki Mode (Loop 4, secure-by-default gate).
3
+
4
+ A standalone, high-precision scanner for a SMALL set of known-bad security
5
+ patterns in a generated app. The entire value of this scanner is precision: a
6
+ security gate that cries wolf is worse than none, because false positives turn
7
+ into friction and users learn to ignore it. So every rule here is written to
8
+ fire on the genuinely dangerous pattern AND to stay silent on the safe
9
+ equivalent. When in doubt we do NOT flag (a missed finding is a known, accepted
10
+ tradeoff for v1 trust; we never invent one).
11
+
12
+ The scanner is honest by construction:
13
+ - It never claims an app is "secure". It reports "N known-bad patterns found"
14
+ (or zero). Absence of these specific findings is not a guarantee of safety.
15
+ - Each finding carries an actionable fix: what is wrong, where (file:line),
16
+ and how to fix it.
17
+ - It scans only text files and skips binaries, vendored trees (node_modules,
18
+ .git, vendor, bundled dist of dependencies) and oversized files, so it is
19
+ cheap and deterministic.
20
+
21
+ The five v1 rules (see internal/LOOP4-SECURE-BY-DEFAULT-PLAN.md):
22
+ 1. private-key-committed (HIGH) a PEM private-key block in any text file
23
+ 2. secret-in-client-file (HIGH) a real secret literal in a browser-served file
24
+ 3. world-open-datastore (HIGH) a literal world-open datastore rule
25
+ 4. debug-in-prod (MEDIUM) a debug flag enabled in a production-config file
26
+ 5. cors-wildcard-credentials (MEDIUM) ACAO * together with ACAC true
27
+
28
+ CLI (mirrors dashboard/audit.py / proof-verify.py shim style):
29
+ python3 autonomy/lib/secure-scan.py <target_dir> [--json]
30
+ Prints a JSON result:
31
+ {
32
+ "rules_version": "1.0",
33
+ "findings": [
34
+ {"rule","file","line","severity","message","fix"}, ...
35
+ ],
36
+ "summary": {"total": N, "by_severity": {"HIGH": h, "MEDIUM": m}}
37
+ }
38
+ Exit codes:
39
+ 0 no findings
40
+ 1 one or more findings
41
+ 2 bad input (target dir missing / unreadable)
42
+ """
43
+
44
+ import json
45
+ import os
46
+ import re
47
+ import sys
48
+
49
+ RULES_VERSION = "1.0"
50
+
51
+ # --- scan limits -----------------------------------------------------------
52
+ # Cap per-file size so a giant generated bundle cannot stall the gate. 2 MiB is
53
+ # generous for source/config; real secrets live in small files and large minified
54
+ # vendor bundles are skipped anyway via SKIP_DIRS / extension filtering.
55
+ MAX_FILE_BYTES = 2 * 1024 * 1024
56
+
57
+ # Directories we never descend into: VCS metadata, installed dependencies, and
58
+ # vendored/build trees that are not the user's own authored code. Flagging a
59
+ # dependency's example key is pure noise.
60
+ SKIP_DIRS = {
61
+ ".git", "node_modules", "vendor", "bower_components",
62
+ ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
63
+ ".tox", ".idea", ".vscode", ".gradle", ".terraform",
64
+ "site-packages", ".next", ".nuxt", ".cache",
65
+ }
66
+
67
+ # Extensions that are unambiguously binary; reading them as text is wasted work
68
+ # and the regexes are meaningless against them.
69
+ BINARY_EXT = {
70
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
71
+ ".pdf", ".zip", ".gz", ".tar", ".tgz", ".bz2", ".xz", ".7z", ".rar",
72
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
73
+ ".mp3", ".mp4", ".mov", ".avi", ".mkv", ".wav", ".flac",
74
+ ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".class",
75
+ ".jar", ".war", ".pyc", ".pyo", ".wasm", ".db", ".sqlite", ".sqlite3",
76
+ }
77
+
78
+ # Web roots: a file under one of these directories is shipped to the browser.
79
+ # We match a path COMPONENT equal to one of these so "src/public/app.js" counts
80
+ # but "publication.js" does not.
81
+ WEB_ROOT_COMPONENTS = {"public", "static", "dist", "www", "build", "assets"}
82
+
83
+ # Client-side source extensions. A .html is always browser-served; .js / .mjs /
84
+ # .jsx / .ts(x) are browser-served only when they ALSO sit under a web root
85
+ # (server-side Node code is also .js and we must not flag a key in a server file
86
+ # here -- rule 1 covers PEM keys anywhere, but rule 2 is specifically about the
87
+ # leak-to-browser surface).
88
+ CLIENT_ALWAYS_EXT = {".html", ".htm"}
89
+ CLIENT_IF_WEBROOT_EXT = {".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"}
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # rule 1: private key committed
94
+ # ---------------------------------------------------------------------------
95
+ # A PEM private-key header is real key material. The false-positive rate is
96
+ # near zero: the literal "-----BEGIN ... PRIVATE KEY-----" framing does not
97
+ # occur in normal source except in actual keys (or a deliberate test fixture,
98
+ # which is exactly what should be flagged anyway).
99
+ _PEM_PRIVATE_KEY = re.compile(
100
+ r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----"
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # rule 2: secret literal in a browser-served file
106
+ # ---------------------------------------------------------------------------
107
+ # Known-prefix secret shapes. These prefixes are vendor-issued and have a fixed
108
+ # alphabet/length, so matching them is high precision (unlike generic entropy
109
+ # heuristics, which are FP-prone and deliberately NOT used here).
110
+ _SECRET_PATTERNS = [
111
+ # AWS access key id
112
+ ("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
113
+ # OpenAI-style secret key (sk- followed by >=20 url-safe chars)
114
+ ("openai-key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
115
+ # GitHub personal access token (classic): ghp_ + 36 chars
116
+ ("github-pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
117
+ # Google API key: AIza + 35 url-safe chars
118
+ ("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
119
+ # Slack bot token: xoxb- + token body
120
+ ("slack-bot-token", re.compile(r"\bxoxb-[0-9A-Za-z-]{10,}\b")),
121
+ ]
122
+
123
+ # Placeholder / example markers. If the matched secret (or its surrounding
124
+ # token) is an obvious placeholder, do NOT flag it. This is the core
125
+ # false-positive guard for rule 2: docs, .env.example files and templates are
126
+ # full of fake keys and flagging them is exactly the cry-wolf failure mode.
127
+ _PLACEHOLDER_TOKENS = (
128
+ "xxxx", "your-key", "your_key", "yourkey", "example", "examplekey",
129
+ "changeme", "change-me", "placeholder", "redacted", "dummy", "sample",
130
+ "test-key", "testkey", "fake", "todo", "replace", "insert",
131
+ )
132
+ _PLACEHOLDER_RE = re.compile(r"<[^>]+>|\{\{[^}]+\}\}|\$\{[^}]+\}")
133
+
134
+
135
+ def _is_placeholder_secret(secret):
136
+ """True if the matched secret literal is an obvious placeholder, not a real
137
+ credential. Conservative: only the well-known fake markers, plus an
138
+ all-same-character body (e.g. AKIAXXXXXXXXXXXXXXXX or all zeros)."""
139
+ low = secret.lower()
140
+ for tok in _PLACEHOLDER_TOKENS:
141
+ if tok in low:
142
+ return True
143
+ # strip the known prefix so we examine just the secret body
144
+ body = secret
145
+ for prefix in ("akia", "sk-", "ghp_", "aiza", "xoxb-"):
146
+ if low.startswith(prefix):
147
+ body = secret[len(prefix):]
148
+ break
149
+ if body:
150
+ # all-identical character (XXXX..., 0000..., AAAA...) => placeholder
151
+ if len(set(body.lower())) <= 1:
152
+ return True
153
+ # mostly x or 0 (a common redaction style) => placeholder
154
+ filler = sum(1 for c in body.lower() if c in "x0")
155
+ if filler >= max(1, int(len(body) * 0.8)):
156
+ return True
157
+ return False
158
+
159
+
160
+ def _markup_wraps_match(line, start, end):
161
+ """True only if a template placeholder (<...>, {{...}}, ${...}) CONTAINS the
162
+ matched secret span [start,end) -- i.e. the value itself is templated, like
163
+ key="${SECRET}" or "<YOUR_KEY>". This must NOT suppress a real secret that
164
+ merely shares a line with an unrelated tag (e.g. an HTML <script>/<meta>
165
+ element with a baked-in key), which the old whole-line check wrongly hid."""
166
+ for mm in _PLACEHOLDER_RE.finditer(line):
167
+ if mm.start() <= start and mm.end() >= end:
168
+ return True
169
+ return False
170
+
171
+
172
+ def _is_browser_served(rel_path):
173
+ """True if a file at rel_path is shipped to the browser.
174
+
175
+ .html is always browser-served. Client-script extensions count only when a
176
+ path component is a known web root, so a server-side foo.js does not trip
177
+ rule 2 (a server file leaking a key to the browser is the thing we are
178
+ detecting; a key in server-only code is a different, non-client concern)."""
179
+ ext = os.path.splitext(rel_path)[1].lower()
180
+ if ext in CLIENT_ALWAYS_EXT:
181
+ return True
182
+ if ext in CLIENT_IF_WEBROOT_EXT:
183
+ parts = {p.lower() for p in rel_path.replace("\\", "/").split("/")}
184
+ return bool(parts & WEB_ROOT_COMPONENTS)
185
+ return False
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # rule 3: world-open datastore (literal forms only)
190
+ # ---------------------------------------------------------------------------
191
+ # Firebase realtime/firestore rules granting unconditional read/write.
192
+ # Matches ".write": true / ".read": true (any whitespace, single or
193
+ # double quotes). This is the literal "anyone can read/write everything" rule.
194
+ # A rule whose value is a string expression (e.g. "auth != null") is NOT
195
+ # matched, because true is the only unconditional-open literal.
196
+ _FIREBASE_OPEN = re.compile(
197
+ r"""['"]\.(?:read|write)['"]\s*:\s*true\b"""
198
+ )
199
+ # S3 / bucket ACL granting public access.
200
+ _S3_PUBLIC_ACL = re.compile(
201
+ r"""['"]?(?:acl|ACL)['"]?\s*[:=]\s*['"](?:public-read|public-read-write)['"]"""
202
+ )
203
+ # AWS canned ACL constant used in CDK/SDK code. MUST be QUALIFIED by an AWS/S3
204
+ # context token so it does not fire on an app-domain identifier (e.g. an
205
+ # ACCESS_LEVELS enum, a Visibility enum, a permissions doc) that merely contains
206
+ # the words PUBLIC_READ. We require either a dotted SDK form
207
+ # (CannedAccessControl.PUBLIC_READ, BucketAccessControl.PUBLIC_READ,
208
+ # s3.X.PUBLIC_READ) or PUBLIC_READ adjacent to an s3/bucket/acl/canned token on
209
+ # the same match. Bare `PUBLIC_READ` (a common permission identifier) is ignored.
210
+ _S3_PUBLIC_ENUM = re.compile(
211
+ r"""(?:
212
+ (?:Canned[A-Za-z]*|BucketAccessControl|s3|S3)\s*\.\s*
213
+ (?:[A-Za-z_]+\s*\.\s*)?PUBLIC_READ(?:_WRITE)?\b
214
+ | \bPUBLIC_READ(?:_WRITE)?\b[^\n]{0,40}?\b(?:acl|ACL|[Bb]ucket|s3|S3|[Cc]anned)\b
215
+ | \b(?:acl|ACL|[Bb]ucket|s3|S3|[Cc]anned)\b[^\n]{0,40}?\bPUBLIC_READ(?:_WRITE)?\b
216
+ )""",
217
+ re.VERBOSE,
218
+ )
219
+ # Lines that are pure comments/docs must never trigger rule 3 (a SECURITY.md or a
220
+ # code comment warning AGAINST PUBLIC_READ is not a vulnerability).
221
+ _COMMENT_OR_DOC_LINE = re.compile(r"""^\s*(?:#|//|\*|<!--|>)""")
222
+ # Supabase: an anon/public policy combined with RLS disabled. We require BOTH
223
+ # the explicit disable AND a public/anon grant in the same file to avoid
224
+ # flagging a legitimately RLS-disabled internal table.
225
+ _SUPABASE_RLS_DISABLE = re.compile(
226
+ r"DISABLE\s+ROW\s+LEVEL\s+SECURITY", re.IGNORECASE
227
+ )
228
+ _SUPABASE_PUBLIC_GRANT = re.compile(
229
+ r"\bTO\s+(?:anon|public)\b", re.IGNORECASE
230
+ )
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # rule 4: debug enabled in production config
235
+ # ---------------------------------------------------------------------------
236
+ # Django settings DEBUG = True
237
+ _DJANGO_DEBUG = re.compile(r"^\s*DEBUG\s*=\s*True\b")
238
+ # Flask app.run(debug=True)
239
+ _FLASK_DEBUG = re.compile(r"\.run\s*\([^)]*\bdebug\s*=\s*True\b")
240
+ # FLASK_DEBUG / DEBUG env set to 1 / true (env-file or compose form)
241
+ _ENV_DEBUG = re.compile(
242
+ r"^\s*(?:FLASK_DEBUG|DJANGO_DEBUG)\s*[:=]\s*['\"]?(?:1|true|True)\b"
243
+ )
244
+
245
+ # A file is treated as production config when its name/path marks it so. dev /
246
+ # test / local files are excluded: flagging DEBUG=True in settings_dev.py is the
247
+ # cry-wolf failure for rule 4.
248
+ _PROD_NAME_RE = re.compile(
249
+ r"(?:^|/)(?:settings\.py|prod[._-]?[^/]*\.py|[^/]*\.production[^/]*|"
250
+ r"settings[._-]?prod[^/]*\.py|production\.py)$",
251
+ re.IGNORECASE,
252
+ )
253
+ _PROD_ENVFILE_RE = re.compile(r"(?:^|/)\.env\.production$", re.IGNORECASE)
254
+ _DEV_MARKER_RE = re.compile(
255
+ r"(?:^|/|[._-])(?:dev|development|test|tests|local|staging|sample|example|"
256
+ r"settings_dev|settings_local|settings_test)(?:[._/-]|$)",
257
+ re.IGNORECASE,
258
+ )
259
+
260
+
261
+ def _is_production_config(rel_path):
262
+ """True if rel_path looks like a production config file (and NOT a dev/test
263
+ one). The dev exclusion takes precedence: an explicitly dev-marked path is
264
+ never production even if it also matches a prod pattern."""
265
+ norm = rel_path.replace("\\", "/")
266
+ base = os.path.basename(norm)
267
+ if _DEV_MARKER_RE.search(norm):
268
+ # settings.py itself is not dev-marked; but settings_dev.py is excluded
269
+ return False
270
+ if _PROD_ENVFILE_RE.search(norm):
271
+ return True
272
+ if _PROD_NAME_RE.search(norm):
273
+ return True
274
+ # bare settings.py (Django default prod settings module)
275
+ if base.lower() == "settings.py":
276
+ return True
277
+ return False
278
+
279
+
280
+ # Rules 3 (world-open datastore) and 5 (CORS) are LITERAL-config rules: the
281
+ # patterns (".write": true, public-read, ACAO * + credentials) are only a
282
+ # vulnerability when they are ACTUAL configuration, never when they appear in
283
+ # prose, a docstring, a comment, or example text. Rather than try to strip every
284
+ # comment/string form out of arbitrary source (fragile, and an attacker can open
285
+ # a fake comment to mask a real finding -- a green-wash), we SCOPE these rules to:
286
+ # (a) recognized config file types/names, OR
287
+ # (b) a line that is itself a recognized config-call form (handled inline).
288
+ # A code file's docstring/comment is therefore never scanned for rules 3/5, and
289
+ # there is no block-tracking to evade. (rules 1/2/4 are unaffected and still scan
290
+ # everything: a private key or client secret is a leak in ANY file.)
291
+ _CONFIG_EXT = {
292
+ ".json", ".yaml", ".yml", ".toml", ".tf", ".tfvars", ".hcl",
293
+ ".conf", ".cfg", ".ini", ".rules", ".properties", ".env", ".sql",
294
+ }
295
+ _CONFIG_NAME_RE = re.compile(
296
+ r"""(?:
297
+ firebase\.json | firestore\.rules | database\.rules\.json
298
+ | storage\.rules | \.firebaserc
299
+ | nginx(?:\.conf)? | httpd\.conf | \.htaccess
300
+ | cors[-_.]? | s3[-_.]?(?:policy|bucket)
301
+ | serverless\.(?:yml|yaml) | vercel\.json | netlify\.toml
302
+ )""",
303
+ re.IGNORECASE | re.VERBOSE,
304
+ )
305
+
306
+
307
+ def _is_config_context_file(rel_path):
308
+ """True if the file is a configuration file (by extension or known name),
309
+ where a literal datastore/CORS rule is real config rather than prose."""
310
+ norm = rel_path.replace("\\", "/")
311
+ base = os.path.basename(norm).lower()
312
+ _, ext = os.path.splitext(base)
313
+ if ext in _CONFIG_EXT:
314
+ return True
315
+ if _CONFIG_NAME_RE.search(base):
316
+ return True
317
+ return False
318
+
319
+
320
+ # Recognized config-CALL forms in code files: these ARE configuration even in a
321
+ # .js/.py/.ts source, so rules 3/5 still apply to a line matching one. Kept tight
322
+ # to avoid matching prose: a real cors() middleware call, an nginx add_header, an
323
+ # AWS SDK ACL assignment, a firebase database().setRules-style call.
324
+ _CONFIG_CALL_RE = re.compile(
325
+ r"""(?:
326
+ \bcors\s*\( | \badd_header\b | BucketAccessControl | CannedAccessControl
327
+ | \bAccessControlAllow # camelCase header constant
328
+ # hyphenated header in a real SETTER context (res.setHeader(...),
329
+ # add_header, headers[...]=). NOT a bare `Header: value` colon form, which
330
+ # also appears in prose/comments in code files; the bare colon form is only
331
+ # trusted inside a real config FILE (handled by _file_is_config), never via
332
+ # this code-line config-call path.
333
+ | (?:setHeader|set_header|add_header|writeHead|headers?\s*\[)[^\n]{0,40}?Access-Control-Allow-(?:Origin|Credentials)
334
+ | setRules\s*\( | \bacl\s*[:=]
335
+ )""",
336
+ re.IGNORECASE | re.VERBOSE,
337
+ )
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # rule 5: CORS wildcard with credentials
342
+ # ---------------------------------------------------------------------------
343
+ # The dangerous combination is ACAO * AND ACAC true in the same config. A bare
344
+ # ACAO * (without credentials) is a common, intentional public-API setting and
345
+ # is NOT flagged. We require both signals in the same file.
346
+ # Accept the wildcard quoted ('*' / "*") OR bare (the raw nginx/apache header
347
+ # form `add_header Access-Control-Allow-Origin *;` and `... -Origin: *`). Widening
348
+ # to bare * cannot false-positive on its own: rule 5 only fires when ACAC true is
349
+ # ALSO present in the same file (a bare ACAO * without credentials stays allowed).
350
+ _ACAO_WILDCARD = re.compile(
351
+ r"""Access-Control-Allow-Origin['"]?\s*[:=,]?\s*['"]?\*['"]?""",
352
+ re.IGNORECASE,
353
+ )
354
+ # Code/framework forms of a wildcard origin: express/cors `origin: "*"` AND
355
+ # flask-cors `origins="*"` (plural, = separator). Either signals an any-origin
356
+ # CORS policy in code.
357
+ _ACAO_WILDCARD_CODE = re.compile(
358
+ r"""\borigins?\s*[:=]\s*['"]\*['"]""",
359
+ re.IGNORECASE,
360
+ )
361
+ _ACAC_TRUE = re.compile(
362
+ r"""Access-Control-Allow-Credentials['"]?\s*[:=,]?\s*['"]?true['"]?""",
363
+ re.IGNORECASE,
364
+ )
365
+ # Code/framework forms of credentials-enabled: express/cors `credentials: true`
366
+ # AND flask-cors `supports_credentials=True`.
367
+ _ACAC_TRUE_CODE = re.compile(
368
+ r"""\b(?:credentials\s*[:=]\s*true|supports_credentials\s*=\s*true)\b""",
369
+ re.IGNORECASE,
370
+ )
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # file walking / reading
375
+ # ---------------------------------------------------------------------------
376
+
377
+ def _iter_files(target_dir):
378
+ """Yield (abs_path, rel_path) for each candidate text file under target_dir,
379
+ skipping VCS/dependency/build trees, binary extensions and oversized files."""
380
+ for root, dirs, files in os.walk(target_dir):
381
+ # prune skip dirs in place so os.walk never descends into them
382
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
383
+ for name in files:
384
+ ext = os.path.splitext(name)[1].lower()
385
+ if ext in BINARY_EXT:
386
+ continue
387
+ abs_path = os.path.join(root, name)
388
+ try:
389
+ if os.path.islink(abs_path):
390
+ continue
391
+ size = os.path.getsize(abs_path)
392
+ except OSError:
393
+ continue
394
+ if size > MAX_FILE_BYTES:
395
+ continue
396
+ rel_path = os.path.relpath(abs_path, target_dir)
397
+ yield abs_path, rel_path
398
+
399
+
400
+ def _read_lines(abs_path):
401
+ """Read a file as text lines. Returns a list of (lineno, text) or None if the
402
+ file is not decodable as UTF-8 text (treat as binary, skip)."""
403
+ try:
404
+ with open(abs_path, "r", encoding="utf-8") as f:
405
+ content = f.read()
406
+ except (UnicodeDecodeError, OSError):
407
+ return None
408
+ # A NUL byte is the strongest binary signal that slipped past the extension
409
+ # filter; skip such files.
410
+ if "\x00" in content:
411
+ return None
412
+ return list(enumerate(content.splitlines(), start=1))
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # per-file scanning
417
+ # ---------------------------------------------------------------------------
418
+
419
+ def _finding(rule, rel_path, line, severity, message, fix):
420
+ return {
421
+ "rule": rule,
422
+ "file": rel_path,
423
+ "line": line,
424
+ "severity": severity,
425
+ "message": message,
426
+ "fix": fix,
427
+ }
428
+
429
+
430
+ def _scan_file(rel_path, lines):
431
+ """Return a list of findings for one file. `lines` is [(lineno, text), ...]."""
432
+ findings = []
433
+ browser_served = _is_browser_served(rel_path)
434
+ prod_config = _is_production_config(rel_path)
435
+
436
+ # rule 3 / rule 5 need whole-file context for the AND conditions.
437
+ saw_rls_disable = None # lineno of a DISABLE ROW LEVEL SECURITY
438
+ saw_public_grant = False
439
+ saw_acao_wildcard = None # lineno
440
+ saw_acac_true = False
441
+
442
+ # Rules 3/5 (literal datastore/CORS config) are scoped to config CONTEXT, so a
443
+ # docstring/comment/prose mention in a code or doc file is never scanned for
444
+ # them (root-level precision; no fragile comment/block stripping to evade).
445
+ _file_is_config = _is_config_context_file(rel_path)
446
+
447
+ for lineno, text in lines:
448
+ # --- rule 1: private key committed (any text file) ---------------
449
+ if _PEM_PRIVATE_KEY.search(text):
450
+ findings.append(_finding(
451
+ "private-key-committed", rel_path, lineno, "HIGH",
452
+ "A PEM private key block is present in this file.",
453
+ "Remove the private key from the repo, rotate the key "
454
+ "immediately (assume it is compromised), and load it at runtime "
455
+ "from a secret manager or an untracked file referenced via "
456
+ "environment variable.",
457
+ ))
458
+
459
+ # --- rule 2: secret literal in a browser-served file -------------
460
+ if browser_served:
461
+ for kind, pat in _SECRET_PATTERNS:
462
+ for m in pat.finditer(text):
463
+ secret = m.group(0)
464
+ if _is_placeholder_secret(secret):
465
+ continue
466
+ # Suppress only when template markup WRAPS the matched secret
467
+ # (a templated/example value like key="<YOUR_KEY>"), NOT when
468
+ # any unrelated tag is elsewhere on the line. The old
469
+ # whole-line check let a real secret inside an HTML line with
470
+ # any tag (e.g. <script>var k="AKIA...";</script>) slip
471
+ # through silently -- the common HTML inline-secret leak.
472
+ if _markup_wraps_match(text, m.start(), m.end()):
473
+ continue
474
+ findings.append(_finding(
475
+ "secret-in-client-file", rel_path, lineno, "HIGH",
476
+ "A live %s appears in a file served to the browser; "
477
+ "anyone who loads the page can read it." % kind,
478
+ "Remove the secret from client code, rotate it, and move "
479
+ "the call that needs it to a server-side endpoint or a "
480
+ "build-time secret that is never shipped to the client.",
481
+ ))
482
+
483
+ # Rules 3/5 fire ONLY in a real config context: a config file, OR a line
484
+ # that is itself a recognized config-call form. This makes prose,
485
+ # docstrings, comments, and example text in code/doc files invisible to
486
+ # the literal rules at the ROOT (no comment/string stripping to get wrong,
487
+ # no block-tracking to evade), while a real datastore/CORS misconfig in a
488
+ # .json/.yaml/.conf or a cors()/add_header/ACL line still fires.
489
+ # A config-call form makes a CODE line config-context -- but NOT if the
490
+ # line is a comment (a comment "call cors() with origin * carefully" is
491
+ # prose, not configuration; firing on it is cry-wolf). Config FILES are
492
+ # config-context regardless (their lines are data, not prose). A line in a
493
+ # config file that is a comment is also skipped via _COMMENT_OR_DOC_LINE.
494
+ _rel_lower = rel_path.lower()
495
+ _is_doc_file = _rel_lower.endswith((".md", ".mdx", ".rst", ".txt", ".adoc"))
496
+ _line_is_comment = bool(_COMMENT_OR_DOC_LINE.match(text))
497
+ # A config-call form makes a line config-context only in a non-doc,
498
+ # non-comment line (a doc/comment mentioning a header name is prose).
499
+ _config_call_line = (
500
+ not _is_doc_file and not _line_is_comment
501
+ and bool(_CONFIG_CALL_RE.search(text))
502
+ )
503
+ _config_ctx = (
504
+ (_file_is_config and not _is_doc_file and not _line_is_comment)
505
+ or _config_call_line
506
+ )
507
+ _rule3_skip = not _config_ctx
508
+
509
+ # --- rule 3a: Firebase world-open rule ---------------------------
510
+ if not _rule3_skip and _FIREBASE_OPEN.search(text):
511
+ findings.append(_finding(
512
+ "world-open-datastore", rel_path, lineno, "HIGH",
513
+ "A Firebase rule grants unconditional public read/write "
514
+ "(\".read\"/\".write\": true).",
515
+ "Replace the literal true with an auth condition, for example "
516
+ "\"auth != null\" or a per-document ownership check, so only "
517
+ "authorized users can access the data.",
518
+ ))
519
+ # --- rule 3b: S3 / bucket public ACL -----------------------------
520
+ if not _rule3_skip and (_S3_PUBLIC_ACL.search(text) or _S3_PUBLIC_ENUM.search(text)):
521
+ findings.append(_finding(
522
+ "world-open-datastore", rel_path, lineno, "HIGH",
523
+ "A storage bucket is configured with a public-read(-write) ACL.",
524
+ "Remove the public ACL, enable block-public-access on the "
525
+ "bucket, and serve objects through signed URLs or an "
526
+ "authenticated endpoint instead.",
527
+ ))
528
+ # --- rule 3c: Supabase RLS disabled (whole-file AND) -------------
529
+ # Also config-context gated (skip comments/prose/doc files), consistent
530
+ # with rules 3a/3b/5: an inline comment naming "DISABLE ROW LEVEL
531
+ # SECURITY" is prose, not a live migration statement.
532
+ if not _rule3_skip:
533
+ if saw_rls_disable is None and _SUPABASE_RLS_DISABLE.search(text):
534
+ saw_rls_disable = lineno
535
+ if _SUPABASE_PUBLIC_GRANT.search(text):
536
+ saw_public_grant = True
537
+
538
+ # --- rule 4: debug in production config --------------------------
539
+ if prod_config:
540
+ if (_DJANGO_DEBUG.search(text) or _FLASK_DEBUG.search(text)
541
+ or _ENV_DEBUG.search(text)):
542
+ findings.append(_finding(
543
+ "debug-in-prod", rel_path, lineno, "MEDIUM",
544
+ "Debug mode is enabled in a production configuration file; "
545
+ "debug mode leaks stack traces, settings and secrets to "
546
+ "users on error.",
547
+ "Set debug to False/0 for production (drive it from an "
548
+ "environment variable that defaults to off), and keep "
549
+ "debug-on only in dev/local config.",
550
+ ))
551
+
552
+ # --- rule 5: CORS wildcard + credentials (whole-file AND) --------
553
+ # Skip prose/doc lines (_rule3_skip = comment-only line or .md/.rst/.txt
554
+ # doc file): a README or comment WARNING about ACAO * + credentials is not
555
+ # a misconfiguration. Same prose-vs-config principle as rule 3.
556
+ if not _rule3_skip:
557
+ if saw_acao_wildcard is None and (
558
+ _ACAO_WILDCARD.search(text) or _ACAO_WILDCARD_CODE.search(text)):
559
+ saw_acao_wildcard = lineno
560
+ if _ACAC_TRUE.search(text) or _ACAC_TRUE_CODE.search(text):
561
+ saw_acac_true = True
562
+
563
+ # whole-file rule 3c verdict
564
+ if saw_rls_disable is not None and saw_public_grant:
565
+ findings.append(_finding(
566
+ "world-open-datastore", rel_path, saw_rls_disable, "HIGH",
567
+ "Row Level Security is disabled while a public/anon grant exists, "
568
+ "leaving the table world-accessible.",
569
+ "Re-enable Row Level Security (ENABLE ROW LEVEL SECURITY) and write "
570
+ "explicit policies that scope access to authenticated/authorized "
571
+ "users instead of granting to anon/public.",
572
+ ))
573
+
574
+ # whole-file rule 5 verdict (the dangerous combo, not a bare wildcard)
575
+ if saw_acao_wildcard is not None and saw_acac_true:
576
+ findings.append(_finding(
577
+ "cors-wildcard-credentials", rel_path, saw_acao_wildcard, "MEDIUM",
578
+ "CORS allows any origin (*) AND allows credentials; this exposes "
579
+ "authenticated responses to every website.",
580
+ "Do not combine a wildcard origin with credentials. Echo back a "
581
+ "specific allowlist of trusted origins when credentials are "
582
+ "required, or drop Allow-Credentials if the endpoint is truly "
583
+ "public.",
584
+ ))
585
+
586
+ return findings
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # scan driver
591
+ # ---------------------------------------------------------------------------
592
+
593
+ def scan(target_dir):
594
+ """Scan target_dir and return the result dict.
595
+
596
+ Raises ValueError if target_dir is not a readable directory (the CLI maps
597
+ that to exit code 2)."""
598
+ if not os.path.isdir(target_dir):
599
+ raise ValueError("not a directory: %s" % target_dir)
600
+
601
+ findings = []
602
+ for abs_path, rel_path in _iter_files(target_dir):
603
+ lines = _read_lines(abs_path)
604
+ if lines is None:
605
+ continue
606
+ findings.extend(_scan_file(rel_path, lines))
607
+
608
+ # deterministic ordering: by file, then line, then rule
609
+ findings.sort(key=lambda f: (f["file"], f["line"], f["rule"]))
610
+
611
+ by_severity = {}
612
+ for f in findings:
613
+ by_severity[f["severity"]] = by_severity.get(f["severity"], 0) + 1
614
+
615
+ return {
616
+ "rules_version": RULES_VERSION,
617
+ "findings": findings,
618
+ "summary": {
619
+ "total": len(findings),
620
+ "by_severity": by_severity,
621
+ },
622
+ }
623
+
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # CLI shim (mirrors dashboard/audit.py / proof-verify.py style)
627
+ # ---------------------------------------------------------------------------
628
+
629
+ def _cli(argv=None):
630
+ argv = list(sys.argv[1:] if argv is None else argv)
631
+ # --json is accepted for symmetry with the other shims; output is always
632
+ # JSON, so the flag is a no-op kept for a stable, predictable interface.
633
+ args = [a for a in argv if a != "--json"]
634
+ if not args or argv[0] in ("-h", "--help"):
635
+ print(json.dumps(
636
+ {"error": "usage: secure-scan.py <target_dir> [--json]"}))
637
+ return 2
638
+ target_dir = args[0]
639
+ try:
640
+ result = scan(target_dir)
641
+ except ValueError as exc:
642
+ print(json.dumps({"error": str(exc)}))
643
+ return 2
644
+ except Exception as exc: # defensive: never a traceback-as-UX
645
+ print(json.dumps({"error": "scan failed: %s" % exc}))
646
+ return 2
647
+ print(json.dumps(result, indent=2))
648
+ return 1 if result["summary"]["total"] > 0 else 0
649
+
650
+
651
+ if __name__ == "__main__":
652
+ sys.exit(_cli())