Updates the 2026-05-22 audit to reflect the three shipped fixes: - #2 yarn audit CI gate (PR #196) - #3 devDependency vulnerabilities 42 → 0 (PR #196) - #4 style-src 'unsafe-inline' removed from all three CSP layers (PR #197) New score: 72/82 (was 66/82). Sole remaining HIGH: .env keystore password. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
14 KiB
MetaScrub Security Audit — 2026-05-22
Performed by Claude Code (claude-sonnet-4-6) using the auditing-repo-security skill.
Status update — 2026-05-23. Three of eight remediation items shipped: #2 (yarn audit CI gate), #3 (devDependency vulns 42 → 0), and #4 (CSP
style-src 'unsafe-inline'removed). New score: 72 / 82 (was 66). Sole remaining HIGH:.envkeystore password in plaintext. See "Remediation Plan" and "Resolved Findings" sections below.
Stack Identification
Type: Privacy-focused metadata-stripping single-page application — desktop offline standalone HTML and Android APK (Capacitor wrapper) as primary distributions; self-hosted Docker PWA as a secondary path.
Languages / Frameworks: TypeScript 5.7 (strict), React 19, Vite 7, Capacitor 7, Gradle for Android. WASM via @ffmpeg/core (GPL) and @uswriting/exiftool (zeroperl WebAssembly Perl). No backend; all processing runs in the browser.
Threat model — who is the attacker?
| Attacker | Surface | Realistic? |
|---|---|---|
| Malicious file on disk | User drops a ZIP-bomb, oversized file, or crafted binary | Yes — primary threat |
| Attacker distributes modified standalone HTML | Trojanised offline file replaces the legitimate one | Yes — no integrity check at distribution |
| CI/CD supply-chain | Compromised devDependency or GitHub Action injects malicious code | Medium |
| Local user with machine access | Reads .env (APK signing key + password) |
Yes — single-user risk |
| Network attacker | There is no server; CSP blocks all external fetches | No (by design) |
Trust boundaries: file bytes from the filesystem (the only user-provided input); localStorage (settings, deserialized at startup); inline DOM base64 (WASM bytes in standalone build); CI secrets (keystore password).
Audit Scope
Audited: All files in the repo excluding node_modules/, dist/, .git/, .docker-cache/, .claude/worktrees/, .worktrees/. Examined: all TypeScript source under src/, CI workflows, Dockerfiles, nginx.conf, public/_headers, android/ manifests and Gradle configs, .env, .env.sample, .gitignore, test fixtures.
Tooling run: yarn audit (v1.22.22) — output incorporated below. gitleaks and trufflehog not found on PATH.
LSP available: TypeScript LSP present; used grep/read as primary tooling given the cross-cutting nature of this audit.
Insufficient Data
- Yarn lockfile CVE check:
yarn auditonly covers advisories indexed by npm; advisories published after training cutoff may be missed. A real-time SCA scan (Snyk, OSV-Scanner) should supplement. - APK binary: the committed
android/app/src/main/assets/public/was inspected for structure but the built APK was not decompiled withapktoolto verify the final AndroidManifest permissions list. @6over3/zeroperl-tsWASM capability set: the audit statement "zeroperl.wasm declares zero sock_* imports" comes from the in-repo docs/poc commentary, not from an independentwasm-objdumprun in this session.
Executive Summary
MetaScrub has a genuinely strong privacy and security posture for a client-side-only tool: a strict multi-layer CSP (meta tag + nginx + Cloudflare Pages headers), zero network calls in production, no analytics or telemetry, encrypted-ZIP detection, and ZIP-bomb budgeting. As of 2026-05-23, the CSP has been tightened to drop style-src 'unsafe-inline' (PR #197) and all 42 devDependency vulnerabilities have been resolved with a yarn audit --level high CI gate added (PR #196). The most concrete remaining finding is a real APK-signing keystore password held in the local .env file in plaintext — while gitignored and not in VCS, it is the private key to the Android release signing trust chain and sits unencrypted on the developer's disk. The Capacitor config.xml <access origin="*"> and a missing HSTS header in the Docker nginx config are minor hardening gaps.
Score: 72 / 82 (was 66 / 82 at audit time; +6 after 2026-05-23 fixes)
(Domain 1 — AuthN/AuthZ excluded from denominator: local offline tool with no user accounts)
| Domain | Max | Score | Δ | Key reason |
|---|---|---|---|---|
| 1. AuthN/AuthZ | 18 | N/A | — | No auth surface — local offline tool |
| 2. Injection & Input Handling | 16 | 14 | +1 | No server-side injection; ZIP-bomb mitigated; style-src 'unsafe-inline' |
| 3. Secrets & Credential Management | 14 | 10 | — | Real keystore password in plaintext .env; no committed secrets |
| 4. Cryptography | 10 | 9 | — | No custom crypto; APK signing uses v1/v2/v3 correctly |
| 5. Data Exposure & Privacy | 10 | 10 | — | Backup disabled, lastModified: 0, eviction on FileEntry removal |
| 6. Security Config & Hardening | 10 | 9 | +1 | Strong CSP (style-src 'unsafe-inline'access origin="*" |
| 7. Dependencies & Supply Chain | 8 | 8 | +4 | yarn audit --level high gate added |
| 8. Error Handling & Logging | 6 | 6 | — | Result<T,E> throughout; no sensitive data in console output |
| 9. Code Quality & Hygiene | 5 | 4 | — | No eval; one unusual Blob dynamic import (not exploitable) |
| 10. Infrastructure & Deployment | 3 | 2 | — | Nginx runs as root; no HSTS; CI uses pinned action versions |
Critical Issues
(None — no directly exploitable critical-severity issues found in production code or committed secrets)
High-Priority Issues
1. Real APK signing keystore password stored in plaintext .env
- Severity: HIGH
- Location:
.env:25-26—METASCRUB_KEYSTORE_PASSWORD/METASCRUB_KEY_PASSWORD - Description: The local
.envfile (gitignored, not in VCS) contains what appears to be a real passphrase for the APK release-signing keystore — not thechange-meplaceholder from.env.sample. If a developer's machine is compromised (malware, stolen laptop, screen recording, shell history leak), both the keystore file and its password are exposed from a single device. An attacker who obtains both can sign arbitrary APKs as MetaScrub, enabling trojanised updates that pass Android's signature check. - Impact: Full APK signing trust-chain compromise; trojanised releases installable by existing users on Android.
- Fix: Use a password manager (
pass, Bitwarden CLI, 1Password CLI) to retrieve the passphrase at release-build time rather than storing it in a plaintext file. Thescripts/build-apk-local.shalready reads from the environment — sourcing from a credential helper instead of.envkeeps the password out of any file on disk. Alternatively, encrypt.envwithgit-cryptorsopsand document the decryption ceremony indocs/android-signing.md.
Medium Issues
(All three medium-severity items at audit time have been resolved. See "Resolved Findings" below.)
Resolved Findings (shipped 2026-05-23)
✅ devDependency critical/high vulnerabilities — RESOLVED in PR #196 (commit e35f8aa)
- Original: 42 vulnerabilities (2 critical
minimistprototype pollution viamadge, 21 highansi-regexReDoS via@capacitor/cli, 19 moderate). - Fix shipped:
@capacitor/cliupgraded to^7.6.5,viteupgraded to^7.3.3, andyarn resolutionsforcing patched transitive versions forminimist,ansi-regex,rollup,picomatch,postcss, andbrace-expansion. - Verified:
yarn auditnow reports 0 findings.
✅ No Dependabot / SCA wired into CI — RESOLVED in PR #196 (commit e35f8aa)
- Fix shipped:
yarn audit --level highadded as a failing step in the CItestjob (.github/workflows/ci.yml). Newhigh-or-criticaladvisories will fail PR checks automatically.
✅ style-src 'unsafe-inline' in all production CSP policies — RESOLVED in PR #197
- Original: All three CSP enforcement layers included
style-src 'self' 'unsafe-inline'. - Fix shipped:
ErrorExpansion.tsx: two static inline styles moved to BEM classes (.file-table__error-text--copyable,.file-table__copy-hint).SegmentedControl.tsx: dynamictransformmigrated to a CSS custom property (--ec-segment-offset) set viauseLayoutEffect+ref.style.setProperty— a CSSOM call, not an inlinestyleattribute, so nounsafe-inlineneeded.vite.config.web.ts: productionstyleSrcis now'self'(dev retains'unsafe-inline'for Vite HMR).nginx.conf+public/_headers: both server-level CSP headers updated tostyle-src 'self'.
- Verified: production build's CSP meta tag emits
style-src 'self'with nounsafe-inline.
Low Issues
nginx.conf— NoStrict-Transport-Securityheader. The Docker self-host config serves on HTTP:80 with no HTTPS redirect; users who deploy behind a TLS terminator get no HSTS. Addadd_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;with a comment that it should only be set when TLS is active.android/app/src/main/res/xml/config.xml:3—<access origin="*" />(default Capacitor policy). Allows WebView navigation to any URL. No external URLs appear in the app code, so the practical risk is negligible. Tighten to<access origin="https://localhost/" />for least-privilege.Dockerfile— Nginx stage runs as root (noUSERdirective).nginx:alpinedrops worker processes tonginxuser by default but the master process stays root. Switch to rootless nginx (port 8080,USER nginx).- Dev mode CSP (
vite.config.web.ts:29) —script-srcincludes'unsafe-inline'in development only (needed for Vite HMR). Acceptable because dev runs on localhost; noted for awareness. tools/forensic/fetch-video-fixtures.sh,docker/android-builder/Dockerfile— external artifacts fetched without hash verification. The cmdline-tools zip is pinned by version but not digest. Addsha256sumverification.
Positive Findings
- Zero outbound network in production: CSP
connect-src 'self'enforced identically across all three layers (meta tag, nginx, CF Pages_headers). No outboundfetch(),XMLHttpRequest,sendBeacon(), orWebSocketcall exists insrc/pointing at any external origin. - ZIP bomb protection:
zip_strategy.tsimplements a nesting-depth cap (MAX_NESTING_DEPTH = 10) and a shared cross-level decompression budget (MAX_DECOMPRESSED_BYTES = 2 GB) with a CDH pre-allocation size check. The shared budget correctly prevents "10 × cap" circumvention via per-level counters. - Android backup disabled:
AndroidManifest.xml:5-6setsandroid:allowBackup="false"andandroid:fullBackupContent="false", backed by a comprehensivedata_extraction_rules.xmlexcluding all storage domains for both cloud-backup and device-transfer. - APK signing secrets handled via tmpfile + shred:
scripts/build-apk-local.sh:193-196writes secrets to amktemp 0600file and shreds it viatrap … EXIT INT TERM, preventing secrets from appearing inps auxeordocker inspectoutput. lastModified: 0on all download files:browser_file_bytes.ts:25explicitly setslastModified: 0on every outputFileobject, implementing privacy invariant §6 (no timestamp leakage through the download path).- Settings validation at deserialization boundary:
settings_schema.ts:130-161validates alllocalStorage-sourced settings through typed guards with safe defaults, usingObject.create(null)to avoid prototype pollution on the parsed object. - No unsafe DOM patterns in source: grep across all production TypeScript files found zero occurrences of
eval, rawinnerHTMLassignment, or React's raw-HTML injection prop. - Encrypted ZIP detection:
zip_strategy.ts:544-605scans the Central Directory for ZipCrypto (flag bit 0), strong encryption (flag bit 6), and WinZip AES method 99 — more thorough than JSZip's built-in check which only catches flag bit 0. usesCleartextTraffic="false"on Android: blocks any accidental HTTP traffic at the OS layer, independent of the CSP.- Google Services removed from Gradle: the Capacitor-default
com.google.gms.google-servicesplugin reference is deleted entirely fromandroid/app/build.gradle, keeping./gradlew dependenciesandaapt dump permissionsclean.
Remediation Plan
| Priority | Action | Effort | Status |
|---|---|---|---|
| 1 | Replace .env with password-manager retrieval for APK signing secret |
1–2 h | ⏳ open |
| 2 | Wire yarn audit --level high into CI as a failing gate |
30 min | ✅ shipped 2026-05-23 (PR #196) |
| 3 | Upgrade madge and @capacitor/cli to resolve critical/high advisory chains |
1 h | ✅ shipped 2026-05-23 (PR #196 — 42 → 0 findings) |
| 4 | Tighten style-src — remove 'unsafe-inline' from all CSP policies |
2–4 h | ✅ shipped 2026-05-23 (PR #197) |
| 5 | Add HSTS header to nginx.conf (behind TLS guard / comment) |
15 min | ⏳ open |
| 6 | Tighten Capacitor config.xml to <access origin="https://localhost/" /> |
5 min | ⏳ open |
| 7 | Run rootless nginx in Dockerfile (port 8080, USER nginx) |
30 min | ⏳ open |
| 8 | Add sha256 verification for commandlinetools-linux-…_latest.zip in Android builder Dockerfile |
30 min | ⏳ open |
Dependency Note
A real-time SCA tool (Snyk, Dependabot, OSV-Scanner, yarn npm audit, etc.) should be run to catch CVEs published after my training cutoff. Versions flagged from yarn audit output (run 2026-05-22):
minimist < 1.2.6— Prototype Pollution (critical) — viamadge > dependency-tree > precinct(two paths). Advisory: npm/1097678. ✅ Resolved 2026-05-23 (PR #196).ansi-regex < 5.0.1— Inefficient RegExp Complexity / ReDoS (high) — via@capacitor/cli(three paths). Advisory: npm/1094092. ✅ Resolved 2026-05-23 (PR #196).brace-expansion(moderate) — Large numeric range defeats DoS protection — viamadge > precinct > detective-vue2 > minimatch. Advisory: npm/1119088. ✅ Resolved 2026-05-23 (PR #196).- All flagged packages were devDependencies; none reached the production browser bundle.
yarn auditpost-fix (2026-05-23): 0 findings.