Public: anyone can read this; the crew can change it.
An agent watching security reads the forge's CSP violations by directive, as the security page counts them #10914
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10906-an-agent-watching-security-reads-the-for → main 4 commits fork 1 thread · 1 unresolved
10 files
changed +179
−28
docs/MCP.md +10 −1
| @@ -208,7 +208,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 208 | 208 | | `subscription_wake` | — | Admin: wake a resting subscription | 168 | |
| 209 | 209 | | `subscriptions` | — | Claude accounts: scopes, budgets | 158 | |
| 210 | 210 | | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 | |
| 211 | | `system` | — | The forge machine now: CPU, memory, disk, network | 193 | | |
| 211 | | `system` | `csp` | The forge machine now: CPU, memory, disk, network | 277 | | |
| 212 | 212 | | `take_back` | — | Open the pull that undoes a shipped pull | 219 | |
| 213 | 213 | | `take_updates` | — | Take the parent forge's updates into a fork | 176 | |
| 214 | 214 | | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 | |
| @@ -274,6 +274,15 @@ each bug `{ repo, number, priority, dueAt }`, soonest due first, at most 100, | ||
| 274 | 274 | from `src/flow/bugs.ts`. An admin sees every repository; anyone else only |
| 275 | 275 | public ones, as the terminal's bugs screen does. |
| 276 | 276 | |
| 277 | `system` with `{ kind: "csp" }` is another (#10906): the last 24 hours of | |
| 278 | Content-Security-Policy violations browsers reported to `/csp-report`, | |
| 279 | `{ hours: 24, since, directives: [{ directive, n, newest }] }`, most first, | |
| 280 | counted per UTC hour so `n` holds only reports from `since` (the first whole | |
| 281 | hour inside the 24) on, `newest` | |
| 282 | the blocked host counted last, read by `violationsByDirective` in | |
| 283 | `src/http/csp_report.ts` as the security page reads its week. A forge admin | |
| 284 | only; anyone else is `forbidden`. | |
| 285 | ||
| 277 | 286 | `flow` with `{ q, action }` is the fleet's write door (#450): the plan |
| 278 | 287 | `POST /api/v1/fleet/ops` answers for a dry run, `{ dryRun: true, query, kind, |
| 279 | 288 | verb, value, reversible, rows, counts }`, with nothing changed and only a read |
docs/features/csp-reports.md +1 −1
| @@ -2,6 +2,6 @@ | ||
| 2 | 2 | |
| 3 | 3 | Every page the forge renders carries `report-uri /csp-report` and `report-to csp` in its policy, with a `Reporting-Endpoints: csp="/csp-report"` header. The bare floor policy that `securityFloor` puts on non-document answers does not name it: the floor also covers an app's proxied responses, and a relative address there would resolve to the app's host, where no report could be counted. When a browser blocks something it posts a report there, so a broken or hostile page is seen, not guessed. |
| 4 | 4 | |
| 5 | `POST /csp-report` (`src/http/csp_report.ts`) takes `application/csp-report`, `application/reports+json` or `application/json`, at most 8 KB, in either the `report-uri` or the Reporting API shape. Anything else gets a 4xx and nothing is stored. It keeps only the directive, the blocked host (no path, no query string) and the page's path, as one count per UTC day in `csp_violation`. That is at most 500 rows a day (the rest fold into one row) and 30 days kept. Anonymous callers are limited to 30 a minute each and 600 a minute per forge. It answers 204. | |
| 5 | `POST /csp-report` (`src/http/csp_report.ts`) takes `application/csp-report`, `application/reports+json` or `application/json`, at most 8 KB, in either the `report-uri` or the Reporting API shape. Anything else gets a 4xx and nothing is stored. It keeps only the directive, the blocked host (no path, no query string) and the page's path, as one count per UTC hour in `csp_violation` (its `day` column holds `YYYY-MM-DDTHH`), so a window's count holds only reports from the window's whole hours, never a report older than it. That is at most 500 rows a UTC day (the rest fold into one row) and 30 days kept. Anonymous callers are limited to 30 a minute each and 600 a minute per forge. It answers 204. | |
| 6 | 6 | |
| 7 | 7 | `/settings/security` shows one line, "Blocked by the page policy, 7 days", listing counts by directive. When nothing was blocked, the line is not shown. The terminal's Security controls screen (admin only) lists the same weekly counts, one row per directive below the controls, and none when nothing was blocked. |
src/core/migration-pins/20261110160000-csp-violation-seen.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | d768f5b2170a5ef1a1638a3693511744564006bcb0e8bd5040ef64840c7ab719 |
src/core/migrations/20261110160000-csp-violation-seen.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // When a CSP violation row was last counted (#10906), in epoch ms, so an | |
| 2 | // agent reads the newest blocked host per directive and a window of hours, | |
| 3 | // not only of UTC days. Rows already kept are dated the end of their day. Additive. | |
| 4 | export default ` | |
| 5 | ALTER TABLE csp_violation ADD COLUMN seen INTEGER NOT NULL DEFAULT 0; | |
| 6 | UPDATE csp_violation SET seen = (unixepoch(day) + 86399) * 1000; | |
| 7 | `; |
src/http/csp_report.ts +46 −17
| @@ -8,7 +8,8 @@ | ||
| 8 | 8 | * At most 8 KB, a known content type, a known shape; anything else is a 4xx |
| 9 | 9 | * and stores nothing. Only three things are kept: the directive, the blocked |
| 10 | 10 | * host (never a path or query string) and the page's path, as one count per |
| 11 | * UTC day. Rows a day are bounded (`DAY_ROWS`, the rest fold into one), and | |
| 11 | * UTC hour (`day` holds `YYYY-MM-DDTHH`), so a window's sum holds only its own | |
| 12 | * hours. Rows a day are bounded (`DAY_ROWS`, the rest fold into one), and | |
| 12 | 13 | * days past `KEEP_DAYS` are dropped as new ones arrive. Anonymous callers are |
| 13 | 14 | * limited per client and per forge like the page-view beacon. Always 204. |
| 14 | 15 | */ |
| @@ -121,33 +122,61 @@ export function parseCspReport(body: string): Violation[] | "malformed" { | ||
| 121 | 122 | return got === undefined ? "malformed" : [got]; |
| 122 | 123 | } |
| 123 | 124 | |
| 124 | /** Count one violation on its UTC day; past `DAY_ROWS` distinct rows a day, the rest share one. */ | |
| 125 | /** The UTC hour bucket a time falls in: `2026-11-10T14`. */ | |
| 126 | const hourOf = (ms: number): string => new Date(ms).toISOString().slice(0, 13); | |
| 127 | ||
| 128 | /** Count one violation in its UTC hour; past `DAY_ROWS` distinct rows a UTC day, the rest share one. */ | |
| 125 | 129 | export function recordViolation(db: Database, v: Violation, now: Date = new Date()): void { |
| 126 | const day = now.toISOString().slice(0, 10); | |
| 127 | const bump = db.query<unknown, [string, string, string, string]>( | |
| 128 | "UPDATE csp_violation SET n = n + 1 WHERE day = ?1 AND directive = ?2 AND blocked = ?3 AND path = ?4", | |
| 130 | const seen = now.getTime(); | |
| 131 | const hour = hourOf(seen); | |
| 132 | const day = hour.slice(0, 10); | |
| 133 | const bump = db.query<unknown, [string, string, string, string, number]>( | |
| 134 | "UPDATE csp_violation SET n = n + 1, seen = ?5 WHERE day = ?1 AND directive = ?2 AND blocked = ?3 AND path = ?4", | |
| 129 | 135 | ); |
| 130 | if (bump.run(day, v.directive, v.blocked, v.path).changes > 0) return; | |
| 131 | const rows = db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM csp_violation WHERE day = ?1").get(day)?.n ?? 0; | |
| 136 | if (bump.run(hour, v.directive, v.blocked, v.path, seen).changes > 0) return; | |
| 137 | const rows = | |
| 138 | db.query<{ n: number }, [string, string]>("SELECT COUNT(*) AS n FROM csp_violation WHERE day >= ?1 AND day <= ?2").get(`${day}T00`, `${day}T23`)?.n ?? 0; | |
| 132 | 139 | const key = rows < DAY_ROWS ? v : { directive: v.directive, blocked: OVERFLOW, path: "" }; |
| 133 | 140 | db.query( |
| 134 | `INSERT INTO csp_violation (day, directive, blocked, path, n) VALUES (?1, ?2, ?3, ?4, 1) | |
| 135 | ON CONFLICT (day, directive, blocked, path) DO UPDATE SET n = n + 1`, | |
| 136 | ).run(day, key.directive, key.blocked, key.path); | |
| 141 | `INSERT INTO csp_violation (day, directive, blocked, path, n, seen) VALUES (?1, ?2, ?3, ?4, 1, ?5) | |
| 142 | ON CONFLICT (day, directive, blocked, path) DO UPDATE SET n = n + 1, seen = ?5`, | |
| 143 | ).run(hour, key.directive, key.blocked, key.path, seen); | |
| 137 | 144 | if (rows === 0) { |
| 138 | const cutoff = new Date(now.getTime() - KEEP_DAYS * 86_400_000).toISOString().slice(0, 10); | |
| 145 | const cutoff = new Date(seen - KEEP_DAYS * 86_400_000).toISOString().slice(0, 10); | |
| 139 | 146 | db.query("DELETE FROM csp_violation WHERE day < ?1").run(cutoff); |
| 140 | 147 | } |
| 141 | 148 | } |
| 142 | 149 | |
| 143 | /** Violations over the last `days` UTC days (today included), per directive, most first. */ | |
| 144 | export function violationsByDirective(db: Database, days = 7, now: Date = new Date()): { directive: string; n: number }[] { | |
| 145 | const since = new Date(now.getTime() - (days - 1) * 86_400_000).toISOString().slice(0, 10); | |
| 150 | export interface DirectiveCount { | |
| 151 | readonly directive: string; | |
| 152 | readonly n: number; | |
| 153 | /** The blocked host (or keyword) counted most recently under this directive. */ | |
| 154 | readonly newest: string; | |
| 155 | } | |
| 156 | ||
| 157 | /** The start of the first whole UTC hour inside the last `hours`: what a window's counts reach back to. */ | |
| 158 | export function windowStart(hours: number, now: Date = new Date()): Date { | |
| 159 | return new Date(Math.ceil((now.getTime() - hours * 3_600_000) / 3_600_000) * 3_600_000); | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Violations counted in the last `hours`, per directive, most first, with the | |
| 164 | * newest blocked host. Only hour buckets that start inside the window count | |
| 165 | * (from `windowStart`), so `n` never includes a report older than `hours`; it | |
| 166 | * may leave out up to the window's first partial hour. Rows from before hourly | |
| 167 | * buckets (`day` a bare date) are not read. The security page reads a week, the | |
| 168 | * terminal too, and MCP `system` `{ kind: "csp" }` a day. | |
| 169 | */ | |
| 170 | export function violationsByDirective(db: Database, hours = 7 * 24, now: Date = new Date()): DirectiveCount[] { | |
| 171 | const from = hourOf(windowStart(hours, now).getTime()); | |
| 172 | // SQLite takes a bare column from the row MAX() chose: `blocked` is the newest's. | |
| 146 | 173 | return db |
| 147 | .query<{ directive: string; n: number }, [string]>( | |
| 148 | "SELECT directive, SUM(n) AS n FROM csp_violation WHERE day >= ?1 GROUP BY directive ORDER BY n DESC, directive", | |
| 174 | .query<{ directive: string; n: number; newest: string }, [string]>( | |
| 175 | `SELECT directive, SUM(n) AS n, blocked AS newest, MAX(seen) AS last FROM csp_violation | |
| 176 | WHERE day >= ?1 AND length(day) = 13 GROUP BY directive ORDER BY n DESC, directive`, | |
| 149 | 177 | ) |
| 150 | .all(since); | |
| 178 | .all(from) | |
| 179 | .map((r) => ({ directive: r.directive, n: r.n, newest: r.newest })); | |
| 151 | 180 | } |
| 152 | 181 | |
| 153 | 182 | export async function cspReportSubmit(ctx: Ctx): Promise<Response> { |
src/http/mcp/tools/system.ts +11 −4
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { noteRead, telemetryReader } from "../../../actions/telemetry.ts"; |
| 2 | 2 | import { systemWithReaders } from "../../../system/telemetry.ts"; |
| 3 | import { violationsByDirective, windowStart } from "../../csp_report.ts"; | |
| 3 | 4 | import { countRead } from "../../../system/telemetry_reads.ts"; |
| 4 | 5 | import { stallHour, TOP_ROUTES } from "../../../metrics/stalls.ts"; |
| 5 | 6 | import { loopWatch } from "../../../perf/live.ts"; |
| @@ -7,12 +8,18 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 7 | 8 | |
| 8 | 9 | export const tool: Tool = { |
| 9 | 10 | name: "system", |
| 10 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`", | |
| 11 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, as the security page reads them; a forge admin only", | |
| 11 | 12 | description: |
| 12 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read.", | |
| 13 | inputSchema: { type: "object", properties: {} }, | |
| 13 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).", | |
| 14 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp"] } } }, | |
| 14 | 15 | writes: false, |
| 15 | run(ctx, user) { | |
| 16 | run(ctx, user, args) { | |
| 17 | if (args["kind"] !== undefined) { | |
| 18 | if (args["kind"] !== "csp") return bad("invalid", "kind is csp, or absent for the machine"); | |
| 19 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations"); | |
| 20 | const now = new Date(); | |
| 21 | return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now) }); | |
| 22 | } | |
| 16 | 23 | const reader = telemetryReader(ctx.app, user); |
| 17 | 24 | if (!reader) |
| 18 | 25 | return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its machine"); |
src/tui/screens/controls.ts +1 −1
| @@ -30,7 +30,7 @@ export function controlRows(dataDir: string, nowMs: number): ListRow[] { | ||
| 30 | 30 | |
| 31 | 31 | /** One row per directive the Content-Security-Policy blocked in the last 7 days; none when nothing was. */ |
| 32 | 32 | export function cspRows(db: Database, nowMs: number): ListRow[] { |
| 33 | return violationsByDirective(db, 7, new Date(nowMs)).map((w) => ({ | |
| 33 | return violationsByDirective(db, 7 * 24, new Date(nowMs)).map((w) => ({ | |
| 34 | 34 | title: `Blocked by the page policy: ${w.directive}`, |
| 35 | 35 | secondary: "", |
| 36 | 36 | meta: `${w.n} in 7 days`, |
src/web/pages/security.ts +1 −1
| @@ -240,7 +240,7 @@ function reportRow(dataDir: string, nowMs: number): Html { | ||
| 240 | 240 | |
| 241 | 241 | /** What browsers said the Content-Security-Policy blocked this week, by directive; nothing when nothing was. */ |
| 242 | 242 | function cspRow(db: Database, nowMs: number): Html | null { |
| 243 | const week = violationsByDirective(db, 7, new Date(nowMs)); | |
| 243 | const week = violationsByDirective(db, 7 * 24, new Date(nowMs)); | |
| 244 | 244 | if (week.length === 0) return null; |
| 245 | 245 | return statusRow( |
| 246 | 246 | "Blocked by the page policy, 7 days", |
test/csp-report.test.ts +3 −3
| @@ -47,7 +47,7 @@ describe("POST /csp-report", () => { | ||
| 47 | 47 | expect((await send(body)).status).toBe(204); |
| 48 | 48 | expect((await send(body)).status).toBe(204); |
| 49 | 49 | expect(rows()).toEqual([ |
| 50 | { day: new Date().toISOString().slice(0, 10), directive: "script-src-elem", blocked: "evil.example:8443", path: "/alice/secret/issues", n: 2 }, | |
| 50 | { day: new Date().toISOString().slice(0, 13), directive: "script-src-elem", blocked: "evil.example:8443", path: "/alice/secret/issues", n: 2 }, | |
| 51 | 51 | ]); |
| 52 | 52 | }); |
| 53 | 53 | |
| @@ -105,9 +105,9 @@ describe("the security page", () => { | ||
| 105 | 105 | expect(await page()).not.toContain('id="csp"'); |
| 106 | 106 | const now = new Date(); |
| 107 | 107 | recordViolation(app.db, { directive: "img-src", blocked: "x.example", path: "/" }, now); |
| 108 | recordViolation(app.db, { directive: "img-src", blocked: "y.example", path: "/a" }, now); | |
| 108 | recordViolation(app.db, { directive: "img-src", blocked: "y.example", path: "/a" }, new Date(now.getTime() + 1)); | |
| 109 | 109 | recordViolation(app.db, { directive: "frame-src", blocked: "z.example", path: "/" }, new Date(now.getTime() - 10 * 86_400_000)); |
| 110 | expect(violationsByDirective(app.db, 7, now)).toEqual([{ directive: "img-src", n: 2 }]); | |
| 110 | expect(violationsByDirective(app.db, 7 * 24, new Date(now.getTime() + 1))).toEqual([{ directive: "img-src", n: 2, newest: "y.example" }]); | |
| 111 | 111 | const html = await page(); |
| 112 | 112 | expect(html).toContain('id="csp"'); |
| 113 | 113 | expect(html).toMatch(/<span class="mono">img-src<\/span> 2/); |
test/mcp-csp-violations.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | // An agent watching security reads CSP violations through MCP `system` `{ kind: "csp" }` (#10906). | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { recordViolation, violationsByDirective } from "../src/http/csp_report.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dir = ""; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let admin = ""; | |
| 16 | let member = ""; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dir = await mkdtemp(join(tmpdir(), "mcp-csp-")); | |
| 20 | app = createApp({ dataDir: dir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | const mk = async (username: string, extra: Record<string, unknown> = {}) => { | |
| 23 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", ...extra }); | |
| 24 | if (!u.ok) throw new Error(u.error); | |
| 25 | return u.value; | |
| 26 | }; | |
| 27 | admin = createToken(app.db, (await mk("alice", { adminIfFirst: true })).id, "a", "ro").token; | |
| 28 | member = createToken(app.db, (await mk("bob")).id, "b", "admin").token; | |
| 29 | }, 30_000); | |
| 30 | afterAll(async () => { | |
| 31 | await servers.stop(); | |
| 32 | app.close(); | |
| 33 | await rm(dir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | async function csp(token: string, kind: unknown = "csp"): Promise<{ isError?: boolean; body: any; text: string }> { | |
| 37 | const res = await fetch(`${servers.url}/mcp`, { | |
| 38 | method: "POST", | |
| 39 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 40 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: { kind } } }), | |
| 41 | }); | |
| 42 | const result = ((await res.json()) as any).result; | |
| 43 | const text: string = result.content[0].text; | |
| 44 | return { isError: result.isError, text, body: result.isError ? undefined : JSON.parse(text) }; | |
| 45 | } | |
| 46 | ||
| 47 | const report = (directive: string, blocked: string) => | |
| 48 | fetch(`${servers.url}/csp-report`, { | |
| 49 | method: "POST", | |
| 50 | headers: { "content-type": "application/csp-report", "x-forwarded-for": "10.7.7.7" }, | |
| 51 | body: JSON.stringify({ "csp-report": { "document-uri": `${servers.url}/x`, "effective-directive": directive, "blocked-uri": blocked } }), | |
| 52 | }); | |
| 53 | ||
| 54 | test("a posted report is read back by directive, with its count and newest blocked host", async () => { | |
| 55 | // A day-old count is outside the 24 hours. | |
| 56 | recordViolation(app.db, { directive: "frame-src", blocked: "old.example", path: "/" }, new Date(Date.now() - 25 * 3_600_000)); | |
| 57 | expect((await report("script-src-elem", "https://a.example/x.js")).status).toBe(204); | |
| 58 | expect((await report("script-src-elem", "https://a.example/x.js")).status).toBe(204); | |
| 59 | await Bun.sleep(5); | |
| 60 | expect((await report("script-src-elem", "https://b.example/y.js?secret=1")).status).toBe(204); | |
| 61 | expect((await report("img-src", "data:image/png;base64,AAAA")).status).toBe(204); | |
| 62 | const got = await csp(admin); | |
| 63 | expect(got.isError).toBeFalsy(); | |
| 64 | expect(got.body).toEqual({ | |
| 65 | hours: 24, | |
| 66 | since: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:00:00\.000Z$/), | |
| 67 | directives: [ | |
| 68 | { directive: "script-src-elem", n: 3, newest: "b.example" }, | |
| 69 | { directive: "img-src", n: 1, newest: "data" }, | |
| 70 | ], | |
| 71 | }); | |
| 72 | // The same function the security page reads. | |
| 73 | expect(got.body.directives).toEqual(violationsByDirective(app.db, 24)); | |
| 74 | }); | |
| 75 | ||
| 76 | test("one directive reported 25 h ago and again now counts 1 in the 24 h, not the whole UTC day", () => { | |
| 77 | app.db.run("DELETE FROM csp_violation"); | |
| 78 | const now = new Date("2026-11-10T12:30:00Z"); | |
| 79 | const v = { directive: "connect-src", blocked: "c.example", path: "/" }; | |
| 80 | recordViolation(app.db, v, new Date(now.getTime() - 25 * 3_600_000)); | |
| 81 | recordViolation(app.db, v, now); | |
| 82 | expect(violationsByDirective(app.db, 24, now)).toEqual([{ directive: "connect-src", n: 1, newest: "c.example" }]); | |
| 83 | // Both reports fall in the week. | |
| 84 | expect(violationsByDirective(app.db, 7 * 24, now)).toEqual([{ directive: "connect-src", n: 2, newest: "c.example" }]); | |
| 85 | // Same UTC day, 11 h apart: a 6 h window still counts only the late one. | |
| 86 | recordViolation(app.db, v, new Date("2026-11-10T01:00:00Z")); | |
| 87 | expect(violationsByDirective(app.db, 6, now)).toEqual([{ directive: "connect-src", n: 1, newest: "c.example" }]); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a non-admin is refused, and an unknown kind is invalid", async () => { | |
| 91 | const refused = await csp(member); | |
| 92 | expect(refused.isError).toBe(true); | |
| 93 | expect(refused.text).toContain("forbidden"); | |
| 94 | expect(refused.text).not.toContain("a.example"); | |
| 95 | const odd = await csp(admin, "secrets"); | |
| 96 | expect(odd.isError).toBe(true); | |
| 97 | expect(odd.text).toContain("invalid"); | |
| 98 | }); |
SUM(n) sums whole-day rows chosen by
seen, so a count can include reports older thanhours. Bucket the counts by hour, or stop calling the result a 24 h count.