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

An admin can see what was scraped and work out who is doing it #9464

Merged opened this in answer to

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

Merged

scout/forge:scout/9406-an-admin-can-see-what-was-scraped-and-wo → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/9406-an-admin-can-see-what-was-scraped-and-wo was merged into main as f3aab73.

Review

Sign in to review this change.
21 files changed +1354 −4
docs/design/audit.md +1
@@ -137,6 +137,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
137137 - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen.
138138 - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen.
139139 - `/settings/traffic` — `pages/traffic.ts` · who used the answering time in the last 15 minutes · `/settings/usage`, `/settings/telemetry` · none seen.
140 - `/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.
140141
141142 ## Operator views — why each is a page (#2696)
142143
docs/features/scrapes.md added +12
@@ -0,0 +1,12 @@
1 # Scrape evidence (#9406)
2
3 An admin sees what was read and works out who is doing it, at `/settings/traffic/scrapes` (linked from Traffic) and in the terminal: `:` → "Scrapes" (a forge admin who is a person; read-only there).
4
5 - **Reads.** For 1, 3 or 7 days: per repository, the paths read, the commits and branches, how much of the repository was covered, and files whose names look like keys or credentials. Counted in memory as requests finish (`perf/reads.ts`), written once a minute to `read_hour`, kept 7 days. No address is kept in it. A stored hour holds at most 500 distinct (repository, ref, path) rows; later ones fold into `(others)` for their repository, so an attacker walking paths cannot grow the table, and the report groups in SQL.
6 - **Networks.** Address prefixes show name, operator, country and abuse contact from the forge's own `network` table, filled by pasting `range, name, operator, country, abuse contact` lines. No visitor address goes to a third party. Where the table has no entry, the page says the evidence stops there.
7 - **Capture.** One click starts a capture: 10 minutes, 2000 requests, in memory only, no cookies, tokens or query strings, signed-in people skipped. Requests that look alike are grouped across addresses with a stated confidence and reasons. Full addresses exist only inside a capture and are gone when it expires or is stopped.
8 - **Bot score and country** (`cf-bot-score`, `x-bot-score`, `cf-ipcountry`) are believed only when the request came through a trusted proxy (`peer.ts`, `wasViaEdge`); from anyone else they are ignored.
9 - **Export.** A group exports as a plain-text abuse report with its limits stated.
10 - Admin only; agent accounts are refused; every mutation is same-origin.
11
12 Not done: lookups against a public registry (by design, nothing leaves the forge), and blocking from this page.
src/core/event_audience.ts +2
@@ -165,6 +165,8 @@ const READER_KINDS: ReadonlySet<string> = new Set([
165165 "issue.confirmed",
166166 "capacity.changed",
167167 "shield.changed",
168 "traffic.capture_started",
169 "traffic.networks_imported",
168170 "crew.governed",
169171 ]);
170172
src/core/migration-pins/20261108100000-scrape-evidence.pin added +1
@@ -0,0 +1,1 @@
1 d90e8888280398516a6b00d80d951bad10ef7c23e8f7af59628a6fee590d77c3
src/core/migrations/20261108100000-scrape-evidence.ts added +26
@@ -0,0 +1,26 @@
1 // Evidence for telling a scraper from a reader (`perf/reads.ts`, `perf/network.ts`).
2 // `read_hour`: what was read from repositories, by the hour for 7 days: repository,
3 // ref (a branch or a commit), path, how many reads and how many were anonymous.
4 // No address is kept here. `network`: the forge's own database of who runs an
5 // address range (name, operator, country, abuse contact), filled by an admin, so no
6 // visitor address ever goes to a third party. New tables only. Additive.
7 export default `
8 CREATE TABLE read_hour (
9 hour INTEGER NOT NULL,
10 repo TEXT NOT NULL,
11 ref TEXT NOT NULL,
12 path TEXT NOT NULL,
13 requests INTEGER NOT NULL,
14 anonymous INTEGER NOT NULL,
15 PRIMARY KEY (hour, repo, ref, path)
16 ) WITHOUT ROWID;
17 CREATE TABLE network (
18 cidr TEXT PRIMARY KEY,
19 name TEXT NOT NULL,
20 operator TEXT NOT NULL,
21 country TEXT NOT NULL,
22 abuse TEXT NOT NULL,
23 source TEXT NOT NULL,
24 added_at INTEGER NOT NULL
25 ) WITHOUT ROWID;
26 `;
src/http/peer.ts +6
@@ -61,6 +61,7 @@ export function resolveClient(
6161
6262 const clients = new WeakMap<Request, string>();
6363 const internals = new WeakSet<Request>();
64 const edged = new WeakSet<Request>();
6465
6566 /** Headers a proxy names a client in. The tunnel always sets its own, so no stranger can leave them all out. */
6667 const CLIENT_HEADERS = ["cf-connecting-ip", "x-forwarded-for", "x-real-ip", "forwarded"];
@@ -85,8 +86,13 @@ export const notePeer = (
8586 ): void => {
8687 clients.set(request, resolveClient(address, request.headers, trust));
8788 if (isInternalPeer(address, request.headers, trust)) internals.add(request);
89 else if (address !== undefined && (trust.proxies.length > 0 ? inCidrs(address, trust.proxies) : LOOPBACK.has(address)))
90 edged.add(request);
8891 };
8992
93 /** The request came through a proxy the operator trusts, so the headers an edge adds (bot score, country) are its, not the client's. */
94 export const wasViaEdge = (request: Request): boolean => edged.has(request);
95
9096 export const wasInternal = (request: Request): boolean => internals.has(request);
9197
9298 export function clientAddress(request: Request): string {
src/http/server.ts +16 −3
@@ -458,14 +458,15 @@ import {
458458 takeDbTime,
459459 type RouteTiming,
460460 } from "../core/route_timing.ts";
461 import { loopWatch, perfRecord } from "../perf/live.ts";
462 import { countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
461 import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts";
462 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
463463 import { createTrafficWriter } from "../perf/traffic_store.ts";
464 import { createReadWriter, readOf } from "../perf/reads.ts";
outdated

Duplicate imports from perf/live.ts and perf/traffic.ts: merge into the existing import lines.

464465 import { parseServerTiming } from "../perf/record.ts";
465466 import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts";
466467 import { bearerChallenge, oauthRoutes } from "./oauth.ts";
467468 import { flushAccess, recordAccess } from "./access_log.ts";
468 import { carryPeer, clientAddress, notePeer, wasInternal, type ProxyTrust } from "./peer.ts";
469 import { carryPeer, clientAddress, notePeer, wasInternal, wasViaEdge, type ProxyTrust } from "./peer.ts";
469470 import { isProbe } from "../kernel/probe.ts";
470471 import { takeStrangerRead } from "./stranger_reads.ts";
471472 import { parseCidrs } from "../kernel/cidr.ts";
@@ -2229,6 +2230,7 @@ export async function serve(app: App): Promise<Servers> {
22292230 const timings = new Map<Database, RouteTiming>();
22302231 // The minute ledger's history: one write a minute, never on a request.
22312232 const trafficWriter = createTrafficWriter(app.db, perfRecord.ledger, app.log);
2233 const readWriter = createReadWriter(app.db, readLedger, app.log);
22322234 const counted = (
22332235 owner: App,
22342236 request: Request,
@@ -2255,6 +2257,16 @@ export async function serve(app: App): Promise<Servers> {
22552257 },
22562258 );
22572259 loopWatch.finished(tag.pattern, started, end);
2260 // Evidence of what was read, and the running capture, if any (never a signed-in person's).
2261 const signedIn = tag.role !== "anon" && tag.role !== "crawler";
2262 const cls = classOf({ signedIn, crawler: wasCrawler(request), deepStranger: wasDeepStranger(request), internal: wasInternal(request) });
2263 if (cls !== "internal" && response.status < 400) {
2264 const read = readOf(tag.pattern, new URL(request.url).pathname);
2265 if (read !== null) readLedger.note(Date.now(), read, !signedIn);
2266 }
2267 const at = Date.now();
2268 if (captureStore.active(at))
2269 captureStore.note(at, { request, address: clientAddress(request), viaEdge: wasViaEdge(request), status: response.status, ms, cls, pattern: tag.pattern });
22582270 } catch {
22592271 // A response the forge is only carrying keeps the headers it came with.
22602272 }
@@ -2492,6 +2504,7 @@ export async function serve(app: App): Promise<Servers> {
24922504 }
24932505 timings.clear();
24942506 trafficWriter.stop();
2507 readWriter.stop();
24952508 // A child's hooks stop answering before its database closes.
24962509 for (const key of [...new Set([...childHttps.keys(), ...childInternals.keys()])]) letGo(key);
24972510 closeHostedChildren(app);
src/http/settings_routes.ts +6
@@ -20,6 +20,7 @@ import { usagePage } from "../web/pages/usage.ts";
2020 import { spendPage } from "../web/pages/spend-tab.ts";
2121 import { securityPage, securityShieldSet } from "../web/pages/security.ts";
2222 import { trafficPage } from "../web/pages/traffic.ts";
23 import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts";
2324 import {
2425 accountAct,
2526 retireAct,
@@ -189,6 +190,11 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
189190 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
190191 router.add("GET", "/settings/spend", spendPage);
191192 router.add("GET", "/settings/traffic", trafficPage);
193 router.add("GET", "/settings/traffic/scrapes", scrapesPage);
194 router.add("GET", "/settings/traffic/scrapes/export", scrapesExport);
195 router.add("POST", "/settings/traffic/scrapes/capture", captureStart);
196 router.add("POST", "/settings/traffic/scrapes/stop", captureStop);
197 router.add("POST", "/settings/traffic/scrapes/networks", networksImport);
192198 router.add("GET", "/settings/security", securityPage);
193199 router.add("POST", "/settings/security/shield", securityShieldSet);
194200 router.add("GET", "/settings/backups", backupsPage);
src/perf/actors.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * One actor across addresses: captured requests grouped by what they share
3 * (user agent, header names and order, language, accept, encoding,
4 * sec-fetch-site), each group with its size, rate, addresses, the commits it
5 * walked, its cadence and Cloudflare's bot score if sent, and a confidence
6 * stated with the reasons for it.
7 *
8 * A group is a claim that requests look alike, not that one party sent them.
9 * Traits every browser of one version shares prove little, so confidence rests
10 * on the strong signals (a steady cadence, a walk through commits, a bot score)
11 * and the page says what the evidence cannot show. Nothing here names anyone.
12 */
13
14 import type { CapturedRequest } from "./capture.ts";
15 import { isCommit } from "./reads.ts";
16
17 export type Confidence = "low" | "medium" | "high";
18
19 export interface Group {
20 readonly id: string;
21 readonly ua: string;
22 readonly language: string;
23 readonly size: number;
24 readonly addresses: readonly string[];
25 readonly prefixes: readonly string[];
26 readonly perMinute: number;
27 readonly spanMs: number;
28 readonly commits: readonly string[];
29 readonly cadence: "steady" | "uneven" | "too few to say";
30 readonly botScore: { readonly min: number; readonly max: number } | null;
31 readonly confidence: Confidence;
32 /** What the confidence rests on, and what it lacks. */
33 readonly reasons: readonly string[];
34 readonly requests: readonly CapturedRequest[];
35 }
36
37 const key = (r: CapturedRequest): string =>
38 [r.ua, r.shape, r.language, r.accept, r.encoding, r.fetchSite].join("\u0000");
39
40 const idOf = (k: string): string => (Number(Bun.hash.xxHash32(k)) >>> 0).toString(16).padStart(8, "0");
41
42 /** Is the gap between requests steady: at least 10 gaps and a coefficient of variation under 0.5. */
43 function cadenceOf(times: readonly number[]): Group["cadence"] {
44 if (times.length < 11) return "too few to say";
45 const sorted = [...times].sort((a, b) => a - b);
46 const gaps = sorted.slice(1).map((t, i) => t - sorted[i]!);
47 const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
48 if (mean <= 0) return "steady";
49 const sd = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length);
50 return sd / mean < 0.5 ? "steady" : "uneven";
51 }
52
53 export function groupRequests(entries: readonly CapturedRequest[]): Group[] {
54 const by = new Map<string, CapturedRequest[]>();
55 for (const e of entries) by.set(key(e), [...(by.get(key(e)) ?? []), e]);
56 const groups: Group[] = [];
57 for (const [k, list] of by) {
58 const addresses = [...new Set(list.map((r) => r.address))];
59 const prefixes = [...new Set(list.map((r) => r.prefix))];
60 const times = list.map((r) => r.at);
61 const spanMs = Math.max(...times) - Math.min(...times);
62 const commits = [...new Set(list.flatMap((r) => (r.read !== null && isCommit(r.read.ref) ? [r.read.ref] : [])))];
63 const scores = list.flatMap((r) => (r.botScore === null ? [] : [r.botScore]));
64 const cadence = cadenceOf(times);
65 const botScore = scores.length === 0 ? null : { min: Math.min(...scores), max: Math.max(...scores) };
66 const strong = [cadence === "steady", commits.length >= 3, botScore !== null && botScore.max <= 29];
67 const strongN = strong.filter(Boolean).length;
68 const size = list.length;
69 const confidence: Confidence =
70 strongN >= 2 && size >= 20 ? "high" : strongN >= 1 && size >= 10 && addresses.length >= 2 ? "medium" : "low";
71 const reasons = [
72 addresses.length >= 2
73 ? `${addresses.length} addresses sent requests with the same user agent and headers.`
74 : "Only one address: nothing here spans addresses.",
75 cadence === "steady" ? "Requests arrive at a steady pace." : cadence === "uneven" ? "Requests arrive unevenly, as people's do." : "Too few requests to judge their pace.",
76 commits.length >= 3 ? `They read ${commits.length} different commits.` : "They did not walk a run of commits.",
77 botScore === null ? "No bot score was sent." : `Cloudflare's bot score was ${botScore.min === botScore.max ? botScore.min : `${botScore.min} to ${botScore.max}`} (low means likely automated).`,
78 size < 20 ? "Few requests: a small sample." : `${size} requests.`,
79 confidence === "low" && strongN === 0 ? "Traits that browsers of one version share are weak evidence on their own." : "",
80 ].filter((r) => r !== "");
81 groups.push({
82 id: idOf(k),
83 ua: list[0]!.ua,
84 language: list[0]!.language,
85 size,
86 addresses,
87 prefixes,
88 perMinute: Math.round((size / Math.max(1, spanMs / 60_000)) * 10) / 10,
89 spanMs,
90 commits,
91 cadence,
92 botScore,
93 confidence,
94 reasons,
95 requests: list,
96 });
97 }
98 const rank: Record<Confidence, number> = { high: 3, medium: 2, low: 1 };
99 return groups.sort((a, b) => b.size - a.size || rank[b.confidence] - rank[a.confidence]);
100 }
src/perf/capture.ts added +167
@@ -0,0 +1,167 @@
1 /**
2 * A capture: for a short while, keep what it takes to tell one actor from
3 * another across addresses. The only place a full visitor address is ever
4 * held, and only in memory, so a restart ends it as an expiry does.
5 *
6 * Bounded in time (`CAPTURE_MS`) and size (`CAPTURE_MAX` requests), and
7 * expires by itself: `current` returns null once it is over and its requests
8 * are dropped. It keeps no cookie, authorization header or query string: of
9 * the headers, only their names, and the handful of values that tell clients
10 * apart. Only requests no one was signed in behind are kept; a signed-in
11 * person's requests are not an investigation's business.
12 */
13
14 import { addressBytes } from "../kernel/cidr.ts";
15 import { prefixOf, type TrafficClass } from "./traffic.ts";
16 import { readOf, type Read } from "./reads.ts";
17
18 export const CAPTURE_MS = 10 * 60_000;
19 export const CAPTURE_MAX = 2_000;
20
21 export interface CapturedRequest {
22 readonly n: number;
23 readonly at: number;
24 readonly address: string;
25 readonly prefix: string;
26 readonly method: string;
27 readonly path: string;
28 readonly status: number;
29 readonly ms: number;
30 readonly ua: string;
31 /** Header names (never values) except the ones a proxy adds, in the order received. */
32 readonly shape: string;
33 readonly language: string;
34 readonly accept: string;
35 readonly encoding: string;
36 readonly fetchSite: string;
37 readonly country: string | null;
38 /** Cloudflare's bot score, 1 to 99, when the edge sent one. */
39 readonly botScore: number | null;
40 readonly read: Read | null;
41 }
42
43 export interface Capture {
44 readonly id: string;
45 readonly startedAt: number;
46 readonly expiresAt: number;
47 readonly by: string;
48 readonly entries: readonly CapturedRequest[];
49 /** The size bound was reached; later requests were not kept. */
50 readonly full: boolean;
51 }
52
53 /** What the server tells a capture of one finished request. */
54 export interface CaptureInput {
55 readonly request: Request;
56 readonly address: string;
57 /** Came through a trusted edge: only then are its bot score and country headers believed. */
58 readonly viaEdge: boolean;
59 readonly status: number;
60 readonly ms: number;
61 readonly cls: TrafficClass;
62 readonly pattern: string;
63 }
64
65 const PROXY_HEADER = /^(x-forwarded-|cf-|x-real-ip$|cdn-loop$|traceparent$|tracestate$|x-request-id$|forwarded$|via$|x-envoy-|x-amzn-)/;
66 const NEVER = /^(cookie|authorization|proxy-authorization|x-api-key|x-auth-token)$/;
67
68 const short = (s: string | null, max: number): string =>
69 // eslint-disable-next-line no-control-regex
70 (s ?? "").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
71
72 /** The Cloudflare bot score a header carries (`cf-bot-score` or `x-bot-score`): a whole number from 1 to 99, else null. */
73 export function botScoreOf(headers: Headers): number | null {
74 const raw = headers.get("cf-bot-score") ?? headers.get("x-bot-score");
75 if (raw === null || !/^\d{1,2}$/.test(raw.trim())) return null;
76 const n = Number(raw);
77 return n >= 1 && n <= 99 ? n : null;
78 }
79
80 export function describe(input: CaptureInput, n: number, at: number): CapturedRequest {
81 const { request: r } = input;
82 const url = new URL(r.url);
83 const bytes = addressBytes(input.address);
84 const names = [...r.headers.keys()].filter((h) => !PROXY_HEADER.test(h) && !NEVER.test(h));
85 const country = input.viaEdge ? (r.headers.get("cf-ipcountry")?.toUpperCase() ?? "") : "";
86 return {
87 n,
88 at,
89 address: bytes === undefined ? "unknown" : short(input.address, 45),
90 prefix: bytes === undefined ? "unknown" : prefixOf(bytes),
91 method: short(r.method, 8),
92 // The query is left out: it can carry a token.
93 path: short(url.pathname, 300),
94 status: input.status,
95 ms: Math.round(input.ms),
96 ua: short(r.headers.get("user-agent"), 200),
97 shape: names.slice(0, 30).join(","),
98 language: short(r.headers.get("accept-language"), 60),
99 accept: short(r.headers.get("accept"), 80),
100 encoding: short(r.headers.get("accept-encoding"), 40),
101 fetchSite: short(r.headers.get("sec-fetch-site"), 16),
102 country: /^[A-Z][A-Z0-9]$/.test(country) ? country : null,
103 botScore: input.viaEdge ? botScoreOf(r.headers) : null,
104 read: readOf(input.pattern, url.pathname),
105 };
106 }
107
108 export interface CaptureStore {
109 /** Begin a capture; if one is running, that one. */
110 start(now: number, by: string): Capture;
111 stop(): void;
112 /** The running capture, or null once it has expired (its requests are dropped then). */
113 current(now: number): Capture | null;
114 /** Cheap: is anything being kept at all. */
115 active(now: number): boolean;
116 note(now: number, input: CaptureInput): void;
117 }
118
119 export function createCaptureStore(): CaptureStore {
120 let id = "";
121 let startedAt = 0;
122 let expiresAt = 0;
123 let by = "";
124 let entries: CapturedRequest[] = [];
125 let full = false;
126 let timer: ReturnType<typeof setTimeout> | undefined;
127 const drop = (): void => {
128 clearTimeout(timer);
129 id = "";
130 entries = [];
131 full = false;
132 expiresAt = 0;
133 };
134 const live = (now: number): boolean => {
135 if (id !== "" && now >= expiresAt) drop();
136 return id !== "";
137 };
138 return {
139 start(now, who) {
140 if (!live(now)) {
141 id = crypto.randomUUID().slice(0, 8);
142 startedAt = now;
143 expiresAt = now + CAPTURE_MS;
144 by = who;
145 entries = [];
146 full = false;
147 timer = setTimeout(drop, CAPTURE_MS + 1_000);
148 timer.unref();
149 }
150 return this.current(now)!;
151 },
152 stop: drop,
153 current(now) {
154 return live(now) ? { id, startedAt, expiresAt, by, entries, full } : null;
155 },
156 active: live,
157 note(now, input) {
158 if (!live(now) || input.cls === "people" || input.cls === "internal") return;
159 if (new URL(input.request.url).pathname.startsWith("/settings/traffic")) return;
160 if (entries.length >= CAPTURE_MAX) {
161 full = true;
162 return;
163 }
164 entries.push(describe(input, entries.length + 1, now));
165 },
166 };
167 }
src/perf/live.ts +5
@@ -3,6 +3,8 @@
33 import { createBootStall } from "./boot_stall.ts";
44 import { createGitRecord } from "./git.ts";
55 import { createLoopWatch } from "./loop.ts";
6 import { createCaptureStore } from "./capture.ts";
7 import { createReadLedger } from "./reads.ts";
68 import { createPerfRecord } from "./record.ts";
79
810 export const perfRecord = createPerfRecord();
@@ -10,3 +12,6 @@ export const loopWatch = createLoopWatch();
1012 export const gitRecord = createGitRecord();
1113 /** The worst stall in this process's first five minutes, read by the release keeper (#8848). */
1214 export const bootStall = createBootStall();
15 /** What was read from repositories (`reads.ts`), and the one bounded capture an admin may run (`capture.ts`). */
16 export const readLedger = createReadLedger();
17 export const captureStore = createCaptureStore();
src/perf/network.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * Who runs an address range, from the forge's own `network` table: name,
3 * operator, country and abuse contact, entered by an admin (a registry
4 * extract, an RIR whois answer they looked up themselves). A lookup is a
5 * local scan of a small table, so no visitor address is sent to anyone.
6 *
7 * What it says is what the table says. A range with no entry has no network
8 * here, and the page says so rather than guess. An entry narrower than a
9 * prefix covers only part of it (`scope: "part"`).
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { addressBytes, inCidr, parseCidr, type Cidr } from "../kernel/cidr.ts";
14
15 export const MAX_IMPORT_LINES = 2_000;
16
17 export interface Network {
18 readonly cidr: string;
19 readonly name: string;
20 readonly operator: string;
21 readonly country: string;
22 readonly abuse: string;
23 /** Does the entry cover the whole of what was asked about, or only part of it. */
24 readonly scope: "whole" | "part";
25 }
26
27 interface Row {
28 cidr: string;
29 name: string;
30 operator: string;
31 country: string;
32 abuse: string;
33 }
34
35 export interface NetworkTable {
36 /** The most specific entry covering an address or a prefix (`203.0.0.0/16`); null when none does. */
37 find(addressOrPrefix: string): Network | null;
38 size: number;
39 }
40
41 export function networkTable(db: Database): NetworkTable {
42 const rows = db
43 .query<Row, []>("SELECT cidr, name, operator, country, abuse FROM network")
44 .all()
45 .flatMap((r) => {
46 const cidr = parseCidr(r.cidr);
47 return cidr === undefined ? [] : [{ r, cidr }];
48 });
49 return {
50 size: rows.length,
51 find(text) {
52 const [base, len] = text.split("/");
53 if (base === undefined || addressBytes(base) === undefined) return null;
54 const asked = len === undefined ? (addressBytes(base)!.length * 8) : Number(len);
55 let best: { r: Row; cidr: Cidr } | undefined;
56 for (const e of rows)
57 if (inCidr(base, e.cidr) && (best === undefined || e.cidr.bits > best.cidr.bits)) best = e;
58 if (best === undefined) return null;
59 return { ...best.r, scope: best.cidr.bits <= asked ? "whole" : "part" };
60 },
61 };
62 }
63
64 const ABUSE = /^(?:[^\s@<>,]{1,64}@[^\s@<>,]{1,120}|https?:\/\/[^\s<>"]{4,200})$/;
65 const clean = (s: string, max: number): string =>
66 // eslint-disable-next-line no-control-regex
67 s.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
68
69 export interface Imported {
70 readonly rows: readonly Omit<Network, "scope">[];
71 /** `line N: why`, for each line refused. */
72 readonly refused: readonly string[];
73 }
74
75 /** Lines of `range, name, operator, country, abuse contact`; a malformed line is named and skipped, never guessed at. */
76 export function parseNetworks(text: string): Imported {
77 const rows: Omit<Network, "scope">[] = [];
78 const refused: string[] = [];
79 const lines = text.split(/\r?\n/);
80 lines.slice(0, MAX_IMPORT_LINES).forEach((line, i) => {
81 if (line.trim() === "" || line.trim().startsWith("#")) return;
82 const f = line.split(",").map((x) => x.trim());
83 const n = `line ${i + 1}`;
84 if (f.length !== 5) return void refused.push(`${n}: want range, name, operator, country, abuse contact`);
85 const [cidr, name, operator, country, abuse] = f as [string, string, string, string, string];
86 if (parseCidr(cidr) === undefined || !cidr.includes("/")) return void refused.push(`${n}: not a range like 203.0.0.0/16`);
87 if (name === "") return void refused.push(`${n}: no network name`);
88 if (!/^[A-Za-z]{2}$/.test(country)) return void refused.push(`${n}: country is two letters`);
89 if (abuse !== "" && !ABUSE.test(abuse)) return void refused.push(`${n}: abuse contact is an email address or a web address`);
90 rows.push({ cidr, name: clean(name, 100), operator: clean(operator, 100), country: country.toUpperCase(), abuse });
91 });
92 if (lines.length > MAX_IMPORT_LINES) refused.push(`only the first ${MAX_IMPORT_LINES} lines were read`);
93 return { rows, refused };
94 }
95
96 export function saveNetworks(db: Database, rows: Imported["rows"], by: string, now: number): void {
97 const put = db.query(
98 `INSERT INTO network (cidr, name, operator, country, abuse, source, added_at)
99 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
100 ON CONFLICT (cidr) DO UPDATE SET name = excluded.name, operator = excluded.operator,
101 country = excluded.country, abuse = excluded.abuse, source = excluded.source, added_at = excluded.added_at`,
102 );
103 db.transaction(() => {
104 for (const r of rows) put.run(r.cidr, r.name, r.operator, r.country, r.abuse, `admin ${by}`, now);
105 })();
106 }
src/perf/reads.ts added +312
@@ -0,0 +1,312 @@
1 /**

HIGH: unbounded read_hour growth (cap is per drain, not per hour) and readsReport loads the whole window; cap distinct keys per hour and aggregate in SQL.

2 * What was read from repositories, not only how much: repository, ref (branch
3 * or commit) and path of every file, tree and commit page, counted in memory
4 * as requests finish and written once a minute into `read_hour` (kept 7 days).
5 * No address is kept here. A path that looks like a secret or a credential is
6 * flagged (`secretPath`) so an admin sees a scraper going for keys.
7 *
8 * Bounded twice: at most `MAX_KEYS` distinct reads wait in memory between
9 * writes, and a stored hour holds at most `HOUR_KEYS_MAX` distinct rows however
10 * many minutes wrote to it; later ones fall under `OTHER_PATH` for their
11 * repository. So the table cannot pass `HOUR_KEYS_MAX` x 168 rows plus the
12 * folds, and the report reads it grouped in SQL, never row by row.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { runGit } from "../git/env.ts";
17 import { withoutWaiting } from "../kernel/db.ts";
18 import { quietly } from "../core/write_clock.ts";
19
20 const HOUR_MS = 3_600_000;
21 const MAX_KEYS = 2_000;
22 /** Distinct (repo, ref, path) rows one hour may hold in the table; later ones fold into `(others)` for their repository. */
23 export const HOUR_KEYS_MAX = 500;
24 const FLUSH_MS = 60_000;
25 export const READ_DAYS_MAX = 7;
26 export const OTHER_PATH = "(others)";
27
28 export interface Read {
29 readonly repo: string;
30 readonly ref: string;
31 readonly path: string;
32 }
33
34 const clean = (text: string, max: number): string =>
35 // eslint-disable-next-line no-control-regex
36 text.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
37
38 function decode(text: string): string {
39 try {
40 return decodeURIComponent(text);
41 } catch {
42 return text;
43 }
44 }
45
46 const FILE_ROUTE = /^\/:owner\/:name\/(blob|raw|tree|commit)\//;
47 const FILE_PATH = /^\/([^/]+)\/([^/]+)\/(blob|raw|tree|commit)\/([^/]+)(?:\/(.*))?$/;
48
49 /** The repository, ref and path a file, tree or commit route read; null for any other route. */
50 export function readOf(pattern: string, pathname: string): Read | null {
51 if (!FILE_ROUTE.test(pattern)) return null;
52 const m = FILE_PATH.exec(pathname);
53 if (m === null) return null;
54 const path = m[3] === "commit" ? "" : decode(m[5] ?? "");
55 return {
56 repo: clean(`${decode(m[1]!)}/${decode(m[2]!)}`, 120),
57 ref: clean(decode(m[4]!), 80),
58 path: clean(path, 300),
59 };
60 }
61
62 /** A ref that is a commit id (7 to 64 hex characters) rather than a branch name. */
63 export const isCommit = (ref: string): boolean => /^[0-9a-f]{7,64}$/.test(ref);
64
65 const SECRET = [
66 /(^|\/)\.env(\.[^/]*)?$/i,
67 /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
68 /\.(pem|key|p12|pfx|jks|kdbx|keystore)$/i,
69 /(^|\/)\.(npmrc|netrc|pgpass|htpasswd|git-credentials|dockercfg|pypirc)$/i,
70 /(^|\/)(credentials|secrets?)(\.[^/]*)?$/i,
71 /(^|\/)\.(aws|ssh|gnupg|kube)\//i,
72 /(^|\/)service[-_]account[^/]*\.json$/i,
73 /(^|\/)wp-config\.php$/i,
74 /(^|\/)\.docker\/config\.json$/i,
75 /(^|\/)(terraform\.tfstate|\.tfvars|[^/]*\.tfvars)$/i,
76 ];
77
78 /** The file's name or place says it holds a secret or a credential: a guess from the path, never from the content. */
79 export const secretPath = (path: string): boolean => SECRET.some((r) => r.test(path));
80
81 interface Count {
82 requests: number;
83 anonymous: number;
84 }
85
86 export interface ReadLedger {
87 note(at: number, read: Read, anonymous: boolean): void;
88 /** What waits to be written, for the page to add to what is stored. */
89 pending(): ReadRow[];
90 drain(): Map<number, Map<string, Count>>;
91 }
92
93 export interface ReadRow extends Read {
94 readonly hour: number;
95 readonly requests: number;
96 readonly anonymous: number;
97 }
98
99 const SEP = "\u0000";
100
101 export function createReadLedger(): ReadLedger {
102 let held = new Map<number, Map<string, Count>>();
103 let size = 0;
104 return {
105 note(at, read, anonymous) {
106 const hour = Math.floor(at / HOUR_MS);
107 let table = held.get(hour);
108 if (table === undefined) held.set(hour, (table = new Map()));
109 let key = [read.repo, read.ref, read.path].join(SEP);
110 if (!table.has(key)) {
111 if (size >= MAX_KEYS) key = [read.repo, "", OTHER_PATH].join(SEP);
112 else size++;
113 }
114 const c = table.get(key) ?? { requests: 0, anonymous: 0 };
115 c.requests++;
116 if (anonymous) c.anonymous++;
117 table.set(key, c);
118 },
119 pending() {
120 const rows: ReadRow[] = [];
121 for (const [hour, table] of held)
122 for (const [key, c] of table) {
123 const [repo, ref, path] = key.split(SEP) as [string, string, string];
124 rows.push({ hour, repo, ref, path, ...c });
125 }
126 return rows;
127 },
128 drain() {
129 const out = held;
130 held = new Map();
131 size = 0;
132 return out;
133 },
134 };
135 }
136
137 export interface ReadWriter {
138 flush(): void;
139 stop(): void;
140 }
141
142 export function createReadWriter(
143 db: Database,
144 ledger: ReadLedger,
145 log: { warn(message: string, fields?: Record<string, unknown>): void },
146 now: () => number = Date.now,
147 flushMs: number = FLUSH_MS,
148 ): ReadWriter {
149 const flush = (): void => {
150 const drained = ledger.drain();
151 try {
152 withoutWaiting(
153 db,
154 db.transaction(() =>
155 quietly(db, () => {
156 const put = db.query(
157 `INSERT INTO read_hour (hour, repo, ref, path, requests, anonymous)
158 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
159 ON CONFLICT (hour, repo, ref, path) DO UPDATE SET
160 requests = requests + excluded.requests,
161 anonymous = anonymous + excluded.anonymous`,
162 );
163 const held = db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM read_hour WHERE hour = ?1");
164 const has = db.query<{ one: number }, [number, string, string, string]>(
165 "SELECT 1 AS one FROM read_hour WHERE hour = ?1 AND repo = ?2 AND ref = ?3 AND path = ?4",
166 );
167 for (const [hour, table] of drained) {
168 let rows = held.get(hour)?.n ?? 0;
169 for (const [key, c] of table) {
170 let [repo, ref, path] = key.split(SEP) as [string, string, string];
171 if (has.get(hour, repo, ref, path) === null) {
172 if (rows >= HOUR_KEYS_MAX && path !== OTHER_PATH) {
173 ref = "";
174 path = OTHER_PATH;
175 if (has.get(hour, repo, ref, path) === null) rows++;
176 } else rows++;
177 }
178 put.run(hour, repo, ref, path, c.requests, c.anonymous);
179 }
180 }
181 db.query("DELETE FROM read_hour WHERE hour < ?1").run(
182 Math.floor(now() / HOUR_MS) - READ_DAYS_MAX * 24,
183 );
184 }),
185 ),
186 );
187 } catch (error) {
188 // A failed write loses that minute's reads; evidence, not a ledger.
189 log.warn("could not write read history", { error: String(error) });
190 }
191 };
192 const tick = setInterval(flush, flushMs);
193 tick.unref();
194 return {
195 flush,
196 stop() {
197 clearInterval(tick);
198 flush();
199 },
200 };
201 }
202
203 export interface PathRow {
204 readonly path: string;
205 readonly requests: number;
206 readonly anonymous: number;
207 readonly secret: boolean;
208 }
209
210 export interface RepoReads {
211 readonly repo: string;
212 readonly requests: number;
213 readonly anonymous: number;
214 /** Distinct file or tree paths read. */
215 readonly paths: number;
216 /** Most-read commits (by id), then branches. */
217 readonly commits: readonly { ref: string; requests: number }[];
218 readonly topPaths: readonly PathRow[];
219 readonly secrets: readonly PathRow[];
220 /** Files at HEAD, and the share of them the window read; filled by `addCoverage`. */
221 files?: number | null;
222 coverage?: number | null;
223 }
224
225 /** The window's reads grouped by repository, busiest first; `days` is cut to 1..7. */
226 export function readsReport(
227 db: Database,
228 ledger: ReadLedger,
229 now: number,
230 days: number,
231 ): RepoReads[] {
232 const d = Math.min(READ_DAYS_MAX, Math.max(1, Math.floor(days) || 1));
233 const since = Math.floor(now / HOUR_MS) - d * 24 + 1;
234 // Grouped in SQL: one row per repository and path, one per repository and ref.
235 const byPath = db
236 .query<ReadRow, [number]>(
237 `SELECT 0 AS hour, repo, '' AS ref, path, SUM(requests) AS requests, SUM(anonymous) AS anonymous
238 FROM read_hour WHERE hour >= ?1 GROUP BY repo, path`,
239 )
240 .all(since);
241 const byRef = db
242 .query<ReadRow, [number]>(
243 `SELECT 0 AS hour, repo, ref, '' AS path, SUM(requests) AS requests, SUM(anonymous) AS anonymous
244 FROM read_hour WHERE hour >= ?1 AND ref != '' GROUP BY repo, ref`,
245 )
246 .all(since);
247 const waiting = ledger.pending().filter((r) => r.hour >= since);
248 const rows = [...byPath, ...waiting];
249 const byRepo = new Map<string, ReadRow[]>();
250 for (const r of rows) {
251 let list = byRepo.get(r.repo);
252 if (list === undefined) byRepo.set(r.repo, (list = []));
253 list.push(r);
254 }
255 const refRows = [...byRef, ...waiting.filter((r) => r.ref !== "")];
256 const refsOf = new Map<string, Map<string, number>>();
257 for (const r of refRows) {
258 let m = refsOf.get(r.repo);
259 if (m === undefined) refsOf.set(r.repo, (m = new Map()));
260 m.set(r.ref, (m.get(r.ref) ?? 0) + r.requests);
261 }
262 const out: RepoReads[] = [];
263 for (const [repo, list] of byRepo) {
264 const paths = new Map<string, Count>();
265 const refs = refsOf.get(repo) ?? new Map<string, number>();
266 let requests = 0;
267 let anonymous = 0;
268 for (const r of list) {
269 requests += r.requests;
270 anonymous += r.anonymous;
271 if (r.path !== "" && r.path !== OTHER_PATH) {
272 const c = paths.get(r.path) ?? { requests: 0, anonymous: 0 };
273 c.requests += r.requests;
274 c.anonymous += r.anonymous;
275 paths.set(r.path, c);
276 }
277 }
278 const all: PathRow[] = [...paths]
279 .map(([path, c]) => ({ path, ...c, secret: secretPath(path) }))
280 .sort((a, b) => b.requests - a.requests || a.path.localeCompare(b.path));
281 out.push({
282 repo,
283 requests,
284 anonymous,
285 paths: paths.size,
286 commits: [...refs]
287 .map(([ref, n]) => ({ ref, requests: n }))
288 .sort((a, b) => Number(isCommit(b.ref)) - Number(isCommit(a.ref)) || b.requests - a.requests)
289 .slice(0, 5),
290 topPaths: all.slice(0, 5),
291 secrets: all.filter((p) => p.secret).slice(0, 10),
292 });
293 }
294 return out.sort((a, b) => b.requests - a.requests || a.repo.localeCompare(b.repo)).slice(0, 20);
295 }
296
297 /** How much of the repository the window's reads reached: distinct paths read over files at HEAD. Null when git cannot say. */
298 export async function addCoverage(
299 report: RepoReads,
300 dirOf: (repo: string) => string | null,
301 ): Promise<RepoReads> {
302 const dir = dirOf(report.repo);
303 if (dir === null) return { ...report, files: null, coverage: null };
304 const listed = await runGit(["ls-tree", "-r", "--name-only", "HEAD"], { cwd: dir });
305 if (!listed.ok) return { ...report, files: null, coverage: null };
306 const files = listed.value === "" ? 0 : listed.value.split("\n").length;
307 return {
308 ...report,
309 files,
310 coverage: files === 0 ? null : Math.min(1, report.paths / files),
311 };
312 }
src/tui/screens/entries.ts +2
@@ -44,6 +44,7 @@ import { entry as profile } from "./profile.ts";
4444 import { entry as quotas } from "./quotas.ts";
4545 import { entry as resilience } from "./resilience.ts";
4646 import { entry as scorecard } from "./scorecard.ts";
47 import { entry as scrapes } from "./scrapes.ts";
4748 import { entry as secrets } from "./secrets.ts";
4849 import { entry as sentinel } from "./sentinel.ts";
4950 import { entry as subscriptions } from "./subscriptions.ts";
@@ -90,6 +91,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
9091 people,
9192 quotas,
9293 limits,
94 scrapes,
9395 teams,
9496 orgTeams,
9597 orgGrants,
src/tui/screens/scrapes.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * What was scraped, in the terminal, a forge admin's who is a person: the last
3 * day's reads by repository (paths, commits, key-looking files) and the groups
4 * of requests a running capture sees, as /settings/traffic/scrapes shows them.
5 * Read-only: a capture is started and exported on the page, where the abuse
6 * report is a download.
7 */
8
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import { groupRequests } from "../../perf/actors.ts";
12 import { captureStore, readLedger } from "../../perf/live.ts";
13 import { readsReport } from "../../perf/reads.ts";
14 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { ListState, drawList, type ListRow } from "./common.ts";
19 import type { PaletteEntry } from "./entries.ts";
20
21 const may = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
22
23 export function scrapesScreen(f: Session, ctx: Ctx): Screen {
24 const state = new ListState();
25 const lines = (): ListRow[] => {
26 if (!may(f)) return [];
27 const rows: ListRow[] = readsReport(f.app.db, readLedger, Date.now(), 1).map((r) => ({
28 title: r.repo,
29 meta: `${r.requests} reads (${r.anonymous} anonymous) · ${r.paths} paths${r.secrets.length > 0 ? ` · ${r.secrets.length} key-looking: ${r.secrets.map((s) => s.path).slice(0, 3).join(", ")}` : ""}`,
30 }));
31 const cap = captureStore.current(Date.now());
32 if (cap !== null)
33 for (const g of groupRequests(cap.entries).filter((x) => x.size >= 3))
34 rows.push({ title: `Group ${g.id}: ${g.size} requests from ${g.addresses.length} addresses`, meta: `confidence ${g.confidence}` });
35 return rows;
36 };
37 return {
38 title: "Scrapes",
39 keys: [binding(["j", "k"], "move cursor")],
40 render(area, s) {
41 const c = ctx.theme.c;
42 const [head, list] = split(area, "v", [len(1), fill(1)]);
43 s.text(head!.x, head!.y, truncate("reads in the last day, and groups in a running capture; networks and the abuse export are on the page", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
44 drawList(s, list!, c, state, lines());
45 },
46 key(e) {
47 return state.key(e, lines().length);
48 },
49 };
50 }
51
52 export const entry: PaletteEntry = {
53 id: "scrapes",
54 title: "Scrapes",
55 hint: "what was read by repository, and groups in a running capture",
56 after: ["crawlers", "limits"],
57 offered: may,
58 run: (session, ctx) => ctx.push(scrapesScreen(session, ctx)),
59 };
src/web/pages/scrapes.ts added +302
@@ -0,0 +1,302 @@
1 /**
2 * /settings/traffic/scrapes: what was read, who it was, and whether one actor
3 * sat behind several addresses (`perf/reads.ts`, `perf/network.ts`,
4 * `perf/capture.ts`, `perf/actors.ts`). Admin only. Full addresses appear
5 * here and in the export only while a capture holds them. Every statement
6 * of identity stops at a network or a group, and says so.
7 */
8
9 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { repoDir } from "../../git/repos.ts";
12 import { perfRecord, captureStore, readLedger } from "../../perf/live.ts";
13 import { groupRequests, type Group } from "../../perf/actors.ts";
14 import { CAPTURE_MAX, CAPTURE_MS } from "../../perf/capture.ts";
15 import { networkTable, parseNetworks, saveNetworks, type Network, type NetworkTable } from "../../perf/network.ts";
16 import { addCoverage, isCommit, READ_DAYS_MAX, readsReport, type RepoReads } from "../../perf/reads.ts";
17 import { trafficHistory } from "../../perf/traffic_store.ts";
18 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
19 import { field, readForm } from "../form.ts";
20 import { html, type Html } from "../html.ts";
21 import { flashTo, page } from "../layout.ts";
22 import { badge, btn, card, empty, seg } from "../ui.ts";
23 import { accountSections } from "./origins.ts";
24
25 const DAYS = [1, 3, 7] as const;
26 const SHOWN_REQUESTS = 100;
27 const BASE = "/settings/traffic/scrapes";
28
29 /** What the evidence cannot show, said wherever a finding is. */
30 export const LIMITS =
31 "This shows a network and a pattern of requests. It does not show who is behind either: a network's name is what the forge's own table says, requests that look alike may come from different people, and nothing here is proof of who did what.";
32
33 const days = (raw: string | null): number => {
34 const n = Number(raw);
35 return (DAYS as readonly number[]).includes(n) ? n : 1;
36 };
37
38 function admin(ctx: Ctx): Response | null {
39 if (!ctx.auth.user) return signInFirst(ctx);
40 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
41 return null;
42 }
43
44 function whose(n: Network | null): Html {
45 if (n === null)
46 return html`<span class="muted">No network on file: the evidence stops at this address range.</span>`;
47 return html`${n.name}${n.operator ? html`, run by ${n.operator}` : null} (${n.country})${n.scope === "part"
48 ? html` <span class="muted">— covers only part of this range</span>`
49 : null}`;
50 }
51
52 function readsCard(r: RepoReads, from: number): Html {
53 const commits = r.commits.filter((c) => isCommit(c.ref));
54 return card(
55 html`<p class="small">
56 <strong class="break">${r.repo}</strong>: ${r.requests} reads, ${r.anonymous} anonymous,
57 ${r.paths} distinct paths.
58 ${r.coverage === undefined || r.coverage === null
59 ? html`<span class="muted">Coverage unknown.</span>`
60 : html`About ${Math.round(r.coverage * 100)}% of its ${r.files} files were read in the last ${from}
61 ${from === 1 ? "day" : "days"}.`}
62 </p>
63 ${r.secrets.length === 0
64 ? null
65 : html`<p class="small">
66 ${badge("Looks like a secret", { tone: "danger" })} Files read whose names look like keys or
67 credentials (a guess from the name; the contents were not looked at):
68 </p>
69 <ul class="small">
70 ${r.secrets.map((p) => html`<li class="break">${p.path} — ${p.requests}</li>`)}
71 </ul>`}
72 <p class="small muted mb">Most read:</p>
73 <ul class="small">
74 ${r.topPaths.map((p) => html`<li class="break">${p.path} — ${p.requests} (${p.anonymous} anonymous)</li>`)}
75 </ul>
76 ${commits.length === 0
77 ? null
78 : html`<p class="small muted">Commits read:
79 ${commits.map((c) => html`<code>${c.ref.slice(0, 12)}</code> ${c.requests} `)}</p>`}`,
80 );
81 }
82
83 function captureCard(now: number): Html {
84 const c = captureStore.current(now);
85 if (c === null)
86 return card(
87 html`<p class="small">
88 A capture keeps full addresses and the shape of requests so one actor can be told across addresses.
89 It lasts ${CAPTURE_MS / 60_000} minutes or ${CAPTURE_MAX} requests, keeps no cookies, tokens or
90 query strings, skips signed-in people, and is deleted when it ends.
91 </p>
92 <form method="post" action="${BASE}/capture" class="inline">${btn("Start a capture", { variant: "primary" })}</form>`,
93 );
94 return card(
95 html`<p class="small">
96 Capture <code>${c.id}</code> is running: ${c.entries.length} requests kept${c.full ? " (full: later requests are not kept)" : ""},
97 ends in ${Math.max(0, Math.ceil((c.expiresAt - now) / 60_000))} min and is then deleted.
98 <a href="${BASE}">Refresh</a>
99 </p>
100 <form method="post" action="${BASE}/stop" class="inline">${btn("Stop and delete it", { size: "sm" })}</form>`,
101 );
102 }
103
104 function groupRow(g: Group, net: NetworkTable): Html {
105 return html`<tr>
106 <td class="break">${g.ua || "(none)"}<br /><span class="muted small">${g.language}</span></td>
107 <td class="num">${g.size}</td>
108 <td class="num">${g.perMinute}/min</td>
109 <td class="num">${g.addresses.length}</td>
110 <td>${badge(g.confidence, { tone: g.confidence === "high" ? "danger" : "default" })}</td>
111 <td class="small">${g.prefixes.slice(0, 3).map((p) => html`<div class="break">${p}: ${whose(net.find(p))}</div>`)}</td>
112 <td><a href="${BASE}?group=${g.id}#group">Requests</a></td>
113 </tr>`;
114 }
115
116 function groupDetail(g: Group, net: NetworkTable): Html {
117 return card(
118 html`<h2 id="group">Group ${g.id}</h2>
119 <p class="small">${g.size} requests from ${g.addresses.length} addresses, confidence ${g.confidence}.</p>
120 <ul class="small">${g.reasons.map((r) => html`<li>${r}</li>`)}</ul>
121 <p class="small muted">${LIMITS}</p>
122 ${btn("Export for an abuse report", { href: `${BASE}/export?group=${g.id}`, size: "sm" })}
123 <div class="table-scroll">
124 <table class="table" data-dense>
125 <caption class="sr-only">Requests behind group ${g.id}</caption>
126 <thead><tr><th scope="col">Address</th><th scope="col">Request</th><th scope="col" class="num">Status</th><th scope="col">Network</th></tr></thead>
127 <tbody>
128 ${g.requests.slice(0, SHOWN_REQUESTS).map(
129 (r) => html`<tr>
130 <td class="break">${r.address}</td>
131 <td class="break">${r.method} ${r.path}</td>
132 <td class="num">${r.status}</td>
133 <td class="small">${whose(net.find(r.address))}</td>
134 </tr>`,
135 )}
136 </tbody>
137 </table>
138 </div>
139 ${g.size > SHOWN_REQUESTS ? html`<p class="small muted">First ${SHOWN_REQUESTS} of ${g.size} shown.</p>` : null}`,
140 );
141 }
142
143 export async function scrapesPage(ctx: Ctx): Promise<Response> {
144 const denied = admin(ctx);
145 if (denied) return denied;
146 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
147 const rendering = performance.now();
148 const now = Date.now();
149 const q = new URL(ctx.request.url).searchParams;
150 const d = days(q.get("days"));
151 const net = networkTable(ctx.app.db);
152 const reports = await Promise.all(
153 readsReport(ctx.app.db, readLedger, now, d)
154 .slice(0, 8)
155 .map((r) => {
156 const [owner, name] = r.repo.split("/");
157 return addCoverage(r, () =>
158 owner && name && /^[\w.-]+$/.test(owner) && /^[\w.-]+$/.test(name)
159 ? repoDir(ctx.app.reposRoot, owner, name)
160 : null,
161 );
162 }),
163 );
164 const prefixes = trafficHistory(ctx.app.db, perfRecord.ledger, now, "24h").tops.prefixes;
165 const cap = captureStore.current(now);
166 const groups = cap === null ? [] : groupRequests(cap.entries).filter((g) => g.size >= 3);
167 const picked = groups.find((g) => g.id === q.get("group"));
168 const body = html`<div class="mb">
169 ${seg(DAYS.map((n) => ({ label: n === 1 ? "1 day" : `${n} days`, href: `${BASE}?days=${n}`, current: n === d })))}
170 </div>
171 <h2>What was read</h2>
172 ${reports.length === 0
173 ? empty({ icon: "users", title: "No file reads recorded in this window", note: `Reads are kept up to ${READ_DAYS_MAX} days.` })
174 : reports.map((r) => readsCard(r, d))}
175 <h2>Who it is</h2>
176 ${card(
177 prefixes.length === 0
178 ? html`<p class="small muted">No address prefixes in the last 24 hours.</p>`
179 : html`<div class="table-scroll"><table class="table" data-dense>
180 <caption class="sr-only">Address prefixes and their networks</caption>
181 <thead><tr><th scope="col">Prefix</th><th scope="col" class="num">Requests</th><th scope="col">Network (from the forge's own table)</th><th scope="col">Abuse contact</th></tr></thead>
182 <tbody>${prefixes.map((p) => {
183 const n = net.find(p.key);
184 return html`<tr><td class="break">${p.key}</td><td class="num">${p.requests}</td><td class="small">${whose(n)}</td><td class="break small">${n?.abuse || "—"}</td></tr>`;
185 })}</tbody></table></div>`,
186 )}
187 <h2>One actor across addresses</h2>
188 ${captureCard(now)}
189 ${cap === null
190 ? null
191 : groups.length === 0
192 ? html`<p class="small muted">No group of three or more similar requests yet.</p>`
193 : card(html`<div class="table-scroll"><table class="table" data-dense>
194 <caption class="sr-only">Groups of requests that look alike</caption>
195 <thead><tr><th scope="col">User agent</th><th scope="col" class="num">Requests</th><th scope="col" class="num">Rate</th><th scope="col" class="num">Addresses</th><th scope="col">Confidence</th><th scope="col">Networks</th><th scope="col"></th></tr></thead>
196 <tbody>${groups.slice(0, 20).map((g) => groupRow(g, net))}</tbody></table></div>
197 <p class="small muted">Groups share a user agent and the names and order of their headers. ${LIMITS}</p>`)}
198 ${picked === undefined ? null : groupDetail(picked, net)}
199 <h2>Networks on file</h2>
200 ${card(html`<p class="small">${net.size} ranges. Lookups are local: no visitor address is sent to anyone. Paste lines of
201 <code>range, name, operator, country, abuse contact</code> from a registry you looked up yourself.</p>
202 <form method="post" action="${BASE}/networks">
203 <textarea name="lines" rows="4" maxlength="200000" aria-label="Network ranges" placeholder="203.0.0.0/16, Example Hosting, Example Ltd, NL, [email protected]"></textarea>
204 ${btn("Save networks", { size: "sm" })}
205 </form>`)}`;
206 notePhase(ctx.request, "render", performance.now() - rendering);
207 return page({
208 ctx,
209 title: "Scrapes",
210 body: html`<div class="page-head"><h1>Scrapes</h1></div>
211 ${settingsLayout(accountSections("traffic", { admin: true }), body, "Account settings")}`,
212 });
213 }
214
215 export async function captureStart(ctx: Ctx): Promise<Response> {
216 const denied = admin(ctx);
217 if (denied) return denied;
218 const user = ctx.auth.user!;
219 if (user.kind === "agent") return notFoundPage(ctx);
220 const form = await readForm(ctx, "admin");
221 if (form instanceof Response) return form;
222 const before = captureStore.current(Date.now());
223 const c = captureStore.start(Date.now(), user.username);
224 if (before === null)
225 ctx.app.bus.append({ kind: "traffic.capture_started", actorId: user.id, payload: { id: c.id, minutes: CAPTURE_MS / 60_000 } });
226 return flashTo(ctx, BASE, { message: `Capture ${c.id} is running.` });
227 }
228
229 export async function captureStop(ctx: Ctx): Promise<Response> {
230 const denied = admin(ctx);
231 if (denied) return denied;
232 const form = await readForm(ctx, "admin");
233 if (form instanceof Response) return form;
234 captureStore.stop();
235 return flashTo(ctx, BASE, { message: "Capture deleted." });
236 }
237
238 export async function networksImport(ctx: Ctx): Promise<Response> {
239 const denied = admin(ctx);
240 if (denied) return denied;
241 const user = ctx.auth.user!;
242 if (user.kind === "agent") return notFoundPage(ctx);
243 const form = await readForm(ctx, "admin", 300_000);
244 if (form instanceof Response) return form;
245 const got = parseNetworks(field(form, "lines"));
246 saveNetworks(ctx.app.db, got.rows, user.username, Date.now());
247 if (got.rows.length > 0)
248 ctx.app.bus.append({ kind: "traffic.networks_imported", actorId: user.id, payload: { count: got.rows.length } });
249 const note = got.refused.length === 0 ? "" : ` Refused: ${got.refused.slice(0, 3).join("; ")}.`;
250 return flashTo(ctx, BASE, {
251 ...(got.refused.length > 0 && got.rows.length === 0 ? { tone: "danger" as const } : {}),
252 message: `Saved ${got.rows.length} ranges.${note}`,
253 });
254 }
255
256 /** A plain-text report for one group: what was seen, the networks and their abuse contacts, and what it cannot show. */
257 export function reportOf(g: Group, net: NetworkTable, capture: { id: string; startedAt: number }): string {
258 const nets = new Map<string, Network | null>(g.prefixes.map((p) => [p, net.find(p)]));
259 const first = Math.min(...g.requests.map((r) => r.at));
260 const last = Math.max(...g.requests.map((r) => r.at));
261 return [
262 `Abuse report draft: group ${g.id} (capture ${capture.id})`,
263 `Window: ${new Date(first).toISOString()} to ${new Date(last).toISOString()}`,
264 `Requests: ${g.size} (${g.perMinute} per minute) from ${g.addresses.length} addresses. Confidence: ${g.confidence}.`,
265 `User agent: ${g.ua || "(none)"}`,
266 "",
267 "Why these requests were grouped:",
268 ...g.reasons.map((r) => `- ${r}`),
269 "",
270 "Networks (from the forge's own table; send to each abuse contact):",
271 ...[...nets].map(([p, n]) =>
272 n === null
273 ? `- ${p}: no network on file; the evidence stops here.`
274 : `- ${p}: ${n.name}${n.operator ? `, ${n.operator}` : ""} (${n.country}); abuse contact: ${n.abuse || "none on file"}`,
275 ),
276 "",
277 "Addresses:",
278 ...g.addresses.map((a) => `- ${a}`),
279 "",
280 "Sample requests:",
281 ...g.requests.slice(0, 50).map((r) => `${new Date(r.at).toISOString()} ${r.address} ${r.method} ${r.path} ${r.status}`),
282 "",
283 `Limits: ${LIMITS}`,
284 "",
285 ].join("\n");
286 }
287
288 export function scrapesExport(ctx: Ctx): Response {
289 const denied = admin(ctx);
290 if (denied) return denied;
291 const cap = captureStore.current(Date.now());
292 const id = new URL(ctx.request.url).searchParams.get("group");
293 const g = cap === null ? undefined : groupRequests(cap.entries).find((x) => x.id === id);
294 if (cap === null || g === undefined) return notFoundPage(ctx);
295 return new Response(reportOf(g, networkTable(ctx.app.db), cap), {
296 headers: {
297 "content-type": "text/plain; charset=utf-8",
298 "content-disposition": `attachment; filename="abuse-report-${g.id}.txt"`,
299 "cache-control": "no-store",
300 },
301 });
302 }
src/web/pages/traffic.ts +3
@@ -171,6 +171,9 @@ export function trafficPage(ctx: Ctx): Response {
171171 ${top("Address prefix", tops.prefixes, t)}
172172 ${top("Country", tops.countries, t)}
173173 ${top("Request signature", tops.signatures, t)}
174 <p class="mt">
175 <a href="/settings/traffic/scrapes">What was scraped, and who: reads, networks, captures</a>
176 </p>
174177 <p class="small muted mt">
175178 ${chosen === "15m"
176179 ? KPI_COUNTS.traffic
test/design-audit.test.ts +1 −1
@@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1515
1616 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1717 const NOT_PAGES =
18 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/;
18 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/;
1919
2020 const getRoutes = [
2121 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/palette-roster.test.ts +1
@@ -94,6 +94,7 @@ test("a site-admin person is offered the whole roster", () => {
9494 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
9595 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
9696 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
97 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
9798 ["api", "API", "the forge's endpoints; run a GET"],
9899 ["changelog", "Changelog", "the last 30 releases and their pulls"],
99100 ["releases", "Releases", "the live release and the kept ones"],
test/scrape-evidence.test.ts added +225
@@ -0,0 +1,225 @@
1 /**
2 * What was scraped and who did it: reads by repository and path with secrets
3 * flagged, networks from the forge's own table, requests grouped across
4 * addresses with a stated confidence, and a bounded capture that keeps no
5 * secrets and expires (#9406).
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { wasViaEdge, notePeer } from "../src/http/peer.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { groupRequests } from "../src/perf/actors.ts";
17 import { botScoreOf, CAPTURE_MAX, CAPTURE_MS, createCaptureStore } from "../src/perf/capture.ts";
18 import { captureStore } from "../src/perf/live.ts";
19 import { networkTable, parseNetworks, saveNetworks } from "../src/perf/network.ts";
20 import { createReadLedger, createReadWriter, HOUR_KEYS_MAX, OTHER_PATH, readOf, readsReport, secretPath } from "../src/perf/reads.ts";
21
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 let admin: User;
26 let bob: User;
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-scrapes-"));
30 app = createApp({ dataDir, port: 0 });
31 servers = await serve(app);
32 const person = async (username: string, isAdmin = false): Promise<User> => {
33 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "correct-horse-battery", isAdmin });
34 if (!made.ok) throw new Error(made.error);
35 return made.value;
36 };
37 admin = await person("keeper", true);
38 bob = await person("bob");
39 });
40 afterAll(async () => {
41 captureStore.stop();
42 servers.stop();
43 app.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 const NOW = 1_800_000_000_000;
48 const HOUR = 3_600_000;
49 const input = (path: string, address: string, headers: Record<string, string> = {}, cls: "deep" | "people" = "deep", pattern = "/:owner/:name/blob/:ref/*", viaEdge = true) => ({
50 request: new Request(`http://forge.test${path}`, { headers: { "user-agent": "ScrapeBot/1.0", ...headers } }),
51 address,
52 viaEdge,
53 status: 200,
54 ms: 5,
55 cls,
56 pattern,
57 });
58
59 test("a file read names repository, ref and path; other routes read nothing", () => {
60 expect(readOf("/:owner/:name/blob/:ref/*", "/alice/open/blob/main/src/a%20b.ts")).toEqual({ repo: "alice/open", ref: "main", path: "src/a b.ts" });
61 expect(readOf("/:owner/:name/commit/:sha", "/alice/open/commit/abc1234")?.ref).toBe("abc1234");
62 expect(readOf("/:owner/:name/issues", "/alice/open/issues")).toBeNull();
63 expect(secretPath(".env")).toBe(true);
64 expect(secretPath("deploy/id_rsa")).toBe(true);
65 expect(secretPath("src/main.ts")).toBe(false);
66 });
67
68 test("reads are reported over a window of at most 7 days, secrets flagged, and no address kept", () => {
69 const ledger = createReadLedger();
70 ledger.note(NOW, { repo: "alice/open", ref: "abc1234", path: ".env" }, true);
71 ledger.note(NOW, { repo: "alice/open", ref: "main", path: "src/a.ts" }, false);
72 ledger.note(NOW - 2 * 24 * HOUR, { repo: "alice/open", ref: "main", path: "old.ts" }, true);
73 const day = readsReport(app.db, ledger, NOW, 1);
74 expect(day[0]?.requests).toBe(2);
75 expect(day[0]?.secrets.map((s) => s.path)).toEqual([".env"]);
76 expect(day[0]?.commits[0]?.ref).toBe("abc1234");
77 expect(readsReport(app.db, ledger, NOW, 3)[0]?.requests).toBe(3);
78 expect(readsReport(app.db, ledger, NOW, 99)[0]?.requests).toBe(3); // cut to 7 days
79 });
80
81 test("a network is found in the forge's own table, most specific first, and absence is absence", () => {
82 const parsed = parseNetworks(
83 "# comment\n203.0.0.0/8, Wide, Wide Inc, us, [email protected]\n203.0.0.0/16, Narrow, Narrow Ltd, NL, https://narrow.example/abuse\nbad line\n10.0.0.0/8, X, X, USA, [email protected]\n1.2.0.0/16, Y, Y, DE, not a contact",
84 );
85 expect(parsed.rows).toHaveLength(2);
86 expect(parsed.refused).toHaveLength(3);
87 saveNetworks(app.db, parsed.rows, "keeper", NOW);
88 const table = networkTable(app.db);
89 expect(table.find("203.0.0.0/16")).toMatchObject({ name: "Narrow", country: "NL", scope: "whole" });
90 expect(table.find("203.5.0.0/16")).toMatchObject({ name: "Wide", scope: "whole" });
91 expect(table.find("198.51.0.0/16")).toBeNull();
92 expect(table.find("203.0.113.9")?.abuse).toBe("https://narrow.example/abuse");
93 });
94
95 test("a capture is bounded, keeps no cookie, token or query, skips people, and expires by itself", () => {
96 const store = createCaptureStore();
97 expect(store.active(NOW)).toBe(false);
98 store.start(NOW, "keeper");
99 store.note(NOW, input("/alice/open/blob/main/a.ts?token=SECRET", "203.0.113.9", { cookie: "forge_session=SECRET", authorization: "Bearer SECRET", "cf-bot-score": "5" }));
100 store.note(NOW, input("/alice/open", "198.51.100.1", {}, "people"));
101 const c = store.current(NOW)!;
102 expect(c.entries).toHaveLength(1);
103 const json = JSON.stringify(c);
104 expect(json).not.toContain("SECRET");
105 expect(c.entries[0]).toMatchObject({ prefix: "203.0.0.0/16", botScore: 5, address: "203.0.113.9" });
106 expect(c.entries[0]!.shape).not.toContain("cookie");
107 for (let i = 0; i < CAPTURE_MAX + 5; i++) store.note(NOW, input("/x", "203.0.113.9"));
108 expect(store.current(NOW)!.entries).toHaveLength(CAPTURE_MAX);
109 expect(store.current(NOW)!.full).toBe(true);
110 expect(store.current(NOW + CAPTURE_MS)).toBeNull(); // expired: addresses gone
111 expect(store.active(NOW + CAPTURE_MS)).toBe(false);
112 store.note(NOW + CAPTURE_MS, input("/x", "203.0.113.9"));
113 expect(store.current(NOW + CAPTURE_MS + 1)).toBeNull();
114 expect(botScoreOf(new Headers({ "cf-bot-score": "150" }))).toBeNull();
115 });
116
117 test("requests that look alike are one group across addresses, with reasons and a confidence; a lone address is not called an actor", () => {
118 const store = createCaptureStore();
119 store.start(NOW, "keeper");
120 for (let i = 0; i < 24; i++)
121 store.note(NOW + i * 1000, input(`/alice/open/commit/${(0xabc1230 + i).toString(16)}`, `203.0.113.${i % 4}`, { "cf-bot-score": "3", "accept-language": "xx" }, "deep", "/:owner/:name/commit/:sha"));
122 store.note(NOW, { ...input("/", "198.51.100.7", { "user-agent": "Other" }) });
123 const groups = groupRequests(store.current(NOW + 30_000)!.entries);
124 expect(groups).toHaveLength(2);
125 const big = groups[0]!;
126 expect(big.size).toBe(24);
127 expect(big.addresses).toHaveLength(4);
128 expect(big.cadence).toBe("steady");
129 expect(big.commits.length).toBeGreaterThanOrEqual(3);
130 expect(big.confidence).toBe("high");
131 expect(big.reasons.join(" ")).toContain("bot score");
132 expect(groups[1]!.confidence).toBe("low");
133 expect(groups[1]!.reasons.join(" ")).toContain("Only one address");
134 });
135
136 test("the page, capture, import and export are an admin's and need a same-origin post", async () => {
137 const cookie = `forge_session=${createSession(app.db, admin.id).token}`;
138 const other = `forge_session=${createSession(app.db, bob.id).token}`;
139 const url = `${servers.url}/settings/traffic/scrapes`;
140 expect((await fetch(url, { headers: { cookie: other } })).status).toBe(404);
141 const page = await fetch(url, { headers: { cookie } });
142 expect(page.status).toBe(200);
143 const text = await page.text();
144 expect(text).toContain("Start a capture");
145 expect(text).toContain("What was read");
146
147 const post = (path: string, body: string, origin = servers.url) =>
148 fetch(`${url}/${path}`, { method: "POST", redirect: "manual", headers: { cookie, origin, "content-type": "application/x-www-form-urlencoded" }, body });
149 expect((await post("capture", "", "https://evil.example")).status).toBe(403);
150 expect(captureStore.active(Date.now())).toBe(false);
151 expect((await post("capture", "")).status).toBe(303);
152 expect(captureStore.active(Date.now())).toBe(true);
153 expect((await post("networks", `lines=${encodeURIComponent("203.0.0.0/16, <b>Evil</b>, Op, DE, [email protected]")}`)).status).toBe(303);
154
155 for (let i = 0; i < 12; i++) captureStore.note(Date.now(), input(`/alice/open/blob/main/f${i}.ts`, `203.0.113.${i}`));
156 const shown = await (await fetch(url, { headers: { cookie } })).text();
157 const id = /group=([0-9a-f]{8})/.exec(shown)?.[1];
158 expect(id).toBeDefined();
159 expect(shown).toContain("&lt;b&gt;Evil&lt;/b&gt;");
160 expect(shown).not.toContain("<b>Evil</b>");
161 const detail = await (await fetch(`${url}?group=${id}`, { headers: { cookie } })).text();
162 expect(detail).toContain("203.0.113.3");
163 expect(detail).toContain("does not show who is behind");
164
165 const report = await fetch(`${url}/export?group=${id}`, { headers: { cookie } });
166 expect(report.headers.get("content-type")).toContain("text/plain");
167 const reportText = await report.text();
168 expect(reportText).toContain("[email protected]");
169 expect(reportText).toContain("Limits:");
170 expect((await fetch(`${url}/export?group=${id}`, { headers: { cookie: other } })).status).toBe(404);
171
172 expect((await post("stop", "")).status).toBe(303);
173 expect(captureStore.current(Date.now())).toBeNull();
174 expect((await fetch(`${url}/export?group=${id}`, { headers: { cookie } })).status).toBe(404);
175 });
176
177 test("a stored hour holds at most HOUR_KEYS_MAX distinct reads however many minutes wrote it; the rest fold into (others)", () => {
178 const ledger = createReadLedger();
179 const writer = createReadWriter(app.db, ledger, { warn() {} }, () => NOW, 3_600_000);
180 const hour = Math.floor((NOW + 5 * HOUR) / HOUR);
181 const at = hour * HOUR;
182 for (let minute = 0; minute < 4; minute++) {
183 for (let i = 0; i < HOUR_KEYS_MAX; i++) ledger.note(at, { repo: "carol/wide", ref: "main", path: `m${minute}/f${i}.ts` }, true);
184 writer.flush();
185 }
186 writer.stop();
187 const n = app.db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM read_hour WHERE hour = ?1").get(hour)!.n;
188 expect(n).toBeLessThanOrEqual(HOUR_KEYS_MAX + 1);
189 const folded = app.db.query<{ requests: number }, [number, string]>("SELECT requests FROM read_hour WHERE hour = ?1 AND path = ?2").get(hour, OTHER_PATH)!;
190 expect(folded.requests).toBe(3 * HOUR_KEYS_MAX);
191 // nothing is lost from the totals
192 expect(readsReport(app.db, createReadLedger(), at, 1).find((r) => r.repo === "carol/wide")?.requests).toBe(4 * HOUR_KEYS_MAX);
193 });
194
195 test("a bot score and country are believed only from a trusted edge", () => {
196 const store = createCaptureStore();
197 store.start(NOW, "keeper");
198 store.note(NOW, input("/x", "203.0.113.1", { "cf-bot-score": "5", "cf-ipcountry": "NL" }, "deep", "/:owner/:name/blob/:ref/*", false));
199 store.note(NOW, input("/x", "203.0.113.2", { "cf-bot-score": "5", "cf-ipcountry": "NL" }));
200 const [forged, edged] = store.current(NOW)!.entries;
201 expect(forged).toMatchObject({ botScore: null, country: null });
202 expect(edged).toMatchObject({ botScore: 5, country: "NL" });
203 const viaProxy = new Request("http://forge.test/");
204 notePeer(viaProxy, "127.0.0.1", { proxies: [] });
205 const direct = new Request("http://forge.test/", { headers: { "cf-bot-score": "5" } });
206 notePeer(direct, "198.51.100.9", { proxies: [] });
207 expect(wasViaEdge(viaProxy) || wasViaEdge(direct)).toBe(false); // loopback with no client named is the forge itself; a stranger is no edge
208 const named = new Request("http://forge.test/", { headers: { "cf-connecting-ip": "203.0.113.5" } });
209 notePeer(named, "127.0.0.1", { proxies: [] });
210 expect(wasViaEdge(named)).toBe(true);
211 });
212
213 test("an agent account, even an admin's, cannot start a capture or import networks", async () => {
214 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "botty", email: "[email protected]", password: "correct-horse-battery", isAdmin: true });
215 if (!made.ok) throw new Error(made.error);
216 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(made.value.id);
217 const cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
218 const url = `${servers.url}/settings/traffic/scrapes`;
219 const post = (path: string, body = "") =>
220 fetch(`${url}/${path}`, { method: "POST", redirect: "manual", headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, body });
221 expect((await post("capture")).status).toBe(404);
222 expect(captureStore.active(Date.now())).toBe(false);
223 expect((await post("networks", "lines=" + encodeURIComponent("198.51.100.0/24, A, B, DE, [email protected]"))).status).toBe(404);
224 expect(networkTable(app.db).find("198.51.100.5")).toBeNull();
225 });
test/server-timing-sweep.test.ts +1
@@ -29,6 +29,7 @@ const NOT_PAGES = new Set([
2929 "/system/stream",
3030 "/settings/teams/stream",
3131 "/fleet/graph/live",
32 "/settings/traffic/scrapes/export",
3233 "/settings/backups/live",
3334 "/:owner/:name/stream",
3435 ]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs