From cc832206de7c65283d290ba4738b70c2aa86869e Mon Sep 17 00:00:00 2001 From: wryan2986 <152133950+wryan2986@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:12:15 -0400 Subject: [PATCH] add bug bounty validation loop --- agents/bounty-discovery.md | 29 ++ agents/bounty-impact.md | 25 ++ agents/bounty-orchestrator.md | 44 +++ agents/bounty-report.md | 25 ++ agents/bounty-reproduction.md | 27 ++ agents/bounty-verifier.md | 27 ++ bug-bounty-loop/README.md | 190 +++++++++ bug-bounty-loop/bin/bounty-http.mjs | 273 +++++++++++++ bug-bounty-loop/bin/bounty-loop.mjs | 374 ++++++++++++++++++ bug-bounty-loop/config/local-lab.example.json | 54 +++ bug-bounty-loop/config/program.example.json | 61 +++ bug-bounty-loop/lib/gates.mjs | 56 +++ bug-bounty-loop/lib/manifest.mjs | 337 ++++++++++++++++ bug-bounty-loop/lib/opencode-adapter.mjs | 119 ++++++ bug-bounty-loop/lib/prompts.mjs | 137 +++++++ bug-bounty-loop/lib/redact.mjs | 32 ++ bug-bounty-loop/lib/state.mjs | 78 ++++ bug-bounty-loop/package.json | 13 + bug-bounty-loop/tests/controller.test.mjs | 105 +++++ bug-bounty-loop/tests/http-wrapper.test.mjs | 122 ++++++ .../tests/manifest-and-gates.test.mjs | 150 +++++++ commands/bounty-init.md | 6 + commands/bounty.md | 10 + docs/bug-bounty-loop.md | 76 ++++ 24 files changed, 2370 insertions(+) create mode 100644 agents/bounty-discovery.md create mode 100644 agents/bounty-impact.md create mode 100644 agents/bounty-orchestrator.md create mode 100644 agents/bounty-report.md create mode 100644 agents/bounty-reproduction.md create mode 100644 agents/bounty-verifier.md create mode 100644 bug-bounty-loop/README.md create mode 100755 bug-bounty-loop/bin/bounty-http.mjs create mode 100755 bug-bounty-loop/bin/bounty-loop.mjs create mode 100644 bug-bounty-loop/config/local-lab.example.json create mode 100644 bug-bounty-loop/config/program.example.json create mode 100644 bug-bounty-loop/lib/gates.mjs create mode 100644 bug-bounty-loop/lib/manifest.mjs create mode 100644 bug-bounty-loop/lib/opencode-adapter.mjs create mode 100644 bug-bounty-loop/lib/prompts.mjs create mode 100644 bug-bounty-loop/lib/redact.mjs create mode 100644 bug-bounty-loop/lib/state.mjs create mode 100644 bug-bounty-loop/package.json create mode 100644 bug-bounty-loop/tests/controller.test.mjs create mode 100644 bug-bounty-loop/tests/http-wrapper.test.mjs create mode 100644 bug-bounty-loop/tests/manifest-and-gates.test.mjs create mode 100644 commands/bounty-init.md create mode 100644 commands/bounty.md create mode 100644 docs/bug-bounty-loop.md diff --git a/agents/bounty-discovery.md b/agents/bounty-discovery.md new file mode 100644 index 0000000..64ff059 --- /dev/null +++ b/agents/bounty-discovery.md @@ -0,0 +1,29 @@ +--- +mode: subagent +model: opencode/deepseek-v4-flash-free +temperature: 0.1 +steps: 80 +description: > + Generates a small set of in-scope bug-bounty hypotheses and performs only + low-impact requests through the deterministic scoped HTTP wrapper. +permission: + read: allow + glob: allow + grep: allow + edit: deny + webfetch: deny + websearch: deny + task: deny + agent_loop: deny + bash: + "*": deny + "node .bounty-loop/tooling/bounty-http.mjs *": allow +--- + +You are the discovery stage of a human-supervised bug-bounty workflow. + +The controller supplies the exact manifest and output schema. Follow them literally. All active HTTP requests must go through `.bounty-loop/tooling/bounty-http.mjs`; no other network path is permitted. + +Prefer high-signal authorization, object-boundary, workflow, and business-logic hypotheses. Omit scanner-only observations, missing headers, version disclosures, speculative dependency CVEs, and anomalies without a plausible security boundary. + +Use only controlled accounts and records. Stop on real-user data, service instability, excluded paths, or out-of-scope redirects. Return only the requested JSON. diff --git a/agents/bounty-impact.md b/agents/bounty-impact.md new file mode 100644 index 0000000..f7dd6b1 --- /dev/null +++ b/agents/bounty-impact.md @@ -0,0 +1,25 @@ +--- +mode: subagent +model: opencode/mimo-v2.5-free +temperature: 0 +steps: 60 +description: > + Adversarially reviews reproduced evidence for concrete impact, policy + exclusions, unrealistic assumptions, and duplicate indicators. Makes no requests. +permission: + read: allow + glob: allow + grep: allow + edit: deny + webfetch: deny + websearch: deny + task: deny + agent_loop: deny + bash: deny +--- + +You are the adversarial impact reviewer. Make no active requests. + +Try to reject the finding by checking whether the data is public, the action is already authorized, another layer enforces the boundary, the behavior is intended, victim cooperation is unrealistic, the issue is excluded by policy, or the evidence resembles a known issue. Require concrete confidentiality, integrity, authentication, authorization, or availability impact. + +Use conservative severity. Return only the requested JSON. diff --git a/agents/bounty-orchestrator.md b/agents/bounty-orchestrator.md new file mode 100644 index 0000000..60da69e --- /dev/null +++ b/agents/bounty-orchestrator.md @@ -0,0 +1,44 @@ +--- +mode: primary +model: opencode-go/deepseek-v4-flash +temperature: 0.1 +reasoning_effort: medium +steps: 40 +description: > + Starts and monitors the deterministic bug-bounty validation controller. It may + help prepare a scope manifest, but it cannot test targets directly or submit reports. +permission: + read: allow + glob: allow + grep: allow + edit: ask + webfetch: deny + websearch: deny + task: deny + agent_loop: deny + question: allow + bash: + "*": deny + "node $OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs *": allow + "node \"$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs\" *": allow +--- + +# Bug-bounty controller operator + +You operate the deterministic bug-bounty validation loop. You do not directly probe targets, invoke scanners, use web tools, or submit reports. + +Use the controller at: + +```text +node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" +``` + +Required behavior: + +1. For initialization, run `init --workspace .` and tell the user to fill `.bounty-loop/program.json` from the current program policy. +2. Before any active run, execute `validate --workspace .`. Do not weaken validation errors or silently change authorization fields. +3. For a run, execute `run --workspace . --objective ""`. +4. Report the case directory, ready-for-human-review candidates, quarantined candidates, and any hard-stop event. +5. Never claim a report was submitted. The controller only drafts reports and records optional human approval for manual submission. +6. Never edit the program manifest to set authorization.confirmed to true on the user's behalf. +7. If the manifest is missing, expired, ambiguous, or out of scope, stop rather than guessing. diff --git a/agents/bounty-report.md b/agents/bounty-report.md new file mode 100644 index 0000000..2279b1a --- /dev/null +++ b/agents/bounty-report.md @@ -0,0 +1,25 @@ +--- +mode: subagent +model: opencode/deepseek-v4-flash-free +temperature: 0.1 +steps: 60 +description: > + Drafts a conservative, self-contained Markdown bounty report from evidence + only after every deterministic validation gate passes. Never submits it. +permission: + read: allow + glob: allow + grep: allow + edit: deny + webfetch: deny + websearch: deny + task: deny + agent_loop: deny + bash: deny +--- + +You draft a report only from evidence that passed the deterministic gates. Make no requests and do not add unsupported impact. + +The report must include the affected asset, roles and preconditions, exact reproduction steps, negative control, observable result, concrete impact, evidence references, testing limits, and cleanup notes. State uncertainty explicitly. Never imply that the report has been submitted. + +Return only the requested JSON. diff --git a/agents/bounty-reproduction.md b/agents/bounty-reproduction.md new file mode 100644 index 0000000..dd7b5ff --- /dev/null +++ b/agents/bounty-reproduction.md @@ -0,0 +1,27 @@ +--- +mode: subagent +model: opencode/deepseek-v4-flash-free +temperature: 0 +steps: 100 +description: > + Minimally reproduces one bounty candidate twice and runs a negative control + through the deterministic scoped HTTP wrapper. +permission: + read: allow + glob: allow + grep: allow + edit: deny + webfetch: deny + websearch: deny + task: deny + agent_loop: deny + bash: + "*": deny + "node .bounty-loop/tooling/bounty-http.mjs *": allow +--- + +You are the reproduction stage. Your job is to reject weak candidates, not confirm them optimistically. + +Use only the approved HTTP wrapper. Reproduce the candidate at least twice, including from a fresh state, and run a nearly identical negative control. A status-code difference alone is not proof. Preserve evidence references under the case directory without printing secrets or response bodies into chat. + +Use only accounts and objects controlled by the researcher. Stop immediately if you encounter real-user data, service instability, or an out-of-scope redirect. Return only the requested JSON. diff --git a/agents/bounty-verifier.md b/agents/bounty-verifier.md new file mode 100644 index 0000000..54e7006 --- /dev/null +++ b/agents/bounty-verifier.md @@ -0,0 +1,27 @@ +--- +mode: subagent +model: opencode/mimo-v2.5-free +temperature: 0 +steps: 100 +description: > + Blindly and independently attempts to disprove and reproduce a candidate, + confirming fresh-state behavior, a negative control, and a real security boundary. +permission: + read: allow + glob: allow + grep: allow + edit: deny + webfetch: deny + websearch: deny + task: deny + agent_loop: deny + bash: + "*": deny + "node .bounty-loop/tooling/bounty-http.mjs *": allow +--- + +You are an independent verifier. The controller deliberately withholds the finder model's confidence and severity claim. + +Attempt to disprove the candidate. Independently reproduce it from a fresh state, run a negative control, and determine whether authentication, authorization, confidentiality, integrity, or availability is actually crossed. Do not treat a surprising response as a vulnerability without a demonstrated boundary. + +All active requests must use the approved wrapper. Stop on real-user data, service instability, excluded paths, or out-of-scope redirects. Return only the requested JSON. diff --git a/bug-bounty-loop/README.md b/bug-bounty-loop/README.md new file mode 100644 index 0000000..9e27260 --- /dev/null +++ b/bug-bounty-loop/README.md @@ -0,0 +1,190 @@ +# Bug Bounty Validation Loop + +A human-supervised bug-bounty research workflow for OpenCode. It is designed to reject weak candidates before they become reports. + +The deterministic controller—not an LLM—owns stage order, scope checks, rate limits, evidence requirements, hard stops, and the final reportability gate. + +```text +scope manifest + | + v +high-signal discovery + | + v +minimal reproduction + negative control + | + v +blind independent verification + | + v +adversarial impact review + | + v +deterministic all-pass gate + | + v +report draft -> human review -> manual submission +``` + +## Non-goals + +This module does not: + +- mass scan targets +- bypass a bounty program's rules +- test real users or uncontrolled data +- perform denial of service, credential attacks, persistence, or destructive actions +- automatically submit reports +- turn scanner output into a report without independent evidence + +## Requirements + +- Node.js 18 or newer +- OpenCode installed and authenticated +- this repository configured as `OPENCODE_CONFIG_DIR` +- a current, manually reviewed bounty-program policy +- explicit authorization for the exact assets and testing methods in the manifest + +The controller launches isolated OpenCode CLI runs with `opencode run --agent ...`, which is supported by OpenCode's non-interactive CLI. + +## Quick start + +From the target workspace: + +```bash +node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" init --workspace . +``` + +Edit `.bounty-loop/program.json` using the current program policy. Keep exact origins; wildcard domains are intentionally unsupported. Leave `authorization.confirmed` false until you personally verify scope and restrictions. + +Validate: + +```bash +node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" validate --workspace . +``` + +Run a bounded case: + +```bash +node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" run \ + --workspace . \ + --objective "Check whether one test user can read another test user's saved object" +``` + +Or use OpenCode commands: + +```text +/bounty-init +/bounty Check whether one test user can read another test user's saved object +``` + +## Manifest design + +The manifest is the enforcement boundary. It records: + +- program and policy snapshot +- who confirmed authorization +- exact allowed and excluded origins +- allowed and excluded path prefixes +- allowed methods +- request and response limits +- redirect policy +- required identifying headers +- state-change permission +- hard-stop conditions +- mandatory human approval and disabled automatic submission + +The default example is passive and unconfirmed. It allows only `GET`, `HEAD`, and `OPTIONS` at six requests per minute. + +## Scoped HTTP wrapper + +Agents cannot use `curl`, `wget`, webfetch, scanners, raw sockets, or arbitrary shell commands. Active requests must use: + +```bash +node .bounty-loop/tooling/bounty-http.mjs \ + --manifest .bounty-loop/program.json \ + --method GET \ + --url https://in-scope.example/path \ + --output .bounty-loop/cases//evidence/response.bin +``` + +The wrapper enforces: + +- exact-origin and path-prefix scope +- allowed methods and state-change policy +- DNS checks against private or reserved networks for public programs +- per-minute and per-case request limits +- redirect scope +- response-size caps +- controlled body-file and evidence-output directories +- secret-header redaction +- response-body hashing + +Response bodies are not printed to stdout. They are saved only when an explicit evidence path is supplied. + +## Submission gates + +Every candidate must satisfy all gates: + +1. exact asset, path, and method are in scope +2. authorization is confirmed +3. only controlled accounts and objects were used +4. reproduction succeeds twice, including from fresh state +5. a nearly identical negative control passes +6. an independent verifier reproduces the issue +7. a real security boundary is demonstrated +8. behavior is unlikely to be intended +9. concrete security impact exists +10. no policy exclusion, unrealistic victim cooperation, or likely duplicate indicator applies +11. both reproduction and verifier evidence references exist +12. report generation succeeds +13. a human reviews the draft before manual submission + +One failed or uncertain gate quarantines the candidate. + +## Case files + +Cases are stored under `.bounty-loop/cases//`: + +```text +state.json +manifest.snapshot.json +events.jsonl +discovery.json +candidates// + candidate.json + reproduction.json + verification.json + impact.json + result.json +evidence/ +reports/ +``` + +Raw agent output and evidence files are mode `0600` where supported. Add `.bounty-loop/` to the target workspace's `.gitignore`; it may contain session metadata or sensitive test evidence. + +## Human approval + +A passing candidate is only `READY_FOR_HUMAN_REVIEW`. After personally reviewing the raw evidence and draft, record approval locally: + +```bash +node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" approve \ + --workspace . \ + --case-id \ + --candidate \ + --statement "I reviewed the raw requests, controls, scope, impact, and report draft." +``` + +This changes local state to `APPROVED_FOR_MANUAL_SUBMISSION`. It does not contact HackerOne, Bugcrowd, Intigriti, or any program. + +## Local training labs + +`config/local-lab.example.json` permits private networking and a state-changing method for an intentionally installed, owned training environment. Never use those settings for a public bounty target. + +## Validation + +```bash +cd "$OPENCODE_CONFIG_DIR/bug-bounty-loop" +npm test +npm run validate +``` diff --git a/bug-bounty-loop/bin/bounty-http.mjs b/bug-bounty-loop/bin/bounty-http.mjs new file mode 100755 index 0000000..8f60a08 --- /dev/null +++ b/bug-bounty-loop/bin/bounty-http.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import dns from 'node:dns/promises'; +import crypto from 'node:crypto'; +import net from 'node:net'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const ALWAYS_BLOCKED_METHODS = new Set(['CONNECT', 'TRACE']); +const SECRET_HEADER_PATTERN = /authorization|cookie|token|api[-_]?key|secret|password|session/i; +const FORBIDDEN_HEADERS = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'upgrade', 'proxy-authorization']); + +function fail(message, code = 1) { + console.error(JSON.stringify({ ok: false, error: message }, null, 2)); + process.exit(code); +} + +function parseArgs(argv) { + const parsed = { headers: [] }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith('--')) fail(`Unexpected argument: ${arg}`, 2); + const key = arg.slice(2); + if (key === 'header') { + const value = argv[++index]; + if (!value) fail('--header requires a value', 2); + parsed.headers.push(value); + continue; + } + const value = argv[++index]; + if (!value) fail(`--${key} requires a value`, 2); + parsed[key.replaceAll('-', '_')] = value; + } + return parsed; +} + +function normalizeOrigin(raw) { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`Unsupported scheme: ${url.protocol}`); + if (url.username || url.password) throw new Error('Credentials in URL are prohibited'); + return url.origin; +} + +function pathMatches(pathname, prefixes) { + return prefixes.some(prefix => pathname === prefix || pathname.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)); +} + +function evaluateScope(manifest, rawUrl, method) { + const reasons = []; + let url; + try { + url = new URL(rawUrl); + } catch { + return { allowed: false, reasons: ['Invalid URL'] }; + } + if (!['http:', 'https:'].includes(url.protocol)) reasons.push(`Scheme ${url.protocol} is not allowed`); + if (url.username || url.password) reasons.push('Credentials in URL are prohibited'); + const allowedOrigins = (manifest.scope?.allowed_origins || []).map(normalizeOrigin); + const excludedOrigins = (manifest.scope?.excluded_origins || []).map(normalizeOrigin); + const allowedPrefixes = manifest.scope?.allowed_path_prefixes || ['/']; + const excludedPrefixes = manifest.scope?.excluded_path_prefixes || []; + if (!allowedOrigins.includes(url.origin)) reasons.push(`Origin ${url.origin} is not allowed`); + if (excludedOrigins.includes(url.origin)) reasons.push(`Origin ${url.origin} is excluded`); + if (!pathMatches(url.pathname, allowedPrefixes)) reasons.push(`Path ${url.pathname} is not allowed`); + if (pathMatches(url.pathname, excludedPrefixes)) reasons.push(`Path ${url.pathname} is excluded`); + const allowedMethods = (manifest.scope?.allowed_methods || []).map(value => String(value).toUpperCase()); + if (!allowedMethods.includes(method)) reasons.push(`Method ${method} is not allowed`); + if (ALWAYS_BLOCKED_METHODS.has(method)) reasons.push(`Method ${method} is always prohibited`); + if (manifest.safety?.allow_state_change !== true && !SAFE_METHODS.has(method)) { + reasons.push(`Method ${method} is state-changing while allow_state_change is false`); + } + return { allowed: reasons.length === 0, reasons, url }; +} + +function isPrivateOrReservedAddress(address) { + const family = net.isIP(address); + if (family === 4) { + const [a, b] = address.split('.').map(Number); + return ( + a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || a >= 224 + ); + } + if (family === 6) { + const normalized = address.toLowerCase(); + return normalized === '::' || normalized === '::1' || normalized.startsWith('fc') || + normalized.startsWith('fd') || /^fe[89ab]/.test(normalized) || normalized.startsWith('ff'); + } + return true; +} + +async function assertPublicResolution(url, allowPrivateNetworks) { + if (allowPrivateNetworks) return; + const records = await dns.lookup(url.hostname, { all: true, verbatim: true }); + if (records.length === 0) throw new Error(`Hostname ${url.hostname} did not resolve`); + const blocked = records.filter(record => isPrivateOrReservedAddress(record.address)); + if (blocked.length > 0) { + throw new Error(`Hostname resolves to a private or reserved address: ${blocked.map(item => item.address).join(', ')}`); + } +} + +function parseHeaders(values, manifestHeaders) { + const headers = new Headers(); + for (const [name, value] of Object.entries(manifestHeaders || {})) { + if (!name || FORBIDDEN_HEADERS.has(name.toLowerCase())) continue; + headers.set(name, String(value)); + } + for (const item of values) { + const separator = item.indexOf(':'); + if (separator <= 0) throw new Error(`Invalid header format: ${item}`); + const name = item.slice(0, separator).trim(); + const value = item.slice(separator + 1).trim(); + if (FORBIDDEN_HEADERS.has(name.toLowerCase())) throw new Error(`Header ${name} is prohibited`); + headers.set(name, value); + } + if (!headers.has('user-agent')) headers.set('user-agent', 'OpenCode-Bug-Bounty-Validation-Loop/0.1'); + return headers; +} + +function redactHeaders(headers) { + const output = {}; + for (const [name, value] of headers.entries()) { + output[name] = SECRET_HEADER_PATTERN.test(name) + ? `[REDACTED sha256:${crypto.createHash('sha256').update(value).digest('hex').slice(0, 12)}]` + : value; + } + return output; +} + +function assertInside(root, candidate, label) { + const absoluteRoot = path.resolve(root); + const absoluteCandidate = path.resolve(candidate); + const relative = path.relative(absoluteRoot, absoluteCandidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${label} must be inside ${absoluteRoot}`); + return absoluteCandidate; +} + +async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, 'utf8')); +} + +async function writeJsonAtomic(filePath, value) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(temp, filePath); +} + +async function consumeRateBudget(workspace, manifest) { + const ledgerPath = path.join(workspace, '.bounty-loop', 'http-ledger.json'); + let ledger = { total: 0, timestamps: [] }; + try { + ledger = await readJson(ledgerPath); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const now = Date.now(); + ledger.timestamps = (ledger.timestamps || []).filter(timestamp => now - timestamp < 60_000); + const perMinute = manifest.scope.max_requests_per_minute; + const maxTotal = manifest.scope.max_total_requests_per_case; + if (ledger.timestamps.length >= perMinute) { + const waitMs = 60_000 - (now - ledger.timestamps[0]); + throw new Error(`Rate limit reached; retry after at least ${Math.ceil(waitMs / 1000)} seconds`); + } + if ((ledger.total || 0) >= maxTotal) throw new Error(`Case request budget exhausted at ${maxTotal} requests`); + ledger.timestamps.push(now); + ledger.total = (ledger.total || 0) + 1; + await writeJsonAtomic(ledgerPath, ledger); + return { total: ledger.total, recent: ledger.timestamps.length }; +} + +async function readLimitedBody(response, maxBytes) { + if (!response.body) return Buffer.alloc(0); + const chunks = []; + let total = 0; + for await (const chunk of response.body) { + const buffer = Buffer.from(chunk); + total += buffer.length; + if (total > maxBytes) throw new Error(`Response exceeded max_response_bytes (${maxBytes})`); + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +async function fetchScoped({ manifest, url, method, headers, body, maxRedirects = 3 }) { + let current = url; + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { + const scope = evaluateScope(manifest, current.href, method); + if (!scope.allowed) throw new Error(`Request blocked by scope: ${scope.reasons.join('; ')}`); + await assertPublicResolution(current, manifest.scope.allow_private_networks === true); + const response = await fetch(current, { + method, + headers, + body, + redirect: 'manual', + signal: AbortSignal.timeout(30_000), + }); + if (![301, 302, 303, 307, 308].includes(response.status)) return { response, finalUrl: current.href, redirects: redirectCount }; + const location = response.headers.get('location'); + if (!location) return { response, finalUrl: current.href, redirects: redirectCount }; + if (manifest.scope.follow_redirects !== true) { + return { response, finalUrl: current.href, redirects: redirectCount, blockedRedirect: new URL(location, current).href }; + } + const next = new URL(location, current); + const nextScope = evaluateScope(manifest, next.href, method); + if (!nextScope.allowed) throw new Error(`Redirect left scope: ${next.href}: ${nextScope.reasons.join('; ')}`); + current = next; + } + throw new Error(`Redirect limit exceeded (${maxRedirects})`); +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.manifest || !args.url) { + fail('Usage: bounty-http.mjs --manifest --url [--method GET] [--header "Name: value"] [--body-file path] [--output path]', 2); +} + +try { + const workspace = process.cwd(); + const manifestPath = assertInside(path.join(workspace, '.bounty-loop'), args.manifest, 'Manifest path'); + const manifest = await readJson(manifestPath); + if (manifest.authorization?.confirmed !== true) throw new Error('Manifest authorization is not confirmed'); + if (manifest.reporting?.auto_submit === true) throw new Error('Manifest is unsafe: auto_submit must be false'); + const method = String(args.method || 'GET').toUpperCase(); + const scope = evaluateScope(manifest, args.url, method); + if (!scope.allowed) throw new Error(`Request blocked by scope: ${scope.reasons.join('; ')}`); + const headers = parseHeaders(args.headers, manifest.scope.identification_headers); + let body; + if (args.body_file) { + const bodyPath = assertInside(path.join(workspace, '.bounty-loop', 'requests'), args.body_file, 'Body file'); + body = await fs.readFile(bodyPath); + } + if (body && SAFE_METHODS.has(method)) throw new Error(`${method} requests may not include a body`); + const budget = await consumeRateBudget(workspace, manifest); + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = await fetchScoped({ manifest, url: scope.url, method, headers, body }); + const responseBody = await readLimitedBody(result.response, manifest.scope.max_response_bytes); + let outputPath = null; + if (args.output) { + outputPath = assertInside(path.join(workspace, '.bounty-loop', 'cases'), args.output, 'Output path'); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, responseBody, { mode: 0o600 }); + } + const summary = { + ok: true, + request: { + method, + url: scope.url.href, + headers: redactHeaders(headers), + body_bytes: body?.length || 0, + }, + response: { + status: result.response.status, + status_text: result.response.statusText, + final_url: result.finalUrl, + redirects: result.redirects, + blocked_redirect: result.blockedRedirect || null, + headers: redactHeaders(result.response.headers), + body_bytes: responseBody.length, + body_sha256: crypto.createHash('sha256').update(responseBody).digest('hex'), + body_saved_to: outputPath ? path.relative(workspace, outputPath) : null, + }, + budget, + timing: { + started_at: startedAt, + duration_ms: Date.now() - started, + }, + }; + console.log(JSON.stringify(summary, null, 2)); +} catch (error) { + fail(error.message); +} diff --git a/bug-bounty-loop/bin/bounty-loop.mjs b/bug-bounty-loop/bin/bounty-loop.mjs new file mode 100755 index 0000000..c88de05 --- /dev/null +++ b/bug-bounty-loop/bin/bounty-loop.mjs @@ -0,0 +1,374 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadManifest, evaluateUrlScope } from '../lib/manifest.mjs'; +import { createCase, ensureDir, readJson, recordEvent, updateCaseState, writeJsonAtomic, slugify } from '../lib/state.mjs'; +import { evaluateSubmissionGates } from '../lib/gates.mjs'; +import { runOpenCodeAgent } from '../lib/opencode-adapter.mjs'; +import { discoveryPrompt, reproductionPrompt, verificationPrompt, impactPrompt, reportPrompt } from '../lib/prompts.mjs'; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(moduleDir, '..'); + +function print(value) { + console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2)); +} + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function parseArgs(argv) { + const [command = 'help', ...rest] = argv; + const options = { _: [] }; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (!arg.startsWith('--')) { + options._.push(arg); + continue; + } + const key = arg.slice(2).replaceAll('-', '_'); + if (['dry_run', 'json'].includes(key)) { + options[key] = true; + continue; + } + const value = rest[++index]; + if (value === undefined) fail(`--${arg.slice(2)} requires a value`, 2); + options[key] = value; + } + return { command, options }; +} + +function help() { + print(`Bug Bounty Validation Loop + +Commands: + init Create .bounty-loop/program.json and install the scoped HTTP wrapper + validate Validate an authorization and scope manifest + run Run discovery -> reproduction -> blind verification -> impact -> report draft + gate Re-evaluate one saved candidate through deterministic gates + approve Record human approval for manual submission; never submits automatically + status Show case state + +Examples: + node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" init --workspace . + node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" validate --workspace . + node "$OPENCODE_CONFIG_DIR/bug-bounty-loop/bin/bounty-loop.mjs" run --workspace . --objective "Review account object authorization" +`); +} + +function resolveWorkspace(options) { + return path.resolve(options.workspace || process.cwd()); +} + +function defaultManifestPath(workspace, options) { + return path.resolve(options.manifest || path.join(workspace, '.bounty-loop', 'program.json')); +} + +async function installTooling(workspace) { + const toolingDir = path.join(workspace, '.bounty-loop', 'tooling'); + await ensureDir(toolingDir); + await fs.copyFile(path.join(packageRoot, 'bin', 'bounty-http.mjs'), path.join(toolingDir, 'bounty-http.mjs')); + await fs.chmod(path.join(toolingDir, 'bounty-http.mjs'), 0o700); + await ensureDir(path.join(workspace, '.bounty-loop', 'requests')); +} + +async function commandInit(options) { + const workspace = resolveWorkspace(options); + await ensureDir(path.join(workspace, '.bounty-loop')); + const destination = defaultManifestPath(workspace, options); + try { + await fs.access(destination); + if (options.force !== 'true') fail(`${destination} already exists. Pass --force true to replace it.`); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + await fs.copyFile(path.join(packageRoot, 'config', 'program.example.json'), destination); + await installTooling(workspace); + print({ + status: 'INITIALIZED', + workspace, + manifest: destination, + next: 'Edit the manifest from the current program policy, set authorization.confirmed=true only after review, then run validate.', + }); +} + +async function commandValidate(options) { + const workspace = resolveWorkspace(options); + const manifestPath = defaultManifestPath(workspace, options); + const result = await loadManifest(manifestPath, { requireAuthorization: options.allow_unconfirmed !== 'true' }); + print({ status: 'VALID', manifest: result.manifest, path: result.path }); +} + +function normalizeCandidates(discovery) { + if (!discovery || !Array.isArray(discovery.candidates)) throw new Error('Discovery output must contain a candidates array'); + return discovery.candidates + .filter(candidate => candidate && typeof candidate === 'object') + .map((candidate, index) => ({ + id: slugify(candidate.id || candidate.title || `candidate-${index + 1}`, `candidate-${index + 1}`), + title: String(candidate.title || `Candidate ${index + 1}`).slice(0, 200), + url: String(candidate.url || ''), + method: String(candidate.method || 'GET').toUpperCase(), + hypothesis: String(candidate.hypothesis || ''), + preconditions: Array.isArray(candidate.preconditions) ? candidate.preconditions.map(String) : [], + planned_checks: Array.isArray(candidate.planned_checks) ? candidate.planned_checks.map(String) : [], + confidence: Number.isFinite(candidate.confidence) ? Math.max(0, Math.min(1, candidate.confidence)) : 0, + })); +} + +async function runStage({ agent, prompt, workspace, caseDir, stage, candidateId, attach }) { + await recordEvent(caseDir, 'stage_started', { stage, candidate_id: candidateId || null, agent }); + try { + const result = await runOpenCodeAgent({ agent, prompt, workspace, attach }); + const rawPath = path.join(caseDir, 'evidence', `${candidateId ? `${candidateId}-` : ''}${stage}-raw.txt`); + await fs.writeFile(rawPath, result.stdout, { mode: 0o600 }); + await recordEvent(caseDir, 'stage_completed', { stage, candidate_id: candidateId || null, agent, raw_path: rawPath }); + return result.parsed; + } catch (error) { + const rawPath = path.join(caseDir, 'evidence', `${candidateId ? `${candidateId}-` : ''}${stage}-error.txt`); + await fs.writeFile(rawPath, `${error.message}\n\nSTDOUT:\n${error.stdout || ''}\n\nSTDERR:\n${error.stderr || ''}`, { mode: 0o600 }); + await recordEvent(caseDir, 'stage_failed', { stage, candidate_id: candidateId || null, agent, error: error.message, raw_path: rawPath }); + throw error; + } +} + +async function commandRun(options) { + const workspace = resolveWorkspace(options); + const manifestPath = defaultManifestPath(workspace, options); + const objective = String(options.objective || options._.join(' ')).trim(); + if (!objective) fail('run requires --objective "..."', 2); + const maxCandidates = Math.max(1, Math.min(10, Number.parseInt(options.max_candidates || '3', 10))); + const { manifest } = await loadManifest(manifestPath, { requireAuthorization: true }); + await installTooling(workspace); + + if (options.dry_run) { + print({ + status: 'DRY_RUN_VALIDATED', + objective, + manifest: manifestPath, + max_candidates: maxCandidates, + stages: ['discovery', 'reproduction', 'blind_verification', 'impact_review', 'deterministic_gate', 'report_draft', 'human_review'], + }); + return; + } + + const { caseId, caseDir } = await createCase(workspace, objective, manifestPath, options.case_id); + await fs.copyFile(manifestPath, path.join(caseDir, 'manifest.snapshot.json')); + await updateCaseState(caseDir, state => ({ ...state, status: 'DISCOVERY' })); + + const discovery = await runStage({ + agent: 'bounty-discovery', + prompt: discoveryPrompt({ objective, manifest, caseDir: path.relative(workspace, caseDir) }), + workspace, + caseDir, + stage: 'discovery', + attach: options.attach, + }); + const candidates = normalizeCandidates(discovery).slice(0, maxCandidates); + await writeJsonAtomic(path.join(caseDir, 'discovery.json'), { candidates }); + + const results = []; + for (const candidate of candidates) { + const candidateDir = path.join(caseDir, 'candidates', candidate.id); + await ensureDir(candidateDir); + await writeJsonAtomic(path.join(candidateDir, 'candidate.json'), candidate); + + const scope = evaluateUrlScope(manifest, candidate.url, candidate.method); + if (!scope.allowed) { + const result = { candidate, gate: { passed: false, status: 'QUARANTINED', failures: scope.reasons.map(reason => `scope: ${reason}`) } }; + await writeJsonAtomic(path.join(candidateDir, 'result.json'), result); + results.push(result); + continue; + } + + let reproduction; + let verification; + let impact; + try { + reproduction = await runStage({ + agent: 'bounty-reproduction', + prompt: reproductionPrompt({ manifest, candidate, caseDir: path.relative(workspace, caseDir) }), + workspace, + caseDir, + stage: 'reproduction', + candidateId: candidate.id, + attach: options.attach, + }); + await writeJsonAtomic(path.join(candidateDir, 'reproduction.json'), reproduction); + + if (reproduction?.unauthorized_data_observed === true || reproduction?.service_instability_observed === true) { + await recordEvent(caseDir, 'hard_stop', { + candidate_id: candidate.id, + reason: reproduction.unauthorized_data_observed ? 'unauthorized_data_observed' : 'service_instability_observed', + }); + } + + const blindCandidate = { + id: candidate.id, + url: candidate.url, + method: candidate.method, + hypothesis: candidate.hypothesis, + preconditions: candidate.preconditions, + planned_checks: candidate.planned_checks, + }; + verification = await runStage({ + agent: 'bounty-verifier', + prompt: verificationPrompt({ + manifest, + blindCandidate, + reproductionEvidence: reproduction?.evidence_refs || [], + caseDir: path.relative(workspace, caseDir), + }), + workspace, + caseDir, + stage: 'verification', + candidateId: candidate.id, + attach: options.attach, + }); + await writeJsonAtomic(path.join(candidateDir, 'verification.json'), verification); + + impact = await runStage({ + agent: 'bounty-impact', + prompt: impactPrompt({ manifest, candidate, reproduction, verification }), + workspace, + caseDir, + stage: 'impact', + candidateId: candidate.id, + attach: options.attach, + }); + await writeJsonAtomic(path.join(candidateDir, 'impact.json'), impact); + } catch (error) { + const result = { + candidate, + gate: { passed: false, status: 'QUARANTINED', failures: [`stage failure: ${error.message}`] }, + }; + await writeJsonAtomic(path.join(candidateDir, 'result.json'), result); + results.push(result); + continue; + } + + const gate = evaluateSubmissionGates({ manifest, candidate, reproduction, verification, impact }); + const result = { candidate, reproduction, verification, impact, gate }; + if (gate.passed) { + try { + const report = await runStage({ + agent: 'bounty-report', + prompt: reportPrompt({ manifest, candidate, reproduction, verification, impact, gate }), + workspace, + caseDir, + stage: 'report', + candidateId: candidate.id, + attach: options.attach, + }); + if (!report || typeof report.markdown !== 'string' || !report.markdown.trim()) { + throw new Error('Report agent returned no Markdown report'); + } + result.report = report; + await fs.writeFile(path.join(caseDir, 'reports', `${candidate.id}.md`), `${report.markdown.trim()}\n`, { mode: 0o600 }); + } catch (error) { + result.gate = { + passed: false, + status: 'QUARANTINED', + failures: [...gate.failures, `report generation failed: ${error.message}`], + }; + } + } + await writeJsonAtomic(path.join(candidateDir, 'result.json'), result); + results.push(result); + } + + const ready = results.filter(result => result.gate?.status === 'READY_FOR_HUMAN_REVIEW'); + const finalState = await updateCaseState(caseDir, state => ({ + ...state, + status: ready.length > 0 ? 'READY_FOR_HUMAN_REVIEW' : 'NO_REPORTABLE_FINDINGS', + candidates: results.map(result => ({ + id: result.candidate.id, + title: result.candidate.title, + status: result.gate.status, + failures: result.gate.failures, + report: result.report ? path.relative(workspace, path.join(caseDir, 'reports', `${result.candidate.id}.md`)) : null, + })), + })); + await recordEvent(caseDir, 'case_completed', { status: finalState.status, ready_count: ready.length }); + print({ + case_id: caseId, + status: finalState.status, + case_dir: path.relative(workspace, caseDir), + ready_for_human_review: ready.map(result => result.candidate.id), + quarantined: results.filter(result => result.gate.status !== 'READY_FOR_HUMAN_REVIEW').map(result => result.candidate.id), + note: 'No report was submitted. Human review and manual submission are required.', + }); +} + +async function findCaseDir(workspace, caseId) { + if (!caseId) fail('--case-id is required', 2); + const caseDir = path.join(workspace, '.bounty-loop', 'cases', slugify(caseId)); + await fs.access(path.join(caseDir, 'state.json')); + return caseDir; +} + +async function commandStatus(options) { + const workspace = resolveWorkspace(options); + const caseDir = await findCaseDir(workspace, options.case_id); + print(await readJson(path.join(caseDir, 'state.json'))); +} + +async function commandGate(options) { + const workspace = resolveWorkspace(options); + const caseDir = await findCaseDir(workspace, options.case_id); + if (!options.candidate) fail('--candidate is required', 2); + const candidateDir = path.join(caseDir, 'candidates', slugify(options.candidate)); + const manifest = await readJson(path.join(caseDir, 'manifest.snapshot.json')); + const candidate = await readJson(path.join(candidateDir, 'candidate.json')); + const reproduction = await readJson(path.join(candidateDir, 'reproduction.json')); + const verification = await readJson(path.join(candidateDir, 'verification.json')); + const impact = await readJson(path.join(candidateDir, 'impact.json')); + print(evaluateSubmissionGates({ manifest, candidate, reproduction, verification, impact })); +} + +async function commandApprove(options) { + const workspace = resolveWorkspace(options); + const caseDir = await findCaseDir(workspace, options.case_id); + if (!options.candidate) fail('--candidate is required', 2); + const statement = String(options.statement || '').trim(); + if (statement.length < 20) fail('--statement must record a substantive human review statement', 2); + const candidateId = slugify(options.candidate); + const result = await readJson(path.join(caseDir, 'candidates', candidateId, 'result.json')); + if (result.gate?.status !== 'READY_FOR_HUMAN_REVIEW') { + fail(`Candidate ${candidateId} is not ready for human review`); + } + const state = await updateCaseState(caseDir, current => ({ + ...current, + status: 'APPROVED_FOR_MANUAL_SUBMISSION', + human_approvals: [ + ...(current.human_approvals || []), + { + candidate_id: candidateId, + approved_at: new Date().toISOString(), + statement, + }, + ], + })); + await recordEvent(caseDir, 'human_approval_recorded', { candidate_id: candidateId }); + print({ + status: state.status, + candidate: candidateId, + note: 'Approval was recorded locally. The loop does not submit reports or interact with bounty platforms.', + }); +} + +const { command, options } = parseArgs(process.argv.slice(2)); +try { + if (command === 'help' || command === '--help' || command === '-h') help(); + else if (command === 'init') await commandInit(options); + else if (command === 'validate') await commandValidate(options); + else if (command === 'run') await commandRun(options); + else if (command === 'gate') await commandGate(options); + else if (command === 'approve') await commandApprove(options); + else if (command === 'status') await commandStatus(options); + else fail(`Unknown command: ${command}`, 2); +} catch (error) { + fail(error.stack || error.message); +} diff --git a/bug-bounty-loop/config/local-lab.example.json b/bug-bounty-loop/config/local-lab.example.json new file mode 100644 index 0000000..835a44a --- /dev/null +++ b/bug-bounty-loop/config/local-lab.example.json @@ -0,0 +1,54 @@ +{ + "schema_version": 1, + "mode": "local_lab", + "program": { + "name": "Local training lab", + "platform": "local", + "policy_url": "local://owned-training-environment", + "policy_snapshot_date": "2026-07-23" + }, + "authorization": { + "confirmed": true, + "confirmed_by": "Local environment owner", + "testing_identity": "local-lab", + "notes": "Use only for an environment you own or intentionally installed for security training." + }, + "scope": { + "allowed_origins": [ + "http://127.0.0.1:3000" + ], + "allowed_path_prefixes": [ + "/" + ], + "excluded_origins": [], + "excluded_path_prefixes": [], + "allowed_methods": [ + "GET", + "HEAD", + "OPTIONS", + "POST" + ], + "max_requests_per_minute": 12, + "max_total_requests_per_case": 100, + "max_response_bytes": 1048576, + "follow_redirects": false, + "allow_private_networks": true, + "identification_headers": {} + }, + "safety": { + "only_owned_test_accounts": true, + "allow_state_change": true, + "stop_on_real_user_data": true, + "stop_on_service_instability": true, + "prohibited_tests": [ + "denial of service", + "destructive actions", + "persistence", + "automated report submission" + ] + }, + "reporting": { + "human_approval_required": true, + "auto_submit": false + } +} diff --git a/bug-bounty-loop/config/program.example.json b/bug-bounty-loop/config/program.example.json new file mode 100644 index 0000000..d0da455 --- /dev/null +++ b/bug-bounty-loop/config/program.example.json @@ -0,0 +1,61 @@ +{ + "schema_version": 1, + "mode": "authorized_program", + "program": { + "name": "Replace with program name", + "platform": "HackerOne, Bugcrowd, Intigriti, or direct", + "policy_url": "https://example.com/security", + "policy_snapshot_date": "2026-07-23" + }, + "authorization": { + "confirmed": false, + "confirmed_by": "Your name", + "testing_identity": "Your bounty-platform username or required researcher header", + "notes": "Confirm the exact current policy before changing confirmed to true." + }, + "scope": { + "allowed_origins": [ + "https://example.com" + ], + "allowed_path_prefixes": [ + "/" + ], + "excluded_origins": [], + "excluded_path_prefixes": [ + "/logout", + "/billing" + ], + "allowed_methods": [ + "GET", + "HEAD", + "OPTIONS" + ], + "max_requests_per_minute": 6, + "max_total_requests_per_case": 40, + "max_response_bytes": 1048576, + "follow_redirects": false, + "allow_private_networks": false, + "identification_headers": { + "X-Bug-Bounty": "replace-with-required-researcher-identifier" + } + }, + "safety": { + "only_owned_test_accounts": true, + "allow_state_change": false, + "stop_on_real_user_data": true, + "stop_on_service_instability": true, + "prohibited_tests": [ + "denial of service", + "credential stuffing", + "social engineering", + "testing real users", + "destructive actions", + "persistence", + "automated report submission" + ] + }, + "reporting": { + "human_approval_required": true, + "auto_submit": false + } +} diff --git a/bug-bounty-loop/lib/gates.mjs b/bug-bounty-loop/lib/gates.mjs new file mode 100644 index 0000000..ea85891 --- /dev/null +++ b/bug-bounty-loop/lib/gates.mjs @@ -0,0 +1,56 @@ +import { evaluateUrlScope } from './manifest.mjs'; + +function requireTrue(value, label, failures) { + if (value !== true) failures.push(label); +} + +function requireFalse(value, label, failures) { + if (value !== false) failures.push(label); +} + +function requireEvidence(value, label, failures) { + if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.trim())) { + failures.push(label); + } +} + +export function evaluateSubmissionGates({ manifest, candidate, reproduction, verification, impact }) { + const failures = []; + const scopeResult = evaluateUrlScope(manifest, candidate.url || candidate.asset || '', candidate.method || 'GET'); + if (!scopeResult.allowed) failures.push(...scopeResult.reasons.map(reason => `scope: ${reason}`)); + + requireTrue(manifest.authorization.confirmed, 'authorization is not confirmed', failures); + requireTrue(manifest.safety.only_owned_test_accounts, 'owned test accounts are not mandatory', failures); + requireTrue(manifest.reporting.human_approval_required, 'human approval is not required', failures); + requireFalse(manifest.reporting.auto_submit, 'automatic submission is enabled', failures); + + requireTrue(reproduction?.reproduced, 'reproduction did not succeed', failures); + requireTrue(reproduction?.fresh_state_reproduced, 'reproduction was not repeated from a fresh state', failures); + requireTrue(reproduction?.negative_control_passed, 'negative control did not pass', failures); + requireTrue(reproduction?.controlled_accounts_only, 'testing was not limited to controlled accounts', failures); + requireFalse(reproduction?.unauthorized_data_observed, 'real or unauthorized user data was observed; stop and review manually', failures); + requireFalse(reproduction?.service_instability_observed, 'service instability was observed; stop testing', failures); + requireEvidence(reproduction?.evidence_refs, 'reproduction evidence is missing', failures); + + requireTrue(verification?.independent, 'verification was not independent', failures); + requireTrue(verification?.reproduced, 'independent verifier could not reproduce', failures); + requireTrue(verification?.fresh_state, 'independent verifier did not use a fresh state', failures); + requireTrue(verification?.negative_control, 'independent verifier did not confirm a negative control', failures); + requireTrue(verification?.security_boundary_crossed, 'no security boundary was demonstrated', failures); + requireFalse(verification?.intended_behavior_likely, 'behavior appears intended or is insufficiently distinguished from intended behavior', failures); + requireEvidence(verification?.evidence_refs, 'independent verification evidence is missing', failures); + + requireTrue(impact?.concrete_impact, 'concrete security impact was not demonstrated', failures); + requireFalse(impact?.requires_unrealistic_cooperation, 'impact requires unrealistic victim cooperation', failures); + requireFalse(impact?.excluded_by_policy, 'finding appears excluded by program policy', failures); + requireFalse(impact?.duplicate_likely, 'finding appears likely to be a known issue or duplicate', failures); + if (!Array.isArray(impact?.affected_security_properties) || impact.affected_security_properties.length === 0) { + failures.push('no affected security property was identified'); + } + + return { + passed: failures.length === 0, + status: failures.length === 0 ? 'READY_FOR_HUMAN_REVIEW' : 'QUARANTINED', + failures, + }; +} diff --git a/bug-bounty-loop/lib/manifest.mjs b/bug-bounty-loop/lib/manifest.mjs new file mode 100644 index 0000000..4538f12 --- /dev/null +++ b/bug-bounty-loop/lib/manifest.mjs @@ -0,0 +1,337 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import net from 'node:net'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const ALWAYS_BLOCKED_METHODS = new Set(['CONNECT', 'TRACE']); + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireObject(value, name, errors) { + if (!isPlainObject(value)) { + errors.push(`${name} must be an object`); + return {}; + } + return value; +} + +function requireString(value, name, errors, { allowEmpty = false } = {}) { + if (typeof value !== 'string' || (!allowEmpty && value.trim() === '')) { + errors.push(`${name} must be a non-empty string`); + return ''; + } + return value.trim(); +} + +function requireBoolean(value, name, errors) { + if (typeof value !== 'boolean') { + errors.push(`${name} must be a boolean`); + return false; + } + return value; +} + +function requirePositiveInteger(value, name, errors, min, max) { + if (!Number.isInteger(value) || value < min || value > max) { + errors.push(`${name} must be an integer from ${min} to ${max}`); + return min; + } + return value; +} + +export function normalizeOrigin(raw) { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Unsupported URL scheme: ${url.protocol}`); + } + if (url.username || url.password) { + throw new Error('Origins must not contain credentials'); + } + if (url.pathname !== '/' || url.search || url.hash) { + throw new Error(`Origin must not include a path, query, or fragment: ${raw}`); + } + return url.origin; +} + +function normalizePathPrefix(raw) { + if (typeof raw !== 'string' || !raw.startsWith('/')) { + throw new Error(`Path prefix must begin with '/': ${String(raw)}`); + } + return raw; +} + +function normalizeMethod(raw) { + const method = String(raw || '').trim().toUpperCase(); + if (!/^[A-Z]+$/.test(method)) { + throw new Error(`Invalid HTTP method: ${String(raw)}`); + } + if (ALWAYS_BLOCKED_METHODS.has(method)) { + throw new Error(`${method} is always prohibited by this loop`); + } + return method; +} + +function normalizeStringArray(value, name, errors, mapper = value => value) { + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${name} must be a non-empty array`); + return []; + } + const output = []; + for (const item of value) { + try { + output.push(mapper(item)); + } catch (error) { + errors.push(`${name}: ${error.message}`); + } + } + return [...new Set(output)]; +} + +function validateSnapshotDate(value, errors) { + const text = requireString(value, 'program.policy_snapshot_date', errors); + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { + errors.push('program.policy_snapshot_date must use YYYY-MM-DD'); + return text; + } + const parsed = new Date(`${text}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) { + errors.push('program.policy_snapshot_date is not a valid date'); + } + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000); + if (parsed > tomorrow) { + errors.push('program.policy_snapshot_date cannot be in the future'); + } + return text; +} + +export function validateManifest(input, { requireAuthorization = true } = {}) { + const errors = []; + const root = requireObject(input, 'manifest', errors); + const program = requireObject(root.program, 'program', errors); + const authorization = requireObject(root.authorization, 'authorization', errors); + const scope = requireObject(root.scope, 'scope', errors); + const safety = requireObject(root.safety, 'safety', errors); + const reporting = requireObject(root.reporting, 'reporting', errors); + + const mode = root.mode === 'local_lab' ? 'local_lab' : 'authorized_program'; + const normalized = { + schema_version: root.schema_version === 1 ? 1 : 1, + mode, + program: { + name: requireString(program.name, 'program.name', errors), + platform: requireString(program.platform, 'program.platform', errors), + policy_url: requireString(program.policy_url, 'program.policy_url', errors), + policy_snapshot_date: validateSnapshotDate(program.policy_snapshot_date, errors), + }, + authorization: { + confirmed: requireBoolean(authorization.confirmed, 'authorization.confirmed', errors), + confirmed_by: requireString(authorization.confirmed_by, 'authorization.confirmed_by', errors), + testing_identity: requireString(authorization.testing_identity, 'authorization.testing_identity', errors), + notes: typeof authorization.notes === 'string' ? authorization.notes.trim() : '', + }, + scope: { + allowed_origins: normalizeStringArray(scope.allowed_origins, 'scope.allowed_origins', errors, normalizeOrigin), + allowed_path_prefixes: normalizeStringArray( + scope.allowed_path_prefixes ?? ['/'], + 'scope.allowed_path_prefixes', + errors, + normalizePathPrefix, + ), + excluded_origins: Array.isArray(scope.excluded_origins) + ? [...new Set(scope.excluded_origins.map(value => normalizeOrigin(value)))] + : [], + excluded_path_prefixes: Array.isArray(scope.excluded_path_prefixes) + ? [...new Set(scope.excluded_path_prefixes.map(value => normalizePathPrefix(value)))] + : [], + allowed_methods: normalizeStringArray( + scope.allowed_methods ?? ['GET', 'HEAD', 'OPTIONS'], + 'scope.allowed_methods', + errors, + normalizeMethod, + ), + max_requests_per_minute: requirePositiveInteger( + scope.max_requests_per_minute, + 'scope.max_requests_per_minute', + errors, + 1, + 60, + ), + max_total_requests_per_case: requirePositiveInteger( + scope.max_total_requests_per_case, + 'scope.max_total_requests_per_case', + errors, + 1, + 500, + ), + max_response_bytes: requirePositiveInteger( + scope.max_response_bytes, + 'scope.max_response_bytes', + errors, + 1024, + 5 * 1024 * 1024, + ), + follow_redirects: requireBoolean(scope.follow_redirects, 'scope.follow_redirects', errors), + allow_private_networks: requireBoolean(scope.allow_private_networks, 'scope.allow_private_networks', errors), + identification_headers: isPlainObject(scope.identification_headers) + ? Object.fromEntries( + Object.entries(scope.identification_headers) + .filter(([key, value]) => typeof key === 'string' && typeof value === 'string') + .map(([key, value]) => [key.trim(), value.trim()]), + ) + : {}, + }, + safety: { + only_owned_test_accounts: requireBoolean( + safety.only_owned_test_accounts, + 'safety.only_owned_test_accounts', + errors, + ), + allow_state_change: requireBoolean(safety.allow_state_change, 'safety.allow_state_change', errors), + stop_on_real_user_data: requireBoolean( + safety.stop_on_real_user_data, + 'safety.stop_on_real_user_data', + errors, + ), + stop_on_service_instability: requireBoolean( + safety.stop_on_service_instability, + 'safety.stop_on_service_instability', + errors, + ), + prohibited_tests: Array.isArray(safety.prohibited_tests) + ? safety.prohibited_tests.filter(value => typeof value === 'string' && value.trim()).map(value => value.trim()) + : [], + }, + reporting: { + human_approval_required: requireBoolean( + reporting.human_approval_required, + 'reporting.human_approval_required', + errors, + ), + auto_submit: requireBoolean(reporting.auto_submit, 'reporting.auto_submit', errors), + }, + }; + + if (requireAuthorization && normalized.authorization.confirmed !== true) { + errors.push('authorization.confirmed must be true before active testing'); + } + if (!normalized.safety.only_owned_test_accounts) { + errors.push('safety.only_owned_test_accounts must remain true'); + } + if (!normalized.safety.stop_on_real_user_data) { + errors.push('safety.stop_on_real_user_data must remain true'); + } + if (!normalized.safety.stop_on_service_instability) { + errors.push('safety.stop_on_service_instability must remain true'); + } + if (!normalized.reporting.human_approval_required) { + errors.push('reporting.human_approval_required must remain true'); + } + if (normalized.reporting.auto_submit) { + errors.push('reporting.auto_submit must remain false'); + } + if (!normalized.safety.allow_state_change) { + const unsafeMethods = normalized.scope.allowed_methods.filter(method => !SAFE_METHODS.has(method)); + if (unsafeMethods.length > 0) { + errors.push( + `scope.allowed_methods contains state-changing methods while safety.allow_state_change is false: ${unsafeMethods.join(', ')}`, + ); + } + } + if (mode !== 'local_lab' && normalized.scope.allow_private_networks) { + errors.push('scope.allow_private_networks may only be true in local_lab mode'); + } + if (normalized.scope.allowed_origins.some(origin => normalized.scope.excluded_origins.includes(origin))) { + errors.push('An origin cannot be both allowed and excluded'); + } + + return { valid: errors.length === 0, errors, manifest: normalized }; +} + +export async function loadManifest(filePath, options) { + const absolute = path.resolve(filePath); + const text = await fs.readFile(absolute, 'utf8'); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`Manifest is not valid JSON: ${error.message}`); + } + const result = validateManifest(parsed, options); + if (!result.valid) { + const error = new Error(`Manifest validation failed:\n- ${result.errors.join('\n- ')}`); + error.validationErrors = result.errors; + throw error; + } + return { ...result, path: absolute }; +} + +function pathMatches(pathname, prefixes) { + return prefixes.some(prefix => pathname === prefix || pathname.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)); +} + +export function evaluateUrlScope(manifest, rawUrl, method = 'GET') { + const reasons = []; + let url; + try { + url = new URL(rawUrl); + } catch { + return { allowed: false, reasons: ['URL is invalid'], url: null }; + } + + if (!['http:', 'https:'].includes(url.protocol)) reasons.push(`Scheme ${url.protocol} is not allowed`); + if (url.username || url.password) reasons.push('Credentials in URLs are not allowed'); + if (!manifest.scope.allowed_origins.includes(url.origin)) reasons.push(`Origin ${url.origin} is not in allowed_origins`); + if (manifest.scope.excluded_origins.includes(url.origin)) reasons.push(`Origin ${url.origin} is explicitly excluded`); + if (!pathMatches(url.pathname, manifest.scope.allowed_path_prefixes)) { + reasons.push(`Path ${url.pathname} is outside allowed_path_prefixes`); + } + if (pathMatches(url.pathname, manifest.scope.excluded_path_prefixes)) { + reasons.push(`Path ${url.pathname} is explicitly excluded`); + } + + const normalizedMethod = String(method || 'GET').toUpperCase(); + if (!manifest.scope.allowed_methods.includes(normalizedMethod)) { + reasons.push(`Method ${normalizedMethod} is not allowed by the manifest`); + } + if (ALWAYS_BLOCKED_METHODS.has(normalizedMethod)) reasons.push(`Method ${normalizedMethod} is always prohibited`); + if (!manifest.safety.allow_state_change && !SAFE_METHODS.has(normalizedMethod)) { + reasons.push(`Method ${normalizedMethod} is state-changing while allow_state_change is false`); + } + + return { allowed: reasons.length === 0, reasons, url }; +} + +export function isPrivateOrReservedAddress(address) { + const family = net.isIP(address); + if (family === 4) { + const parts = address.split('.').map(Number); + const [a, b] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + a >= 224 + ); + } + if (family === 6) { + const normalized = address.toLowerCase(); + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe8') || + normalized.startsWith('fe9') || + normalized.startsWith('fea') || + normalized.startsWith('feb') || + normalized.startsWith('ff') + ); + } + return true; +} diff --git a/bug-bounty-loop/lib/opencode-adapter.mjs b/bug-bounty-loop/lib/opencode-adapter.mjs new file mode 100644 index 0000000..c923b82 --- /dev/null +++ b/bug-bounty-loop/lib/opencode-adapter.mjs @@ -0,0 +1,119 @@ +import { spawn } from 'node:child_process'; + +function extractFencedJson(text) { + const matches = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]; + for (const match of matches.reverse()) { + try { + return JSON.parse(match[1].trim()); + } catch { + // Try another fenced block. + } + } + return null; +} + +function extractBalancedJson(text) { + for (let start = text.length - 1; start >= 0; start -= 1) { + if (text[start] !== '{' && text[start] !== '[') continue; + const open = text[start]; + const close = open === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === open) depth += 1; + if (char === close) depth -= 1; + if (depth === 0) { + const candidate = text.slice(start, index + 1); + try { + return JSON.parse(candidate); + } catch { + break; + } + } + } + } + return null; +} + +export function extractJson(text) { + const trimmed = String(text || '').trim(); + try { + return JSON.parse(trimmed); + } catch { + return extractFencedJson(trimmed) ?? extractBalancedJson(trimmed); + } +} + +export async function runOpenCodeAgent({ + agent, + prompt, + workspace, + timeoutMs = 10 * 60 * 1000, + opencodeBin = process.env.OPENCODE_BIN || 'opencode', + attach, +}) { + const args = ['run', '--agent', agent, '--format', 'default', '--dir', workspace]; + if (attach) args.push('--attach', attach); + args.push(prompt); + + return await new Promise((resolve, reject) => { + const child = spawn(opencodeBin, args, { + cwd: workspace, + env: { + ...process.env, + AGENT_LOOP_CHILD: '1', + BUG_BOUNTY_CHILD: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + setTimeout(() => child.kill('SIGKILL'), 2_000).unref(); + }, timeoutMs); + + child.stdout.on('data', chunk => { + stdout += chunk.toString(); + }); + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', error => { + clearTimeout(timer); + reject(new Error(`Could not start OpenCode agent '${agent}': ${error.message}`)); + }); + child.on('close', code => { + clearTimeout(timer); + const parsed = extractJson(stdout); + if (code !== 0) { + const error = new Error(`OpenCode agent '${agent}' exited with code ${code}: ${stderr.trim() || stdout.trim()}`); + error.stdout = stdout; + error.stderr = stderr; + error.exitCode = code; + reject(error); + return; + } + if (parsed === null) { + const error = new Error(`OpenCode agent '${agent}' did not return valid JSON`); + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ parsed, stdout, stderr, exitCode: code }); + }); + }); +} diff --git a/bug-bounty-loop/lib/prompts.mjs b/bug-bounty-loop/lib/prompts.mjs new file mode 100644 index 0000000..8b9f406 --- /dev/null +++ b/bug-bounty-loop/lib/prompts.mjs @@ -0,0 +1,137 @@ +function json(value) { + return JSON.stringify(value, null, 2); +} + +const BASE_RULES = ` +Hard rules: +- Operate only within the exact origins, paths, methods, rate limits, and exclusions in the manifest. +- Use only accounts and records controlled by the researcher. +- Use the approved HTTP wrapper for all active requests. Do not use curl, wget, browsers, webfetch, raw sockets, scanners, or other network paths. +- Stop immediately if real-user data, service instability, or an out-of-scope redirect appears. +- Do not perform denial of service, credential attacks, social engineering, persistence, destructive actions, or automated submission. +- Output one JSON value only. Do not wrap it in prose. +`; + +export function discoveryPrompt({ objective, manifest, caseDir }) { + return `${BASE_RULES} +You are the discovery stage of a human-supervised bug-bounty validation loop. +Generate a small number of concrete, testable hypotheses. Prefer authorization and business-logic boundaries over generic scanner findings. You may make low-impact requests through .bounty-loop/tooling/bounty-http.mjs when needed. + +Objective:\n${objective} + +Manifest:\n${json(manifest)} + +Case directory: ${caseDir} + +Return exactly: +{ + "candidates": [ + { + "id": "short-stable-id", + "title": "neutral hypothesis title", + "url": "https://exact-in-scope-origin/path", + "method": "GET", + "hypothesis": "what security boundary may fail", + "preconditions": ["researcher-controlled prerequisites"], + "planned_checks": ["minimal checks including a negative control"], + "confidence": 0.0 + } + ] +} +Limit candidates to high-signal items. Scanner-only observations, missing headers, version disclosures, and impact-free anomalies must be omitted.`; +} + +export function reproductionPrompt({ manifest, candidate, caseDir }) { + return `${BASE_RULES} +You are the reproduction stage. Test one candidate minimally and conservatively. Reproduce twice, including once from a fresh state, and run a nearly identical negative control. Do not claim impact merely because status codes differ. + +Manifest:\n${json(manifest)} + +Candidate:\n${json(candidate)} + +Case directory: ${caseDir} +Save any response bodies only under ${caseDir}/evidence using the approved wrapper. + +Return exactly: +{ + "reproduced": false, + "fresh_state_reproduced": false, + "negative_control_passed": false, + "controlled_accounts_only": true, + "unauthorized_data_observed": false, + "service_instability_observed": false, + "evidence_refs": [], + "observed_result": "", + "rejection_reason": "" +}`; +} + +export function verificationPrompt({ manifest, blindCandidate, reproductionEvidence, caseDir }) { + return `${BASE_RULES} +You are an independent verifier. You have not been given the finder model's severity, confidence, or persuasive narrative. Attempt to reproduce the described behavior independently from a fresh state, confirm a negative control, and determine whether an actual security boundary is crossed. Try to disprove the hypothesis. + +Manifest:\n${json(manifest)} + +Blind candidate facts:\n${json(blindCandidate)} + +Reproduction evidence references:\n${json(reproductionEvidence)} + +Case directory: ${caseDir} + +Return exactly: +{ + "independent": true, + "reproduced": false, + "fresh_state": false, + "negative_control": false, + "security_boundary_crossed": false, + "intended_behavior_likely": false, + "evidence_refs": [], + "disproof_attempts": [], + "rejection_reason": "" +}`; +} + +export function impactPrompt({ manifest, candidate, reproduction, verification }) { + return `${BASE_RULES} +You are the adversarial impact reviewer. Do not make active network requests. Assess whether the evidence demonstrates concrete confidentiality, integrity, authentication, authorization, or availability impact. Treat policy exclusions, unrealistic victim cooperation, public data, expected behavior, and known-issue indicators as reasons to reject or quarantine. + +Manifest:\n${json(manifest)} +Candidate:\n${json(candidate)} +Reproduction:\n${json(reproduction)} +Independent verification:\n${json(verification)} + +Return exactly: +{ + "concrete_impact": false, + "affected_security_properties": [], + "attacker_capability": "", + "victim_role": "", + "requires_unrealistic_cooperation": false, + "excluded_by_policy": false, + "duplicate_likely": false, + "severity": "informational", + "reasoning_summary": "" +}`; +} + +export function reportPrompt({ manifest, candidate, reproduction, verification, impact, gate }) { + return `${BASE_RULES} +You are the report-drafting stage. Do not make active requests. Draft a factual, conservative Markdown report from the verified evidence. Do not inflate severity, speculate beyond evidence, or imply that submission occurred. Include a negative control and cleanup/testing notes. + +Manifest:\n${json(manifest)} +Candidate:\n${json(candidate)} +Reproduction:\n${json(reproduction)} +Verification:\n${json(verification)} +Impact:\n${json(impact)} +Gate result:\n${json(gate)} + +Return exactly: +{ + "title": "", + "markdown": "# Summary\\n...", + "suggested_severity": "", + "cwe": "", + "reviewer_notes": [] +}`; +} diff --git a/bug-bounty-loop/lib/redact.mjs b/bug-bounty-loop/lib/redact.mjs new file mode 100644 index 0000000..57d75a1 --- /dev/null +++ b/bug-bounty-loop/lib/redact.mjs @@ -0,0 +1,32 @@ +import crypto from 'node:crypto'; + +const SECRET_KEY_PATTERN = /authorization|cookie|set-cookie|token|api[-_]?key|secret|password|session/i; + +function shortHash(value) { + return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 12); +} + +export function redactHeaders(headers = {}) { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => { + if (SECRET_KEY_PATTERN.test(key)) { + return [key, `[REDACTED sha256:${shortHash(value)}]`]; + } + return [key, String(value)]; + }), + ); +} + +export function redactValue(value, key = '') { + if (SECRET_KEY_PATTERN.test(key)) return '[REDACTED]'; + if (Array.isArray(value)) return value.map(item => redactValue(item, key)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, redactValue(child, childKey)])); + } + if (typeof value === 'string') { + return value + .replace(/\bBearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]') + .replace(/\b(?:sk|pk)_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED_KEY]'); + } + return value; +} diff --git a/bug-bounty-loop/lib/state.mjs b/bug-bounty-loop/lib/state.mjs new file mode 100644 index 0000000..15ed043 --- /dev/null +++ b/bug-bounty-loop/lib/state.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +export function slugify(value, fallback = 'case') { + const slug = String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return slug || fallback; +} + +export function createCaseId(objective) { + const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); + const entropy = crypto.randomBytes(3).toString('hex'); + return `${slugify(objective)}-${timestamp}-${entropy}`; +} + +export async function ensureDir(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); + return dirPath; +} + +export async function writeJsonAtomic(filePath, value) { + await ensureDir(path.dirname(filePath)); + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(temporary, filePath); +} + +export async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, 'utf8')); +} + +export async function appendJsonLine(filePath, value) { + await ensureDir(path.dirname(filePath)); + await fs.appendFile(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); +} + +export async function createCase(workspace, objective, manifestPath, requestedCaseId) { + const caseId = requestedCaseId ? slugify(requestedCaseId) : createCaseId(objective); + const root = path.resolve(workspace, '.bounty-loop'); + const caseDir = path.join(root, 'cases', caseId); + await ensureDir(path.join(caseDir, 'candidates')); + await ensureDir(path.join(caseDir, 'evidence')); + await ensureDir(path.join(caseDir, 'reports')); + const state = { + schema_version: 1, + case_id: caseId, + objective, + manifest_path: path.resolve(manifestPath), + status: 'CREATED', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + candidates: [], + human_approvals: [], + }; + await writeJsonAtomic(path.join(caseDir, 'state.json'), state); + return { caseId, caseDir, state }; +} + +export async function updateCaseState(caseDir, updater) { + const statePath = path.join(caseDir, 'state.json'); + const state = await readJson(statePath); + const updated = await updater(structuredClone(state)); + updated.updated_at = new Date().toISOString(); + await writeJsonAtomic(statePath, updated); + return updated; +} + +export async function recordEvent(caseDir, type, details = {}) { + await appendJsonLine(path.join(caseDir, 'events.jsonl'), { + at: new Date().toISOString(), + type, + ...details, + }); +} diff --git a/bug-bounty-loop/package.json b/bug-bounty-loop/package.json new file mode 100644 index 0000000..9ec8ec8 --- /dev/null +++ b/bug-bounty-loop/package.json @@ -0,0 +1,13 @@ +{ + "name": "opencode-bug-bounty-validation-loop", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test tests/*.test.mjs", + "validate": "node --check bin/bounty-loop.mjs && node --check bin/bounty-http.mjs && npm test" + } +} diff --git a/bug-bounty-loop/tests/controller.test.mjs b/bug-bounty-loop/tests/controller.test.mjs new file mode 100644 index 0000000..2f534b2 --- /dev/null +++ b/bug-bounty-loop/tests/controller.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const controller = path.resolve(testDir, '..', 'bin', 'bounty-loop.mjs'); +const example = path.resolve(testDir, '..', 'config', 'local-lab.example.json'); + +function run(args, cwd) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [controller, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); +} + +test('init creates manifest and scoped wrapper', async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'bounty-controller-')); + const result = await run(['init', '--workspace', workspace], workspace); + assert.equal(result.code, 0, result.stderr); + await fs.access(path.join(workspace, '.bounty-loop', 'program.json')); + await fs.access(path.join(workspace, '.bounty-loop', 'tooling', 'bounty-http.mjs')); +}); + +test('dry run validates a confirmed manifest without invoking OpenCode', async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'bounty-controller-')); + await fs.mkdir(path.join(workspace, '.bounty-loop'), { recursive: true }); + await fs.copyFile(example, path.join(workspace, '.bounty-loop', 'program.json')); + const result = await run([ + 'run', + '--workspace', workspace, + '--objective', 'Validate local object authorization', + '--dry-run', + ], workspace); + assert.equal(result.code, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.status, 'DRY_RUN_VALIDATED'); + assert.deepEqual(output.stages, [ + 'discovery', + 'reproduction', + 'blind_verification', + 'impact_review', + 'deterministic_gate', + 'report_draft', + 'human_review', + ]); +}); + +test('full controller run keeps submission manual and writes a report after all gates pass', async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'bounty-controller-')); + await fs.mkdir(path.join(workspace, '.bounty-loop'), { recursive: true }); + await fs.copyFile(example, path.join(workspace, '.bounty-loop', 'program.json')); + + const fakeOpenCode = path.join(workspace, 'fake-opencode.mjs'); + await fs.writeFile(fakeOpenCode, `#!/usr/bin/env node +const args = process.argv.slice(2); +const agent = args[args.indexOf('--agent') + 1]; +const outputs = { + 'bounty-discovery': { candidates: [{ id: 'idor-test', title: 'Test object authorization', url: 'http://127.0.0.1:3000/api/items/2', method: 'GET', hypothesis: 'test user A may read test user B object', preconditions: ['two controlled users'], planned_checks: ['request A object', 'negative control'], confidence: 0.6 }] }, + 'bounty-reproduction': { reproduced: true, fresh_state_reproduced: true, negative_control_passed: true, controlled_accounts_only: true, unauthorized_data_observed: false, service_instability_observed: false, evidence_refs: ['evidence/repro.json', 'evidence/control.json'], observed_result: 'controlled cross-account object was returned', rejection_reason: '' }, + 'bounty-verifier': { independent: true, reproduced: true, fresh_state: true, negative_control: true, security_boundary_crossed: true, intended_behavior_likely: false, evidence_refs: ['evidence/verify.json'], disproof_attempts: ['tested public visibility'], rejection_reason: '' }, + 'bounty-impact': { concrete_impact: true, affected_security_properties: ['confidentiality'], attacker_capability: 'authenticated test user', victim_role: 'another test user', requires_unrealistic_cooperation: false, excluded_by_policy: false, duplicate_likely: false, severity: 'medium', reasoning_summary: 'cross-account read' }, + 'bounty-report': { title: 'Cross-account object read', markdown: '# Summary\\nA controlled cross-account read was independently reproduced.\\n', suggested_severity: 'medium', cwe: 'CWE-639', reviewer_notes: [] } +}; +if (!outputs[agent]) process.exit(3); +process.stdout.write(JSON.stringify(outputs[agent])); +`, { mode: 0o700 }); + + const child = spawn(process.execPath, [controller, + 'run', + '--workspace', workspace, + '--objective', 'Validate local object authorization', + '--case-id', 'integration-case', + ], { + cwd: workspace, + env: { ...process.env, OPENCODE_BIN: fakeOpenCode }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + const code = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + + assert.equal(code, 0, stderr); + const output = JSON.parse(stdout); + assert.equal(output.status, 'READY_FOR_HUMAN_REVIEW'); + assert.deepEqual(output.ready_for_human_review, ['idor-test']); + assert.match(output.note, /No report was submitted/); + const report = await fs.readFile(path.join(workspace, '.bounty-loop', 'cases', 'integration-case', 'reports', 'idor-test.md'), 'utf8'); + assert.match(report, /cross-account read/i); + const state = JSON.parse(await fs.readFile(path.join(workspace, '.bounty-loop', 'cases', 'integration-case', 'state.json'), 'utf8')); + assert.equal(state.status, 'READY_FOR_HUMAN_REVIEW'); +}); diff --git a/bug-bounty-loop/tests/http-wrapper.test.mjs b/bug-bounty-loop/tests/http-wrapper.test.mjs new file mode 100644 index 0000000..968f0ce --- /dev/null +++ b/bug-bounty-loop/tests/http-wrapper.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const wrapper = path.resolve(testDir, '..', 'bin', 'bounty-http.mjs'); + +function run(args, cwd) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [wrapper, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); +} + +async function withServer(handler, fn) { + const server = http.createServer(handler); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + try { + await fn(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +function localManifest(origin) { + return { + mode: 'local_lab', + authorization: { confirmed: true }, + scope: { + allowed_origins: [origin], + allowed_path_prefixes: ['/api'], + excluded_origins: [], + excluded_path_prefixes: ['/api/private'], + allowed_methods: ['GET'], + max_requests_per_minute: 2, + max_total_requests_per_case: 2, + max_response_bytes: 4096, + follow_redirects: false, + allow_private_networks: true, + identification_headers: { Authorization: 'Bearer test-secret' }, + }, + safety: { allow_state_change: false }, + reporting: { auto_submit: false }, + }; +} + +test('wrapper permits scoped local-lab request, saves body, and redacts secret header', async () => { + await withServer((request, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ ok: true, path: request.url })); + }, async origin => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'bounty-http-')); + const manifestPath = path.join(workspace, '.bounty-loop', 'program.json'); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, JSON.stringify(localManifest(origin))); + const output = path.join(workspace, '.bounty-loop', 'cases', 'case-1', 'evidence', 'response.json'); + + const result = await run([ + '--manifest', '.bounty-loop/program.json', + '--method', 'GET', + '--url', `${origin}/api/items`, + '--output', '.bounty-loop/cases/case-1/evidence/response.json', + ], workspace); + + assert.equal(result.code, 0, result.stderr); + const summary = JSON.parse(result.stdout); + assert.equal(summary.ok, true); + assert.match(summary.request.headers.authorization, /REDACTED/); + assert.deepEqual(JSON.parse(await fs.readFile(output, 'utf8')), { ok: true, path: '/api/items' }); + }); +}); + +test('wrapper blocks excluded paths before request', async () => { + let requests = 0; + await withServer((_request, response) => { + requests += 1; + response.end('unexpected'); + }, async origin => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'bounty-http-')); + const manifestPath = path.join(workspace, '.bounty-loop', 'program.json'); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, JSON.stringify(localManifest(origin))); + + const result = await run([ + '--manifest', '.bounty-loop/program.json', + '--url', `${origin}/api/private/users`, + ], workspace); + + assert.notEqual(result.code, 0); + assert.match(result.stderr, /excluded/); + assert.equal(requests, 0); + }); +}); + +test('wrapper enforces per-case total request budget', async () => { + await withServer((_request, response) => response.end('ok'), async origin => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'bounty-http-')); + const manifest = localManifest(origin); + manifest.scope.max_requests_per_minute = 10; + manifest.scope.max_total_requests_per_case = 1; + const manifestPath = path.join(workspace, '.bounty-loop', 'program.json'); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + + const first = await run(['--manifest', '.bounty-loop/program.json', '--url', `${origin}/api/one`], workspace); + const second = await run(['--manifest', '.bounty-loop/program.json', '--url', `${origin}/api/two`], workspace); + assert.equal(first.code, 0, first.stderr); + assert.notEqual(second.code, 0); + assert.match(second.stderr, /budget exhausted/); + }); +}); diff --git a/bug-bounty-loop/tests/manifest-and-gates.test.mjs b/bug-bounty-loop/tests/manifest-and-gates.test.mjs new file mode 100644 index 0000000..48736c5 --- /dev/null +++ b/bug-bounty-loop/tests/manifest-and-gates.test.mjs @@ -0,0 +1,150 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateManifest, evaluateUrlScope } from '../lib/manifest.mjs'; +import { evaluateSubmissionGates } from '../lib/gates.mjs'; + +function validManifest() { + return { + schema_version: 1, + mode: 'authorized_program', + program: { + name: 'Example', + platform: 'direct', + policy_url: 'https://example.com/security', + policy_snapshot_date: '2026-07-23', + }, + authorization: { + confirmed: true, + confirmed_by: 'Researcher', + testing_identity: 'researcher-id', + notes: '', + }, + scope: { + allowed_origins: ['https://example.com'], + allowed_path_prefixes: ['/api'], + excluded_origins: [], + excluded_path_prefixes: ['/api/admin'], + allowed_methods: ['GET', 'HEAD', 'OPTIONS'], + max_requests_per_minute: 6, + max_total_requests_per_case: 40, + max_response_bytes: 1024 * 1024, + follow_redirects: false, + allow_private_networks: false, + identification_headers: { 'X-Bug-Bounty': 'researcher-id' }, + }, + safety: { + only_owned_test_accounts: true, + allow_state_change: false, + stop_on_real_user_data: true, + stop_on_service_instability: true, + prohibited_tests: ['denial of service'], + }, + reporting: { + human_approval_required: true, + auto_submit: false, + }, + }; +} + +test('valid manifest normalizes and passes', () => { + const result = validateManifest(validManifest()); + assert.equal(result.valid, true, result.errors.join('\n')); + assert.deepEqual(result.manifest.scope.allowed_origins, ['https://example.com']); +}); + +test('manifest rejects automatic submission and unconfirmed authorization', () => { + const manifest = validManifest(); + manifest.authorization.confirmed = false; + manifest.reporting.auto_submit = true; + const result = validateManifest(manifest); + assert.equal(result.valid, false); + assert.match(result.errors.join('\n'), /authorization\.confirmed/); + assert.match(result.errors.join('\n'), /auto_submit/); +}); + +test('manifest rejects state-changing methods unless explicitly allowed', () => { + const manifest = validManifest(); + manifest.scope.allowed_methods.push('POST'); + const result = validateManifest(manifest); + assert.equal(result.valid, false); + assert.match(result.errors.join('\n'), /state-changing/); +}); + +test('scope is exact by origin and path', () => { + const manifest = validateManifest(validManifest()).manifest; + assert.equal(evaluateUrlScope(manifest, 'https://example.com/api/items/1', 'GET').allowed, true); + assert.equal(evaluateUrlScope(manifest, 'https://sub.example.com/api/items/1', 'GET').allowed, false); + assert.equal(evaluateUrlScope(manifest, 'https://example.com/api/admin/users', 'GET').allowed, false); + assert.equal(evaluateUrlScope(manifest, 'https://example.com/public', 'GET').allowed, false); +}); + +test('all-pass gate yields READY_FOR_HUMAN_REVIEW', () => { + const manifest = validateManifest(validManifest()).manifest; + const result = evaluateSubmissionGates({ + manifest, + candidate: { url: 'https://example.com/api/items/1', method: 'GET' }, + reproduction: { + reproduced: true, + fresh_state_reproduced: true, + negative_control_passed: true, + controlled_accounts_only: true, + unauthorized_data_observed: false, + service_instability_observed: false, + evidence_refs: ['evidence/repro-1.json', 'evidence/control.json'], + }, + verification: { + independent: true, + reproduced: true, + fresh_state: true, + negative_control: true, + security_boundary_crossed: true, + intended_behavior_likely: false, + evidence_refs: ['evidence/verify.json'], + }, + impact: { + concrete_impact: true, + affected_security_properties: ['confidentiality'], + requires_unrealistic_cooperation: false, + excluded_by_policy: false, + duplicate_likely: false, + }, + }); + assert.equal(result.passed, true); + assert.equal(result.status, 'READY_FOR_HUMAN_REVIEW'); +}); + +test('one uncertain control quarantines the candidate', () => { + const manifest = validateManifest(validManifest()).manifest; + const result = evaluateSubmissionGates({ + manifest, + candidate: { url: 'https://example.com/api/items/1', method: 'GET' }, + reproduction: { + reproduced: true, + fresh_state_reproduced: true, + negative_control_passed: false, + controlled_accounts_only: true, + unauthorized_data_observed: false, + service_instability_observed: false, + evidence_refs: ['evidence/repro.json'], + }, + verification: { + independent: true, + reproduced: true, + fresh_state: true, + negative_control: true, + security_boundary_crossed: true, + intended_behavior_likely: false, + evidence_refs: ['evidence/verify.json'], + }, + impact: { + concrete_impact: true, + affected_security_properties: ['confidentiality'], + requires_unrealistic_cooperation: false, + excluded_by_policy: false, + duplicate_likely: false, + }, + }); + assert.equal(result.passed, false); + assert.equal(result.status, 'QUARANTINED'); + assert.match(result.failures.join('\n'), /negative control/); +}); diff --git a/commands/bounty-init.md b/commands/bounty-init.md new file mode 100644 index 0000000..6ac3e0e --- /dev/null +++ b/commands/bounty-init.md @@ -0,0 +1,6 @@ +--- +agent: bounty-orchestrator +description: Initialize the deterministic bug-bounty validation loop in the current workspace. +--- + +Initialize the bug-bounty validation loop in the current workspace. Run the controller's `init --workspace .` command, then explain which fields in `.bounty-loop/program.json` the user must copy from the current bounty policy. Do not set authorization.confirmed to true for the user. diff --git a/commands/bounty.md b/commands/bounty.md new file mode 100644 index 0000000..eb4f304 --- /dev/null +++ b/commands/bounty.md @@ -0,0 +1,10 @@ +--- +agent: bounty-orchestrator +description: Run a scoped, human-supervised bug-bounty validation case. +--- + +Run the deterministic bug-bounty validation loop for this objective: + +$ARGUMENTS + +First validate `.bounty-loop/program.json`. If it is valid and authorization is explicitly confirmed, run the controller with the exact objective. Summarize ready-for-human-review and quarantined candidates. Do not submit any report. diff --git a/docs/bug-bounty-loop.md b/docs/bug-bounty-loop.md new file mode 100644 index 0000000..a494cf6 --- /dev/null +++ b/docs/bug-bounty-loop.md @@ -0,0 +1,76 @@ +# Bug Bounty Validation Loop + +The bug-bounty extension uses the repository's specialized-agent approach but deliberately does not route active testing through the general feature-development orchestrator. + +## Authority split + +The deterministic controller owns: + +- scope-manifest validation +- exact URL and method checks +- stage order +- candidate limits +- scoped HTTP tooling +- request budgets +- hard-stop events +- evidence presence +- all-pass reportability gates +- local human-approval records + +LLM agents own bounded semantic tasks: + +- hypothesis generation +- minimal reproduction planning and execution +- independent verification +- adversarial impact review +- report drafting + +No model may expand scope, alter authorization, change request budgets, bypass the wrapper, or submit a report. + +## Why it is separate from `/feature` + +The main `/feature` workflow operates on a local repository and delegates build, test, and review roles through the patched OpenCode runtime. Bug-bounty work has materially different invariants: + +- authorization is external and asset-specific +- network requests require exact scope and rate controls +- real-user data and service instability are hard-stop conditions +- independent reproduction must be blind to the finder's confidence and severity +- a candidate can be technically reproducible but still non-reportable +- submission must remain manual + +Keeping the module separate avoids granting the development orchestrator network-testing authority or weakening the existing policy kernel. + +## Recommended operating sequence + +1. Copy the current program policy into `.bounty-loop/program.json`. +2. Record the policy snapshot date and required researcher identity headers. +3. Confirm exact allowed origins, paths, methods, exclusions, and limits. +4. Keep state changes disabled unless the policy and test plan require them. +5. Run one narrow objective with a maximum of three candidates. +6. Review hard-stop and quarantine events before further testing. +7. Inspect raw requests, controls, and response hashes for any passing candidate. +8. Search the program's known issues, public disclosures, changelog, CVEs, and repository advisories manually. +9. Record human approval only after the report remains valid. +10. Submit manually through the program's official channel. + +## False-positive controls + +The loop intentionally favors false negatives over wasting reviewer time. It suppresses: + +- scanner output without manual reproduction +- version disclosure without an exploitable path +- missing headers without demonstrated impact +- status-code differences without a boundary +- public-data exposure claims +- self-XSS and open redirects without a meaningful chain +- speculative dependency CVEs +- rate-limit observations without sustained security impact +- findings requiring unrealistic victim behavior +- third-party or excluded assets +- candidates not repeated from fresh state +- candidates without a negative control +- candidates the independent verifier cannot reproduce + +## Security limitations + +OpenCode permissions and the wrapper are application-level safeguards, not an operating-system sandbox. Run the loop in a dedicated VM or container, keep credentials limited to bounty test accounts, and do not mount unrelated secrets or production data.