From 8ebb320bd44afe845bb5e8bf76c2a8de1a6edab7 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Sat, 15 Aug 2026 13:10:14 +0700 Subject: [PATCH] [verified] Add privacy and branding audit harness --- .hermes/evidence/chatwoot-private/baseline.md | 145 ++++++++++++ script/privacy_audit | 218 ++++++++++++++++++ script/privacy_audit_test.sh | 114 +++++++++ 3 files changed, 477 insertions(+) create mode 100644 .hermes/evidence/chatwoot-private/baseline.md create mode 100755 script/privacy_audit create mode 100755 script/privacy_audit_test.sh diff --git a/.hermes/evidence/chatwoot-private/baseline.md b/.hermes/evidence/chatwoot-private/baseline.md new file mode 100644 index 000000000..9e06cdedc --- /dev/null +++ b/.hermes/evidence/chatwoot-private/baseline.md @@ -0,0 +1,145 @@ +# Private Fork Baseline Evidence + +Captured: 2026-08-15T11:57:57+07:00 +Repository: `/Users/kunthawat/Gitea/Chatwoot` + +## Repository + +```text +HEAD=9a73c1473ffa0ae6a9c7725046b8ca17922dcc83 +SHALLOW=true +STATUS=?? .hermes/ +?? HANDOFF.md +?? engineering-log.md +``` + +The audited HEAD matches the current HEAD. The existing untracked planning/log files were present before implementation. No application source changes are present. + +## Runtime + +```text +ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25] +node v26.5.1 +pnpm 10.2.0 +``` + +`pnpm` emitted these baseline warnings: + +```text +[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting. +WARN Unsupported engine: wanted: {"node":"24.x"} (current: {"node":"v26.5.1","pnpm":"10.2.0"}) +``` + +`bundle -v` and `bundle exec rails -v` are blocked before Rails boot: + +```text +Could not find 'bundler' (2.5.16) required by your /Users/kunthawat/Gitea/Chatwoot/Gemfile.lock. (Gem::GemNotFoundException) +To update to the latest version installed on your system, run `gem install bundler:2.5.16` +``` + +No package installation was performed in this baseline task. + +## Backend narrow baseline + +All seven commands were run separately. Every command exited `1` in `0` seconds before loading the application because Bundler `2.5.16` is missing: + +```text +bundle exec rspec spec/lib/chatwoot_hub_spec.rb +bundle exec rspec spec/jobs/internal/check_new_versions_job_spec.rb +bundle exec rspec spec/controllers/installation/onboarding_controller_spec.rb +bundle exec rspec spec/services/notification/push_notification_service_spec.rb +bundle exec rspec spec/controllers/api/v1/accounts_controller_spec.rb +bundle exec rspec spec/services/website_branding_service_spec.rb +bundle exec rspec spec/controllers/dashboard_controller_spec.rb +``` + +Baseline blocker: install/use the lockfile's Bundler and compatible Ruby toolchain before backend specs can provide pass/fail application results. + +## Frontend narrow baseline + +All three commands were run separately. Every command exited `1` because `node_modules` is absent: + +```text +pnpm test app/javascript/dashboard/helper/AnalyticsHelper/specs/helper.spec.js +``` + +Result: + +```text +sh: vitest: command not found +ELIFECYCLE Test failed +WARN Local package.json exists, but node_modules missing +``` + +```text +pnpm test app/javascript/shared/composables/specs/useBranding.spec.js +``` + +Result: same `vitest: command not found` / missing `node_modules` blocker. + +```text +pnpm eslint app/javascript/dashboard/helper/AnalyticsHelper/index.js app/javascript/entrypoints/v3app.js app/javascript/shared/components/Branding.vue +``` + +Result: + +```text +sh: eslint: command not found +ELIFECYCLE Command failed +WARN Local package.json exists, but node_modules missing +``` + +Baseline blocker: install dependencies using the repository-approved toolchain before frontend tests/lint can provide application results. + +## Static checks + +```text +git diff --check +``` + +Result: exit `0`, no output. + +## Baseline status + +- Repository identity: **PASS** +- Backend application baseline: **BLOCKED** by missing Bundler `2.5.16` +- Frontend application baseline: **BLOCKED** by missing `node_modules` (`vitest`/`eslint` unavailable) +- No baseline failures are classified as application regressions because the test runners did not boot. + +## Follow-up environment-unblocked baseline + +Captured: 2026-08-15T12:18:12+07:00. The repository toolchain was installed outside the repository and a temporary PostgreSQL 18 test cluster was prepared at `/tmp/chatwoot-pgdata` with `pgvector`. + +```text +ruby 3.4.4 +Bundler 2.5.16 +node v24.19.0 +pnpm 10.2.0 +Bundle complete! 151 Gemfile dependencies, 379 gems now installed. +``` + +Targeted backend commands were rerun against `RAILS_ENV=test`, `POSTGRES_HOST=127.0.0.1`, database `chatwoot_test`: + +```text +spec/lib/chatwoot_hub_spec.rb 8 examples, 0 failures +spec/jobs/internal/check_new_versions_job_spec.rb 1 example, 0 failures +spec/controllers/installation/onboarding_controller_spec.rb 6 examples, 0 failures +spec/services/notification/push_notification_service_spec.rb 3 examples, 0 failures +spec/controllers/api/v1/accounts_controller_spec.rb 27 examples, 5 failures +spec/lib/chatwoot_exception_tracker_spec.rb 2 examples, 0 failures +``` + +The five `accounts_controller_spec` failures are baseline failures on the pristine HEAD: the requests returned `404` or did not invoke `AccountBuilder`/`ChatwootCaptcha` under the local test environment. They are recorded as pre-existing and are not attributed to SM-01.04. + +Targeted frontend commands were rerun with Node 24 and installed dependencies: + +```text +pnpm test app/javascript/dashboard/helper/AnalyticsHelper/specs/helper.spec.js + 1 file, 11 tests passed +pnpm test app/javascript/shared/composables/specs/useBranding.spec.js + 1 file, 8 tests passed +pnpm eslint app/javascript/dashboard/helper/AnalyticsHelper/index.js app/javascript/entrypoints/v3app.js app/javascript/shared/components/Branding.vue + ESLint: No issues found +``` + +Warnings observed but not failures: pnpm ignores the deprecated `pnpm.overrides` field, Browserslist data is outdated, and Rails emits existing enum deprecation warnings. diff --git a/script/privacy_audit b/script/privacy_audit new file mode 100755 index 000000000..848f72a35 --- /dev/null +++ b/script/privacy_audit @@ -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()) diff --git a/script/privacy_audit_test.sh b/script/privacy_audit_test.sh new file mode 100755 index 000000000..5cdd81d6a --- /dev/null +++ b/script/privacy_audit_test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +AUDIT="$SCRIPT_DIR/privacy_audit" +ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-test.XXXXXX") +OUTSIDE=$(mktemp "${TMPDIR:-/tmp}/privacy-audit-outside.XXXXXX") +OUTSIDE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-outside-dir.XXXXXX") +TRACKED_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/privacy-audit-tracked.XXXXXX") +trap 'rm -rf "$ROOT" "$OUTSIDE" "$OUTSIDE_DIR" "$TRACKED_ROOT"' EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +[ -x "$AUDIT" ] || fail 'privacy audit script missing or not executable' + +mkdir -p "$ROOT/app" "$ROOT/docs" +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$ROOT/app/forbidden.txt" + +set +e +output=$( + python3 "$AUDIT" --root "$ROOT" 2>&1 +) +status=$? +set -e +[ "$status" -ne 0 ] || fail 'forbidden URL must fail the audit' +printf '%s\n' "${output}" | grep -F 'app/forbidden.txt:1:hub-url' >/dev/null || fail 'audit did not report the forbidden file and line' +rm "$ROOT/app/forbidden.txt" + +printf '%s\n' 'docs/audit-record.md' > "$ROOT/allowlist.txt" +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$ROOT/docs/audit-record.md" +python3 "$AUDIT" --root "$ROOT" --allowlist-file "$ROOT/allowlist.txt" >/dev/null || fail 'explicitly allowlisted audit document must pass' + +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$OUTSIDE" +set +e +outside_allowlist_status=$(python3 "$AUDIT" --root "$ROOT" --allowlist-file "$OUTSIDE" >/dev/null 2>&1) +outside_allowlist_exit=$? +set -e +[ "$outside_allowlist_exit" -eq 2 ] || fail 'allowlist outside root must return configuration error status 2' +rm "$ROOT/docs/audit-record.md" +ln -s "$OUTSIDE" "$ROOT/app/outside-link" +set +e +symlink_status=$(python3 "$AUDIT" --root "$ROOT" >/dev/null 2>&1) +symlink_exit=$? +set -e +[ "$symlink_exit" -eq 0 ] || fail 'audit must not follow a symlink outside the root' + +mkdir -p "$ROOT/public" +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$OUTSIDE_DIR/bad.js" +ln -s "$OUTSIDE_DIR" "$ROOT/public/vite" +set +e +built_symlink_output=$(python3 "$AUDIT" --root "$ROOT" 2>&1) +built_symlink_exit=$? +set -e +[ "$built_symlink_exit" -eq 0 ] || fail 'audit must not traverse a symlinked built-artifact directory' + +mkdir -p "$ROOT/public/assets" +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$ROOT/public/assets/bundle.js" +set +e +built_output=$(python3 "$AUDIT" --root "$ROOT" 2>&1) +built_exit=$? +set -e +[ "$built_exit" -ne 0 ] || fail 'audit must scan a real built artifact' +printf '%s\n' "$built_output" | grep -F 'public/assets/bundle.js:1:hub-url' >/dev/null || fail 'audit did not report the built artifact finding' +rm "$ROOT/public/assets/bundle.js" + +printf 'binary\0https://hub.2.chatwoot.com/ping\n' > "$ROOT/app/binary.dat" +set +e +binary_output=$(python3 "$AUDIT" --root "$ROOT" >/dev/null 2>&1) +binary_status=$? +set -e +[ "$binary_status" -eq 0 ] || fail 'binary files must be skipped without failing the audit' +rm "$ROOT/app/binary.dat" + +mkdir -p "$TRACKED_ROOT/app" +git -C "$TRACKED_ROOT" init -q +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$OUTSIDE_DIR/tracked.js" +ln -s "$OUTSIDE_DIR" "$TRACKED_ROOT/app/generated" +git -C "$TRACKED_ROOT" add app/generated +set +e +tracked_symlink_output=$(python3 "$AUDIT" --root "$TRACKED_ROOT" 2>&1) +tracked_symlink_exit=$? +set -e +[ "$tracked_symlink_exit" -eq 0 ] || fail 'audit must ignore tracked symlinks escaping the root' + +rm "$ROOT/app/outside-link" +printf '%s\n' 'https://hub.2.chatwoot.com/ping' > "$ROOT/app/forbidden.txt" +printf '%s\n' '../app/forbidden.txt' > "$ROOT/invalid-allowlist.txt" +set +e +invalid_allowlist_output=$(python3 "$AUDIT" --root "$ROOT" --allowlist-file "$ROOT/invalid-allowlist.txt" 2>&1) +invalid_allowlist_exit=$? +set -e +[ "$invalid_allowlist_exit" -eq 2 ] || fail 'non-relative allowlist entries must return configuration error status 2' + +set +e +report_output=$( + python3 "$AUDIT" --root "$ROOT" --report-only 2>&1 +) +report_status=$? +set -e +[ "$report_status" -eq 0 ] || fail 'report-only mode must not fail on known findings' +printf '%s\n' "$report_output" | grep -F 'app/forbidden.txt:1:hub-url' >/dev/null || fail 'report-only mode did not report the finding' + +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +set +e +repo_report=$(python3 "$AUDIT" --root "$REPO_ROOT" --report-only 2>&1) +repo_status=$? +set -e +[ "$repo_status" -eq 0 ] || fail 'report-only mode must scan a git repository without crashing' +printf '%s\n' "$repo_report" | grep -F 'privacy_audit: REPORT-ONLY' >/dev/null || fail 'git-root report-only summary missing' + +printf 'PASS: privacy audit harness\n'