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).
240 lines
7.8 KiB
Python
Executable File
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())
|