182 lines
7.0 KiB
Python
Executable File
182 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Fail when tracked content is unsafe for a public showcase."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SELF = "scripts/check-public-safety.py"
|
|
|
|
PLACEHOLDER_WORDS = (
|
|
"change_me",
|
|
"redacted",
|
|
"placeholder",
|
|
"example",
|
|
"your_token",
|
|
"your_key",
|
|
"***",
|
|
)
|
|
|
|
ANY_IPV4 = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
|
|
ANY_IPV6 = re.compile(r"(?i)(?<![0-9a-f:])(?:[0-9a-f]{0,4}:){2,8}[0-9a-f]{0,4}(?![0-9a-f:])")
|
|
MAC = re.compile(r"(?i)\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b")
|
|
UUID = re.compile(r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b")
|
|
INTERNAL_DOMAIN = re.compile(r"(?i)\b[a-z0-9.-]+\.(?:local|lan|internal|home|private)\b")
|
|
PRIVATE_HOME = re.compile(r"(?i)/home/(?!user\b|example\b|operator\b)[a-z0-9._-]+\b")
|
|
PRIVATE_MOUNT = re.compile(r"(?i)/mnt/(?!storage\b|media\b|backups\b|example\b)[a-z0-9._-]+\b")
|
|
PRIVATE_OPT = re.compile(r"(?i)/opt/(?!example\b|application\b)[a-z0-9._-]+\b")
|
|
PRIVATE_KEY = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.I)
|
|
EMAIL = re.compile(r"(?i)\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b")
|
|
URL_HOST = re.compile(r"(?i)https?://([a-z0-9.-]+)")
|
|
SECRET_ASSIGNMENT = re.compile(
|
|
r"(?i)\b(?:api[_-]?(?:key|token)|access[_-]?token|auth[_-]?token|token|password|passwd|"
|
|
r"client[_-]?secret|cookie|private[_-]?key)\b\s*[:=]\s*[\"']?([^\s\"',}]+)"
|
|
)
|
|
AUTHORIZATION = re.compile(
|
|
r"(?i)authorization\s*[:=]\s*(?:bearer|token|basic)\s+([^\s\"']+)"
|
|
)
|
|
OPAQUE = re.compile(r"\b[A-Za-z0-9_+/=-]{32,}\b")
|
|
|
|
RISKY_SUFFIXES = {
|
|
".pem", ".key", ".p12", ".pfx", ".kdbx", ".ovpn", ".mobileconfig",
|
|
".db", ".sqlite", ".sqlite3", ".dump", ".pcap", ".pcapng",
|
|
}
|
|
RISKY_NAMES = {
|
|
".env", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa", "wg0.conf"
|
|
}
|
|
RAW_EXPORT_NAME = re.compile(r"(?i)(?:^|[-_.])(?:backup|dump|export|baseline|snapshot)(?:[-_.]|$)")
|
|
ALLOWED_URL_HOSTS = {"github.com", "service.example.net"}
|
|
|
|
|
|
def tracked_files() -> list[Path]:
|
|
result = subprocess.run(
|
|
["git", "-C", str(ROOT), "ls-files", "-z"],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return [ROOT / value.decode() for value in result.stdout.split(b"\0") if value]
|
|
|
|
|
|
def is_placeholder(value: str) -> bool:
|
|
lowered = value.lower()
|
|
return value.startswith("${") or any(word in lowered for word in PLACEHOLDER_WORDS)
|
|
|
|
|
|
def entropy(value: str) -> float:
|
|
counts = Counter(value)
|
|
total = len(value)
|
|
return -sum((count / total) * math.log2(count / total) for count in counts.values())
|
|
|
|
|
|
def main() -> int:
|
|
findings: set[str] = set()
|
|
files = tracked_files()
|
|
|
|
for path in files:
|
|
relative = path.relative_to(ROOT).as_posix()
|
|
if relative == SELF:
|
|
continue
|
|
|
|
name = path.name.lower()
|
|
if name.startswith(".env") and name != ".env.example":
|
|
findings.add(f"{relative}: prohibited environment filename")
|
|
if name in RISKY_NAMES or re.fullmatch(r"id_[a-z0-9_-]+", name):
|
|
findings.add(f"{relative}: prohibited credential filename")
|
|
if path.suffix.lower() in RISKY_SUFFIXES:
|
|
findings.add(f"{relative}: prohibited key/config suffix")
|
|
if re.fullmatch(r"wg\d+\.conf", name):
|
|
findings.add(f"{relative}: prohibited VPN configuration filename")
|
|
if RAW_EXPORT_NAME.search(name):
|
|
findings.add(f"{relative}: raw export/backup-style filename requires review")
|
|
|
|
try:
|
|
data = path.read_bytes()
|
|
except OSError as exc:
|
|
findings.add(f"{relative}: unreadable: {exc}")
|
|
continue
|
|
if b"\0" in data[:4096]:
|
|
findings.add(f"{relative}: binary tracked file requires manual review")
|
|
continue
|
|
|
|
for line_number, line in enumerate(data.decode("utf-8", errors="ignore").splitlines(), 1):
|
|
prefix = f"{relative}:{line_number}"
|
|
|
|
if ANY_IPV4.search(line):
|
|
findings.add(f"{prefix}: literal IPv4 address")
|
|
if ANY_IPV6.search(line):
|
|
findings.add(f"{prefix}: literal IPv6 address")
|
|
if MAC.search(line):
|
|
findings.add(f"{prefix}: MAC address")
|
|
if UUID.search(line):
|
|
findings.add(f"{prefix}: UUID/device identifier")
|
|
if INTERNAL_DOMAIN.search(line):
|
|
findings.add(f"{prefix}: internal domain")
|
|
if PRIVATE_HOME.search(line):
|
|
findings.add(f"{prefix}: private home path")
|
|
if PRIVATE_MOUNT.search(line):
|
|
findings.add(f"{prefix}: private mount path")
|
|
if PRIVATE_OPT.search(line):
|
|
findings.add(f"{prefix}: private application path")
|
|
if PRIVATE_KEY.search(line):
|
|
findings.add(f"{prefix}: private-key block")
|
|
|
|
for match in EMAIL.finditer(line):
|
|
domain = match.group(1).lower()
|
|
if not domain.endswith(("example.com", "example.net", "example.org")):
|
|
findings.add(f"{prefix}: non-example email address")
|
|
|
|
for match in URL_HOST.finditer(line):
|
|
host = match.group(1).lower()
|
|
if host not in ALLOWED_URL_HOSTS and not host.endswith(
|
|
(".example.com", ".example.net", ".example.org")
|
|
):
|
|
findings.add(f"{prefix}: non-approved URL host")
|
|
|
|
for match in SECRET_ASSIGNMENT.finditer(line):
|
|
value = match.group(1)
|
|
if not is_placeholder(value):
|
|
findings.add(f"{prefix}: non-placeholder secret assignment")
|
|
|
|
for match in AUTHORIZATION.finditer(line):
|
|
value = match.group(1)
|
|
if not is_placeholder(value):
|
|
findings.add(f"{prefix}: authorization material")
|
|
|
|
line_without_urls = re.sub(r"https?://\S+", "", line)
|
|
for candidate in OPAQUE.findall(line_without_urls):
|
|
if is_placeholder(candidate):
|
|
continue
|
|
# Commit hashes and content digests are still identifiers; require a label.
|
|
labelled_hash = re.search(
|
|
r"(?i)(?:\b(?:sha(?:1|256|512)|digest|commit|checksum|example[_-]?hash)\b|\buses\s*:)",
|
|
line,
|
|
)
|
|
if labelled_hash and re.fullmatch(r"[0-9a-fA-F]{32,128}", candidate):
|
|
continue
|
|
if entropy(candidate) >= 3.5:
|
|
findings.add(f"{prefix}: opaque high-entropy value")
|
|
break
|
|
|
|
if findings:
|
|
print("Public-safety scan failed:")
|
|
for finding in sorted(findings):
|
|
print(f"- {finding}")
|
|
return 1
|
|
|
|
content_count = len(files) - int(any(path.relative_to(ROOT).as_posix() == SELF for path in files))
|
|
print(
|
|
"Public-safety scan passed: "
|
|
f"{content_count} tracked files content-scanned; detector source executed separately"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|