Files
moreminimore-chat/script/privacy_audit
Kunthawat Greethong 8101395608 [privacy] remove Community Chatwoot Hub egress
Remove Community Chatwoot Hub URL/push relay/sync/registration/event/changelog
egress. changelog.js becomes a local empty-feed adapter (no axios/fetch/network)
exporting the default ChangelogApi; links.js drops the Hub changelog URL.
lib/chatwoot_hub.rb removes base_url/push_notification_url/billing_base_url/
instance_config/send_push/send_push_with_response; billing_url reads only an
explicit CHATWOOT_BILLING_URL env, HTTPS-only with host and no userinfo, and
never falls back to a Hub URL. Enterprise proprietary base_url override is
preserved (spec uses singleton_class.instance_methods(false) for edition-safety).
privacy_audit uses a narrow per-file/per-rule Enterprise exception (hub-url
only) and privacy_audit_test.sh proves forbidden Enterprise runtime lines are
still detected; deployment privacy guard unchanged. Approved by independent
five-key review deleg_49d6ee2e (passed=true, blocking arrays empty).
2026-08-16 07:37:52 +07:00

240 lines
7.8 KiB
Python
Executable File

#!/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",
"deployment/spec/setup_20.04_privacy_test.sh",
# Historical planning/evidence files may quote removed endpoints or SDKs;
# runtime source and built artifacts are never covered by this exception.
".hermes/plans/2026-08-15_092534-chatwoot-private-rebrand.md",
".hermes/plans/chatwoot-private/06-remove-amplitude.md",
# The log records removed symbols; Enterprise retains proprietary Hub code
# outside the Community release boundary and is reviewed separately.
"engineering-log.md",
"spec/enterprise/lib/chatwoot_hub_spec.rb",
}
# Enterprise keeps a proprietary Hub boundary outside the Community release.
# Allow only the known boundary URL; all other privacy rules still scan this
# runtime file so adding a forbidden SDK or event reporter cannot be hidden.
DEFAULT_ALLOWED_RULES = {
"enterprise/lib/enterprise/chatwoot_hub.rb": {"hub-url"},
}
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_path = relative in allowlist
allowed_rules = DEFAULT_ALLOWED_RULES.get(relative, set())
for line_number, line in enumerate(text.splitlines(), start=1):
for category, pattern in RULES:
if pattern.search(line):
if allowed_path or category in allowed_rules:
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())