bugbounty.sa 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bugbounty_sa-0.1.0/PKG-INFO +140 -0
- bugbounty_sa-0.1.0/README.md +126 -0
- bugbounty_sa-0.1.0/pyproject.toml +37 -0
- bugbounty_sa-0.1.0/pyproject.toml.orig +30 -0
- bugbounty_sa-0.1.0/src/bbsa/__init__.py +134 -0
- bugbounty_sa-0.1.0/src/bbsa/api.py +86 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/__init__.py +179 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/commands/__init__.py +1 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/commands/finance.py +66 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/commands/me.py +36 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/commands/misc.py +78 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/commands/programs.py +106 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/commands/reports.py +102 -0
- bugbounty_sa-0.1.0/src/bbsa/cli/formatters.py +187 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bugbounty.sa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: bugbounty.sa(bbsa): read-only CLI + MCP server for bugbounty.sa — programs, reports, finance, leaderboard
|
|
5
|
+
Keywords: bugbounty,mcp,cli,bug-bounty,security,researcher
|
|
6
|
+
Author: shulkwisec
|
|
7
|
+
Author-email: shulkwisec <shulkwisec@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Dist: mcp>=1.0.0
|
|
10
|
+
Requires-Dist: httpx>=0.27.0
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Repository, https://github.com/ShulkwiSEC/bugbounty.sa
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# bugbounty.sa (bbsa)
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Read-only CLI + MCP server for [bugbounty.sa](https://bugbounty.sa) — query programs, reports, invoices, the leaderboard, and notifications from your terminal or your agent. Report submission stays strictly manual.
|
|
20
|
+
|
|
21
|
+
- **CLI:** `bbsa`
|
|
22
|
+
- **MCP server:** `bbsa-mcp`
|
|
23
|
+
- **Repo:** [github.com/ShulkwiSEC/bugbounty.sa](https://github.com/ShulkwiSEC/bugbounty.sa)
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **`bbsa` CLI** — agent- and human-friendly: stable `--json` on every command, ANSI color only on a TTY, tab-separated plain output when piped, errors on stderr, exit codes `0`/`1`/`2`/`3`.
|
|
28
|
+
- **MCP server** — 14 read-only tools + a `bugbounty://me/profile` resource for Claude, OpenCode, Gemini, etc.
|
|
29
|
+
- **Read-only by construction** — every request is `GET`. No mutations exist anywhere in the codebase.
|
|
30
|
+
- **One HTTP layer** — the CLI and the MCP server share a single `api.py`; no duplicated request handling, no drift.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install git+https://github.com/ShulkwiSEC/bugbounty.sa
|
|
38
|
+
# or, from a clone:
|
|
39
|
+
uv pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Dependencies: [`httpx`](https://www.python-httpx.org/) (HTTP), [`mcp`](https://github.com/modelcontextprotocol/python-sdk) (MCP server). Build backend: [`uv_build`](https://docs.astral.sh/uv/concepts/build-backend/). The distribution is published on PyPI as `bugbounty.sa`; the import package is `bbsa`.
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export BUGBOUNTY_SA_TOKEN="your-bugbounty-sa-token"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The token is your bugbounty.sa API token. Without it, private endpoints return a clear `401` telling you exactly what to set.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Quick tour
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
bbsa me your researcher profile
|
|
58
|
+
bbsa programs list active programs
|
|
59
|
+
bbsa programs show <ID> scope, policy, reward ranges, domains
|
|
60
|
+
bbsa reports list your reports
|
|
61
|
+
bbsa reports show <ID-or-slug> one report's detail
|
|
62
|
+
bbsa reports stats [--group] counts by status|severity|type
|
|
63
|
+
bbsa finance invoices your invoices
|
|
64
|
+
bbsa finance stats invoice totals (paid / unpaid)
|
|
65
|
+
bbsa leaderboard top 10 researchers (public)
|
|
66
|
+
bbsa notifications your notifications
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Examples
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ bbsa programs list
|
|
73
|
+
ID NAME TYPE STATUS PLATFORM ENDS
|
|
74
|
+
1475 CoderHub public active Web 2027-08-31T21:00:00.000000Z
|
|
75
|
+
1474 Tuwaiq Academy public active Web 2027-08-30T21:00:00.000000Z
|
|
76
|
+
313 Flagyard Platform public active Web 2026-12-31T03:00:05.000000Z
|
|
77
|
+
|
|
78
|
+
Next: bbsa programs show <ID> for scope, policy, reward ranges
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Every command produces stable JSON with `--json`, so it drops straight into pipelines:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bbsa leaderboard --json | jq -r '.data[] | "\(.rank) \(.username)"'
|
|
85
|
+
bbsa reports list --json | jq -c '.data[] | select(.severity == "high")'
|
|
86
|
+
bbsa programs show 1475 --json | jq .data.domains
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Exit codes: `0` ok, `1` error, `2` usage, `3` not found. `--debug` prints full tracebacks; `--no-color` forces plain output for scripting.
|
|
90
|
+
|
|
91
|
+
### MCP server
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"bugbounty.sa": {
|
|
97
|
+
"command": "bbsa-mcp",
|
|
98
|
+
"env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or via `uv run`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"bugbounty.sa": {
|
|
110
|
+
"command": "uv",
|
|
111
|
+
"args": ["run", "bbsa-mcp"],
|
|
112
|
+
"cwd": "/path/to/repo",
|
|
113
|
+
"env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Tools: `list_programs`, `get_program_scope`, `list_reports`, `get_report`, `get_report_stats`, `get_wallet_balance`, `list_invoices`, `get_invoice_stats`, `list_transactions`, `get_transaction_stats`, `get_public_leaderboard`, `list_companies`, `get_company`, `list_notifications`. Resource: `bugbounty://me/profile` (`GET /me`).
|
|
120
|
+
|
|
121
|
+
## Example
|
|
122
|
+
|
|
123
|
+
Agent prompts that work with the MCP server connected to your client (the agent calls the tools itself — no CLI needed):
|
|
124
|
+
|
|
125
|
+
1. **Investigate your open work**
|
|
126
|
+
> "List my reports, then for any still in triage pull the full detail and summarize the status, severity, and next step I should take for each."
|
|
127
|
+
|
|
128
|
+
2. **Recon a program before hunting**
|
|
129
|
+
> "Show me the active programs, then for the highest-bounty public one give me its full scope, reward ranges, and target domains."
|
|
130
|
+
|
|
131
|
+
3. **Market-scan as a researcher**
|
|
132
|
+
> "Write a short briefing: who's leading the researcher leaderboard, which recent notifications or new programs are relevant to me, and how my profile compares."
|
|
133
|
+
|
|
134
|
+
## Contributing
|
|
135
|
+
|
|
136
|
+
Issues and PRs welcome at [github.com/ShulkwiSEC/bugbounty.sa/issues](https://github.com/ShulkwiSEC/bugbounty.sa/issues). Keep it read-only: no write endpoints, no new dependencies without a good reason.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
[Apache-2.0](LICENSE) — full text in [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# bugbounty.sa (bbsa)
|
|
2
|
+

|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
Read-only CLI + MCP server for [bugbounty.sa](https://bugbounty.sa) — query programs, reports, invoices, the leaderboard, and notifications from your terminal or your agent. Report submission stays strictly manual.
|
|
6
|
+
|
|
7
|
+
- **CLI:** `bbsa`
|
|
8
|
+
- **MCP server:** `bbsa-mcp`
|
|
9
|
+
- **Repo:** [github.com/ShulkwiSEC/bugbounty.sa](https://github.com/ShulkwiSEC/bugbounty.sa)
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **`bbsa` CLI** — agent- and human-friendly: stable `--json` on every command, ANSI color only on a TTY, tab-separated plain output when piped, errors on stderr, exit codes `0`/`1`/`2`/`3`.
|
|
14
|
+
- **MCP server** — 14 read-only tools + a `bugbounty://me/profile` resource for Claude, OpenCode, Gemini, etc.
|
|
15
|
+
- **Read-only by construction** — every request is `GET`. No mutations exist anywhere in the codebase.
|
|
16
|
+
- **One HTTP layer** — the CLI and the MCP server share a single `api.py`; no duplicated request handling, no drift.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install git+https://github.com/ShulkwiSEC/bugbounty.sa
|
|
24
|
+
# or, from a clone:
|
|
25
|
+
uv pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Dependencies: [`httpx`](https://www.python-httpx.org/) (HTTP), [`mcp`](https://github.com/modelcontextprotocol/python-sdk) (MCP server). Build backend: [`uv_build`](https://docs.astral.sh/uv/concepts/build-backend/). The distribution is published on PyPI as `bugbounty.sa`; the import package is `bbsa`.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export BUGBOUNTY_SA_TOKEN="your-bugbounty-sa-token"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The token is your bugbounty.sa API token. Without it, private endpoints return a clear `401` telling you exactly what to set.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Quick tour
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
bbsa me your researcher profile
|
|
44
|
+
bbsa programs list active programs
|
|
45
|
+
bbsa programs show <ID> scope, policy, reward ranges, domains
|
|
46
|
+
bbsa reports list your reports
|
|
47
|
+
bbsa reports show <ID-or-slug> one report's detail
|
|
48
|
+
bbsa reports stats [--group] counts by status|severity|type
|
|
49
|
+
bbsa finance invoices your invoices
|
|
50
|
+
bbsa finance stats invoice totals (paid / unpaid)
|
|
51
|
+
bbsa leaderboard top 10 researchers (public)
|
|
52
|
+
bbsa notifications your notifications
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Examples
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ bbsa programs list
|
|
59
|
+
ID NAME TYPE STATUS PLATFORM ENDS
|
|
60
|
+
1475 CoderHub public active Web 2027-08-31T21:00:00.000000Z
|
|
61
|
+
1474 Tuwaiq Academy public active Web 2027-08-30T21:00:00.000000Z
|
|
62
|
+
313 Flagyard Platform public active Web 2026-12-31T03:00:05.000000Z
|
|
63
|
+
|
|
64
|
+
Next: bbsa programs show <ID> for scope, policy, reward ranges
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Every command produces stable JSON with `--json`, so it drops straight into pipelines:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bbsa leaderboard --json | jq -r '.data[] | "\(.rank) \(.username)"'
|
|
71
|
+
bbsa reports list --json | jq -c '.data[] | select(.severity == "high")'
|
|
72
|
+
bbsa programs show 1475 --json | jq .data.domains
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Exit codes: `0` ok, `1` error, `2` usage, `3` not found. `--debug` prints full tracebacks; `--no-color` forces plain output for scripting.
|
|
76
|
+
|
|
77
|
+
### MCP server
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"bugbounty.sa": {
|
|
83
|
+
"command": "bbsa-mcp",
|
|
84
|
+
"env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or via `uv run`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"bugbounty.sa": {
|
|
96
|
+
"command": "uv",
|
|
97
|
+
"args": ["run", "bbsa-mcp"],
|
|
98
|
+
"cwd": "/path/to/repo",
|
|
99
|
+
"env": { "BUGBOUNTY_SA_TOKEN": "<your-token>" }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Tools: `list_programs`, `get_program_scope`, `list_reports`, `get_report`, `get_report_stats`, `get_wallet_balance`, `list_invoices`, `get_invoice_stats`, `list_transactions`, `get_transaction_stats`, `get_public_leaderboard`, `list_companies`, `get_company`, `list_notifications`. Resource: `bugbounty://me/profile` (`GET /me`).
|
|
106
|
+
|
|
107
|
+
## Example
|
|
108
|
+
|
|
109
|
+
Agent prompts that work with the MCP server connected to your client (the agent calls the tools itself — no CLI needed):
|
|
110
|
+
|
|
111
|
+
1. **Investigate your open work**
|
|
112
|
+
> "List my reports, then for any still in triage pull the full detail and summarize the status, severity, and next step I should take for each."
|
|
113
|
+
|
|
114
|
+
2. **Recon a program before hunting**
|
|
115
|
+
> "Show me the active programs, then for the highest-bounty public one give me its full scope, reward ranges, and target domains."
|
|
116
|
+
|
|
117
|
+
3. **Market-scan as a researcher**
|
|
118
|
+
> "Write a short briefing: who's leading the researcher leaderboard, which recent notifications or new programs are relevant to me, and how my profile compares."
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
|
|
122
|
+
Issues and PRs welcome at [github.com/ShulkwiSEC/bugbounty.sa/issues](https://github.com/ShulkwiSEC/bugbounty.sa/issues). Keep it read-only: no write endpoints, no new dependencies without a good reason.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
[Apache-2.0](LICENSE) — full text in [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bugbounty.sa"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "bugbounty.sa(bbsa): read-only CLI + MCP server for bugbounty.sa — programs, reports, finance, leaderboard"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
keywords = [
|
|
7
|
+
"bugbounty",
|
|
8
|
+
"mcp",
|
|
9
|
+
"cli",
|
|
10
|
+
"bug-bounty",
|
|
11
|
+
"security",
|
|
12
|
+
"researcher",
|
|
13
|
+
]
|
|
14
|
+
license = "Apache-2.0"
|
|
15
|
+
requires-python = ">=3.12"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"mcp>=1.0.0",
|
|
18
|
+
"httpx>=0.27.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[project.authors]]
|
|
22
|
+
name = "shulkwisec"
|
|
23
|
+
email = "shulkwisec@gmail.com"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
bbsa-mcp = "bbsa:main"
|
|
27
|
+
bbsa = "bbsa.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/ShulkwiSEC/bugbounty.sa"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[tool.uv.build-backend]
|
|
37
|
+
module-name = "bbsa"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bugbounty.sa"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "bugbounty.sa(bbsa): read-only CLI + MCP server for bugbounty.sa — programs, reports, finance, leaderboard"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "shulkwisec", email = "shulkwisec@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
keywords = ["bugbounty", "mcp", "cli", "bug-bounty", "security", "researcher"]
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"mcp>=1.0.0",
|
|
14
|
+
"httpx>=0.27.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
bbsa-mcp = "bbsa:main"
|
|
19
|
+
bbsa = "bbsa.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/ShulkwiSEC/bugbounty.sa"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
# Distribution is "bugbounty.sa"; the import package/module dir is "bbsa".
|
|
29
|
+
[tool.uv.build-backend]
|
|
30
|
+
module-name = "bbsa"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Read-only MCP server for bugbounty.sa — tools + 1 resource.
|
|
2
|
+
|
|
3
|
+
Shares the HTTP layer with the bbsa CLI (see `api.py`).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from mcp.server.mcpserver import MCPServer
|
|
9
|
+
|
|
10
|
+
from bbsa.api import get as _get
|
|
11
|
+
|
|
12
|
+
mcp = MCPServer("bugbounty-sa")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── Programs ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@mcp.tool()
|
|
19
|
+
def list_programs() -> dict:
|
|
20
|
+
"""List active programs on bugbounty.sa."""
|
|
21
|
+
return _get("/programs")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def get_program_scope(program_id: int) -> dict:
|
|
26
|
+
"""Get full program detail: scope, policy, reward ranges, domains."""
|
|
27
|
+
return _get(f"/programs/{program_id}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Reports ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp.tool()
|
|
34
|
+
def list_reports() -> dict:
|
|
35
|
+
"""List your submitted reports with triage status."""
|
|
36
|
+
return _get("/reports")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def get_report(report_id_or_slug: str) -> dict:
|
|
41
|
+
"""Get single report detail by ID or slug."""
|
|
42
|
+
return _get(f"/reports/{report_id_or_slug}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def get_report_stats(group_by: str = "status") -> dict:
|
|
47
|
+
"""Dashboard stats: report counts grouped by status/severity/type."""
|
|
48
|
+
return _get("/reports/stats/grouped", {"groupBy": group_by})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Finance ──────────────────────────────────────────────────────────
|
|
52
|
+
# ponytail: role-gated — wallet/transactions verified 403 with a researcher
|
|
53
|
+
# token (invoices work, that's the researcher payout surface).
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def get_wallet_balance() -> dict:
|
|
58
|
+
"""Get account wallet balance and total payout stats (company/admin role)."""
|
|
59
|
+
return _get("/wallet/balance")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def list_invoices() -> dict:
|
|
64
|
+
"""List bounty invoices."""
|
|
65
|
+
return _get("/invoices")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def get_invoice_stats() -> dict:
|
|
70
|
+
"""Invoice summary stats."""
|
|
71
|
+
return _get("/invoices/stats")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
def list_transactions() -> dict:
|
|
76
|
+
"""List payment/transaction history."""
|
|
77
|
+
return _get("/transactions")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def get_transaction_stats() -> dict:
|
|
82
|
+
"""Transaction summary stats."""
|
|
83
|
+
return _get("/transactions/stats")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Leaderboard ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
def get_public_leaderboard() -> dict:
|
|
91
|
+
"""Top 10 researchers leaderboard (public, no auth needed)."""
|
|
92
|
+
return _get("/leaderboard")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Companies ────────────────────────────────────────────────────────
|
|
96
|
+
# ponytail: role-gated — verified 403 with a researcher token. Keep for
|
|
97
|
+
# admin/company tokens; drop these tools if the server is researcher-only.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
def list_companies() -> dict:
|
|
102
|
+
"""List companies on the platform."""
|
|
103
|
+
return _get("/companies")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
def get_company(company_id: int) -> dict:
|
|
108
|
+
"""Get company detail by ID."""
|
|
109
|
+
return _get(f"/companies/{company_id}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Notifications ────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool()
|
|
116
|
+
def list_notifications() -> dict:
|
|
117
|
+
"""List notifications (unread count, recent alerts)."""
|
|
118
|
+
return _get("/notifications")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Resource ─────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.resource("bugbounty://me/profile")
|
|
125
|
+
def get_my_profile() -> dict:
|
|
126
|
+
"""Current researcher account profile."""
|
|
127
|
+
return _get("/me")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Entry point ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> None:
|
|
134
|
+
mcp.run()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Shared HTTP layer for bugbounty.sa — used by both the MCP server and the CLI.
|
|
2
|
+
|
|
3
|
+
Read-only by design: the only verb ever issued is GET.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
14
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://api.bugbounty.sa/api"
|
|
17
|
+
HEADERS = {
|
|
18
|
+
"Accept": "application/json",
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"Accept-Language": "en",
|
|
21
|
+
"tz": "Asia/Riyadh",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiError(Exception):
|
|
26
|
+
"""Raised on any failed request. code + retryable let consumers act on it."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
code: str = "api_error",
|
|
32
|
+
retryable: bool = False,
|
|
33
|
+
status: int | None = None,
|
|
34
|
+
):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
self.retryable = retryable
|
|
38
|
+
self.status = status
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _err_text(r: httpx.Response) -> str:
|
|
42
|
+
try:
|
|
43
|
+
return r.json().get("message") or r.text[:200]
|
|
44
|
+
except Exception:
|
|
45
|
+
return r.text[:200]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get(path: str, params: dict | None = None) -> dict:
|
|
49
|
+
"""Single GET helper. Returns the full JSON envelope. Raises ApiError."""
|
|
50
|
+
token = os.environ.get("BUGBOUNTY_SA_TOKEN", "")
|
|
51
|
+
headers = dict(HEADERS)
|
|
52
|
+
if token:
|
|
53
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
54
|
+
try:
|
|
55
|
+
r = httpx.get(f"{BASE_URL}{path}", params=params, headers=headers, timeout=30)
|
|
56
|
+
except httpx.HTTPError as exc:
|
|
57
|
+
raise ApiError(
|
|
58
|
+
f"Request to {path} failed: {exc}", code="network_error", retryable=True
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
if r.status_code < 400:
|
|
62
|
+
return r.json()
|
|
63
|
+
|
|
64
|
+
msg = _err_text(r)
|
|
65
|
+
if r.status_code == 401:
|
|
66
|
+
raise ApiError(
|
|
67
|
+
"Unauthenticated. Set BUGBOUNTY_SA_TOKEN to a bugbounty.sa bearer token.",
|
|
68
|
+
code="unauthenticated",
|
|
69
|
+
retryable=True,
|
|
70
|
+
status=401,
|
|
71
|
+
)
|
|
72
|
+
if r.status_code == 403:
|
|
73
|
+
raise ApiError(
|
|
74
|
+
msg or "Forbidden — this token's role lacks access to the endpoint.",
|
|
75
|
+
code="forbidden",
|
|
76
|
+
retryable=False,
|
|
77
|
+
status=403,
|
|
78
|
+
)
|
|
79
|
+
if r.status_code == 404:
|
|
80
|
+
raise ApiError(msg or "Not Found.", code="not_found", retryable=False, status=404)
|
|
81
|
+
raise ApiError(
|
|
82
|
+
msg or "API error.",
|
|
83
|
+
code="http_error",
|
|
84
|
+
retryable=r.status_code >= 500,
|
|
85
|
+
status=r.status_code,
|
|
86
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""bbsa — read-only bugbounty.sa CLI.
|
|
2
|
+
|
|
3
|
+
Divides into a `src/bbsa/api.py` (shared HTTP), a
|
|
4
|
+
`formatters.py` (stable JSON, exit codes, tables) and one `commands/*` module
|
|
5
|
+
per resource, mirroring the oh-my-ctf CLI conventions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from bbsa import api
|
|
14
|
+
from bbsa.cli.formatters import (
|
|
15
|
+
EXIT_ERROR,
|
|
16
|
+
EXIT_NOT_FOUND,
|
|
17
|
+
EXIT_OK,
|
|
18
|
+
EXIT_USAGE,
|
|
19
|
+
print_json_error,
|
|
20
|
+
red,
|
|
21
|
+
set_color_enabled,
|
|
22
|
+
)
|
|
23
|
+
from bbsa.cli.commands.finance import cmd_finance_invoices, cmd_finance_stats
|
|
24
|
+
from bbsa.cli.commands.me import cmd_me
|
|
25
|
+
from bbsa.cli.commands.misc import cmd_leaderboard, cmd_notifications
|
|
26
|
+
from bbsa.cli.commands.programs import cmd_programs_list, cmd_programs_show
|
|
27
|
+
from bbsa.cli.commands.reports import (
|
|
28
|
+
cmd_reports_list,
|
|
29
|
+
cmd_reports_show,
|
|
30
|
+
cmd_reports_stats,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = ["main"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _common_flags() -> argparse.ArgumentParser:
|
|
37
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
38
|
+
common.add_argument("--json", action="store_true", help="Emit stable JSON to stdout")
|
|
39
|
+
common.add_argument("--no-color", action="store_true", help="Disable ANSI color output")
|
|
40
|
+
common.add_argument("--debug", action="store_true", help="Show full stack traces on error")
|
|
41
|
+
return common
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
common = _common_flags()
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="bbsa",
|
|
48
|
+
parents=[common],
|
|
49
|
+
description="bbsa: read-only bugbounty.sa CLI (programs, reports, finance, leaderboard).",
|
|
50
|
+
epilog="Examples:\n"
|
|
51
|
+
" bbsa me\n"
|
|
52
|
+
" bbsa programs list\n"
|
|
53
|
+
" bbsa programs show 1475\n"
|
|
54
|
+
" bbsa reports list --limit 25 --json\n"
|
|
55
|
+
" bbsa reports show <ID-or-slug>\n"
|
|
56
|
+
" bbsa reports stats --group severity\n"
|
|
57
|
+
" bbsa finance invoices\n"
|
|
58
|
+
" bbsa finance stats\n"
|
|
59
|
+
" bbsa leaderboard --json\n"
|
|
60
|
+
" bbsa notifications\n"
|
|
61
|
+
"\nPipeline-friendly: bbsa leaderboard --json | jq -r '.data[] | \"\\\\(.rank) \\\\(.username)\"'",
|
|
62
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
63
|
+
)
|
|
64
|
+
sub = parser.add_subparsers(dest="subcommand", title="Commands")
|
|
65
|
+
|
|
66
|
+
# me
|
|
67
|
+
p_me = sub.add_parser("me", parents=[common], help="Show your researcher profile")
|
|
68
|
+
p_me.set_defaults(handler=cmd_me)
|
|
69
|
+
|
|
70
|
+
# programs
|
|
71
|
+
p_programs = sub.add_parser(
|
|
72
|
+
"programs", parents=[common], help="List programs or show one program's scope"
|
|
73
|
+
)
|
|
74
|
+
programs_sub = p_programs.add_subparsers(dest="programs_subcommand", title="Programs Commands")
|
|
75
|
+
p_pl = programs_sub.add_parser("list", parents=[common], help="List programs")
|
|
76
|
+
p_pl.add_argument("--limit", type=int, default=25, help="Max programs to show (default: 25)")
|
|
77
|
+
p_pl.set_defaults(handler=cmd_programs_list)
|
|
78
|
+
p_ps = programs_sub.add_parser("show", parents=[common], help="Show program scope, policy, reward ranges")
|
|
79
|
+
p_ps.add_argument("id", type=int, help="Program ID")
|
|
80
|
+
p_ps.set_defaults(handler=cmd_programs_show)
|
|
81
|
+
|
|
82
|
+
# reports
|
|
83
|
+
p_reports = sub.add_parser(
|
|
84
|
+
"reports", parents=[common], help="List, inspect, or aggregate your reports"
|
|
85
|
+
)
|
|
86
|
+
reports_sub = p_reports.add_subparsers(dest="reports_subcommand", title="Reports Commands")
|
|
87
|
+
p_rl = reports_sub.add_parser("list", parents=[common], help="List your reports")
|
|
88
|
+
p_rl.add_argument("--limit", type=int, default=25, help="Max reports to show (default: 25)")
|
|
89
|
+
p_rl.set_defaults(handler=cmd_reports_list)
|
|
90
|
+
p_rs = reports_sub.add_parser("show", parents=[common], help="Show one report's detail")
|
|
91
|
+
p_rs.add_argument("id", help="Report ID or slug")
|
|
92
|
+
p_rs.set_defaults(handler=cmd_reports_show)
|
|
93
|
+
p_rst = reports_sub.add_parser("stats", parents=[common], help="Report counts grouped by a field")
|
|
94
|
+
p_rst.add_argument(
|
|
95
|
+
"--group",
|
|
96
|
+
choices=("status", "severity", "type"),
|
|
97
|
+
default="status",
|
|
98
|
+
help="Grouping field (default: status)",
|
|
99
|
+
)
|
|
100
|
+
p_rst.set_defaults(handler=cmd_reports_stats)
|
|
101
|
+
|
|
102
|
+
# finance
|
|
103
|
+
p_finance = sub.add_parser(
|
|
104
|
+
"finance", parents=[common], help="Invoices and payout statistics"
|
|
105
|
+
)
|
|
106
|
+
finance_sub = p_finance.add_subparsers(dest="finance_subcommand", title="Finance Commands")
|
|
107
|
+
p_fi = finance_sub.add_parser("invoices", parents=[common], help="List your invoices")
|
|
108
|
+
p_fi.add_argument("--limit", type=int, default=25, help="Max invoices to show (default: 25)")
|
|
109
|
+
p_fi.set_defaults(handler=cmd_finance_invoices)
|
|
110
|
+
p_fs = finance_sub.add_parser("stats", parents=[common], help="Invoice summary stats")
|
|
111
|
+
p_fs.set_defaults(handler=cmd_finance_stats)
|
|
112
|
+
|
|
113
|
+
# leaderboard
|
|
114
|
+
p_lb = sub.add_parser("leaderboard", parents=[common], help="Top 10 researchers (public)")
|
|
115
|
+
p_lb.set_defaults(handler=cmd_leaderboard)
|
|
116
|
+
|
|
117
|
+
# notifications
|
|
118
|
+
p_nt = sub.add_parser("notifications", parents=[common], help="List your notifications")
|
|
119
|
+
p_nt.add_argument("--limit", type=int, default=25, help="Max notifications (default: 25)")
|
|
120
|
+
p_nt.set_defaults(handler=cmd_notifications)
|
|
121
|
+
|
|
122
|
+
return parser
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_SUBCOMMAND_CHILDREN = {"programs": ["list", "show"], "reports": ["list", "show", "stats"], "finance": ["invoices", "stats"]}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> int:
|
|
129
|
+
if argv is None:
|
|
130
|
+
argv = sys.argv[1:]
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
parser = build_parser()
|
|
134
|
+
if not argv:
|
|
135
|
+
parser.print_help()
|
|
136
|
+
return EXIT_OK
|
|
137
|
+
|
|
138
|
+
args = parser.parse_args(argv)
|
|
139
|
+
if args.no_color:
|
|
140
|
+
set_color_enabled(False)
|
|
141
|
+
|
|
142
|
+
if not hasattr(args, "handler"):
|
|
143
|
+
if args.subcommand in _SUBCOMMAND_CHILDREN:
|
|
144
|
+
kids = ", ".join(_SUBCOMMAND_CHILDREN[args.subcommand])
|
|
145
|
+
if args.json:
|
|
146
|
+
print_json_error(
|
|
147
|
+
"invalid_arguments",
|
|
148
|
+
f"Command 'bbsa {args.subcommand}' requires a subcommand ({kids}).",
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
sys.stderr.write(
|
|
152
|
+
f"Error: 'bbsa {args.subcommand}' requires a subcommand: {kids}\n"
|
|
153
|
+
f"Run 'bbsa {args.subcommand} --help' for details.\n"
|
|
154
|
+
)
|
|
155
|
+
return EXIT_USAGE
|
|
156
|
+
parser.print_help()
|
|
157
|
+
return EXIT_USAGE
|
|
158
|
+
|
|
159
|
+
return args.handler(args)
|
|
160
|
+
except api.ApiError as exc:
|
|
161
|
+
if "--json" in argv:
|
|
162
|
+
print_json_error(exc.code, str(exc), retryable=exc.retryable)
|
|
163
|
+
else:
|
|
164
|
+
sys.stderr.write(red(f"Error: {exc}\n"))
|
|
165
|
+
return EXIT_NOT_FOUND if exc.code == "not_found" else EXIT_ERROR
|
|
166
|
+
except SystemExit as exc:
|
|
167
|
+
return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
if "--debug" in argv:
|
|
170
|
+
raise
|
|
171
|
+
if "--json" in argv:
|
|
172
|
+
print_json_error("unexpected_error", str(exc), retryable=False)
|
|
173
|
+
else:
|
|
174
|
+
sys.stderr.write(red(f"Error: {exc}\n"))
|
|
175
|
+
return EXIT_ERROR
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""bbsa finance — invoices and payout stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from bbsa import api
|
|
8
|
+
from bbsa.cli.formatters import (
|
|
9
|
+
EXIT_OK,
|
|
10
|
+
bold,
|
|
11
|
+
dim,
|
|
12
|
+
print_json_success,
|
|
13
|
+
render_kv,
|
|
14
|
+
render_table,
|
|
15
|
+
pick,
|
|
16
|
+
suggest_next_step,
|
|
17
|
+
text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_finance_invoices(args: argparse.Namespace) -> int:
|
|
22
|
+
resp = api.get("/invoices")
|
|
23
|
+
items = resp.get("data") or []
|
|
24
|
+
meta = resp.get("meta") or {}
|
|
25
|
+
total = meta.get("total") if meta.get("total") is not None else len(items)
|
|
26
|
+
shown = items[: args.limit] if args.limit else items
|
|
27
|
+
|
|
28
|
+
if args.json:
|
|
29
|
+
print_json_success(shown, meta={"total": total, "shown": len(shown)})
|
|
30
|
+
return EXIT_OK
|
|
31
|
+
if not shown:
|
|
32
|
+
print(dim("No invoices found."))
|
|
33
|
+
return EXIT_OK
|
|
34
|
+
|
|
35
|
+
rows = [
|
|
36
|
+
[
|
|
37
|
+
str(i.get("id", "-")),
|
|
38
|
+
text(pick(i, "amount", "total", "price", default="-")),
|
|
39
|
+
text(pick(i, "status", "state", default="-")),
|
|
40
|
+
str(pick(i, "created_at", "date", "paid_at", default="-")),
|
|
41
|
+
]
|
|
42
|
+
for i in shown
|
|
43
|
+
]
|
|
44
|
+
print(render_table(["ID", "AMOUNT", "STATUS", "DATE"], rows))
|
|
45
|
+
if len(shown) < total:
|
|
46
|
+
suggest_next_step(f"Showing {len(shown)} of {total} invoices — --limit {total} to list all")
|
|
47
|
+
else:
|
|
48
|
+
suggest_next_step("bbsa finance stats")
|
|
49
|
+
return EXIT_OK
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_finance_stats(args: argparse.Namespace) -> int:
|
|
53
|
+
stats = api.get("/invoices/stats")
|
|
54
|
+
if args.json:
|
|
55
|
+
print_json_success(stats)
|
|
56
|
+
return EXIT_OK
|
|
57
|
+
pairs = []
|
|
58
|
+
for k, v in stats.items():
|
|
59
|
+
label = str(k).replace("_", " ").title()
|
|
60
|
+
if isinstance(v, dict):
|
|
61
|
+
v = f"{v.get('count', 0)} invoices, total amount {v.get('amount', 0)}"
|
|
62
|
+
pairs.append((label, str(v)))
|
|
63
|
+
print(bold("══ Invoice Stats ══\n"))
|
|
64
|
+
print(render_kv(pairs))
|
|
65
|
+
suggest_next_step("bbsa finance invoices")
|
|
66
|
+
return EXIT_OK
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""bbsa me — current researcher profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from bbsa import api
|
|
8
|
+
from bbsa.cli.formatters import EXIT_OK, bold, print_json_success, render_kv, suggest_next_step
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_me(args: argparse.Namespace) -> int:
|
|
12
|
+
data = api.get("/me")["data"]
|
|
13
|
+
if args.json:
|
|
14
|
+
print_json_success(data)
|
|
15
|
+
return EXIT_OK
|
|
16
|
+
|
|
17
|
+
res = data.get("researcher") or {}
|
|
18
|
+
country = data.get("country")
|
|
19
|
+
if isinstance(country, dict):
|
|
20
|
+
country = country.get("name") or country.get("id")
|
|
21
|
+
pairs = [
|
|
22
|
+
("Name", str(data.get("full_name") or data.get("name") or "-")),
|
|
23
|
+
("Username", str(data.get("username") or "-")),
|
|
24
|
+
("Email", str(data.get("email") or "-")),
|
|
25
|
+
("Country", str(country or "-")),
|
|
26
|
+
("Verified", str(bool(data.get("is_verified")))),
|
|
27
|
+
("Rank", str(res.get("rank") or "-")),
|
|
28
|
+
("Points", str(res.get("points") or 0)),
|
|
29
|
+
("Total Bounties", str(res.get("total_bounties") or 0)),
|
|
30
|
+
("Resolved Reports", str(res.get("resolved_reports") or 0)),
|
|
31
|
+
("Full data", "run with --json"),
|
|
32
|
+
]
|
|
33
|
+
print(bold("══ Your Profile ══\n"))
|
|
34
|
+
print(render_kv(pairs))
|
|
35
|
+
suggest_next_step("bbsa programs list")
|
|
36
|
+
return EXIT_OK
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""bbsa leaderboard / notifications — misc queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from bbsa import api
|
|
8
|
+
from bbsa.cli.formatters import (
|
|
9
|
+
EXIT_OK,
|
|
10
|
+
bold,
|
|
11
|
+
dim,
|
|
12
|
+
green,
|
|
13
|
+
print_json_success,
|
|
14
|
+
render_table,
|
|
15
|
+
suggest_next_step,
|
|
16
|
+
yellow,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_leaderboard(args: argparse.Namespace) -> int:
|
|
21
|
+
resp = api.get("/leaderboard")
|
|
22
|
+
items = resp.get("items") or []
|
|
23
|
+
if args.json:
|
|
24
|
+
print_json_success(items, meta={"total": len(items)})
|
|
25
|
+
return EXIT_OK
|
|
26
|
+
if not items:
|
|
27
|
+
print(dim("Leaderboard is empty."))
|
|
28
|
+
return EXIT_OK
|
|
29
|
+
rows = [
|
|
30
|
+
[
|
|
31
|
+
f"#{i.get('rank')}",
|
|
32
|
+
str(i.get("username", "-")),
|
|
33
|
+
str(i.get("company_researcher_points") or i.get("points") or 0),
|
|
34
|
+
green("verified") if i.get("is_verified") else "not verified",
|
|
35
|
+
]
|
|
36
|
+
for i in items
|
|
37
|
+
]
|
|
38
|
+
print(bold("══ Top Researchers ══\n"))
|
|
39
|
+
print(render_table(["RANK", "USERNAME", "POINTS", "VERIFIED"], rows))
|
|
40
|
+
suggest_next_step(r"jq filter: bbsa leaderboard --json | jq -r '.data[] | \"\(.rank) \(.username)\"'")
|
|
41
|
+
return EXIT_OK
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_notifications(args: argparse.Namespace) -> int:
|
|
45
|
+
resp = api.get("/notifications")
|
|
46
|
+
items = resp.get("data") or []
|
|
47
|
+
meta = resp.get("meta") or {}
|
|
48
|
+
total = meta.get("total") if meta.get("total") is not None else len(items)
|
|
49
|
+
shown = items[: args.limit] if args.limit else items
|
|
50
|
+
|
|
51
|
+
if args.json:
|
|
52
|
+
print_json_success(shown, meta={"total": total, "shown": len(shown)})
|
|
53
|
+
return EXIT_OK
|
|
54
|
+
if not shown:
|
|
55
|
+
print(dim("No notifications."))
|
|
56
|
+
return EXIT_OK
|
|
57
|
+
|
|
58
|
+
rows = []
|
|
59
|
+
for n in shown:
|
|
60
|
+
d = n.get("data")
|
|
61
|
+
d = d if isinstance(d, dict) else {}
|
|
62
|
+
title = d.get("title") if isinstance(d.get("title"), dict) else {}
|
|
63
|
+
message = d.get("message")
|
|
64
|
+
if isinstance(message, dict):
|
|
65
|
+
message = message.get("message_en") or str(message)
|
|
66
|
+
text = str(title.get("title_en") or message or "-")
|
|
67
|
+
rows.append([
|
|
68
|
+
str(n.get("id", "-")),
|
|
69
|
+
text,
|
|
70
|
+
green("read") if n.get("read_at") else yellow("unread"),
|
|
71
|
+
str(n.get("created_at", "-")),
|
|
72
|
+
])
|
|
73
|
+
print(render_table(["ID", "MESSAGE", "STATUS", "AT"], rows))
|
|
74
|
+
if len(shown) < total:
|
|
75
|
+
suggest_next_step(f"Showing {len(shown)} of {total} — --limit {total} for all")
|
|
76
|
+
else:
|
|
77
|
+
suggest_next_step("bbsa reports list")
|
|
78
|
+
return EXIT_OK
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""bbsa programs list / show."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from bbsa import api
|
|
8
|
+
from bbsa.cli.formatters import (
|
|
9
|
+
EXIT_OK,
|
|
10
|
+
bold,
|
|
11
|
+
dim,
|
|
12
|
+
green,
|
|
13
|
+
print_json_success,
|
|
14
|
+
render_kv,
|
|
15
|
+
render_table,
|
|
16
|
+
red,
|
|
17
|
+
suggest_next_step,
|
|
18
|
+
text,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _platform_name(p: dict) -> str:
|
|
23
|
+
plat = p.get("platform")
|
|
24
|
+
if isinstance(plat, dict):
|
|
25
|
+
return str(plat.get("name") or "-")
|
|
26
|
+
return str(plat or "-")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_programs_list(args: argparse.Namespace) -> int:
|
|
30
|
+
resp = api.get("/programs")
|
|
31
|
+
items = resp.get("data") or []
|
|
32
|
+
meta = resp.get("meta") or {}
|
|
33
|
+
total = meta.get("total") if meta.get("total") is not None else len(items)
|
|
34
|
+
# ponytail: client-side slice; switch to server page/limit if lists grow large
|
|
35
|
+
shown = items[: args.limit] if args.limit else items
|
|
36
|
+
|
|
37
|
+
if args.json:
|
|
38
|
+
print_json_success(shown, meta={"total": total, "shown": len(shown)})
|
|
39
|
+
return EXIT_OK
|
|
40
|
+
if not shown:
|
|
41
|
+
print(dim("No programs found."))
|
|
42
|
+
return EXIT_OK
|
|
43
|
+
|
|
44
|
+
rows = [
|
|
45
|
+
[
|
|
46
|
+
str(p.get("id", "-")),
|
|
47
|
+
text(p.get("name", "-")),
|
|
48
|
+
str(p.get("type", "-")),
|
|
49
|
+
green("active") if p.get("is_active") else red("inactive"),
|
|
50
|
+
_platform_name(p),
|
|
51
|
+
str(p.get("end_date") or p.get("end") or "-"),
|
|
52
|
+
]
|
|
53
|
+
for p in shown
|
|
54
|
+
]
|
|
55
|
+
print(render_table(["ID", "NAME", "TYPE", "STATUS", "PLATFORM", "ENDS"], rows))
|
|
56
|
+
if len(shown) < total:
|
|
57
|
+
suggest_next_step(f"Showing {len(shown)} of {total} — see all with --limit {total}")
|
|
58
|
+
else:
|
|
59
|
+
suggest_next_step("bbsa programs show <ID> for scope, policy, reward ranges")
|
|
60
|
+
return EXIT_OK
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_programs_show(args: argparse.Namespace) -> int:
|
|
64
|
+
prog = api.get(f"/programs/{args.id}")["data"]
|
|
65
|
+
if args.json:
|
|
66
|
+
print_json_success(prog)
|
|
67
|
+
return EXIT_OK
|
|
68
|
+
|
|
69
|
+
pairs = [
|
|
70
|
+
("ID", str(prog.get("id"))),
|
|
71
|
+
("Name", str(prog.get("name") or "-")),
|
|
72
|
+
("Type", str(prog.get("type") or "-")),
|
|
73
|
+
("Platform", _platform_name(prog)),
|
|
74
|
+
("Status", green("active") if prog.get("is_active") else red("inactive")),
|
|
75
|
+
("Start", str(prog.get("start_date") or "-")),
|
|
76
|
+
("End", str(prog.get("end_date") or "-")),
|
|
77
|
+
]
|
|
78
|
+
print(bold(f"══ Program {prog.get('id')}: {prog.get('name')} ══\n"))
|
|
79
|
+
print(render_kv(pairs))
|
|
80
|
+
|
|
81
|
+
ranges = [
|
|
82
|
+
(lvl.title(), f"{prog.get(f'{lvl}_range_from')} - {prog.get(f'{lvl}_range_to')}")
|
|
83
|
+
for lvl in ("critical", "high", "medium", "low")
|
|
84
|
+
if prog.get(f"{lvl}_range_from") is not None
|
|
85
|
+
]
|
|
86
|
+
if ranges:
|
|
87
|
+
print(f"\n{bold('Reward Ranges:')}")
|
|
88
|
+
print(render_kv(ranges))
|
|
89
|
+
|
|
90
|
+
domains = prog.get("domains") or []
|
|
91
|
+
if domains:
|
|
92
|
+
print(f"\n{bold('Domains:')}")
|
|
93
|
+
for d in domains:
|
|
94
|
+
print(f" • {d.get('domain', d) if isinstance(d, dict) else d}")
|
|
95
|
+
|
|
96
|
+
policy = prog.get("policy")
|
|
97
|
+
if policy:
|
|
98
|
+
print(f"\n{bold('Policy:')}\n{policy}")
|
|
99
|
+
|
|
100
|
+
out = prog.get("out_of_scope")
|
|
101
|
+
if out:
|
|
102
|
+
print(f"\n{bold('Out of Scope:')}\n{out}")
|
|
103
|
+
|
|
104
|
+
print()
|
|
105
|
+
suggest_next_step("bbsa reports list --json | jq '.[] | {id, status}'")
|
|
106
|
+
return EXIT_OK
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""bbsa reports list / show / stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from bbsa import api
|
|
8
|
+
from bbsa.cli.formatters import (
|
|
9
|
+
EXIT_OK,
|
|
10
|
+
bold,
|
|
11
|
+
dim,
|
|
12
|
+
print_json_success,
|
|
13
|
+
render_kv,
|
|
14
|
+
render_table,
|
|
15
|
+
pick,
|
|
16
|
+
suggest_next_step,
|
|
17
|
+
text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_reports_list(args: argparse.Namespace) -> int:
|
|
22
|
+
resp = api.get("/reports")
|
|
23
|
+
items = resp.get("data") or []
|
|
24
|
+
meta = resp.get("meta") or {}
|
|
25
|
+
total = meta.get("total") if meta.get("total") is not None else len(items)
|
|
26
|
+
# ponytail: client-side slice; switch to server page/limit if get large
|
|
27
|
+
shown = items[: args.limit] if args.limit else items
|
|
28
|
+
|
|
29
|
+
if args.json:
|
|
30
|
+
print_json_success(shown, meta={"total": total, "shown": len(shown)})
|
|
31
|
+
return EXIT_OK
|
|
32
|
+
if not shown:
|
|
33
|
+
print(dim("No reports found."))
|
|
34
|
+
return EXIT_OK
|
|
35
|
+
|
|
36
|
+
rows = [
|
|
37
|
+
[
|
|
38
|
+
str(r.get("id", "-")),
|
|
39
|
+
text(pick(r, "title", "name", default="-")),
|
|
40
|
+
text(pick(r, "status", default="-")),
|
|
41
|
+
text(pick(r, "severity", default="-")),
|
|
42
|
+
str(pick(r, "created_at", "updated_at", default="-")),
|
|
43
|
+
]
|
|
44
|
+
for r in shown
|
|
45
|
+
]
|
|
46
|
+
print(render_table(["ID", "TITLE", "STATUS", "SEVERITY", "UPDATED"], rows))
|
|
47
|
+
if len(shown) < total:
|
|
48
|
+
suggest_next_step(f"Showing {len(shown)} of {total} — see all with --limit {total}")
|
|
49
|
+
else:
|
|
50
|
+
suggest_next_step("bbsa reports show <ID-or-slug> for detail")
|
|
51
|
+
return EXIT_OK
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_reports_show(args: argparse.Namespace) -> int:
|
|
55
|
+
r = api.get(f"/reports/{args.id}")["data"]
|
|
56
|
+
if args.json:
|
|
57
|
+
print_json_success(r)
|
|
58
|
+
return EXIT_OK
|
|
59
|
+
|
|
60
|
+
pairs = [
|
|
61
|
+
("Title", text(pick(r, "title", "name", default="-"))),
|
|
62
|
+
("Status", text(pick(r, "status", default="-"))),
|
|
63
|
+
("Severity", text(pick(r, "severity", default="-"))),
|
|
64
|
+
("Program", text(pick(r, "program", default=""))),
|
|
65
|
+
("Created", text(pick(r, "created_at", default="-"))),
|
|
66
|
+
("Updated", text(pick(r, "updated_at", default="-"))),
|
|
67
|
+
("Summary", text(pick(r, "summary", default=""))),
|
|
68
|
+
]
|
|
69
|
+
pairs = [(k, v) for k, v in pairs if v]
|
|
70
|
+
print(bold(f"══ Report {args.id} ══\n"))
|
|
71
|
+
print(render_kv(pairs))
|
|
72
|
+
|
|
73
|
+
desc = pick(r, "description", "body", "reproduction_steps", default="")
|
|
74
|
+
if desc:
|
|
75
|
+
print(f"\n{bold('Description:')}\n{desc}")
|
|
76
|
+
|
|
77
|
+
print("\nFull fields available with --json.")
|
|
78
|
+
return EXIT_OK
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_reports_stats(args: argparse.Namespace) -> int:
|
|
82
|
+
resp = api.get("/reports/stats/grouped", {"groupBy": args.group})
|
|
83
|
+
payload = resp.get("data", resp)
|
|
84
|
+
if args.json:
|
|
85
|
+
print_json_success(payload, meta={"group_by": args.group})
|
|
86
|
+
return EXIT_OK
|
|
87
|
+
|
|
88
|
+
if isinstance(payload, dict):
|
|
89
|
+
print(bold(f"══ Report Stats (by {args.group}) ══\n"))
|
|
90
|
+
print(render_kv([(str(k), str(v)) for k, v in payload.items()]))
|
|
91
|
+
elif isinstance(payload, list):
|
|
92
|
+
rows = []
|
|
93
|
+
for x in payload:
|
|
94
|
+
if isinstance(x, dict):
|
|
95
|
+
rows.append([str(pick(x, "group", args.group, default="-")), str(x.get("count", "-"))])
|
|
96
|
+
else:
|
|
97
|
+
rows.append([str(x), "?"])
|
|
98
|
+
print(render_table([args.group.title(), "COUNT"], rows))
|
|
99
|
+
else:
|
|
100
|
+
print(str(payload))
|
|
101
|
+
suggest_next_step(r"jq filter: bbsa reports stats --json | jq '.data'")
|
|
102
|
+
return EXIT_OK
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""CLI formatters: JSON envelopes, exit codes, ANSI color, tables, hints.
|
|
2
|
+
|
|
3
|
+
Mirrors the oh-my-ctf CLI conventions: stable JSON with --json, tab-separated
|
|
4
|
+
plain text when piped, ANSI color only on a TTY, data on stdout / errors on
|
|
5
|
+
stderr, bounded human output with next-step hints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
EXIT_OK = 0
|
|
18
|
+
EXIT_ERROR = 1
|
|
19
|
+
EXIT_USAGE = 2
|
|
20
|
+
EXIT_NOT_FOUND = 3
|
|
21
|
+
|
|
22
|
+
_COLOR_OVERRIDE: bool | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_color_enabled(enabled: bool | None) -> None:
|
|
26
|
+
global _COLOR_OVERRIDE
|
|
27
|
+
_COLOR_OVERRIDE = enabled
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_color_enabled(stream=sys.stdout) -> bool:
|
|
31
|
+
if _COLOR_OVERRIDE is not None:
|
|
32
|
+
return _COLOR_OVERRIDE
|
|
33
|
+
if "NO_COLOR" in os.environ:
|
|
34
|
+
return False
|
|
35
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ansi(code: str, text: str, stream=sys.stdout) -> str:
|
|
39
|
+
if not is_color_enabled(stream):
|
|
40
|
+
return str(text)
|
|
41
|
+
return f"\033[{code}m{text}\033[0m"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bold(text, stream=sys.stdout):
|
|
45
|
+
return _ansi("1", text, stream)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dim(text, stream=sys.stdout):
|
|
49
|
+
return _ansi("2", text, stream)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def red(text, stream=sys.stdout):
|
|
53
|
+
return _ansi("31", text, stream)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def green(text, stream=sys.stdout):
|
|
57
|
+
return _ansi("32", text, stream)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def yellow(text, stream=sys.stdout):
|
|
61
|
+
return _ansi("33", text, stream)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cyan(text, stream=sys.stdout):
|
|
65
|
+
return _ansi("36", text, stream)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pick(d: dict, *keys, default: Any = "") -> Any:
|
|
69
|
+
"""First present key in d, or default. Immune to unknown response shapes."""
|
|
70
|
+
for k in keys:
|
|
71
|
+
if isinstance(d, dict) and d.get(k) is not None:
|
|
72
|
+
return d[k]
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def text(v: Any, max_len: int = 80) -> str:
|
|
77
|
+
"""Human-friendly single-line rendering. Nested dicts collapse to their
|
|
78
|
+
name/label/title; long strings are truncated (bounded output)."""
|
|
79
|
+
if isinstance(v, dict):
|
|
80
|
+
for k in ("name", "label", "title", "title_en", "value"):
|
|
81
|
+
if v.get(k) is not None:
|
|
82
|
+
return text(v[k], max_len)
|
|
83
|
+
return str(v)[:max_len]
|
|
84
|
+
if isinstance(v, bool):
|
|
85
|
+
return str(v)
|
|
86
|
+
s = str(v)
|
|
87
|
+
if len(s) <= max_len:
|
|
88
|
+
return s
|
|
89
|
+
return s[: max_len - 1] + "…"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── JSON envelopes ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_json_success(data: Any, meta: dict[str, Any] | None = None) -> str:
|
|
96
|
+
payload = {
|
|
97
|
+
"ok": True,
|
|
98
|
+
"data": data,
|
|
99
|
+
"meta": meta
|
|
100
|
+
or {"side_effects": False, "estimated_cost": None, "retryable": False},
|
|
101
|
+
}
|
|
102
|
+
return json.dumps(payload, indent=2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_json_error(
|
|
106
|
+
code: str, message: str, retryable: bool = False, details: Any = None
|
|
107
|
+
) -> str:
|
|
108
|
+
payload = {
|
|
109
|
+
"ok": False,
|
|
110
|
+
"error": {"code": code, "message": message, "retryable": retryable, "details": details},
|
|
111
|
+
}
|
|
112
|
+
return json.dumps(payload, indent=2)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_json_success(data: Any, meta: dict[str, Any] | None = None) -> None:
|
|
116
|
+
print(format_json_success(data, meta))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def print_json_error(
|
|
120
|
+
code: str, message: str, retryable: bool = False, details: Any = None
|
|
121
|
+
) -> None:
|
|
122
|
+
print(format_json_error(code, message, retryable, details))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── Tables & text ─────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_table(
|
|
129
|
+
headers: Sequence[str],
|
|
130
|
+
rows: Sequence[Sequence[str]],
|
|
131
|
+
alignments: Sequence[str] | None = None,
|
|
132
|
+
stream=sys.stdout,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Aligned table on a TTY; tab-separated plain rows when piped."""
|
|
135
|
+
if not rows:
|
|
136
|
+
return ""
|
|
137
|
+
is_tty = is_color_enabled(stream) or (hasattr(stream, "isatty") and stream.isatty())
|
|
138
|
+
align = list(alignments or ["<"] * len(headers))
|
|
139
|
+
while len(align) < len(headers):
|
|
140
|
+
align.append("<")
|
|
141
|
+
|
|
142
|
+
col_widths = [len(str(h)) for h in headers]
|
|
143
|
+
for row in rows:
|
|
144
|
+
for i, val in enumerate(row):
|
|
145
|
+
if i < len(col_widths):
|
|
146
|
+
col_widths[i] = max(col_widths[i], len(_strip_ansi(str(val))))
|
|
147
|
+
|
|
148
|
+
if not is_tty:
|
|
149
|
+
lines = ["\t".join(str(h) for h in headers)]
|
|
150
|
+
for row in rows:
|
|
151
|
+
lines.append("\t".join(str(cell) for cell in row))
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
def cell(text: str, width: int, alignment: str) -> str:
|
|
155
|
+
clean = _strip_ansi(str(text))
|
|
156
|
+
padding = width - len(clean)
|
|
157
|
+
if padding <= 0:
|
|
158
|
+
return str(text)
|
|
159
|
+
return (" " * padding + str(text)) if alignment == ">" else str(text) + " " * padding
|
|
160
|
+
|
|
161
|
+
header_line = " ".join(
|
|
162
|
+
bold(cell(headers[i], col_widths[i], align[i]), stream) for i in range(len(headers))
|
|
163
|
+
)
|
|
164
|
+
separator = " ".join(dim("─" * col_widths[i], stream) for i in range(len(headers)))
|
|
165
|
+
lines = [header_line, separator]
|
|
166
|
+
for row in rows:
|
|
167
|
+
lines.append(
|
|
168
|
+
" ".join(cell(str(row[i]) if i < len(row) else "", col_widths[i], align[i]) for i in range(len(headers)))
|
|
169
|
+
)
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _strip_ansi(text: str) -> str:
|
|
174
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def render_kv(pairs: list[tuple[str, str]], stream=sys.stdout) -> str:
|
|
178
|
+
if not pairs:
|
|
179
|
+
return ""
|
|
180
|
+
max_k = max(len(k) for k, _ in pairs)
|
|
181
|
+
return "\n".join(f"{bold(k.ljust(max_k), stream)} : {v}" for k, v in pairs)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def suggest_next_step(hint: str, stream=sys.stdout) -> None:
|
|
185
|
+
"""Print a context-aware next-step hint, TTY only."""
|
|
186
|
+
if hasattr(stream, "isatty") and stream.isatty():
|
|
187
|
+
print(f"\n{dim('Next:', stream)} {cyan(hint, stream)}")
|