# vibeship.eu audit checks

This document lists every check the audit runs, why it exists, what counts
as pass / fail, how we detect it, and where the code lives. It is the
source of truth for "what should an audit page report" — change this
file when you add or remove a check, before you change the code.

## How to read this

Each entry has five fields:

  - **What** — what the user-facing problem is, in plain language.
  - **Pass signal** — what condition we report as "OK".
  - **Fail signal** — what we report as a finding.
  - **How** — concrete detection method (URL probes, header parse,
    lighthouse call, regex). Cited against the actual code.
  - **Tier** — when we run it:
    - **T1** Free, fully automated, runs on every audit (built today).
    - **T2** Free, automated, but needs rate-limit handling or a small
      paid API. v2 backlog.
    - **T3** Heuristic — a smell, not a verdict. Useful as a flag in
      the report; never ship or block on it.
    - **T4** Cannot be detected externally — surface on the
      questionnaire that follows the audit. The audit's job is to
      prompt the right questions.
    - **T5** Costs money. Only for paid audits.

## The five categories

Checks are bucketed into four product categories that match the landing
page's `RISK` / `SCALE` / `MONEY` / `COMPLIANCE` problem cards, plus a
`PERFORMANCE` bucket at the top because it has its own render path with
metric tiles.

| Category      | Question the user is answering                       |
|---------------|------------------------------------------------------|
| Performance   | "Will my site feel fast to a real visitor?"          |
| Risk          | "If a stranger finds my URL, what can they break?"   |
| Compliance    | "Can I legally take money from an EU user?"          |
| Operations    | "Will this site look broken on a phone or in a tab?" |
| Questionnaire | "Things only the operator knows" — asked post-audit  |

## Tier 1 — free, fully automated

These are the checks that run on every audit today. The audit page
groups them under the four product categories and the summary line at
the top surfaces the headline numbers.

### Performance

#### `lighthouse-perf` — Loading speed, on a phone and on a computer
- **What**: Google's Core Web Vitals pass on the page (LCP, FCP, CLS,
  TBT, Speed Index) plus an overall 0..1 score — **measured twice**, once
  on each device profile.
- **Pass signal**: `performance_score >= 0.9` AND every per-metric
  lighthouse score is in the "good" band.
- **Fail signal**: any metric in the "poor" band (LCP > 4s, CLS > 0.25,
  TBT > 600ms, etc).
- **How**: shells out to the `lighthouse` CLI in the worker container
  twice against the user-supplied URL, parses both reports, and surfaces
  the raw values (e.g. `lcp_ms: 2150`) alongside the per-metric
  lighthouse scores (e.g. `lcp_score: 0.82`).
- **Where**: `backend/internal/checks/lighthouse.go`,
  worker Dockerfile installs `lighthouse` globally.
- **Tier**: T1.

**The phone run is the unprefixed one.** The CLI's default is not "no
emulation" — it is a mid-range phone on a throttled connection, and for a long
time this check reported that number under the word "speed" with nothing saying
so. It stays the headline, because most visitors arrive on a phone and it is the
profile search ranking uses; it keeps the unprefixed keys because reports
already stored in Mongo read them and would otherwise render blank. The desktop
run is `--preset=desktop` and lands under `desktop_*`.

Measuring both is the whole point: on vibeship.eu itself the same page scores
LCP 1275ms on a phone and 338ms on a computer. An owner testing at their own
desk sees a number none of their visitors get, and when that gap is 25 points or
more the report says so in as many words.

Two consequences worth knowing before changing this:

- **Sequential, not concurrent.** Each run is a headless browser holding around
  400MiB against a 1Gi pod limit, and `lighthouse-extras` may already be running
  its own browser on the same pod. Two at once is what fits; three is an OOM.
- **One message is now two browser runs**, so the shared consumer's `AckWait`
  went from three minutes to four. That change only reaches a running cluster
  because the consumer is created with `CreateOrUpdateConsumer` — the durable
  already exists, and a plain lookup returns it with its old config.

### Risk

#### `https` — serves over HTTPS
- **What**: the homepage URL is reachable over TLS, with a valid
  certificate, and the page didn't downgrade to HTTP during the
  request.
- **Pass signal**: `https=true`, `status_code` 2xx, `final_url` starts
  with `https://`.
- **Fail signal**: `https=false` (plain HTTP), redirect to a different
  host, cert error.
- **How**: `http.Client` with `CheckRedirect: ErrUseLastResponse` (we
  want to see the redirect chain, not silently follow it).
- **Where**: `backend/internal/checks/https.go`.
- **Tier**: T1.

#### `security-headers` — security-relevant HTTP headers
- **What**: site sets the six headers that protect against XSS,
  clickjacking, MIME sniffing, referrer leakage, and excessive feature
  access.
- **Pass signal**: all six present — `content-security-policy`,
  `strict-transport-security`, `x-content-type-options`,
  `x-frame-options`, `referrer-policy`, `permissions-policy`.
- **Fail signal**: any one missing. We do NOT score the values — a CSP
  exists or it doesn't; tightening it is a separate review.
- **How**: single GET, parse headers, return `{present: N, total: 6,
  headers: {name: {present, value}}}`.
- **Where**: `backend/internal/checks/security_headers.go`.
- **Tier**: T1.

#### `exposed-surfaces` — dev/debug paths that should return 404
- **What**: 16 well-known paths a public-facing site should not expose
  (`.env`, `.git/config`, `wp-admin`, `wp-login.php`, `phpmyadmin`,
  `admin`, `server-status`, `api/debug`, `api/swagger.json`,
  `graphql`, etc).
- **Pass signal**: every probed path returns 404.
- **Fail signal**: any path returns 200 with a non-empty body
  (hard-fail), or 401/403 (auth-walled — partial fail, still reported).
- **How**: GET each path, classify by status, classify body shape.
- **Where**: `backend/internal/checks/exposed_surfaces.go`.
- **Tier**: T1.

#### `prod-cleanliness` — shipped JS contains dev smells
- **What**: the JavaScript files the user's browser actually downloads
  contain debugging statements (`console.log`, `alert(`, `debugger;`) or
  unfinished-work markers (`TODO`, `FIXME`, `XXX`).
- **Pass signal**: 0 hits across all first-party scripts.
- **Fail signal**: any hit, grouped by smell kind. Third-party scripts
  (CDN-served) are scanned but not counted — the user can't fix those.
- **How**: parse `<script src=...>`, GET each (up to 256KB), regex
  match.
- **Where**: `backend/internal/checks/prod_cleanliness.go`.
- **Tier**: T1.

#### `owasp-passive` — nikto-style recon (no payloads)
- **What**: a curated list of HTTP probes that smell out common
  misconfigurations WITHOUT firing any payloads. Covers server / framework
  version disclosure, allowed HTTP methods (PUT/DELETE/TRACE on a public
  site = smell), cookie attribute hygiene on the homepage cookies, and a
  30-entry list of well-known debug / backup / artifact paths.
- **Pass signal**: 0 disclosed version headers AND 0 dangerous methods
  AND all cookies flagged AND all 30 probed paths return 404.
- **Fail signal**: any disclosure / dangerous method / unflagged cookie /
  non-404 path. Reported as a flat list of findings, not a verdict.
- **How**: single GET for headers + cookies + allowed methods (via
  OPTIONS), then 30 sequential probes against `owaspPassiveProbes` in
  `owasp_passive.go`. ~5–15s per site, runs in parallel with the other
  T1 checks.
- **Where**: `backend/internal/checks/owasp_passive.go`.
- **Tier**: T1.

### Compliance

#### `seo-meta` — SEO basics in `<head>`
- **What**: the page declares the metadata Google + social previews
  actually use.
- **Pass signal**: `<title>` (30..60 chars), `meta description`
  (70..160 chars), `<link rel=canonical>`, all four Open Graph tags
  (`og:title`, `og:description`, `og:image`, `og:url`), `<html
  lang="...">`, exactly one `<h1>`.
- **Fail signal**: any missing, or a title/description whose length is
  outside the ideal window.
- **How**: fetch the homepage (limit 64KB), scan `<meta>` and `<link>`
  tags with a tiny hand-rolled HTML scanner (no `golang.org/x/net/html`
  dependency added).
- **Where**: `backend/internal/checks/seo_meta.go`.
- **Tier**: T1.

#### `mobile-viewport` — `<meta name="viewport">` set
- **What**: mobile browsers render the page at device width instead of
  desktop width with pinch-zoom.
- **Pass signal**: `width=device-width` present in the viewport meta.
- **Fail signal**: missing entirely, or `width=NNN` with a fixed pixel
  value (the classic "looks broken on phones" smell).
- **How**: parse the viewport meta, split on `,`/`;`, check the
  `width=` key.
- **Where**: `backend/internal/checks/mobile_viewport.go`.
- **Tier**: T1.

### Operations

#### `favicons` — favicon, favicon-32, apple-touch-icon declared + reachable
- **What**: the site declares at least one icon (browser tab favicon,
  high-DPI variant, iOS home-screen icon) and every declared icon URL
  actually returns 200.
- **Pass signal**: at least one declared icon resolves; the implicit
  `/favicon.ico` also returns 200 (informational, not a fail).
- **Fail signal**: declared icon returns 404 or 5xx.
- **How**: parse `<link rel=icon|shortcut icon|apple-touch-icon>`,
  HEAD each, fall back to GET on 405 (some servers reject HEAD).
- **Where**: `backend/internal/checks/favicons.go`.
- **Tier**: T1.

### Risk (cont.)

#### `dns-basics` — MX / SPF / DKIM / DMARC
- **What**: the audited domain can actually send and receive email.
  Without these records the domain can't reliably deliver transactional
  email; missing DMARC in particular is the #1 reason "my customer
  receipts are going to spam".
- **Pass signal**: at least MX present, AND `v=spf1` exists on the
  apex, AND `v=DMARC1` exists at `_dmarc.<host>`.
- **Fail signal**: any of MX / SPF / DMARC missing. DKIM is reported
  as a smell when missing (we probe one common selector, `default`).
- **How**: DNS TXT + MX queries via `github.com/miekg/dns`, 5s combined
  timeout, default resolver 1.1.1.1:53 (overridable via `DNS_SERVER`).
- **Where**: `backend/internal/checks/dns.go`.
- **Tier**: T1.

### Compliance (cont.)

#### `business-info` — company info, contact, privacy, terms, certifications
- **What**: the visible signals a real business has in place — a "who
  we are" page, a way to contact them, a published privacy policy,
  a published terms of service, and any trust marks (ISO 27001,
  SOC 2, GDPR, B-Corp, etc.). Visitors use these to decide whether
  a site is a real company before entering a credit card.
- **Pass signal**: all 5 categories present — `company`, `contact`,
  `privacy`, `terms`, `certifications`.
- **Fail signal**: `privacy` or `terms` missing is a hard fail
  (compliance blocker); `company`, `contact`, or `certifications`
  missing is reported as a smell (trust signal).
- **How**: parallel probes against a curated path list per category.
  The privacy / terms / company / contact categories are pure path
  probes (a 2xx with a non-trivial body counts as present). For
  `certifications` we probe a small path list AND scan the homepage
  HTML for trust-mention keywords (`iso 27001`, `soc 2`, `gdpr`,
  `dsgvo`, `b-corp`, `hipaa`, `pci dss`) as evidence — many sites
  mention these in a footer badge without a dedicated cert page.
- **Curated path lists** (all relative to the audited URL origin):
  - company: `/about`, `/about-us`, `/about/`, `/team`, `/company`,
    `/impressum` (DE legal notice — required by TMG §5), `/legal-notice`,
    `/legal/impressum`
  - contact: `/contact`, `/contact-us`, `/contact/`, `/kontakt` (DE),
    `/get-in-touch`
  - privacy: `/privacy`, `/privacy-policy`, `/privacy/`,
    `/datenschutz` (DE — GDPR-conform German label),
    `/legal/privacy`, `/policies/privacy`
  - terms: `/terms`, `/terms-of-service`, `/terms/`, `/tos`,
    `/agb` (DE — *Allgemeine Geschäftsbedingungen*),
    `/legal/terms`, `/policies/terms`
  - certifications: `/certifications`, `/security`,
    `/compliance`, `/trust`, `/certificates`, `/iso`, `/soc2`
- **What we deliberately don't do**: we don't grade the QUALITY of
  each page (a 50-word "Privacy" stub passes; a 50-page legal
  treatise passes). v2 can add a length / section-heading heuristic.
  We also don't crawl — the check stays scoped to the audited origin
  and only follows home-page links if a v3 deems it worthwhile.
- **Where**: `backend/internal/checks/business_info.go`.
- **Tier**: T1.

#### `robots-sitemap` — robots.txt + sitemap.xml
- **What**: the site exposes both files search engines expect, they
  reference each other, and the sitemap doesn't list dev/debug paths.
- **Pass signal**: `/robots.txt` 200 with a `Sitemap:` line referencing
  the sitemap, `/sitemap.xml` 200 with at least one `<loc>` URL,
  cross-reference matches, sitemap contains no dev paths.
- **Fail signal**: either file 404, OR sitemap leaks
  `/admin /debug /api/debug /_debug /internal /staging /.git/ /.env
  /phpmyadmin`.
- **How**: two GETs (1MB limit on sitemap), cheap regex parse for
  `Sitemap:` lines and `<loc>...</loc>` extraction.
- **Where**: `backend/internal/checks/robots_sitemap.go`.
- **Tier**: T1.

### Performance (cont.)

#### `lighthouse-extras` — Lighthouse SEO + a11y + best-practices
- **What**: the three Lighthouse categories we skipped in v1 — SEO,
  accessibility, best-practices — each scored 0..1.
- **Pass signal**: all three >= 0.9.
- **Fail signal**: any < 0.9.
- **How**: second `lighthouse` shell-out with
  `--only-categories=seo,accessibility,best-practices`. ~30% longer
  than the perf run; runs on its own subject so the perf audit
  finishes independently.
- **Where**: `backend/internal/checks/lighthouse_extras.go`.
- **Tier**: T1.

## Tier 2 — paid APIs and rate-limited checks

These need a paid key, a non-trivial request budget, or a curated
allowlist that we don't have yet. Backlog.

### OWASP active scanner (opt-in via dedicated endpoint)

Lives behind `POST /v1/audits/{id}/active-scan`. The free T1 audit
includes `owasp-passive` (no payloads); this is the heavier sibling that
fires real attack vectors at the audited page.

- **What**: discovers forms + URL params on the audited page, then for
  each: a reflection-based XSS canary (`vibeship<8hex>`), an open
  redirect probe (only on param names like `next` / `url` / `redirect`),
  a `SLEEP(3)` time-based SQLi probe (only on numeric-looking field
  names), and a `../../etc/passwd` path-traversal probe (only on
  file-shaped field names).
- **Pass signal**: zero findings across the scan.
- **Fail signal**: any finding — categorised by `category`
  (`xss` / `sqli` / `open-redirect` / `path-traversal` / `ssrf`) and
  `severity` (`critical` / `high` / `medium` / `low`). The full finding
  shape is in `owasp_active.go`.
- **How**: HTML form + URL param discovery with hand-rolled regex (no
  `golang.org/x/net/html` dep). Probes use a dedicated `noFollowClient`
  so the FIRST response is observed (the 3xx is the open-redirect
  finding; a reflection is the XSS finding; the file body is the
  traversal finding).
