|
- #!/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.')
|