From 1738d0bedc813cbe8a021888164072b30e23f152 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 15:15:47 +0000 Subject: [PATCH] feat: customer webhooks via @profullstack/webhooks 0.2.1 (0.2.0) target.verified, run.started, run.completed, run.failed, run.refused and finding.created, delivered as signed CloudEvents to the account owning the target. Findings carry severity + title only, never the probe payload. /api/v1/webhooks behind the existing API-key auth, /account#webhooks UI, CLI webhooks/webhook, MCP webhook tools. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++- bun.lock | 3 ++ package.json | 21 ++++++-- src/cli.ts | 21 +++++++- src/client.ts | 7 +++ src/html.ts | 12 ++++- src/mcp.ts | 11 ++++ src/server.ts | 61 ++++++++++++++++++++-- src/service.ts | 48 +++++++++++++++-- src/version.ts | 2 +- src/worker.ts | 14 ++++- test/webhooks.test.ts | 117 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 test/webhooks.test.ts diff --git a/README.md b/README.md index c4db191..21a777a 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,19 @@ authorization log row. See [the manifesto](https://auto.hacker.pit.world/manifes - **Web** (PWA): add target, proof + verify, launch scan, live SSE run stream, history, CSV/JSON export, account/profile. - **API**: `GET/POST /api/v1/targets`, `/targets/:id/verify`, `POST /api/v1/runs`, `/runs/:id`, `/runs/:id/stream` (SSE), `/runs/:id/export.{json,csv}`, `GET /api/v1/me/profile` (401 logged out). Bearer `ah_…`. -- **CLI**: `autohacker add|proof|verify|scan|runs|report|targets`. -- **MCP**: `autohacker mcp` (add_target, get_proof, verify, scan, runs, report, targets). +- **CLI**: `autohacker add|proof|verify|scan|runs|report|targets|webhooks|webhook`. +- **MCP**: `autohacker mcp` (add_target, get_proof, verify, scan, runs, report, targets, list_webhooks, create_webhook, test_webhook, rotate_webhook_secret, delete_webhook, webhook_deliveries). - **TUI**: `autohacker tui`. +## Webhooks + +Customer webhooks via [`@profullstack/webhooks`](https://github.com/profullstack/webhooks): add a URL on +`/account#webhooks`, with `autohacker webhook add [events...]`, MCP `create_webhook`, or +`POST /api/v1/webhooks`. auto.hacker POSTs signed CloudEvents (Standard Webhooks) for `target.verified`, +`run.started`, `run.completed`, `run.failed`, `run.refused` and `finding.created`, to the account that owns +the target, retried for about three days. `finding.created` carries severity and the check's title only, +never the probe payload, probed URL or raw details. + ## Realtime + scheduled A run streams live over SSE. A verified host can be subscribed to periodic diff --git a/bun.lock b/bun.lock index fbcb0df..9e9e5e8 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@profullstack/footer": "^0.3.0", "@profullstack/hqtui": "^0.10.3", "@profullstack/profile": "^0.1.6", + "@profullstack/webhooks": "0.2.1", "hono": "^4.13.13", "zod": "^3.25.0", }, @@ -32,6 +33,8 @@ "@profullstack/profile": ["@profullstack/profile@0.1.6", "", { "peerDependencies": { "@libsql/client": ">=0.6.0", "postgres": "^3.4.0", "react": ">=18" }, "optionalPeers": ["@libsql/client", "postgres", "react"], "bin": { "profile": "src/cli.js" } }, "sha512-xkn0P3ANz151QJdlBwcBXkHk813qW9qAmuTZKcuvpEZYkyHDC9J9KfvpVSRepfjECzz4ONGHWO5Zwc4BHpfK8g=="], + "@profullstack/webhooks": ["@profullstack/webhooks@0.2.1", "", {}, "sha512-QHeSkpu4O44oJ620qhQqxqFlD3u/OhupmXRnBHw4MGKuDTa0R+Y/727nnF7psSHxxSfnbWfNuVl15z3IXuaZiw=="], + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], diff --git a/package.json b/package.json index c703bcc..223a937 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,21 @@ { "name": "@profullstack/autohacker", - "version": "0.1.0", + "version": "0.2.0", "description": "auto.hacker: authorized, non-destructive website pentest service. Scan only sites you have PROVEN you own (DNS TXT, a /.well-known file, or a meta tag). Powered by the ThreatCrush engine. Web (PWA), API, CLI, TUI and MCP, with realtime SSE run streaming and scheduled email summaries.", "license": "MIT", "type": "module", "homepage": "https://auto.hacker.pit.world", - "repository": { "type": "git", "url": "git+https://github.com/moshcoder/auto.hacker.git" }, - "bin": { "autohacker": "bin/autohacker" }, + "repository": { + "type": "git", + "url": "git+https://github.com/moshcoder/auto.hacker.git" + }, + "bin": { + "autohacker": "bin/autohacker" + }, "files": ["bin/autohacker", "src", "public", "OpenStack.md", "README.md", "LICENSE"], - "engines": { "bun": ">=1.1" }, + "engines": { + "bun": ">=1.1" + }, "scripts": { "start": "bun src/server.ts", "dev": "bun --watch src/server.ts", @@ -21,8 +28,12 @@ "@profullstack/footer": "^0.3.0", "@profullstack/hqtui": "^0.10.3", "@profullstack/profile": "^0.1.6", + "@profullstack/webhooks": "0.2.1", "hono": "^4.13.13", "zod": "^3.25.0" }, - "devDependencies": { "@types/bun": "^1.4.3", "typescript": "^7.0.2" } + "devDependencies": { + "@types/bun": "^1.4.3", + "typescript": "^7.0.2" + } } diff --git a/src/cli.ts b/src/cli.ts index 45dc102..9e98152 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,10 @@ * autohacker runs list recent runs * autohacker report print a run's findings * autohacker targets list targets and their state + * autohacker webhooks your webhook endpoints and the events sent + * autohacker webhook add [events...] POST scan events to your URL (prints the secret) + * autohacker webhook test|rotate|rm send a test, replace the secret, or remove it + * autohacker webhook log [id] recent deliveries * * Config: AUTOHACKER_URL (default https://auto.hacker.pit.world), AUTOHACKER_API_KEY. */ @@ -60,10 +64,25 @@ export async function main(argv = process.argv.slice(2)): Promise { case 'runs': { const { runs } = await c.runs(); for (const r of runs) console.log(`${r.id} ${r.status.padEnd(8)} ${r.host} ${r.summary ?? ''}`); return 0 } case 'report': { const { run, findings } = await c.run(rest[0]!); console.log(`${run.host} — ${run.status} — ${run.summary ?? run.error ?? ''}`); printFindings(findings); return 0 } case 'targets': { const { targets } = await c.targets(); for (const t of targets) console.log(`${t.state.padEnd(11)} ${t.host}`); return 0 } + case 'webhooks': { + const r = await c.webhooks() + for (const w of r.endpoints) console.log(`${w.id} ${w.url} ${w.events.join(',')}${w.enabled ? '' : ` OFF (${w.disabled_reason})`}`) + if (!r.endpoints.length) console.log('no webhooks') + console.log(`events: ${r.event_types.join(', ')}`); return 0 + } + case 'webhook': { + const [sub, arg, ...more] = rest + if (sub === 'add' && arg) { const r = await c.addWebhook(arg, more); console.log(`${r.endpoint.id} -> ${r.endpoint.url}\nsigning secret: ${r.secret}`); return 0 } + if (sub === 'test' && arg) { const r = await c.testWebhook(arg); console.log(r.ok ? `delivered (HTTP ${r.status})` : `failed: ${r.error}`); return r.ok ? 0 : 2 } + if (sub === 'rotate' && arg) { console.log(`new signing secret: ${(await c.rotateWebhook(arg)).secret}`); return 0 } + if ((sub === 'rm' || sub === 'remove' || sub === 'delete') && arg) { await c.removeWebhook(arg); console.log('removed'); return 0 } + if (sub === 'log') { const r = await c.webhookLog(arg); for (const d of r.deliveries) console.log(`${String(d.created_at).slice(0, 19)} ${String(d.event_type).padEnd(16)} ${String(d.status).padEnd(9)} x${d.attempts} ${d.last_status ?? ''} ${d.last_error ?? ''}`); if (!r.deliveries.length) console.log('no deliveries yet'); return 0 } + console.error('Usage: autohacker webhook add [events...] | test | rotate | rm | log [id]'); return 1 + } case 'stats': { console.log(JSON.stringify(await c.stats(), null, 2)); return 0 } case 'version': case '--version': case '-v': console.log(VERSION); return 0 default: - console.log(`autohacker v${VERSION}\n\nUsage:\n autohacker add \n autohacker proof \n autohacker verify [--method dns|file|meta]\n autohacker scan [--wait]\n autohacker runs\n autohacker report \n autohacker targets\n\nEnv: AUTOHACKER_URL, AUTOHACKER_API_KEY (make one on your account page).`) + console.log(`autohacker v${VERSION}\n\nUsage:\n autohacker add \n autohacker proof \n autohacker verify [--method dns|file|meta]\n autohacker scan [--wait]\n autohacker runs\n autohacker report \n autohacker targets\n autohacker webhooks\n autohacker webhook add [events...] | test | rotate | rm | log [id]\n\nEnv: AUTOHACKER_URL, AUTOHACKER_API_KEY (make one on your account page).`) return cmd ? 1 : 0 } } catch (e) { console.error((e as Error).message); return 1 } diff --git a/src/client.ts b/src/client.ts index 773c3c0..1a6f9d1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -29,4 +29,11 @@ export class Client { runs() { return this.req('GET', '/api/v1/runs') } run(id: string) { return this.req('GET', `/api/v1/runs/${id}`) } stats() { return this.req('GET', '/api/v1/stats') } + /** Your webhook endpoints, and the events auto.hacker sends. */ + webhooks(): Promise<{ endpoints: any[]; event_types: string[] }> { return this.req('GET', '/api/v1/webhooks') } + addWebhook(url: string, events?: string[], description = ''): Promise<{ endpoint: any; secret: string }> { return this.req('POST', '/api/v1/webhooks', { url, ...(events?.length ? { events } : {}), description }) } + removeWebhook(id: string) { return this.req('DELETE', `/api/v1/webhooks/${encodeURIComponent(id)}`) } + testWebhook(id: string): Promise<{ ok: boolean; status: number | null; error: string | null }> { return this.req('POST', `/api/v1/webhooks/${encodeURIComponent(id)}/test`) } + rotateWebhook(id: string): Promise<{ secret: string }> { return this.req('POST', `/api/v1/webhooks/${encodeURIComponent(id)}/rotate`) } + webhookLog(id?: string): Promise<{ deliveries: any[] }> { return this.req('GET', id ? `/api/v1/webhooks/${encodeURIComponent(id)}/deliveries` : '/api/v1/webhooks/deliveries') } } diff --git a/src/html.ts b/src/html.ts index 184a972..edd5896 100644 --- a/src/html.ts +++ b/src/html.ts @@ -5,6 +5,8 @@ import { pitFooter } from './pit-footer.ts' import { RING_PLACEHOLDER } from './ring.ts' import type { Run, Target, User, Schedule, Finding } from './db.ts' import { VERSION } from './version.ts' +import type { PublicEndpoint } from '@profullstack/webhooks' +import { WEBHOOK_EVENTS } from './service.ts' const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } export const esc = (v: unknown) => String(v ?? '').replace(/[&<>"']/g, (c) => ESC[c]!) @@ -109,7 +111,7 @@ export function loginPage(ctx: Ctx, opts: { next?: string | null; sent?: boolean return layout(ctx, { title: 'Sign in — auto.hacker', desc: 'Sign in to auto.hacker.', body }) } -export function accountPage(ctx: Ctx, opts: { apiKey?: string; profile?: Record | null; paid?: boolean }): string { +export function accountPage(ctx: Ctx, opts: { apiKey?: string; profile?: Record | null; paid?: boolean; flash?: string; webhooks?: PublicEndpoint[]; webhookSecret?: string }): string { const p = opts.profile ?? {} const fields = FIELDS.map((f) => ``).join('\n') const body = ` @@ -125,6 +127,14 @@ export function accountPage(ctx: Ctx, opts: { apiKey?: string; profile?: Record<

For the autohacker CLI, the MCP server and the API. Making a new key replaces the old one.

${opts.apiKey ? `
${esc(opts.apiKey)}

Copy it now — it is shown once.

` : ''}
+

Webhooks

+

auto.hacker POSTs what happens to your targets and scans to your URL: ${WEBHOOK_EVENTS.map((e) => `${esc(e)}`).join(', ')}. CloudEvents JSON, signed per Standard Webhooks (verify with @profullstack/webhooks or any Standard Webhooks library); retried for about three days. Findings carry severity and title only; the detail stays in the signed-in report.

+ ${opts.flash ? `

${esc(opts.flash)}

` : ''} + ${opts.webhookSecret ? `

Signing secret:

${esc(opts.webhookSecret)}
` : ''} + ${(opts.webhooks ?? []).map((w) => `
${esc(w.url)} ${esc(w.events.join(', '))}${w.enabled ? '' : ` · off: ${esc(w.disabled_reason ?? 'disabled')}`} + ${(['test', 'rotate', 'delete'] as const).map((a) => `
`).join('')}
`).join('')} +
+

Also from the CLI (autohacker webhooks), MCP and /api/v1/webhooks.

Scheduled scans

Free scans run on demand against your verified sites. Scheduled, recurring scans with emailed summaries are the paid tier.