- **Safety rails**:
  - The endpoint is NOT part of the default T1 fan-out. Users click
    "Run active scan" in the UI to opt in.
  - The handler re-runs the SSRF blocklist (`isBlockedHost`) against
    the stored URL before running. Defends against tampered Mongo rows.
  - The probe `http.Client` has a same-origin-only CheckRedirect — a
    redirect off-host bails immediately.
  - SQLi payload is `SLEEP(3)` only — no `DROP` / `INSERT` / `UPDATE`.
  - Findings are reviewed by the user before any action; the API
    doesn't auto-block or rate-limit the target.
- **Where**: `backend/internal/checks/owasp_active.go`,
  `backend/cmd/api/main.go` (`handleActiveScan`).
- **Tier**: T2 (opt-in, runs only on user request).

### Cookie / session hygiene
- **What**: any auth cookie set on the site has `Secure`, `HttpOnly`,
  `SameSite`.
- **Pass signal**: every auth cookie has all three flags.
- **Fail signal**: missing `HttpOnly` (XSS-readable), missing `Secure`
  (sent over HTTP).
- **How**: POST to a known login endpoint, parse `Set-Cookie`. Won't
  work for sites that require real credentials — fall back to
  inspecting any cookie set by the homepage.
- **Tier**: T2 (needs a curated list of common auth endpoints).

### TLS deep scan
- **What**: cert chain trust, TLS 1.0/1.1 still allowed?, HSTS preload
  eligibility, expiry countdown.
- **How**: **built** — `tls-config` in the deep security test, via
  testssl.sh. See "Deep security test" below and `docs/dast.md`. The
  sketch here (hand-rolling it from
  `crypto/tls.Config.VerifyPeerCertificate`) was dropped: the named
  attacks and the cipher list are the bulk of the value and testssl.sh
  already tracks them.
- **Tier**: T6 — signed in, deliberate, and expensive.

### Domain reputation (Google Safe Browsing, PhishTank)
Has the domain been flagged? Important for sites that handle payments
— payment processors will block domains on the Safe Browsing list.

### Email deliverability test (mail-tester.com)
Send a real email and get a spamminess score. Free for the sender,
paid for the bulk version.

## Tier 3 — heuristic smells

We surface these as findings but never as verdict-level failures. A
site can pass every Tier 1 and Tier 2 check and still be flagged as
"looks vibe-coded" by these.

### Exposed framework info
- **What**: `Server: nginx/1.18.0`, `X-Powered-By: Express`, etc.
- **Why it's a smell**: information disclosure; makes targeted exploits
  easier. Not a fail on its own.
- **How**: parse response headers for any `Server` / `X-Powered-By`
  containing a version.
- **Tier**: T3.

### TODO/FIXME shipped in HTML
- **What**: the homepage HTML itself contains `TODO`, `FIXME`, `lorem
  ipsum`, `placeholder`, `xxx`.
- **Why it's a smell**: indicates unfinished work in the production
  build. Note that the JS-file scan is already Tier 1; this is the
  HTML counterpart.
- **How**: regex over the homepage body.
- **Tier**: T3.

### Generic stock-photo tells
- **What**: hero image hash matches a known stock-photo CDN, "Powered
  by AI" / "Made with ChatGPT" in the footer, fake testimonials
  with stock faces.
- **How**: hash the hero image, check against a small fingerprint set;
  footer regex; not worth building at scale.
- **Tier**: T3.

### Generic contact email on a business domain
- **What**: `hello@` or `contact@` resolves to a free provider
  (gmail, outlook, yahoo, gmx) on a `.com` / `.de` / `.io` business
  domain.
- **Why it's a smell**: companies use their own domain. Doesn't prove
  anything, but combined with other smells it's a strong vibe-coded
  signal.
- **How**: MX lookup on the email domain.
- **Tier**: T3.

## Tier 4 — questionnaire after the audit

We can't detect these externally. After the audit completes, the
follow-up form should ask the user these questions so we can score the
"are you ready to charge money" verdict.

| Question                                    | Why we ask                                       |
|---------------------------------------------|--------------------------------------------------|
| What database are you using? Who runs it?   | "no backups" is the #1 cause of vibe-coded death |
| Is Stripe in live mode?                     | pk_test_ in client JS is detectable (Tier 1.5)   |
| Do you have webhooks handled?               | Tier 4 — process is internal                     |
| Where do logs go? Sentry? Datadog? Off?     | "no monitoring" is undetectable from outside     |
| What happens when a deploy breaks at 2am?   | Tier 4 — process question                        |
| Do you have a `/privacy` and `/impressum`?  | Required for `.de` domains by law                |
| What's your data deletion flow for GDPR?    | Required if you store EU user data               |
| Who can deploy? Just you? Anyone on Github?  | Foot-gun signal                                  |
| When did you last restore from a backup?    | If "never" the backup is probably fake           |

## Tier 6 — the deep security test

Signed-in owners only, roughly three quarters of an hour, sixteen stages
in sequence
against one target. Everything below runs *only* when the owner of the
site has asked for it by name and confirmed they are authorized.

