Skip to content

Commit 3a856a0

Browse files
R3dTr4pclaude
andcommitted
add bulk download of HackerOne reports as Markdown
New `bbscope reports h1` command fetches all reports via the Hacker API and saves them as structured Markdown files organized by program handle. Supports --dry-run, --program/--state/--severity filters, and --overwrite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d37bc3d commit 3a856a0

5 files changed

Lines changed: 515 additions & 0 deletions

File tree

cmd/reports.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
var reportsCmd = &cobra.Command{
8+
Use: "reports",
9+
Short: "Download bug bounty reports as Markdown files",
10+
}
11+
12+
func init() {
13+
rootCmd.AddCommand(reportsCmd)
14+
15+
reportsCmd.PersistentFlags().String("output-dir", "reports", "Output directory for downloaded reports")
16+
reportsCmd.PersistentFlags().StringSlice("program", nil, "Filter by program handle(s)")
17+
reportsCmd.PersistentFlags().StringSlice("state", nil, "Filter by report state(s) (e.g. resolved,triaged)")
18+
reportsCmd.PersistentFlags().StringSlice("severity", nil, "Filter by severity (e.g. high,critical)")
19+
reportsCmd.PersistentFlags().Bool("dry-run", false, "List reports without downloading")
20+
reportsCmd.PersistentFlags().Bool("overwrite", false, "Overwrite existing report files")
21+
}

cmd/reports_h1.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"text/tabwriter"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/viper"
11+
"github.com/sw33tLie/bbscope/v2/internal/utils"
12+
"github.com/sw33tLie/bbscope/v2/pkg/reports"
13+
"github.com/sw33tLie/bbscope/v2/pkg/whttp"
14+
)
15+
16+
var reportsH1Cmd = &cobra.Command{
17+
Use: "h1",
18+
Short: "Download reports from HackerOne",
19+
RunE: func(cmd *cobra.Command, _ []string) error {
20+
user := viper.GetString("hackerone.username")
21+
token := viper.GetString("hackerone.token")
22+
if user == "" || token == "" {
23+
utils.Log.Error("hackerone requires a username and token")
24+
return nil
25+
}
26+
27+
proxy, _ := rootCmd.Flags().GetString("proxy")
28+
if proxy != "" {
29+
whttp.SetupProxy(proxy)
30+
}
31+
32+
outputDir, _ := cmd.Flags().GetString("output-dir")
33+
programs, _ := cmd.Flags().GetStringSlice("program")
34+
states, _ := cmd.Flags().GetStringSlice("state")
35+
severities, _ := cmd.Flags().GetStringSlice("severity")
36+
dryRun, _ := cmd.Flags().GetBool("dry-run")
37+
overwrite, _ := cmd.Flags().GetBool("overwrite")
38+
39+
fetcher := reports.NewH1Fetcher(user, token)
40+
opts := reports.FetchOptions{
41+
Programs: programs,
42+
States: states,
43+
Severities: severities,
44+
DryRun: dryRun,
45+
Overwrite: overwrite,
46+
OutputDir: outputDir,
47+
}
48+
49+
return runReportsH1(cmd.Context(), fetcher, opts)
50+
},
51+
}
52+
53+
func init() {
54+
reportsCmd.AddCommand(reportsH1Cmd)
55+
reportsH1Cmd.Flags().StringP("user", "u", "", "HackerOne username")
56+
reportsH1Cmd.Flags().StringP("token", "t", "", "HackerOne API token")
57+
viper.BindPFlag("hackerone.username", reportsH1Cmd.Flags().Lookup("user"))
58+
viper.BindPFlag("hackerone.token", reportsH1Cmd.Flags().Lookup("token"))
59+
}
60+
61+
func runReportsH1(ctx context.Context, fetcher *reports.H1Fetcher, opts reports.FetchOptions) error {
62+
utils.Log.Info("Fetching report list from HackerOne...")
63+
64+
summaries, err := fetcher.ListReports(ctx, opts)
65+
if err != nil {
66+
return fmt.Errorf("listing reports: %w", err)
67+
}
68+
69+
utils.Log.Infof("Found %d reports", len(summaries))
70+
71+
if len(summaries) == 0 {
72+
return nil
73+
}
74+
75+
// Dry-run: print table and exit
76+
if opts.DryRun {
77+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
78+
fmt.Fprintln(w, "ID\tPROGRAM\tSTATE\tSEVERITY\tCREATED\tTITLE")
79+
for _, s := range summaries {
80+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
81+
s.ID, s.ProgramHandle, s.Substate, s.SeverityRating, s.CreatedAt, s.Title)
82+
}
83+
w.Flush()
84+
return nil
85+
}
86+
87+
// Download mode
88+
var written, skipped, errored int
89+
total := len(summaries)
90+
91+
for i, s := range summaries {
92+
utils.Log.Infof("[%d/%d] Fetching report %s: %s", i+1, total, s.ID, s.Title)
93+
94+
report, err := fetcher.FetchReport(ctx, s.ID)
95+
if err != nil {
96+
utils.Log.Warnf("Error fetching report %s: %v", s.ID, err)
97+
errored++
98+
continue
99+
}
100+
101+
ok, err := reports.WriteReport(report, opts.OutputDir, opts.Overwrite)
102+
if err != nil {
103+
utils.Log.Warnf("Error writing report %s: %v", s.ID, err)
104+
errored++
105+
continue
106+
}
107+
108+
if ok {
109+
written++
110+
} else {
111+
skipped++
112+
}
113+
}
114+
115+
utils.Log.Infof("Done: %d written, %d skipped, %d errors", written, skipped, errored)
116+
return nil
117+
}

