Publish sanitized current homelab architecture
Some checks failed
public-safety / privacy-and-secret-scan (push) Has been cancelled
Some checks failed
public-safety / privacy-and-secret-scan (push) Has been cancelled
This commit is contained in:
162
scripts/check-public-safety.py
Executable file
162
scripts/check-public-safety.py
Executable file
@@ -0,0 +1,162 @@
|
||||
#!/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")
|
||||
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"
|
||||
}
|
||||
RISKY_NAMES = {
|
||||
".env", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa", "wg0.conf"
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
findings.add(f"{relative}: unreadable: {exc}")
|
||||
continue
|
||||
if b"\0" in data[:4096]:
|
||||
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 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")
|
||||
|
||||
for candidate in OPAQUE.findall(line):
|
||||
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",
|
||||
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
|
||||
|
||||
print(f"Public-safety scan passed: {len(files)} tracked files checked")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user