Automating H1 Submissions: attachments or severity, pick one

Are you tired of manually filling up all the fields in your H1 reports? Given that we now have AI agents that do everything for us, we could also use them to save even more time. I'm talking about changing the way I've been preparing my H1 reports since I started my bug bounty journey. When I was happy with the content, there was always the part that I didn't particularly like:

  1. pick the scope
  2. pick a weakness type
  3. pick the severity
  4. copy+paste the title
  5. copy+paste the description
  6. copy+paste the impact
  7. optionally upload some attachments and place them within the text

...and finally click submit.

This is all possible because before any of it happens, I tend to have everything polished already in a git repo corresponding to the target that I'm currently hunting. It's not a lot of work, but once you have 20-30 reports to send, a little knowledge and a bit of help from AI lets you automate about 90% of it and avoid the context switching required for filling in the report. Of course, you shouldn't use it to mass-post reports that you haven't even read. The triagers hate it and it makes the whole ecosystem worse for all of us. But if your intentions are good, you are welcome to join me on this journey through the H1 API docs and setup.

Getting a token

First, we need a token that you can generate in your user settings:

HackerOne user settings

Then we go to the "API Token" section:

API Token section

And we generate a fresh token that we save for later:

Generating a new API token

This is all we need to start working with the API. This token will allow us to send a report without even leaving our terminal. If you are interested in all the operations that you can perform using this token, you can visit the official docs here: https://api.hackerone.com/hacker-resources/

Every script below reads the credentials from the environment, so put them in a .env file next to the scripts. Note that the username is the API username shown next to the token, not your hacker handle:

H1_USERNAME=your-api-username
H1_TOKEN=your-token

Weaknesses and scopes

We can check if the credentials work by fetching the scopes and weakness types a program supports. I'll use the H1 program here, and Node.js because I like it. Let's go.

We need to obtain the program handle, which you can find when you visit the program page:

Program handle in the URL

It's in the URL, for H1 it's simply "security". We use it to point the script at a specific program. We get the list of weaknesses first:

Weaknesses listing output
weaknesses.mjs
const auth = Buffer.from(`${process.env.H1_USERNAME}:${process.env.H1_TOKEN}`).toString("base64");
const handle = process.argv[2] || "security";
let url = `https://api.hackerone.com/v1/hackers/programs/${handle}/weaknesses?page[size]=100`;

