[verified] Add privacy and branding audit harness
This commit is contained in:
218
script/privacy_audit
Executable file
218
script/privacy_audit
Executable file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static privacy/branding audit for the private Chatwoot fork.
|
||||
|
||||
The audit deliberately fails only on explicit privacy rules. Visible product
|
||||
branding is reported separately until the branding phase is complete. No
|
||||
network calls are made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RULES = (
|
||||
("hub-url", re.compile(r"\bhub\.2\.chatwoot\.com\b", re.IGNORECASE)),
|
||||
("amplitude-sdk", re.compile(r"@amplitude/analytics-browser", re.IGNORECASE)),
|
||||
("sentry-sdk", re.compile(r"@sentry/vue", re.IGNORECASE)),
|
||||
(
|
||||
"hub-method",
|
||||
re.compile(
|
||||
r"\bChatwootHub\.(?:sync_with_hub|register_instance|emit_event|send_push(?:_with_response)?)\b"
|
||||
),
|
||||
),
|
||||
("cwctl-event-report", re.compile(r"\breport_event\b", re.IGNORECASE)),
|
||||
)
|
||||
|
||||
VISIBLE_BRANDING = re.compile(r"\bChatwoot\b")
|
||||
VISIBLE_ROOTS = ("app/views", "app/javascript", "config/locales", "public")
|
||||
BUILT_ROOTS = ("public/assets", "public/packs", "public/vite")
|
||||
SKIP_PARTS = {".git", "node_modules", ".pnpm-store", "tmp", "log", "coverage", "storage"}
|
||||
DEFAULT_ALLOWED_PATHS = {"LICENSE", "script/privacy_audit", "script/privacy_audit_test.sh"}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent.parent,
|
||||
help="repository or fixture root to scan",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowlist-file",
|
||||
type=Path,
|
||||
help="file containing exact repository-relative paths allowed to contain findings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report-only",
|
||||
action="store_true",
|
||||
help="print findings but exit zero; useful before removal/branding phases",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_skipped(path: Path, root: Path) -> bool:
|
||||
relative_parts = path.relative_to(root).parts
|
||||
if any(part in SKIP_PARTS for part in relative_parts):
|
||||
return True
|
||||
return len(relative_parts) >= 2 and relative_parts[:2] == ("public", "uploads")
|
||||
|
||||
|
||||
def is_safe_file(path: Path, root: Path) -> bool:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return False
|
||||
try:
|
||||
path.resolve(strict=True).relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def files_under(root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for directory, directory_names, file_names in os.walk(root, topdown=True, followlinks=False):
|
||||
current = Path(directory)
|
||||
directory_names[:] = [
|
||||
name for name in directory_names if not (current / name).is_symlink()
|
||||
]
|
||||
files.extend(
|
||||
current / name
|
||||
for name in file_names
|
||||
if is_safe_file(current / name, root)
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def tracked_files(root: Path) -> list[Path]:
|
||||
git_dir = root / ".git"
|
||||
if git_dir.exists():
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "ls-files", "-z"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
paths = [root / Path(raw.decode("utf-8")) for raw in result.stdout.split(b"\0") if raw]
|
||||
else:
|
||||
paths = files_under(root)
|
||||
|
||||
for relative_root in BUILT_ROOTS:
|
||||
build_root = root / relative_root
|
||||
if build_root.exists() and not build_root.is_symlink():
|
||||
paths.extend(files_under(build_root))
|
||||
|
||||
unique = {path for path in paths if is_safe_file(path, root)}
|
||||
return sorted(path for path in unique if not is_skipped(path, root))
|
||||
|
||||
|
||||
def load_allowlist(root: Path, allowlist_file: Path | None) -> set[str]:
|
||||
entries = set(DEFAULT_ALLOWED_PATHS)
|
||||
if allowlist_file is None:
|
||||
return entries
|
||||
|
||||
allowlist_path = allowlist_file.resolve()
|
||||
try:
|
||||
allowlist_relative = allowlist_path.relative_to(root).as_posix()
|
||||
except ValueError as error:
|
||||
raise ValueError("allowlist file must be inside --root") from error
|
||||
|
||||
entries.add(allowlist_relative)
|
||||
for raw_line in allowlist_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("/")
|
||||
if (
|
||||
Path(line).is_absolute()
|
||||
or line in {".", ".."}
|
||||
or line.startswith(("./", "../", "/"))
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise ValueError(
|
||||
f"allowlist entry must be an exact repository-relative path: {line}"
|
||||
)
|
||||
entries.add(line)
|
||||
return entries
|
||||
|
||||
|
||||
def relative_path(path: Path, root: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def is_visible_path(relative: str) -> bool:
|
||||
return any(relative == root or relative.startswith(f"{root}/") for root in VISIBLE_ROOTS)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str | None:
|
||||
data = path.read_bytes()
|
||||
if b"\0" in data:
|
||||
return None
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print(f"privacy_audit: root does not exist: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
allowlist = load_allowlist(root, args.allowlist_file)
|
||||
files = tracked_files(root)
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
print(f"privacy_audit: discovery failed: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
findings = 0
|
||||
visible_reports = 0
|
||||
for path in files:
|
||||
relative = relative_path(path, root)
|
||||
text = read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
allowed = relative in allowlist
|
||||
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
for category, pattern in RULES:
|
||||
if pattern.search(line):
|
||||
if allowed:
|
||||
continue
|
||||
prefix = "REPORT" if args.report_only else "FAIL"
|
||||
print(f"{prefix} {relative}:{line_number}:{category}")
|
||||
findings += 1
|
||||
break
|
||||
|
||||
if is_visible_path(relative) and VISIBLE_BRANDING.search(line):
|
||||
print(f"REPORT {relative}:{line_number}:visible-branding")
|
||||
visible_reports += 1
|
||||
|
||||
if args.report_only:
|
||||
print(
|
||||
f"privacy_audit: REPORT-ONLY findings={findings} "
|
||||
f"visible_branding_reports={visible_reports} files={len(files)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if findings:
|
||||
print(
|
||||
f"privacy_audit: FAIL findings={findings} "
|
||||
f"visible_branding_reports={visible_reports} files={len(files)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"privacy_audit: PASS findings=0 "
|
||||
f"visible_branding_reports={visible_reports} files={len(files)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user