hackerone-mcp-server 0.0.0-stage → 1.1.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,960 @@
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 public program rewards ───────────────────────────────────
486
+ export async function getProgramRewards(handle) {
487
+ handle = handle.trim();
488
+ if (!handle)
489
+ throw new Error("program_handle must not be empty");
490
+ const cacheKey = `program-rewards:${handle}`;
491
+ const cached = cacheGet(cacheKey);
492
+ if (cached)
493
+ return cached;
494
+ // shortcut: uses the public website API; switch when the Hacker API exposes reward groups.
495
+ const result = {
496
+ handle, currency: null, source: "https://hackerone.com/graphql",
497
+ program_url: `https://hackerone.com/${encodeURIComponent(handle)}`,
498
+ format: null, description: null, availability: "unavailable", issues: [], groups: null,
499
+ };
500
+ const issue = (code, message, group_id = null) => {
501
+ result.issues.push({ code, group_id, message });
502
+ };
503
+ const request = async (query, variables = {}) => {
504
+ const res = await fetch(result.source, {
505
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
506
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
507
+ body: JSON.stringify({ query, variables: { handle, ...variables } }),
508
+ });
509
+ if (!res.ok)
510
+ throw new Error(`HackerOne GraphQL HTTP error ${res.status}`);
511
+ const body = await res.json();
512
+ if (body?.errors !== undefined && (!Array.isArray(body.errors) || body.errors.length)) {
513
+ if (body.data?.team === null && Array.isArray(body.errors) &&
514
+ body.errors.length && body.errors.every((e) => e?.type === "NOT_FOUND"))
515
+ return null;
516
+ throw new Error("HackerOne GraphQL returned errors");
517
+ }
518
+ const team = body?.data?.team;
519
+ if (team !== null && !isObject(team))
520
+ throw new Error("Malformed HackerOne team response");
521
+ return team;
522
+ };
523
+ const scopeFields = `_id asset_identifier asset_type eligible_for_submission eligible_for_bounty`;
524
+ const rewardsQuery = `query ProgramRewards($handle: String!, $after: String) {
525
+ team(handle: $handle) {
526
+ handle currency scope_and_rewards_groups_enabled
527
+ bounty_table {
528
+ _id description low_label medium_label high_label critical_label
529
+ bounty_table_rows(first: 100, after: $after) {
530
+ pageInfo { hasNextPage endCursor }
531
+ edges { node {
532
+ _id name description use_range
533
+ low low_minimum medium medium_minimum high high_minimum critical critical_minimum
534
+ structured_scope { ${scopeFields} }
535
+ } }
536
+ }
537
+ }
538
+ }
539
+ }`;
540
+ const rows = new Map();
541
+ const groups = [];
542
+ const nextCursor = (connection, seen) => {
543
+ const info = connection?.pageInfo;
544
+ if (!isObject(info) || typeof info.hasNextPage !== "boolean") {
545
+ throw new Error("Missing reward pagination metadata");
546
+ }
547
+ if (!connection.edges.length && seen.size)
548
+ throw new Error("Prematurely empty reward page");
549
+ if (!info.hasNextPage)
550
+ return null;
551
+ if (!connection.edges.length || typeof info.endCursor !== "string" ||
552
+ !info.endCursor || seen.has(info.endCursor)) {
553
+ throw new Error("Incomplete or repeated reward pagination cursor");
554
+ }
555
+ seen.add(info.endCursor);
556
+ return info.endCursor;
557
+ };
558
+ const nodes = (connection) => {
559
+ if (!isObject(connection) || !Array.isArray(connection.edges) ||
560
+ connection.edges.some((e) => !isObject(e?.node))) {
561
+ throw new Error("Malformed reward connection");
562
+ }
563
+ return connection.edges.map((e) => e.node);
564
+ };
565
+ const asset = (raw) => {
566
+ if (!isObject(raw) || typeof raw._id !== "string" || !raw._id ||
567
+ typeof raw.asset_identifier !== "string" || !raw.asset_identifier) {
568
+ throw new Error("Malformed reward scope asset");
569
+ }
570
+ return {
571
+ id: raw._id, asset_identifier: raw.asset_identifier,
572
+ asset_type: typeof raw.asset_type === "string" ? raw.asset_type : null,
573
+ eligible_for_submission: typeof raw.eligible_for_submission === "boolean" ? raw.eligible_for_submission : null,
574
+ eligible_for_bounty: typeof raw.eligible_for_bounty === "boolean" ? raw.eligible_for_bounty : null,
575
+ };
576
+ };
577
+ try {
578
+ let team = await request(rewardsQuery);
579
+ if (team === null) {
580
+ issue("not_public_or_not_found", "Program was not found or is not publicly accessible");
581
+ return result;
582
+ }
583
+ if (typeof team.handle !== "string" || typeof team.scope_and_rewards_groups_enabled !== "boolean") {
584
+ throw new Error("Malformed program rewards metadata");
585
+ }
586
+ result.handle = team.handle;
587
+ result.format = team.scope_and_rewards_groups_enabled ? "scope_and_rewards" : "legacy";
588
+ const sourceCurrency = team.currency;
589
+ result.currency = typeof team.currency === "string" && team.currency.trim() ? team.currency : null;
590
+ if (result.currency === null)
591
+ issue("missing_currency", "Reward currency is unknown");
592
+ if (team.bounty_table === null) {
593
+ result.groups = [];
594
+ issue("no_published_table", "No public bounty table is available");
595
+ return result;
596
+ }
597
+ const tableId = team.bounty_table?._id;
598
+ if (typeof tableId !== "string" || !tableId)
599
+ throw new Error("Malformed bounty table");
600
+ result.description = typeof team.bounty_table.description === "string" ? team.bounty_table.description : null;
601
+ const cursors = new Set();
602
+ while (true) {
603
+ const table = team?.bounty_table;
604
+ if (table?._id !== tableId || team.handle !== result.handle ||
605
+ team.scope_and_rewards_groups_enabled !== (result.format === "scope_and_rewards") ||
606
+ team.currency !== sourceCurrency) {
607
+ throw new Error("Program rewards changed during pagination");
608
+ }
609
+ for (const row of nodes(table.bounty_table_rows)) {
610
+ if (typeof row._id !== "string" || !row._id || rows.has(row._id)) {
611
+ throw new Error("Missing or repeated reward group ID");
612
+ }
613
+ const useRange = typeof row.use_range === "boolean" ? row.use_range : null;
614
+ const rewards = {};
615
+ let incomplete = useRange === null;
616
+ for (const severity of ["low", "medium", "high", "critical"]) {
617
+ const amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
618
+ let maximum = amount(row[severity]);
619
+ let minimum = useRange === false ? maximum : amount(row[`${severity}_minimum`]);
620
+ if (minimum !== null && maximum !== null && minimum > maximum) {
621
+ minimum = maximum = null;
622
+ }
623
+ incomplete ||= minimum === null || maximum === null;
624
+ rewards[severity] = {
625
+ label: typeof table[`${severity}_label`] === "string" ? table[`${severity}_label`] : null,
626
+ minimum, maximum,
627
+ };
628
+ }
629
+ if (incomplete)
630
+ issue("incomplete_rewards", "Some payout amounts, bounds, or the range setting are unknown or invalid", row._id);
631
+ groups.push({
632
+ id: row._id, name: typeof row.name === "string" ? row.name : null,
633
+ description: typeof row.description === "string" ? row.description : null,
634
+ scope_kind: result.format === "scope_and_rewards" ? "reward_group" :
635
+ row.structured_scope === null ? "all_assets" : isObject(row.structured_scope) ? "asset" : null,
636
+ use_range: useRange, rewards, assets: null, assets_availability: "unavailable",
637
+ });
638
+ rows.set(row._id, row);
639
+ }
640
+ const after = nextCursor(table.bounty_table_rows, cursors);
641
+ if (after === null)
642
+ break;
643
+ team = await request(rewardsQuery, { after });
644
+ }
645
+ if (!groups.length) {
646
+ result.groups = [];
647
+ issue("no_published_rewards", "The public bounty table contains no reward rows");
648
+ return result;
649
+ }
650
+ }
651
+ catch (err) {
652
+ issue("upstream_error", `Could not retrieve every reward group: ${err.message}`);
653
+ if (!groups.length)
654
+ return result;
655
+ }
656
+ result.groups = groups;
657
+ let allAssets;
658
+ for (const group of groups) {
659
+ try {
660
+ const assets = [];
661
+ const seenIds = new Set();
662
+ const append = (rawAssets) => {
663
+ for (const raw of rawAssets) {
664
+ const item = asset(raw);
665
+ if (seenIds.has(item.id))
666
+ throw new Error("Repeated scope asset during pagination");
667
+ seenIds.add(item.id);
668
+ assets.push(item);
669
+ }
670
+ };
671
+ if (group.scope_kind === "reward_group") {
672
+ const rowId = Number(group.id);
673
+ if (!/^\d+$/.test(group.id) || !Number.isSafeInteger(rowId) || rowId < 1 || rowId > 2147483647) {
674
+ throw new Error("Invalid reward group ID for scope search");
675
+ }
676
+ let total;
677
+ while (true) {
678
+ const team = await request(`query ProgramRewardAssets($handle: String!, $rowId: Int!, $from: Int!) {
679
+ team(handle: $handle) {
680
+ structured_scopes_search(bounty_table_row_id: $rowId, from: $from, size: 100,
681
+ sort: {field: "id", direction: ASC}) {
682
+ total_count edges { node { ... on StructuredScopeDocument {
683
+ _id asset_identifier: identifier asset_type eligible_for_submission eligible_for_bounty
684
+ } } }
685
+ }
686
+ }
687
+ }`, { rowId, from: assets.length });
688
+ const page = team?.structured_scopes_search;
689
+ const batch = nodes(page);
690
+ if (!Number.isSafeInteger(page.total_count) || page.total_count < 0 ||
691
+ (total !== undefined && total !== page.total_count)) {
692
+ throw new Error("Missing or inconsistent reward asset total");
693
+ }
694
+ total = page.total_count;
695
+ append(batch);
696
+ if (assets.length === total)
697
+ break;
698
+ if (!batch.length || assets.length > total)
699
+ throw new Error("Incomplete reward asset search");
700
+ }
701
+ group.assets = assets;
702
+ }
703
+ else if (group.scope_kind === "asset") {
704
+ group.assets = [asset(rows.get(group.id).structured_scope)];
705
+ }
706
+ else if (group.scope_kind === "all_assets") {
707
+ if (!allAssets) {
708
+ let after = null;
709
+ const cursors = new Set();
710
+ do {
711
+ const team = await request(`query ProgramRewardDefaultAssets($handle: String!, $after: String) {
712
+ team(handle: $handle) {
713
+ structured_scopes(first: 100, after: $after, archived: false,
714
+ eligible_for_submission: true, eligible_for_bounty: true) {
715
+ pageInfo { hasNextPage endCursor }
716
+ edges { node { ${scopeFields} } }
717
+ }
718
+ }
719
+ }`, { after });
720
+ const page = team?.structured_scopes;
721
+ append(nodes(page));
722
+ after = nextCursor(page, cursors);
723
+ } while (after !== null);
724
+ allAssets = assets;
725
+ }
726
+ group.assets = allAssets;
727
+ }
728
+ else {
729
+ throw new Error("Legacy reward scope applicability is unknown");
730
+ }
731
+ group.assets_availability = group.assets.some((a) => a.asset_type === null || a.eligible_for_bounty === null || a.eligible_for_submission === null) ? "partial" : "available";
732
+ if (group.assets_availability === "partial")
733
+ issue("incomplete_asset_fields", "Some asset types or eligibility fields are unknown", group.id);
734
+ }
735
+ catch (err) {
736
+ issue("assets_unavailable", `Could not retrieve every asset: ${err.message}`, group.id);
737
+ }
738
+ }
739
+ result.availability = result.issues.length ? "partial" : "available";
740
+ if (result.availability === "available")
741
+ cacheSet(cacheKey, result);
742
+ return result;
743
+ }
744
+ // ── Get program scope (auto-paginated) ────────────────────────────
745
+ export async function getProgramScope(handle, pageSize) {
746
+ const allData = await h1FetchAllPages(`/hackers/programs/${handle}/structured_scopes`, pageSize);
747
+ return allData.map((s) => ({
748
+ id: s.id,
749
+ asset_type: s.attributes.asset_type,
750
+ asset_identifier: s.attributes.asset_identifier,
751
+ eligible_for_bounty: s.attributes.eligible_for_bounty,
752
+ eligible_for_submission: s.attributes.eligible_for_submission,
753
+ instruction: s.attributes.instruction,
754
+ max_severity: s.attributes.max_severity,
755
+ created_at: s.attributes.created_at,
756
+ }));
757
+ }
758
+ // ── Get program weaknesses (auto-paginated) ───────────────────────
759
+ export async function getProgramWeaknesses(handle, pageSize) {
760
+ const allData = await h1FetchAllPages(`/hackers/programs/${handle}/weaknesses`, pageSize);
761
+ return allData.map((w) => ({
762
+ id: w.id,
763
+ name: w.attributes.name,
764
+ description: w.attributes.description,
765
+ external_id: w.attributes.external_id,
766
+ }));
767
+ }
768
+ // ── Get earnings ──────────────────────────────────────────────────
769
+ export async function getEarnings(pageSize = 100) {
770
+ const data = await h1Fetch("/hackers/payments/earnings", {
771
+ "page[size]": String(pageSize),
772
+ });
773
+ return data.data.map((e) => ({
774
+ id: e.id,
775
+ amount: e.attributes.amount,
776
+ awarded_by: e.attributes.awarded_by_name,
777
+ created_at: e.attributes.created_at,
778
+ currency: e.relationships?.program?.data?.attributes?.currency ?? null,
779
+ program: e.relationships?.program?.data?.attributes?.handle ?? null,
780
+ }));
781
+ }
782
+ // ── Get hacker profile ────────────────────────────────────────────
783
+ export async function getHackerProfile() {
784
+ const reports = await h1FetchAllPages("/hackers/me/reports");
785
+ const u = reports
786
+ .map((report) => report.relationships?.reporter?.data)
787
+ .find((reporter) => isObject(reporter) && reporter.type === "user" &&
788
+ isObject(reporter.attributes) &&
789
+ typeof reporter.attributes.username === "string" &&
790
+ reporter.attributes.username === process.env.H1_USERNAME);
791
+ const attrs = u?.attributes;
792
+ return {
793
+ id: u?.id ?? null,
794
+ username: attrs?.username ?? null,
795
+ name: attrs?.name ?? null,
796
+ bio: attrs?.bio ?? null,
797
+ reputation: attrs?.reputation ?? null,
798
+ signal: attrs?.signal ?? null,
799
+ impact: attrs?.impact ?? null,
800
+ rank: attrs?.rank ?? null,
801
+ created_at: attrs?.created_at ?? null,
802
+ hackerone_triager: attrs?.hackerone_triager ?? null,
803
+ source: "/hackers/me/reports reporter relationship",
804
+ unavailable_reason: u ? null : reports.length === 0
805
+ ? "No reports available; profile data and statistics are unavailable."
806
+ : "No reporter username matches H1_USERNAME; an API identifier may differ from the account username.",
807
+ };
808
+ }
809
+ // ── Get balance ───────────────────────────────────────────────────
810
+ export async function getBalance() {
811
+ const data = await h1Fetch("/hackers/payments/balance");
812
+ // The balance endpoint may return differently; handle both formats
813
+ if (data.data) {
814
+ const attrs = data.data.attributes ?? data.data;
815
+ return {
816
+ balance: attrs.balance ?? attrs.amount ?? null,
817
+ currency: attrs.currency ?? null,
818
+ pending: attrs.pending ?? null,
819
+ };
820
+ }
821
+ return data;
822
+ }
823
+ // ── Get report summary (condensed for Claude context) ──────────────
824
+ export async function getReportSummary(reportId) {
825
+ const report = await getReport(reportId);
826
+ const activities = await getReportActivities(reportId);
827
+ const comments = activities.filter((a) => a.message &&
828
+ !a.automated_response &&
829
+ (a.type === "activity-comment" ||
830
+ a.type === "activity-bug-triaged" ||
831
+ a.type === "activity-bug-resolved" ||
832
+ a.type === "activity-bounty-awarded"));
833
+ return {
834
+ ...report,
835
+ conversation: comments.map((c) => ({
836
+ from: c.actor ?? c.actor_type,
837
+ type: c.type.replace("activity-", ""),
838
+ message: c.message,
839
+ date: c.created_at,
840
+ })),
841
+ };
842
+ }
843
+ // ── Submit report ─────────────────────────────────────────────────
844
+ export async function submitReport(opts) {
845
+ const relationships = {
846
+ program: {
847
+ data: {
848
+ type: "program",
849
+ attributes: { handle: opts.program_handle },
850
+ },
851
+ },
852
+ };
853
+ if (opts.weakness_id) {
854
+ relationships.weakness = {
855
+ data: { type: "weakness", id: opts.weakness_id },
856
+ };
857
+ }
858
+ if (opts.structured_scope_id) {
859
+ relationships.structured_scope = {
860
+ data: { type: "structured-scope", id: opts.structured_scope_id },
861
+ };
862
+ }
863
+ const severity = {};
864
+ if (opts.severity_rating) {
865
+ severity.rating = opts.severity_rating;
866
+ }
867
+ const body = {
868
+ data: {
869
+ type: "report",
870
+ attributes: {
871
+ team_handle: opts.program_handle,
872
+ title: opts.title,
873
+ vulnerability_information: opts.vulnerability_information,
874
+ impact: opts.impact ?? "",
875
+ severity_rating: opts.severity_rating,
876
+ },
877
+ relationships,
878
+ },
879
+ };
880
+ const result = await h1Post("/hackers/reports", body);
881
+ const r = result.data;
882
+ return {
883
+ id: r.id,
884
+ title: r.attributes?.title,
885
+ state: r.attributes?.state,
886
+ url: `https://hackerone.com/reports/${r.id}`,
887
+ };
888
+ }
889
+ // ── Add comment to report ─────────────────────────────────────────
890
+ export async function addComment(reportId, message, internal = false) {
891
+ const body = {
892
+ data: {
893
+ type: "activity-comment",
894
+ attributes: {
895
+ message,
896
+ internal,
897
+ },
898
+ },
899
+ };
900
+ const result = await h1Post(`/hackers/reports/${reportId}/activities`, body);
901
+ return {
902
+ id: result.data?.id,
903
+ type: result.data?.type,
904
+ message: result.data?.attributes?.message,
905
+ created_at: result.data?.attributes?.created_at,
906
+ };
907
+ }
908
+ // ── Close / withdraw report ───────────────────────────────────────
909
+ export async function closeReport(reportId, message) {
910
+ const body = {
911
+ data: {
912
+ type: "activity-hacker-requested-mediation",
913
+ attributes: {
914
+ message: message ?? "Withdrawing this report.",
915
+ close_report: true,
916
+ },
917
+ },
918
+ };
919
+ // Attempt the state change via activities
920
+ const result = await h1Post(`/hackers/reports/${reportId}/activities`, body);
921
+ return {
922
+ id: result.data?.id,
923
+ type: result.data?.type,
924
+ message: result.data?.attributes?.message,
925
+ created_at: result.data?.attributes?.created_at,
926
+ };
927
+ }
928
+ // ── Search disclosed reports ──────────────────────────────────────
929
+ export async function searchDisclosedReports(opts) {
930
+ // The hacktivity endpoint for disclosed reports
931
+ const params = {
932
+ "page[size]": String(opts.page_size ?? 25),
933
+ };
934
+ if (opts.program) {
935
+ params["filter[team_handle][]"] = opts.program;
936
+ }
937
+ const data = await h1Fetch("/hackers/hacktivity", params, {
938
+ skipCache: true,
939
+ });
940
+ let reports = (data.data ?? []).map((r) => ({
941
+ id: r.id,
942
+ title: r.attributes?.title ?? r.attributes?.raw_title,
943
+ severity: r.attributes?.severity_rating,
944
+ disclosed_at: r.attributes?.disclosed_at,
945
+ total_awarded_amount: r.attributes?.total_awarded_amount,
946
+ upvotes: r.attributes?.vote_count ?? r.attributes?.upvotes,
947
+ url: r.attributes?.url ?? `https://hackerone.com/reports/${r.id}`,
948
+ reporter: r.relationships?.reporter?.data?.attributes?.username ?? null,
949
+ program: r.relationships?.team?.data?.attributes?.handle ??
950
+ r.relationships?.program?.data?.attributes?.handle ??
951
+ null,
952
+ weakness: r.relationships?.weakness?.data?.attributes?.name ?? null,
953
+ }));
954
+ if (opts.query) {
955
+ const q = opts.query.toLowerCase();
956
+ reports = reports.filter((r) => r.title?.toLowerCase().includes(q) ||
957
+ r.weakness?.toLowerCase().includes(q));
958
+ }
959
+ return reports;
960
+ }