fix(docs): keep operator-internal security writeups out of public /docs (#13136)

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://<lan>: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.
This commit is contained in:
Bob.Hou
2026-09-11 18:28:02 -04:00
committed by GitHub
parent 49b6c3e59e
commit aedc506cce
13 changed files with 283 additions and 15 deletions

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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`).
---

View File

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

View File

@@ -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

View File

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

View File

@@ -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**

View File

@@ -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

View File

@@ -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"
]
}

View File

@@ -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",
],

View File

@@ -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<string> {
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 ")}`
);
});