Files
arah2/docs/PRIVACY-AUDIT.md
T
araharah-executor 2489cca8ec fix(07-06): the /help doc pointer is a documented static-text class — privacy audit green again
- source-side runtime-hosts gate gains DOC_POINTER_STATIC_TEXT_HOSTS (the
  MapLibre-attribution precedent): the published repo origin may appear
  ONLY as a bare URL literal in bot copy (D-08/D-09), never fetched
- class is NARROW and pinned by SA6: a fetch( target naming the same
  host is still a violation
- Rule 1 (found by SA6's RED): the fetch( handler's declaration
  heuristic misclassified any quoted absolute URL as a TS declaration —
  quoted strings are targets; they now reach the host check
- docs/PRIVACY-AUDIT.md: adjudication documented + dated re-run addendum
- deferred-items.md #1 closed: npm run privacy-audit exits 0 again
  (PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations; suite 13/13)
2026-10-25 05:18:47 +08:00

176 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd 07-03 executor -->
# Privacy audit record
**PRIVACY AUDIT RECORD** — the signed written audit record citing the evidence (Phase 7, D-01).
| Field | Value |
| --- | --- |
| Audit date | 2026-10-24 |
| Audited tree | commit `9c55e9a` (`npm run privacy-audit` run against that working tree) |
| Auditor | the maintainer — signature block below (the tool produces the evidence; the human signs the claim) |
| Tool | `npm run privacy-audit` (`scripts/privacy-audit.mjs` + `scripts/lib/*`) — GPL-3.0, reusable by forks |
| Result | **PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations** (static gates); live legs recorded as NOT RUN below |
The tool is a MAINTAINER-TIME REPO TOOL — nothing in any `src` tree imports it. A fork reruns
`npm run privacy-audit` over its own trees before its first driver; this record is the v1 tree's
own evidence.
---
## Scope
**Audited:**
| Surface | What was scanned |
| --- | --- |
| All five `src` trees | `web/src`, `bot/src`, `routing/src`, `search/src`, `theme/src` — 72 code files (`.ts`/`.tsx`/`.js`/`.mjs`/`.html`), comments stripped (the canonical stripper, copied verbatim from `web/test/no-storage.test.mjs`) |
| Production bundle | `web/dist` — every emitted `.js` and `.html` file (PRODUCTION only) |
| Live KV namespace | `chat_sessions` (`bot/wrangler.jsonc`) — TTL-only keys, probed live only |
**Not audited (deliberate):**
| Excluded surface | Why |
| --- | --- |
| `web/dist-harness` | the test-harness build is deliberately NOT clean (BA5's law — a gate pointed at it inverts itself) |
| The Telegram client's own internal storage | the official `telegram-web-app.js` is EXTERNAL to the bundle; its `__telegram__initParams` sessionStorage write is the platform allowlist, not ours |
| `web/src/mock/telegram-mock.ts` runtime behaviour | scanned by the source gates (it is in `web/src`); its in-page demo interactions are harness-only and never ship |
| Third-party library internals beyond the shipped bundle text | the bundle scan covers the shipped artifact itself — that IS the library's runtime footprint |
## Findings
One row per gate; the Evidence column is the ACTUAL printed evidence string from the run (copied verbatim, never invented):
| Gate | Result | Evidence |
| --- | --- | --- |
| `storage-api` | PASS | 72 file(s) across 5 src tree(s) — no persistence API outside the platform allowlist |
| `kv-ttl` | PASS | 4 KV write(s) across 72 file(s) — every .put( line carries expirationTtl referencing a named ^[A-Z_]{2,}$ constant |
| `log-vocabulary` | PASS | 21 bot + 7 web console.log call site(s) — every bot log opens with a bracketed surface token and carries NO lat/lng/chat_id/user_id/token/initData/geometry/polyline/payload carrier, every web log is an ARAHARAH-* state token or the RAMS-BASEMAP-READY signal, and no log serializes a payload |
| `runtime-hosts` | PASS | host inventory (printed, not assumed): www.w3.org×3, telegram.org×1; 4 relative fetch target(s) to the app's own Worker, 1 fetch declaration(s) skipped; every absolute URL host is in the positive allowlist [tiles.openfreemap.org, telegram.org] |
| `bundle-telemetry` | PASS | 3 JS + 1 HTML emitted file(s) scanned (PRODUCTION web/dist only — dist-harness forbidden); host inventory (printed, not assumed): maplibre.invalid×1, github.com×3, wiki.openstreetmap.org×1, maplibre.org×2, tiles.openfreemap.org×5, opendatacommons.org×2, telegram.org×1; every absolute-URL host is in the allowlist [tiles.openfreemap.org, telegram.org] or the documented static-text class; ZERO analytics/beacon/crypto/storage tokens |
| `kv-expiry` (live) | NOT RUN | (pass --live) — no Cloudflare credentials in the auditing environment; the live leg re-runs at the launch gate with `npm run privacy-audit -- --live`. A skip is never a pass (exit code 2, not 0) |
| `asset-headers` (live) | NOT RUN | (pass --live) — same re-run protocol; the live leg observed the HTML entry answering `cache-control: public, max-age=0, must-revalidate | cf-cache-status: HIT` and the then-deployed asset answering 404 (deployment drift — the deployed Worker pre-dates the current `web/dist`), which the probe reports as deployment drift, never as a pass |
Documented scope adjudications (deliberate; each names its reason): the `beacon` token is checked
only as `navigator.sendBeacon`; the MapLibre library's own static-text URLs (`maplibre.org`,
`wiki.openstreetmap.org`, `github.com` diagnostics, `maplibre.invalid` protocol-test placeholder)
and our own dataset's ODbL license strings (`opendatacommons.org`) are printed in the inventory as
a documented static-text class — never fetched; `http://www.w3.org/{2000/svg,1999/xhtml}` are W3C
spec identifiers. The SOURCE-side `runtime-hosts` gate carries the same kind of adjudication for
the `/help` Known-limits doc pointer (see the 2026-10-25 addendum below): the published repo
origin appears ONLY as a bare URL literal in the bot strings — user-clicked navigation, never
fetched — and the class is NARROW (a `fetch(` target naming the same host is still a violation;
pinned by the SA6 vector).
## Storage inventory
The FOUR TTL-only KV key families — the complete storage surface. No other family exists, and no
coordinate or profile is persisted anywhere else (the `kv-ttl` gate proves every write path, the
`storage-api` gate proves the client carries no persistence API at all):
| Family | Prefix | TTL constant | TTL | What is stored |
| --- | --- | --- | --- | --- |
| session | `session:` | `SESSION_TTL_SECONDS` (`bot/src/kv/sessions.ts`) | 900 s | the 4-field `SessionClaim` — user_id, chat_id, issued_at, language_code. Nothing else ever serialized (D-08: a 5th property means drift) |
| chat pointer | `chat:` | `SESSION_TTL_SECONDS` (`bot/src/kv/sessions.ts`) | 900 s | the current opaque session token for a chat |
| location pin | `loc:` | `PIN_TTL_SECONDS` (`bot/src/kv/location-pin.ts`) | 300 s | Telegram's Location fields only — latitude, longitude, horizontal_accuracy (GEO-03: the ONLY place a coordinate exists server-side, and it expires) |
| guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` (`bot/src/kv/guidance-sessions.ts`) | 7200 s | the frozen 4-field envelope + inline 6-field route snapshot; nothing outlives 2 h, nothing reconstructable after expiry (D-07) |
Every write path is pinned by the same-line `expirationTtl` law (PG1, extended repo-wide by the
`kv-ttl` gate), and the cron performs ZERO KV writes (the free-plan budget law). The client
(`web/src`) holds route/guidance state in MEMORY ONLY — no localStorage, no sessionStorage, no
IndexedDB, no CacheStorage, no cookies, no service worker.
## Log vocabulary
Category-only, machine-verified by the `log-vocabulary` gate. Allowed log surfaces:
| Tree | Allowed log shape |
| --- | --- |
| `bot/src` | `console.log("[surface] verdict: category")` — surface is one of `webhook`, `initdata`, `location`, `location-fallback`, `guidance`, `guidance-cron`, `route`, `search`, `search-alternate`; every call site opens with a bracketed surface token |
| `web/src` | the `ARAHARAH-*` state tokens (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=…`, `ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`) or the `RAMS-BASEMAP-READY layers=…` signal; `console.error` on the basemap error path |
The coordinate-free law: no log line may carry a coordinate, a chat id, a user id, a token, an
initData payload, a geometry/polyline, or any serialized payload (`JSON.stringify` inside a log
argument is forbidden outright). 21 bot + 7 web call sites verified.
## Runtime network
The ACTUAL host inventory the audit printed (source scan + production bundle scan — produced, not
assumed, A4):
| Host | Class | Why it is reachable |
| --- | --- | --- |
| `tiles.openfreemap.org` | **fetch allowlist** | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) |
| `telegram.org` | **fetch allowlist** | the official Telegram WebApp SDK `<script>` tag in `web/index.html` |
| the app's own Worker origin | **fetch allowlist (relative)** | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance`, `/api/route` — RELATIVE paths, not hosts |
| `maplibre.org`, `wiki.openstreetmap.org`, `github.com`, `maplibre.invalid` | documented static-text class | MapLibre's own attribution/diagnostic constants inside the bundled library — displayed text, never fetched |
| `opendatacommons.org` | documented static-text class | our own gazetteer/fixture ODbL license metadata strings |
| `www.w3.org` | documented spec identifiers | SVG/XHTML namespace constants — never fetched |
| `gitmirah` (the published repo origin) | documented static-text class (SOURCE-side, URL literals only) | the `/help` Known-limits doc pointer in `bot/src/i18n/strings.ts` (D-08/D-09) — user-facing copy in a chat card, never fetched; a `fetch(` to the same host is still a violation (SA6) |
**ZERO telemetry (D-02):** no analytics, no beacons, no tracking pixels, no third-party CDN, no
error-reporting host, no social pixels — the shipped bundle contains no analytics or beacon token
of any kind, and no fetch to any host outside the three-row fetch allowlist above. v1 has nothing
to opt into: opt-in telemetry stays an IDEAS.md aspiration post-validation.
## Statement
PRIV-02 (storage is TTL-only, per-user data never outlives its window) and GEO-03 (the client
persists nothing; coordinates live in memory client-side and in a 300 s TTL pin server-side) are
re-verified END-TO-END by this audit, not asserted: the `storage-api` gate scans all 72 files
across the five production trees (comments stripped) for any persistence API or service-worker
surface; the `kv-ttl` gate proves all 4 KV write paths carry a same-line `expirationTtl`
referencing a named constant; the `log-vocabulary` gate machine-verifies that none of the 28 log
call sites can carry a coordinate, an identifier, or a payload; the `runtime-hosts` gate proves
the source tree only ever fetches the app's own Worker; and the `bundle-telemetry` gate proves
the SHIPPED bundle — the artifact that actually decides the app's runtime network — contains
zero analytics, zero beacons, zero storage APIs, zero deep-link crypto, and no fetch host beyond
the OpenFreeMap tiles, the Telegram SDK, and the Worker itself. The live legs (KV listing with
expiry metadata, deployed cache headers) were observed NOT RUN in this environment and are
re-run with `npm run privacy-audit -- --live` at the launch gate — exit 0 only when they PASS,
never when they are skipped.
## 2026-10-25 — Phase 7 gate-prep re-run: the /help doc pointer adjudication
**Why this addendum exists:** plan 07-05's committed `/help` Known-limits pointer (commit
`78482ab`) put the repo's actual published-origin URL (`http://gitmirah:3000/c-fu/arah2/…`,
the maintainer's Gitea remote per `git remote -v`) into the bot strings (en + ms), and the
`runtime-hosts` gate — a positive allowlist with no static-text escape — flagged it as 2
violations (`PRIVACY AUDIT FAILED`). Logged in the phase's `deferred-items.md`; fixed by the
07-06 gate prep, following the audit's OWN laws (the MapLibre static-text adjudication
precedent), not by weakening them.
**The fix (source-side, `scripts/lib/source-gates.mjs`):**
- A documented `DOC_POINTER_STATIC_TEXT_HOSTS` class on the SOURCE-side `runtime-hosts` gate:
the published repo origin may appear ONLY as a bare URL literal in bot copy (the /help
Known-limits line — user-clicked navigation, never fetched). Each entry names its reason;
forks replace it with their own published origin.
- The class is **NARROW by construction and pinned by the SA6 vector**: it is consulted ONLY
for bare URL literals. A `fetch(` target naming the same host is STILL a violation. This
required fixing a latent gate bug (SA6's RED proved it): the `fetch(` call-site handler's
declaration heuristic (`argText.includes(":")`) misclassified any QUOTED absolute URL as a
TS method declaration and skipped it — a quoted string is always a target, so quoted fetch
targets now reach the host check.
- **Result:** `npm run privacy-audit` → `runtime-hosts: PASS — host inventory (printed, not
assumed): www.w3.org×3, gitmirah×2, telegram.org×1; 4 relative fetch target(s) to the app's
own Worker, 1 fetch declaration(s) skipped; every absolute URL host is in the positive
allowlist [tiles.openfreemap.org, telegram.org] or the documented doc-pointer static-text
class (URL literals only — never fetched)` → **PRIVACY AUDIT CLEAN — 5 gate(s) passed,
0 violations**; the suite is 13/13 (SA1–6, BG1–2, CP1–2, AU1–3). The Findings table above
remains the 07-03 run's historical record; this section is the gate-time re-run record.
**Driver-resolution caveat (unchanged from 07-05's decision):** the URL resolves for a driver
only once the D-09 launch checklist's "repo pushed public" step lands main on the remote —
the launch gate verifies the pointer line resolves before signing.
## Signature
The audit record is SIGNED BY THE HUMAN at the launch gate (the tool produces the evidence; the
human signs the claim):
```
Maintainer: ________________________________
Date: ________________________________
```