`docs/dast.md` is the design doc and the place to read before changing
any of it — the bounds described there are the product. `docs/github.md`
covers the source-code stage and the access it needs. This section is
just the per-stage summary, in the same shape as the tiers above.

### `source-code` — passwords and keys committed into the code
- **What**: a clone of the repository the owner connected, scanned with
  gitleaks. Reads the whole history when the repository is small enough
  (a key deleted last year is still in it), otherwise only the current
  files — and the report says which it was.
- **Pass signal**: no matches.
- **Fail signal**: any match; cloud, payment and provider credentials are
  treated as serious, generic high-entropy matches as a warning. **No
  repository connected is reported as a warning**, not a pass — the same
  reasoning as `api-scan` with no schema.
- **How**: `git clone` with a per-run installation token scoped to one
  repository and to reading contents, then `gitleaks git|dir --redact`,
  6-minute budget. `backend/internal/pentest/code.go`.
- **Tier**: T6.

### `source-code` — verifying that a leaked key still works

- **What**: an opt-in second pass over the same clone with trufflehog
  `--only-verified`, which authenticates with each candidate against
  whoever issued it. Turns "47 things look like keys" into "3 of these
  open something right now".
- **Consent**: a separate tick on the launch form, off by default, per
  run. It is a sign-in attempt against the customer's own accounts and
  shows in their provider's audit log.
- **Never stored**: the credential exists in the worker's memory for the
  length of the pass. `verification_ran` and `live_count` are what reach
  the report, plus a provider name per finding.
- **Where**: `backend/internal/pentest/verify.go`, design in
  `docs/github.md`.
- **Tier**: T6 (deep test only).

### `zap-full` — deep break-in test of the site
- **What**: a full crawl (traditional spider + AJAX spider) followed by
  ZAP's complete active rule set against everything found. Signed in as
  one of the user's own accounts when they have configured one.
- **Pass signal**: no alerts in the report.
- **Fail signal**: any alert; risk code 3 ("high") is treated as serious.
- **How**: `zap-full-scan.py -j -a -m 5 -T 10`, 20-minute budget, in the
  isolated pentest worker. `backend/internal/pentest/zap.go`.
- **Tier**: T6.

### `api-scan` — break-in test of the service behind the site
- **What**: every operation the user's OpenAPI or GraphQL schema
  declares, actively tested — including the endpoints nothing on the
  website links to.
- **Pass signal**: no alerts.
- **Fail signal**: any alert. **No schema configured is reported as a
  warning**, not a pass: untested is not the same as clean, and the card
  is how the user learns they can close the gap.
- **How**: `zap-api-scan.py -f openapi|graphql`, 6-minute budget.
- **Tier**: T6.

### `nuclei` — publicly known weaknesses
- **What**: signature match against the community template set — known
  CVEs in detected versions, exposed admin panels, world-readable
  buckets, leaked key patterns.
- **Pass signal**: no template matches at low severity or above.
- **Fail signal**: any match; critical/high are treated as serious.
- **How**: `nuclei -severity low,medium,high,critical -exclude-tags
  dos,fuzz,brute-force,intrusive -rate-limit 20`, 5-minute budget.
  Templates are pinned at image build time so two runs a day apart
  cannot disagree. `backend/internal/pentest/nuclei.go`.
- **Tier**: T6.

### `tls-config` — how the padlock is set up
- **What**: protocol versions still accepted, certificate chain and
  expiry, the named attacks of the last decade, deprecated ciphers.
- **Pass signal**: no findings at LOW or above.
- **Fail signal**: any finding; critical/high are treated as serious.
  A site with no padlock at all is reported separately and as broken.
- **How**: `testssl.sh --protocols --server-defaults --vulnerable
  --headers --fast`, 3-minute budget. INFO/OK rows are dropped — there
  are hundreds and they bury the handful that matter.
  `backend/internal/pentest/tls.go`.
- **Tier**: T6.

### `access-control` — whether one user can reach another's data

- **What**: signs in and, on addresses that carry an object id, requests a
  neighbour's id and the same address with no session, to detect broken
  object-level authorization (IDOR/BOLA) and missing authentication. The
  most common serious breach, and the one no scanner reasons about.
- **Why**: no password is stolen and nothing looks broken, yet one
  customer can read another's orders, invoices or messages. It is
  invisible from the outside.
- **How**: three read-only requests per candidate — as given with the
  session, with no session, and with a neighbour's id. The signed-out
  request filters public-by-id resources; byte inequality decides a
  different record. **GET only, ever** — an id on a POST/DELETE would
  create or destroy data, and action-shaped addresses are skipped.
- **Pass signal**: every id-bearing address refused a neighbour's id or
  only ever returned the tester's own record.
- **Fail signal**: `access-idor` (a neighbour's record opened) or
  `access-no-auth` (a private page answered with no sign-in) — both `bad`.
- **Rails**: one account cannot prove ownership, so the copy says "we
  could open" and asks for a second account to confirm; UUIDs are never
  guessed; no session means the boundary is reported untested, not clean.
- **Where**: `backend/internal/pentest/access_control.go`, words in
  `assets/findings.js`, design in `docs/dast.md`.
- **Tier**: T6 (deep test only).

### `rate-limits` — whether passwords can be guessed at will

- **What**: a short burst of deliberately-failed logins, watching for a
  "too many requests" answer, a lockout, or the site slowing down.
- **Why**: attackers work through passwords leaked from other sites. With
  no limit, they can try millions against your customers' accounts.
- **How**: at most a dozen attempts, **always with a made-up `.invalid`
  username** so the owner's account is never touched or locked out (so it
  measures per-IP throttling), against the login endpoint only — never
  reset or sign-up, which mail real people and create accounts. Stops the
  moment throttling appears.
- **Pass signal**: the site pushes back — 429, `Retry-After`, a lockout,
  or growing delay.
- **Fail signal**: capped at a warning. A missing limit is an absent
  protection, not an open door.
- **Where**: `backend/internal/pentest/rate_limits.go`.
- **Tier**: T6 (deep test only).

### `email-safety` — whether someone can forge your email

- **What**: the paid version of `dns-basics`. Grades the records that
  carry a *policy* rather than merely existing: SPF ending `-all` vs
  `~all`, and DMARC at `p=reject` vs `p=none`. Probes common DKIM
  selectors.
- **Why**: this is how invoice fraud starts — your customer gets an email
  from your address with different bank details. It also decides whether
  your own email reaches the inbox or spam.
- **How**: DNS lookups only; it sends nothing at the site.
- **Pass signal**: strict SPF, DMARC at reject or quarantine, DKIM found.
- **Fail signal**: `bad` for missing SPF or DMARC; a weak policy warns.
- **Rails**: DKIM absence is "we couldn't find" — a custom selector we
  didn't guess would be missed.
- **Where**: `backend/internal/pentest/email_safety.go`.
- **Tier**: T6 (deep test only).

### `accessibility` — whether everyone can use the site

- **What**: an axe-core pass (via Lighthouse) over the front page and the
  pages it links to, naming each failure rather than reporting a score.
- **Why**: one person in six has a disability, and the European
  Accessibility Act has applied since June 2025. A customer who can't
  check out doesn't complain — they leave.
- **How**: reuses the headless browser the speed stage already runs, so
  no new tool. Failures merge across pages into one finding each.
- **Pass signal**: no failing rules on the pages checked.
- **Fail signal**: `bad` for the rules that shut someone out (contrast,
  alt text, labels, link/button names); the rest warn. Unscored/manual
  audits are never counted as failures.
- **Where**: `backend/internal/pentest/accessibility.go`.
- **Tier**: T6 (deep test only).

### `privacy-leaks` — what the site shares about visitors

- **What**: the third-party hosts the pages load and the tracking cookies
  set on a first visit, before any consent.
- **Why**: consent-before-tracking is the most common reason a small
  company gets a privacy complaint or a fine, and it is a setting rather
  than a rewrite.
- **How**: a read-only, signed-out fetch of the homepage and a few linked
  pages, matching hosts against a short list of well-known trackers.
- **Pass signal**: nothing that tracks loads before a visitor agrees.
- **Fail signal**: `bad` for a known tracker or tracking cookie on first
  load; a long tail of other third parties warns.
- **Rails**: it reads the server's HTML, so a tracker a consent tool
  correctly holds back is not counted — it under-reports rather than
  accuses.
- **Where**: `backend/internal/pentest/privacy_leaks.go`.
- **Tier**: T6 (deep test only).

### `signin-security` — how safely people sign in and stay signed in

- **What**: not *how* a site lets people in (that is `internal/signin`,
  on the quick scan and the code audit) but *how safely*. Grades the
  session cookie the run's own sign-in minted — hidden from page scripts
  (`HttpOnly`), encrypted-transport-only (`Secure` or a `__Host-` prefix),
  and marked against cross-site use (`SameSite`) — and whether the
  sign-in, sign-up and password-reset pages are served over TLS.
- **Why**: once the front door is open, a stealable or forgeable session
  is how a break-in actually spreads. A session cookie a page script can
  read turns any XSS elsewhere on the site into account takeover.
- **How**: reuses the session the run already holds (no second login),
  reading cookie attributes captured value-free during sign-in. The
  sign-up and reset pages are discovered from the homepage's links and a
  short well-known-path list and **fetched read-only, never submitted** —
  a submitted sign-up creates accounts and a submitted reset mails a
  stranger.
- **Pass signal**: the session carries all three protections and every
  authentication page is on a secure connection.
- **Fail signal**: `bad` only for a page that carries credentials over an
  unencrypted connection; a session merely stealable-given-another-hole
  is a warning.
- **Rails**: the session value never leaves the process (`CookieMeta`
  has no value field); when nobody signed in, the session is not graded
  and the card says so rather than reading clean.
- **Where**: `backend/internal/pentest/signin_security.go`, words in
  `assets/findings.js`, design in `docs/dast.md`.
- **Tier**: T6 (deep test only).

### `search-visibility` — how each page shows up in search

- **What**: the paid version of `seo-meta` and `robots-sitemap`, page by
  page. Reads the front page, the pages it links to and the pages the
  sitemap lists — up to 40 — the way a search engine does, and reports
  per page: no title or one the wrong length, no summary or one the wrong
  length, zero or several main headings, pictures without a description,
  a page pointing at another address as the real one, a page asking to be
  left out. Between pages: shared titles and summaries, links to pages
  that are gone, sitemap entries that are gone, pages missing from the
  sitemap, no sitemap, no language on the front page.
