[verified] Add privacy and branding audit harness

This commit is contained in:
Kunthawat Greethong
2026-08-15 13:10:14 +07:00
parent 9a73c1473f
commit 8ebb320bd4
3 changed files with 477 additions and 0 deletions

View File

@@ -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.

218
script/privacy_audit Executable file
View 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())

114
script/privacy_audit_test.sh Executable file
View File

@@ -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'