while (url) {
  const res = await fetch(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json" } });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  const page = await res.json();
  for (const w of page.data) console.log(w.id, w.attributes.external_id, w.attributes.name);
  url = page.links?.next ?? null;
}

We do the same for the scopes:

Scopes listing output
scopes.mjs
const auth = Buffer.from(`${process.env.H1_USERNAME}:${process.env.H1_TOKEN}`).toString("base64");
const handle = process.argv[2] || "security";
let url = `https://api.hackerone.com/v1/hackers/programs/${handle}/structured_scopes?page[size]=100`;

while (url) {
  const res = await fetch(url, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json" } });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  const page = await res.json();
  for (const s of page.data) {
    const a = s.attributes;
    console.log(s.id, a.asset_type, a.eligible_for_submission ? "submit" : "-", a.asset_identifier);
  }
  url = page.links?.next ?? null;
}

Both endpoints page at 25 by default, so we ask for 100 and follow links.next, otherwise you silently get a partial list, which is a nasty surprise when the id you need is on page two. Run them with the token in the environment:

node --env-file=.env weaknesses.mjs security

Sending a report

And initially that's basically all you need to send a valid report. As long as you don't need to create a draft first (to manually send it after confirmation) or attach files, you can just go ahead and use the "Create Report" operation:

Sending the report from the terminal
create-report.mjs
const auth = Buffer.from(`${process.env.H1_USERNAME}:${process.env.H1_TOKEN}`).toString("base64");

const res = await fetch("https://api.hackerone.com/v1/hackers/reports", {
  method: "POST",
  headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({
    data: {
      type: "report",
      attributes: {
        team_handle: "security",
        title: "Reflected XSS in the q parameter",
        vulnerability_information: "## Steps To Reproduce\n\n1. ...",
        impact: "An attacker can execute arbitrary JavaScript ...",
        severity_rating: "low",     // none | low | medium | high | critical
        weakness_id: 67,            // from weaknesses.mjs
        structured_scope_id: 5,     // from scopes.mjs
      },
    },
  }),
});

const body = await res.json();
console.log(res.status, body.data?.id, body.data?.attributes?.state);

And boom:

The submitted report in the browser

The report is there without even touching the browser.

The caveat

This way doesn't allow uploading attachments, which is sometimes fine but in most cases you would like to include them. I have to warn you: at the time of writing, there doesn't seem to be a way to file a full report. The first method lacks the attachments, and the second one uses an older version of the report assistant. You are allowed to upload attachments there, but the submitted report doesn't carry any weakness type or severity rating, which you would need to set up manually after submitting the report. This is a strange situation, but there is not much we can do.

Drafts and attachments: the Report Intents API

Let's now see how a report can actually be created with attachments, draft first and then publishing it. For this we will use the Report Intents API. Attachments can only be uploaded to an existing report intent, so the script first creates one with a placeholder description. It has to be a placeholder, because the real text needs the attachment ids and those don't exist until the intent does. Uploading is allowed straight away, even while the assistant is still working, so we send the files next and get back the respective ids that we can place inside our report description. Then we wait, update the description with the markers, and wait again for the AI to finish analyzing the report. Once it's finished, the draft is there ready to be submitted:

The draft report intent
report-intent.mjs
import { openAsBlob } from "node:fs";

const BASE = "https://api.hackerone.com/v1/hackers/report_intents";
const auth = Buffer.from(`${process.env.H1_USERNAME}:${process.env.H1_TOKEN}`).toString("base64");

const api = async (method, path, body) => {
  const multipart = body instanceof FormData;
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Basic ${auth}`,
      Accept: "application/json",
      ...(body && !multipart && { "Content-Type": "application/json" }),
    },
    ...(body && { body: multipart ? body : JSON.stringify(body) }),
  });
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text()}`);
  return res.json();
};

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// PATCH is rejected while the assistant is working, and it restarts it too
const settle = async (id) => {
  let i = await api("GET", `/${id}`);
  while (i.data.attributes.state === "pending") { await sleep(2000); i = await api("GET", `/${id}`); }
  return i;
};

// 1. create the draft with a stub - the real text needs the attachment ids
const { data: { id } } = await api("POST", "", {
  data: { type: "report-intent", attributes: { team_handle: "security", description: "Draft in progress." } },
});

// 2. upload files (allowed while pending) and turn the ids into {F} markers
const form = new FormData();
form.append("files[]", await openAsBlob("poc.png"), "poc.png");
const up = await api("POST", `/${id}/attachments`, form);
const markers = (Array.isArray(up.data) ? up.data : [up.data]).map((a) => `{F${a.id}}`).join("\n");

// 3. wait, then write the real description with the markers inline
await settle(id);
await api("PATCH", `/${id}`, {
  data: {
    type: "report-intent",
    attributes: {
      description: `**Summary:**\n\nReflected XSS in the q parameter\n\n### Steps To Reproduce\n\n1. ...\n\n${markers}`,
    },
  },
});

// 4. the PATCH restarts the analysis, so wait again before submitting
const done = await settle(id);
console.log(id, done.data.attributes.state, done.data.attributes.metadata);

// this one actually files it, so check the draft first
// await api("POST", `/${id}/submit`);

A few things are worth knowing here, because none of them are in the docs. Attachments can be uploaded while the intent is still pending, but the description cannot. A PATCH in that state comes back with "Report intent can not be updated in current state". The {F<id>} markers are what place an image inline; without them the file only shows up in the list at the bottom of the report. And the PATCH itself restarts the assistant, so the intent drops back to pending and you have to wait again before submitting. Submit too early and the report is built from the previous text.

One more thing that cost me a few drafts: the description is not really a field, it's a message to the assistant. If your text says anywhere that it's only a test, the assistant politely declines to process it and your description is never stored.

And it can be submitted:

The submitted report from the intent

As mentioned previously, it doesn't have the severity rating or weakness type, so we would need to set these manually (or ask the AI to suggest one in the UI). So neither way is fully autonomous yet, which sucks, but at least we can get a partial submission either way, which saves us at least some time.

What's next

I will be watching for upcoming changes to the H1 API, as maybe they will soon integrate the v2 agent experience into report intents, as it seems the second version is actually able to set the severity and weakness type. This can take some time though, as given that the "AI Horde" is already storming every bug bounty program:

AI Horde activity

...this might not be their highest priority, giving the Horde tools to send reports even faster :D

As soon as this changes, this article will get updated. But for now, unless we want to use their internal API, this is what we get. Maybe that could be a topic for another article?