Annual security review 2026
Self-audit. Public. Honest.
Self-audit against the two most-respected application + organisation security frameworks. Each control is listed below with our current status and supporting evidence. Where we're partial or not-applicable, we say so; we don't claim wins we can't defend. Re-verified 29 August 2026: every control was re-checked against the current code - two controls we previously marked pass are now honestly partial (tenant sign-in 2FA enforcement, incident postmortem publishing), one was fixed during the review (12-character password minimum, previously 8 on tenant paths).
Reviewed: 29 August 2026 · Frameworks: OWASP ASVS 4.0.3 · CIS Critical Security Controls v8
Pass
22 / 28
79%
Partial
6 / 28
21%
Access Control
CIS v8
CIS-6.5Require MFA for administrative access
Still not required anywhere — the enforcement we promised for Phase 30+ has not shipped. Operator (super-admin) TOTP is real once enrolled: login becomes two-step and the code is verified (app/api/superadmin/login/route.ts); enrolment itself stays optional. And we must correct our earlier wording: tenant users can enrol 2FA at /security (app/api/security/2fa) but the tenant login route never checks the code — tenant TOTP is enrolment-only today. No step-up auth on billing actions either.
Partial
Account Management
CIS v8
CIS-5.1Establish and maintain an inventory of accounts
listTenants() (lib/tenant/index.ts) enumerates every tenant dir; per-tenant users live in auth.json (lib/auth.ts) and operators in data/superadmin/auth.json with listSuperAdmins() (lib/superadmin-auth.ts). New since May: machine API keys are inventoried per tenant with soft-delete revocation, so a revoked key's later use stays attributable (lib/api-keys.ts). The superadmin console lists tenants and operators; Clerk (when enabled) adds its own user dashboard.
Pass
Application Software Security
CIS v8
CIS-16.1Establish a secure application development process
Solo author holds — all 485 commits since 2026-05-28 are Tom's. Stronger than May's informal claim: since 2026-07-24 a blocking CI verify job (.github/workflows/fly-deploy.yml) gates every deploy with typecheck, build-context guard, lint ratchet (scripts/lint-ceiling.mjs — ceiling 143, only moves down) and ~25 test suites (deploy needs: verify). Type errors fail the build (next.config.ts). Still no written SDLC doc — the enforced pipeline is the process.
Pass
CIS v8
CIS-16.7Use standard hardening configuration templates
Next standalone build (next.config.ts, output: "standalone") in a multi-stage node:20-alpine image running as non-root user scarif with an /api/health HEALTHCHECK (Dockerfile). Fly.io single-VM London runtime (fly.toml). proxy.ts sets HSTS 2y+preload, X-Frame-Options DENY, nosniff, Referrer-Policy, Permissions-Policy and a CSP with report-uri — all confirmed live on scarifone.com this review. Honest gap: CSP script-src still allows 'unsafe-inline'/'unsafe-eval' (Stripe/Meta/Clerk).
Pass
Audit Log Management
CIS v8
CIS-8.2Collect audit logs
lib/audit.ts (per-tenant, ~260 call sites, atomic writes, rolling 500 entries) + operator NDJSON append log at /data/superadmin/audit.log (lib/superadmin-auth.ts) covering logins, TOTP, team and master-key changes. New since May: lib/machine-audit.ts — append-only JSONL of every API-key / connected-Claude call, denied attempts included. All timestamped. Honest caveat: the per-tenant caps are rolling windows, not infinite retention.
Pass
CIS v8
CIS-8.5Collect detailed audit logs for sensitive data access
Key-access log (lib/key-access-audit.ts) records provider + caller + timestamp + outcome on every AI-key resolve — now all four providers (Gemini, Anthropic, OpenAI, ElevenLabs) — and is tenant-visible on /security. The machine-call audit (lib/machine-audit.ts, July) adds per-call logging of API-key access, denied attempts included. Still partial: rolling 1000-entry window, writes are fire-and-forget (dropped on disk error), and history only starts from each feature's deployment.
Partial
Authentication
OWASP ASVS
ASVS-2.1.1Passwords are at least 12 characters in length
Minimum 12 characters enforced at every password set-time path: signup (app/api/signup/route.ts), setup wizard (app/api/setup/admin + setup/save), tenant + team invites, email-verify set-password, and resets (lib/auth.ts resetTenantAdminPassword). Super-admin operators were already 12+ (lib/superadmin-auth.ts). Argon2id-hashed, never truncated. Raised from 8 to 12 in this review after we caught our own May over-credit. Hosted signups via Clerk inherit Clerk's policy.
Pass
OWASP ASVS
ASVS-2.1.2Passwords are NOT truncated
Full plaintext goes to Argon2 verbatim: argon2Hash/argon2Verify receive the raw password at lib/auth.ts:255, 404 and 442, lib/team.ts:102, lib/superadmin-auth.ts:210/239/261 and lib/agency/index.ts:249. We grepped every password path for slice/substring — nothing truncates. Argon2id has no bcrypt-style 72-byte cap, so long passphrases hash whole.
Pass
OWASP ASVS
ASVS-2.4.1Passwords are stored using approved one-way hash function (Argon2id, scrypt, bcrypt, PBKDF2)
Argon2id (algorithm 2) with memoryCost 65536 KiB, timeCost 3, parallelism 4 — lib/auth.ts:32-37, with identical constants in lib/team.ts:22, lib/superadmin-auth.ts:30-34 and lib/agency/index.ts:50-54, so every password store on the install uses the same OWASP-recommended parameters. Clerk-authenticated hosted users never touch these hashes — their stored placeholder hash is random and never verified (lib/auth.ts:306-312).
Pass
OWASP ASVS
ASVS-2.7.2Out-of-band authenticator (TOTP)
RFC 6238 TOTP is implemented dependency-free in lib/totp.ts (SHA-1, 30s step, 6 digits, ±1 skew). Super-admin login enforces it end-to-end when enrolled via a two-step handle flow (app/api/superadmin/login/route.ts:43-64,79-83). Tenant users can enrol at /security (app/api/security/2fa) — but the tenant login route never checks the mfa flag (app/api/auth/login/route.ts), so tenant TOTP isn't enforced at sign-in yet, and the planned step-up for high-risk actions hasn't shipped.
Partial
OWASP ASVS
ASVS-2.7.5WebAuthn / passkey
WebAuthn via @simplewebauthn/server + /browser 13.3.0 (package.json:61-62). lib/passkeys.ts binds rpId to the tenant subdomain (per-tenant isolation by construction), requires discoverable credentials for username-less login, and carries the ceremony challenge in a 5-min HttpOnly SameSite=Strict cookie. Full register/authenticate routes live under app/api/auth/passkey/. Sovereign users manage passkeys at /security; hosted-with-Clerk installs lean on Clerk's own passkey support.
Pass
Communications
OWASP ASVS
ASVS-9.1.1TLS 1.2+ enforced
HSTS max-age=63072000; includeSubDomains; preload set on every response in proxy.ts (line 78). Verified live 2026-08-29 with curl on both scarifone.com (Fly edge) and theruminfusionist.scarifone.com (Cloudflare + Fly) — identical header on both, and on /api responses. A live probe negotiated TLS 1.3; a TLS ≤1.1 handshake did not complete. hstspreload.org list submission is still unconfirmed.
Pass
Configuration
OWASP ASVS
ASVS-14.4.1Verify that every HTTP response contains a Content-Security-Policy header
CSP built in proxy.ts (lines 87-113) on every non-/api response: explicit allowlist for Stripe, Clerk, Meta Pixel, Google/Gemini, Anthropic, OpenAI, Shopify, Judge.me; frame-ancestors, base-uri and form-action locked down; report-uri /api/csp-report with a collector at app/api/csp-report/route.ts (rolling 500 reports, write-throttled). Verified live 2026-08-29 on both hosts. script-src still carries 'unsafe-inline' and 'unsafe-eval' — a Next.js constraint we haven't nonce'd away yet.
Pass
OWASP ASVS
ASVS-14.4.3Verify that X-Content-Type-Options: nosniff is set
X-Content-Type-Options: nosniff set unconditionally in proxy.ts (line 79) on every response, /api included. Verified live 2026-08-29 on scarifone.com, theruminfusionist.scarifone.com, and /api/health.
Pass
OWASP ASVS
ASVS-14.4.4Verify a strong Referrer-Policy is set
Referrer-Policy: strict-origin-when-cross-origin set unconditionally in proxy.ts (line 81) on every response. Verified live 2026-08-29 on both scarifone.com and theruminfusionist.scarifone.com.
Pass
OWASP ASVS
ASVS-14.4.5Verify that X-Frame-Options is set or CSP frame-ancestors is set
Both mechanisms set in proxy.ts: X-Frame-Options DENY (line 80) and CSP frame-ancestors 'none' (line 103), relaxed to SAMEORIGIN / frame-ancestors 'self' only for the /email-templates/ preview prefix — static rendered HTML with no auth, framed by the Template Engine UI. Verified live 2026-08-29: both headers present with the strict values on scarifone.com and theruminfusinist… tenant host.
Pass
Cryptography
OWASP ASVS
ASVS-6.2.1All cryptographic modules fail securely
AES-256-GCM in lib/secrets.ts. decrypt() verifies the 16-byte GCM auth tag and throws on corruption; an encrypted value whose master key has gone missing also throws rather than passing anything through. One gap closed since May: with no SCARIF_SECRET_KEY, encrypt() passes plaintext (self-host dev convenience) — /api/health now reports secretsAtRest and 503s a hosted install in that state, and a July migration encrypted the credentials already on disk.
Pass
OWASP ASVS
ASVS-6.2.4All cryptographic modules use approved algorithms
AES-256-GCM (lib/secrets.ts), Argon2id via @node-rs/argon2 with OWASP-recommended params (lib/auth.ts, lib/superadmin-auth.ts, lib/team.ts, lib/recovery-code.ts), HMAC-SHA256 (lib/license-server.ts trial state, lib/scheduler-boot.ts), Ed25519 licence signing (lib/license.ts, lib/license-issue.ts). API keys are hashed with plain SHA-256 by design — 256-bit random tokens, not passwords; rationale documented in lib/api-keys.ts. No MD5, SHA-1, DES, or RC4 anywhere in lib/.
Pass
Data Protection
CIS v8
CIS-3.11Encrypt sensitive data at rest
Tenant credentials envelope-encrypted AES-256-GCM (lib/secrets.ts) over a declarative field list (lib/tenant/sensitive-fields.ts), applied on every save (lib/tenant/index.ts). Honest note: hosted prod ran without SCARIF_SECRET_KEY until 2026-07-27, so earlier writes sat plaintext and 4 fields were off the list. Since closed: migration script, boot-time secret-sweep (lib/scheduler-boot.ts), a test that fails any new credential field off the list, and /api/health fails hosted installs without the key — live check shows secretsAtRest ok.
Pass
CIS v8
CIS-3.14Log sensitive data access
Per-tenant audit log for meaningful actions (lib/audit.ts, rolling 500 entries), now attributing machine API-key actors too. AI-key access logged per resolve — provider, caller, outcome, never the key — in lib/key-access-audit.ts (rolling 1000, 100% sampled, env-tunable) and shown on the tenant /security page. Super-admin actions append to data/superadmin/audit.log (NDJSON, append-only). Still partial: general reads of tenant data aren't individually logged, and tenant-side logs are rolling windows, not archives.
Partial
Data Recovery
CIS v8
CIS-11.1Establish and maintain a data recovery process
Nightly 04:00 backups (lib/scheduler.ts) to S3-compatible storage (lib/backups.ts): server-side AES-256, and credentials inside are already AES-256-GCM ciphertext. Restore is now real code — restoreTenant() + scripts/restore.cjs, exercised on every deploy — proven live 2026-07-28 by pulling archives back from R2 and dry-restoring. Platform state (licence, operator creds) now included. /api/health reports whether backups WORK, not just configured; 'ok' at this re-check. No restore UI yet — CLI + runbook.
Pass
Incident Response
CIS v8
CIS-17.1Designate personnel for incident response
Tom (founder, solo) is the designated responder. security@scarifone.com is published on the live /trust page (48h acknowledgement commitment at #vulnerabilities, listed again at #contact), in /legal/terms and the in-app setup page; the 72h breach SLA is live at /trust#breach — all fetched and confirmed this review. We dropped the 'forwards to his pager-equivalent' wording: mailbox routing isn't verifiable from the repo. It's Tom's inbox, and he answers it.
Pass
CIS v8
CIS-17.4Establish and maintain an incident response process
The process is documented and live at /trust#breach (72h notice to you + the ICO, postmortem on resolution) — confirmed on the live page. /incidents is live and renders /data/_super/incidents.json (app/(marketing)/incidents/page.tsx), but it shows 'No incidents recorded' — while proxy.ts itself records a real 2026-07-28 outage (~12 min, misconfigured Clerk keys, every tenant dark) that never got an entry. Partial until the publishing half of the process is actually exercised.
Partial
Network Monitoring
CIS v8
CIS-13.1Centralised security event alerting
Errors land in a server ledger (/data/error-reports.jsonl, lib/error-log.ts) that prints to Fly logs and emails us on first occurrence; an hourly in-process health sweep (lib/scheduler.ts) emails when any health check fails — backups included — with 24h suppression. Tenant-facing alerts fire to each tenant's own Slack webhook + email (lib/alerts.ts). Still no SIEM: operator alerting is push email, not a central console.
Partial
Session
OWASP ASVS
ASVS-3.2.1Sessions are unique to each individual and cannot be guessed
Sessions are HMAC-SHA256-signed payloads keyed by a per-tenant randomBytes(48) secret (lib/auth.ts:261, 479), verified with timingSafeEqual (lib/auth.ts:512) and tenant-bound so a cookie for tenant A is rejected on tenant B (lib/auth.ts:498). TTLs: 30-day tenant sessions (lib/auth.ts:27), 7-day super-admin (lib/superadmin-auth.ts:28), 14-day client-portal (lib/portal-auth.ts:35), impersonation capped at 4-24h. Remember-me off makes the cookie session-only.
Pass
OWASP ASVS
ASVS-3.4.1Cookies have Secure attribute
Secure is set whenever NODE_ENV=production — true for all hosted deploys, which also sit behind HTTPS-only Fly ingress. There is no single cookie helper any more: we checked every route that mints the session cookie (14 of them, e.g. app/api/auth/login/route.ts:138, signup, passkey, Clerk, Shopify OAuth, invites) plus the agency and portal helpers (lib/auth.ts:1030-1034, lib/portal-auth.ts:170) — all gate Secure identically. Self-hosted installs run without it only if NODE_ENV isn't production.
Pass
OWASP ASVS
ASVS-3.4.2Cookies have HttpOnly attribute
HttpOnly is set unconditionally on every session cookie we mint. We checked all 14 routes that set the tenant session (app/api/auth/login/route.ts:136, signup:279, setup/admin:97, passkey authenticate-finish:67, clerk-signin:83, clerk-callback:236, Shopify token-exchange:238 and callback:337, invites, verify-email, impersonation) plus super-admin (superadmin/login/route.ts:101), agency (lib/auth.ts:1027) and portal (lib/portal-auth.ts:165) helpers. No exceptions found.
Pass
OWASP ASVS
ASVS-3.4.3Cookies have SameSite attribute
SameSite=Lax on every tenant, agency and portal session cookie — verified across all 14 minting routes (e.g. app/api/auth/login/route.ts:137, passkey authenticate-finish:67, clerk-callback:237, Shopify OAuth token-exchange:239) and the shared helpers (lib/auth.ts:1028, lib/portal-auth.ts:166). The super-admin session (superadmin/login/route.ts:102) and passkey challenge cookies (lib/passkeys.ts:185) go stricter: SameSite=Strict.
Pass