[verified] Add privacy and branding audit harness
This commit is contained in:
145
.hermes/evidence/chatwoot-private/baseline.md
Normal file
145
.hermes/evidence/chatwoot-private/baseline.md
Normal 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
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())
|
||||
114
script/privacy_audit_test.sh
Executable file
114
script/privacy_audit_test.sh
Executable 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'
|
||||
Reference in New Issue
Block a user