#!/usr/bin/env python3 """Local secret review. Never print matched values. Use alongside manual review.""" import argparse import hashlib import math from pathlib import Path import re import subprocess import sys from collections import Counter root = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser() parser.add_argument('--staged', action='store_true') parser.add_argument('--tracked', action='store_true') args = parser.parse_args() if args.staged and args.tracked: parser.error('Choose one Git mode') mode = 'staged' if args.staged else 'tracked' if args.tracked else 'tree' def git(*parts): return subprocess.check_output(['git', '-C', str(root), *parts]) if mode != 'tree': files = [p.decode() for p in git('ls-files', '-z').split(b'\0') if p] else: files = [str(p.relative_to(root)) for p in root.rglob('*') if p.is_file() and '.git' not in p.relative_to(root).parts] # Only these two exact artifacts may bypass heuristic string/entropy inspection. # Their content is checked against audited digests; any change requires review. trusted = {} manifest = root / 'assets' / 'vendor-sha256.txt' for line in manifest.read_text().splitlines(): digest, name = line.split(' ', 1) trusted['assets/' + name] = digest keywords = re.compile(r'password|passwd|secret|token|api_key|apikey|Authorization:|BEGIN (?:RSA )?PRIVATE KEY|cookie', re.I) private_key = re.compile(r'-{5}BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-{5}') url_auth = re.compile(r'https?://[^\s/\'"]+:[^\s/\'"]+@', re.I) credential = re.compile(r'''["']?(?:password|passwd|secret|token|api_key|apikey)["']?\s*(?:=>|=|:)\s*(["'])([^"'\n]+)\1''', re.I) known_token = re.compile(r'\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|xox[baprs]-[A-Za-z0-9-]{15,})\b') private_ip = re.compile(r'\b(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})\b') quote = re.compile(r'''["']([A-Za-z0-9_+/=.-]{28,})["']''') findings = [] review = [] def flag(name, line, reason): findings.append(f'{name}:{line}: {reason}') for name in sorted(files): path = root / name if mode == 'tree' and path.is_symlink(): flag(name, 0, 'unexpected symlink'); continue if mode == 'staged': content = git('show', ':' + name) elif mode == 'tracked': content = git('show', 'HEAD:' + name) else: content = path.read_bytes() leaf = Path(name).name.lower() if (leaf in ['config.php', '.env', 'id_rsa', 'id_ed25519'] and name != 'lib/Config.php') or leaf.startswith('.env.') or leaf.endswith(('.local.php', '.sql', '.sql.gz', '.bak', '.swp', '.swo', '~', '.zip', '.tar', '.tar.gz', '.key', '.p12', '.pfx')): flag(name, 0, 'private/configuration/backup/archive filename') if name in trusted: if hashlib.sha256(content).hexdigest() != trusted[name]: flag(name, 0, 'vendor digest mismatch') continue try: text = content.decode('utf-8') except UnicodeDecodeError: flag(name, 0, 'unreviewed binary file'); continue for number, line in enumerate(text.splitlines(), 1): if keywords.search(line): review.append(f'{name}:{number}') if private_key.search(line) or known_token.search(line): flag(name, number, 'private key or known credential pattern') if url_auth.search(line) and not (name in ('tests/run.php', 'tests/backups.php') and 'example.invalid' in line): flag(name, number, 'credential embedded in URL') if private_ip.search(line): flag(name, number, 'private IP literal') for match in credential.finditer(line): value = match.group(2) if name in ('tests/run.php', 'tests/backups.php') and value.startswith('FAKE_'): continue flag(name, number, 'nonempty credential-like literal') for match in quote.finditer(line): value = match.group(1) if name in ('tests/run.php', 'tests/backups.php') and value.startswith('FAKE_'): continue if 'example.invalid' in value or re.fullmatch(r'[0-9a-f-]{36}', value): continue # Code identifiers, paths and human phrases need manual keyword review; # look for random-looking mixed-case/digit string literals. counts = Counter(value) entropy = -sum((n/len(value))*math.log2(n/len(value)) for n in counts.values()) if entropy > 4.5 and re.search('[a-z]', value) and re.search('[A-Z]', value) and re.search('[0-9]', value): flag(name, number, 'high-entropy literal requires review') print(f'Audit mode={mode}; files={len(files)}; keyword lines={len(review)}; flagged={len(findings)}') for item in findings: print(item) if findings: sys.exit(1) print('PASS heuristic scan. Keyword matches require the documented manual review; values are not printed.')