Spaces:
Running
Running
Deploy bbcollect 8fcea0a244395b5057e019e22276481549cb1a4b
Browse files- README.md +75 -52
- api.py +371 -0
- app.py +31 -4
- docs/API.md +184 -0
- docs/DEPLOYMENT.md +48 -0
- docs/EXAMPLES.md +148 -0
- examples/export_programs.py +59 -0
- requirements.txt +2 -0
README.md
CHANGED
|
@@ -10,86 +10,109 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# bbcollect
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
-
- Source: https://github.com/Wuhpondiscord/bbcollect
|
| 19 |
-
- HackerOne: live public GraphQL requests initialized from https://hackerone.com/opportunities/all, with explicit dataset fallback.
|
| 20 |
-
- Bugcrowd, Intigriti, YesWeHack directories: public snapshots from [arkadiyt/bounty-targets-data](https://github.com/arkadiyt/bounty-targets-data).
|
| 21 |
-
- All four platforms: live program details, reward ranges/tables, scopes, exclusions and full program rules.
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
##
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
```
|
| 30 |
-
|
| 31 |
-
client = Client("wuhp/bbconnectapi")
|
| 32 |
-
page = client.predict(
|
| 33 |
-
platforms=["hackerone", "bugcrowd", "intigriti", "yeswehack"],
|
| 34 |
-
search="", bounty_only=False, limit=10, offset=0,
|
| 35 |
-
api_name="/collect",
|
| 36 |
-
)
|
| 37 |
-
print(page["programs"])
|
| 38 |
-
# For the next page use page["next_offset"] as offset, unless it is None.
|
| 39 |
-
scope = client.predict(platform="hackerone", handle="security", api_name="/scope")
|
| 40 |
-
print(scope["targets"]["in_scope"])
|
| 41 |
-
print(scope["targets"]["out_of_scope"])
|
| 42 |
-
print(client.predict(api_name="/sources"))
|
| 43 |
-
details = client.predict(platform="bugcrowd", handle="CCData-mbb-og", api_name="/details")
|
| 44 |
-
print(details["min_bounty"], details["max_bounty"], details["rules"])
|
| 45 |
```
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
```
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
-
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
|
| 65 |
-
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
##
|
| 74 |
|
| 75 |
-
The
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
##
|
| 80 |
|
| 81 |
```bash
|
|
|
|
|
|
|
| 82 |
python -m venv .venv
|
| 83 |
-
# Activate
|
| 84 |
pip install -r requirements.txt
|
| 85 |
-
python -m unittest discover -s tests -v
|
| 86 |
python app.py
|
| 87 |
```
|
| 88 |
|
| 89 |
-
Open http://localhost:7860
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
|
| 93 |
-
|
| 94 |
|
| 95 |
-
Additional feeds and the HackerOne fallback use public data maintained by [arkadiyt/bounty-targets-data](https://github.com/arkadiyt/bounty-targets-data). The upstream Ruby collector is not bundled.
|
|
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# bbcollect · bbconnectapi
|
| 14 |
|
| 15 |
+
Collect public bug bounty programs, published reward ranges, scopes, exclusions, and program rules from **HackerOne, Bugcrowd, Intigriti, and YesWeHack**.
|
| 16 |
|
| 17 |
+
**No API key. No account. No Authorization header.** The hosted API is public and supports browser requests with CORS.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
- [Interactive API documentation](https://wuhp-bbconnectapi.hf.space/api/docs) — try requests in your browser
|
| 20 |
+
- [API reference](https://github.com/Wuhpondiscord/bbcollect/blob/main/docs/API.md) — endpoints, parameters, responses, errors, limits
|
| 21 |
+
- [Usage recipes](https://github.com/Wuhpondiscord/bbcollect/blob/main/docs/EXAMPLES.md) — Python, JavaScript, curl, pagination, and exports
|
| 22 |
+
- [Gradio app](https://huggingface.co/spaces/wuhp/bbconnectapi) · [OpenAPI JSON](https://wuhp-bbconnectapi.hf.space/api/openapi.json)
|
| 23 |
|
| 24 |
+
## Quick start
|
| 25 |
|
| 26 |
+
Base URL: **`https://wuhp-bbconnectapi.hf.space/api/v1`**
|
| 27 |
|
| 28 |
+
```bash
|
| 29 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/programs?platforms=hackerone,bugcrowd&limit=10'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
```
|
| 31 |
|
| 32 |
+
```python
|
| 33 |
+
import requests
|
| 34 |
+
|
| 35 |
+
base = "https://wuhp-bbconnectapi.hf.space/api/v1"
|
| 36 |
+
response = requests.get(f"{base}/programs", params={
|
| 37 |
+
"platforms": "hackerone,bugcrowd",
|
| 38 |
+
"search": "", "bounty_only": True, "limit": 10,
|
| 39 |
+
}, timeout=180)
|
| 40 |
+
response.raise_for_status()
|
| 41 |
+
page = response.json()
|
| 42 |
+
for program in page["programs"]:
|
| 43 |
+
print(program["name"], program["max_bounty"], program["currency"])
|
| 44 |
+
|
| 45 |
+
# One program's complete live document: rewards, scopes and policies.
|
| 46 |
+
response = requests.get(f"{base}/programs/hackerone/security", timeout=180)
|
| 47 |
+
response.raise_for_status()
|
| 48 |
+
detail = response.json()
|
| 49 |
+
print(detail["targets"]["in_scope"])
|
| 50 |
+
print(detail["targets"]["out_of_scope"])
|
| 51 |
+
print(detail["rules"])
|
| 52 |
```
|
| 53 |
|
| 54 |
+
Install the Python example dependency with `pip install requests`. REST responses are ordinary JSON; no event IDs or streaming client are required.
|
| 55 |
|
| 56 |
+
## What can I do?
|
| 57 |
|
| 58 |
+
| Endpoint | Purpose |
|
| 59 |
+
| --- | --- |
|
| 60 |
+
| `GET /programs` | Search multiple platforms, filter by bounty/currency, sort, and paginate |
|
| 61 |
+
| `GET /programs/{platform}/{handle}` | Live bounty tables, scopes, exclusions, and full program rules |
|
| 62 |
+
| `GET /programs/{platform}/{handle}/assets` | Common asset identifiers/types, with native records and all exclusions retained |
|
| 63 |
+
| `POST /programs/batch` | Up to 20 detail lookups in one request |
|
| 64 |
+
| `GET /stats` | Directory counts by platform without crawling individual policies |
|
| 65 |
+
| `GET /sources` | Supported sources, capabilities, and cache settings |
|
| 66 |
+
| `GET /health` | Cheap application readiness check |
|
| 67 |
|
| 68 |
+
Paths above are relative to `/api/v1`. **REST listings return summaries by default.** Add `include_details=true` to include live scopes and rules for every program on the page. Start with `limit=5` or `10`: full policies and large scopes can take much longer than summaries.
|
| 69 |
|
| 70 |
+
## Understand the data
|
| 71 |
|
| 72 |
+
| Platform | Directory | Program details |
|
| 73 |
+
| --- | --- | --- |
|
| 74 |
+
| HackerOne | Public live GraphQL, with dataset fallback | Live public policy, reward table, and paginated scope |
|
| 75 |
+
| Bugcrowd | Public dataset | Live public engagement brief, reward tiers, target groups, rules, safe harbor |
|
| 76 |
+
| Intigriti | Public dataset | Live current versions of assets, reward tables, rules, FAQs, and testing requirements |
|
| 77 |
+
| YesWeHack | Public dataset | Live public rules, reward grids, scopes, exclusions, and vulnerability requirements |
|
| 78 |
|
| 79 |
+
Directory snapshots and fallbacks come from [arkadiyt/bounty-targets-data](https://github.com/arkadiyt/bounty-targets-data). Only public metadata is collected; no target scanning or authenticated/private program data is involved. HackerOne covers the public open-program directory rather than every promotion on its Opportunities page.
|
| 80 |
|
| 81 |
+
- Check `ok`, `partial`, `sources`, `details_source`, and `field_status`. A partial response may retain dataset scopes while live rules are unavailable.
|
| 82 |
+
- `null` means unknown/unpublished/unavailable, not zero. Advertised rewards are not payout guarantees. Currency is separate; amount filters require an explicit currency.
|
| 83 |
+
- Most directory reward/currency fields outside HackerOne come from snapshots. Reward filters run on directory metadata **before** live detail enrichment, so values may differ from a refreshed detail document.
|
| 84 |
+
- Source results are cached in memory for 15 minutes; failures for 60 seconds. `fetched_at` is retrieval time, not the platform's last edit. `source_updated_at` may be unavailable.
|
| 85 |
+
- Always check the linked program's current rules. Asset filters retain exclusions; program rules and group instructions still apply.
|
| 86 |
|
| 87 |
+
## Existing Gradio clients
|
| 88 |
|
| 89 |
+
The Gradio API remains available without a token:
|
| 90 |
|
| 91 |
+
```python
|
| 92 |
+
from gradio_client import Client
|
| 93 |
+
client = Client("wuhp/bbconnectapi")
|
| 94 |
+
page = client.predict(["hackerone"], "", False, 10, 0, api_name="/collect")
|
| 95 |
+
detail = client.predict("hackerone", "security", api_name="/details")
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
`/collect` keeps exactly **five** inputs and always includes full details. `/collect_options` adds a sixth `include_details` boolean. `/scope` remains an alias for `/details`; `/sources` takes no inputs. See the [compatibility reference](https://github.com/Wuhpondiscord/bbcollect/blob/main/docs/API.md#gradio-compatibility) for raw HTTP payloads.
|
| 99 |
|
| 100 |
+
## Run your own instance
|
| 101 |
|
| 102 |
```bash
|
| 103 |
+
git clone https://github.com/Wuhpondiscord/bbcollect.git
|
| 104 |
+
cd bbcollect
|
| 105 |
python -m venv .venv
|
| 106 |
+
# Activate .venv for your shell, then:
|
| 107 |
pip install -r requirements.txt
|
|
|
|
| 108 |
python app.py
|
| 109 |
```
|
| 110 |
|
| 111 |
+
Open `http://localhost:7860`, `http://localhost:7860/api/docs`, or `http://localhost:7860/api/v1/health`. `PORT` overrides port 7860. No runtime secrets are required. [Deployment guide](https://github.com/Wuhpondiscord/bbcollect/blob/main/docs/DEPLOYMENT.md).
|
| 112 |
+
|
| 113 |
+
The hosted Space may sleep or restart; there is no uptime guarantee or persistent database. Busy REST collection requests receive `429` and a `Retry-After` header. Prefer summary pages and batch only the details you need. Cache expiration can shift offset pagination; deduplicate program IDs during long crawls.
|
| 114 |
|
| 115 |
+
## Contribute
|
| 116 |
|
| 117 |
+
Run `python -m unittest discover -s tests -v` before submitting changes. Tests cover the REST contract, filters, normalization, partial failures, and the original five-input Gradio request. New providers should preserve scopes, exclusions, native policies, and source provenance. See [CONTRIBUTING.md](https://github.com/Wuhpondiscord/bbcollect/blob/main/CONTRIBUTING.md) and [CHANGELOG.md](https://github.com/Wuhpondiscord/bbcollect/blob/main/CHANGELOG.md).
|
| 118 |
|
|
|
api.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public, unauthenticated REST v1 interface alongside the Gradio API."""
|
| 2 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 3 |
+
from contextlib import contextmanager
|
| 4 |
+
from enum import Enum
|
| 5 |
+
import threading
|
| 6 |
+
from typing import Any, Literal
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, HTTPException, Path, Query, Response
|
| 9 |
+
from fastapi.exceptions import RequestValidationError
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 13 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 14 |
+
|
| 15 |
+
from collector import CACHE_SECONDS, FAILURE_SECONDS, PLATFORMS, collector, utcnow
|
| 16 |
+
|
| 17 |
+
VERSION = "1.0.0"
|
| 18 |
+
_work_slots = threading.BoundedSemaphore(2)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Platform(str, Enum):
|
| 22 |
+
hackerone = "hackerone"
|
| 23 |
+
bugcrowd = "bugcrowd"
|
| 24 |
+
intigriti = "intigriti"
|
| 25 |
+
yeswehack = "yeswehack"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ProgramRef(BaseModel):
|
| 29 |
+
model_config = ConfigDict(extra="forbid")
|
| 30 |
+
platform: Platform
|
| 31 |
+
handle: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_.-]+$",
|
| 32 |
+
description="Use the exact handle returned by the programs endpoint.")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class BatchRequest(BaseModel):
|
| 36 |
+
model_config = ConfigDict(extra="forbid")
|
| 37 |
+
programs: list[ProgramRef] = Field(min_length=1, max_length=20)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Payload(BaseModel):
|
| 41 |
+
"""Shared envelope; platform-native policy and reward structures remain JSON."""
|
| 42 |
+
model_config = ConfigDict(extra="allow")
|
| 43 |
+
ok: bool
|
| 44 |
+
partial: bool = False
|
| 45 |
+
api_version: str = VERSION
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ErrorInfo(BaseModel):
|
| 49 |
+
code: str
|
| 50 |
+
message: str
|
| 51 |
+
details: list[dict[str, Any]] | None = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ErrorPayload(BaseModel):
|
| 55 |
+
ok: Literal[False] = False
|
| 56 |
+
api_version: str = VERSION
|
| 57 |
+
error: ErrorInfo
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ProgramResult(BaseModel):
|
| 61 |
+
model_config = ConfigDict(extra="allow")
|
| 62 |
+
id: str
|
| 63 |
+
platform: Platform
|
| 64 |
+
handle: str
|
| 65 |
+
name: str
|
| 66 |
+
url: str
|
| 67 |
+
offers_bounties: bool | None = None
|
| 68 |
+
min_bounty: float | None = None
|
| 69 |
+
max_bounty: float | None = None
|
| 70 |
+
currency: str | None = None
|
| 71 |
+
targets: dict[str, Any] | None = None
|
| 72 |
+
rules: dict[str, Any] | None = None
|
| 73 |
+
reward_table: Any = None
|
| 74 |
+
details_source: dict[str, Any] | None = None
|
| 75 |
+
field_status: dict[str, str] | None = None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class ProgramPage(Payload):
|
| 79 |
+
total: int
|
| 80 |
+
limit: int
|
| 81 |
+
offset: int
|
| 82 |
+
next_offset: int | None
|
| 83 |
+
include_details: bool
|
| 84 |
+
sources: dict[str, dict[str, Any]]
|
| 85 |
+
programs: list[ProgramResult]
|
| 86 |
+
filters: dict[str, Any]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class ProgramDetails(Payload):
|
| 90 |
+
platform: str
|
| 91 |
+
handle: str
|
| 92 |
+
targets: dict[str, Any] | None = None
|
| 93 |
+
rules: dict[str, Any] | None = None
|
| 94 |
+
reward_table: Any = None
|
| 95 |
+
min_bounty: float | None = None
|
| 96 |
+
max_bounty: float | None = None
|
| 97 |
+
currency: str | None = None
|
| 98 |
+
field_status: dict[str, str] | None = None
|
| 99 |
+
mode: str | None = None
|
| 100 |
+
source_url: str | None = None
|
| 101 |
+
fetched_at: str | None = None
|
| 102 |
+
source_updated_at: str | None = None
|
| 103 |
+
warning: str | None = None
|
| 104 |
+
cached: bool = False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class BatchResult(Payload):
|
| 108 |
+
count: int
|
| 109 |
+
succeeded: int
|
| 110 |
+
programs: list[ProgramDetails]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class Asset(BaseModel):
|
| 114 |
+
identifier: str
|
| 115 |
+
type: str
|
| 116 |
+
native_type: str | int | None
|
| 117 |
+
instructions: str | None
|
| 118 |
+
eligible_for_bounty: bool | None
|
| 119 |
+
raw: Any
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class AssetPage(Payload):
|
| 123 |
+
platform: str
|
| 124 |
+
handle: str
|
| 125 |
+
total: int
|
| 126 |
+
limit: int
|
| 127 |
+
offset: int
|
| 128 |
+
next_offset: int | None
|
| 129 |
+
in_scope: list[Asset]
|
| 130 |
+
out_of_scope: list[Asset]
|
| 131 |
+
rules: dict[str, Any] | None = None
|
| 132 |
+
source: dict[str, Any]
|
| 133 |
+
note: str
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
ERRORS = {code: {"model": ErrorPayload, "description": label} for code, label in (
|
| 137 |
+
(400, "Invalid filter combination"), (422, "Invalid query or request body"),
|
| 138 |
+
(429, "Collection capacity busy; retry after the Retry-After header"),
|
| 139 |
+
(503, "Required upstream data is unavailable"))}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def envelope(result):
|
| 143 |
+
return {"api_version": VERSION, **result}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def bad_request(message):
|
| 147 |
+
raise HTTPException(400, {"code": "invalid_filters", "message": message})
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def parse_platforms(value):
|
| 151 |
+
selected = list(dict.fromkeys(p.strip().lower() for p in value.split(",") if p.strip()))
|
| 152 |
+
if not selected or any(p not in PLATFORMS for p in selected):
|
| 153 |
+
bad_request("platforms must contain comma-separated values from: " + ", ".join(PLATFORMS))
|
| 154 |
+
return selected
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@contextmanager
|
| 158 |
+
def work_slot():
|
| 159 |
+
if not _work_slots.acquire(blocking=False):
|
| 160 |
+
raise HTTPException(429, {"code": "busy", "message": "Collection capacity is busy; retry in a few seconds."},
|
| 161 |
+
headers={"Retry-After": "5"})
|
| 162 |
+
try:
|
| 163 |
+
yield
|
| 164 |
+
finally:
|
| 165 |
+
_work_slots.release()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def require_data(result):
|
| 169 |
+
if not result.get("ok"):
|
| 170 |
+
raise HTTPException(503, {"code": "source_unavailable",
|
| 171 |
+
"message": "Public program data was not found or is temporarily unavailable."})
|
| 172 |
+
return envelope(result)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def normalize_asset(asset):
|
| 176 |
+
"""Common identifiers/types, retaining the complete native asset record."""
|
| 177 |
+
if isinstance(asset, str):
|
| 178 |
+
return {"identifier": asset, "type": "other", "native_type": None,
|
| 179 |
+
"instructions": None, "eligible_for_bounty": None, "raw": asset}
|
| 180 |
+
identifier = next((asset[k] for k in ("asset_identifier", "scope", "endpoint", "target", "uri", "name", "ipAddress")
|
| 181 |
+
if asset.get(k)), "")
|
| 182 |
+
native = asset.get("asset_type", asset.get("scope_type", asset.get("category", asset.get("type", asset.get("typeId")))))
|
| 183 |
+
types = {1: "url", 2: "android", 3: "ios", 4: "ip_range", 5: "device", 6: "other", 7: "wildcard",
|
| 184 |
+
"website": "url", "web-application": "url", "url": "url", "wildcard": "wildcard",
|
| 185 |
+
"api": "api", "cidr": "ip_range", "iprange": "ip_range", "ip_range": "ip_range",
|
| 186 |
+
"ip_address": "ip", "ip": "ip", "android": "android", "ios": "ios",
|
| 187 |
+
"apple_store_app_id": "ios", "google_play_app_id": "android", "domain": "domain"}
|
| 188 |
+
key = native.lower() if isinstance(native, str) else native
|
| 189 |
+
kind = types.get(key, "other")
|
| 190 |
+
return {"identifier": str(identifier), "type": kind, "native_type": native,
|
| 191 |
+
"instructions": asset.get("instruction", asset.get("description")),
|
| 192 |
+
"eligible_for_bounty": asset.get("eligible_for_bounty"), "raw": asset}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def create_api():
|
| 196 |
+
app = FastAPI(title="bbconnectapi", version=VERSION,
|
| 197 |
+
description="Public bug bounty program metadata, rewards, scopes and policies. "
|
| 198 |
+
"No API key, login or Authorization header required. "
|
| 199 |
+
"Directory summaries are fast; live details can take longer. "
|
| 200 |
+
"Check partial/source status before using results.",
|
| 201 |
+
docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json",
|
| 202 |
+
servers=[{"url": "/", "description": "Current host"}],
|
| 203 |
+
openapi_tags=[{"name": "Programs", "description": "Search public programs and retrieve details."},
|
| 204 |
+
{"name": "Assets", "description": "Normalized assets with native records and exclusions."},
|
| 205 |
+
{"name": "Service", "description": "Readiness, supported sources and directory statistics."}])
|
| 206 |
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST", "OPTIONS"],
|
| 207 |
+
allow_headers=["Content-Type"], allow_credentials=False,
|
| 208 |
+
expose_headers=["Retry-After", "X-API-Version"])
|
| 209 |
+
|
| 210 |
+
@app.middleware("http")
|
| 211 |
+
async def version_header(request, call_next):
|
| 212 |
+
response = await call_next(request)
|
| 213 |
+
if request.url.path.startswith("/api/"):
|
| 214 |
+
response.headers["X-API-Version"] = VERSION
|
| 215 |
+
response.headers["Cache-Control"] = "no-store"
|
| 216 |
+
return response
|
| 217 |
+
|
| 218 |
+
@app.exception_handler(StarletteHTTPException)
|
| 219 |
+
async def http_error(request, exc):
|
| 220 |
+
detail = exc.detail if isinstance(exc.detail, dict) else {"code": "http_error", "message": str(exc.detail)}
|
| 221 |
+
return JSONResponse(status_code=exc.status_code, headers=exc.headers,
|
| 222 |
+
content={"ok": False, "api_version": VERSION, "error": detail})
|
| 223 |
+
|
| 224 |
+
@app.exception_handler(RequestValidationError)
|
| 225 |
+
async def validation_error(request, exc):
|
| 226 |
+
details = [{"location": list(e["loc"]), "message": e["msg"], "type": e["type"]} for e in exc.errors()]
|
| 227 |
+
return JSONResponse(status_code=422, content={"ok": False, "api_version": VERSION,
|
| 228 |
+
"error": {"code": "validation_error", "message": "Check the request parameters.", "details": details}})
|
| 229 |
+
|
| 230 |
+
@app.get("/api/v1/health", tags=["Service"], response_model=Payload,
|
| 231 |
+
summary="Check application readiness without contacting platforms")
|
| 232 |
+
def health():
|
| 233 |
+
return {"ok": True, "api_version": VERSION, "authentication_required": False,
|
| 234 |
+
"generated_at": utcnow(), "upstream_checked": False,
|
| 235 |
+
"docs_url": "/api/docs", "openapi_url": "/api/openapi.json"}
|
| 236 |
+
|
| 237 |
+
@app.get("/api/v1/sources", tags=["Service"], response_model=Payload,
|
| 238 |
+
summary="Describe data sources, capabilities and cache lifetimes")
|
| 239 |
+
def sources():
|
| 240 |
+
return envelope({"ok": True, "authentication_required": False,
|
| 241 |
+
"cache_seconds": CACHE_SECONDS, "failure_cache_seconds": FAILURE_SECONDS,
|
| 242 |
+
"platforms": [{"platform": p, "directory": "live_with_dataset_fallback" if p == "hackerone" else "public_dataset",
|
| 243 |
+
"details": "live_with_dataset_fallback", "rules": "live_only"} for p in PLATFORMS],
|
| 244 |
+
"limits": {"full_detail_page": 100, "summary_page": 1000, "batch_programs": 20},
|
| 245 |
+
"availability": "Public programs only. Fields may be unpublished; fallbacks can be partial."})
|
| 246 |
+
|
| 247 |
+
@app.get("/api/v1/programs", tags=["Programs"], response_model=ProgramPage, responses=ERRORS,
|
| 248 |
+
summary="Search and paginate programs; summaries by default")
|
| 249 |
+
def programs(
|
| 250 |
+
platforms: str = Query("hackerone", description="Comma-separated platform names; use /sources for supported values."),
|
| 251 |
+
search: str = Query("", max_length=300, description="Case-insensitive substring in name, handle, URL or website."),
|
| 252 |
+
bounty_only: bool = False,
|
| 253 |
+
limit: int = Query(25, ge=1, le=1000), offset: int = Query(0, ge=0, le=100000),
|
| 254 |
+
include_details: bool = Query(False, description="Live rewards, targets and rules for this page; maximum limit 100."),
|
| 255 |
+
currency: str | None = Query(None, pattern=r"^[A-Za-z]{3}$", description="ISO currency code; excludes unknown currencies."),
|
| 256 |
+
reward_at_least: float | None = Query(None, ge=0, allow_inf_nan=False,
|
| 257 |
+
description="Require advertised max_bounty >= this amount. Requires currency; applied to directory metadata."),
|
| 258 |
+
sort_by: Literal["name", "max_bounty", "min_bounty"] = "name",
|
| 259 |
+
descending: bool = False,
|
| 260 |
+
):
|
| 261 |
+
selected = parse_platforms(platforms)
|
| 262 |
+
if (reward_at_least is not None or sort_by != "name") and currency is None:
|
| 263 |
+
bad_request("Set currency when filtering or sorting by bounty amounts; currencies are never mixed.")
|
| 264 |
+
if include_details and limit > 100:
|
| 265 |
+
bad_request("include_details=true supports limit <= 100; use summary pages for larger limits.")
|
| 266 |
+
with work_slot():
|
| 267 |
+
# Fetch the complete small directory before filtering/sorting/pagination.
|
| 268 |
+
result = collector.collect(selected, search, bounty_only, 1000, 0, False)
|
| 269 |
+
require_data(result)
|
| 270 |
+
if result.get("next_offset") is not None:
|
| 271 |
+
# A platform may grow beyond one summary page.
|
| 272 |
+
items = list(result["programs"])
|
| 273 |
+
cursor = result["next_offset"]
|
| 274 |
+
while cursor is not None:
|
| 275 |
+
extra = collector.collect(selected, search, bounty_only, 1000, cursor, False)
|
| 276 |
+
require_data(extra)
|
| 277 |
+
items.extend(extra["programs"])
|
| 278 |
+
cursor = extra["next_offset"]
|
| 279 |
+
result["programs"] = items
|
| 280 |
+
rows = result["programs"]
|
| 281 |
+
if currency:
|
| 282 |
+
rows = [p for p in rows if (p.get("currency") or "").upper() == currency.upper()]
|
| 283 |
+
if reward_at_least is not None:
|
| 284 |
+
rows = [p for p in rows if isinstance(p.get("max_bounty"), (int, float)) and p["max_bounty"] >= reward_at_least]
|
| 285 |
+
if sort_by == "name":
|
| 286 |
+
rows.sort(key=lambda p: (p["name"].casefold(), p["id"]), reverse=descending)
|
| 287 |
+
else:
|
| 288 |
+
known = [p for p in rows if isinstance(p.get(sort_by), (int, float))]
|
| 289 |
+
unknown = [p for p in rows if not isinstance(p.get(sort_by), (int, float))]
|
| 290 |
+
known.sort(key=lambda p: (p[sort_by], p["id"]), reverse=descending)
|
| 291 |
+
rows = known + sorted(unknown, key=lambda p: p["id"])
|
| 292 |
+
total = len(rows)
|
| 293 |
+
page = rows[offset:offset + limit]
|
| 294 |
+
if include_details and page:
|
| 295 |
+
def enrich(p):
|
| 296 |
+
detail = collector.details(p["platform"], p["handle"])
|
| 297 |
+
fields = ("targets", "rules", "reward_table", "field_status", "currency", "min_bounty", "max_bounty")
|
| 298 |
+
return {**p, **{k: detail[k] for k in fields if k in detail},
|
| 299 |
+
"details_source": {k: v for k, v in detail.items() if k not in fields}}
|
| 300 |
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 301 |
+
page = list(pool.map(enrich, page))
|
| 302 |
+
partial = result.get("partial", False) or any(
|
| 303 |
+
p.get("details_source", {}).get("partial") or p.get("details_source", {}).get("ok") is False for p in page)
|
| 304 |
+
return envelope({**result, "programs": page, "total": total, "limit": limit, "offset": offset,
|
| 305 |
+
"next_offset": offset + limit if offset + limit < total else None,
|
| 306 |
+
"include_details": include_details, "partial": partial,
|
| 307 |
+
"filters": {"currency": currency.upper() if currency else None, "reward_at_least": reward_at_least,
|
| 308 |
+
"reward_filter_source": "directory_metadata", "sort_by": sort_by, "descending": descending}})
|
| 309 |
+
|
| 310 |
+
@app.post("/api/v1/programs/batch", tags=["Programs"], response_model=BatchResult,
|
| 311 |
+
responses={**ERRORS, 503: {"model": BatchResult, "description": "All requested program lookups failed; inspect each item."}},
|
| 312 |
+
summary="Retrieve details for up to 20 programs; preserve input order")
|
| 313 |
+
def batch(body: BatchRequest, response: Response):
|
| 314 |
+
with work_slot():
|
| 315 |
+
def lookup(ref):
|
| 316 |
+
return collector.details(ref.platform.value, ref.handle)
|
| 317 |
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 318 |
+
results = list(pool.map(lookup, body.programs))
|
| 319 |
+
successes = sum(bool(r.get("ok")) for r in results)
|
| 320 |
+
if not successes:
|
| 321 |
+
response.status_code = 503
|
| 322 |
+
return envelope({"ok": successes > 0, "partial": any(not r.get("ok") or r.get("partial") for r in results),
|
| 323 |
+
"count": len(results), "succeeded": successes, "programs": results})
|
| 324 |
+
|
| 325 |
+
@app.get("/api/v1/programs/{platform}/{handle}", tags=["Programs"], response_model=ProgramDetails, responses=ERRORS,
|
| 326 |
+
summary="Retrieve public program rewards, rules, scopes and exclusions")
|
| 327 |
+
def program(platform: Platform, handle: str = Path(pattern=r"^[A-Za-z0-9_.-]{1,200}$")):
|
| 328 |
+
with work_slot():
|
| 329 |
+
return require_data(collector.details(platform.value, handle))
|
| 330 |
+
|
| 331 |
+
@app.get("/api/v1/programs/{platform}/{handle}/assets", tags=["Assets"], response_model=AssetPage, responses=ERRORS,
|
| 332 |
+
summary="Get normalized assets; all exclusions remain visible")
|
| 333 |
+
def assets(platform: Platform, handle: str = Path(pattern=r"^[A-Za-z0-9_.-]{1,200}$"),
|
| 334 |
+
asset_type: Literal["url", "api", "domain", "wildcard", "ip", "ip_range", "android", "ios", "device", "other"] | None = None,
|
| 335 |
+
search: str = Query("", max_length=300), limit: int = Query(100, ge=1, le=1000),
|
| 336 |
+
offset: int = Query(0, ge=0, le=100000)):
|
| 337 |
+
with work_slot():
|
| 338 |
+
detail = require_data(collector.details(platform.value, handle))
|
| 339 |
+
targets = detail.get("targets")
|
| 340 |
+
if not isinstance(targets, dict):
|
| 341 |
+
raise HTTPException(503, {"code": "scope_unavailable", "message": "No scope data is available for this program."})
|
| 342 |
+
rows = [normalize_asset(a) for a in targets.get("in_scope", [])]
|
| 343 |
+
rows = [a for a in rows if (asset_type is None or a["type"] == asset_type) and search.casefold() in a["identifier"].casefold()]
|
| 344 |
+
exclusions = [normalize_asset(a) for a in targets.get("out_of_scope", [])]
|
| 345 |
+
return envelope({"ok": True, "partial": detail.get("partial", False), "platform": platform.value,
|
| 346 |
+
"handle": handle, "total": len(rows), "limit": limit, "offset": offset,
|
| 347 |
+
"next_offset": offset + limit if offset + limit < len(rows) else None,
|
| 348 |
+
"in_scope": rows[offset:offset + limit], "out_of_scope": exclusions,
|
| 349 |
+
"rules": detail.get("rules"), "field_status": detail.get("field_status"),
|
| 350 |
+
"source": {k: detail.get(k) for k in ("mode", "source_url", "fetched_at", "source_updated_at", "cached", "warning")},
|
| 351 |
+
"note": "Filters/pagination apply only to in_scope. All exclusions and program rules are retained."})
|
| 352 |
+
|
| 353 |
+
@app.get("/api/v1/stats", tags=["Service"], response_model=Payload, responses=ERRORS,
|
| 354 |
+
summary="Count directory programs without fetching live detail documents")
|
| 355 |
+
def stats(platforms: str = "hackerone,bugcrowd,intigriti,yeswehack"):
|
| 356 |
+
selected = parse_platforms(platforms)
|
| 357 |
+
sources, counts = {}, {}
|
| 358 |
+
with work_slot():
|
| 359 |
+
for p in selected:
|
| 360 |
+
result = collector.programs(p)
|
| 361 |
+
rows = list({r["id"]: r for r in result.get("programs", [])}.values())
|
| 362 |
+
counts[p] = {"total": len(rows),
|
| 363 |
+
"offers_bounties": sum(r.get("offers_bounties") is True for r in rows),
|
| 364 |
+
"unknown_bounty_status": sum(r.get("offers_bounties") is None for r in rows)}
|
| 365 |
+
sources[p] = {k: v for k, v in result.items() if k != "programs"}
|
| 366 |
+
ok = any(s.get("ok") for s in sources.values())
|
| 367 |
+
result = {"ok": ok, "partial": any(not s.get("ok") for s in sources.values()), "generated_at": utcnow(),
|
| 368 |
+
"total": sum(c["total"] for c in counts.values()), "platforms": counts, "sources": sources}
|
| 369 |
+
return require_data(result)
|
| 370 |
+
|
| 371 |
+
return app
|
app.py
CHANGED
|
@@ -5,6 +5,7 @@ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
from collector import PLATFORMS, collector
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def collect(platforms, search, bounty_only, limit, offset):
|
|
@@ -38,7 +39,9 @@ def sources():
|
|
| 38 |
|
| 39 |
|
| 40 |
with gr.Blocks(title="bbconnectapi") as demo:
|
| 41 |
-
gr.Markdown("# bbconnectapi\nPublic bug bounty programs and scope
|
|
|
|
|
|
|
| 42 |
"HackerOne uses live public data with an explicit dataset fallback. "
|
| 43 |
"All four platforms have live program details: reward tables, scopes and rules. "
|
| 44 |
"Directory feeds use public snapshots outside HackerOne. Results are cached for 15 minutes.")
|
|
@@ -68,7 +71,28 @@ with gr.Blocks(title="bbconnectapi") as demo:
|
|
| 68 |
details_button = gr.Button("Get full details via /details")
|
| 69 |
details_button.click(details, [platform, handle], scope_json, api_name="details", concurrency_limit=2)
|
| 70 |
with gr.Tab("API"):
|
| 71 |
-
gr.Markdown('''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
```python
|
| 74 |
from gradio_client import Client
|
|
@@ -86,6 +110,9 @@ Scope is a snapshot: check the linked program's current rules and exclusions bef
|
|
| 86 |
|
| 87 |
demo.queue(max_size=32, default_concurrency_limit=2)
|
| 88 |
|
|
|
|
|
|
|
|
|
|
| 89 |
if __name__ == "__main__":
|
| 90 |
-
|
| 91 |
-
|
|
|
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
from collector import PLATFORMS, collector
|
| 8 |
+
from api import create_api
|
| 9 |
|
| 10 |
|
| 11 |
def collect(platforms, search, bounty_only, limit, offset):
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
with gr.Blocks(title="bbconnectapi") as demo:
|
| 42 |
+
gr.Markdown("# bbconnectapi\nPublic bug bounty programs and scope. **No API key or login required.** "
|
| 43 |
+
"[Interactive REST docs](/api/docs) · [OpenAPI schema](/api/openapi.json) · "
|
| 44 |
+
"[GitHub guide](https://github.com/Wuhpondiscord/bbcollect)\n\n"
|
| 45 |
"HackerOne uses live public data with an explicit dataset fallback. "
|
| 46 |
"All four platforms have live program details: reward tables, scopes and rules. "
|
| 47 |
"Directory feeds use public snapshots outside HackerOne. Results are cached for 15 minutes.")
|
|
|
|
| 71 |
details_button = gr.Button("Get full details via /details")
|
| 72 |
details_button.click(details, [platform, handle], scope_json, api_name="details", concurrency_limit=2)
|
| 73 |
with gr.Tab("API"):
|
| 74 |
+
gr.Markdown('''### REST API — direct JSON, no key
|
| 75 |
+
|
| 76 |
+
Base URL: `https://wuhp-bbconnectapi.hf.space/api/v1`
|
| 77 |
+
|
| 78 |
+
```python
|
| 79 |
+
import requests
|
| 80 |
+
base = "https://wuhp-bbconnectapi.hf.space/api/v1"
|
| 81 |
+
page = requests.get(f"{base}/programs", params={
|
| 82 |
+
"platforms": "hackerone,bugcrowd", "search": "", "limit": 10
|
| 83 |
+
}, timeout=120).json()
|
| 84 |
+
detail = requests.get(f"{base}/programs/hackerone/security", timeout=120).json()
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Summaries are the REST default. Add `include_details=true` for full policies and scopes.
|
| 88 |
+
Use `/programs/{platform}/{handle}/assets` for normalized assets and exclusions,
|
| 89 |
+
`POST /programs/batch` for up to 20 detail lookups, and `/stats`, `/sources`, `/health` for metadata.
|
| 90 |
+
|
| 91 |
+
[Try requests in the interactive docs](/api/docs) · [Full API reference on GitHub](https://github.com/Wuhpondiscord/bbcollect/blob/main/docs/API.md)
|
| 92 |
+
|
| 93 |
+
### Existing Gradio API
|
| 94 |
+
|
| 95 |
+
Use **View API** in the footer for generated client examples. `/collect` keeps its five-input contract.
|
| 96 |
|
| 97 |
```python
|
| 98 |
from gradio_client import Client
|
|
|
|
| 110 |
|
| 111 |
demo.queue(max_size=32, default_concurrency_limit=2)
|
| 112 |
|
| 113 |
+
# Register REST routes before the root Gradio mount. Existing Gradio URLs stay unchanged.
|
| 114 |
+
app = gr.mount_gradio_app(create_api(), demo, path="/", show_error=False, ssr_mode=False)
|
| 115 |
+
|
| 116 |
if __name__ == "__main__":
|
| 117 |
+
import uvicorn
|
| 118 |
+
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
|
docs/API.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API reference · v1
|
| 2 |
+
|
| 3 |
+
Base: `https://wuhp-bbconnectapi.hf.space/api/v1`
|
| 4 |
+
|
| 5 |
+
**All endpoints are public. Do not send an API key, HF token, login cookie, or Authorization header.** The deployment secret is unrelated to calling this API.
|
| 6 |
+
|
| 7 |
+
[Interactive docs](https://wuhp-bbconnectapi.hf.space/api/docs) · [OpenAPI JSON](https://wuhp-bbconnectapi.hf.space/api/openapi.json) · [Examples](EXAMPLES.md)
|
| 8 |
+
|
| 9 |
+
## List programs
|
| 10 |
+
|
| 11 |
+
`GET /programs`
|
| 12 |
+
|
| 13 |
+
| Query parameter | Default | Meaning |
|
| 14 |
+
| --- | --- | --- |
|
| 15 |
+
| `platforms` | `hackerone` | Comma-separated `hackerone`, `bugcrowd`, `intigriti`, `yeswehack`; case-insensitive |
|
| 16 |
+
| `search` | empty | Case-insensitive substring in name, handle, program URL, or website; not policy/asset text |
|
| 17 |
+
| `bounty_only` | `false` | Include only programs known to offer bounties; unknown status is excluded |
|
| 18 |
+
| `include_details` | `false` | Retrieve live rewards, scopes, rules, and tables for the selected page |
|
| 19 |
+
| `limit` | `25` | 1–1000 for summaries; 1–100 with full details |
|
| 20 |
+
| `offset` | `0` | 0–100000; use the returned `next_offset` |
|
| 21 |
+
| `currency` | unset | Three-letter currency code, case-insensitive; excludes unknown currencies |
|
| 22 |
+
| `reward_at_least` | unset | Require advertised directory `max_bounty` >= amount; requires `currency` |
|
| 23 |
+
| `sort_by` | `name` | `name`, `max_bounty`, or `min_bounty`; bounty sorting requires `currency` |
|
| 24 |
+
| `descending` | `false` | Reverse the selected sort; unknown bounty values always come last |
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/programs?platforms=hackerone¤cy=USD&reward_at_least=1000&sort_by=max_bounty&descending=true&limit=10'
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Filtering/sorting uses directory metadata before pagination and live enrichment. It does not fetch every program's reward table to decide eligibility. Missing snapshot currencies/reward values can exclude otherwise matching programs; use individual details for current values. No currency conversion is performed.
|
| 31 |
+
|
| 32 |
+
Response shape (illustrative values):
|
| 33 |
+
|
| 34 |
+
```json
|
| 35 |
+
{
|
| 36 |
+
"ok": true,
|
| 37 |
+
"partial": false,
|
| 38 |
+
"api_version": "1.0.0",
|
| 39 |
+
"total": 200,
|
| 40 |
+
"limit": 10,
|
| 41 |
+
"offset": 0,
|
| 42 |
+
"next_offset": 10,
|
| 43 |
+
"include_details": false,
|
| 44 |
+
"programs": [{
|
| 45 |
+
"id": "hackerone:security",
|
| 46 |
+
"platform": "hackerone",
|
| 47 |
+
"handle": "security",
|
| 48 |
+
"name": "HackerOne",
|
| 49 |
+
"url": "https://hackerone.com/security",
|
| 50 |
+
"offers_bounties": true,
|
| 51 |
+
"min_bounty": 100,
|
| 52 |
+
"max_bounty": 15000,
|
| 53 |
+
"currency": "usd"
|
| 54 |
+
}],
|
| 55 |
+
"sources": {},
|
| 56 |
+
"filters": {
|
| 57 |
+
"currency": null,
|
| 58 |
+
"reward_at_least": null,
|
| 59 |
+
"reward_filter_source": "directory_metadata",
|
| 60 |
+
"sort_by": "name",
|
| 61 |
+
"descending": false
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
The example is abbreviated; generated response models also allow native/source metadata. Optional detail fields are null in summary responses. `total` counts filtered directory programs, not only the returned page. `next_offset: null` means the crawl is finished. IDs include the platform; Intigriti uses its program UUID as `handle` because company URL slugs can repeat.
|
| 67 |
+
|
| 68 |
+
With `include_details=true`, each program also has `targets`, `rules`, `reward_table`, `field_status`, and `details_source`. Any failed/partial detail lookup makes the page `partial: true`; inspect each program rather than discarding the whole page.
|
| 69 |
+
|
| 70 |
+
## Get program details
|
| 71 |
+
|
| 72 |
+
`GET /programs/{platform}/{handle}`
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/programs/hackerone/security'
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Use handles from `/programs`. Successful responses include:
|
| 79 |
+
|
| 80 |
+
| Field | Description |
|
| 81 |
+
| --- | --- |
|
| 82 |
+
| `ok`, `partial`, `api_version` | Result status and REST contract version |
|
| 83 |
+
| `platform`, `handle` | Requested program identity |
|
| 84 |
+
| `min_bounty`, `max_bounty`, `currency` | Published numeric range and currency; null when unavailable |
|
| 85 |
+
| `reward_table` | Native severity/asset-tier table or grid, preserving its descriptions |
|
| 86 |
+
| `targets.in_scope`, `targets.out_of_scope` | Native platform records, kept separate |
|
| 87 |
+
| `rules` | Native policy sections; fields vary by platform, described below |
|
| 88 |
+
| `field_status` | Availability for scopes, rules, and ranges |
|
| 89 |
+
| `mode` | `live` or `public_dataset` |
|
| 90 |
+
| `source_url`, `fetched_at`, `source_updated_at`, `cached`, `warning` | Provenance, retrieval time, last edit if known, fallback/cache state |
|
| 91 |
+
|
| 92 |
+
HackerOne rules include policy text, scope description, structured policy, disclosure/safe-harbor declarations, and exclusions. Bugcrowd includes brief HTML, targets overview, target-group instructions, safe harbor, resources, and VRT rules. Intigriti includes the latest in/out-of-scope text, FAQs, severity assessment, and rules of engagement/testing requirements. YesWeHack includes policy text/HTML, qualifying/non-qualifying vulnerabilities, account requirements, and relevant program rule grids.
|
| 93 |
+
|
| 94 |
+
Policy HTML is data, not trusted executable markup. Referenced external documents remain links; the API does not recursively crawl them. Absence of a rule never implies permission to test.
|
| 95 |
+
|
| 96 |
+
Fallback responses have `partial: true`: dataset scopes and known rewards may remain available, while `rules` is null and its `field_status` is `unavailable`. Live fields may be marked `not_published` rather than being invented. `0` is a published zero; `null` is not zero.
|
| 97 |
+
|
| 98 |
+
A missing or unreachable program returns `503 source_unavailable` when no usable public data exists. The API cannot always distinguish an unknown program from upstream denial/outage, so it does not falsely claim a definitive program 404.
|
| 99 |
+
|
| 100 |
+
## Normalized program assets
|
| 101 |
+
|
| 102 |
+
`GET /programs/{platform}/{handle}/assets`
|
| 103 |
+
|
| 104 |
+
Optional queries: `asset_type`, `search`, `limit` (1–1000, default 100), `offset` (default 0).
|
| 105 |
+
|
| 106 |
+
Common asset types: `url`, `api`, `domain`, `wildcard`, `ip`, `ip_range`, `android`, `ios`, `device`, `other`. Unsupported native categories become `other`, with their native type retained.
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/programs/hackerone/security/assets?asset_type=wildcard&limit=20'
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
Each normalized record has `identifier`, `type`, `native_type`, `instructions`, `eligible_for_bounty`, and `raw` (the complete original record). Out-of-scope prose can be a native string, retained in `raw`.
|
| 113 |
+
|
| 114 |
+
Response fields: `in_scope`, `out_of_scope`, `rules`, `source`, `field_status`, `total`, `limit`, `offset`, `next_offset`, `ok`, `partial`, `api_version`, `note`.
|
| 115 |
+
|
| 116 |
+
**Asset filtering and pagination apply only to `in_scope`. Every exclusion and the full program rules are retained.** `total` is the filtered in-scope count. A wildcard never overrides an exclusion or a restriction in the rules. Group instructions remain in the native `raw` record.
|
| 117 |
+
|
| 118 |
+
## Batch program details
|
| 119 |
+
|
| 120 |
+
`POST /programs/batch`
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
curl -X POST 'https://wuhp-bbconnectapi.hf.space/api/v1/programs/batch' \
|
| 124 |
+
-H 'Content-Type: application/json' \
|
| 125 |
+
-d '{"programs":[{"platform":"hackerone","handle":"security"},{"platform":"yeswehack","handle":"outscale"}]}'
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Body: `programs`, an array of 1–20 objects containing `platform` and `handle`; unknown body fields are rejected. Results preserve input order (including duplicates), with individual statuses. Response: `ok`, `partial`, `api_version`, `count`, `succeeded`, `programs`.
|
| 129 |
+
|
| 130 |
+
If any lookup succeeds, HTTP status is 200, even when other items fail. If all fail, status is 503 **with the same batch result envelope**, so you can still inspect every item. A success that uses dataset fallback can itself be partial.
|
| 131 |
+
|
| 132 |
+
## Service metadata
|
| 133 |
+
|
| 134 |
+
| Endpoint | Behavior |
|
| 135 |
+
| --- | --- |
|
| 136 |
+
| `GET /health` | Application readiness/version, `authentication_required: false`, `upstream_checked: false`; no upstream calls |
|
| 137 |
+
| `GET /sources` | Per-platform capabilities, cache times, public-access status, and request limits; no upstream calls |
|
| 138 |
+
| `GET /stats?platforms=hackerone,bugcrowd` | Directory totals, known bounty counts, unknown bounty status, and source states; all four platforms by default |
|
| 139 |
+
|
| 140 |
+
Healthy application status does not guarantee that every upstream platform is reachable. Stats do not fetch individual live detail documents. Free hosted Spaces can sleep, making the first request slow.
|
| 141 |
+
|
| 142 |
+
## Errors, capacity, and retries
|
| 143 |
+
|
| 144 |
+
| HTTP status | Meaning | Action |
|
| 145 |
+
| --- | --- | --- |
|
| 146 |
+
| `200` | Successful response, possibly partial | Check `ok`, `partial`, and per-item/source states |
|
| 147 |
+
| `400` | Invalid filter combination/platform list | Fix the query; see error message |
|
| 148 |
+
| `422` | Invalid type, enum, bounds, or JSON body | Inspect `error.details` |
|
| 149 |
+
| `429` | Both REST collection slots are busy | Wait for `Retry-After` seconds, then retry |
|
| 150 |
+
| `503` | No usable upstream data, or every batch item failed | Retry with backoff; inspect fallback/field availability |
|
| 151 |
+
|
| 152 |
+
Ordinary errors use:
|
| 153 |
+
|
| 154 |
+
```json
|
| 155 |
+
{"ok":false,"api_version":"1.0.0","error":{"code":"invalid_filters","message":"Set currency when filtering or sorting by bounty amounts; currencies are never mixed."}}
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
Validation errors add `error.details` entries with `location`, `message`, `type`. All-failed batch responses use the batch envelope instead. Responses include `X-API-Version`. REST responses are not HTTP-cached (`Cache-Control: no-store`), while source data is shared in an in-memory cache.
|
| 159 |
+
|
| 160 |
+
REST collection capacity is two concurrent requests per application process, not a per-user key quota. Each batch/detail page uses at most four workers. Prefer summary listings, small full-detail pages, and bounded retries on 429/503. Do not run unbounded parallel crawls. There is no key to obtain or refresh.
|
| 161 |
+
|
| 162 |
+
## Freshness and pagination
|
| 163 |
+
|
| 164 |
+
Successful source requests are cached for 900 seconds; failed requests for 60. The cache is lost on restart/sleep. There is no persistent database, background schedule, refresh-bypass parameter, or atomic multi-platform snapshot. Source `fetched_at` and `source_updated_at` have different meanings. Dates may be ISO timestamps or native upstream HTTP dates.
|
| 165 |
+
|
| 166 |
+
Offset pages can shift as directories change or caches expire. Follow `next_offset` and deduplicate by `id` for long crawls. Native rule and asset fields are intentionally preserved and can evolve with platforms; common REST envelope fields use the versioned `/api/v1` path.
|
| 167 |
+
|
| 168 |
+
## Gradio compatibility
|
| 169 |
+
|
| 170 |
+
The Gradio UI and event API remain at the same host/root paths.
|
| 171 |
+
|
| 172 |
+
| Endpoint | Positional inputs | Behavior |
|
| 173 |
+
| --- | --- | --- |
|
| 174 |
+
| `/collect` | `[platforms, search, bounty_only, limit, offset]` | Exactly five values; full details |
|
| 175 |
+
| `/collect_options` | `[platforms, search, bounty_only, limit, offset, include_details]` | Exactly six values; optional summary mode |
|
| 176 |
+
| `/details` | `[platform, handle]` | Complete live detail document |
|
| 177 |
+
| `/scope` | `[platform, handle]` | Alias of `/details` |
|
| 178 |
+
| `/sources` | `[]` | Source capabilities |
|
| 179 |
+
|
| 180 |
+
Gradio `/collect` defaults to full details; REST `/api/v1/programs` defaults to summaries. This deliberate difference preserves existing clients.
|
| 181 |
+
|
| 182 |
+
For raw Gradio HTTP, POST `{"data":[["hackerone"],"",false,10,0]}` to `/gradio_api/call/collect`, read `event_id`, then GET `/gradio_api/call/collect/EVENT_ID` as an event stream. The `complete` event's JSON array contains the result at index 0; `error` events indicate processing failures. Heartbeats are not results. Python defaults cannot replace missing Gradio positional values.
|
| 183 |
+
|
| 184 |
+
New integrations should use REST unless they specifically need Gradio queue/event behavior.
|
docs/DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Development and deployment
|
| 2 |
+
|
| 3 |
+
## Local run
|
| 4 |
+
|
| 5 |
+
Python 3.13 is used by the hosted Space and CI.
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
git clone https://github.com/Wuhpondiscord/bbcollect.git
|
| 9 |
+
cd bbcollect
|
| 10 |
+
python -m venv .venv
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Activate `.venv` (`.venv\Scripts\Activate.ps1` in PowerShell; `source .venv/bin/activate` on macOS/Linux), then:
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
pip install -r requirements.txt
|
| 17 |
+
python -m unittest discover -s tests -v
|
| 18 |
+
python app.py
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Gradio: `http://localhost:7860` · REST: `http://localhost:7860/api/v1` · Docs: `http://localhost:7860/api/docs`.
|
| 22 |
+
|
| 23 |
+
`app.py` mounts Gradio at `/` on the FastAPI application after registering REST routes, then starts Uvicorn. Keep the REST route registration before the root mount. No platform tokens or runtime secrets are needed. Set `PORT` to change port 7860.
|
| 24 |
+
|
| 25 |
+
## Hugging Face Space
|
| 26 |
+
|
| 27 |
+
The `README.md` front matter configures the Gradio Space with Python 3.13, Gradio 6.31.0, and `app_file: app.py`. Keep the Space public for anonymous access. Use CPU Basic unless your own load requires more resources; the app does not run a model.
|
| 28 |
+
|
| 29 |
+
GitHub Actions tests every push and pull request. Successful pushes to `main` and manual workflow dispatches upload application files to the existing `wuhp/bbconnectapi` Space. `scripts/deploy.py` explicitly selects app modules, docs, and examples; it never uploads secrets, `.git`, virtual environments, or tests, and does not mirror-delete unrelated Space files.
|
| 30 |
+
|
| 31 |
+
The GitHub Actions repository secret `HF_TOKEN` needs write access to the destination Space. **This secret is only for publishing. API callers do not need it, and the running application does not read it.** For a fork, change `repo_id` in `scripts/deploy.py`, add your own deployment secret, and update the public URLs in docs/examples.
|
| 32 |
+
|
| 33 |
+
## Public hosting behavior
|
| 34 |
+
|
| 35 |
+
- REST and Gradio are deliberately unauthenticated. Browser CORS allows public GET/POST calls without credentials.
|
| 36 |
+
- A per-process capacity gate permits two concurrent REST collection requests; busy callers get `429` with `Retry-After: 5`. Health/source descriptions stay lightweight.
|
| 37 |
+
- Source cache is process-local: 900 seconds for successes, 60 for failures. Multiple processes have independent caches/capacity gates. Use one Uvicorn process on the free Space.
|
| 38 |
+
- Free Spaces can sleep/restart. Long live scope requests may exceed a client's default timeout; start with small pages and allow a larger timeout. Summary discovery plus selected batch details is usually faster.
|
| 39 |
+
- There is no persistent storage, scheduled refresh, force-refresh bypass, service-level agreement, or API-key subscription mechanism.
|
| 40 |
+
|
| 41 |
+
## Release checklist
|
| 42 |
+
|
| 43 |
+
1. Run the test suite and verify generated OpenAPI schemas.
|
| 44 |
+
2. Keep the five-input Gradio `/collect` contract and six-input `/collect_options` contract intact.
|
| 45 |
+
3. Test `/api/v1/health`, `/api/docs`, a summary page, a detail document, normalized assets, and a mixed batch.
|
| 46 |
+
4. Confirm the Gradio UI and its event API still work after mounting.
|
| 47 |
+
5. Check `ok`, `partial`, and source states rather than treating missing rules as an empty policy.
|
| 48 |
+
6. Update the changelog, API reference, and examples for intentional behavior changes. Make breaking REST changes under a new versioned path.
|
docs/EXAMPLES.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Usage recipes
|
| 2 |
+
|
| 3 |
+
No API key or login is needed. All examples call the public REST API. Start with summaries, then retrieve the details you need.
|
| 4 |
+
|
| 5 |
+
## Python: summary search and full details
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install requests
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
```python
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
base = "https://wuhp-bbconnectapi.hf.space/api/v1"
|
| 15 |
+
session = requests.Session()
|
| 16 |
+
|
| 17 |
+
def get(path, **params):
|
| 18 |
+
response = session.get(base + path, params=params, timeout=180)
|
| 19 |
+
response.raise_for_status()
|
| 20 |
+
result = response.json()
|
| 21 |
+
if not result["ok"]:
|
| 22 |
+
raise RuntimeError(result)
|
| 23 |
+
return result
|
| 24 |
+
|
| 25 |
+
page = get("/programs", platforms="hackerone,bugcrowd", search="", limit=10)
|
| 26 |
+
print(page["total"], page["next_offset"])
|
| 27 |
+
|
| 28 |
+
for program in page["programs"][:2]:
|
| 29 |
+
detail = get(f"/programs/{program['platform']}/{program['handle']}")
|
| 30 |
+
print(program["name"], detail["min_bounty"], detail["max_bounty"], detail["currency"])
|
| 31 |
+
print("partial:", detail["partial"], "fields:", detail["field_status"])
|
| 32 |
+
print(detail["targets"], detail["rules"])
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Details may use a fallback. A successful HTTP response is not proof that every field is present; inspect `partial`/`field_status` before accessing optional values.
|
| 36 |
+
|
| 37 |
+
## Crawl directory pages and export JSON/CSV
|
| 38 |
+
|
| 39 |
+
Run the [complete example](../examples/export_programs.py):
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
python examples/export_programs.py --platforms hackerone,bugcrowd,intigriti,yeswehack --output programs.json --csv programs.csv
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
The script follows `next_offset`, deduplicates IDs, validates statuses, and retries a bounded number of 429/503 responses. It exports directory summaries, not every policy document. To point it at a local instance, add `--base-url http://localhost:7860/api/v1`.
|
| 46 |
+
|
| 47 |
+
Equivalent pagination pattern:
|
| 48 |
+
|
| 49 |
+
```python
|
| 50 |
+
programs = {}
|
| 51 |
+
offset = 0
|
| 52 |
+
while True:
|
| 53 |
+
page = get("/programs", platforms="hackerone,bugcrowd", limit=100, offset=offset)
|
| 54 |
+
if page["partial"]:
|
| 55 |
+
raise RuntimeError("A directory source is unavailable; inspect page['sources']")
|
| 56 |
+
programs.update({p["id"]: p for p in page["programs"]})
|
| 57 |
+
if page["next_offset"] is None:
|
| 58 |
+
break
|
| 59 |
+
offset = page["next_offset"]
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
For large collections, use summaries to discover IDs, then batch selected detail lookups. Offset pagination is not an atomic snapshot and can shift after cache expiration.
|
| 63 |
+
|
| 64 |
+
## Filter advertised rewards without mixing currencies
|
| 65 |
+
|
| 66 |
+
```python
|
| 67 |
+
page = get("/programs", platforms="hackerone", currency="USD",
|
| 68 |
+
reward_at_least=1000, sort_by="max_bounty", descending=True, limit=10)
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
This uses directory `max_bounty`. Unknown amounts/currencies are excluded. It does not convert EUR to USD, infer minimums, or crawl all live tables before filtering.
|
| 72 |
+
|
| 73 |
+
## Get normalized assets while retaining exclusions
|
| 74 |
+
|
| 75 |
+
```python
|
| 76 |
+
assets = get("/programs/hackerone/security/assets", asset_type="wildcard", limit=100)
|
| 77 |
+
for asset in assets["in_scope"]:
|
| 78 |
+
print(asset["identifier"], asset["type"], asset["instructions"])
|
| 79 |
+
print("All exclusions:", assets["out_of_scope"])
|
| 80 |
+
print("Program rules:", assets["rules"])
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
If `next_offset` is present, continue paging the included assets. All exclusions and rules remain in each response. Native target-group restrictions are in each asset's `raw` record; a normalized identifier alone is not sufficient authorization to test.
|
| 84 |
+
|
| 85 |
+
## Batch detail lookup
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
response = session.post(base + "/programs/batch", json={"programs": [
|
| 89 |
+
{"platform": "hackerone", "handle": "security"},
|
| 90 |
+
{"platform": "yeswehack", "handle": "outscale"},
|
| 91 |
+
]}, timeout=180)
|
| 92 |
+
# Batch status 503 still contains individual results if all lookups failed.
|
| 93 |
+
if response.status_code not in (200, 503):
|
| 94 |
+
response.raise_for_status()
|
| 95 |
+
batch = response.json()
|
| 96 |
+
if "programs" not in batch:
|
| 97 |
+
raise RuntimeError(batch)
|
| 98 |
+
for detail in batch["programs"]:
|
| 99 |
+
print(detail["platform"], detail["handle"], detail["ok"], detail.get("partial"))
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## JavaScript / browser fetch
|
| 103 |
+
|
| 104 |
+
```javascript
|
| 105 |
+
const base = "https://wuhp-bbconnectapi.hf.space/api/v1";
|
| 106 |
+
const query = new URLSearchParams({
|
| 107 |
+
platforms: "hackerone,bugcrowd", limit: "10", include_details: "false"
|
| 108 |
+
});
|
| 109 |
+
const response = await fetch(`${base}/programs?${query}`);
|
| 110 |
+
const page = await response.json();
|
| 111 |
+
if (!response.ok || !page.ok) throw new Error(JSON.stringify(page));
|
| 112 |
+
console.log(page.programs, page.next_offset);
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
CORS is enabled for public GET/POST requests. Do not add credentials, tokens, or an Authorization header. For batch requests use `method: "POST"`, `headers: {"Content-Type": "application/json"}`, and `body: JSON.stringify({programs: [...]})`.
|
| 116 |
+
|
| 117 |
+
## curl
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
# Is the application awake? (No upstream check.)
|
| 121 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/health'
|
| 122 |
+
|
| 123 |
+
# Program counts and source status
|
| 124 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/stats'
|
| 125 |
+
|
| 126 |
+
# A small page with full scopes and rules
|
| 127 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/programs?platforms=hackerone&include_details=true&limit=5'
|
| 128 |
+
|
| 129 |
+
# Save a complete policy/scope response
|
| 130 |
+
curl 'https://wuhp-bbconnectapi.hf.space/api/v1/programs/yeswehack/outscale' -o outscale.json
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
In Windows PowerShell, use `curl.exe` if `curl` is an alias in your shell. For JSON POSTs, Python or a saved request-body file avoids shell-specific quote escaping.
|
| 134 |
+
|
| 135 |
+
## Existing Gradio integrations
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
from gradio_client import Client
|
| 139 |
+
client = Client("wuhp/bbconnectapi")
|
| 140 |
+
|
| 141 |
+
# Original contract: exactly five positional values, always full details.
|
| 142 |
+
page = client.predict(["hackerone"], "", False, 10, 0, api_name="/collect")
|
| 143 |
+
|
| 144 |
+
# Six values only on collect_options; False selects summaries.
|
| 145 |
+
summary = client.predict(["hackerone"], "", False, 10, 0, False, api_name="/collect_options")
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
For direct REST examples, `requests` is the only client dependency. `gradio_client` is needed only for Gradio calls.
|
examples/export_programs.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export all selected directory summaries. Requires only `pip install requests`."""
|
| 2 |
+
import argparse
|
| 3 |
+
import csv
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_json(client, url, params):
|
| 12 |
+
for attempt in range(5):
|
| 13 |
+
response = client.get(url, params=params, timeout=180)
|
| 14 |
+
if response.status_code in (429, 503) and attempt < 4:
|
| 15 |
+
retry = response.headers.get("Retry-After", "")
|
| 16 |
+
delay = min(int(retry), 60) if retry.isdigit() else min(2 ** (attempt + 1), 30)
|
| 17 |
+
time.sleep(delay)
|
| 18 |
+
continue
|
| 19 |
+
response.raise_for_status()
|
| 20 |
+
data = response.json()
|
| 21 |
+
if not data.get("ok") or data.get("partial"):
|
| 22 |
+
raise RuntimeError("Incomplete directory response; inspect source status: " + json.dumps(data.get("sources")))
|
| 23 |
+
return data
|
| 24 |
+
raise RuntimeError("Retry limit reached")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main():
|
| 28 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 29 |
+
parser.add_argument("--base-url", default="https://wuhp-bbconnectapi.hf.space/api/v1")
|
| 30 |
+
parser.add_argument("--platforms", default="hackerone,bugcrowd,intigriti,yeswehack")
|
| 31 |
+
parser.add_argument("--output", type=Path, default=Path("programs.json"))
|
| 32 |
+
parser.add_argument("--csv", type=Path, help="Optional CSV of summary columns")
|
| 33 |
+
args = parser.parse_args()
|
| 34 |
+
programs, seen_offsets = {}, set()
|
| 35 |
+
offset = 0
|
| 36 |
+
with requests.Session() as client:
|
| 37 |
+
while True:
|
| 38 |
+
if offset in seen_offsets:
|
| 39 |
+
raise RuntimeError("Repeated pagination offset")
|
| 40 |
+
seen_offsets.add(offset)
|
| 41 |
+
page = get_json(client, args.base_url.rstrip("/") + "/programs", {
|
| 42 |
+
"platforms": args.platforms, "limit": 100, "offset": offset})
|
| 43 |
+
programs.update({p["id"]: p for p in page["programs"]})
|
| 44 |
+
offset = page["next_offset"]
|
| 45 |
+
if offset is None:
|
| 46 |
+
break
|
| 47 |
+
rows = list(programs.values())
|
| 48 |
+
args.output.write_text(json.dumps({"count": len(rows), "programs": rows}, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 49 |
+
if args.csv:
|
| 50 |
+
columns = ["id", "platform", "handle", "name", "url", "offers_bounties", "min_bounty", "max_bounty", "currency"]
|
| 51 |
+
with args.csv.open("w", newline="", encoding="utf-8") as stream:
|
| 52 |
+
writer = csv.DictWriter(stream, columns, extrasaction="ignore")
|
| 53 |
+
writer.writeheader()
|
| 54 |
+
writer.writerows(rows)
|
| 55 |
+
print(f"Exported {len(rows)} programs to {args.output}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
main()
|
requirements.txt
CHANGED
|
@@ -1,2 +1,4 @@
|
|
| 1 |
gradio==6.31.0
|
| 2 |
requests==2.32.5
|
|
|
|
|
|
|
|
|
| 1 |
gradio==6.31.0
|
| 2 |
requests==2.32.5
|
| 3 |
+
fastapi==0.162.0
|
| 4 |
+
uvicorn==0.54.0
|