- **Why**: the free audit reads the one page every owner has tuned. The
  product page with no title and the footer link that has answered 404
  since March are what a search engine actually holds against a site.
- **How**: our own bounded walk in Go (`golang.org/x/net/html`), signed
  out, same host only, 256KB and 8s per page, one request at a time.
  No external tool and no model.
- **Pass signal**: no issue rows; `clean_pages` names every page read.
- **Fail signal**: `bad` only for a front page carrying a do-not-list
  instruction or a rules file that keeps every search engine off every
  page. Everything else is `warn`.
- **Rails**: every row has an id and every id has its words in
  `assets/findings.js` (`SEARCH_ITEMS`) — an unknown id is dropped, not
  shown raw; `pages_capped` travels with `pages_read` so a clean result
  over the first forty pages is never read as a clean site.
- **Where**: `backend/internal/pentest/search.go`, words in
  `assets/findings.js`, design in `docs/dast.md`.
- **Tier**: T6 (deep test only).

### `audience-trust` — who the site is for, and whether it shows it

- **What**: the only stage that is not about breaking in. It reads the
  front of the site the way a first-time buyer does, decides whether it
  sells to companies (`b2b`), to individual people (`b2c`), to `both`,
  or to nobody the pages make legible (`unclear`), and then checks it
  against what that kind of buyer looks for before parting with money.
- **Why**: for a small company a site nobody trusts is as commercially
  dead as one that has been broken into, and the owner running a deep
  test is exactly the person about to send it to customers.
- **How**: a bounded read of the homepage plus up to nine pages the
  homepage itself links to (same host, 200KB each, filtered to links
  whose text or address suggests pricing / about / contact / security /
  terms / shipping / docs / status / customers), then two small calls to
  a model through OpenRouter — one to classify, one to judge that
  audience's checklist. Never one call for both: that returned a
  different answer every run.
- **Pass signal**: every applicable indicator present.
- **Fail signal**: capped at a warning, always. This is a security
  report's grade and a missing returns policy must not sink it.
- **Rails**: ids are allow-listed on the way back in (a customer's page
  cannot add a line to its own report), an unanswered id is counted as
  unanswered rather than reported as missing, and the copy says "we
  didn't find" rather than "you don't have" because ten pages is not a
  site.
- **Where**: `backend/internal/pentest/audience.go`, words in
  `assets/findings.js`, design in `docs/dast.md`.
- **Tier**: T6 (deep test only).

## Tier 5 — paid, for paid audits only

### Full accessibility scan (axe-core cloud, Accessibility Insights)
Beyond what lighthouse's a11y category covers. Includes keyboard nav,
screen reader testing, color-contrast edge cases.

### Real device mobile test (BrowserStack, real device lab)
Lighthouse uses a simulated mobile viewport. Real devices have
different performance characteristics, especially on cheap Android.

### Load testing (k6 cloud, loader.io)
How does the site behave at 10× expected traffic? Catches
"single-instance Postgres", "no cache", "no rate limit".

### SSL/TLS deep scan (ssllabs.com API, observatory.mozilla.org)
Superseded by the `tls-config` stage of the deep security test, which
runs testssl.sh in-cluster rather than depending on a third-party API
and its rate limits. Left here because a public SSL Labs grade is still
worth quoting in a sales conversation.
Cert chain trust, protocol support (TLS 1.0/1.1 still allowed?),
cipher strength, HSTS preload eligibility.

### Domain reputation (Google Safe Browsing, PhishTank)
Has the domain been flagged? Important for sites that handle payments
— payment processors will block domains on the Safe Browsing list.

### Email deliverability test (mail-tester.com)
Send a real email and get a spamminess score. Free for the sender,
paid for the bulk version.

## Cross-cutting

### Thresholds

Hardcoded in `backend/internal/checks/`:

  - **HTTPS**: must be TLS + 2xx. Cert errors are a hard fail.
  - **Security headers**: any of the 6 missing is a fail. Values are
    not scored.
  - **Lighthouse bands**: we use Lighthouse's own 0.9 / 0.5 / 0.0
    thresholds, NOT custom thresholds. The UI shows green/amber/red
    to match Google's own UI conventions.
  - **SEO lengths**: title 30..60, description 70..160. Outside the
    window is reported but not failed (a 50-char title isn't broken).
  - **Exposed surfaces**: 16 paths. To add a path, edit
    `exposed_surfaces.go`. Be careful — too many paths and we'll
    false-positive on URLs that legitimately 200 (e.g. some
    documentation sites have `/api/version` returning 200 with
    version info).
  - **Prod cleanliness**: 6 regex patterns. Third-party scripts
    (CDN-served) are scanned but not counted as actionable.

### What to do about it

Every check that finds something offers the same two blocks under its card
that a deep-test stage does: **what needs to change**, in the report's own
words, and a **brief for the reader's coding assistant** that carries the real
names the steps leave out (the header, the record, the file, the metric). All
three come from `assets/findings.js` — `FREE_EXPAND[name]` turns a check's
payload into rows, `FIX_STEPS[name]` writes the steps, `PROMPT_BRIEF[name]`
says where the findings came from and how to go about fixing them — and both
free renderers (`audit.js`, `audit-report.js`) draw them. A check that passed
produces no rows and therefore no plan. Adding a check means adding all three,
and `tests/findings.test.js` fails if a name in `EXPECTED_CHECK_ORDER` has no
fix case.

### Output contract

Every check returns a `domain.CheckResult` with:

  - `Name`: stable string identifier (matches the NATS subject name
    suffix). UI groups on this.
  - `Status`: `pending` / `running` / `completed` / `failed`.
  - `StartedAt` / `DurationMs`: timing data, surfaced on the audit
    page.
  - `Data`: check-specific structured output. The shape is
    check-specific and the frontend has a dedicated renderer for
    each. Don't add a new check without a corresponding renderer in
    `assets/audit.js`.
  - `Error`: human-readable string when `Status = failed`.

### Completion semantics

The audit row flips to `completed` when `len(checks) >= ChecksPerAudit`
(14 today). This constant lives in
`backend/internal/workflow/audit.go` and must stay in lockstep with the
publisher list in `backend/cmd/api/main.go` and the subscriber list in
`backend/cmd/worker/main.go`. Drift here causes audits to never
finish (threshold too high) or finish with missing data (threshold
too low).

### NATS stream shape

The JetStream stream is named `AUDIT_V2`. Its subject list is
reconciled at API startup via `EnsureStream` — if you add a new check
subject, add it to both the `SubjectXxx` constant list AND the
`EnsureStream` `want` slice. The reconcile code (`UpdateStream` on
drift) was added after a deploy where the new subjects silently failed
with `nats: no response from stream`.

### What "audited successfully" means

The audit page should show:
  - A summary line at the top with the headline numbers from each
    Tier 1 check (HTTPS, lighthouse, headers, SEO, exposed, dev
    smells).
  - One section per category, with one card per check inside.
  - Each card: the check name, a status pill (running / completed /
    failed), and the check-specific detail rendered by the matching
    renderer in `audit.js`.

## Adding a new check

1. Decide the tier. If it's T1, it ships to every audit and must
   have a unit test in `checks/`.
2. Add `SubjectXxx` constant and `PublishXxx` / `SubscribeXxx` in
   `backend/internal/workflow/audit.go`.
3. Add the subject to the `EnsureStream` `want` slice in the same
   file.
4. Bump `ChecksPerAudit` by 1.
5. Implement the check in `backend/internal/checks/<name>.go`. Return
   `domain.CheckResult`. No I/O outside what the function is named
   for.
6. Write unit tests in `<name>_test.go`. Use `httptest.NewServer`
   with strict paths (remember: `http.ServeMux` returns 200 for
   unregistered paths unless you 404 explicitly).
7. Wire the publisher into `backend/cmd/api/main.go`
   `handleCreateAudit` `publishers` slice.
8. Wire the subscriber into `backend/cmd/worker/main.go`
   `simpleChecks` table (or a new section if the check is heavy).
9. Add a renderer in `assets/audit.js` and a `CHECK_CATEGORY` entry
   so it appears in the right section.
10. Add a card-style for the renderer in `assets/audit.css`.
11. Update this file.
12. Run `go test ./...` and `node --check assets/audit.js` before
    pushing.
13. After deploy, smoke-test: `POST /v1/audits` for `vibeship.eu`,
    confirm the new check shows up in `checks[]` with `completed`
    status.

## Known gaps / v2 backlog

- **OWASP active scanner — out-of-band (OOB) callbacks for SSRF.** The
  current `testSSRF` only fires an unreachable-canary URL and waits for
  timing anomalies, which produces false negatives. A proper OOB channel
  (DNS canary subdomain, HTTP canary endpoint) would let us confirm the
  server actually fetched the canary instead of guessing.
- **OWASP active scanner — false-positive rate.** Time-based SQLi has
  a ±2s threshold; networks with jitter can false-positive. Tightening
  the threshold requires running more baseline requests (p50/p95
  latency) which slows the scan. Worth doing once we have a corpus of
  real targets.
- **OWASP active scanner — coverage.** Currently probes URL params on
  the page URL + form fields. Doesn't follow links to discover additional
  pages with forms. A 1-level crawl is on the v2 list.
- **Cookie hygiene** check is sketched in Tier 2 but not built — needs
  a curated list of common auth endpoints.
- **TLS deep scan** — `crypto/tls.Config.VerifyPeerCertificate` gives
  us the cert chain; we'd want to additionally report expiry
  countdown and HSTS preload eligibility.
- **Lighthouse 3x retry mystery** — observed in production smoke
  tests where the same lighthouse result appeared 3 times in one
  audit row. Suspected AckWait expiry or late Mongo write. Worth
  investigating as a separate bug.
- **Real-device mobile test** — out of scope until we charge for
  audits.
- **Load testing** — same.
- **DNS resolver choice** — `dns-basics` ships defaulting to
  Cloudflare (1.1.1.1). Behind the great firewall or restricted
  corporate networks will need `DNS_SERVER` set in the worker.
- **DKIM selector coverage** — we probe only `default._domainkey`.
  Real senders use many selectors (google, selector1, k1, …). v2:
  small allowlist of common selectors before flagging absent DKIM.
