Public: anyone can read this; the crew can change it.

Ops security page: one line per control and when it was last checked #10595

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10452-ops-security-page-one-line-per-control-a → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/10452-ops-security-page-one-line-per-control-a was merged into main as 181ac20.

Review

Sign in to review this change.
11 files changed +314 −2
docs/design/audit.md +1
@@ -144,6 +144,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
144144 - `/settings/teams` — `pages/teams.ts` · the named crew teams, each with a charter, a dial and members · `/settings/crew` · none seen.
145145 - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen.
146146 - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen.
147 - `/settings/security/controls` — `pages/security_controls.ts` · one line per RED-TEAM control and when it was last checked · `/settings/security` · none seen.
147148 - `/settings/traffic` — `pages/traffic.ts` · who used the answering time in the last 15 minutes · `/settings/usage`, `/settings/telemetry` · none seen.
148149 - `/settings/traffic/sources` — `pages/sources.ts` · where people come from and where they go: sources, campaigns, landing pages, exits, a path to a goal, GitHub traffic; each number links to its probe · `/settings/traffic`, `/settings/traffic/scrapes` · none seen.
149150 - `/settings/traffic/scrapes` — `pages/scrapes.ts` · what was read by repository and path, which networks and addresses did it, a bounded capture, the abuse export · `/settings/traffic`, `/settings/security` · none seen.
docs/features/security-controls.md added +30
@@ -0,0 +1,30 @@
1 # Security controls
2
3 Each safeguard of `docs/ops/RED-TEAM.md`, and when it was last tested. A site
4 admin sees it at `/settings/security/controls` (linked from Security) and in
5 the terminal as the palette command "Security controls". Anyone else gets a 404
6 on the page and is offered nothing in the terminal. Read-only.
7
8 ## Where the times come from
9
10 `<FORGE_DATA>/security-report.json`, the file the Security page reads, written
11 whole by the timer outside the forge. Per control, first match wins:
12
13 1. An entry in the optional `controls` list: `{ "id": "app-env", "at": "<ISO date>", "ok": true }`.
14 Ids: `app-env`, `identity-headers`, `edge-secret`, `crew-sandbox`,
15 `injection-marking`, `pod-security`. Unknown ids, duplicates, a bad date or a
16 non-boolean `ok` are ignored.
17 2. For the five controls a test file keeps: the report's own `at`, passing when
18 `selfTest.failed` is 0. The timer's self-test runs those tests, so existing
19 reports already give a time.
20 3. Otherwise "No check recorded". Pod security is kept in the cluster's source,
21 so only a `controls` entry written by whatever checks the cluster gives it a time.
22
23 A line says "Failed" when `ok` is false and "Out of date" when the check is
24 more than 8 days old; both use one badge. Hover a name for what it holds and
25 what keeps it.
26
27 ## Code
28
29 `src/checks/controls.ts` (the list and the reading), `src/web/pages/security_controls.ts`,
30 `src/tui/screens/controls.ts`. Tests: `test/security-controls.test.ts`.
docs/ops/RED-TEAM.md +1 −1
@@ -25,4 +25,4 @@ so the live run is still open as its own issue.
2525 happens there.
2626 - **The live run inside the pod and the sandbox escape attempts**: #10450.
2727 - **Wider prompt-injection tests**: #10451.
28 - **A security page under ops** with one line per control: #10452.
28 - Done: the security controls page, `/settings/security/controls` (#10452); how it reads `security-report.json` is in `docs/features/security-controls.md`.
src/checks/controls.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * Each safeguard of docs/ops/RED-TEAM.md and when it was last tested: one
3 * `Standing` per control, read from `<dataDir>/security-report.json` (the file
4 * the Security page reads). A control the report names in its optional
5 * `controls` list (`[{ id, at, ok }]`) uses that; a control a test file keeps
6 * otherwise takes the report's own `at` and `selfTest.failed === 0`, since the
7 * timer's self-test runs those tests. Pod security is kept in the cluster's
8 * source, no test here can see it: it has a time only when `controls` says so.
9 */
10
11 import { existsSync, readFileSync, statSync } from "node:fs";
12 import { join } from "node:path";
13 import { readSecurityReport } from "../web/pages/security.ts";
14
15 const DAY_MS = 86_400_000;
16 export const STALE_DAYS = 8;
17 const REPORT_MAX_BYTES = 64 * 1024;
18
19 export interface Control {
20 readonly id: string;
21 readonly name: string;
22 /** What the door does, in a sentence. */
23 readonly holds: string;
24 /** The test that fails if it reopens, or where it is kept when no test here can. */
25 readonly keptBy: string;
26 /** True when the timer's self-test covers it. */
27 readonly tested: boolean;
28 }
29
30 /** The controls of docs/ops/RED-TEAM.md, in its order. */
31 export const CONTROLS: readonly Control[] = [
32 { id: "app-env", name: "App environment allowlist", holds: "An app starts from a short allowlist, never the forge's environment.", keptBy: "test/redteam-doors.test.ts", tested: true },
33 { id: "identity-headers", name: "Identity header strip", holds: "The proxy deletes visitor-sent identity headers and writes signed ones.", keptBy: "test/apps-identity.test.ts", tested: true },
34 { id: "edge-secret", name: "Edge secret", holds: "Only a request carrying the edge secret can claim a person; apps never see it.", keptBy: "test/edge-auth.test.ts", tested: true },
35 { id: "crew-sandbox", name: "Crew sandbox", holds: "A crew run stays in its worktree and its network allowlist.", keptBy: "test/crew-sandbox.test.ts", tested: true },
36 { id: "injection-marking", name: "Injection marking", holds: "Text from people who cannot write here is marked untrusted in briefs.", keptBy: "test/redteam-doors.test.ts", tested: true },
37 { id: "pod-security", name: "Pod security", holds: "Pod security labels on the cluster's namespaces; kept in the cluster's own source.", keptBy: "the cluster's GitOps source", tested: false },
38 ];
39
40 export interface ControlCheck {
41 readonly at: string;
42 readonly ok: boolean;
43 }
44
45 export interface Standing {
46 readonly control: Control;
47 readonly check: ControlCheck | null;
48 /** Failed, or older than STALE_DAYS: the one thing worth a badge. */
49 readonly attention: boolean;
50 }
51
52 /** `controls` of `<dataDir>/security-report.json`, by id; anything malformed is left out. */
53 export function readControlChecks(dataDir: string): ReadonlyMap<string, ControlCheck> {
54 const out = new Map<string, ControlCheck>();
55 const path = join(dataDir, "security-report.json");
56 try {
57 if (!existsSync(path) || statSync(path).size > REPORT_MAX_BYTES) return out;
58 const r = JSON.parse(readFileSync(path, "utf8")) as { controls?: unknown } | null;
59 if (!Array.isArray(r?.controls)) return out;
60 const known = new Set(CONTROLS.map((c) => c.id));
61 for (const c of r.controls as unknown[]) {
62 const e = c as { id?: unknown; at?: unknown; ok?: unknown } | null;
63 if (typeof e?.id !== "string" || !known.has(e.id) || out.has(e.id)) continue;
64 if (typeof e.at !== "string" || Number.isNaN(Date.parse(e.at)) || typeof e.ok !== "boolean") continue;
65 out.set(e.id, { at: e.at, ok: e.ok });
66 }
67 } catch {
68 // Unreadable evidence is no evidence.
69 }
70 return out;
71 }
72
73 export function controlStandings(dataDir: string, nowMs: number): Standing[] {
74 const named = readControlChecks(dataDir);
75 const report = readSecurityReport(dataDir);
76 return CONTROLS.map((control) => {
77 const check: ControlCheck | null =
78 named.get(control.id) ?? (control.tested && report ? { at: report.at, ok: report.selfTest.failed === 0 } : null);
79 const attention = check !== null && (!check.ok || nowMs - Date.parse(check.at) > STALE_DAYS * DAY_MS);
80 return { control, check, attention };
81 });
82 }
src/http/settings_routes.ts +2
@@ -19,6 +19,7 @@ import { economicsPage, economicsSet } from "../web/pages/economics.ts";
1919 import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts";
2020 import { usagePage } from "../web/pages/usage.ts";
2121 import { spendPage } from "../web/pages/spend-tab.ts";
22 import { securityControlsPage } from "../web/pages/security_controls.ts";
2223 import { securityPage, securityShieldSet } from "../web/pages/security.ts";
2324 import { trafficPage } from "../web/pages/traffic.ts";
2425 import { sourcesPage } from "../web/pages/sources.ts";
@@ -203,6 +204,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
203204 router.add("POST", "/settings/traffic/scrapes/stop", captureStop);
204205 router.add("POST", "/settings/traffic/scrapes/networks", networksImport);
205206 router.add("GET", "/settings/security", securityPage);
207 router.add("GET", "/settings/security/controls", securityControlsPage);
206208 router.add("POST", "/settings/security/shield", securityShieldSet);
207209 router.add("GET", "/settings/backups", backupsPage);
208210 router.add("GET", "/settings/backups/live", backupsLive);
src/tui/screens/controls.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * Security controls in the terminal, a forge admin's: the same lines as
3 * /settings/security/controls (`checks/controls.ts`), each safeguard and when
4 * it was last tested. Read-only.
5 */
6
7 import { controlStandings } from "../../checks/controls.ts";
8 import type { Ctx, Screen } from "../app/app.ts";
9 import { binding } from "../app/keys.ts";
10 import type { Session } from "../session.ts";
11 import { ListState, drawList, type ListRow } from "./common.ts";
12 import type { PaletteEntry } from "./entries.ts";
13
14 const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
15
16 export function controlRows(dataDir: string, nowMs: number): ListRow[] {
17 return controlStandings(dataDir, nowMs).map(({ control, check, attention }) => ({
18 title: control.name,
19 secondary: "",
20 meta:
21 check === null
22 ? "No check recorded"
23 : `${attention ? (check.ok ? "Out of date, " : "Failed, ") : ""}checked ${check.at.slice(0, 10)}`,
24 }));
25 }
26
27 export function controlsScreen(f: Session, ctx: Ctx): Screen {
28 const state = new ListState();
29 const rows = controlRows(f.app.config.dataDir, Date.now());
30 return {
31 title: "Security controls",
32 keys: [binding(["j", "k"], "move cursor")],
33 render(area, s) {
34 drawList(s, area, ctx.theme.c, state, rows);
35 },
36 key(e) {
37 return state.key(e, rows.length);
38 },
39 };
40 }
41
42 /** Each safeguard, and when it was last tested. */
43 export const entry: PaletteEntry = {
44 id: "security-controls",
45 title: "Security controls",
46 hint: "each safeguard, and when it was last tested",
47 offered: isAdmin,
48 run: (session, ctx) => ctx.push(controlsScreen(session, ctx)),
49 };
src/tui/screens/entries.ts +2
@@ -56,6 +56,7 @@ import { entry as resilience } from "./resilience.ts";
5656 import { entry as scorecard } from "./scorecard.ts";
5757 import { entry as scrapes } from "./scrapes.ts";
5858 import { entry as trafficScreenEntry } from "./traffic.ts";
59 import { entry as securityControls } from "./controls.ts";
5960 import { entry as secrets } from "./secrets.ts";
6061 import { entry as sentinel } from "./sentinel.ts";
6162 import { entry as subscriptions } from "./subscriptions.ts";
@@ -97,6 +98,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
9798 edgeOnly,
9899 traffic,
99100 audit,
101 securityControls,
100102 secrets,
101103 crewSplits,
102104 crewHandover,
src/web/pages/security.ts +1 −1
@@ -258,7 +258,7 @@ export function securityPage(ctx: Ctx): Response {
258258 const none = (n: number, what: string) => (n === 0 ? html`<span class="muted">None</span>` : html`${n} ${what}`);
259259 const deleted = deletedRows(db, since);
260260 const body = html`<p class="small muted mb">
261 Checked ${time(nowIso)}. Something looks wrong? <a href="${RUNBOOK}" title="docs/SECURITY-RUNBOOK.md">The runbook</a> says what to do.
261 Checked ${time(nowIso)}. <a href="/settings/security/controls">Each safeguard, and when it was last tested.</a> Something looks wrong? <a href="${RUNBOOK}" title="docs/SECURITY-RUNBOOK.md">The runbook</a> says what to do.
262262 </p>
263263 ${shieldCard(ctx)}
outdated

Principle 1: this link repeats the page's own introduction above it. Fold it into the existing sentence or a status row instead of a second line.

264264 <dl class="status-rows">
src/web/pages/security_controls.ts added +42
@@ -0,0 +1,42 @@
1 /** GET /settings/security/controls: each safeguard, and when it was last tested (`checks/controls.ts`). */
2
3 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
4 import { controlStandings, type Standing } from "../../checks/controls.ts";
5 import type { Ctx } from "../../http/context.ts";
6 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
7 import { html, type Html } from "../html.ts";
8 import { page } from "../layout.ts";
9 import { badge, time } from "../ui.ts";
10 import { accountSections } from "./origins.ts";
11
12 function line({ control: c, check, attention }: Standing): Html {
13 const state =
14 check === null
15 ? html`<span class="muted">No check recorded</span>`
16 : html`${attention ? html`${badge(check.ok ? "Out of date" : "Failed", { tone: "attn" })} ` : null}<span class="muted">Checked ${time(check.at)}</span>`;
17 return html`<div class="status-row" id="${c.id}">
18 <dt title="${c.holds} Kept by ${c.keptBy}.">${c.name}</dt>
19 <dd>${state}</dd>
20 </div>`;
21 }
22
23 export function securityControlsPage(ctx: Ctx): Response {
24 if (!ctx.auth.user) return signInFirst(ctx);
25 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
26 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
27 const rendering = performance.now();
28 const standings = controlStandings(ctx.app.config.dataDir, Date.now());
29 const body = html`<p class="small muted mb">
30 Each safeguard, and when it was last tested. <a href="/settings/security">Back to Security</a>.
31 </p>
32 <dl class="status-rows">
33 ${standings.map(line)}
34 </dl>`;
35 notePhase(ctx.request, "render", performance.now() - rendering);
36 return page({
37 ctx,
38 title: "Security controls",
39 body: html`<div class="page-head"><h1>Security controls</h1></div>
40 ${settingsLayout(accountSections("security", { admin: true }), body, "Account settings")}`,
41 });
42 }
test/palette-roster.test.ts +1
@@ -139,6 +139,7 @@ test("a site-admin person is offered the whole roster", () => {
139139 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
140140 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
141141 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
142 ["security-controls", "Security controls", "each safeguard, and when it was last tested"],
142143 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
143144 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
144145 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
test/security-controls.test.ts added +103
@@ -0,0 +1,103 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdtemp, rm, writeFile } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createSession } from "../src/core/sessions.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { controlRows } from "../src/tui/screens/controls.ts";
10 import { CONTROLS, readControlChecks } from "../src/checks/controls.ts";
11
12 let dir = "";
13 let app: App;
14 let servers: Servers;
15 let admin = "";
16 let other = "";
17 const get = (cookie?: string) =>
18 fetch(`${servers.url}/settings/security/controls`, { redirect: "manual", headers: cookie ? { cookie } : {} });
19
20 beforeAll(async () => {
21 dir = await mkdtemp(join(tmpdir(), "forge-controls-"));
22 app = createApp({ dataDir: dir, port: 0 });
23 servers = await serve(app);
24 const seed = { origin: "seed", trusted: true, password: "a-good-passphrase" } as const;
25 const a = await createUser(app.db, { ...seed, username: "alice", email: "[email protected]", isAdmin: true });
26 const b = await createUser(app.db, { ...seed, username: "bob", email: "[email protected]" });
27 if (!a.ok || !b.ok) throw new Error("no users");
28 admin = `forge_session=${createSession(app.db, a.value.id).token}`;
29 other = `forge_session=${createSession(app.db, b.value.id).token}`;
30 });
31 afterAll(async () => {
32 servers.stop();
33 await rm(dir, { recursive: true, force: true });
34 });
35
36 test("an admin sees every control; with no report each says no check recorded", async () => {
37 const res = await get(admin);
38 expect(res.status).toBe(200);
39 const text = await res.text();
40 for (const c of CONTROLS) expect(text).toContain(c.name);
41 expect(text.match(/No check recorded/g)?.length).toBe(CONTROLS.length);
42 });
43
44 test("the page is an admin's", async () => {
45 expect((await get()).status).toBe(303);
46 expect((await get(other)).status).toBe(404);
47 });
48
49 test("a recorded check shows its time; a failed one says failed; junk is ignored", async () => {
50 const at = new Date().toISOString();
51 await writeFile(
52 join(dir, "security-report.json"),
53 JSON.stringify({
54 controls: [
55 { id: "app-env", at, ok: true },
56 { id: "edge-secret", at, ok: false },
57 { id: "nope", at, ok: true },
58 { id: "crew-sandbox", at: "garbage", ok: true },
59 ],
60 }),
61 );
62 const m = readControlChecks(dir);
63 expect([...m.keys()].sort()).toEqual(["app-env", "edge-secret"]);
64 const text = await (await get(admin)).text();
65 expect(text).toContain("Failed");
66 expect(text.match(/No check recorded/g)?.length).toBe(CONTROLS.length - 2);
outdated

Add a test with a check older than 8 days that expects Out of date, and one that expects the link on /settings/security.

67 });
68
69 test("a check older than 8 days says out of date; one inside 8 days does not", async () => {
70 const old = new Date(Date.now() - 9 * 86_400_000).toISOString();
71 const fresh = new Date(Date.now() - 7 * 86_400_000).toISOString();
72 await writeFile(join(dir, "security-report.json"), JSON.stringify({ controls: [{ id: "app-env", at: old, ok: true }] }));
73 expect(await (await get(admin)).text()).toContain("Out of date");
74 await writeFile(join(dir, "security-report.json"), JSON.stringify({ controls: [{ id: "app-env", at: fresh, ok: true }] }));
75 expect(await (await get(admin)).text()).not.toContain("Out of date");
76 });
77
78 test("without a controls list the report's own time covers the tested controls, not pod security", async () => {
79 const at = new Date().toISOString();
80 await writeFile(
81 join(dir, "security-report.json"),
82 JSON.stringify({ at, secretScan: { findings: 0 }, selfTest: { passed: 3, failed: 0 } }),
83 );
84 const text = await (await get(admin)).text();
85 expect(text.match(/No check recorded/g)?.length).toBe(1);
86 expect(text).not.toContain("Failed");
87 await writeFile(
88 join(dir, "security-report.json"),
89 JSON.stringify({ at, secretScan: { findings: 0 }, selfTest: { passed: 2, failed: 1 } }),
90 );
91 expect(await (await get(admin)).text()).toContain("Failed");
92 });
93
94 test("the security page links to the controls", async () => {
95 const res = await fetch(`${servers.url}/settings/security`, { headers: { cookie: admin } });
96 expect(await res.text()).toContain('href="/settings/security/controls"');
97 });
98
99 test("the terminal rows match the page", () => {
100 const rows = controlRows(dir, Date.now());
101 expect(rows.map((r) => r.title)).toEqual(CONTROLS.map((c) => c.name));
102 expect(rows.find((r) => r.title === "Pod security")?.meta).toBe("No check recorded");
103 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs