Public: anyone can read this; the crew can change it.
Security is top tier: enforce pod security everywhere, then red-team the forge from inside #10454
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10441-security-is-top-tier-enforce-pod-securit → main 2 commits fork
2 files
changed +127
−0
docs/ops/RED-TEAM.md added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | # Red team: the forge from inside | |
| 2 | ||
| 3 | What issue #10441 asked: can something running inside the forge pod get past | |
| 4 | a door it should not? Each finding names the code that holds the door and the | |
| 5 | test that fails if it reopens. This round reads the code paths. It was not a | |
| 6 | live run in the pod (a crew sandbox can reach neither the pod nor the cluster), | |
| 7 | so the live run is still open as its own issue. | |
| 8 | ||
| 9 | ## Findings, 2026-11-09 | |
| 10 | ||
| 11 | | Door | Holds | Kept by | | |
| 12 | | --- | --- | --- | | |
| 13 | | An app reads the forge's env (`FORGE_EDGE_TOKEN`, keys, the forge's own token) | `appEnv` builds the app's environment from an allowlist (`src/apps/process.ts`); the forge's environment is never copied in | `test/redteam-doors.test.ts` "app to forge" | | |
| 14 | | An app reads the forge's data | `FORGE_APP_DATA` is the app's own directory, and the cell confines it (`src/apps/contain.ts`) | `test/redteam-doors.test.ts`, the cell suites (they run on the forge's check, not in a crew sandbox) | | |
| 15 | | A visitor sends `x-forge-identity` / `x-forge-signature` that the app believes | The proxy deletes both and writes signed ones of its own (`IDENTITY_HEADERS`, `src/apps/proxy.ts`) | `test/apps-identity.test.ts`, `test/redteam-doors.test.ts` | | |
| 16 | | An app learns the edge secret or a bearer token from a forwarded request | `stripForgeCredentials` drops every `x-plat-*` header, `authorization`, and the grant cookie | `test/redteam-doors.test.ts` "forged identity headers" | | |
| 17 | | Someone outside claims a person with `x-plat-user` | `edgeIdentity` believes it only with the edge secret, compared as digests | `test/redteam-doors.test.ts`, `test/edge-auth.test.ts` | | |
| 18 | | A crew run leaves its worktree or its network allowlist | bwrap in `src/crew/sandbox.ts` | `test/crew-sandbox.test.ts`; the escape attempts under `procMount: Unmasked` are still open | | |
| 19 | | Note or issue text steers a crew brief | `untrusted` marking in briefs | still open: needs a wider test than #10365 | | |
| 20 | ||
| 21 | ## Not done here | |
| 22 | ||
| 23 | - **Pod security enforced on all nine namespaces.** The namespace labels live | |
| 24 | in the cluster's GitOps source, which is not in this repository. That work | |
| 25 | happens there. | |
| 26 | - **The live run inside the pod and the sandbox escape attempts**: #10450. | |
| 27 | - **Wider prompt-injection tests**: #10451. | |
| 28 | - **A security page under ops** with one line per control: #10452. |
test/redteam-doors.test.ts added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | /** | |
| 2 | * The red-team findings of #10441 (docs/ops/RED-TEAM.md), each kept as a | |
| 3 | * test that fails the day its door reopens. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 7 | import { appEnv } from "../src/apps/process.ts"; | |
| 8 | import { IDENTITY_HEADERS, stripForgeCredentials } from "../src/apps/proxy.ts"; | |
| 9 | import { edgeIdentity } from "../src/http/edge.ts"; | |
| 10 | ||
| 11 | const FORGE_SECRETS = { | |
| 12 | FORGE_EDGE_TOKEN: "edge-secret-xyz", | |
| 13 | FORGE_SECRET_KEY: "secret-key-xyz", | |
| 14 | FORGE_TOKEN: "forge-own-token-xyz", | |
| 15 | ANTHROPIC_API_KEY: "sk-ant-xyz", | |
| 16 | GITHUB_TOKEN: "ghp-xyz", | |
| 17 | DATABASE_URL: "postgres://forge:pw@db/forge", | |
| 18 | }; | |
| 19 | ||
| 20 | const saved: Record<string, string | undefined> = {}; | |
| 21 | afterEach(() => { | |
| 22 | for (const [name, value] of Object.entries(saved)) { | |
| 23 | if (value === undefined) delete process.env[name]; | |
| 24 | else process.env[name] = value; | |
| 25 | } | |
| 26 | }); | |
| 27 | ||
| 28 | function envWithForgeSecretsSet(): Record<string, string> { | |
| 29 | for (const [name, value] of Object.entries(FORGE_SECRETS)) { | |
| 30 | saved[name] = process.env[name]; | |
| 31 | process.env[name] = value; | |
| 32 | } | |
| 33 | return appEnv({ | |
| 34 | port: 4100, | |
| 35 | dataDir: "/data/apps/notes/production", | |
| 36 | env: "production", | |
| 37 | url: "https://notes.example", | |
| 38 | commitOid: "a".repeat(40), | |
| 39 | forgeUrl: "https://forge.example", | |
| 40 | identityKey: "identity-key", | |
| 41 | }); | |
| 42 | } | |
| 43 | ||
| 44 | describe("app to forge: an app process hears none of the forge's secrets", () => { | |
| 45 | test("no forge secret's value reaches the app's environment", () => { | |
| 46 | const env = envWithForgeSecretsSet(); | |
| 47 | const values = Object.values(env).join("\n"); | |
| 48 | for (const secret of Object.values(FORGE_SECRETS)) expect(values).not.toContain(secret); | |
| 49 | expect(env["FORGE_EDGE_TOKEN"]).toBeUndefined(); | |
| 50 | expect(env["FORGE_TOKEN"]).toBeUndefined(); | |
| 51 | }); | |
| 52 | ||
| 53 | test("the app's environment is an allowlist, not the forge's environment minus some", () => { | |
| 54 | const env = envWithForgeSecretsSet(); | |
| 55 | const allowed = new Set([ | |
| 56 | "PATH", "HOME", "LANG", "TMPDIR", "PORT", "HOST", "NODE_ENV", | |
| 57 | "FORGE_APP_ENV", "FORGE_APP_DATA", "FORGE_APP_URL", "FORGE_APP_COMMIT", | |
| 58 | "FORGE_URL", "FORGE_API_URL", "FORGE_APP_IDENTITY_KEY", | |
| 59 | "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", | |
| 60 | ]); | |
| 61 | for (const name of Object.keys(env)) expect(allowed.has(name) ? name : `unexpected ${name}`).toBe(name); | |
| 62 | }); | |
| 63 | ||
| 64 | test("its data directory is its own, never the forge's", () => { | |
| 65 | const env = envWithForgeSecretsSet(); | |
| 66 | expect(env["FORGE_APP_DATA"]).toBe("/data/apps/notes/production"); | |
| 67 | }); | |
| 68 | }); | |
| 69 | ||
| 70 | describe("forged identity headers: what a visitor writes never reaches an app as truth", () => { | |
| 71 | test("the forge's identity headers are the ones the proxy replaces", () => { | |
| 72 | expect(IDENTITY_HEADERS).toEqual(["x-forge-identity", "x-forge-signature"]); | |
| 73 | }); | |
| 74 | ||
| 75 | test("edge headers, bearer tokens and the grant cookie are stripped before the app", () => { | |
| 76 | const headers = new Headers({ | |
| 77 | "x-plat-user": "admin", | |
| 78 | "x-plat-edge-token": "edge-secret-xyz", | |
| 79 | "X-Plat-Anything": "1", | |
| 80 | authorization: "Bearer forge-own-token-xyz", | |
| 81 | cookie: "forge_grant_notes=abc; theme=dark", | |
| 82 | }); | |
| 83 | stripForgeCredentials(headers, "forge_grant_notes"); | |
| 84 | expect([...headers.keys()].filter((n) => n.startsWith("x-plat-"))).toEqual([]); | |
| 85 | expect(headers.get("authorization")).toBeNull(); | |
| 86 | expect(headers.get("cookie")).toBe("theme=dark"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("an edge claim without the edge's secret is nobody", () => { | |
| 90 | const forged = new Request("https://forge.example/", { headers: { "x-plat-user": "trevato" } }); | |
| 91 | expect(edgeIdentity(forged, "edge-secret-xyz")).toBeUndefined(); | |
| 92 | const wrong = new Request("https://forge.example/", { | |
| 93 | headers: { "x-plat-user": "trevato", "x-plat-edge-token": "guess" }, | |
| 94 | }); | |
| 95 | expect(edgeIdentity(wrong, "edge-secret-xyz")).toBeUndefined(); | |
| 96 | expect(edgeIdentity(wrong, "")).toBeUndefined(); | |
| 97 | expect(edgeIdentity(wrong, undefined)).toBeUndefined(); | |
| 98 | }); | |
| 99 | }); |