diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60e9362 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,221 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + NO_CREDS: HACKERONE_USERNAME="" HACKERONE_API_KEY="" + FAKE_CREDS: HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.10 + + - name: Install dependencies + run: uv sync + + - name: Ruff check + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ["3.10", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install ${{ matrix.python }} + + - name: Install dependencies + run: uv sync + + # --- Help & JSON output --- + + - name: "help: shows output" + shell: bash + run: uv run hackerone help | grep -q "Hacker Modules" + + - name: "help: works without credentials" + shell: bash + run: | + HACKERONE_USERNAME="" HACKERONE_API_KEY="" uv run hackerone --env-file /dev/null help | grep -q "Hacker Modules" + + - name: "help --json: returns valid JSON" + shell: bash + run: uv run hackerone help --json | python3 -m json.tool > /dev/null + + - name: "help --json: contains commands key" + shell: bash + run: | + uv run hackerone help --json | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'commands' in d" + + - name: "help --json: lists all hacker commands" + shell: bash + run: | + uv run hackerone help --json | python3 -c " + import json,sys + keys = ' '.join(json.load(sys.stdin)['commands'].keys()) + for c in ['balance','reports','programs','profile','earnings','payouts','report','program','burp','csv','scope']: + assert c in keys, f'missing {c}' + " + + - name: "help --json: lists all org commands" + shell: bash + run: | + uv run hackerone help --json | python3 -c " + import json,sys + keys = ' '.join(json.load(sys.stdin)['commands'].keys()) + for c in ['org','org-members','org-reports','org-report','org-update-report','org-activities','org-metrics','org-scopes','org-invite-hacker','org-bounty','org-swag']: + assert c in keys, f'missing {c}' + " + + # --- Error handling --- + + - name: "error: no credentials" + shell: bash + run: | + output=$(HACKERONE_USERNAME="" HACKERONE_API_KEY="" uv run hackerone --env-file /dev/null balance 2>&1 || true) + echo "$output" | grep -q "No username provided" + + - name: "error: no API key" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="" uv run hackerone --env-file /dev/null balance 2>&1 || true) + echo "$output" | grep -q "No API key provided" + + - name: "error: no arguments" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null 2>&1 || true) + echo "$output" | grep -q "No argument provided" + + - name: "error: invalid module" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null notamodule 2>&1 || true) + echo "$output" | grep -q "Invalid module" + + - name: "error: no-creds JSON outputs valid JSON" + shell: bash + run: | + output=$(HACKERONE_USERNAME="" HACKERONE_API_KEY="" uv run hackerone --env-file /dev/null --json balance 2>&1 || true) + echo "$output" | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'error' in d" + + # --- Argument validation --- + + - name: "report: rejects non-numeric ID" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null report abc 2>&1 || true) + echo "$output" | grep -q "Invalid ID" + + - name: "org-report: rejects non-numeric ID" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-report abc 2>&1 || true) + echo "$output" | grep -q "Invalid ID" + + - name: "org-update-report: rejects invalid state" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-update-report 123 badstate 2>&1 || true) + echo "$output" | grep -q "Invalid state" + + - name: "org-update-report: rejects missing args" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-update-report 2>&1 || true) + echo "$output" | grep -q "Usage" + + - name: "org-bounty: rejects invalid amount" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-bounty 123 notanumber 2>&1 || true) + echo "$output" | grep -q "Invalid amount" + + - name: "org-bounty: rejects missing args" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-bounty 2>&1 || true) + echo "$output" | grep -q "Usage" + + - name: "org-members: requires org ID" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-members 2>&1 || true) + echo "$output" | grep -q "No organization ID" + + - name: "org-groups: requires org ID" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-groups 2>&1 || true) + echo "$output" | grep -q "No organization ID" + + - name: "org-reports: requires handle" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-reports 2>&1 || true) + echo "$output" | grep -q "No program handle" + + - name: "org-metrics: requires handle" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-metrics 2>&1 || true) + echo "$output" | grep -q "No program handle" + + - name: "org-scopes: requires handle" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-scopes 2>&1 || true) + echo "$output" | grep -q "No program handle" + + - name: "org-invite-hacker: requires args" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-invite-hacker 2>&1 || true) + echo "$output" | grep -q "Usage" + + - name: "org-swag: requires report ID" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null org-swag 2>&1 || true) + echo "$output" | grep -q "Usage" + + # --- Scope (no creds needed) --- + + - name: "scope: works without credentials" + shell: bash + run: | + HACKERONE_USERNAME="" HACKERONE_API_KEY="" uv run hackerone --env-file /dev/null scope 2>&1 | grep -q "Invalid arguments" + + # --- Verbose flag --- + + - name: "verbose: shows auth info" + shell: bash + run: | + output=$(HACKERONE_USERNAME="x" HACKERONE_API_KEY="x" uv run hackerone --env-file /dev/null -v help 2>&1 || true) + # help doesn't show auth (no creds needed), but should still work + echo "$output" | grep -q "Hacker Modules" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..755e083 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +venv/ +.venv/ +.env +.ruff_cache/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..07e1593 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +just 1.47.1 diff --git a/README.md b/README.md index fad6e0b..c7bf266 100644 --- a/README.md +++ b/README.md @@ -1,385 +1,593 @@ -# HackerOne CLI Utility - -## Index - -1. [Description](#description) -2. [Usage](#usage) - 2.1. [Windows](#windows) - 2.2. [Unix](#unix) -3. [Installation](#installation) - 3.1. [Requirements](#requirements) - 3.2. [Windows](#windows-1) - 3.3. [Unix](#unix-1) - 3.4. [Tests](#tests) -4. [Modules](#modules) - 4.1. [balance](#balance) - 4.2. [burp](#burp) - 4.3. [csv](#csv) - 4.4. [earnings](#earnings) - 4.5. [help](#help) - 4.6. [payouts](#payouts) - 4.7. [profile](#profile) - 4.8. [program](#program) - 4.9. [programs](#programs) - 4.10. [report](#report) - 4.11. [reports](#reports) - 4.12. [scope](#scope) -5. [License](#license) - -## Description - -This is an (unofficial) utility that works as a client to the HackerOne platform. It allows you to perform multiple operations directly from the command-line. -The tool uses the official [HackerOne API](https://api.hackerone.com/) to access the data it needs. -It contains several modules so you manage and view information related to your profile, reports, programs, payments, etc and it's really easy to use. - -## Usage - -### Windows - -After installing, just run `python3 hackerone.py` to use the utility. - -### Unix - -After installing, you can call `hackerone` directly from the command-line (since a symbolic link will be created during installation) or use python (`python3 hackerone.py`). - -## Installation - -### Requirements - -- Python >= 3.10 -- Git -- HackerOne API Key (you can get it from [here](https://hackerone.com/settings/api_token/edit)) - -### Windows - -1. Open Powershell in the same directory as the project -2. Run the installation file: - - ```ps1 - .\install.ps1 - ``` - -3. Export the required environment variables (don't forget to replace the values between double quotes): - - ```ps1 - echo 'HACKERONE_USERNAME=""' > .env - echo 'HACKERONE_API_KEY=""' >> .env - ``` - - or - - ```ps1 - $env:HACKERONE_USERNAME = "" - $env:HACKERONE_API_KEY = "" - ``` - -### Unix - -1. Open a shell in the same directory as the project -2. Run the installation file: - - ```sh - chmod +x install.sh - ./install.sh - ``` - -3. Export the required environment variables (don't forget to replace the values between double quotes): - - ```sh - echo 'HACKERONE_USERNAME=""' > .env - echo 'HACKERONE_API_KEY=""' >> .env - ``` - - or - - ```sh - export HACKERONE_USERNAME="" - export HACKERONE_API_KEY="" - ``` - -4. Optionally, you can set the environment variables permanently (if you're not using the first option, in the previous process): - - (using ZSH shell) - - ```sh - echo 'export HACKERONE_USERNAME=""' >> .zshrc - echo 'export HACKERONE_API_KEY=""' >> .zshrc - ``` - - (using Bash shell) - - ```sh - echo 'export HACKERONE_USERNAME=""' >> .bashrc - echo 'export HACKERONE_API_KEY=""' >> .bashrc - ``` - -### Tests - -This tool was tested (and performed well) on: - -- Kali Linux 2023.2 + Python 3.11.4 -- Windows 11 (Powershell 7.3.5) + Python 3.11.4 -- Android (with Termux) + Python 3.11.4 - -## Modules - -```txt -balance Check your balance -burp Download the burp configuration file (only from public programs) -csv Download CSV scope file (only from public programs) -earnings Check your earnings -help Help page -payouts Get a list of your payouts -profile Your profile on HackerOne -program Get information from a program -programs [] Get a list of current programs (optional: = maximum number of results) -report Get a specific report -reports Get a list of your reports -scope [] Save a program's scope into a text file (optional: = output file to store results) -``` - -### balance - -Check your money balance. The value provided is in the currency provided on your profile. - -```txt -hackerone balance - -Balance: 1337.0 -``` - -### burp - -Downloads the burp configuration file from public programs. This is not done through the HackerOne API, since there's no such feature, that's why it only works with public programs. -You need to pass the program's handle to use this module. You can find the program handle using the module `programs` or by checking the URL in the browser, while using the HackerOne platform (example: `https://hackerone.com//policy_scopes` in any program) - -```txt -hackerone burp security - -Filename: security-(...).json -``` - -### csv - -Downloads the CSV scope file from public programs. This is not done through the HackerOne API, since there's no such feature, that's why it only works with public programs. -You need to pass the program's handle to use this module. You can find the program handle using the module `programs` or by checking the URL in the browser, while using the HackerOne platform (example: `https://hackerone.com//policy_scopes` in any program) - -```txt -hackerone csv security - -Filename: scopes_for_security_(...).csv -``` - -### earnings - -Check your earnings from the programs you have been. - -```txt -hackerone earnings - -Earnings ----------------------------------------- -Amount: 1337 -Date: 2016-02-02T04:05:06.000Z -Program: HackerOne -Report: RXSS at example.hackerone.com ----------------------------------------- -``` - -### help - -Shows the help page, listing the modules available, their descriptions, and the parameters to be passed. - -### payouts - -Lists all the payouts you had. - -```txt -hackerone payouts - -Payouts ----------------------------------------- -Amount: 1337 -Status: sent -Date: 2016-02-02T04:05:06.000Z -Provider: Paypal ----------------------------------------- -``` - -### profile - -Gets your profile information. It only works if you have any reports, since this module actually checks for the 'reporter' from a report you submitted (there is no user searching / profile feature in the - hacker - API). Unfortunatly it is not possible to get the Signal, Impact or Rank data. - -```txt -hackerone profile - -Profile ----------------------------------------- -ID: 1234567 -Username: example -Reputation: 1337 -Name: Hacker Man -Creation Date: 2020-11-24T16:20:24.066Z -Bio: My beautiful bio -Website: https://example.com/ -Location: Right here ----------------------------------------- -``` - -### program - -Allows you to get information from a program (public or private - if you have authorization) such as the program handle, name, state, creation date, privacy, scope, bounty splitting, bookmarked status and bounty. - -```txt -hackerone program security - -Program ----------------------------------------- -Name: HackerOne -Handle: security -State: open -Availability: Public -Creation date: 2013-11-06T00:00:00.000Z -Bounty: yes -Bounty Splitting: yes -Bookmarked: no - -Scope --------------------- -Asset: hackerone.com -Type: URL -State: In-Scope -Bounty: yes -Instruction: This is our main application that hackers and customers use to interact with each other. It connects with a database that contains information about vulnerability reports, users, and programs. This system’s backend is written in Ruby and exposes data to the client through GraphQL, rendered pages, and JSON endpoints. -Max Severity: critical --------------------- -``` - -### programs - -Gets a list of the most recently updated programs (including the private programs you are in) and some extra information from each. You can pass an extra argument that filters the number of results. The default value is `10`. - -```txt -hackerone programs 2 - -Programs ----------------------------------------- -Program: Example 1 -Handle: example_1 -State: open -Availability: Public -Available since: 2027-07-10T17:10:05.936Z -Bounty Splitting: no -Bookmarked: yes ----------------------------------------- -Program: Example 2 -Handle: example_2 -State: open -Availability: Public -Available since: 2027-05-16T16:00:37.600Z -Bounty Splitting: yes -Bookmarked: no ----------------------------------------- - -Got 2 results! -``` - -### report - -Get most of the information available from a report, including the severity, asset, title, comments, content (only if the report is yours), CVE, CWE, bounties (normal bounty + extra bounty), participants, weakness, etc. -You need to pass the report ID which you can find by using the module `reports` (to get the IDs from your reports) or in a report in the HackerOne website (`https://hackerone.com/reports/`). - -```txt -hackerone report 1234567 - -Report ----------------------------------------- -ID: 1234567 -Title: Stored XSS on example.com -State: resolved -Date: 2027-06-01T00:54:43.308Z -Program: example -Severity: medium -CWE: CWE-79 -Weakness: Cross-site Scripting (XSS) - Stored - -Comments --------------------- -@triager - -Hi! -Thanks for the report, @hacker. We're looking into it. - --------------------- -@triager - -This should now be fixed. Marking as resolved. - --------------------- -@example awarded a bounty (1337.00 + 0.00)! - --------------------- -@hacker - -Hi @triager -Can we disclosure full ? -Thanks - --------------------- -@triager agreed on the report going public! - --------------------- -@triager changed the report visibility to public! - ----------------------------------------- -``` - -### reports - -Get a list of your reports with some information about each one, including the title, ID, state, creation date, CWE, CVSS, weakness, program and severity. - -```txt -hackerone reports - -Reports ----------------------------------------- -ID: 1234567 -Title: Information Exposure through phpinfo() at example.com -State: triaged -Date: 2027-03-13T16:48:17.286Z -CWE: CWE-200 -Program: example -Severity: low -CVSS: 3.7 ----------------------------------------- -ID: 1234568 -Title: RXSS on https://example.com/ via id parameter -State: duplicate -Date: 2027-01-06T12:23:36.605Z -CWE: CWE-79 -Program: example -Severity: high -CVSS: 8.3 ----------------------------------------- -``` - -### scope - -Save a list of in-scope domains, URLs, IP addresses, CIDR addresses, and wildcards in a text file (to use on external tools). This module extracts this info from the csv file available for each program. You need to download it (using the HackerOne website or the `csv` module) and pass the filename as an argument to this module. -Optionally, you can pass the name of the output file as a second argument (default: `inscope.txt`). - -```txt -hackerone scope scopes_for_example.csv out.txt - -In-Scope ----------------------------------------- -*.example.com -127.0.0.0/24 -test.example.com ----------------------------------------- -File 'out.txt' saved! -``` - -## License - -License is available [here](./LICENSE). +# HackerOne CLI + +[![CI](https://github.com/thereisnotime/hackerone-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/thereisnotime/hackerone-cli/actions/workflows/ci.yml) + +An unofficial CLI client for [HackerOne](https://hackerone.com/). Manage your profile, reports, programs, payments, and more from the terminal. Built on the official [HackerOne API v1](https://api.hackerone.com/). + +## Table of Contents + +- [Quick Start](#quick-start) +- [Installation](#installation) + - [Update](#update) | [Uninstall](#uninstall) +- [Configuration](#configuration) + - [Credentials](#credentials) | [Custom .env File](#custom-env-file) +- [Usage](#usage) + - [Global Options](#global-options) | [JSON Output](#json-output) +- [Commands](#commands) + - Hacker: [Programs & Scope](#programs--scope) | [Reports](#reports) | [Payments](#payments) | [Account](#account) + - Program Manager: [Organization](#organization--program-management) — `org` `org-reports` `org-update-report` `org-metrics` `org-bounty` and more +- [Development](#development) +- [License](#license) + +## Quick Start + +```sh +git clone git@github.com:thereisnotime/hackerone-cli.git +cd hackerone-cli +uv tool install -e . +echo 'HACKERONE_USERNAME="your-username"' > .env +echo 'HACKERONE_API_KEY="your-api-key"' >> .env +hackerone programs 5 +``` + +## Installation + +**Requirements:** Python >= 3.10, a [HackerOne API key](https://hackerone.com/settings/api_token/edit) + +```sh +git clone git@github.com:thereisnotime/hackerone-cli.git +cd hackerone-cli +uv tool install -e . +``` + +For rendered markdown in `report` and `program` output, install with the optional `markdown` extra: + +```sh +uv tool install -e ".[markdown]" +``` + +This installs `hackerone` globally so you can run it from anywhere. + +### Update + +```sh +cd hackerone-cli +git pull +uv tool install -e . --force +``` + +### Uninstall + +```sh +uv tool uninstall hackerone-cli +``` + +### Other install methods + +
+Click to expand + +**With pip:** + +```sh +pip install -e . +``` + +**With uv (project-local):** + +```sh +uv sync # install in .venv +uv run hackerone programs # run via uv +# or activate the venv directly: +source .venv/bin/activate +hackerone programs +``` + +**With just:** + +```sh +just install +``` + +
+ +## Configuration + +### Credentials + +Credentials are resolved in this order (first match wins): + +| Method | Example | +|---|---| +| CLI flags | `hackerone -u myuser -k mykey programs` | +| Environment variables | `export HACKERONE_USERNAME="..."` / `export HACKERONE_API_KEY="..."` | +| `.env` file | Auto-loaded from the current directory | + +### Custom .env File + +```sh +hackerone --env-file /path/to/.env programs +``` + +Get your API key from [HackerOne Settings](https://hackerone.com/settings/api_token/edit). + +## Usage + +```sh +hackerone [args] [options] +``` + +### Global Options + +| Flag | Short | Description | +|---|---|---| +| `--json` | `-j` | Machine-readable JSON output | +| `--username ` | `-u` | HackerOne username (overrides env) | +| `--api-key ` | `-k` | HackerOne API key (overrides env) | +| `--env-file ` | | Path to a custom `.env` file | +| `--verbose` | `-v` | Show progress and debug info | + +### JSON Output + +Any command supports `--json` / `-j` for machine-readable output. Useful for scripting, piping into `jq`, or integrating with other tools. + +```sh +hackerone programs 5 --json +hackerone program security -j | jq '.attributes.handle' +``` + +## Commands + +### Command Reference + +| Command | Description | +|---|---| +| [`programs [max]`](#programs-max) | List programs you have access to (default: 10) | +| [`program `](#program-handle) | Program details — scope, policy, bounty info | +| [`csv `](#csv-handle) | Download CSV scope file (public only) | +| [`scope [outfile]`](#scope-csv-outfile) | Extract in-scope targets from CSV | +| [`burp `](#burp-handle) | Download Burp Suite config (public only) | +| [`reports`](#reports-1) | List your submitted reports | +| [`report `](#report-id) | Full report details | +| [`balance`](#balance) | Current balance | +| [`earnings`](#earnings) | Bounty earnings | +| [`payouts`](#payouts) | Payout history | +| [`profile`](#profile) | Your profile info | +| [`help`](#help) | Show available commands | +| | **Program Management** (requires org API token) | +| [`org`](#org) | Show your organization info | +| [`org-members `](#org-members-org_id) | List organization members | +| [`org-groups `](#org-groups-org_id) | List permission groups | +| [`org-invitations `](#org-invitations-org_id) | List pending invitations | +| [`org-reports [max]`](#org-reports-handle-max) | List reports submitted to your program | +| [`org-report `](#org-report-id) | Get a report submitted to your program | +| [`org-update-report `](#org-update-report-id-state) | Update report state | +| [`org-activities [handle]`](#org-activities-handle) | List recent activities | +| [`org-metrics `](#org-metrics-handle) | Program metrics | +| [`org-scopes `](#org-scopes-handle) | List program scope/assets | +| [`org-invite-hacker `](#org-invite-hacker-program_id-username) | Invite hacker to program | +| [`org-bounty `](#org-bounty-report_id-amount) | Award bounty | +| [`org-swag `](#org-swag-report_id) | Award swag | + +--- + +### Programs & Scope + +#### `programs [max]` + +List recently updated programs you have access to, including private ones. Defaults to 10 results. + +
+Example output + +``` +$ hackerone programs 2 + +Programs +---------------------------------------- +Program: Example 1 +Handle: example_1 +State: open +Availability: Public +Available since: 2027-07-10T17:10:05.936Z +Bounty Splitting: no +Bookmarked: yes +---------------------------------------- +Program: Example 2 +Handle: example_2 +State: open +Availability: Public +Available since: 2027-05-16T16:00:37.600Z +Bounty Splitting: yes +Bookmarked: no +---------------------------------------- + +Got 2 results! +``` + +
+ +#### `program ` + +Get details about a specific program — scope, policy, bounty info. Works with both public and private programs you have access to. + +Find the handle via `programs` or from the URL: `https://hackerone.com/`. + +
+Example output + +``` +$ hackerone program security + +Program +---------------------------------------- +Name: HackerOne +Handle: security +State: open +Availability: Public +Creation date: 2013-11-06T00:00:00.000Z +Bounty: yes +Bounty Splitting: yes + +Scope +-------------------- +Asset: hackerone.com +Type: URL +State: In-Scope +Bounty: yes +Max Severity: critical +-------------------- +``` + +
+ +#### `csv ` + +Download the CSV scope file from a public program. Uses a web endpoint (not the API), so only works with public programs. + +``` +$ hackerone csv security +Filename: scopes_for_security_(...).csv +``` + +#### `scope [outfile]` + +Extract in-scope domains, URLs, IPs, CIDRs, and wildcards from a downloaded CSV scope file. Output defaults to `inscope.txt`. Useful for feeding into recon tools. + +
+Example output + +``` +$ hackerone scope scopes_for_example.csv targets.txt + +In-Scope +---------------------------------------- +*.example.com +127.0.0.0/24 +test.example.com +---------------------------------------- +File 'targets.txt' saved! +``` + +
+ +#### `burp ` + +Download the Burp Suite project configuration file from a public program. + +``` +$ hackerone burp security +Filename: security-(...).json +``` + +--- + +### Reports + +#### `reports` + +List your submitted reports with key metadata. + +
+Example output + +``` +$ hackerone reports + +Reports +---------------------------------------- +ID: 1234567 +Title: Information Exposure through phpinfo() at example.com +State: triaged +Date: 2027-03-13T16:48:17.286Z +CWE: CWE-200 +Program: example +Severity: low +CVSS: 3.7 +---------------------------------------- +``` + +
+ +#### `report ` + +Full details on a specific report — severity, comments, bounties, content, CVE/CWE, and more. + +
+Example output + +``` +$ hackerone report 1234567 + +Report +---------------------------------------- +ID: 1234567 +Title: Stored XSS on example.com +State: resolved +Date: 2027-06-01T00:54:43.308Z +Program: example +Severity: medium +CWE: CWE-79 +Weakness: Cross-site Scripting (XSS) - Stored + +Comments +-------------------- +@triager + +Thanks for the report. We're looking into it. + +-------------------- +@example awarded a bounty (1337.00 + 0.00)! + +---------------------------------------- +``` + +
+ +--- + +### Payments + +#### `balance` + +Check your current balance (in the currency set on your profile). + +``` +$ hackerone balance +Balance: 1337.0 +``` + +#### `earnings` + +List your bounty earnings by program. + +
+Example output + +``` +$ hackerone earnings + +Earnings +---------------------------------------- +Amount: 1337 USD +Date: 2016-02-02T04:05:06.000Z +Program: HackerOne +Report: RXSS at example.hackerone.com +---------------------------------------- +``` + +
+ +#### `payouts` + +List your processed payouts. + +
+Example output + +``` +$ hackerone payouts + +Payouts +---------------------------------------- +Amount: 1337 +Status: sent +Date: 2016-02-02T04:05:06.000Z +Provider: Paypal +---------------------------------------- +``` + +
+ +--- + +### Account + +#### `profile` + +Show your profile info. Requires at least one submitted report (the API derives your profile from the reporter field). + +
+Example output + +``` +$ hackerone profile + +Profile +---------------------------------------- +ID: 1234567 +Username: example +Reputation: 1337 +Name: Hacker Man +Creation Date: 2020-11-24T16:20:24.066Z +Bio: My beautiful bio +Website: https://example.com/ +Location: Right here +---------------------------------------- +``` + +
+ +#### `help` + +Show all available commands and their arguments. + +--- + +### Organization / Program Management + +These commands use the **program management API** and require an organization-level API token (created in Organization Settings > API Tokens on HackerOne), not a hacker profile token. + +You can use both token types in the same `.env` by switching with `--username` / `--api-key` flags, or use separate `.env` files with `--env-file`. + +#### `org` + +Show your organization info — ID, handle, name, and permissions. + +``` +$ hackerone org + +Organization +---------------------------------------- +ID: 12345 +Handle: mycompany +Name: My Company +Created: 2025-01-01T00:00:00.000Z +Permissions: report_management, reward +---------------------------------------- +``` + +#### `org-reports [max]` + +List reports submitted to your program. Defaults to 10 results. Shows reporter, severity, state, and more. + +``` +$ hackerone org-reports mycompany 5 + +Reports for 'mycompany' +---------------------------------------- +ID: 1234567 +Title: XSS on login page +State: triaged +Date: 2027-03-13T16:48:17.286Z +Reporter: hackerman +Severity: high +CVSS: 8.3 +---------------------------------------- + +Showing 1 of 1 results. +``` + +#### `org-report ` + +Full details on a report submitted to your program, including content, activities, and metadata. + +``` +$ hackerone org-report 1234567 +``` + +#### `org-update-report ` + +Update the state of a report. Valid states: `triaged`, `resolved`, `duplicate`, `spam`, `not-applicable`, `informative`, `needs-more-info`, `new`. + +```sh +hackerone org-update-report 1234567 triaged +hackerone org-update-report 1234567 resolved "Fixed in v2.1" +``` + +#### `org-members ` + +List all members in your organization. Get your org ID from `org`. + +```sh +hackerone org-members 12345 +``` + +#### `org-groups ` + +List permission groups in your organization. + +```sh +hackerone org-groups 12345 +``` + +#### `org-invitations ` + +List pending organization member invitations. + +```sh +hackerone org-invitations 12345 +``` + +#### `org-activities [handle]` + +List recent activities across your programs. Optionally filter by program handle. + +```sh +hackerone org-activities +hackerone org-activities mycompany +``` + +#### `org-metrics ` + +Get program health metrics — response times, acceptance rates, efficiency stats. + +```sh +hackerone org-metrics mycompany +``` + +#### `org-scopes ` + +List all structured scopes (assets) for a program, grouped by in-scope and out-of-scope. + +```sh +hackerone org-scopes mycompany +``` + +#### `org-invite-hacker ` + +Invite a hacker to a private program. + +```sh +hackerone org-invite-hacker 12345 hackerman +``` + +#### `org-bounty ` + +Award a bounty on a report. Optionally include a message. + +```sh +hackerone org-bounty 1234567 500 +hackerone org-bounty 1234567 1000 "Great find, thanks!" +``` + +#### `org-swag ` + +Award swag on a report. Optionally include a message. + +```sh +hackerone org-swag 1234567 +hackerone org-swag 1234567 "T-shirt on its way!" +``` + +## Development + +```sh +just install # Install dependencies +just check # Run lint + format checks +just fix # Auto-fix lint and formatting +just test # Smoke test against the live API +just run # Run the CLI +``` + +Or without just: + +```sh +uv sync +uv run ruff check . +uv run ruff format . +``` + +## License + +[MIT](./LICENSE) diff --git a/hackerone.py b/hackerone.py index 22998ff..21a171a 100644 --- a/hackerone.py +++ b/hackerone.py @@ -1,83 +1,172 @@ #!/usr/bin/env python3 -import requests -import os -import sys +import csv as csvmod import json -import mdv +import os import re -import csv as csvmod -from requests.auth import HTTPBasicAuth +import sys + +import requests from dotenv import load_dotenv +from requests.auth import HTTPBasicAuth + +__version__ = "1.0.2" + +JSON_OUTPUT = False +VERBOSE = False +auth = None + + +def _render_markdown(text): + """Try to render markdown with mdv, fall back to plain text.""" + try: + if "COLUMNS" not in os.environ: + try: + os.environ["COLUMNS"] = str(os.get_terminal_size()[0]) + except OSError: + os.environ["COLUMNS"] = "80" + import mdv + + mdv.term_columns = _get_terminal_width() + return mdv.main(text) + except Exception: + return text + + +def _get_terminal_width(default=80): + try: + return os.get_terminal_size()[0] + except OSError: + return default + + +def _log(msg): + if VERBOSE and not JSON_OUTPUT: + print(f"[*] {msg}", file=sys.stderr) + + +def _error(msg): + if JSON_OUTPUT: + print(json.dumps({"error": msg})) + else: + print(msg) + + +def _error_exit(msg): + _error(msg) + sys.exit(1) + + +def show_help(): + hacker_commands = { + "balance": "Check your balance", + "burp ": "Download the burp configuration file (only from public programs)", + "csv ": "Download CSV scope file (only from public programs)", + "earnings": "Check your earnings", + "help": "Help page", + "payouts": "Get a list of your payouts", + "profile": "Your profile on HackerOne", + "program ": "Get information from a program", + "programs []": "Get a list of current programs (optional: = maximum number of results)", + "report ": "Get a specific report", + "reports": "Get a list of your reports", + "scope []": "Extract in-scope domains/URLs/wildcards/IPs/CIDRs from a csv scope file and save it to a text file", + } + org_commands = { + "org": "Show your organization info", + "org-members ": "List organization members", + "org-groups ": "List organization permission groups", + "org-invitations ": "List pending organization invitations", + "org-reports []": "List reports submitted to your program (default: 10)", + "org-report ": "Get a report submitted to your program", + "org-update-report ": "Update report state (triaged, resolved, duplicate, spam, not-applicable)", + "org-activities []": "List recent activities (optionally filter by program)", + "org-metrics ": "Get program metrics and response efficiency", + "org-scopes ": "List structured scopes (assets) for a program", + "org-invite-hacker ": "Invite a hacker to a private program", + "org-bounty ": "Award a bounty on a report", + "org-swag ": "Award swag on a report", + } + all_commands = {**hacker_commands, **org_commands} + if JSON_OUTPUT: + print(json.dumps({"commands": all_commands})) + return + print("Hacker Modules:") + for cmd, desc in hacker_commands.items(): + print(f" {cmd:<44}{desc}") + print("\nProgram Management Modules:") + for cmd, desc in org_commands.items(): + print(f" {cmd:<44}{desc}") + print("\nOptions:") + print(" --username, -u HackerOne API token identifier (overrides HACKERONE_USERNAME)") + print(" --api-key, -k HackerOne API token value (overrides HACKERONE_API_KEY)") + print(" --json, -j Output as JSON") + print(" --env-file Path to .env file (default: .env in current directory)") + print(" --verbose, -v Show progress and debug info") -__version__ = "1.0.1" - -load_dotenv() -USERNAME = os.getenv("HACKERONE_USERNAME") -TOKEN = os.getenv("HACKERONE_API_KEY") -auth = HTTPBasicAuth(USERNAME, TOKEN) - -def help(): - print("""Modules: - balance Check your balance - burp Download the burp configuration file (only from public programs) - csv Download CSV scope file (only from public programs) - earnings Check your earnings - help Help page - payouts Get a list of your payouts - profile Your profile on HackerOne - program Get information from a program - programs [] Get a list of current programs (optional: = maximum number of results) - report Get a specific report - reports Get a list of your reports - scope [] Extract in-scope domains/URLs/wildcards/IPs/CIDRs from a csv scope file and save it to a text file""") def burp(): - if (len(sys.argv) != 3): - print("Invalid arguments provided!") + if len(sys.argv) != 3: + _error("Invalid arguments provided!") return - + handler = sys.argv[2] + _log(f"Downloading Burp config for '{handler}'...") r = requests.get(f"https://hackerone.com/teams/{handler}/assets/download_burp_project_file.json") - if (r.status_code != 200 and r.status_code != 404): - print(f"Request returned {r.status_code}!") - sys.exit() - if (not r.headers["Content-Disposition"].startswith("attachment")): - print(f"Could not find program '{handler}'!") - return - - print("Filename: " + r.headers["Content-Disposition"].split("\"")[1]) - with open(r.headers["Content-Disposition"].split("\"")[1], "wb") as fp: + if r.status_code != 200 and r.status_code != 404: + _error_exit(f"Request returned {r.status_code}!") + if not r.headers["Content-Disposition"].startswith("attachment"): + _error(f"Could not find program '{handler}'!") + return + + filename = r.headers["Content-Disposition"].split('"')[1] + with open(filename, "wb") as fp: fp.write(r.content) + if JSON_OUTPUT: + print(json.dumps({"filename": filename, "status": "downloaded"})) + else: + print("Filename: " + filename) + + def csv(): - if (len(sys.argv) != 3): - print("Invalid arguments provided!") + if len(sys.argv) != 3: + _error("Invalid arguments provided!") return - + handler = sys.argv[2] + _log(f"Downloading CSV scope for '{handler}'...") r = requests.get(f"https://hackerone.com/teams/{handler}/assets/download_csv.csv") - if (r.status_code != 200 and r.status_code != 404): - print(f"Request returned {r.status_code}!") - sys.exit() - if (not r.headers["Content-Disposition"].startswith("attachment")): - print(f"Could not find program '{handler}'!") - return - - print("Filename: " + r.headers["Content-Disposition"].split("\"")[1]) - with open(r.headers["Content-Disposition"].split("\"")[1], "wb") as fp: + if r.status_code != 200 and r.status_code != 404: + _error_exit(f"Request returned {r.status_code}!") + if not r.headers["Content-Disposition"].startswith("attachment"): + _error(f"Could not find program '{handler}'!") + return + + filename = r.headers["Content-Disposition"].split('"')[1] + with open(filename, "wb") as fp: fp.write(r.content) + if JSON_OUTPUT: + print(json.dumps({"filename": filename, "status": "downloaded"})) + else: + print("Filename: " + filename) + + def reports(): + _log("Fetching reports...") r = requests.get("https://api.hackerone.com/v1/hackers/me/reports", auth=auth) - if (r.status_code != 200): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) - if (len(data["data"]) == 0): + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: print("You have no reports.") return @@ -93,7 +182,7 @@ def reports(): print("Severity: " + rep["relationships"]["severity"]["data"]["attributes"]["rating"]) except: print("Severity: none") - if ("cve_ids" in rep["attributes"] and rep["attributes"]["cve_ids"] not in [None, "", []]): + if "cve_ids" in rep["attributes"] and rep["attributes"]["cve_ids"] not in [None, "", []]: print("CVE: " + ", ".join(rep["attributes"]["cve_ids"])) try: print("CWE: " + str.upper(rep["relationships"]["weakness"]["data"]["attributes"]["external_id"])) @@ -107,28 +196,33 @@ def reports(): pass print("----------------------------------------") + def report(): - if (len(sys.argv) != 3): - print("Invalid arguments provided!") + if len(sys.argv) != 3: + _error("Invalid arguments provided!") return - - if (not sys.argv[2].isdigit()): - print(f"Invalid ID provided '{sys.argv[2]}'!") + + if not sys.argv[2].isdigit(): + _error(f"Invalid ID provided '{sys.argv[2]}'!") return id = sys.argv[2] + _log(f"Fetching report {id}...") r = requests.get(f"https://api.hackerone.com/v1/hackers/reports/{id}", auth=auth) - if (r.status_code != 200 and r.status_code != 404): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200 and r.status_code != 404: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) - if (r.status_code == 404): - print("Report not found!") + if r.status_code == 404: + _error("Report not found!") + return + + if JSON_OUTPUT: + print(json.dumps(data)) return - if (len(data["data"]) == 0): + if len(data["data"]) == 0: print("You have no reports.") return @@ -145,7 +239,7 @@ def report(): print("Severity: " + rep["relationships"]["severity"]["data"]["attributes"]["rating"]) except: print("Severity: none") - if ("cve_ids" in rep["attributes"] and rep["attributes"]["cve_ids"] not in [None, "", []]): + if "cve_ids" in rep["attributes"] and rep["attributes"]["cve_ids"] not in [None, "", []]: print("CVE: " + ", ".join(rep["attributes"]["cve_ids"])) try: print("CWE: " + str.upper(rep["relationships"]["weakness"]["data"]["attributes"]["external_id"])) @@ -153,7 +247,7 @@ def report(): except: print("CWE: none") print("Weakness: none") - + try: print("Asset: " + rep["relationships"]["structured_scope"]["data"]["attributes"]["asset_identifier"]) print("Asset Type: " + rep["relationships"]["structured_scope"]["data"]["attributes"]["asset_type"]) @@ -164,117 +258,218 @@ def report(): except: pass - if ("vulnerability_information" in rep["attributes"]): + if "vulnerability_information" in rep["attributes"]: print("\nContent") print("--------------------") - try: - mdv.term_columns = os.get_terminal_size()[0] - print(mdv.main(rep["attributes"]["vulnerability_information"])) - except FileNotFoundError: - print(rep["attributes"]["vulnerability_information"]) - + print(_render_markdown(rep["attributes"]["vulnerability_information"])) print("\nComments") for comment in rep["relationships"]["activities"]["data"]: print("--------------------") - if ("username" in comment["relationships"]["actor"]["data"]["attributes"]): + if "username" in comment["relationships"]["actor"]["data"]["attributes"]: entity = comment["relationships"]["actor"]["data"]["attributes"]["username"] - elif ("handle" in comment["relationships"]["actor"]["data"]["attributes"]): + elif "handle" in comment["relationships"]["actor"]["data"]["attributes"]: entity = comment["relationships"]["actor"]["data"]["attributes"]["handle"] else: entity = "someone" try: match comment["type"]: case "activity-report-severity-updated": - print("\x1B[3m@" + entity + "\x1B[23m updated the severity of the report!") + print("\x1b[3m@" + entity + "\x1b[23m updated the severity of the report!") case "activity-bug-pending-program-review": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " changed the report status to 'Pending for review'!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " changed the report status to 'Pending for review'!" + ) + ) case "activity-comment": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " posted a comment! (Not visible)")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " posted a comment! (Not visible)" + ) + ) case "activity-bug-triaged": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " changed the report status to 'Triaged'!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " changed the report status to 'Triaged'!" + ) + ) case "activity-bug-resolved": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " changed the report status to 'Resolved'!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " changed the report status to 'Resolved'!" + ) + ) case "activity-bug-duplicate": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " changed the report status to 'Duplicate'!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " changed the report status to 'Duplicate'!" + ) + ) case "activity-bounty-awarded": - print("\x1B[3m@" + rep["relationships"]["program"]["data"]["attributes"]["handle"] + "\x1B[23m" + f" awarded a bounty ({comment['attributes']['bounty_amount']} + {comment['attributes']['bonus_amount']})!" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else "")) + print( + "\x1b[3m@" + + rep["relationships"]["program"]["data"]["attributes"]["handle"] + + "\x1b[23m" + + f" awarded a bounty ({comment['attributes']['bounty_amount']} + {comment['attributes']['bonus_amount']})!" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else "" + ) + ) case "activity-bug-retesting": - print("\x1B[3m@" + entity + "\x1B[23m changed the status of the report to 'Retesting'!") + print("\x1b[3m@" + entity + "\x1b[23m changed the status of the report to 'Retesting'!") case "activity-hacker-requested-mediation": - print("\x1B[3m@" + entity + "\x1B[23m has requested mediation from HackerOne Support!") + print("\x1b[3m@" + entity + "\x1b[23m has requested mediation from HackerOne Support!") case "activity-user-completed-retest": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " completed retesting!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " completed retesting!" + ) + ) case "activity-report-retest-approved": - print("\x1B[3m@" + entity + "\x1B[23m approved the retesting!") + print("\x1b[3m@" + entity + "\x1b[23m approved the retesting!") case "activity-report-collaborator-invited": - print("\x1B[3m@" + entity + "\x1B[23m invited a collaborator!") + print("\x1b[3m@" + entity + "\x1b[23m invited a collaborator!") case "activity-report-collaborator-joined": - print("\x1B[3m@" + entity + "\x1B[23m joined as a collaborator!") + print("\x1b[3m@" + entity + "\x1b[23m joined as a collaborator!") case "activity-agreed-on-going-public": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " agreed on the report going public!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " agreed on the report going public!" + ) + ) case "activity-report-became-public": - print("\x1B[3m@" + entity + "\x1B[23m changed the report visibility to public!") + print("\x1b[3m@" + entity + "\x1b[23m changed the report visibility to public!") case "activity-cancelled-disclosure-request": - print("\x1B[3m@" + entity + "\x1B[23m" + ("\n\n" + comment["attributes"]["message"] if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] else " requested to cancel disclosure!")) + print( + "\x1b[3m@" + + entity + + "\x1b[23m" + + ( + "\n\n" + comment["attributes"]["message"] + if "message" in comment["attributes"] and comment["attributes"]["message"] not in [None, ""] + else " requested to cancel disclosure!" + ) + ) case "activity-report-title-updated": - print("\x1B[3m@" + entity + "\x1B[23m changed the report title!") + print("\x1b[3m@" + entity + "\x1b[23m changed the report title!") case "activity-bug-needs-more-info": - print("\x1B[3m@" + entity + "\x1B[23m changed the report status to 'Needs more info'!") + print("\x1b[3m@" + entity + "\x1b[23m changed the report status to 'Needs more info'!") case "activity-bug-new": - print("\x1B[3m@" + entity + "\x1B[23m changed the report status to 'New'!") + print("\x1b[3m@" + entity + "\x1b[23m changed the report status to 'New'!") case "activity-cve-id-added": - print("\x1B[3m@" + entity + "\x1B[23m added a CVE id!") + print("\x1b[3m@" + entity + "\x1b[23m added a CVE id!") case "activity-external-user-joined": - print("\x1B[3m@" + entity + "\x1B[23m joined this report as a participant!") + print("\x1b[3m@" + entity + "\x1b[23m joined this report as a participant!") case "activity-manually-disclosed": - print("\x1B[3m@" + entity + "\x1B[23m disclosed this report!") + print("\x1b[3m@" + entity + "\x1b[23m disclosed this report!") case "activity-report-vulnerability-types-updated": - print("\x1B[3m@" + entity + "\x1B[23m updated the vulnerability type/weakness!") + print("\x1b[3m@" + entity + "\x1b[23m updated the vulnerability type/weakness!") case _: raise Exception() except: print(comment) - print("\x1B[3m@" + entity + "\x1B[23m participated on the report! (Could not get more details)") + print("\x1b[3m@" + entity + "\x1b[23m participated on the report! (Could not get more details)") print() print("----------------------------------------") + def balance(): + _log("Fetching balance...") r = requests.get("https://api.hackerone.com/v1/hackers/payments/balance", auth=auth) - if (r.status_code != 200): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + print("Balance: " + str(data["data"]["balance"])) + def earnings(): + _log("Fetching earnings...") r = requests.get("https://api.hackerone.com/v1/hackers/payments/earnings", auth=auth) - if (r.status_code != 200): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) - if (len(data["data"]) == 0): + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: print("You have no earnings.") return print("Earnings") print("----------------------------------------") for earn in data["data"]: - print("Amount: " + earn["relationships"]["bounty"]["data"]["attributes"]["amount"] + " " + earn["relationships"]["bounty"]["data"]["attributes"]["awarded_currency"]) + print( + "Amount: " + + earn["relationships"]["bounty"]["data"]["attributes"]["amount"] + + " " + + earn["relationships"]["bounty"]["data"]["attributes"]["awarded_currency"] + ) print("Date: " + earn["attributes"]["created_at"]) print("Program: " + earn["relationships"]["program"]["data"]["attributes"]["name"]) - print("Report: " + earn["relationships"]["bounty"]["data"]["relationships"]["report"]["data"]["attributes"]["title"]) + print( + "Report: " + + earn["relationships"]["bounty"]["data"]["relationships"]["report"]["data"]["attributes"]["title"] + ) print("----------------------------------------") + def payouts(): + _log("Fetching payouts...") r = requests.get("https://api.hackerone.com/v1/hackers/payments/payouts", auth=auth) - if (r.status_code != 200): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) - if (len(data["data"]) == 0): + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: print("You have no payouts.") return @@ -286,46 +481,53 @@ def payouts(): print("Date: " + payout["paid_out_at"]) print("Provider: " + payout["payout_provider"]) + def programs(): - max = 10 - if (len(sys.argv) == 3): - if (sys.argv[2].isdigit() and int(sys.argv[2]) > 0): - max = sys.argv[2] + limit = 10 + if len(sys.argv) == 3: + if sys.argv[2].isdigit() and int(sys.argv[2]) > 0: + limit = sys.argv[2] else: - print(f"Invalid maximum value '{sys.argv[2]}'!") + _error(f"Invalid maximum value '{sys.argv[2]}'!") return - - max = int(max) + + limit = int(limit) c = 0 - programs = [] + results = [] + _log("Fetching programs...") while True: + _log(f" Fetching page {c + 1}...") r = requests.get(f"https://api.hackerone.com/v1/hackers/programs?page[size]=100&page[number]={c}", auth=auth) - if (r.status_code != 200 and r.status_code != 404): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200 and r.status_code != 404: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) - if (r.status_code == 404): + if r.status_code == 404: break - - if (len(data["data"]) == 0): + + if len(data["data"]) == 0: break for program in data["data"]: - programs.append(program) - + results.append(program) + c += 1 - - programs = programs[::-1] + + _log(f"Fetched {len(results)} programs total.") + results = results[::-1] + results = results[:limit] + + if JSON_OUTPUT: + print(json.dumps({"data": results, "count": len(results)})) + return + count = 0 print("Programs") - for program in programs: - if (count == max): - break + for program in results: print("----------------------------------------") print("Program: " + program["attributes"]["name"]) print("Handle: " + program["attributes"]["handle"]) @@ -335,23 +537,28 @@ def programs(): print("Bounty Splitting: " + ("yes" if program["attributes"]["allows_bounty_splitting"] else "no")) print("Bookmarked: " + ("yes" if program["attributes"]["bookmarked"] else "no")) count += 1 - + print("----------------------------------------\n") print(f"Got {count} results!") + def profile(): + _log("Fetching profile...") r = requests.get("https://api.hackerone.com/v1/hackers/me/reports", auth=auth) - if (r.status_code != 200): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) - if (len(data["data"]) == 0): - print("Could not check your profile!") + if len(data["data"]) == 0: + _error("Could not check your profile!") return user = data["data"][0]["relationships"]["reporter"]["data"] + if JSON_OUTPUT: + print(json.dumps(user)) + return + print("Profile") print("----------------------------------------") print("ID: " + user["id"]) @@ -359,23 +566,49 @@ def profile(): print("Reputation: " + str(user["attributes"]["reputation"])) print("Name: " + user["attributes"]["name"]) print("Creation Date: " + user["attributes"]["created_at"]) - print("Bio: " + (user["attributes"]["bio"] if "bio" in user["attributes"] and user["attributes"]["bio"] not in [None, ""] else "")) - print("Website: " + (user["attributes"]["website"] if "website" in user["attributes"] and user["attributes"]["website"] not in [None, ""] else "")) - print("Location: " + (user["attributes"]["location"] if "location" in user["attributes"] and user["attributes"]["location"] not in [None, ""] else "")) + print( + "Bio: " + + ( + user["attributes"]["bio"] + if "bio" in user["attributes"] and user["attributes"]["bio"] not in [None, ""] + else "" + ) + ) + print( + "Website: " + + ( + user["attributes"]["website"] + if "website" in user["attributes"] and user["attributes"]["website"] not in [None, ""] + else "" + ) + ) + print( + "Location: " + + ( + user["attributes"]["location"] + if "location" in user["attributes"] and user["attributes"]["location"] not in [None, ""] + else "" + ) + ) print("----------------------------------------") + def program(): - if (len(sys.argv) < 3): - print("No handle provided!") + if len(sys.argv) < 3: + _error("No handle provided!") return handle = sys.argv[2] + _log(f"Fetching program '{handle}'...") r = requests.get(f"https://api.hackerone.com/v1/hackers/programs/{handle}", auth=auth) - if (r.status_code != 200): - print(f"Request returned {r.status_code}!") - sys.exit() + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") data = json.loads(r.text) + if JSON_OUTPUT: + print(json.dumps(data)) + return + print("Program") print("----------------------------------------") print("Name: " + data["attributes"]["name"]) @@ -389,81 +622,729 @@ def program(): print("\nPolicy") print("--------------------") - try: - mdv.term_columns = os.get_terminal_size()[0] - print(mdv.main(data["attributes"]["policy"])) - except FileNotFoundError: - print(data["attributes"]["policy"]) - + print(_render_markdown(data["attributes"]["policy"])) + print("\nScope") for scope in data["relationships"]["structured_scopes"]["data"]: print("--------------------") print("Asset: " + scope["attributes"]["asset_identifier"]) print("Type: " + scope["attributes"]["asset_type"]) print("State: " + ("In-Scope" if scope["attributes"]["eligible_for_submission"] else "Out-of-Scope")) - if ("eligible_for_bounty" in scope["attributes"] and scope["attributes"]["eligible_for_bounty"]): + if "eligible_for_bounty" in scope["attributes"] and scope["attributes"]["eligible_for_bounty"]: print("Bounty: " + ("yes" if scope["attributes"]["eligible_for_bounty"] else "no")) - if (scope["attributes"]["instruction"]): + if scope["attributes"]["instruction"]: print("Instruction: " + scope["attributes"]["instruction"]) - print("Max Severity: " + scope["attributes"]["max_severity"] if scope["attributes"]["max_severity"] is not None else "None") + print( + "Max Severity: " + scope["attributes"]["max_severity"] + if scope["attributes"]["max_severity"] is not None + else "None" + ) print("----------------------------------------") + def scope(): - if (len(sys.argv) not in [3,4]): - print(sys.argv) - print("Invalid arguments provided!") + if len(sys.argv) not in [3, 4]: + _error("Invalid arguments provided!") return - - handle = sys.argv[2] + outfile = sys.argv[3] if len(sys.argv) == 4 else "inscope.txt" inscope = [] try: - with open(sys.argv[2], "r") as fp: + with open(sys.argv[2]) as fp: reader = csvmod.reader(fp) try: next(reader) except: raise Exception() - print("In-Scope") - print("----------------------------------------") + if not JSON_OUTPUT: + print("In-Scope") + print("----------------------------------------") for row in reader: - if (not row[4] or row[1] not in ["URL", "DOMAIN", "OTHER", "WILDCARD", "CIDR"]): + if not row[4] or row[1] not in ["URL", "DOMAIN", "OTHER", "WILDCARD", "CIDR"]: continue - if (re.match(r"^(\*\.)?([a-zA-Z0-9\*]([a-zA-Z0-9\-\*]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,256}$", row[0]) or re.match(r"^(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}(\/[0-3]?[0-9])?$", row[0])): - inscope.append(row[0] + "\n") - print(row[0]) - print("----------------------------------------") + if re.match( + r"^(\*\.)?([a-zA-Z0-9\*]([a-zA-Z0-9\-\*]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,256}$", row[0] + ) or re.match( + r"^(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}(\/[0-3]?[0-9])?$", + row[0], + ): + inscope.append(row[0]) + if not JSON_OUTPUT: + print(row[0]) + if not JSON_OUTPUT: + print("----------------------------------------") except: - print(f"Failed to read file '{sys.argv[2]}'!") + _error(f"Failed to read file '{sys.argv[2]}'!") return - + + if JSON_OUTPUT: + print(json.dumps({"inscope": sorted(inscope), "outfile": outfile})) + return + try: with open(f"{outfile}", "a") as fp: - fp.writelines(sorted(inscope)) + fp.writelines(item + "\n" for item in sorted(inscope)) print(f"File '{outfile}' saved!") except: print(f"Failed to write to file '{outfile}'!") return + +# --- Program Management (Organization) Commands --- + + +def org(): + _log("Fetching organization info...") + r = requests.get("https://api.hackerone.com/v1/me/organizations", auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print("You don't belong to any organization.") + return + + for o in data["data"]: + print("Organization") + print("----------------------------------------") + print("ID: " + o["id"]) + print("Handle: " + o["attributes"]["handle"]) + print("Name: " + o["attributes"]["name"]) + print("Created: " + o["attributes"]["created_at"]) + if "permissions" in o["attributes"] and o["attributes"]["permissions"]: + print("Permissions: " + ", ".join(o["attributes"]["permissions"])) + print("----------------------------------------") + + +def org_reports(): + if len(sys.argv) < 3: + _error("No program handle provided!") + return + + handle = sys.argv[2] + limit = 10 + if len(sys.argv) >= 4: + if sys.argv[3].isdigit() and int(sys.argv[3]) > 0: + limit = int(sys.argv[3]) + else: + _error(f"Invalid maximum value '{sys.argv[3]}'!") + return + + _log(f"Fetching reports for program '{handle}'...") + params = { + "filter[program][]": handle, + "page[size]": min(limit, 100), + "page[number]": 1, + } + r = requests.get("https://api.hackerone.com/v1/reports", params=params, auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print(f"No reports found for program '{handle}'.") + return + + print(f"Reports for '{handle}'") + print("----------------------------------------") + count = 0 + for rep in data["data"]: + if count >= limit: + break + print("ID: " + rep["id"]) + print("Title: " + rep["attributes"]["title"]) + print("State: " + rep["attributes"]["state"]) + print("Date: " + rep["attributes"]["created_at"]) + try: + print("Reporter: " + rep["relationships"]["reporter"]["data"]["attributes"]["username"]) + except: + print("Reporter: unknown") + try: + print("Severity: " + rep["relationships"]["severity"]["data"]["attributes"]["rating"]) + except: + print("Severity: none") + try: + print("CWE: " + str.upper(rep["relationships"]["weakness"]["data"]["attributes"]["external_id"])) + except: + pass + try: + print("CVSS: " + str(rep["relationships"]["severity"]["data"]["attributes"]["score"])) + except: + pass + print("----------------------------------------") + count += 1 + print(f"\nShowing {count} of {len(data['data'])} results.") + + +def org_report(): + if len(sys.argv) != 3: + _error("Invalid arguments provided!") + return + + if not sys.argv[2].isdigit(): + _error(f"Invalid ID provided '{sys.argv[2]}'!") + return + + id = sys.argv[2] + + _log(f"Fetching report {id}...") + r = requests.get(f"https://api.hackerone.com/v1/reports/{id}", auth=auth) + if r.status_code == 404: + _error("Report not found!") + return + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + rep = data["data"] + + print("Report") + print("----------------------------------------") + print("ID: " + rep["id"]) + print("Title: " + rep["attributes"]["title"]) + print("State: " + rep["attributes"]["state"]) + print("Date: " + rep["attributes"]["created_at"]) + try: + print("Reporter: " + rep["relationships"]["reporter"]["data"]["attributes"]["username"]) + except: + print("Reporter: unknown") + try: + print("Program: " + rep["relationships"]["program"]["data"]["attributes"]["handle"]) + except: + pass + try: + print("Severity: " + rep["relationships"]["severity"]["data"]["attributes"]["rating"]) + except: + print("Severity: none") + if "cve_ids" in rep["attributes"] and rep["attributes"]["cve_ids"] not in [None, "", []]: + print("CVE: " + ", ".join(rep["attributes"]["cve_ids"])) + try: + print("CWE: " + str.upper(rep["relationships"]["weakness"]["data"]["attributes"]["external_id"])) + print("Weakness: " + rep["relationships"]["weakness"]["data"]["attributes"]["name"]) + except: + pass + try: + print("Asset: " + rep["relationships"]["structured_scope"]["data"]["attributes"]["asset_identifier"]) + except: + pass + try: + print("CVSS: " + str(rep["relationships"]["severity"]["data"]["attributes"]["score"])) + except: + pass + + if "vulnerability_information" in rep["attributes"] and rep["attributes"]["vulnerability_information"]: + print("\nContent") + print("--------------------") + print(_render_markdown(rep["attributes"]["vulnerability_information"])) + + try: + activities = rep["relationships"]["activities"]["data"] + if activities: + print("\nActivities") + for act in activities: + print("--------------------") + try: + actor = act["relationships"]["actor"]["data"]["attributes"] + entity = actor.get("username") or actor.get("handle") or "someone" + except: + entity = "someone" + act_type = act["type"].replace("activity-", "").replace("-", " ").title() + msg = "" + if "message" in act.get("attributes", {}) and act["attributes"]["message"]: + msg = "\n" + act["attributes"]["message"] + print(f"@{entity} — {act_type}{msg}") + print("----------------------------------------") + except: + pass + + +def org_members(): + if len(sys.argv) < 3: + _error("No organization ID provided! Use 'org' to find your org ID.") + return + + org_id = sys.argv[2] + _log(f"Fetching members for organization {org_id}...") + r = requests.get(f"https://api.hackerone.com/v1/organizations/{org_id}/members", auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print("No members found.") + return + + print("Members") + print("----------------------------------------") + for member in data["data"]: + try: + user = member["relationships"]["user"]["data"]["attributes"] + print("Username: " + user.get("username", "unknown")) + except: + print("ID: " + member["id"]) + try: + groups = member["relationships"]["organization_member_groups"]["data"] + group_names = [g["attributes"]["name"] for g in groups] + if group_names: + print("Groups: " + ", ".join(group_names)) + except: + pass + print("----------------------------------------") + print(f"\n{len(data['data'])} members.") + + +def org_groups(): + if len(sys.argv) < 3: + _error("No organization ID provided! Use 'org' to find your org ID.") + return + + org_id = sys.argv[2] + _log(f"Fetching groups for organization {org_id}...") + r = requests.get(f"https://api.hackerone.com/v1/organizations/{org_id}/groups", auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print("No groups found.") + return + + print("Groups") + print("----------------------------------------") + for group in data["data"]: + print("ID: " + group["id"]) + print("Name: " + group["attributes"]["name"]) + try: + perms = group["attributes"]["permissions"] + if perms: + print("Permissions: " + ", ".join(perms)) + except: + pass + print("----------------------------------------") + + +def org_invitations(): + if len(sys.argv) < 3: + _error("No organization ID provided! Use 'org' to find your org ID.") + return + + org_id = sys.argv[2] + _log(f"Fetching invitations for organization {org_id}...") + r = requests.get(f"https://api.hackerone.com/v1/organizations/{org_id}/invitations", auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print("No pending invitations.") + return + + print("Pending Invitations") + print("----------------------------------------") + for inv in data["data"]: + print("ID: " + inv["id"]) + try: + print("Email: " + inv["attributes"]["email"]) + except: + pass + try: + print("Created: " + inv["attributes"]["created_at"]) + except: + pass + print("----------------------------------------") + + +def org_update_report(): + if len(sys.argv) < 4: + _error("Usage: org-update-report ") + _error("States: triaged, resolved, duplicate, spam, not-applicable") + return + + if not sys.argv[2].isdigit(): + _error(f"Invalid report ID '{sys.argv[2]}'!") + return + + report_id = sys.argv[2] + state = sys.argv[3] + + state_map = { + "triaged": "activity-bug-triaged", + "resolved": "activity-bug-resolved", + "duplicate": "activity-bug-duplicate", + "spam": "activity-bug-spam", + "not-applicable": "activity-bug-not-applicable", + "informative": "activity-bug-informative", + "needs-more-info": "activity-bug-needs-more-info", + "new": "activity-bug-new", + } + + if state not in state_map: + _error(f"Invalid state '{state}'. Valid states: {', '.join(state_map.keys())}") + return + + message = sys.argv[4] if len(sys.argv) >= 5 else "" + + _log(f"Updating report {report_id} to '{state}'...") + payload = { + "data": { + "type": state_map[state], + "attributes": { + "message": message, + }, + } + } + + r = requests.post( + f"https://api.hackerone.com/v1/reports/{report_id}/activities", + json=payload, + auth=auth, + ) + if r.status_code not in (200, 201): + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + print(f"Report {report_id} updated to '{state}'.") + + +def org_activities(): + handle = sys.argv[2] if len(sys.argv) >= 3 else None + + _log("Fetching activities...") + params = {"page[size]": 25} + if handle: + params["filter[program][]"] = handle + + r = requests.get("https://api.hackerone.com/v1/incremental/activities", params=params, auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print("No activities found.") + return + + print("Activities" + (f" for '{handle}'" if handle else "")) + print("----------------------------------------") + for act in data["data"]: + act_type = act["type"].replace("activity-", "").replace("-", " ").title() + try: + actor = act["relationships"]["actor"]["data"]["attributes"] + entity = actor.get("username") or actor.get("handle") or "someone" + except: + entity = "someone" + date = act.get("attributes", {}).get("created_at", "") + msg = "" + if "message" in act.get("attributes", {}) and act["attributes"]["message"]: + msg = "\n " + act["attributes"]["message"][:200] + print(f"[{date}] @{entity} — {act_type}{msg}") + print("--------------------") + + +def org_metrics(): + if len(sys.argv) < 3: + _error("No program handle provided!") + return + + handle = sys.argv[2] + + # First get program ID from handle + _log(f"Fetching program '{handle}' to get ID...") + r = requests.get(f"https://api.hackerone.com/v1/hackers/programs/{handle}", auth=auth) + if r.status_code != 200: + _error_exit(f"Could not find program '{handle}' (status {r.status_code})!") + program_data = json.loads(r.text) + program_id = program_data.get("id", program_data.get("data", {}).get("id")) + + if not program_id: + _error("Could not determine program ID!") + return + + _log(f"Fetching metrics for program {program_id}...") + r = requests.get(f"https://api.hackerone.com/v1/programs/{program_id}/metrics", auth=auth) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + print(f"Metrics for '{handle}'") + print("----------------------------------------") + if "data" in data: + for key, value in data["data"].get("attributes", data["data"]).items(): + if key not in ("type", "id"): + print(f"{key.replace('_', ' ').title()}: {value}") + else: + for key, value in data.items(): + print(f"{key.replace('_', ' ').title()}: {value}") + print("----------------------------------------") + + +def org_scopes(): + if len(sys.argv) < 3: + _error("No program handle provided!") + return + + handle = sys.argv[2] + + _log(f"Fetching scopes for program '{handle}'...") + r = requests.get( + f"https://api.hackerone.com/v1/hackers/programs/{handle}/structured_scopes", + params={"page[size]": 100}, + auth=auth, + ) + if r.status_code != 200: + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + if len(data["data"]) == 0: + print(f"No scopes found for '{handle}'.") + return + + in_scope = [s for s in data["data"] if s["attributes"].get("eligible_for_submission")] + out_scope = [s for s in data["data"] if not s["attributes"].get("eligible_for_submission")] + + print(f"Scopes for '{handle}'") + + if in_scope: + print("\nIn-Scope") + print("----------------------------------------") + for s in in_scope: + attrs = s["attributes"] + bounty = "yes" if attrs.get("eligible_for_bounty") else "no" + print(f" {attrs['asset_identifier']} ({attrs['asset_type']}) — bounty: {bounty}") + if attrs.get("instruction"): + print(f" {attrs['instruction'][:120]}") + print() + + if out_scope: + print("Out-of-Scope") + print("----------------------------------------") + for s in out_scope: + attrs = s["attributes"] + print(f" {attrs['asset_identifier']} ({attrs['asset_type']})") + print() + + print(f"{len(in_scope)} in-scope, {len(out_scope)} out-of-scope.") + + +def org_invite_hacker(): + if len(sys.argv) < 4: + _error("Usage: org-invite-hacker ") + return + + program_id = sys.argv[2] + username = sys.argv[3] + + _log(f"Inviting '{username}' to program {program_id}...") + payload = { + "data": [ + { + "type": "hacker-invitation", + "attributes": { + "username": username, + }, + } + ] + } + + r = requests.post( + f"https://api.hackerone.com/v1/programs/{program_id}/hacker_invitations", + json=payload, + auth=auth, + ) + if r.status_code not in (200, 201): + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + print(f"Invitation sent to '{username}' for program {program_id}.") + + +def org_bounty(): + if len(sys.argv) < 4: + _error("Usage: org-bounty [message]") + return + + if not sys.argv[2].isdigit(): + _error(f"Invalid report ID '{sys.argv[2]}'!") + return + + report_id = sys.argv[2] + amount = sys.argv[3] + message = sys.argv[4] if len(sys.argv) >= 5 else "" + + try: + float(amount) + except ValueError: + _error(f"Invalid amount '{amount}'!") + return + + _log(f"Awarding ${amount} bounty on report {report_id}...") + payload = { + "data": { + "type": "bounty", + "attributes": { + "amount": float(amount), + "message": message, + }, + } + } + + r = requests.post( + f"https://api.hackerone.com/v1/reports/{report_id}/bounties", + json=payload, + auth=auth, + ) + if r.status_code not in (200, 201): + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + print(f"Bounty of ${amount} awarded on report {report_id}.") + + +def org_swag(): + if len(sys.argv) < 3: + _error("Usage: org-swag [message]") + return + + if not sys.argv[2].isdigit(): + _error(f"Invalid report ID '{sys.argv[2]}'!") + return + + report_id = sys.argv[2] + message = sys.argv[3] if len(sys.argv) >= 4 else "" + + _log(f"Awarding swag on report {report_id}...") + payload = { + "data": { + "type": "swag", + "attributes": { + "message": message, + }, + } + } + + r = requests.post( + f"https://api.hackerone.com/v1/reports/{report_id}/swag", + json=payload, + auth=auth, + ) + if r.status_code not in (200, 201): + _error_exit(f"Request returned {r.status_code}!") + data = json.loads(r.text) + + if JSON_OUTPUT: + print(json.dumps(data)) + return + + print(f"Swag awarded on report {report_id}.") + + +def _extract_flag(flag, *aliases): + """Extract a --flag value from sys.argv, removing both the flag and its value.""" + for name in (flag, *aliases): + if name in sys.argv: + idx = sys.argv.index(name) + if idx + 1 < len(sys.argv): + value = sys.argv[idx + 1] + sys.argv = sys.argv[:idx] + sys.argv[idx + 2 :] + return value + else: + sys.argv = sys.argv[:idx] + sys.argv[idx + 1 :] + return None + return None + + +# Commands that don't need authentication +NO_AUTH_COMMANDS = {"help", "scope"} + + def main(): - print() - if USERNAME is None: - print("Environment variable HACKERONE_USERNAME is not set!") - sys.exit() - if TOKEN is None: - print("Environment variable HACKERONE_API_KEY is not set!") - sys.exit() - - if (len(sys.argv) < 2): - print("No argument provided!\n") - print(f"Usage: {__file__} help") - sys.exit() - - match sys.argv[1]: + global JSON_OUTPUT, VERBOSE, auth + if "--json" in sys.argv or "-j" in sys.argv: + JSON_OUTPUT = True + sys.argv = [a for a in sys.argv if a not in ("--json", "-j")] + + if "--verbose" in sys.argv or "-v" in sys.argv: + VERBOSE = True + sys.argv = [a for a in sys.argv if a not in ("--verbose", "-v")] + + env_file = _extract_flag("--env-file") + load_dotenv(env_file if env_file else ".env") + + username = _extract_flag("--username", "-u") or os.getenv("HACKERONE_USERNAME") or None + api_key = _extract_flag("--api-key", "-k") or os.getenv("HACKERONE_API_KEY") or None + + if not JSON_OUTPUT: + print() + + if len(sys.argv) < 2: + _error("No argument provided!") + if not JSON_OUTPUT: + print(f"Usage: {__file__} help") + sys.exit(1) + + command = sys.argv[1] + + # Only require credentials for commands that hit the API + if command not in NO_AUTH_COMMANDS: + if username is None: + _error_exit("No username provided! Use --username or set HACKERONE_USERNAME.") + if api_key is None: + _error_exit("No API key provided! Use --api-key or set HACKERONE_API_KEY.") + auth = HTTPBasicAuth(username, api_key) + _log(f"Authenticated as '{username}'.") + + match command: case "csv": csv() case "help": - help() + show_help() case "reports": reports() case "report": @@ -484,12 +1365,39 @@ def main(): burp() case "scope": scope() + case "org": + org() + case "org-members": + org_members() + case "org-groups": + org_groups() + case "org-invitations": + org_invitations() + case "org-reports": + org_reports() + case "org-report": + org_report() + case "org-update-report": + org_update_report() + case "org-activities": + org_activities() + case "org-metrics": + org_metrics() + case "org-scopes": + org_scopes() + case "org-invite-hacker": + org_invite_hacker() + case "org-bounty": + org_bounty() + case "org-swag": + org_swag() case _: - print(f"Invalid module '{sys.argv[1]}'") - sys.exit() - + _error(f"Invalid module '{command}'") + sys.exit(1) + + if __name__ == "__main__": try: main() except KeyboardInterrupt: - print("Exiting...") \ No newline at end of file + print("\nExiting...") diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 7e6dcc1..0000000 --- a/install.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -pip3 install markdown pygments pyyaml docopt tabulate mdv requests python-dotenv -pip3 install --upgrade --force-reinstall git+http://github.com/axiros/terminal_markdown_viewer -Write-Host -Write-Host "DONE!" -Write-Host -Write-Host "Usage: python3 hackerone.py help" \ No newline at end of file diff --git a/install.sh b/install.sh deleted file mode 100644 index 24f78da..0000000 --- a/install.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -pip3 install markdown pygments pyyaml docopt tabulate mdv requests python-dotenv -pip3 install --upgrade --force-reinstall git+http://github.com/axiros/terminal_markdown_viewer -chmod +x "$(pwd)/hackerone.py" -sudo ln -s "$(pwd)/hackerone.py" /usr/local/bin/hackerone -echo -echo "DONE!" -echo -echo "Usage: hackerone help" \ No newline at end of file diff --git a/justfile b/justfile new file mode 100644 index 0000000..f28654a --- /dev/null +++ b/justfile @@ -0,0 +1,57 @@ +# Show available recipes +default: + @just --list + @echo "" + @echo "Run 'just install' to get started." + +# Install dependencies and set up the project +install: + uv sync + +# Run linting checks +lint: + uv run ruff check . + +# Run formatting checks +format-check: + uv run ruff format --check . + +# Auto-fix lint issues +fix: + uv run ruff check --fix . + uv run ruff format . + +# Run all checks (lint + format) +check: lint format-check + +# Run the CLI (pass args after --) +run *ARGS: + uv run hackerone {{ ARGS }} + +# Run the CLI with JSON output (pass args after --) +run-json *ARGS: + uv run hackerone {{ ARGS }} --json + +# Quick smoke test against the API +test: + #!/usr/bin/env bash + set -euo pipefail + echo "Testing help..." + uv run hackerone help > /dev/null + echo "Testing help --json..." + uv run hackerone help --json | python3 -m json.tool > /dev/null + echo "Testing no-creds error..." + output=$(HACKERONE_USERNAME="" HACKERONE_API_KEY="" uv run hackerone --env-file /dev/null balance 2>&1 || true) + echo "$output" | grep -q "No username provided" && echo " OK" || { echo " FAIL: should error without creds"; exit 1; } + echo "Testing balance --json..." + uv run hackerone balance --json | python3 -m json.tool > /dev/null + echo "Testing programs 1 --json..." + uv run hackerone programs 1 --json | python3 -m json.tool > /dev/null + echo "Testing verbose..." + output=$(uv run hackerone balance -v 2>&1) + echo "$output" | grep -q "Authenticated" && echo " OK" || { echo " FAIL: verbose should show auth info"; exit 1; } + echo "All tests passed." + +# Clean up generated files +clean: + rm -rf .venv .ruff_cache *.egg-info dist build __pycache__ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..813fff3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "hackerone-cli" +version = "1.0.2" +description = "An unofficial CLI tool for interacting with the HackerOne API" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +dependencies = [ + "requests", + "python-dotenv", +] + +[project.optional-dependencies] +markdown = ["mdv"] + +[project.scripts] +hackerone = "hackerone:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "UP", + "B", + "SIM", +] +ignore = [ + "E501", + "SIM105", + "B028", + "E722", + "B904", +] + +[tool.ruff.format] +quote-style = "double" + +[dependency-groups] +dev = [ + "ruff>=0.15.7", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..53478aa --- /dev/null +++ b/uv.lock @@ -0,0 +1,242 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "hackerone-cli" +version = "1.0.2" +source = { editable = "." } +dependencies = [ + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.optional-dependencies] +markdown = [ + { name = "mdv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mdv", marker = "extra == 'markdown'" }, + { name = "python-dotenv" }, + { name = "requests" }, +] +provides-extras = ["markdown"] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.7" }] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "mdv" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/32/f5e1b8c70dc40b02604fbd0be3ff0bd5e01ee99c9fddf8f423b10d07cd31/mdv-1.7.5.tar.gz", hash = "sha256:eb84ed52a2b68d2e083e007cb485d14fac1deb755fd8f35011eff8f2889df6e9", size = 54174, upload-time = "2023-10-02T21:19:34.482Z" } + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]