diff --git a/skills/datastore/scripts/audit_hosting_boundaries.py b/skills/datastore/scripts/audit_hosting_boundaries.py new file mode 100644 index 0000000..e47efca --- /dev/null +++ b/skills/datastore/scripts/audit_hosting_boundaries.py @@ -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() diff --git a/skills/datastore/scripts/update_moritz_website.py b/skills/datastore/scripts/update_moritz_website.py index 542a38a..169fe3f 100644 --- a/skills/datastore/scripts/update_moritz_website.py +++ b/skills/datastore/scripts/update_moritz_website.py @@ -475,7 +475,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: "name": "Moritz Health", "short_name": "Health", "description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.", - "start_url": "/moritz.html", + "start_url": "/family/moritz.html", + "scope": "/family/", "display": "standalone", "background_color": "#f8fafc", "theme_color": "#ffffff", @@ -1089,23 +1090,22 @@ def main(): print("Building Moritz Health Dashboard from DuckDB...") html = generate_dashboard_html(engine) - # 1. Save locally to knowledge-wiki/public/moritz.html (and also nutrition.html for legacy compatibility) - wiki_public = data_dir.parent / "public" - wiki_public.mkdir(parents=True, exist_ok=True) - out_file = Path(args.output_file) if args.output_file else (wiki_public / "moritz.html") + # 1. Save locally strictly to knowledge-wiki/public/family/moritz.html (and nutrition.html) + wiki_public_family = data_dir.parent / "public" / "family" + wiki_public_family.mkdir(parents=True, exist_ok=True) + out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "moritz.html") out_file.write_text(html, encoding="utf-8") print(f"✅ Saved locally: {out_file}") # Also keep public/family/nutrition.html in sync - family_nutrition = wiki_public / "family" / "nutrition.html" - if family_nutrition.parent.is_dir(): - family_nutrition.write_text(html, encoding="utf-8") - print(f"✅ Synced locally: {family_nutrition}") + family_nutrition = wiki_public_family / "nutrition.html" + family_nutrition.write_text(html, encoding="utf-8") + print(f"✅ Synced locally: {family_nutrition}") - # 2. Upload to S3 hosting + # 2. Upload strictly to protected S3 hosting under /family/ if not args.skip_upload: try: - url = publish_to_s3(html, key="moritz.html") + url = publish_to_s3(html, key="family/moritz.html") print(f"🚀 Published successfully to: {url}") # Also publish to /family/nutrition.html for backwards compatibility publish_to_s3(html, key="family/nutrition.html") diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py index 3c769af..a4dd062 100644 --- a/skills/datastore/scripts/update_website.py +++ b/skills/datastore/scripts/update_website.py @@ -808,17 +808,17 @@ def publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str: s3 = get_s3_client() assets_dir = SCRIPT_DIR.parent / "assets" - # HTML files (both in family/mila.html and mila.html) - for key in ["family/mila.html", "mila.html"]: - s3.put_object( - Bucket=DEFAULT_BUCKET, - Key=key, - Body=html_content.encode("utf-8"), - ContentType="text/html; charset=utf-8", - CacheControl="no-cache, no-store, must-revalidate", - ) + # HTML files (strictly under protected family/ path) + key = "family/mila.html" + s3.put_object( + Bucket=DEFAULT_BUCKET, + Key=key, + Body=html_content.encode("utf-8"), + ContentType="text/html; charset=utf-8", + CacheControl="no-cache, no-store, must-revalidate", + ) - # Static PWA Assets to upload + # Static PWA Assets to upload (strictly under family/) static_assets = [ ("mila.webmanifest", "application/manifest+json; charset=utf-8", "no-cache, no-store, must-revalidate"), ("sw.js", "application/javascript; charset=utf-8", "no-cache, no-store, must-revalidate"), @@ -831,24 +831,23 @@ def publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str: for fname, ctype, cache_ctrl in static_assets: candidates = [assets_dir / fname] if data_dir: - candidates.extend([data_dir.parent / "public" / "family" / fname, data_dir.parent / "public" / fname]) + candidates.append(data_dir.parent / "public" / "family" / fname) body_bytes = None for candidate in candidates: if candidate.is_file(): body_bytes = candidate.read_bytes() break if body_bytes: - for prefix in ["family/", ""]: - try: - s3.put_object( - Bucket=DEFAULT_BUCKET, - Key=f"{prefix}{fname}", - Body=body_bytes, - ContentType=ctype, - CacheControl=cache_ctrl, - ) - except Exception as ex: - print(f"Warning: could not upload {prefix}{fname}: {ex}", file=sys.stderr) + try: + s3.put_object( + Bucket=DEFAULT_BUCKET, + Key=f"family/{fname}", + Body=body_bytes, + ContentType=ctype, + CacheControl=cache_ctrl, + ) + except Exception as ex: + print(f"Warning: could not upload family/{fname}: {ex}", file=sys.stderr) url = f"{DEFAULT_BASE_URL}/family/mila.html" return url @@ -867,21 +866,13 @@ def main(): print("Building Mila Dashboard from DuckDB...") html = generate_dashboard_html(engine) - # 1. Save locally to knowledge-wiki/public/family/mila.html AND public/mila.html + # 1. Save locally strictly to knowledge-wiki/public/family/mila.html wiki_public_family = data_dir.parent / "public" / "family" wiki_public_family.mkdir(parents=True, exist_ok=True) out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "mila.html") out_file.write_text(html, encoding="utf-8") print(f"✅ Saved locally: {out_file}") - # Mirror to public/mila.html - root_mila = data_dir.parent / "public" / "mila.html" - try: - root_mila.write_text(html, encoding="utf-8") - print(f"✅ Synced locally: {root_mila}") - except Exception as e: - print(f"Warning: could not sync to {root_mila}: {e}", file=sys.stderr) - # 2. Upload to S3 hosting if not args.skip_upload: try: