From aedc506cce3eecc68d9457ceb748362bb8a48330 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Fri, 11 Sep 2026 18:28:02 -0400 Subject: [PATCH] fix(docs): keep operator-internal security writeups out of public /docs (#13136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security-relevant and the right fix. A Basic gate on the whole `/docs` path was a deploy bandage that also locked LAN operators out of `https://:20128/docs`; dropping the four operator-internal writeups from the fumadocs glob and the Docker image removes the reason for the gate instead of papering over it. Keeping them in git and citing repo paths from the public pages is the right trade. The `.dockerignore` matcher walking rules in file order with last-match-wins is a genuine correctness fix in the test's own matcher. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the other 19 PRs of this batch. Two in-batch conflicts, both additive and resolved by keeping each side: the `ENVIRONMENT.md` table (#13035 + #13011) and the `chatHelpers.ts` import block (#12975 on the tip + #13017). - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK; `check:docs-counts` migrations ✓ - complexity 2816 / baseline 3218 and cognitive-complexity 1271 / baseline 1437 — both under baseline - 531 of 532 focused assertions green across the batch's 46 test files - `check-file-size` rebaselined for the batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_houminxi`, landed on #13038), attributed per PR The single red is **not this batch**: `tests/unit/combo/quota-weighted-strategy.test.ts` → "A/B isolation: 7 hard-empty + 2 at 0.5% + 1 at 40%, floor=1" asserts an order between two connections of identical weight and flakes on the pure tip too — 2 failures in 4 runs at `origin/release/v3.8.51` with nothing from this batch applied. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, untouched here). Thanks @HouMinXi — the live evidence on these (X500 logs, `storage.sqlite` state, real `/v1/models` probes, the 36-minute outage write-up) is what let a 20-PR batch be reviewed as a unit. --- .dockerignore | 5 + .../fixes/13136-docs-sensitive-catalog.md | 1 + docs/architecture/ARCHITECTURE.md | 2 +- docs/architecture/REPOSITORY_MAP.md | 2 +- docs/architecture/RESILIENCE_GUIDE.md | 2 +- docs/frameworks/AGENTBRIDGE.md | 4 +- docs/frameworks/TRAFFIC_INSPECTOR.md | 6 +- docs/guides/DOCKER_GUIDE.md | 2 +- docs/ops/CONTRIBUTION_GOLDEN_PATH.md | 2 +- docs/security/EGRESS_POLICY.md | 2 +- docs/security/meta.json | 4 - source.config.ts | 6 + ...ocs-public-catalog-sensitive-pages.test.ts | 260 ++++++++++++++++++ 13 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 changelog.d/fixes/13136-docs-sensitive-catalog.md create mode 100644 tests/unit/docs-public-catalog-sensitive-pages.test.ts diff --git a/.dockerignore b/.dockerignore index 70653bf14d..a32d1c42cf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -73,6 +73,11 @@ docs/i18n/** # so without this rule these land in /app/docs and become readable through the # dashboard's Docs viewer at runtime. docs/superpowers/** +# Operator-internal security writeups: git only, not the image or /docs catalog. +docs/security/STEALTH_GUIDE.md +docs/security/SOCKET_DEV_FINDINGS.md +docs/security/MITM-TPROXY-DECRYPT.md +docs/security/PUBLIC_CREDS.md docs/diagrams/**/*.png docs/diagrams/**/*.jpg docs/diagrams/**/*.jpeg diff --git a/changelog.d/fixes/13136-docs-sensitive-catalog.md b/changelog.d/fixes/13136-docs-sensitive-catalog.md new file mode 100644 index 0000000000..573d471a3a --- /dev/null +++ b/changelog.d/fixes/13136-docs-sensitive-catalog.md @@ -0,0 +1 @@ +- **fix(docs):** drop TLS-impersonation, MITM-decrypt, supply-chain attestation, and XOR-mask writeups from the public `/docs` catalog and Docker image. Files stay in git for engineers; operators who need them open the repo, not the website. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 17725faeb5..687e231587 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -494,7 +494,7 @@ the global circuit breaker / connection cooldown / model lockout layers: - Claude Code obfuscation: `open-sse/services/claudeCodeObfuscation.ts` For the full stealth playbook and operational guidance, see -[`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md). +`docs/security/STEALTH_GUIDE.md` (git; not compiled into `/docs`). ### H. Webhooks, Reasoning Cache, Read Cache diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index 76bdcc3394..9d139de54e 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -418,7 +418,7 @@ open-sse/ | `REASONING_REPLAY.md` | Hybrid memory/SQLite cache for `reasoning_content` | | `AUTHZ_GUIDE.md` | Authorization pipeline (`classify` → `policies` → `enforce`) | | `RESILIENCE_GUIDE.md` | Circuit breaker + cooldown + model lockout | -| `STEALTH_GUIDE.md` | TLS fingerprinting (JA3/JA4), Claude Code CCH, MITM cert | +| `docs/security/STEALTH_GUIDE.md` (git only) | TLS fingerprinting (JA3/JA4), Claude Code CCH, MITM cert | | `AUTO-COMBO.md` | Auto Combo engine (16-factor scoring, 6 mode packs, virtual factory) | ### Compression diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index 8599ed8462..a6d06c8309 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -628,7 +628,7 @@ rate limit is the same signal as an exhausted quota. Honest limits: ## TLS Fingerprinting & Stealth -Provider-specific stealth (JA3/JA4, CCH, obfuscation) is separately documented — see [STEALTH_GUIDE.md](../security/STEALTH_GUIDE.md). +Provider-specific stealth (JA3/JA4, CCH, obfuscation) is separately documented — see `docs/security/STEALTH_GUIDE.md` (git; not compiled into `/docs`). --- diff --git a/docs/frameworks/AGENTBRIDGE.md b/docs/frameworks/AGENTBRIDGE.md index 7076746f1d..65c68b1cab 100644 --- a/docs/frameworks/AGENTBRIDGE.md +++ b/docs/frameworks/AGENTBRIDGE.md @@ -10,7 +10,7 @@ AgentBridge is OmniRoute's MITM (Man-in-the-Middle) proxy that intercepts HTTPS **Dashboard location:** `/dashboard/tools/agent-bridge` **Sidebar group:** Tools (after Cloud Agents) -**See also:** [`TRAFFIC_INSPECTOR.md`](./TRAFFIC_INSPECTOR.md) — monitor all intercepted traffic in real-time; [`docs/security/MITM-TPROXY-DECRYPT.md`](../security/MITM-TPROXY-DECRYPT.md) — the Linux TPROXY transparent-decrypt capture mode driven by the `/api/tools/agent-bridge/tproxy` route. +**See also:** [`TRAFFIC_INSPECTOR.md`](./TRAFFIC_INSPECTOR.md) — monitor all intercepted traffic in real-time; `docs/security/MITM-TPROXY-DECRYPT.md` (git; not compiled into `/docs`) — the Linux TPROXY transparent-decrypt capture mode driven by the `/api/tools/agent-bridge/tproxy` route. --- @@ -527,7 +527,7 @@ Base path: `/api/tools/agent-bridge/` | GET | `/api/tools/agent-bridge/upstream-ca` | Get configured upstream CA path | | POST | `/api/tools/agent-bridge/upstream-ca` | Validate + persist upstream CA path | | POST | `/api/tools/agent-bridge/upstream-ca/test` | Validate-only (dry-run) an upstream CA path — does not persist | -| GET / POST / DELETE | `/api/tools/agent-bridge/tproxy` | TPROXY transparent-decrypt capture mode — see [`docs/security/MITM-TPROXY-DECRYPT.md`](../security/MITM-TPROXY-DECRYPT.md) | +| GET / POST / DELETE | `/api/tools/agent-bridge/tproxy` | TPROXY transparent-decrypt capture mode — see `docs/security/MITM-TPROXY-DECRYPT.md` (git; not compiled into `/docs`) | Full OpenAPI schemas: `docs/openapi.yaml` → tag `AgentBridge`. diff --git a/docs/frameworks/TRAFFIC_INSPECTOR.md b/docs/frameworks/TRAFFIC_INSPECTOR.md index 6fb9ecff28..b304842ef7 100644 --- a/docs/frameworks/TRAFFIC_INSPECTOR.md +++ b/docs/frameworks/TRAFFIC_INSPECTOR.md @@ -111,7 +111,7 @@ export HTTPS_PROXY=http://127.0.0.1:8080 **Requirements:** Linux only (**IP_TRANSPARENT** is Linux-only), the **CAP_NET_ADMIN** capability (root), and a native N-API addon that must be built with a C toolchain (`npm run build:native:tproxy`). When unavailable, the dashboard toggle is disabled with the tooltip "TPROXY decrypt requires Linux + root + the native addon". The firewall rules apply/revert transactionally (a crash never leaves a `mangle` rule behind) and flush on reboot. An SO_MARK-based anti-loop keeps the proxy's own re-encrypted forward from being re-intercepted. -This is a substantial subsystem with its own dedicated operator guide — see **[`docs/security/MITM-TPROXY-DECRYPT.md`](../security/MITM-TPROXY-DECRYPT.md)** for the full firewall recipe, the per-SNI dynamic CA + trust-store installer, the local-only route, anti-loop details, and the configuration schema. The toggle is driven by `GET / POST / DELETE /api/tools/agent-bridge/tproxy` (note: the route lives under the AgentBridge prefix, not the Traffic Inspector prefix). +This is a substantial subsystem with its own dedicated operator guide — see `docs/security/MITM-TPROXY-DECRYPT.md` (git; not compiled into `/docs`) for the full firewall recipe, the per-SNI dynamic CA + trust-store installer, the local-only route, anti-loop details, and the configuration schema. The toggle is driven by `GET / POST / DELETE /api/tools/agent-bridge/tproxy` (note: the route lives under the AgentBridge prefix, not the Traffic Inspector prefix). ### Capture mode comparison @@ -121,7 +121,7 @@ This is a substantial subsystem with its own dedicated operator guide — see ** | 2. Custom Hosts | Per-host input | Yes (hosts file) | Any app using that host | Persisted in DB | | 3. HTTP_PROXY | `export HTTPS_PROXY=...` | No | Apps respecting env | Port 8080, no TLS decrypt by default | | 4. System-wide | Toggle + confirm | Yes | All apps on machine | Auto-disable in 30 min | -| 5. TPROXY decrypt | Toggle (Linux + native addon) | Yes (root + CA install) | Any host on the target port | Decrypts arbitrary hosts; off by default — see [MITM-TPROXY-DECRYPT.md](../security/MITM-TPROXY-DECRYPT.md) | +| 5. TPROXY decrypt | Toggle (Linux + native addon) | Yes (root + CA install) | Any host on the target port | Decrypts arbitrary hosts; off by default — see `docs/security/MITM-TPROXY-DECRYPT.md` (git; not compiled into `/docs`) | --- @@ -477,7 +477,7 @@ Base path: `/api/tools/traffic-inspector/` > **TPROXY decrypt** (capture mode 5) is driven by a **separate** route under the > AgentBridge prefix — `GET / POST / DELETE /api/tools/agent-bridge/tproxy` — not > under `/api/tools/traffic-inspector/`. See -> [`docs/security/MITM-TPROXY-DECRYPT.md`](../security/MITM-TPROXY-DECRYPT.md). +> `docs/security/MITM-TPROXY-DECRYPT.md` (git; not compiled into `/docs`). ### Sessions diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 69a4e5a9e5..9a9352c918 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -162,7 +162,7 @@ with a warning that it will not survive the container. > (`COMPOSE_PROFILES=core,redis` or shorter). The other profiles do not > mount the Docker socket. > -> See `docs/security/MITM-TPROXY-DECRYPT.md` for the related threat model +> See `docs/security/MITM-TPROXY-DECRYPT.md` (git; not compiled into `/docs`) for the related threat model > around MITM, and `docs/security/SUPPLY_CHAIN.md` for the > `codex`/`claude-code`/`droid`/`openclaw` binary provenance chain. diff --git a/docs/ops/CONTRIBUTION_GOLDEN_PATH.md b/docs/ops/CONTRIBUTION_GOLDEN_PATH.md index fa171a4890..fc461ade8d 100644 --- a/docs/ops/CONTRIBUTION_GOLDEN_PATH.md +++ b/docs/ops/CONTRIBUTION_GOLDEN_PATH.md @@ -44,7 +44,7 @@ behavior you changed. - Executor/translator selection, OAuth or API-key configuration, dashboard assets, and generated provider reference when applicable. - Public credentials must use `resolvePublicCred()`; error responses must use the shared sanitized - error helpers. See [Public Credentials](../security/PUBLIC_CREDS.md) and + error helpers. See `docs/security/PUBLIC_CREDS.md` (git; not compiled into `/docs`) and [Error Sanitization](../security/ERROR_SANITIZATION.md). **Focused loop** diff --git a/docs/security/EGRESS_POLICY.md b/docs/security/EGRESS_POLICY.md index a3d4ac402a..ec7a06efac 100644 --- a/docs/security/EGRESS_POLICY.md +++ b/docs/security/EGRESS_POLICY.md @@ -243,5 +243,5 @@ When a resolved proxy object carries a non-`auto` `family`, `proxyConfigToUrl` a > 📖 **Related documentation:** > > - [Proxy Guide](../ops/PROXY_GUIDE.md) — full proxy system: registry CRUD, 4-level resolution, rotation, health checking, API reference -> - [Stealth Guide](./STEALTH_GUIDE.md) — TLS fingerprint and CLI fingerprint layers that ride on top of the proxy +> - `docs/security/STEALTH_GUIDE.md` (git; not compiled into `/docs`) — TLS fingerprint and CLI fingerprint layers that ride on top of the proxy > - [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for local-only routes diff --git a/docs/security/meta.json b/docs/security/meta.json index 33ab9c576e..cc28c2bbb1 100644 --- a/docs/security/meta.json +++ b/docs/security/meta.json @@ -2,18 +2,14 @@ "title": "Security", "pages": [ "GUARDRAILS", - "PUBLIC_CREDS", "ERROR_SANITIZATION", "ROUTE_GUARD_TIERS", "BAN_DETECTION", "AGENTROUTER_WAF", "CORS", - "STEALTH_GUIDE", "EGRESS_POLICY", - "MITM-TPROXY-DECRYPT", "SUPPLY_CHAIN", "COMPLIANCE", - "SOCKET_DEV_FINDINGS", "CLI_TOKEN" ] } diff --git a/source.config.ts b/source.config.ts index 4f1164d7d7..258c84050a 100644 --- a/source.config.ts +++ b/source.config.ts @@ -10,6 +10,12 @@ export const docs = defineDocs({ "./frameworks/**/*.md", "./routing/**/*.md", "./security/**/*.md", + // Operator-internal: TLS impersonation, MITM decrypt, supply-chain + // attestation, XOR-mask recipe. Stay in git; do not compile into /docs. + "!./security/STEALTH_GUIDE.md", + "!./security/SOCKET_DEV_FINDINGS.md", + "!./security/MITM-TPROXY-DECRYPT.md", + "!./security/PUBLIC_CREDS.md", "./compression/**/*.md", "./ops/**/*.md", ], diff --git a/tests/unit/docs-public-catalog-sensitive-pages.test.ts b/tests/unit/docs-public-catalog-sensitive-pages.test.ts new file mode 100644 index 0000000000..d2501b1955 --- /dev/null +++ b/tests/unit/docs-public-catalog-sensitive-pages.test.ts @@ -0,0 +1,260 @@ +/** + * Public /docs is a fumadocs tree compiled from source.config.ts globs. + * Operator-internal security writeups (TLS impersonation, MITM decrypt, + * supply-chain attestation, XOR-mask recipe) must stay in git for + * engineers and MUST NOT enter the public catalog. A Caddy blanket + * Basic-auth on /docs is a deploy-time bandage, not the product fix. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "tinyglobby"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../.."); +const CONFIG_PATH = path.join(REPO_ROOT, "source.config.ts"); +const META_PATH = path.join(REPO_ROOT, "docs/security/meta.json"); +const DOCKERIGNORE_PATH = path.join(REPO_ROOT, ".dockerignore"); + +export const SENSITIVE_PUBLIC_DOCS = [ + "docs/security/STEALTH_GUIDE.md", + "docs/security/SOCKET_DEV_FINDINGS.md", + "docs/security/MITM-TPROXY-DECRYPT.md", + "docs/security/PUBLIC_CREDS.md", +] as const; + +const PUBLIC_SECURITY_KEEP = [ + "docs/security/GUARDRAILS.md", + "docs/security/ERROR_SANITIZATION.md", + "docs/security/ROUTE_GUARD_TIERS.md", +] as const; + +function readConfiguredGlobs(): string[] { + const src = fs.readFileSync(CONFIG_PATH, "utf-8"); + const block = src.match(/files\s*:\s*\[([\s\S]*?)\]/); + assert.ok(block, "source.config.ts must declare files: [...]"); + const globs = [...block[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]); + assert.ok(globs.length > 0, "source.config.ts must declare at least one glob"); + return globs; +} + +function catalogRelPaths(): Set { + const globs = readConfiguredGlobs(); + const files = globSync(globs, { cwd: path.join(REPO_ROOT, "docs"), onlyFiles: true }); + return new Set(files.map((f) => `docs/${f.replace(/^\.\//, "")}`)); +} + +function parseDockerignore(text: string) { + const excludes: string[] = []; + const includes: string[] = []; + const rules: Array<{ kind: "exclude" | "include"; pattern: string }> = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("!")) { + const pattern = line.slice(1); + includes.push(pattern); + rules.push({ kind: "include", pattern }); + } else { + excludes.push(line); + rules.push({ kind: "exclude", pattern: line }); + } + } + return { excludes, includes, rules }; +} + +function patternMatches(pattern: string, file: string): boolean { + const pSegs = pattern.split("/"); + const fSegs = file.split("/"); + return matchSegments(pSegs, 0, fSegs, 0); +} + +function matchSegments(p: string[], pi: number, f: string[], fi: number): boolean { + while (pi < p.length) { + const seg = p[pi]; + if (seg === "**") { + if (pi === p.length - 1) return true; + for (let k = fi; k <= f.length; k++) { + if (matchSegments(p, pi + 1, f, k)) return true; + } + return false; + } + if (fi >= f.length) return false; + if (!segmentMatches(seg, f[fi])) return false; + pi++; + fi++; + } + return fi === f.length; +} + +function segmentMatches(pattern: string, segment: string): boolean { + if (pattern === "*") return true; + if (!pattern.includes("*")) return pattern === segment; + const parts = pattern.split("*"); + let cursor = 0; + const first = parts[0]; + if (first && !segment.startsWith(first)) return false; + cursor = first.length; + const last = parts[parts.length - 1]; + if (last && !segment.endsWith(last)) return false; + const endLimit = segment.length - last.length; + for (let i = 1; i < parts.length - 1; i++) { + const idx = segment.indexOf(parts[i], cursor); + if (idx === -1 || idx + parts[i].length > endLimit) return false; + cursor = idx + parts[i].length; + } + return true; +} + +function isIgnored( + file: string, + parsed: { + excludes: string[]; + includes: string[]; + rules?: Array<{ kind: "exclude" | "include"; pattern: string }>; + } +): boolean { + if (parsed.rules && parsed.rules.length > 0) { + let ignored = false; + for (const rule of parsed.rules) { + if (patternMatches(rule.pattern, file) || file === rule.pattern) { + ignored = rule.kind === "exclude"; + } + } + return ignored; + } + let ignored = false; + for (const ex of parsed.excludes) { + if (patternMatches(ex, file) || file === ex) ignored = true; + } + for (const inc of parsed.includes) { + if (patternMatches(inc, file) || file === inc) ignored = false; + } + return ignored; +} + +test("dockerignore last matching rule wins", () => { + const laterExclude = parseDockerignore( + "!docs/security/STEALTH_GUIDE.md\ndocs/security/STEALTH_GUIDE.md\n" + ); + assert.equal( + isIgnored("docs/security/STEALTH_GUIDE.md", laterExclude), + true, + "a later exact exclude must win over an earlier include" + ); + + const laterInclude = parseDockerignore( + "docs/security/STEALTH_GUIDE.md\n!docs/security/STEALTH_GUIDE.md\n" + ); + assert.equal( + isIgnored("docs/security/STEALTH_GUIDE.md", laterInclude), + false, + "a later exact include must win over an earlier exclude" + ); +}); + +test("segmentMatches anchors the last literal of a * glob", () => { + assert.equal(segmentMatches("*.md", "STEALTH_GUIDE.md"), true); + assert.equal( + segmentMatches("*.md", "STEALTH_GUIDE.md.bak"), + false, + "*.md must not match a longer suffix" + ); +}); + +test("sensitive security markdown still exists in git for engineers", () => { + for (const rel of SENSITIVE_PUBLIC_DOCS) { + assert.ok( + fs.existsSync(path.join(REPO_ROOT, rel)), + `${rel} must remain in the repo (catalog exclusion is not a delete)` + ); + } +}); + +test("fumadocs catalog glob does not compile sensitive security pages", () => { + const catalog = catalogRelPaths(); + const leaked = SENSITIVE_PUBLIC_DOCS.filter((rel) => catalog.has(rel)); + assert.deepEqual( + leaked, + [], + `public /docs catalog still compiles operator-internal pages:\n ${leaked.join("\n ")}` + ); + for (const rel of PUBLIC_SECURITY_KEEP) { + assert.ok(catalog.has(rel), `${rel} must stay on the public security index`); + } +}); + +test("security nav meta.json does not list sensitive pages", () => { + const meta = JSON.parse(fs.readFileSync(META_PATH, "utf-8")) as { + pages: string[]; + }; + const forbidden = ["STEALTH_GUIDE", "SOCKET_DEV_FINDINGS", "MITM-TPROXY-DECRYPT", "PUBLIC_CREDS"]; + const listed = forbidden.filter((id) => meta.pages.includes(id)); + assert.deepEqual( + listed, + [], + `docs/security/meta.json still links operator-internal pages: ${listed.join(", ")}` + ); + assert.ok(meta.pages.includes("GUARDRAILS")); + assert.ok(meta.pages.includes("ERROR_SANITIZATION")); +}); + +test("docker image does not ship sensitive security markdown", () => { + const parsed = parseDockerignore(fs.readFileSync(DOCKERIGNORE_PATH, "utf8")); + const shipped = SENSITIVE_PUBLIC_DOCS.filter((rel) => !isIgnored(rel, parsed)); + assert.deepEqual( + shipped, + [], + `sensitive pages still in Docker context (would be readable if a glob regresses):\n ${shipped.join("\n ")}` + ); +}); + +test("compiled public docs must not markdown-link sensitive pages", () => { + const compiledRoots = [ + "docs/architecture", + "docs/guides", + "docs/reference", + "docs/frameworks", + "docs/routing", + "docs/security", + "docs/compression", + "docs/ops", + ]; + const sensitive = new Set(SENSITIVE_PUBLIC_DOCS.map((rel) => path.basename(rel, ".md"))); + const href = /\]\((?:\.\.\/)*security\/([A-Z0-9_-]+)\.md\)|\]\(\.\/([A-Z0-9_-]+)\.md\)/g; + const leaks: string[] = []; + for (const root of compiledRoots) { + const abs = path.join(REPO_ROOT, root); + if (!fs.existsSync(abs)) continue; + const stack = [abs]; + while (stack.length > 0) { + const dir = stack.pop() as string; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!entry.name.endsWith(".md")) continue; + const rel = path.relative(REPO_ROOT, full).replaceAll("\\", "/"); + if (SENSITIVE_PUBLIC_DOCS.includes(rel as (typeof SENSITIVE_PUBLIC_DOCS)[number])) { + continue; + } + const text = fs.readFileSync(full, "utf8"); + for (const match of text.matchAll(href)) { + const id = match[1] ?? match[2]; + if (id && sensitive.has(id)) { + leaks.push(`${rel} -> ${id}`); + } + } + } + } + } + assert.deepEqual( + leaks, + [], + `public /docs pages still href operator-internal docs:\n ${leaks.join("\n ")}` + ); +});