HackerOne Hacktivity Processor — Design Document
1. Overview
Purpose: Fetch publicly disclosed bug bounty reports from HackerOne’s unauthenticated GraphQL API and store them across CVEMetadata (+ relations), Exploit, and BugBountySubmission tables.
Data source: HackerOne GraphQL API — POST https://hackerone.com/graphql
API notes:
- Unauthenticated — returns only publicly disclosed reports
- Relay-style cursor pagination (
first/after/pageInfo) - Supports incremental filtering:
where: {disclosed_at: {_gt: "ISO8601"}} - No
order_bysupport — default order is most-recently-disclosed first - Introspection disabled — schema discovered empirically (April 2026)
- No documented rate limits, but aggressive bot detection — browser UA headers required
Schedule: Daily at 06:15 UTC (cron cron(15 6 * * ? *) — Terraform EventBridge)
Timeout: 60 minutes
Resources: 256 CPU units, 512 MB memory
What it reads:
- HackerOne GraphQL API (public, no auth)
What it writes:
BugBountySubmission(source=hackerone— every report)Exploit(source=hackerone— every report, category=bug-bounty)ExploitCVE(linking reports to CVE records)ExploitAffectedProduct(vendor/product from team info)CVEMetadata(source=hackerone) — every report gets a primary record keyedH1-<reportId>(main.go:478), not only reports that carry a CVE ID. Reports that do carrycve_idsadditionally get a minimal FK-target row per aliased CVE (EnsureMinimalCVEMetadata, main.go:559) whosedatePublishedis0and which holds no content of its own — those rows exist to satisfy theCVEAliasforeign key and must be excluded from any coverage metric.CVEDescription,CVEMetadataReferences,CVEMetric,CVEProblemType(CVE relation tables)CVEAlias(viadb.InsertAliases—H1-<reportId>↔ eachcve_idsentry)BulkDataDumpTracker(source=hackerone_hacktivity— cursor/freshness)
Environment variables:
DATABASE_URL— requiredDATABASE_URL_READ— optional
2. GraphQL API Details
Query Structure
{
reports(first: 100, after: "cursor", where: {disclosed_at: {_gt: "2026-04-01T00:00:00Z"}}) {
pageInfo { hasNextPage endCursor }
edges {
cursor
node {
id title url state substate
created_at disclosed_at closed_at cve_ids
severity {
rating score attack_vector attack_complexity
privileges_required user_interaction scope
confidentiality integrity availability
}
weakness { id external_id name description }
team { handle name url }
reporter { username name }
structured_scope { asset_identifier asset_type }
bounties { edges { node { amount bonus_amount } } }
}
}
}
}
Available Top-Level Queries
| Query | Type | Auth Required |
|---|---|---|
reports(first, after, where) | ReportConnection | No |
teams(first) | TeamConnection | No |
Field Availability
| Field | Type | Notes |
|---|---|---|
id | String | Base64 global ID (e.g., Z2lkOi8vaGFja2Vyb25lL1JlcG9ydC8zNjU4MDQ5) |
title | String | Report title |
url | String | https://hackerone.com/reports/NNNN |
state | String? | Usually null for disclosed reports |
substate | String? | informative, not-applicable, resolved, duplicate |
created_at | String | ISO 8601 timestamp |
disclosed_at | String? | ISO 8601 — when publicly disclosed |
closed_at | String? | ISO 8601 or null |
cve_ids | [String] | Array of CVE IDs (often empty) |
vulnerability_information | String? | Always null (redacted for unauthenticated access) |
severity.rating | String? | low, medium, high, critical |
severity.score | Float? | Numeric CVSS score (often null) |
severity.* | String? | CVSS components (often all null) |
weakness.external_id | String | CWE ID in format cwe-NNN |
weakness.name | String | CWE name |
team.handle | String | Program slug (e.g., curl, github) |
team.name | String | Program display name |
reporter.username | String | Hacker’s username |
structured_scope.asset_identifier | String | Asset URL or identifier |
structured_scope.asset_type | String | URL, SOURCE_CODE, etc. |
bounties[].amount | String | Bounty amount as decimal string |
bounties[].bonus_amount | String | Bonus amount as decimal string |
Filtering
The where argument accepts FiltersReportFilterInput with at least:
disclosed_at: { _gt: "ISO8601", _is_null: Boolean }reporter: ...(untested)
No order_by — results are ordered by disclosed_at descending by default.
Sample Responses
Report with CVE and bounty:
{
"title": "Add labels to arbitrary issues/prs & compromise github actions label checks",
"url": "https://hackerone.com/reports/3527771",
"cve_ids": ["CVE-2026-3306"],
"severity": { "rating": "medium" },
"weakness": { "external_id": "cwe-639", "name": "Improper Access Control - Generic" },
"team": { "handle": "github" },
"bounties": { "edges": [{ "node": { "amount": "0.00", "bonus_amount": "0.00" } }] }
}
Report without CVE:
{
"title": "libcurl: Integer truncation in curl_easy_ssls_import()",
"url": "https://hackerone.com/reports/3658049",
"cve_ids": [],
"severity": { "rating": "medium" },
"weakness": null,
"team": { "handle": "curl", "name": "curl" },
"bounties": { "edges": [] }
}
3. Business Logic
Processing Strategy
Single phase — paginate all new reports:
- Check
BulkDataDumpTrackerfor last run timestamp - If fresh (< frequency), skip with
NoWork - Build incremental filter:
disclosed_at._gt= last run time (ISO 8601) - First run (no tracker): full backfill with no date filter
- Paginate through all matching reports (100 per page)
- Batch upsert every 500 reports in a single transaction with SAVEPOINTs
Per-Report Processing
Every report (regardless of CVE presence):
UpsertBugBountySubmission— full report metadataUpsertExploit— disclosed reports are exploit/PoC evidenceDeleteExploitAffectedProducts+InsertExploitAffectedProduct— from team infoUpsertCVEMetadataforH1-<reportId>— title,datePublishedfromdisclosed_at, vendor/product from the teamInsertReferences— link to HackerOne report URLInsertProblemTypes— CWE from weakness (if present)InsertMetrics— CVSS 3.1 from severity (if present)InsertDescriptions— report title as descriptionInsertExploitCVE— junction between the Exploit and theH1-record
Reports with cve_ids (additionally, for each CVE ID):
10. InsertAliases — H1-<reportId> ↔ CVE edge (the canonical alias write path)
11. EnsureMinimalCVEMetadata — guarantee the alias FK target exists
12. InsertExploitCVE — junction between the Exploit and the real CVE
A report whose disclosed_at is null lands with CVEMetadata.datePublished = 0
(main.go:479) — in practice HackerOne always sets disclosed_at on the reports the
unauthenticated API returns, so every H1- row in production carries a real date.
CVSS Vector Construction
HackerOne severity components are mapped to CVSS 3.1 abbreviations:
| H1 Value | CVSS Abbrev | Component |
|---|---|---|
network | N | Attack Vector |
adjacent | A | Attack Vector |
local | L | Attack Vector |
physical | P | Attack Vector |
low | L | Attack Complexity / Score Impact |
high | H | Attack Complexity / Score Impact |
none | N | Privileges Required / User Interaction |
required | R | User Interaction |
unchanged | U | Scope |
changed | C | Scope |
Vector is only constructed when all 8 components are present. If any is nil, only baseSeverity and baseScore are stored.
Rate Limiting & Retry
- 1-second delay between GraphQL page fetches
- Single retry with 5-second backoff on fetch failure
- Soft deadline: 50 minutes (configurable via
EXPECTED_DURATION_MINUTES)
Idempotency
All writes use ON CONFLICT DO UPDATE or ON CONFLICT DO NOTHING. Safe to re-run. The BugBountySubmission and Exploit tables use (reportId, source) and (exploitId, source) unique constraints respectively.
4. Architecture Diagram
5. Deployment Diagram
go-hackerone-json-processor] TASKDEF --> EB[EventBridge Schedule
cron 15 6 * * ? *] EB -->|trigger daily| FARGATE[ECS Fargate
vdb-scheduler cluster] FARGATE -->|POST GraphQL| H1[hackerone.com/graphql] FARGATE --> WRITE[RDS Write Proxy]
6. Processing Flow
hackerone_hacktivity} TRACKER -->|fresh| NOWORK([NoWork — exit 0]) TRACKER -->|stale or missing| FILTER[Build disclosed_at filter
from last run timestamp] FILTER --> PAGE[Fetch page of 100 reports
POST /graphql] PAGE -->|error| RETRY[Retry once after 5s] RETRY -->|error| STOP[Stop pagination] PAGE -->|success| BUFFER[Add reports to batch buffer] BUFFER -->|>= 500| PROCESS[processBatch in WithTx] BUFFER -->|< 500| NEXT{hasNextPage?} PROCESS --> NEXT NEXT -->|yes| SLEEP[Sleep 1s rate limit] SLEEP --> PAGE NEXT -->|no| FINAL[Process remaining batch] STOP --> FINAL FINAL --> UPDATE[UpsertTracker] UPDATE --> DONE{Errors?} DONE -->|yes| ERRORED([Errored — exit 1]) DONE -->|no| COMPLETED([Completed — exit 0])
7. Data Mapping
8. Field Mapping Reference
| HackerOne Field | Target Table.Column |
|---|---|
url (numeric part) | BugBountySubmission.reportId, Exploit.exploitId |
title | BugBountySubmission.title, Exploit.title, CVEDescription.value, CVEMetadata.title |
url (numeric part) | CVEMetadata.cveId as H1-<reportId> (source=hackerone) |
cve_ids[i] | CVEAlias edge from H1-<reportId>, minimal CVEMetadata FK row, ExploitCVE.cveId |
disclosed_at | CVEMetadata.datePublished, Exploit.datePublished, BugBountySubmission.disclosedAt |
severity.rating | CVEMetric.baseSeverity, BugBountySubmission.severityRating |
severity.score | CVEMetric.baseScore, BugBountySubmission.severityScore |
severity.{components} | CVEMetric.vectorString (constructed CVSS:3.1 vector) |
weakness.external_id | CVEProblemType.cweId (normalized CWE-NNN) |
weakness.name | CVEProblemType.description, BugBountySubmission.weaknessName |
team.handle | CVEMetadata.affectedVendor, ExploitAffectedProduct.vendor, BugBountySubmission.teamHandle |
team.name | CVEMetadata.affectedProduct, ExploitAffectedProduct.product, BugBountySubmission.teamName |
reporter.username | Exploit.author, BugBountySubmission.reporterUsername |
reporter.name | BugBountySubmission.reporterName |
structured_scope.asset_type | Exploit.platform, BugBountySubmission.assetType |
structured_scope.asset_identifier | ExploitAffectedProduct.homepage, BugBountySubmission.assetIdentifier |
bounties[0].amount | BugBountySubmission.bountyAmount |
bounties[0].bonus_amount | BugBountySubmission.bonusAmount |
substate | BugBountySubmission.substate |
url | CVEMetadataReferences.url, Exploit.originalUrl, BugBountySubmission.reportUrl |
9. New Table: BugBountySubmission
Migration SQL (idempotent)
CREATE TABLE IF NOT EXISTS "BugBountySubmission" (
"uuid" TEXT NOT NULL,
"reportId" TEXT NOT NULL,
"source" TEXT NOT NULL,
"title" TEXT NOT NULL,
"reportUrl" TEXT,
"substate" TEXT,
"teamHandle" TEXT,
"teamName" TEXT,
"reporterUsername" TEXT,
"reporterName" TEXT,
"bountyAmount" TEXT,
"bonusAmount" TEXT,
"assetIdentifier" TEXT,
"assetType" TEXT,
"cveIds" TEXT,
"weaknessCweId" TEXT,
"weaknessName" TEXT,
"severityRating" TEXT,
"severityScore" DOUBLE PRECISION,
"createdAtSource" BIGINT,
"disclosedAt" BIGINT,
"closedAt" BIGINT,
"createdAt" BIGINT NOT NULL,
"updatedAt" BIGINT,
CONSTRAINT "BugBountySubmission_pkey" PRIMARY KEY ("uuid")
);
CREATE UNIQUE INDEX IF NOT EXISTS "BugBountySubmission_reportId_source_key"
ON "BugBountySubmission"("reportId", "source");
CREATE INDEX IF NOT EXISTS "BugBountySubmission_source_idx"
ON "BugBountySubmission"("source");
CREATE INDEX IF NOT EXISTS "BugBountySubmission_teamHandle_idx"
ON "BugBountySubmission"("teamHandle");
CREATE INDEX IF NOT EXISTS "BugBountySubmission_substate_idx"
ON "BugBountySubmission"("substate");
CREATE INDEX IF NOT EXISTS "BugBountySubmission_severityRating_idx"
ON "BugBountySubmission"("severityRating");
CREATE INDEX IF NOT EXISTS "BugBountySubmission_disclosedAt_idx"
ON "BugBountySubmission"("disclosedAt");
CREATE INDEX IF NOT EXISTS "BugBountySubmission_reporterUsername_idx"
ON "BugBountySubmission"("reporterUsername");
Design Rationale
- Source-agnostic:
sourcecolumn supports future Bugcrowd/Intigriti processors - Bounty as string: Preserves decimal precision without float rounding
- CWE denormalized:
weaknessCweIdenables fast filtering without JOIN - Unique constraint:
(reportId, source)prevents duplicates per platform - No FK to CVEMetadata: Reports may have zero CVE IDs; CVE linkage is handled via
Exploit/ExploitCVE
10. Infrastructure
| System | Name | Value |
|---|---|---|
| cmd/ directory | hackerone-json-processor | cmd/hackerone-json-processor/main.go |
| Containerfile target | hackerone-json-processor | FROM scratch AS hackerone-json-processor |
| ECR image tag | go-hackerone-json-processor-latest | |
| ECS task family | go-hackerone-json-processor | |
| EventBridge schedule | go-hackerone-json-processor | cron(15 6 * * ? *) |
| CloudWatch log group | /ecs/vdb-scheduler/go-hackerone-json-processor | |
| task-manager.toml | [tasks.hackerone-json-processor] | |
| Terraform module | hackerone_json_processor | terraform/go-schedules.tf |
| justfile recipe | go-hackerone-json-backfill | |
| ecr-build targets | simpleTargets | targets.go |
| ECR hook | post-push-ecr.sh TARGETS |
11. Verification
# Local test
just go-hackerone-json-backfill
# Check BugBountySubmission
psql "$DATABASE_URL" -c 'SELECT COUNT(*) FROM "BugBountySubmission" WHERE source = '\''hackerone'\'''
# Check CVE population
psql "$DATABASE_URL" -c 'SELECT COUNT(*) FROM "CVEMetadata" WHERE source = '\''hackerone'\'''
# Check Exploit population
psql "$DATABASE_URL" -c 'SELECT COUNT(*) FROM "Exploit" WHERE source = '\''hackerone'\'''
# Check junctions
psql "$DATABASE_URL" -c 'SELECT COUNT(*) FROM "ExploitCVE" WHERE source = '\''hackerone'\'''
# Idempotency — run again, counts should not increase
just go-hackerone-json-backfill
# Docker build
podman build --target hackerone-json-processor -f Containerfile.go-processors scripts/go-processors/
S3 Persistence
- Archive path:
hackerone/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/hackerone-json-processor/{YYYY-MM-DD}/{reason}/{filename}✓ - Failure reasons emitted:
store-error
The unit of work is a record, not a file, so each report is re-serialised to
canonical JSON and keyed by {reportId}.json (main.go:310). Payload bytes are
computed before the transaction opens so no DB connection is held during the S3
PUT; committed reports are archived and failed ones quarantined after the
transaction resolves (main.go:292-305). Skipped when S3_BUCKET_NAME is unset.