# 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` — Lighthouse performance score
- **What**: Google's Core Web Vitals pass on the page (LCP, FCP, CLS,
  TBT, Speed Index) plus an overall 0..1 score.
- **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
  against the user-supplied URL, parses the JSON, surfaces both the raw
  values (e.g. `lcp_ms: 2150`) and the per-metric lighthouse scores
  (e.g. `lcp_score: 0.82`).
- **Where**: `backend/internal/checks/lighthouse.go`,
  worker Dockerfile installs `lighthouse` globally.
- **Tier**: T1.

### 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 half an hour, four scanners 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. This section is
just the per-stage summary, in the same shape as the tiers above.

### `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.

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

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