feat(security): enforce ADR 0005 hosting boundary segregation strictly to family/
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "boto3>=1.34.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
audit_hosting_boundaries.py — Automated Continuous Leak Prevention Guard (ADR 0005).
|
||||
|
||||
Verifies that no personal, familial, or physiological data files are exposed at the
|
||||
unauthenticated root or public folders of MinIO bucket 'hosting'.
|
||||
Enforces:
|
||||
1. All dashboards for Mila, Moritz, family nutrition, and baby sleep must strictly reside under 'family/'.
|
||||
2. Root and public directories must only contain public assets (/recipes/*, /showcase/*).
|
||||
3. Any violating key found at root or public prefixes is detected and optionally quarantined/purged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
|
||||
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
|
||||
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
|
||||
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
|
||||
|
||||
SENSITIVE_PATTERNS = [
|
||||
"mila",
|
||||
"moritz",
|
||||
"nutrition",
|
||||
"sleep",
|
||||
"schlaf",
|
||||
"health",
|
||||
"diaper",
|
||||
"windel",
|
||||
"fläschchen",
|
||||
"flaeschchen",
|
||||
"garmin",
|
||||
"zwift",
|
||||
"incyclist",
|
||||
]
|
||||
|
||||
|
||||
def get_s3_client():
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=DEFAULT_ENDPOINT,
|
||||
aws_access_key_id=DEFAULT_ACCESS_KEY,
|
||||
aws_secret_access_key=DEFAULT_SECRET_KEY,
|
||||
config=Config(signature_version="s3v4", connect_timeout=5, retries={"max_attempts": 2}),
|
||||
region_name="us-east-1",
|
||||
)
|
||||
|
||||
|
||||
def audit_hosting_bucket(clean: bool = False) -> list[str]:
|
||||
"""Scans MinIO bucket for sensitive personal files outside family/."""
|
||||
s3 = get_s3_client()
|
||||
violations: list[str] = []
|
||||
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
for page in paginator.paginate(Bucket=DEFAULT_BUCKET):
|
||||
for obj in page.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
# Whitelisted protected prefixes
|
||||
if key.startswith("family/") or key.startswith("private/"):
|
||||
continue
|
||||
|
||||
# Check if key matches sensitive personal data
|
||||
key_lower = key.lower()
|
||||
if any(term in key_lower for term in SENSITIVE_PATTERNS):
|
||||
violations.append(key)
|
||||
|
||||
if violations:
|
||||
print(f"🚨 SECURITY VIOLATION: Found {len(violations)} sensitive personal file(s) outside /family/ in bucket '{DEFAULT_BUCKET}':", file=sys.stderr)
|
||||
for v in violations:
|
||||
print(f" - {v}", file=sys.stderr)
|
||||
if clean:
|
||||
try:
|
||||
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=v)
|
||||
print(f" [CLEANED] Deleted '{v}' from public area.", file=sys.stderr)
|
||||
except Exception as ex:
|
||||
print(f" [ERROR] Failed to delete '{v}': {ex}", file=sys.stderr)
|
||||
else:
|
||||
print(f"✅ S3 Hosting Audit PASSED: Zero sensitive personal files found in public areas of bucket '{DEFAULT_BUCKET}'.")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def audit_local_public_vault(vault_dir: Path, clean: bool = False) -> list[Path]:
|
||||
"""Scans local knowledge-wiki/public directory for sensitive files outside family/."""
|
||||
violations: list[Path] = []
|
||||
public_dir = vault_dir / "public"
|
||||
if not public_dir.is_dir():
|
||||
return violations
|
||||
|
||||
for f in public_dir.iterdir():
|
||||
if f.is_file():
|
||||
f_lower = f.name.lower()
|
||||
if any(term in f_lower for term in SENSITIVE_PATTERNS):
|
||||
violations.append(f)
|
||||
if clean:
|
||||
try:
|
||||
f.unlink()
|
||||
print(f" [CLEANED] Deleted local unauthenticated file: {f}")
|
||||
except Exception as ex:
|
||||
print(f" [ERROR] Failed to delete {f}: {ex}")
|
||||
|
||||
if violations and not clean:
|
||||
print(f"🚨 Local Vault Public Warning: Found {len(violations)} personal file(s) in {public_dir} root.", file=sys.stderr)
|
||||
elif not violations:
|
||||
print(f"✅ Local Vault Public Audit PASSED: No sensitive files at public root.")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Audit and enforce ADR 0005 hosting boundary segregation")
|
||||
parser.add_argument("--clean", action="store_true", help="Automatically delete any violating files found")
|
||||
parser.add_argument("--vault", help="Path to knowledge-wiki directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
vault_path = Path(args.vault).resolve() if args.vault else Path(__file__).resolve().parents[4] / "knowledge-wiki"
|
||||
|
||||
s3_violations = []
|
||||
try:
|
||||
s3_violations = audit_hosting_bucket(clean=args.clean)
|
||||
except Exception as ex:
|
||||
print(f"Warning: could not connect to S3/MinIO for audit: {ex}", file=sys.stderr)
|
||||
|
||||
local_violations = audit_local_public_vault(vault_path, clean=args.clean)
|
||||
|
||||
if s3_violations or local_violations:
|
||||
if not args.clean:
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user