Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> [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
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 16 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
21 changes: 20 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
* autohacker runs list recent runs
* autohacker report <runId> print a run's findings
* autohacker targets list targets and their state
* autohacker webhooks your webhook endpoints and the events sent
* autohacker webhook add <url> [events...] POST scan events to your URL (prints the secret)
* autohacker webhook test|rotate|rm <id> 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.
*/
Expand Down Expand Up @@ -60,10 +64,25 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
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 <url> [events...] | test <id> | rotate <id> | rm <id> | 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 <host>\n autohacker proof <host>\n autohacker verify <host> [--method dns|file|meta]\n autohacker scan <host> [--wait]\n autohacker runs\n autohacker report <runId>\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 <host>\n autohacker proof <host>\n autohacker verify <host> [--method dns|file|meta]\n autohacker scan <host> [--wait]\n autohacker runs\n autohacker report <runId>\n autohacker targets\n autohacker webhooks\n autohacker webhook add <url> [events...] | test <id> | rotate <id> | rm <id> | 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 }
Expand Down
7 changes: 7 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') }
}
12 changes: 11 additions & 1 deletion src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
export const esc = (v: unknown) => String(v ?? '').replace(/[&<>"']/g, (c) => ESC[c]!)
Expand Down Expand Up @@ -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<string, string | null> | null; paid?: boolean }): string {
export function accountPage(ctx: Ctx, opts: { apiKey?: string; profile?: Record<string, string | null> | null; paid?: boolean; flash?: string; webhooks?: PublicEndpoint[]; webhookSecret?: string }): string {
const p = opts.profile ?? {}
const fields = FIELDS.map((f) => `<label>${esc(f.label)}<input type="${f.type}" name="${f.name}" value="${esc(p[f.name] ?? '')}" ${f.name === 'email' ? 'readonly' : ''} placeholder="${esc(f.placeholder ?? '')}" maxlength="${f.maxLength}"></label>`).join('\n')
const body = `
Expand All @@ -125,6 +127,14 @@ export function accountPage(ctx: Ctx, opts: { apiKey?: string; profile?: Record<
<p class="muted">For the <code>autohacker</code> CLI, the MCP server and the API. Making a new key replaces the old one.</p>
${opts.apiKey ? `<pre class="key">${esc(opts.apiKey)}</pre><p class="muted">Copy it now — it is shown once.</p>` : ''}
<form method="post" action="/account/key"><button class="btn ghost" type="submit">${opts.apiKey ? 'Regenerate' : 'Create'} API key</button></form>
<h2 id="webhooks">Webhooks</h2>
<p class="muted">auto.hacker POSTs what happens to your targets and scans to your URL: ${WEBHOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ')}. CloudEvents JSON, signed per <a href="https://www.standardwebhooks.com/">Standard Webhooks</a> (verify with <code>@profullstack/webhooks</code> or any Standard Webhooks library); retried for about three days. Findings carry severity and title only; the detail stays in the signed-in report.</p>
${opts.flash ? `<p class="ok">${esc(opts.flash)}</p>` : ''}
${opts.webhookSecret ? `<p class="muted">Signing secret:</p><pre class="key">${esc(opts.webhookSecret)}</pre>` : ''}
${(opts.webhooks ?? []).map((w) => `<div class="row"><code>${esc(w.url)}</code> <span class="muted">${esc(w.events.join(', '))}${w.enabled ? '' : ` · off: ${esc(w.disabled_reason ?? 'disabled')}`}</span>
${(['test', 'rotate', 'delete'] as const).map((a) => `<form method="post" action="/account/webhooks/${a}" class="inline"><input type="hidden" name="id" value="${esc(w.id)}"><button class="btn sm ${a === 'test' ? '' : 'ghost'}" type="submit">${a === 'test' ? 'Send a test' : a === 'rotate' ? 'New secret' : 'Remove'}</button></form>`).join('')}</div>`).join('')}
<form method="post" action="/account/webhooks/add" class="row"><input name="url" type="url" required placeholder="https://example.com/hooks/autohacker" aria-label="Webhook URL"><input name="events" placeholder="Events (blank = all, or run.*, finding.created)" aria-label="Events"><button class="btn" type="submit">Add webhook</button></form>
<p class="muted">Also from the CLI (<code>autohacker webhooks</code>), MCP and <code>/api/v1/webhooks</code>.</p>
<h2>Scheduled scans</h2>
<p class="muted">Free scans run on demand against your verified sites. Scheduled, recurring scans with emailed summaries are the paid tier.</p>
<form method="post" action="/account/checkout"><button class="btn" type="submit">Enable scheduled scans — pay with CoinPay</button></form>
Expand Down
11 changes: 11 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading