wuhp commited on
Commit
fc0b9f5
·
verified ·
1 Parent(s): 6a19827

Deploy bbcollect 8fcea0a244395b5057e019e22276481549cb1a4b

Browse files
Files changed (8) hide show
  1. README.md +75 -52
  2. api.py +371 -0
  3. app.py +31 -4
  4. docs/API.md +184 -0
  5. docs/DEPLOYMENT.md +48 -0
  6. docs/EXAMPLES.md +148 -0
  7. examples/export_programs.py +59 -0
  8. requirements.txt +2 -0
README.md CHANGED
@@ -10,86 +10,109 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # bbcollect / bbconnectapi
14
 
15
- A Gradio UI and JSON API for public bug bounty program listings and scope.
16
 
17
- - Space: https://huggingface.co/spaces/wuhp/bbconnectapi
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
- This collects the public open-program directory, not every promotion or feature of the Opportunities page. Private programs and authenticated account data are not collected. No platform credentials are required.
 
 
 
24
 
25
- ## API usage
26
 
27
- Install `gradio_client`, then:
28
 
29
- ```python
30
- from gradio_client import Client
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
- Raw HTTP uses Gradio's two-step event API, not REST GET routes:
48
-
49
- ```bash
50
- curl -X POST https://wuhp-bbconnectapi.hf.space/gradio_api/call/collect \
51
- -H 'Content-Type: application/json' \
52
- -d '{"data":[["hackerone"],"",false,10,0]}'
53
- # Use the returned event_id:
54
- curl -N https://wuhp-bbconnectapi.hf.space/gradio_api/call/collect/EVENT_ID
 
 
 
 
 
 
 
 
 
 
 
 
55
  ```
56
 
57
- `/collect` accepts exactly five inputs in this order: `platforms`, `search`, `bounty_only`, `limit`, `offset`. It returns `ok`, `partial`, `total` (after filtering), `next_offset`, `sources`, and `programs`. It always includes live details: each program has `targets`, `rules`, `reward_table`, `field_status`, and `details_source`.
58
 
59
- Use `/collect_options` for the same five inputs plus a sixth `include_details` boolean. Set it to `False` for fast summary pages. Clients using the six-value payload from the previous version should change their endpoint to `/collect_options`; original five-value `/collect` clients require no changes. Python default arguments do not make Gradio component inputs optional in raw HTTP requests.
60
 
61
- Full-detail pages support 1–100 programs; summary pages support 1–1000. `offset` is 0–100000. Four workers fetch details with a shared 15-minute cache, so large first requests take longer.
 
 
 
 
 
 
 
 
62
 
63
- Failures are reported per source and program. A successful empty search differs from an upstream failure. Sorting is by platform, name, then ID. Search matches name, handle, URL or website. The bounty filter excludes unknown values.
64
 
65
- Program fields: `id`, `platform`, `handle`, `name`, `url`, `website`, `offers_bounties`, `min_bounty`, `max_bounty`, `currency`, `submission_state`. Bounty amounts are numeric with a separate currency when available. Unknown values are null; an unpublished minimum is never inferred from the maximum. Reward tables preserve severity/asset-tier ranges. Bugcrowd ranges use all published in-scope severity tiers; HackerOne ranges use its published bounty table. These are advertised rewards, not historical payouts or guarantees.
66
 
67
- Use the exact platform and handle returned by `/collect` for `/details` or `/scope` (an alias returning the same full details). Intigriti uses its program UUID because URL slugs can repeat across companies. `targets` contains `in_scope` and `out_of_scope` arrays with native fields, group instructions, and exclusions. `rules` preserves full policy text/HTML, disclosure terms, safe-harbor information, testing requirements, FAQs, or qualifying vulnerability types where published. Intigriti uses the latest version of every section. HTML is returned as JSON strings, not executed.
 
 
 
 
 
68
 
69
- If live details fail, available dataset scopes/rewards are retained, `partial` is true, and `field_status` explicitly marks missing rules. Missing policies are never represented as an empty set of permissions. Rules linked from program text remain links; the collector does not recursively crawl external documents.
70
 
71
- Each source reports `mode` (`live` or `public_dataset`), `source_url`, `fetched_at`, `source_updated_at` (null if unavailable), and `cached`. Retrieval time is not the time the platform last changed its data. Live pages are fetched sequentially, not atomically; pagination may shift after the cache expires.
 
 
 
 
72
 
73
- ## Deployment
74
 
75
- The GitHub Actions workflow tests pushes/PRs and deploys successful `main` builds to the existing `wuhp/bbconnectapi` Space. Manual workflow dispatch is supported. The repository Actions secret **HF_TOKEN** must have write access to that Space. The token is used only by the deployment job; the public app never receives it.
76
 
77
- Only `app.py`, `collector.py`, `details.py`, `requirements.txt`, and `README.md` are uploaded. Existing unrelated Space files are retained. Tokens are never printed or placed in Git remote URLs.
 
 
 
 
 
 
 
78
 
79
- ## Local development
80
 
81
  ```bash
 
 
82
  python -m venv .venv
83
- # Activate the environment for your shell, then:
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. Public Spaces expose their Gradio API publicly; use a private Space and an authenticated client for restricted access. Free Spaces may sleep and lose their cache. Collection runs on demand with a shared 15-minute memory cache, or 60 seconds for failures. There is no scheduled crawler or persistent database.
 
 
90
 
91
- Check current program rules and scope at the linked platform before testing. Snapshots can lag behind changes and do not grant permission to test assets.
92
 
93
- ## Data attribution
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, available as a JSON API. "
 
 
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('''Use **View API** in the footer for generated client examples.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")),
91
- show_error=False)
 
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&currency=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