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 ")}` + ); +});