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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9406-an-admin-can-see-what-was-scraped-and-wo → main 5 commits fork 3 threads · 3 unresolved
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`, | ||
| 137 | 137 | - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen. |
| 138 | 138 | - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen. |
| 139 | 139 | - `/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. | |
| 140 | 141 | |
| 141 | 142 | ## Operator views — why each is a page (#2696) |
| 142 | 143 |
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([ | ||
| 165 | 165 | "issue.confirmed", |
| 166 | 166 | "capacity.changed", |
| 167 | 167 | "shield.changed", |
| 168 | "traffic.capture_started", | |
| 169 | "traffic.networks_imported", | |
| 168 | 170 | "crew.governed", |
| 169 | 171 | ]); |
| 170 | 172 |
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( | ||
| 61 | 61 | |
| 62 | 62 | const clients = new WeakMap<Request, string>(); |
| 63 | 63 | const internals = new WeakSet<Request>(); |
| 64 | const edged = new WeakSet<Request>(); | |
| 64 | 65 | |
| 65 | 66 | /** Headers a proxy names a client in. The tunnel always sets its own, so no stranger can leave them all out. */ |
| 66 | 67 | const CLIENT_HEADERS = ["cf-connecting-ip", "x-forwarded-for", "x-real-ip", "forwarded"]; |
| @@ -85,8 +86,13 @@ export const notePeer = ( | ||
| 85 | 86 | ): void => { |
| 86 | 87 | clients.set(request, resolveClient(address, request.headers, trust)); |
| 87 | 88 | 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); | |
| 88 | 91 | }; |
| 89 | 92 | |
| 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 | ||
| 90 | 96 | export const wasInternal = (request: Request): boolean => internals.has(request); |
| 91 | 97 | |
| 92 | 98 | export function clientAddress(request: Request): string { |
src/http/server.ts +16 −3
| @@ -458,14 +458,15 @@ import { | ||
| 458 | 458 | takeDbTime, |
| 459 | 459 | type RouteTiming, |
| 460 | 460 | } 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"; | |
| 463 | 463 | import { createTrafficWriter } from "../perf/traffic_store.ts"; |
| 464 | import { createReadWriter, readOf } from "../perf/reads.ts"; | |
| 464 | 465 | import { parseServerTiming } from "../perf/record.ts"; |
| 465 | 466 | import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts"; |
| 466 | 467 | import { bearerChallenge, oauthRoutes } from "./oauth.ts"; |
| 467 | 468 | 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"; | |
| 469 | 470 | import { isProbe } from "../kernel/probe.ts"; |
| 470 | 471 | import { takeStrangerRead } from "./stranger_reads.ts"; |
| 471 | 472 | import { parseCidrs } from "../kernel/cidr.ts"; |
| @@ -2229,6 +2230,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2229 | 2230 | const timings = new Map<Database, RouteTiming>(); |
| 2230 | 2231 | // The minute ledger's history: one write a minute, never on a request. |
| 2231 | 2232 | const trafficWriter = createTrafficWriter(app.db, perfRecord.ledger, app.log); |
| 2233 | const readWriter = createReadWriter(app.db, readLedger, app.log); | |
| 2232 | 2234 | const counted = ( |
| 2233 | 2235 | owner: App, |
| 2234 | 2236 | request: Request, |
| @@ -2255,6 +2257,16 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2255 | 2257 | }, |
| 2256 | 2258 | ); |
| 2257 | 2259 | 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 }); | |
| 2258 | 2270 | } catch { |
| 2259 | 2271 | // A response the forge is only carrying keeps the headers it came with. |
| 2260 | 2272 | } |
| @@ -2492,6 +2504,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2492 | 2504 | } |
| 2493 | 2505 | timings.clear(); |
| 2494 | 2506 | trafficWriter.stop(); |
| 2507 | readWriter.stop(); | |
| 2495 | 2508 | // A child's hooks stop answering before its database closes. |
| 2496 | 2509 | for (const key of [...new Set([...childHttps.keys(), ...childInternals.keys()])]) letGo(key); |
| 2497 | 2510 | closeHostedChildren(app); |
src/http/settings_routes.ts +6
| @@ -20,6 +20,7 @@ import { usagePage } from "../web/pages/usage.ts"; | ||
| 20 | 20 | import { spendPage } from "../web/pages/spend-tab.ts"; |
| 21 | 21 | import { securityPage, securityShieldSet } from "../web/pages/security.ts"; |
| 22 | 22 | import { trafficPage } from "../web/pages/traffic.ts"; |
| 23 | import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts"; | |
| 23 | 24 | import { |
| 24 | 25 | accountAct, |
| 25 | 26 | retireAct, |
| @@ -189,6 +190,11 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 189 | 190 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); |
| 190 | 191 | router.add("GET", "/settings/spend", spendPage); |
| 191 | 192 | 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); | |
| 192 | 198 | router.add("GET", "/settings/security", securityPage); |
| 193 | 199 | router.add("POST", "/settings/security/shield", securityShieldSet); |
| 194 | 200 | 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 @@ | ||
| 3 | 3 | import { createBootStall } from "./boot_stall.ts"; |
| 4 | 4 | import { createGitRecord } from "./git.ts"; |
| 5 | 5 | import { createLoopWatch } from "./loop.ts"; |
| 6 | import { createCaptureStore } from "./capture.ts"; | |
| 7 | import { createReadLedger } from "./reads.ts"; | |
| 6 | 8 | import { createPerfRecord } from "./record.ts"; |
| 7 | 9 | |
| 8 | 10 | export const perfRecord = createPerfRecord(); |
| @@ -10,3 +12,6 @@ export const loopWatch = createLoopWatch(); | ||
| 10 | 12 | export const gitRecord = createGitRecord(); |
| 11 | 13 | /** The worst stall in this process's first five minutes, read by the release keeper (#8848). */ |
| 12 | 14 | 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"; | ||
| 44 | 44 | import { entry as quotas } from "./quotas.ts"; |
| 45 | 45 | import { entry as resilience } from "./resilience.ts"; |
| 46 | 46 | import { entry as scorecard } from "./scorecard.ts"; |
| 47 | import { entry as scrapes } from "./scrapes.ts"; | |
| 47 | 48 | import { entry as secrets } from "./secrets.ts"; |
| 48 | 49 | import { entry as sentinel } from "./sentinel.ts"; |
| 49 | 50 | import { entry as subscriptions } from "./subscriptions.ts"; |
| @@ -90,6 +91,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 90 | 91 | people, |
| 91 | 92 | quotas, |
| 92 | 93 | limits, |
| 94 | scrapes, | |
| 93 | 95 | teams, |
| 94 | 96 | orgTeams, |
| 95 | 97 | 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 { | ||
| 171 | 171 | ${top("Address prefix", tops.prefixes, t)} |
| 172 | 172 | ${top("Country", tops.countries, t)} |
| 173 | 173 | ${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> | |
| 174 | 177 | <p class="small muted mt"> |
| 175 | 178 | ${chosen === "15m" |
| 176 | 179 | ? KPI_COUNTS.traffic |
test/design-audit.test.ts +1 −1
| @@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | 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\/)/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...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", () => { | ||
| 94 | 94 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 95 | 95 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 96 | 96 | ["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"], | |
| 97 | 98 | ["api", "API", "the forge's endpoints; run a GET"], |
| 98 | 99 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 99 | 100 | ["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("<b>Evil</b>"); | |
| 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([ | ||
| 29 | 29 | "/system/stream", |
| 30 | 30 | "/settings/teams/stream", |
| 31 | 31 | "/fleet/graph/live", |
| 32 | "/settings/traffic/scrapes/export", | |
| 32 | 33 | "/settings/backups/live", |
| 33 | 34 | "/:owner/:name/stream", |
| 34 | 35 | ]); |
new above
offline
Duplicate imports from perf/live.ts and perf/traffic.ts: merge into the existing import lines.