Files
homelab-showcase/scripts/check-public-safety.py
fizzlepoof 3e0975af21
Some checks failed
public-safety / privacy-and-secret-scan (push) Has been cancelled
Harden public showcase privacy controls
2026-09-08 22:04:38 +00:00

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())