pkg/reports/hackerone.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package reports
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/sw33tLie/bbscope/v2/internal/utils"
12+
"github.com/sw33tLie/bbscope/v2/pkg/whttp"
13+
"github.com/tidwall/gjson"
14+
)
15+
16+
// H1Fetcher fetches reports from the HackerOne Hacker API.
17+
type H1Fetcher struct {
18+
authB64 string
19+
}
20+
21+
// NewH1Fetcher creates a fetcher using the same base64 auth pattern as the poller.
22+
func NewH1Fetcher(username, token string) *H1Fetcher {
23+
raw := username + ":" + token
24+
return &H1Fetcher{authB64: base64.StdEncoding.EncodeToString([]byte(raw))}
25+
}
26+
27+
// ListReports fetches all report summaries matching the given options.
28+
func (f *H1Fetcher) ListReports(ctx context.Context, opts FetchOptions) ([]ReportSummary, error) {
29+
var summaries []ReportSummary
30+
31+
queryFilter := buildQueryFilter(opts)
32+
currentURL := "https://api.hackerone.com/v1/hackers/me/reports?page%5Bsize%5D=100"
33+
if queryFilter != "" {
34+
currentURL += "&filter%5Bkeyword%5D=" + queryFilter
35+
}
36+
37+
for {
38+
body, err := f.doRequest(currentURL)
39+
if err != nil {
40+
return summaries, err
41+
}
42+
if body == "" {
43+
break // non-retryable status, stop
44+
}
45+
46+
count := int(gjson.Get(body, "data.#").Int())
47+
for i := 0; i < count; i++ {
48+
prefix := "data." + strconv.Itoa(i)
49+
summary := ReportSummary{
50+
ID: gjson.Get(body, prefix+".id").String(),
51+
Title: gjson.Get(body, prefix+".attributes.title").String(),
52+
State: gjson.Get(body, prefix+".attributes.state").String(),
53+
Substate: gjson.Get(body, prefix+".attributes.substate").String(),
54+
CreatedAt: gjson.Get(body, prefix+".attributes.created_at").String(),
55+
SeverityRating: gjson.Get(body, prefix+".relationships.severity.data.attributes.rating").String(),
56+
}
57+
58+
// Program handle from relationships
59+
summary.ProgramHandle = gjson.Get(body, prefix+".relationships.program.data.attributes.handle").String()
60+
61+
summaries = append(summaries, summary)
62+
}
63+
64+
nextURL := gjson.Get(body, "links.next").String()
65+
if nextURL == "" {
66+
break
67+
}
68+
currentURL = nextURL
69+
}
70+
71+
return summaries, nil
72+
}
73+
74+
// FetchReport fetches the full detail of a single report by ID.
75+
func (f *H1Fetcher) FetchReport(ctx context.Context, reportID string) (*Report, error) {
76+
url := "https://api.hackerone.com/v1/hackers/reports/" + reportID
77+
78+
body, err := f.doRequest(url)
79+
if err != nil {
80+
return nil, err
81+
}
82+
if body == "" {
83+
return nil, fmt.Errorf("report %s: not found or not accessible", reportID)
84+
}
85+
86+
r := &Report{
87+
ID: gjson.Get(body, "data.id").String(),
88+
Title: gjson.Get(body, "data.attributes.title").String(),
89+
State: gjson.Get(body, "data.attributes.state").String(),
90+
Substate: gjson.Get(body, "data.attributes.substate").String(),
91+
CreatedAt: formatTimestamp(gjson.Get(body, "data.attributes.created_at").String()),
92+
TriagedAt: formatTimestamp(gjson.Get(body, "data.attributes.triaged_at").String()),
93+
ClosedAt: formatTimestamp(gjson.Get(body, "data.attributes.closed_at").String()),
94+
DisclosedAt: formatTimestamp(gjson.Get(body, "data.attributes.disclosed_at").String()),
95+
VulnerabilityInformation: gjson.Get(body, "data.attributes.vulnerability_information").String(),
96+
Impact: gjson.Get(body, "data.attributes.impact").String(),
97+
ProgramHandle: gjson.Get(body, "data.relationships.program.data.attributes.handle").String(),
98+
SeverityRating: gjson.Get(body, "data.relationships.severity.data.attributes.rating").String(),
99+
CVSSScore: gjson.Get(body, "data.relationships.severity.data.attributes.score").String(),
100+
WeaknessName: gjson.Get(body, "data.relationships.weakness.data.attributes.name").String(),
101+
WeaknessCWE: gjson.Get(body, "data.relationships.weakness.data.attributes.external_id").String(),
102+
AssetIdentifier: gjson.Get(body, "data.relationships.structured_scope.data.attributes.asset_identifier").String(),
103+
}
104+
105+
// Bounty amounts — sum all bounty relationships
106+
var totalBounty float64
107+
bounties := gjson.Get(body, "data.relationships.bounties.data")
108+
if bounties.Exists() {
109+
bounties.ForEach(func(_, v gjson.Result) bool {
110+
totalBounty += v.Get("attributes.amount").Float()
111+
return true
112+
})
113+
}
114+
if totalBounty > 0 {
115+
r.BountyAmount = fmt.Sprintf("%.2f", totalBounty)
116+
}
117+
118+
// CVE IDs
119+
cves := gjson.Get(body, "data.attributes.cve_ids")
120+
if cves.Exists() {
121+
cves.ForEach(func(_, v gjson.Result) bool {
122+
if id := v.String(); id != "" {
123+
r.CVEIDs = append(r.CVEIDs, id)
124+
}
125+
return true
126+
})
127+
}
128+
129+
return r, nil
130+
}
131+
132+
// doRequest performs an authenticated GET with retries and rate-limit handling.
133+
func (f *H1Fetcher) doRequest(url string) (string, error) {
134+
retries := 3
135+
for retries > 0 {
136+
res, err := whttp.SendHTTPRequest(&whttp.WHTTPReq{
137+
Method: "GET",
138+
URL: url,
139+
Headers: []whttp.WHTTPHeader{{Name: "Authorization", Value: "Basic " + f.authB64}},
140+
}, nil)
141+
142+
if err != nil {
143+
retries--
144+
utils.Log.Warnf("HTTP request failed (%s), retrying: %v", url, err)
145+
time.Sleep(2 * time.Second)
146+
continue
147+
}
148+
149+
// Rate limited
150+
if res.StatusCode == 429 {
151+
utils.Log.Warn("Rate limited by HackerOne, waiting 60s...")
152+
time.Sleep(60 * time.Second)
153+
continue // don't decrement retries for rate limits
154+
}
155+
156+
// Non-retryable errors
157+
if res.StatusCode == 400 || res.StatusCode == 403 || res.StatusCode == 404 {
158+
utils.Log.Warnf("Got status %d for %s, skipping", res.StatusCode, url)
159+
return "", nil
160+
}
161+
162+
if res.StatusCode != 200 {
163+
retries--
164+
utils.Log.Warnf("Got status %d for %s, retrying", res.StatusCode, url)
165+
time.Sleep(2 * time.Second)
166+
continue
167+
}
168+
169+
return res.BodyString, nil
170+
}
171+
172+
return "", fmt.Errorf("failed to fetch %s after retries", url)
173+
}
174+
175+
// buildQueryFilter builds a Lucene-syntax filter string for the H1 API.
176+
func buildQueryFilter(opts FetchOptions) string {
177+
var parts []string
178+
179+
if len(opts.Programs) > 0 {
180+
for _, p := range opts.Programs {
181+
parts = append(parts, "team:"+p)
182+
}
183+
}
184+
185+
if len(opts.States) > 0 {
186+
for _, s := range opts.States {
187+
parts = append(parts, "substate:"+s)
188+
}
189+
}
190+
191+
if len(opts.Severities) > 0 {
192+
for _, s := range opts.Severities {
193+
parts = append(parts, "severity_rating:"+s)
194+
}
195+
}
196+
197+
return strings.Join(parts, " ")
198+
}
199+
200+
// formatTimestamp converts an ISO 8601 timestamp to a human-readable format.
201+
func formatTimestamp(ts string) string {
202+
if ts == "" {
203+
return ""
204+
}
205+
t, err := time.Parse(time.RFC3339, ts)
206+
if err != nil {
207+
return ts // return as-is if unparseable
208+
}
209+
return t.UTC().Format("2006-01-02 15:04 UTC")
210+
}

pkg/reports/types.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package reports
2+
3+
// Report holds the full detail of a single HackerOne report.
4+
type Report struct {
5+
ID string
6+
Title string
7+
State string
8+
Substate string
9+
CreatedAt string
10+
TriagedAt string
11+
ClosedAt string
12+
DisclosedAt string
13+
VulnerabilityInformation string
14+
Impact string
15+
CVEIDs []string
16+
ProgramHandle string
17+
SeverityRating string
18+
CVSSScore string
19+
WeaknessName string
20+
WeaknessCWE string
21+
AssetIdentifier string
22+
BountyAmount string
23+
}
24+
25+
// ReportSummary is the lightweight version returned by the list endpoint.
26+
type ReportSummary struct {
27+
ID string
28+
Title string
29+
State string
30+
Substate string
31+
ProgramHandle string
32+
SeverityRating string
33+
CreatedAt string
34+
}
35+
36+
// FetchOptions controls which reports to fetch and how to save them.
37+
type FetchOptions struct {
38+
Programs []string
39+
States []string
40+
Severities []string
41+
DryRun bool
42+
Overwrite bool
43+
OutputDir string
44+
}

0 commit comments

Comments
 (0)