hackerone-mcp-server 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,701 @@
1
+ import fetch from "node-fetch";
2
+ const H1_BASE = "https://api.hackerone.com/v1";
3
+ const cache = new Map();
4
+ const CACHE_TTL_MS = 60_000; // 1 minute
5
+ function cacheGet(key) {
6
+ const entry = cache.get(key);
7
+ if (!entry)
8
+ return undefined;
9
+ if (Date.now() > entry.expiresAt) {
10
+ cache.delete(key);
11
+ return undefined;
12
+ }
13
+ return entry.data;
14
+ }
15
+ function cacheSet(key, data) {
16
+ cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS });
17
+ }
18
+ function cacheInvalidatePrefix(prefix) {
19
+ for (const key of cache.keys()) {
20
+ if (key.startsWith(prefix))
21
+ cache.delete(key);
22
+ }
23
+ }
24
+ // ── Auth ──────────────────────────────────────────────────────────
25
+ function getAuth() {
26
+ const username = process.env.H1_USERNAME;
27
+ const token = process.env.H1_API_TOKEN;
28
+ if (!username || !token) {
29
+ throw new Error("Missing H1_USERNAME or H1_API_TOKEN environment variables");
30
+ }
31
+ return Buffer.from(`${username}:${token}`).toString("base64");
32
+ }
33
+ // ── HTTP helpers with retry + backoff ─────────────────────────────
34
+ async function h1Fetch(path, params, options) {
35
+ const url = new URL(`${H1_BASE}${path}`);
36
+ if (params) {
37
+ for (const [k, v] of Object.entries(params)) {
38
+ if (v != null && v !== "")
39
+ url.searchParams.set(k, v);
40
+ }
41
+ }
42
+ const cacheKey = url.toString();
43
+ if (!options?.skipCache) {
44
+ const cached = cacheGet(cacheKey);
45
+ if (cached)
46
+ return cached;
47
+ }
48
+ let lastErr = null;
49
+ for (let attempt = 0; attempt < 3; attempt++) {
50
+ if (attempt > 0) {
51
+ await sleep(1000 * Math.pow(2, attempt)); // 2s, 4s
52
+ }
53
+ try {
54
+ const res = await fetch(url.toString(), {
55
+ redirect: "error",
56
+ headers: {
57
+ Authorization: `Basic ${getAuth()}`,
58
+ Accept: "application/json",
59
+ },
60
+ });
61
+ if (res.status === 429) {
62
+ const retryAfter = res.headers.get("retry-after");
63
+ const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
64
+ await sleep(waitMs);
65
+ continue;
66
+ }
67
+ if (!res.ok) {
68
+ const body = await res.text();
69
+ throw new Error(`HackerOne API error ${res.status}: ${body}`);
70
+ }
71
+ const json = await res.json();
72
+ cacheSet(cacheKey, json);
73
+ return json;
74
+ }
75
+ catch (err) {
76
+ lastErr = err;
77
+ if (err.message?.includes("HackerOne API error"))
78
+ throw err;
79
+ }
80
+ }
81
+ throw lastErr ?? new Error("h1Fetch failed after retries");
82
+ }
83
+ async function h1Post(path, body, contentType = "application/json") {
84
+ const url = `${H1_BASE}${path}`;
85
+ let lastErr = null;
86
+ for (let attempt = 0; attempt < 3; attempt++) {
87
+ if (attempt > 0)
88
+ await sleep(1000 * Math.pow(2, attempt));
89
+ try {
90
+ const res = await fetch(url, {
91
+ method: "POST",
92
+ headers: {
93
+ Authorization: `Basic ${getAuth()}`,
94
+ Accept: "application/json",
95
+ "Content-Type": contentType,
96
+ },
97
+ body: typeof body === "string" ? body : JSON.stringify(body),
98
+ });
99
+ if (res.status === 429) {
100
+ const retryAfter = res.headers.get("retry-after");
101
+ await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000);
102
+ continue;
103
+ }
104
+ if (!res.ok) {
105
+ const text = await res.text();
106
+ throw new Error(`HackerOne API error ${res.status}: ${text}`);
107
+ }
108
+ // Invalidate caches that may be stale after a write
109
+ cacheInvalidatePrefix(`${H1_BASE}/hackers/me/reports`);
110
+ cacheInvalidatePrefix(`${H1_BASE}/hackers/reports`);
111
+ const text = await res.text();
112
+ return text ? JSON.parse(text) : {};
113
+ }
114
+ catch (err) {
115
+ lastErr = err;
116
+ if (err.message?.includes("HackerOne API error"))
117
+ throw err;
118
+ }
119
+ }
120
+ throw lastErr ?? new Error("h1Post failed after retries");
121
+ }
122
+ function sleep(ms) {
123
+ return new Promise((r) => setTimeout(r, ms));
124
+ }
125
+ // ── Auto-pagination helper ────────────────────────────────────────
126
+ function isObject(value) {
127
+ return value !== null && typeof value === "object" && !Array.isArray(value);
128
+ }
129
+ async function h1FetchAllPages(path, limit) {
130
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
131
+ throw new Error("page_size must be a positive integer");
132
+ }
133
+ const endpoint = new URL(`${H1_BASE}${path}`);
134
+ let url = new URL(endpoint);
135
+ url.searchParams.set("page[size]", String(Math.min(limit ?? 100, 100)));
136
+ url.searchParams.set("page[number]", "1");
137
+ const visited = new Set();
138
+ const all = [];
139
+ while (true) {
140
+ url.searchParams.sort();
141
+ if (visited.has(url.href)) {
142
+ throw new Error(`Repeated HackerOne pagination link for ${path}`);
143
+ }
144
+ visited.add(url.href);
145
+ const page = await h1Fetch(url.href.slice(H1_BASE.length));
146
+ if (!isObject(page) || !Array.isArray(page.data) ||
147
+ page.data.some((item) => !isObject(item) || typeof item.id !== "string" ||
148
+ typeof item.type !== "string" || !isObject(item.attributes)) ||
149
+ (page.links !== undefined && !isObject(page.links))) {
150
+ throw new Error(`Malformed HackerOne page for ${path}: expected a data array of resources and optional links object`);
151
+ }
152
+ const next = page.links?.next;
153
+ let nextUrl;
154
+ if (next !== undefined && next !== null) {
155
+ if (typeof next !== "string" || !next.trim()) {
156
+ throw new Error(`Malformed HackerOne pagination link for ${path}`);
157
+ }
158
+ try {
159
+ nextUrl = new URL(next, url);
160
+ }
161
+ catch {
162
+ throw new Error(`Malformed HackerOne pagination link for ${path}`);
163
+ }
164
+ if (nextUrl.protocol !== "https:" || nextUrl.origin !== endpoint.origin ||
165
+ nextUrl.pathname !== endpoint.pathname || nextUrl.username ||
166
+ nextUrl.password || nextUrl.hash) {
167
+ throw new Error(`HackerOne pagination link leaves the original HTTPS API endpoint for ${path}`);
168
+ }
169
+ }
170
+ all.push(...page.data.slice(0, limit === undefined ? undefined : limit - all.length));
171
+ if (all.length === limit || !nextUrl)
172
+ break;
173
+ url = nextUrl;
174
+ }
175
+ return all;
176
+ }
177
+ export async function searchReports(opts = {}) {
178
+ const needsFilter = !!(opts.program ||
179
+ opts.severity ||
180
+ opts.state ||
181
+ opts.query);
182
+ const requestedSize = opts.page_size ?? 25;
183
+ const fetchSize = needsFilter ? 100 : requestedSize;
184
+ const pageNumber = opts.page_number ?? 1;
185
+ let allReports = [];
186
+ if (needsFilter) {
187
+ // Build server-side filter params where possible
188
+ const serverParams = {
189
+ "page[size]": "100",
190
+ "page[number]": "1",
191
+ };
192
+ if (opts.program) {
193
+ serverParams["filter[program][]"] = opts.program;
194
+ }
195
+ if (opts.severity) {
196
+ serverParams["filter[severity][]"] = opts.severity;
197
+ }
198
+ if (opts.state) {
199
+ serverParams["filter[state][]"] = opts.state;
200
+ }
201
+ // If we have server-side filters (not just keyword), use them
202
+ const hasServerFilters = !!(opts.program || opts.severity || opts.state);
203
+ if (hasServerFilters) {
204
+ // Fetch with server-side filters, paginate until we have enough
205
+ for (let page = 1; page <= 20; page++) {
206
+ serverParams["page[number]"] = String(page);
207
+ const data = await h1Fetch("/hackers/me/reports", serverParams);
208
+ if (!data.data || data.data.length === 0)
209
+ break;
210
+ allReports.push(...data.data);
211
+ if (data.data.length < 100)
212
+ break;
213
+ if (!opts.query && allReports.length >= requestedSize)
214
+ break;
215
+ }
216
+ }
217
+ else {
218
+ // Keyword-only: fall back to client-side search with backward pagination
219
+ const probeRes = await h1Fetch("/hackers/me/reports", {
220
+ "page[size]": "100",
221
+ "page[number]": "1",
222
+ });
223
+ if (probeRes.data?.length === 100) {
224
+ let lo = 1, hi = 50;
225
+ while (lo < hi) {
226
+ const mid = Math.ceil((lo + hi) / 2);
227
+ const check = await h1Fetch("/hackers/me/reports", {
228
+ "page[size]": "100",
229
+ "page[number]": String(mid),
230
+ });
231
+ if (check.data?.length > 0) {
232
+ lo = mid;
233
+ if (check.data.length < 100)
234
+ break;
235
+ hi = Math.max(hi, mid + 5);
236
+ }
237
+ else {
238
+ hi = mid - 1;
239
+ }
240
+ }
241
+ for (let page = lo; page >= 1; page--) {
242
+ const data = page === 1 && probeRes.data
243
+ ? probeRes
244
+ : await h1Fetch("/hackers/me/reports", {
245
+ "page[size]": "100",
246
+ "page[number]": String(page),
247
+ });
248
+ if (!data.data || data.data.length === 0)
249
+ continue;
250
+ allReports.push(...data.data);
251
+ const tempFiltered = allReports.filter((r) => {
252
+ const q = opts.query.toLowerCase();
253
+ const title = r.attributes.title?.toLowerCase() ?? "";
254
+ const vuln = r.attributes.vulnerability_information?.toLowerCase() ?? "";
255
+ const weakness = r.relationships?.weakness?.data?.attributes?.name?.toLowerCase() ??
256
+ "";
257
+ return (title.includes(q) || vuln.includes(q) || weakness.includes(q));
258
+ });
259
+ if (tempFiltered.length >= requestedSize)
260
+ break;
261
+ }
262
+ }
263
+ else {
264
+ allReports = probeRes.data ?? [];
265
+ }
266
+ }
267
+ }
268
+ else {
269
+ const data = await h1Fetch("/hackers/me/reports", {
270
+ "page[size]": String(fetchSize),
271
+ "page[number]": String(pageNumber),
272
+ });
273
+ allReports = data.data ?? [];
274
+ }
275
+ let reports = allReports.map((r) => mapReportSummary(r));
276
+ // Client-side filtering (keyword always needs this; program/severity/state as fallback)
277
+ if (opts.program) {
278
+ const prog = opts.program.toLowerCase();
279
+ reports = reports.filter((r) => r.program?.toLowerCase() === prog);
280
+ }
281
+ if (opts.severity) {
282
+ reports = reports.filter((r) => r.severity === opts.severity);
283
+ }
284
+ if (opts.state) {
285
+ reports = reports.filter((r) => r.state === opts.state);
286
+ }
287
+ if (opts.query) {
288
+ const q = opts.query.toLowerCase();
289
+ reports = reports.filter((r) => r.title?.toLowerCase().includes(q) ||
290
+ r._vuln_info?.toLowerCase().includes(q) ||
291
+ r.weakness?.toLowerCase().includes(q));
292
+ }
293
+ if (opts.sort) {
294
+ const desc = opts.sort.startsWith("-");
295
+ const field = opts.sort.replace(/^-/, "").replace("reports.", "");
296
+ reports.sort((a, b) => {
297
+ const va = a[field] ?? "";
298
+ const vb = b[field] ?? "";
299
+ return desc ? (vb > va ? 1 : -1) : va > vb ? 1 : -1;
300
+ });
301
+ }
302
+ if (needsFilter) {
303
+ reports = reports.slice(0, requestedSize);
304
+ }
305
+ return reports.map(({ _vuln_info, ...rest }) => rest);
306
+ }
307
+ function mapReportSummary(r) {
308
+ const bounty = r.relationships?.bounties?.data?.[0]?.attributes;
309
+ return {
310
+ id: r.id,
311
+ title: r.attributes.title,
312
+ state: r.attributes.state,
313
+ substate: r.attributes.substate,
314
+ severity: r.attributes.severity_rating,
315
+ created_at: r.attributes.created_at,
316
+ disclosed_at: r.attributes.disclosed_at,
317
+ bounty_awarded_at: r.attributes.bounty_awarded_at,
318
+ bounty_amount: bounty?.amount ?? null,
319
+ bounty_bonus: bounty?.bonus_amount ?? null,
320
+ _vuln_info: r.attributes.vulnerability_information,
321
+ weakness: r.relationships?.weakness?.data?.attributes?.name ?? null,
322
+ program: r.relationships?.program?.data?.attributes?.handle ?? null,
323
+ };
324
+ }
325
+ // ── Get single report (with full CVSS + bounty) ──────────────────
326
+ export async function getReport(reportId) {
327
+ const data = await h1Fetch(`/hackers/reports/${reportId}`);
328
+ const r = data.data;
329
+ const attrs = r.attributes;
330
+ const sev = r.relationships?.severity?.data?.attributes;
331
+ const bounty = r.relationships?.bounties?.data?.[0]?.attributes;
332
+ const attachments = r.relationships?.attachments?.data ?? [];
333
+ return {
334
+ id: r.id,
335
+ title: attrs.title,
336
+ state: attrs.state,
337
+ created_at: attrs.created_at,
338
+ closed_at: attrs.closed_at,
339
+ triaged_at: attrs.triaged_at,
340
+ bounty_awarded_at: attrs.bounty_awarded_at,
341
+ disclosed_at: attrs.disclosed_at,
342
+ severity: sev?.rating ?? null,
343
+ cvss_score: sev?.score ?? null,
344
+ cvss_vector: sev?.attack_vector
345
+ ? {
346
+ attack_vector: sev.attack_vector,
347
+ attack_complexity: sev.attack_complexity,
348
+ privileges_required: sev.privileges_required,
349
+ user_interaction: sev.user_interaction,
350
+ scope: sev.scope,
351
+ confidentiality: sev.confidentiality,
352
+ integrity: sev.integrity,
353
+ availability: sev.availability,
354
+ }
355
+ : null,
356
+ bounty_amount: bounty?.amount ?? null,
357
+ bounty_bonus: bounty?.bonus_amount ?? null,
358
+ vulnerability_information: attrs.vulnerability_information,
359
+ impact: attrs.impact,
360
+ weakness: r.relationships?.weakness?.data?.attributes?.name ?? null,
361
+ weakness_id: r.relationships?.weakness?.data?.attributes?.external_id ?? null,
362
+ program: r.relationships?.program?.data?.attributes?.handle ?? null,
363
+ structured_scope: r.relationships?.structured_scope?.data?.attributes?.asset_identifier ??
364
+ null,
365
+ structured_scope_type: r.relationships?.structured_scope?.data?.attributes?.asset_type ?? null,
366
+ attachments: attachments.map((a) => ({
367
+ id: a.id,
368
+ file_name: a.attributes?.file_name,
369
+ content_type: a.attributes?.content_type,
370
+ file_size: a.attributes?.file_size,
371
+ expiring_url: a.attributes?.expiring_url,
372
+ })),
373
+ };
374
+ }
375
+ // ── Get report activities (comments, state changes) ────────────────
376
+ export async function getReportActivities(reportId, _pageSize = 50) {
377
+ const data = await h1Fetch(`/hackers/reports/${reportId}`);
378
+ const activities = data.data?.relationships?.activities?.data ?? [];
379
+ return activities.map((a) => ({
380
+ id: a.id,
381
+ type: a.type,
382
+ message: a.attributes.message,
383
+ created_at: a.attributes.created_at,
384
+ internal: a.attributes.internal,
385
+ automated_response: a.attributes.automated_response,
386
+ actor_type: a.relationships?.actor?.data?.type ?? null,
387
+ actor: a.relationships?.actor?.data?.attributes?.username ??
388
+ a.relationships?.actor?.data?.attributes?.name ??
389
+ null,
390
+ }));
391
+ }
392
+ // ── List programs (auto-paginated) ────────────────────────────────
393
+ export async function listPrograms(pageSize) {
394
+ const allData = await h1FetchAllPages("/hackers/programs", pageSize);
395
+ return allData.map((p) => ({
396
+ id: p.id,
397
+ handle: p.attributes.handle,
398
+ name: p.attributes.name,
399
+ offers_bounties: p.attributes.offers_bounties,
400
+ state: p.attributes.state,
401
+ started_accepting_at: p.attributes.started_accepting_at,
402
+ submission_state: p.attributes.submission_state,
403
+ }));
404
+ }
405
+ // ── Get program details ───────────────────────────────────────────
406
+ export async function getProgramDetails(handle) {
407
+ const data = await h1Fetch(`/hackers/programs/${handle}`);
408
+ const p = isObject(data) && "data" in data ? data.data : data;
409
+ if (!isObject(p) || p.type !== "program" || typeof p.id !== "string" ||
410
+ !isObject(p.attributes) || typeof p.attributes.handle !== "string") {
411
+ throw new Error(`Malformed HackerOne program response for ${handle}: expected a program resource with id and attributes.handle`);
412
+ }
413
+ const attrs = p.attributes;
414
+ return {
415
+ id: p.id,
416
+ handle: attrs.handle,
417
+ name: attrs.name,
418
+ url: attrs.url,
419
+ offers_bounties: attrs.offers_bounties,
420
+ state: attrs.state,
421
+ submission_state: attrs.submission_state,
422
+ started_accepting_at: attrs.started_accepting_at,
423
+ policy: attrs.policy,
424
+ response_efficiency_percentage: attrs.response_efficiency_percentage ?? null,
425
+ average_time_to_first_program_response: attrs.average_time_to_first_program_response ?? null,
426
+ average_time_to_report_resolved: attrs.average_time_to_report_resolved ?? null,
427
+ average_time_to_bounty_awarded: attrs.average_time_to_bounty_awarded ?? null,
428
+ allow_bounty_splitting: attrs.allows_bounty_splitting ?? null,
429
+ bookmarked: attrs.bookmarked,
430
+ };
431
+ }
432
+ // ── Get public program signal requirements ────────────────────────
433
+ export async function getProgramSignalRequirements(handle) {
434
+ handle = handle.trim();
435
+ if (!handle)
436
+ throw new Error("program_handle must not be empty");
437
+ const cacheKey = `program-signal-requirements:${handle}`;
438
+ const cached = cacheGet(cacheKey);
439
+ if (cached)
440
+ return cached;
441
+ // shortcut: uses the public website API; switch when the Hacker API exposes this field.
442
+ const source = "https://hackerone.com/graphql";
443
+ const res = await fetch(source, {
444
+ method: "POST",
445
+ redirect: "error",
446
+ signal: AbortSignal.timeout(15_000),
447
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
448
+ body: JSON.stringify({
449
+ query: `query ProgramSignalRequirements($handle: String!) {
450
+ team(handle: $handle) {
451
+ handle
452
+ signal_requirements_setting { target_signal }
453
+ }
454
+ }`,
455
+ variables: { handle },
456
+ }),
457
+ });
458
+ if (!res.ok) {
459
+ throw new Error(`HackerOne GraphQL HTTP error ${res.status}: ${await res.text()}`);
460
+ }
461
+ const data = await res.json();
462
+ if (data?.errors && (!Array.isArray(data.errors) || data.errors.length > 0)) {
463
+ throw new Error(`HackerOne GraphQL error: ${JSON.stringify(data.errors)}`);
464
+ }
465
+ const team = data?.data?.team;
466
+ if (team === null) {
467
+ throw new Error(`Program ${handle} was not found or is not publicly accessible; signal requirements are unavailable`);
468
+ }
469
+ const setting = team?.signal_requirements_setting;
470
+ if (!isObject(team) || typeof team.handle !== "string" ||
471
+ (setting !== null && (!isObject(setting) ||
472
+ (setting.target_signal !== null && (typeof setting.target_signal !== "number" || !Number.isFinite(setting.target_signal)))))) {
473
+ throw new Error(`Malformed HackerOne signal requirements response for ${handle}`);
474
+ }
475
+ const target = setting?.target_signal ?? null;
476
+ const result = {
477
+ handle: team.handle,
478
+ signal_requirement: target === -10 ? null : target,
479
+ signal_requirement_enabled: target === null ? null : target !== -10,
480
+ source,
481
+ };
482
+ cacheSet(cacheKey, result);
483
+ return result;
484
+ }
485
+ // ── Get program scope (auto-paginated) ────────────────────────────
486
+ export async function getProgramScope(handle, pageSize) {
487
+ const allData = await h1FetchAllPages(`/hackers/programs/${handle}/structured_scopes`, pageSize);
488
+ return allData.map((s) => ({
489
+ id: s.id,
490
+ asset_type: s.attributes.asset_type,
491
+ asset_identifier: s.attributes.asset_identifier,
492
+ eligible_for_bounty: s.attributes.eligible_for_bounty,
493
+ eligible_for_submission: s.attributes.eligible_for_submission,
494
+ instruction: s.attributes.instruction,
495
+ max_severity: s.attributes.max_severity,
496
+ created_at: s.attributes.created_at,
497
+ }));
498
+ }
499
+ // ── Get program weaknesses (auto-paginated) ───────────────────────
500
+ export async function getProgramWeaknesses(handle, pageSize) {
501
+ const allData = await h1FetchAllPages(`/hackers/programs/${handle}/weaknesses`, pageSize);
502
+ return allData.map((w) => ({
503
+ id: w.id,
504
+ name: w.attributes.name,
505
+ description: w.attributes.description,
506
+ external_id: w.attributes.external_id,
507
+ }));
508
+ }
509
+ // ── Get earnings ──────────────────────────────────────────────────
510
+ export async function getEarnings(pageSize = 100) {
511
+ const data = await h1Fetch("/hackers/payments/earnings", {
512
+ "page[size]": String(pageSize),
513
+ });
514
+ return data.data.map((e) => ({
515
+ id: e.id,
516
+ amount: e.attributes.amount,
517
+ awarded_by: e.attributes.awarded_by_name,
518
+ created_at: e.attributes.created_at,
519
+ currency: e.relationships?.program?.data?.attributes?.currency ?? null,
520
+ program: e.relationships?.program?.data?.attributes?.handle ?? null,
521
+ }));
522
+ }
523
+ // ── Get hacker profile ────────────────────────────────────────────
524
+ export async function getHackerProfile() {
525
+ const reports = await h1FetchAllPages("/hackers/me/reports");
526
+ const u = reports
527
+ .map((report) => report.relationships?.reporter?.data)
528
+ .find((reporter) => isObject(reporter) && reporter.type === "user" &&
529
+ isObject(reporter.attributes) &&
530
+ typeof reporter.attributes.username === "string" &&
531
+ reporter.attributes.username === process.env.H1_USERNAME);
532
+ const attrs = u?.attributes;
533
+ return {
534
+ id: u?.id ?? null,
535
+ username: attrs?.username ?? null,
536
+ name: attrs?.name ?? null,
537
+ bio: attrs?.bio ?? null,
538
+ reputation: attrs?.reputation ?? null,
539
+ signal: attrs?.signal ?? null,
540
+ impact: attrs?.impact ?? null,
541
+ rank: attrs?.rank ?? null,
542
+ created_at: attrs?.created_at ?? null,
543
+ hackerone_triager: attrs?.hackerone_triager ?? null,
544
+ source: "/hackers/me/reports reporter relationship",
545
+ unavailable_reason: u ? null : reports.length === 0
546
+ ? "No reports available; profile data and statistics are unavailable."
547
+ : "No reporter username matches H1_USERNAME; an API identifier may differ from the account username.",
548
+ };
549
+ }
550
+ // ── Get balance ───────────────────────────────────────────────────
551
+ export async function getBalance() {
552
+ const data = await h1Fetch("/hackers/payments/balance");
553
+ // The balance endpoint may return differently; handle both formats
554
+ if (data.data) {
555
+ const attrs = data.data.attributes ?? data.data;
556
+ return {
557
+ balance: attrs.balance ?? attrs.amount ?? null,
558
+ currency: attrs.currency ?? null,
559
+ pending: attrs.pending ?? null,
560
+ };
561
+ }
562
+ return data;
563
+ }
564
+ // ── Get report summary (condensed for Claude context) ──────────────
565
+ export async function getReportSummary(reportId) {
566
+ const report = await getReport(reportId);
567
+ const activities = await getReportActivities(reportId);
568
+ const comments = activities.filter((a) => a.message &&
569
+ !a.automated_response &&
570
+ (a.type === "activity-comment" ||
571
+ a.type === "activity-bug-triaged" ||
572
+ a.type === "activity-bug-resolved" ||
573
+ a.type === "activity-bounty-awarded"));
574
+ return {
575
+ ...report,
576
+ conversation: comments.map((c) => ({
577
+ from: c.actor ?? c.actor_type,
578
+ type: c.type.replace("activity-", ""),
579
+ message: c.message,
580
+ date: c.created_at,
581
+ })),
582
+ };
583
+ }
584
+ // ── Submit report ─────────────────────────────────────────────────
585
+ export async function submitReport(opts) {
586
+ const relationships = {
587
+ program: {
588
+ data: {
589
+ type: "program",
590
+ attributes: { handle: opts.program_handle },
591
+ },
592
+ },
593
+ };
594
+ if (opts.weakness_id) {
595
+ relationships.weakness = {
596
+ data: { type: "weakness", id: opts.weakness_id },
597
+ };
598
+ }
599
+ if (opts.structured_scope_id) {
600
+ relationships.structured_scope = {
601
+ data: { type: "structured-scope", id: opts.structured_scope_id },
602
+ };
603
+ }
604
+ const severity = {};
605
+ if (opts.severity_rating) {
606
+ severity.rating = opts.severity_rating;
607
+ }
608
+ const body = {
609
+ data: {
610
+ type: "report",
611
+ attributes: {
612
+ team_handle: opts.program_handle,
613
+ title: opts.title,
614
+ vulnerability_information: opts.vulnerability_information,
615
+ impact: opts.impact ?? "",
616
+ severity_rating: opts.severity_rating,
617
+ },
618
+ relationships,
619
+ },
620
+ };
621
+ const result = await h1Post("/hackers/reports", body);
622
+ const r = result.data;
623
+ return {
624
+ id: r.id,
625
+ title: r.attributes?.title,
626
+ state: r.attributes?.state,
627
+ url: `https://hackerone.com/reports/${r.id}`,
628
+ };
629
+ }
630
+ // ── Add comment to report ─────────────────────────────────────────
631
+ export async function addComment(reportId, message, internal = false) {
632
+ const body = {
633
+ data: {
634
+ type: "activity-comment",
635
+ attributes: {
636
+ message,
637
+ internal,
638
+ },
639
+ },
640
+ };
641
+ const result = await h1Post(`/hackers/reports/${reportId}/activities`, body);
642
+ return {
643
+ id: result.data?.id,
644
+ type: result.data?.type,
645
+ message: result.data?.attributes?.message,
646
+ created_at: result.data?.attributes?.created_at,
647
+ };
648
+ }
649
+ // ── Close / withdraw report ───────────────────────────────────────
650
+ export async function closeReport(reportId, message) {
651
+ const body = {
652
+ data: {
653
+ type: "activity-hacker-requested-mediation",
654
+ attributes: {
655
+ message: message ?? "Withdrawing this report.",
656
+ close_report: true,
657
+ },
658
+ },
659
+ };
660
+ // Attempt the state change via activities
661
+ const result = await h1Post(`/hackers/reports/${reportId}/activities`, body);
662
+ return {
663
+ id: result.data?.id,
664
+ type: result.data?.type,
665
+ message: result.data?.attributes?.message,
666
+ created_at: result.data?.attributes?.created_at,
667
+ };
668
+ }
669
+ // ── Search disclosed reports ──────────────────────────────────────
670
+ export async function searchDisclosedReports(opts) {
671
+ // The hacktivity endpoint for disclosed reports
672
+ const params = {
673
+ "page[size]": String(opts.page_size ?? 25),
674
+ };
675
+ if (opts.program) {
676
+ params["filter[team_handle][]"] = opts.program;
677
+ }
678
+ const data = await h1Fetch("/hackers/hacktivity", params, {
679
+ skipCache: true,
680
+ });
681
+ let reports = (data.data ?? []).map((r) => ({
682
+ id: r.id,
683
+ title: r.attributes?.title ?? r.attributes?.raw_title,
684
+ severity: r.attributes?.severity_rating,
685
+ disclosed_at: r.attributes?.disclosed_at,
686
+ total_awarded_amount: r.attributes?.total_awarded_amount,
687
+ upvotes: r.attributes?.vote_count ?? r.attributes?.upvotes,
688
+ url: r.attributes?.url ?? `https://hackerone.com/reports/${r.id}`,
689
+ reporter: r.relationships?.reporter?.data?.attributes?.username ?? null,
690
+ program: r.relationships?.team?.data?.attributes?.handle ??
691
+ r.relationships?.program?.data?.attributes?.handle ??
692
+ null,
693
+ weakness: r.relationships?.weakness?.data?.attributes?.name ?? null,
694
+ }));
695
+ if (opts.query) {
696
+ const q = opts.query.toLowerCase();
697
+ reports = reports.filter((r) => r.title?.toLowerCase().includes(q) ||
698
+ r.weakness?.toLowerCase().includes(q));
699
+ }
700
+ return reports;
701
+ }