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