diff --git a/src/mcp.ts b/src/mcp.ts index 4ed4be0..3eee925 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -32,6 +32,17 @@ export function buildServer(client = new Client()): McpServer { server.tool('runs', 'List recent scan runs.', {}, async () => text(await client.runs())) server.tool('report', 'Get a run and its findings by id.', { run_id: z.string() }, async ({ run_id }) => text(await client.run(run_id))) server.tool('targets', 'List your targets and their verification state.', {}, async () => text(await client.targets())) + server.tool('list_webhooks', 'Your webhook endpoints and the event types auto.hacker sends.', {}, async () => text(await client.webhooks())) + server.tool( + 'create_webhook', + 'Have auto.hacker POST scan events (target.verified, run.started, run.completed, run.failed, run.refused, finding.created) to a URL as signed CloudEvents (Standard Webhooks). Returns the signing secret. events: patterns like "run.*"; omit for all.', + { url: z.string().url(), events: z.array(z.string()).optional(), description: z.string().max(200).optional() }, + async ({ url, events, description }) => text(await client.addWebhook(url, events, description ?? '')), + ) + server.tool('test_webhook', 'Send a webhook.test event to an endpoint now.', { id: z.string() }, async ({ id }) => text(await client.testWebhook(id))) + server.tool('rotate_webhook_secret', 'Replace an endpoint\'s signing secret; the old one stops working at once.', { id: z.string() }, async ({ id }) => text(await client.rotateWebhook(id))) + server.tool('delete_webhook', 'Remove a webhook endpoint.', { id: z.string() }, async ({ id }) => text(await client.removeWebhook(id))) + server.tool('webhook_deliveries', 'Recent webhook deliveries, optionally for one endpoint.', { id: z.string().optional() }, async ({ id }) => text(await client.webhookLog(id))) return server } diff --git a/src/server.ts b/src/server.ts index 1832c0a..196c424 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,9 +8,10 @@ import { Hono, type Context } from 'hono' import { getCookie, setCookie, deleteCookie } from 'hono/cookie' import { streamSSE } from 'hono/streaming' import { validateProfile, createProfileStore } from '@profullstack/profile' +import { webhooksApi, WebhookError } from '@profullstack/webhooks' import { Store, type User } from './db.ts' import { Auth, consoleMailer, resendMailer, safeNext, SESSION_COOKIE, validEmail, type Mailer } from './auth.ts' -import { AutoHacker, InputError, type Config } from './service.ts' +import { AutoHacker, InputError, WEBHOOK_EVENTS, type Config } from './service.ts' import { OwnershipError, type Method } from './gate.ts' import { Worker } from './worker.ts' import { startPitFooterRefresh } from './pit-footer.ts' @@ -168,7 +169,35 @@ export function createApp(deps: Deps) { app.get('/account', async (c) => { let u: User; try { u = needUser(c) } catch { return c.redirect('/login?next=/account') } const profile = (await profiles.get(u.id)) ?? { email: u.email } - return page(c, accountPage(ctxOf(c), { profile: profile as any, paid: c.req.query('paid') === '1' })) + return page(c, accountPage(ctxOf(c), { profile: profile as any, paid: c.req.query('paid') === '1', webhooks: await service.hooks.listEndpoints(u.id) })) + }) + // Webhooks from the account page: add, test, rotate, delete. /api/v1/webhooks does the same with a key. + app.post('/account/webhooks/:action{(add|test|rotate|delete)}', async (c) => { + if (crossSite(c)) return c.text('refused', 403) + let u: User; try { u = needUser(c) } catch { return c.redirect('/login?next=/account') } + const form = await c.req.parseBody() + const show = async (data: { flash?: string; webhookSecret?: string }, status = 200) => { + const profile = (await profiles.get(u.id)) ?? { email: u.email } + return page(c, accountPage(ctxOf(c), { ...data, profile: profile as any, webhooks: await service.hooks.listEndpoints(u.id) }), status) + } + if (!postLimit.allow(`wh:${u.id}`)) return show({ flash: 'Too many changes. Wait a few minutes.' }, 429) + try { + const action = c.req.param('action') + if (action === 'add') { + const r = await service.hooks.createEndpoint(u.id, { url: String(form.url ?? ''), events: String(form.events ?? '') || undefined, description: String(form.description ?? '') }) + return show({ flash: 'Webhook added. Its signing secret is below (GET /api/v1/webhooks/:id/secret shows it again).', webhookSecret: r.secret }) + } + const id = String(form.id ?? '') + if (action === 'test') { + const r = await service.hooks.test(u.id, id) + return show({ flash: r.ok ? `Test delivered (HTTP ${r.status}).` : `Test failed: ${r.error}` }) + } + if (action === 'rotate') return show({ flash: 'New signing secret below; the old one has stopped working.', webhookSecret: (await service.hooks.rotateSecret(u.id, id)).secret }) + await service.hooks.deleteEndpoint(u.id, id) + return show({ flash: 'Webhook removed.' }) + } catch (e) { + return show({ flash: (e as Error).message }, e instanceof WebhookError ? e.status : 400) + } }) app.post('/account/profile', async (c) => { let u: User; try { u = needUser(c) } catch { return c.redirect('/login') } @@ -225,6 +254,17 @@ export function createApp(deps: Deps) { } app.get('/api/v1/runs/:id/export.json', (c) => exportRun(c, 'json')) app.get('/api/v1/runs/:id/export.csv', (c) => exportRun(c, 'csv')) + // Customer webhooks: the @profullstack/webhooks API, scoped to the API key's (or session's) account. + const hookOwner = new WeakMap() + const hooksApi = webhooksApi(service.hooks, { basePath: '/api/v1/webhooks', owner: (req) => hookOwner.get(req) }) + const webhooksRoute = async (c: Context) => { + const u = api(c); if (!u) return c.json({ error: 'unauthorized' }, 401) + if (c.req.method !== 'GET' && !bearer(c) && crossSite(c)) return c.json({ error: 'cross-site request refused' }, 403) + hookOwner.set(c.req.raw, u.id) + return (await hooksApi(c.req.raw)) ?? c.json({ error: 'not found' }, 404) + } + app.all('/api/v1/webhooks', webhooksRoute) + app.all('/api/v1/webhooks/*', webhooksRoute) app.get('/api/v1/stats', (c) => c.json({ ...store.stats(), version: VERSION })) app.post('/hooks/coinpay', async (c) => { @@ -263,8 +303,19 @@ GET /api/v1/runs/:id GET /api/v1/runs/:id/stream (text/event-stream, live) GET /api/v1/runs/:id/export.json GET /api/v1/runs/:id/export.csv -GET /api/v1/stats -

The autohacker CLI and the MCP server speak the same verbs: add, verify, scan, runs, report.

` +GET /api/v1/stats + +# customer webhooks (signed CloudEvents, Standard Webhooks) +GET /api/v1/webhooks -> { endpoints, event_types } +POST /api/v1/webhooks { "url": "https://…", "events": ["run.*"] } -> { endpoint, secret } +PATCH /api/v1/webhooks/:id { "url", "events", "description", "enabled" } +DELETE /api/v1/webhooks/:id +POST /api/v1/webhooks/:id/test +GET /api/v1/webhooks/:id/secret -> { secret } +POST /api/v1/webhooks/:id/rotate -> { secret } +GET /api/v1/webhooks/:id/deliveries (or /api/v1/webhooks/deliveries for all) +

Events: ${WEBHOOK_EVENTS.map((e) => `${e}`).join(', ')}. finding.created carries the severity and the check's title only, never the probe payload or URL: fetch the run report for detail.

+

The autohacker CLI and the MCP server speak the same verbs: add, verify, scan, runs, report, webhooks.

` return layout(ctx, { title: 'API — auto.hacker', desc: 'auto.hacker HTTP API.', body, publicPage: true }) } @@ -290,6 +341,8 @@ export function serve(options: { port?: number; hostname?: string } = {}) { setRingFooterOptions(ringFooterOptions([{ label: 'Manifesto', href: '/manifesto' }, { label: 'API', href: '/docs' }, { label: 'ThreatCrush', href: 'https://github.com/profullstack/threatcrush' }])) startRingFooterRefresh() worker.start() + // Customer webhooks: deliveries waiting from before the restart go out now, then every 5 s. + service.hooks.start() return Bun.serve({ port: options.port ?? Number(env.PORT ?? 8793), hostname: options.hostname ?? env.HOST ?? '127.0.0.1', idleTimeout: 120, maxRequestBodySize: 1024 * 1024, fetch: app.fetch }) } diff --git a/src/service.ts b/src/service.ts index 5ad356d..2043f76 100644 --- a/src/service.ts +++ b/src/service.ts @@ -12,6 +12,14 @@ import { checkOwnership, normalizeHost, proofInstructions, verifyToken, type GateDeps, type Method, OwnershipError } from './gate.ts' import type { Run, Store, Target, User } from './db.ts' import { createHmac, timingSafeEqual } from 'node:crypto' +import { Webhooks, sqliteStore as webhookStore, type GuardOptions } from '@profullstack/webhooks' + +/** + * What auto.hacker tells customer webhooks about. Finding payloads carry the + * severity and the check's title only: never the probe payload, the probed URL or + * the raw details (those stay behind the authenticated report). + */ +export const WEBHOOK_EVENTS = ['target.verified', 'run.started', 'run.completed', 'run.failed', 'run.refused', 'finding.created'] export class InputError extends Error { constructor(message: string, readonly field?: string) { super(message) } } @@ -24,13 +32,46 @@ export type Config = { /** at most one run per host per this many ms */ runCooldownMs: number gate?: GateDeps + /** Checks on customer webhook URLs; tests allow private addresses. */ + webhookGuard?: GuardOptions + /** fetch for webhook deliveries (tests inject a receiver). */ + webhookFetch?: typeof fetch } const SETTLED = new Set(['paid', 'completed', 'confirmed', 'forwarded', 'succeeded', 'settled']) const CADENCE_MS: Record = { daily: 86_400_000, weekly: 7 * 86_400_000, monthly: 30 * 86_400_000 } export class AutoHacker { - constructor(readonly store: Store, readonly config: Config) {} + /** Customer webhooks (@profullstack/webhooks): endpoints per account, durable delivery. */ + readonly hooks: Webhooks + + constructor(readonly store: Store, readonly config: Config) { + this.hooks = new Webhooks({ + store: webhookStore(store.db as never), + source: config.origin, + eventTypes: WEBHOOK_EVENTS, + ...(config.webhookFetch ? { fetchImpl: config.webhookFetch } : {}), + ...(config.webhookGuard ? { guard: config.webhookGuard } : {}), + log: (m) => console.log(m), + }) + } + + /** Tell the account's webhooks; never in the way of a scan. */ + hook(userId: string, type: string, data: Record, subject: string): void { + void this.hooks.emit(userId, type, data, { subject }).catch((e) => console.error(`[webhooks] ${type}: ${(e as Error).message}`)) + } + + /** Record a live proof; fires target.verified when the host was not verified before. */ + markVerified(user: User, target: Target, method: Method, token: string): void { + const was = this.store.target(target.id)?.state + this.store.recordVerified(target, method, token, user.email) + if (was !== 'verified') this.hook(user.id, 'target.verified', { target_id: target.id, host: target.host, method }, `target:${target.id}`) + } + + /** run.refused: the gate turned a run away (at launch or right before the engine). */ + hookRefused(run: Run, reason: string): void { + this.hook(run.userId, 'run.refused', { run_id: run.id, target_id: run.targetId, host: run.host, trigger: run.trigger, reason, url: `${this.config.origin}/runs/${run.id}` }, `run:${run.id}`) + } /** The exact proof value this user must publish for this host. */ token(user: User, host: string): string { return verifyToken(this.config.secret, user.id, host) } @@ -50,7 +91,7 @@ export class AutoHacker { if (target.userId !== user.id) throw new InputError('Not your target.') const token = this.token(user, target.host) const r = await checkOwnership({ host: target.host, token, only }, this.config.gate) - if (r.ok && r.method) this.store.recordVerified(target, r.method, token, user.email) + if (r.ok && r.method) this.markVerified(user, target, r.method, token) else this.store.recordCheckFail(target.id) return r } @@ -75,10 +116,11 @@ export class AutoHacker { const run = this.store.createRun(target, trigger, 'refused') this.store.setRunStatus(run.id, 'refused', { summary: 'Refused: ownership of this host is not proven.', error: `No live proof found. Looked: ${gate.looked.join('; ')}` }) this.store.recordCheckFail(target.id) + this.hookRefused(run, 'ownership is not proven') throw new OwnershipError(`auto.hacker will not scan ${target.host}: ownership is not proven. Add the DNS TXT, /.well-known file, or meta tag shown on the target page, then Verify.`) } // Record the fresh proof (keeps verified state + audit log current) and queue. - if (gate.method) this.store.recordVerified(target, gate.method, token, user.email) + if (gate.method) this.markVerified(user, target, gate.method, token) return this.store.createRun(target, trigger, 'queued') } diff --git a/src/version.ts b/src/version.ts index faab3a3..5c5211b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.1.0' +export const VERSION = '0.2.0' diff --git a/src/worker.ts b/src/worker.ts index e709670..fd6d876 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -91,6 +91,8 @@ export class Worker { this.store.setRunStatus(run.id, 'running') this.bus.emit(run.id, { type: 'status', status: 'running', at: Date.now() }) + const base = { run_id: run.id, target_id: run.targetId, host: run.host, trigger: run.trigger, url: `${this.origin}/runs/${run.id}` } + const subject = `run:${run.id}` // === SECOND GATE WALL: re-check the live proof right before the engine === const token = this.service.token(user, target.host) @@ -100,24 +102,32 @@ export class Worker { this.store.setRunStatus(run.id, 'refused', { summary: 'Refused: ownership lapsed.', error }) this.store.recordCheckFail(target.id) this.bus.emit(run.id, { type: 'refused', error, at: Date.now() }) + this.service.hookRefused(run, 'ownership lapsed before the scan') return } - if (gate.method) this.store.recordVerified(target, gate.method, token, user.email) + if (gate.method) this.service.markVerified(user, target, gate.method, token) + this.service.hook(user.id, 'run.started', base, subject) try { const onStep = (note: string) => this.bus.emit(run.id, { type: 'step', note, at: Date.now() }) const result = await runPentest(target.host, onStep) for (const f of result.findings as EngineFinding[]) { - this.store.addFinding(run.id, { type: f.type, severity: f.severity, message: f.message, location: f.location, details: f.details ?? null }) + const saved = this.store.addFinding(run.id, { type: f.type, severity: f.severity, message: f.message, location: f.location, details: f.details ?? null }) + // Severity + title only: the message/location can name the probe payload. + this.service.hook(user.id, 'finding.created', { run_id: run.id, target_id: run.targetId, host: run.host, finding_id: saved.id, severity: f.severity, title: f.type, url: base.url }, subject) this.bus.emit(run.id, { type: 'finding', finding: { type: f.type, severity: f.severity, message: f.message, location: f.location }, at: Date.now() }) } this.store.setRunStatus(run.id, 'done', { summary: result.summary, counts: result.severity_summary }) this.bus.emit(run.id, { type: 'done', summary: result.summary, counts: result.severity_summary, at: Date.now() }) + // The engine reports an unreachable target as a result with `error`, not a throw. + if (result.error) this.service.hook(user.id, 'run.failed', { ...base, error: result.error }, subject) + else this.service.hook(user.id, 'run.completed', { ...base, summary: result.summary, counts: result.severity_summary, findings: result.findings.length }, subject) await this.emailResult(user.email, this.store.run(run.id)!) } catch (e) { const error = (e as Error).message this.store.setRunStatus(run.id, 'error', { error }) this.bus.emit(run.id, { type: 'error', error, at: Date.now() }) + this.service.hook(user.id, 'run.failed', { ...base, error }, subject) } } diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts new file mode 100644 index 0000000..2d2b834 --- /dev/null +++ b/test/webhooks.test.ts @@ -0,0 +1,117 @@ +import { expect, test, describe } from 'bun:test' +import { verifySignature } from '@profullstack/webhooks' +import { Store } from '../src/db.ts' +import { AutoHacker, WEBHOOK_EVENTS } from '../src/service.ts' +import { Worker } from '../src/worker.ts' +import { createApp } from '../src/server.ts' +import { Auth, consoleMailer } from '../src/auth.ts' +import type { GateDeps } from '../src/gate.ts' + +const SECRET = 'z'.repeat(32) +const HOST = 'mysite.example' +const ORIGIN = 'https://auto.hacker.pit.world' + +type Got = { headers: Headers; body: string; json: any } + +function fresh(opts: { proof?: () => boolean } = {}) { + let token = '' + const proof = opts.proof ?? (() => true) + const gate: GateDeps = { resolveTxt: async () => (proof() && token ? [[`autohacker-verify=${token}`]] : []), fetchText: async () => null } + const got: Got[] = [] + const receiver = (async (_url: string, init: RequestInit) => { + const body = String(init.body) + got.push({ headers: new Headers(init.headers), body, json: JSON.parse(body) }) + return new Response('ok', { status: 200 }) + }) as unknown as typeof fetch + const store = new Store(':memory:', SECRET) + const service = new AutoHacker(store, { origin: ORIGIN, secret: SECRET, coinpay: null, priceUsd: 9, runCooldownMs: 0, gate, webhookFetch: receiver, webhookGuard: { lookup: async () => ['93.184.216.34'] } }) + const user = store.findOrCreateUser('owner@example.com') + const target = service.addTarget(user, HOST) + token = service.token(user, HOST) + return { store, service, user, target, got } +} + +const types = (got: Got[]) => got.map((g) => g.json.type) + +describe('customer webhooks', () => { + test('a verified scan sends target.verified, run.started, finding.created and run.completed, signed', async () => { + const { store, service, user, target, got } = fresh() + const { secret } = await service.hooks.createEndpoint(user.id, { url: 'https://hooks.example/ah' }) + const realFetch = globalThis.fetch + // The engine's probes: every response looks like a SQL error, so the SQLi check fires with its payload in the message. + globalThis.fetch = (async () => new Response('You have an error in your SQL syntax near', { status: 200 })) as unknown as typeof fetch + try { + const run = await service.createRun(user, target) + await new Worker(store, service, null, ORIGIN).execute(run) + } finally { globalThis.fetch = realFetch } + await service.hooks.drain() + const t = types(got) + expect(t[0]).toBe('target.verified') + expect(t.filter((x) => x === 'target.verified').length).toBe(1) // not re-sent by the worker's re-check + expect(t).toContain('run.started') + expect(t).toContain('finding.created') + expect(t[t.length - 1]).toBe('run.completed') + for (const g of got) { + expect(verifySignature({ secret, headers: g.headers, body: g.body }).ok).toBe(true) + expect(g.json.source).toBe(ORIGIN) + } + const sqli = got.find((g) => g.json.type === 'finding.created' && g.json.data.title === 'SQL Injection')! + expect(sqli.json.data.severity).toBe('critical') + // Never the probe payload, location or details. + expect(sqli.body).not.toContain("'") + expect(Object.keys(sqli.json.data).sort()).toEqual(['finding_id', 'host', 'run_id', 'severity', 'target_id', 'title', 'url']) + const done = got.find((g) => g.json.type === 'run.completed')! + expect(done.json.data.counts.critical).toBeGreaterThan(0) + }) + + test('an unproven host sends run.refused, nothing else', async () => { + const { service, user, target, got } = fresh({ proof: () => false }) + await service.hooks.createEndpoint(user.id, { url: 'https://hooks.example/ah' }) + await service.createRun(user, target).catch(() => {}) + await service.hooks.drain() + expect(types(got)).toEqual(['run.refused']) + expect(got[0]!.json.data.host).toBe(HOST) + }) + + test('an unreachable target sends run.failed', async () => { + const { store, service, user, target, got } = fresh() + await service.hooks.createEndpoint(user.id, { url: 'https://hooks.example/ah', events: ['run.*'] }) + const realFetch = globalThis.fetch + globalThis.fetch = (async () => { throw new Error('connect ECONNREFUSED') }) as unknown as typeof fetch + try { + const run = await service.createRun(user, target) + await new Worker(store, service, null, ORIGIN).execute(run) + } finally { globalThis.fetch = realFetch } + await service.hooks.drain() + expect(types(got)).toEqual(['run.started', 'run.failed']) + }) + + test('another account never receives my events', async () => { + const { store, service, user, target, got } = fresh() + const other = store.findOrCreateUser('other@example.com') + await service.hooks.createEndpoint(other.id, { url: 'https://hooks.example/other' }) + await service.createRun(user, target) + await service.hooks.drain() + expect(got.length).toBe(0) + }) + + test('/api/v1/webhooks: 401 without a key; scoped CRUD with one', async () => { + const { store, service, user } = fresh() + const worker = new Worker(store, service, null, ORIGIN) + const app = createApp({ service, auth: new Auth(store, consoleMailer), worker, profiles: { get: async () => null, save: async () => {} } as any }) + expect((await app.request('/api/v1/webhooks')).status).toBe(401) + expect((await app.request('/api/v1/webhooks', { headers: { authorization: 'Bearer ah_nope' } })).status).toBe(401) + const key = store.newApiKey(user.id) + const auth = { authorization: `Bearer ${key}`, 'content-type': 'application/json' } + const list = await (await app.request('/api/v1/webhooks', { headers: auth })).json() as any + expect(list.event_types).toEqual(WEBHOOK_EVENTS) + const made = await app.request('/api/v1/webhooks', { method: 'POST', headers: auth, body: JSON.stringify({ url: 'https://hooks.example/x', events: ['finding.created'] }) }) + expect(made.status).toBe(201) + const { endpoint, secret } = await made.json() as any + expect(secret).toStartWith('whsec_') + // Another account's key cannot see or delete it. + const otherKey = store.newApiKey(store.findOrCreateUser('other@example.com').id) + expect((await app.request(`/api/v1/webhooks/${endpoint.id}`, { method: 'DELETE', headers: { authorization: `Bearer ${otherKey}` } })).status).toBe(404) + expect((await app.request(`/api/v1/webhooks/${endpoint.id}`, { method: 'DELETE', headers: auth })).status).toBe(200) + }) +})