docs(07): plan phase 07 — hardening + private launch (6 plans, 3 waves)
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
---
|
||||
phase: 07-hardening-private-launch
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- scripts/privacy-audit.mjs
|
||||
- scripts/lib/source-gates.mjs
|
||||
- scripts/lib/bundle-gates.mjs
|
||||
- scripts/lib/cloudflare-probes.mjs
|
||||
- package.json
|
||||
- docs/PRIVACY-AUDIT.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PX-04
|
||||
must_have_notes:
|
||||
- "This plan re-verifies PRIV-02 and GEO-03 end-to-end (D-01). PX-04 is claimed here because SC3 is a Phase-7 success criterion under the PX-04 phase."
|
||||
must_haves:
|
||||
truths:
|
||||
- "No production source tree in the repo contains a persistence API outside the platform allowlist — the memory-only client law and the TTL-only Worker law, extended repo-wide."
|
||||
- "Every Worker KV write carries an expirationTtl that references a NAMED constant — no TTL-free write path can ship."
|
||||
- "No log statement can carry a coordinate, a chat id, or a route payload — the category-only vocabulary is machine-verified."
|
||||
- "The production bundle contains no analytics, no beacon, and no fetch to any host outside the positive allowlist (OpenFreeMap tiles, the Telegram API, the app's own Worker)."
|
||||
- "A live KV probe shows every live key family is one of session:/chat:/loc:/guidance: and every key carries an expiration."
|
||||
- "The audit's exit codes distinguish PASS (0), FAIL (1) and NOT-RUN / missing environment (2) — a skipped live probe is never mistaken for a pass."
|
||||
artifacts:
|
||||
- path: "scripts/privacy-audit.mjs"
|
||||
provides: "The consolidated audit CLI (npm run privacy-audit) — orchestration, evidence report, exit codes"
|
||||
contains: "PRIVACY AUDIT"
|
||||
- path: "scripts/lib/source-gates.mjs"
|
||||
provides: "Comment-stripped source scans over ALL src trees — storage, TTL, log vocabulary, runtime fetch hosts"
|
||||
contains: "stripComments"
|
||||
- path: "scripts/lib/bundle-gates.mjs"
|
||||
provides: "Production bundle scans — analytics/beacon tokens + the positive fetch-host allowlist"
|
||||
contains: "ALLOWED_FETCH_HOSTS"
|
||||
- path: "scripts/lib/cloudflare-probes.mjs"
|
||||
provides: "The live KV listing probe (key families + expiration metadata) and the live header probe"
|
||||
contains: "list_complete"
|
||||
- path: "docs/PRIVACY-AUDIT.md"
|
||||
provides: "The signed written audit record citing the evidence (D-01)"
|
||||
contains: "PRIVACY AUDIT RECORD"
|
||||
key_links:
|
||||
- from: "scripts/privacy-audit.mjs"
|
||||
to: "scripts/lib/source-gates.mjs"
|
||||
via: "runStorageGate / runCoordinateLogGate / runRuntimeHostGate — each returns {id, pass, evidence}"
|
||||
pattern: "source-gates\\.mjs"
|
||||
- from: "scripts/privacy-audit.mjs"
|
||||
to: "scripts/lib/bundle-gates.mjs"
|
||||
via: "runBundleTelemetryGate — scans web/dist (PRODUCTION only)"
|
||||
pattern: "bundle-gates\\.mjs"
|
||||
- from: "scripts/privacy-audit.mjs"
|
||||
to: "scripts/lib/cloudflare-probes.mjs"
|
||||
via: "runKvExpiryProbe — only under the --live flag; missing credentials => exit 2"
|
||||
pattern: "cloudflare-probes\\.mjs"
|
||||
- from: "package.json"
|
||||
to: "scripts/privacy-audit.mjs"
|
||||
via: "the root script \"privacy-audit\": \"node scripts/privacy-audit.mjs\""
|
||||
pattern: "privacy-audit"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Ship the ONE consolidated privacy-audit tool the repo can run before every
|
||||
fork's first driver, and the signed written audit record that cites its
|
||||
evidence.
|
||||
|
||||
Purpose: D-01 — PRIV-02 and GEO-03 are re-verified END-TO-END, not asserted:
|
||||
source scans over every `src` tree, a production-bundle scan for telemetry, a
|
||||
live KV listing probe that reads the expiry metadata, and a signed record a
|
||||
reviewer (or a fork) can rerun. D-02 — v1 carries ZERO telemetry, so there is
|
||||
nothing to opt into; the audit proves the absence rather than auditing a
|
||||
consent surface that does not exist.
|
||||
Output: `scripts/privacy-audit.mjs` + `scripts/lib/{source-gates,bundle-gates,cloudflare-probes}.mjs`
|
||||
(new), the root `privacy-audit` npm script, and `docs/PRIVACY-AUDIT.md` (the
|
||||
signed record).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
|
||||
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
|
||||
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
The comment-stripped source scan — copy this EXACTLY from
|
||||
`web/test/no-storage.test.mjs` lines 29–42 (the canonical twin also lives in
|
||||
`bot/test/route-privacy.test.mjs` lines 42–59):
|
||||
|
||||
```javascript
|
||||
function listFiles(dir, exts, acc = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = path.join(dir, entry);
|
||||
if (statSync(full).isDirectory()) listFiles(full, exts, acc);
|
||||
else if (exts.some((e) => entry.endsWith(e))) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
|
||||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading)
|
||||
}
|
||||
```
|
||||
|
||||
The banned persistence vocabulary — from `web/test/no-storage.test.mjs` lines 22–25:
|
||||
|
||||
```javascript
|
||||
const BANNED = [
|
||||
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
|
||||
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
|
||||
];
|
||||
```
|
||||
|
||||
The KV write-TTL law — from `bot/test/privacy.test.mjs` lines 32–49 (PG1):
|
||||
|
||||
```javascript
|
||||
lines.forEach((line, i) => {
|
||||
if (line.includes(".put(")) {
|
||||
assert.ok(line.includes("expirationTtl"), `${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`);
|
||||
const ttl = line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1];
|
||||
assert.ok(ttl && /^[A-Z_]{2,}$/.test(ttl), `${file}:${i + 1} — expirationTtl must reference a named TTL constant, not an inline literal`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The CLI exit-code contract — from `theme/scripts/audit.mjs` lines 20–24 and
|
||||
797–814:
|
||||
|
||||
```text
|
||||
* Exit codes (no silent passes — T-1-07):
|
||||
* 0 clean — every checked declaration derives from tokens
|
||||
* 1 violations — each printed as file:line: declaration
|
||||
* 2 drift/error — token sheet degraded (see message)
|
||||
```
|
||||
|
||||
The gate-orchestration shape — from 07-RESEARCH.md §Pattern 4:
|
||||
|
||||
```javascript
|
||||
const results = [
|
||||
await runStorageGate(),
|
||||
await runCoordinateLogGate(),
|
||||
await runRuntimeHostGate(),
|
||||
await runBundleTelemetryGate(),
|
||||
await runKvExpiryProbe(),
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
console.log(`${result.id}: ${result.pass ? "PASS" : "FAIL"} — ${result.evidence}`);
|
||||
}
|
||||
process.exit(results.every((r) => r.pass) ? 0 : 1);
|
||||
```
|
||||
|
||||
The four KV key families and their TTL constants (from `bot/src/kv/*.ts`):
|
||||
|
||||
| Family | Prefix | TTL constant | TTL |
|
||||
|--------|---------|--------------|-----|
|
||||
| session | `session:` | `SESSION_TTL_SECONDS` | 900 |
|
||||
| chat pointer | `chat:` | `SESSION_TTL_SECONDS` | 900 |
|
||||
| location pin | `loc:` | `PIN_TTL_SECONDS` | 300 |
|
||||
| guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` | 7200 |
|
||||
|
||||
The category-only log vocabulary (from `bot/src/webhook.ts` lines 63–68) — every
|
||||
`console.log` in `bot/src` has the shape `[surface] verdict: category`, where
|
||||
`surface` is one of `webhook`, `initdata`, `location`, `location-fallback`,
|
||||
`guidance`, `guidance-cron`, `route`, `search`, `search-alternate`.
|
||||
|
||||
The web-side state-token vocabulary (from `web/src/main.ts` and
|
||||
`web/src/route/route.ts`) — every `console.log` in `web/src` is either a
|
||||
`ARAHARAH-*` state token (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=… acc=…`,
|
||||
`ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`),
|
||||
the `RAMS-BASEMAP-READY layers=…` signal, or a `console.error` carrying an
|
||||
error object.
|
||||
|
||||
The positive runtime fetch-host allowlist (D-01) — the ONLY hosts the shipped
|
||||
bundle may reach:
|
||||
|
||||
| Host | Why |
|
||||
|------|-----|
|
||||
| `tiles.openfreemap.org` | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) |
|
||||
| `telegram.org` | the official Telegram WebApp SDK script tag in `web/index.html` |
|
||||
| the app's own Worker origin | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance` — a RELATIVE path, not a host |
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: the source-gates library — storage, TTL, log vocabulary, runtime fetch hosts (ALL src trees)</name>
|
||||
<files>scripts/lib/source-gates.mjs</files>
|
||||
<read_first>
|
||||
- web/test/no-storage.test.mjs (the canonical comment-stripper + the BANNED list)
|
||||
- bot/test/privacy.test.mjs (the PG1 same-line expirationTtl law)
|
||||
- bot/test/route-privacy.test.mjs (the host-ban + import-allowlist style)
|
||||
- .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`scripts/privacy-audit.mjs` + §Shared Patterns
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/lib/source-gates.mjs` — the repo-local source scanner (a
|
||||
MAINTAINER-TIME REPO TOOL — nothing in `bot/src`, `web/src`, `routing/src`, or
|
||||
`search/src` imports it; its header comment must say so, copying the law from
|
||||
`routing/scripts/measure-route-cpu.mjs` lines 3–9).
|
||||
|
||||
It MUST export these named functions, each returning
|
||||
`{ id, pass, evidence }` (the RESEARCH Pattern 4 shape):
|
||||
|
||||
```javascript
|
||||
export function runStorageGate(); // id: "storage-api"
|
||||
export function runKvTtlGate(); // id: "kv-ttl"
|
||||
export function runLogVocabularyGate(); // id: "log-vocabulary"
|
||||
export function runRuntimeHostGate(); // id: "runtime-hosts"
|
||||
```
|
||||
|
||||
**`runStorageGate()`** — scans EVERY production source tree: `web/src`,
|
||||
`bot/src`, `routing/src`, `search/src`, `theme/src`. For each `.ts`/`.tsx`/
|
||||
`.js`/`.mjs`/`.html` file, strip comments with the EXACT stripper above, then
|
||||
assert the file contains NONE of the `BANNED` persistence tokens. Also assert
|
||||
no file contains `serviceWorker.register`, `navigator.serviceWorker`, or
|
||||
`workbox`. The evidence string must name the file count scanned and every
|
||||
violation as `relative-path: token`.
|
||||
|
||||
**`runKvTtlGate()`** — scans EVERY file under `bot/src` (and any other tree
|
||||
containing `.put(`). For each line containing `.put(`, assert the SAME LINE
|
||||
contains `expirationTtl` and that the captured value matches
|
||||
`/^[A-Z_]{2,}$/` (a NAMED constant, never an inline literal). The evidence
|
||||
string must name each violation as `relative-path:line — KV write without a
|
||||
named expirationTtl`.
|
||||
|
||||
**`runLogVocabularyGate()`** — the category-only vocabulary. For every
|
||||
`console.log(` call site in `bot/src` and `web/src`:
|
||||
- In `bot/src`: the line (comments stripped) MUST match
|
||||
`/console\.log\(\s*[`"']\[([a-z-]+)\][^`"']*`/i` — i.e. it starts with a
|
||||
bracketed surface token. Additionally the line MUST NOT contain any of the
|
||||
coordinate/identifier carriers: `lat`, `lng`, `chat_id`, `user_id`, `token`,
|
||||
`initData`, `geometry`, `polyline`, `payload`. (Note: `chat_id`/`user_id`
|
||||
must be checked as whole words so that `[location]` does not trip
|
||||
`chat`.)
|
||||
- In `web/src`: the line MUST match one of
|
||||
`/ARAHARAH-(DEST|LIVE|ROUTE|GUIDE|DRIVE)\s+state=/`,
|
||||
`/ARAHARAH-LIVE\s+fix=/`,
|
||||
`/RAMS-BASEMAP-READY/`. A `console.error(` line is allowed (the basemap
|
||||
error path). Any other `console.log` FAILS the gate.
|
||||
- ALSO assert that NO file in `bot/src` or `web/src` contains a bare
|
||||
`JSON.stringify(` inside a `console.log(` argument — a serialized payload is
|
||||
a payload leak.
|
||||
|
||||
The evidence string lists each violation as `relative-path:line — <the line>`.
|
||||
|
||||
**`runRuntimeHostGate()`** — the POSITIVE host allowlist. Enumerate every
|
||||
`fetch(` call site in `web/src` and `bot/src` and every absolute `https://`
|
||||
URL in `web/src`, `bot/src`, `theme/src`, and `web/index.html`. For each
|
||||
ABSOLUTE URL, its host MUST be in the allowlist
|
||||
`["tiles.openfreemap.org", "telegram.org"]` (a relative path like `/api/guidance`
|
||||
is NOT a host and is allowed — the app's own Worker). For each `fetch(` call
|
||||
site, the first argument MUST be a relative path (starting with `/`) or a
|
||||
template literal whose static prefix is relative — NEVER an absolute URL to an
|
||||
unapproved host. The evidence string must print the ACTUAL host inventory it
|
||||
found (this is the A4 assumption's evidence — the host list is produced, not
|
||||
assumed).
|
||||
|
||||
IMPORTANT — the stripper's over-stripping is deliberate ("prefer-fail
|
||||
reading"). Do NOT write a smarter stripper. The header comment must say so.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `scripts/lib/source-gates.mjs` exists and exports `runStorageGate`, `runKvTtlGate`, `runLogVocabularyGate`, `runRuntimeHostGate`
|
||||
- `grep -c "stripComments" scripts/lib/source-gates.mjs` returns ≥ 2 (the definition + at least one use)
|
||||
- `grep -c "expirationTtl" scripts/lib/source-gates.mjs` returns ≥ 1 and `grep -c "localStorage" scripts/lib/source-gates.mjs` returns ≥ 1
|
||||
- `grep -c "console.log" scripts/lib/source-gates.mjs` returns ≥ 1 (the log-vocabulary gate exists)
|
||||
- `grep -c "tiles.openfreemap.org" scripts/lib/source-gates.mjs` returns ≥ 1 (the positive allowlist names the tile host)
|
||||
- `grep -c "web/src\|bot/src\|routing/src\|search/src" scripts/lib/source-gates.mjs` returns ≥ 4 (the scan is repo-wide, not web-only)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/source-gates.mjs && echo "syntax ok" && node -e "import('./scripts/lib/source-gates.mjs').then(m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=m[k]();console.log(r.id+': '+(r.pass?'PASS':'FAIL')+' — '+r.evidence.slice(0,200));}})"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
The four source gates run over every production tree, print their
|
||||
evidence, and report PASS on the current tree.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 2: the bundle-gates library + the cloudflare-probes library</name>
|
||||
<files>scripts/lib/bundle-gates.mjs, scripts/lib/cloudflare-probes.mjs</files>
|
||||
<read_first>
|
||||
- web/test/build-artifact.test.mjs (the DirEnt walk at lines 79–87, the PRODUCTION-only scope law at lines 133–147, the JS-assets-only crypto rule at lines 175–187, and the BA9 gzip measurement at lines 221–234)
|
||||
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfalls 5, 6, 7, 10 + §Pattern 5
|
||||
- .planning/phases/07-hardening-private-launch/07-PATTERNS.md §No Analog Found (the live-probe guidance)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/lib/bundle-gates.mjs`:
|
||||
|
||||
```javascript
|
||||
export function runBundleTelemetryGate(); // id: "bundle-telemetry" — reads web/dist ONLY
|
||||
```
|
||||
|
||||
- It walks `web/dist` recursively (the DirEnt walk from
|
||||
`web/test/build-artifact.test.mjs` lines 79–87 — copy it).
|
||||
- **PRODUCTION ONLY**: `web/dist` — NEVER `web/dist-harness` (BA5's law: a
|
||||
gate pointed at the harness build inverts itself; the harness is
|
||||
deliberately not clean).
|
||||
- If `web/dist` does not exist, the gate returns `{ pass: false, evidence:
|
||||
"web/dist does not exist — run \`npm run build -w theme && npm run build -w web\` first" }`
|
||||
(never a silent pass).
|
||||
- It scans every emitted `.js` file for the analytics/beacon token set:
|
||||
`["google-analytics.com","googletagmanager.com","analytics.google","segment.io",
|
||||
"mixpanel","sentry.io","amplitude","datadog","bugsnag","posthog","plausible.io",
|
||||
"hotjar","intercom","beacon","navigator.sendBeacon","gtag(","fbq("]`.
|
||||
(`beacon` is checked as `navigator.sendBeacon` — a bare `beacon` substring
|
||||
would false-positive on unrelated words; use the explicit form.)
|
||||
- It scans every emitted `.js` AND `.html` file for ABSOLUTE `https://` URLs
|
||||
and asserts every such URL's host is in the allowlist
|
||||
`["tiles.openfreemap.org", "telegram.org"]`. The evidence string PRINTS the
|
||||
full host inventory it found.
|
||||
- It scans the emitted `.js` assets (JS ONLY, never the HTML — the official
|
||||
Telegram SDK script is external and carries its own crypto, BA8's law) for
|
||||
the deep-link crypto tokens `["createHmac","crypto.subtle","sha256","HMAC("]`
|
||||
— the D-11 law. (Note: `crypto.subtle` may legitimately appear in the
|
||||
harness mock — but the mock never ships to `web/dist`; if the scan trips on
|
||||
it, that IS a finding.)
|
||||
- It scans the emitted `.js` assets for the storage tokens
|
||||
`["localStorage","sessionStorage","indexedDB","document.cookie"]` — but with
|
||||
the platform allowlist: the OFFICIAL Telegram SDK's own
|
||||
`__telegram__initParams` sessionStorage key is written by the EXTERNAL
|
||||
`telegram-web-app.js`, which is loaded as a `<script src>` and is NOT in
|
||||
`web/dist` — so the shipped bundle should carry NO storage token at all. If
|
||||
a storage token IS found in `web/dist`, it is a FAIL (evidence names the
|
||||
file).
|
||||
- The evidence string must name every violation as `relative-path — <token>`.
|
||||
|
||||
Create `scripts/lib/cloudflare-probes.mjs`:
|
||||
|
||||
```javascript
|
||||
export function runKvExpiryProbe(opts); // id: "kv-expiry" — LIVE ONLY
|
||||
export function runAssetHeaderProbe(opts); // id: "asset-headers" — LIVE ONLY
|
||||
export function probeUnavailable(reason); // { id, pass: false, notRun: true, evidence }
|
||||
```
|
||||
|
||||
- **The live probe needs credentials.** If `CLOUDFLARE_API_TOKEN` +
|
||||
`CLOUDFLARE_ACCOUNT_ID` are not in the environment (or `npx wrangler` is
|
||||
unavailable), the probe returns `probeUnavailable("missing Cloudflare credentials")`
|
||||
— `pass: false`, `notRun: true`. The orchestrator turns a `notRun` result
|
||||
into exit code 2 (environment drift), NEVER into a pass.
|
||||
- `runKvExpiryProbe` shells out to
|
||||
`npx wrangler kv key list --namespace-id=59f58a54bc44483e89872b20a005a5eb`
|
||||
(the live namespace id from `bot/wrangler.jsonc` — read it from that file
|
||||
at runtime rather than hard-coding it twice; the id lives in
|
||||
`bot/wrangler.jsonc` under `kv_namespaces[0].id`). It parses the JSON
|
||||
output for each key's `name`, the optional `expiration`, the optional
|
||||
`metadata`, the `list_complete` flag and the `cursor` (Pitfall 7 — key
|
||||
presence alone does not prove TTL-only). It asserts:
|
||||
1. every key's family prefix is one of `session:`, `chat:`, `loc:`,
|
||||
`guidance:` — an unexpected family is REPORTED (A5 says report, never
|
||||
assume absence);
|
||||
2. every listed key carries an `expiration`;
|
||||
3. it reports the OLDEST and NEWEST expiry it saw.
|
||||
It FOLLOWS THE CURSOR until `list_complete` is true.
|
||||
- `runAssetHeaderProbe` shells out to
|
||||
`curl -sSI <MINI_APP_URL or the deployed origin>` and reads the
|
||||
`cache-control` and `cf-cache-status` headers for the HTML entry and for a
|
||||
hashed `/assets/*` file. It reports the observed headers VERBATIM
|
||||
(CF-Cache-Status is probabilistic per Cloudflare's own footnote — the
|
||||
evidence prints it, it does not interpret it as a guarantee).
|
||||
- Both probes must be safe to call with no credentials: they return
|
||||
`probeUnavailable(...)` rather than throwing.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `scripts/lib/bundle-gates.mjs` exists and exports `runBundleTelemetryGate`; it contains the literal `web/dist` and the literal `dist-harness` (as the FORBIDDEN path, in a comment or an exclusion check)
|
||||
- `grep -c "ALLOWED\|allowlist" scripts/lib/bundle-gates.mjs` returns ≥ 1 and `grep -c "tiles.openfreemap.org" scripts/lib/bundle-gates.mjs` returns ≥ 1
|
||||
- `grep -c "navigator.sendBeacon" scripts/lib/bundle-gates.mjs` returns ≥ 1 (the beacon token)
|
||||
- `scripts/lib/cloudflare-probes.mjs` exists and exports `runKvExpiryProbe`, `runAssetHeaderProbe`, `probeUnavailable`
|
||||
- `grep -c "list_complete" scripts/lib/cloudflare-probes.mjs` returns ≥ 1 and `grep -c "expiration" scripts/lib/cloudflare-probes.mjs` returns ≥ 1
|
||||
- `grep -c "kv key list" scripts/lib/cloudflare-probes.mjs` returns ≥ 1 (the current wrangler syntax — the deprecated `kv:key list` form is forbidden)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/bundle-gates.mjs && node --check scripts/lib/cloudflare-probes.mjs && node -e "import('./scripts/lib/cloudflare-probes.mjs').then(m=>{const r=m.probeUnavailable('missing Cloudflare credentials');console.log(JSON.stringify(r)); if(r.pass!==false||r.notRun!==true){console.error('probeUnavailable must be pass:false + notRun:true');process.exit(1);}})"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
The bundle gate scans `web/dist` only against the positive host allowlist
|
||||
and the analytics/beacon token set; the live probes parse the expiry
|
||||
metadata and the pagination cursor, and degrade to `notRun` (never a
|
||||
false pass) without credentials.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: the privacy-audit CLI + the root npm script + the signed audit record</name>
|
||||
<files>scripts/privacy-audit.mjs, package.json, docs/PRIVACY-AUDIT.md</files>
|
||||
<read_first>
|
||||
- theme/scripts/audit.mjs (the exit-code contract, lines 20–24, and the report shape, lines 797–814)
|
||||
- routing/scripts/measure-route-cpu.mjs (the maintainer-time-tool header law, lines 3–9)
|
||||
- docs/CONFIGURATION.md (the docs-dir clinical prose style — a leading "surfaces, by kind" table)
|
||||
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-01 and D-02 verbatim)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/privacy-audit.mjs` — the orchestrator (RESEARCH Pattern 4's
|
||||
shape). Its header comment must state: this is a MAINTAINER-TIME REPO TOOL,
|
||||
not a runtime dependency; it is GPL-licensed and reusable by forks; v1 carries
|
||||
ZERO telemetry (D-02) so the audit proves an ABSENCE.
|
||||
|
||||
Behaviour:
|
||||
- Accepts an optional `--live` flag. Without it, the live probes are SKIPPED
|
||||
and the run reports `kv-expiry: NOT RUN (pass --live)` — a skip is never
|
||||
silently green.
|
||||
- Runs, in order: `runStorageGate()`, `runKvTtlGate()`, `runLogVocabularyGate()`,
|
||||
`runRuntimeHostGate()`, `runBundleTelemetryGate()`; with `--live`, also
|
||||
`runKvExpiryProbe()` and `runAssetHeaderProbe()`.
|
||||
- Prints ONE line per gate in the form
|
||||
`<id>: PASS|FAIL|NOT RUN — <evidence>` (the Pattern 4 shape), then a
|
||||
summary line:
|
||||
`PRIVACY AUDIT CLEAN — <n> gate(s) passed, 0 violations` or
|
||||
`PRIVACY AUDIT FAILED — <n> violation(s) across <m> gate(s)` or
|
||||
`PRIVACY AUDIT NOT RUN — <reason> (environment drift; never a pass)`.
|
||||
- EXIT CODES (the repo's no-silent-passes contract):
|
||||
- `0` — every required gate PASSed.
|
||||
- `1` — at least one gate FAILED (the violations are printed above).
|
||||
- `2` — environment/credential drift (a required live probe could not run
|
||||
because credentials or the build are absent) — this is NOT a pass and the
|
||||
message says so.
|
||||
- The script must NEVER print a coordinate, a chat id, a user id, a token, or
|
||||
an initData payload — its output is category-only, exactly like the bot's
|
||||
logs.
|
||||
|
||||
Add to the ROOT `package.json` `scripts` (next to the other root scripts):
|
||||
|
||||
```json
|
||||
"privacy-audit": "node scripts/privacy-audit.mjs",
|
||||
```
|
||||
|
||||
Create `docs/PRIVACY-AUDIT.md` — the SIGNED written audit record (D-01's
|
||||
"signed written audit record citing the evidence"). Follow the
|
||||
`docs/CONFIGURATION.md` clinical-prose style. It MUST contain:
|
||||
|
||||
1. A title `# Privacy audit record` and a dated front-matter block naming the
|
||||
audit date, the commit SHA (`git rev-parse --short HEAD`), and the auditor.
|
||||
2. A `## Scope` section naming what was audited (all five `src` trees, the
|
||||
production bundle `web/dist`, the live KV namespace) and what was NOT
|
||||
(the harness build `web/dist-harness`, the Telegram client's own internal
|
||||
storage — the platform allowlist).
|
||||
3. A `## Findings` table with one row per gate: `| Gate | Result | Evidence |`
|
||||
where the Evidence column carries the ACTUAL printed evidence string from
|
||||
the run (copy it from the audit output — never invent it).
|
||||
4. A `## Storage inventory` table listing the four KV key families with
|
||||
their TTL constants and TTLs, and stating that no other family exists and
|
||||
that no coordinate or profile is persisted.
|
||||
5. A `## Log vocabulary` section listing the allowed log surfaces and
|
||||
stating the coordinate-free law.
|
||||
6. A `## Runtime network` section listing the ACTUAL host inventory the audit
|
||||
printed, stating that no analytics/beacon host exists, and stating
|
||||
D-02's zero-telemetry posture explicitly.
|
||||
7. A `## Statement` section: the PRIV-02 + GEO-03 re-verification claim in
|
||||
one honest paragraph, naming the evidence that backs it.
|
||||
8. A `## Signature` block at the end with a line for the maintainer's name
|
||||
and a date line, both left as `________________` to be filled by the human
|
||||
at the launch gate (the audit record is SIGNED BY THE HUMAN — the tool
|
||||
produces the evidence, the human signs the claim).
|
||||
|
||||
FILL the Findings/Storage/Log/Network sections with the REAL run output. Run
|
||||
`npm run privacy-audit` yourself, paste the printed evidence verbatim. If a
|
||||
gate FAILS, fix the underlying code and re-run — do NOT soften the record.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- `scripts/privacy-audit.mjs` exists and contains the literals `PRIVACY AUDIT CLEAN`, `PRIVACY AUDIT FAILED`, `PRIVACY AUDIT NOT RUN`
|
||||
- `grep -c "process.exit" scripts/privacy-audit.mjs` returns ≥ 3 (the three exit codes)
|
||||
- `grep -c "notRun" scripts/privacy-audit.mjs` returns ≥ 1 (a skipped probe is never a pass)
|
||||
- `grep -c '"privacy-audit"' package.json` returns exactly 1
|
||||
- `docs/PRIVACY-AUDIT.md` exists and contains the literals `PRIVACY AUDIT RECORD` (or the title `Privacy audit record`), `## Scope`, `## Findings`, `## Storage inventory`, `## Log vocabulary`, `## Runtime network`, `## Statement`, `## Signature`
|
||||
- `npm run privacy-audit` exits 0 on the current tree and prints `PRIVACY AUDIT CLEAN`
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run privacy-audit 2>&1 | tail -20 && npm run privacy-audit >/dev/null 2>&1; echo "exit=$?"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`npm run privacy-audit` runs the consolidated gates, prints per-gate
|
||||
evidence, exits 0/1/2 correctly, and `docs/PRIVACY-AUDIT.md` carries the
|
||||
signed record citing that run's real evidence.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Source tree → audit scanner | The scanner reads untrusted repository text; a comment-stripping mistake could hide a call site behind a prose mention. |
|
||||
| Production bundle → runtime network | The shipped JS decides which hosts the app actually reaches; the source tree does not prove the bundle. |
|
||||
| Cloudflare account → audit probe | The live probe reads the real namespace; a missing credential must never read as "clean". |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-01 | Information Disclosure | `bot/src/kv/*` — a KV write without a TTL becomes a persistent per-user location history | mitigate | `runKvTtlGate` asserts EVERY `.put(` line carries `expirationTtl` referencing a NAMED `^[A-Z_]{2,}$` constant, across every tree — the PG1 law extended repo-wide (D-01). The live probe additionally asserts every listed key carries an `expiration` and reports the oldest/newest expiry (Pitfall 7). |
|
||||
| T-07-02 | Information Disclosure | `web/src` — a persistence API would make the client a location-history store | mitigate | `runStorageGate` scans all five production trees for the full BANNED persistence list plus the service-worker family, with comments stripped (the canonical stripper, copied verbatim). The bundle gate re-checks the shipped `web/dist` for the same tokens. |
|
||||
| T-07-03 | Information Disclosure | `bot/src` / `web/src` log call sites — a coordinate, chat id, or payload in a log line | mitigate | `runLogVocabularyGate` requires every `bot/src` log to open with a bracketed surface token and forbids `lat`/`lng`/`chat_id`/`user_id`/`token`/`initData`/`geometry`/`polyline`/`payload` on the line; every `web/src` log must be an `ARAHARAH-*` state token or the basemap signal. Serialized payloads inside a log are forbidden outright. |
|
||||
| T-07-04 | Information Disclosure | the production bundle — a smuggled analytics/beacon host or an unapproved fetch | mitigate | `runBundleTelemetryGate` scans `web/dist` (production only) for the analytics/beacon token set AND enforces a POSITIVE host allowlist (`tiles.openfreemap.org`, `telegram.org`); the host inventory is printed as evidence, not assumed (A4). |
|
||||
| T-07-16 | Spoofing | the audit's own verdict — a missing credential reported as clean | mitigate | The live probes return `{pass:false, notRun:true}` without credentials and the CLI maps a `notRun` result to exit code 2 with the message `PRIVACY AUDIT NOT RUN` — a skip is never a pass (RESEARCH Pattern 4, the repo's no-silent-passes contract). |
|
||||
| T-07-17 | Tampering | the comment stripper — a prose mention tripping the gate, or a call site hiding behind one | accept | The repo's own stripper is copied VERBATIM with its deliberate over-stripping ("prefer-fail reading"): a false positive fails the gate (a loud failure), and a hidden call site is caught by the bundle scan and the live probe, which do not rely on comment stripping. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run after all tasks:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
|
||||
cd /home/C-Fu/araharah
|
||||
npm run build -w theme && npm run build -w web
|
||||
npm run privacy-audit
|
||||
npm run privacy-audit -- --live
|
||||
```
|
||||
|
||||
The first must exit 0 with `PRIVACY AUDIT CLEAN`. The second must either exit 0
|
||||
(with the live probes' evidence printed) or exit 2 with
|
||||
`PRIVACY AUDIT NOT RUN` when the credentials are absent — it must NEVER exit 0
|
||||
while reporting a live probe as skipped.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `npm run privacy-audit` exists and runs the storage, KV-TTL, log-vocabulary, runtime-host and bundle-telemetry gates over ALL production trees plus `web/dist`, printing per-gate evidence.
|
||||
- The exit codes are 0 / 1 / 2 with "NOT RUN" never counted as a pass.
|
||||
- The live KV probe reads the expiry metadata and the pagination cursor, and the live header probe prints the observed cache headers verbatim.
|
||||
- `docs/PRIVACY-AUDIT.md` carries the signed audit record with the real run evidence in its Findings/Storage/Log/Network sections and a human signature block.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-hardening-private-launch/07-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user