openhack-cli 0.1.0__py3-none-any.whl
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.
- openhack_cli/__init__.py +3 -0
- openhack_cli/cli.py +88 -0
- openhack_cli/client.py +124 -0
- openhack_cli/commands/__init__.py +1 -0
- openhack_cli/commands/auth.py +185 -0
- openhack_cli/commands/config_cmd.py +56 -0
- openhack_cli/commands/orgs.py +71 -0
- openhack_cli/commands/pentest.py +603 -0
- openhack_cli/commands/projects.py +130 -0
- openhack_cli/commands/scans.py +154 -0
- openhack_cli/commands/vulns.py +341 -0
- openhack_cli/config.py +137 -0
- openhack_cli/context.py +76 -0
- openhack_cli/output.py +104 -0
- openhack_cli-0.1.0.dist-info/METADATA +164 -0
- openhack_cli-0.1.0.dist-info/RECORD +20 -0
- openhack_cli-0.1.0.dist-info/WHEEL +5 -0
- openhack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openhack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openhack_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""`openhack-cli projects` — list, inspect, create, and select projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import output
|
|
8
|
+
from ..context import get_client, get_config, match_resource, resolve_org_id
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def projects() -> None:
|
|
13
|
+
"""List, create, and select projects."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fetch_projects(ctx: click.Context) -> list[dict]:
|
|
17
|
+
client = get_client(ctx)
|
|
18
|
+
data = client.get("/api/projects")
|
|
19
|
+
return data.get("projects", []) if isinstance(data, dict) else (data or [])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@projects.command(name="list")
|
|
23
|
+
@click.option("--org", default=None,
|
|
24
|
+
help="Filter to a single org id (client-side).")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def list_projects(ctx: click.Context, org: str | None) -> None:
|
|
27
|
+
"""List your projects."""
|
|
28
|
+
items = _fetch_projects(ctx)
|
|
29
|
+
if org:
|
|
30
|
+
items = [p for p in items if p.get("ownerOrg") == org]
|
|
31
|
+
cfg = get_config(ctx)
|
|
32
|
+
active_id = (cfg.project or {}).get("id")
|
|
33
|
+
|
|
34
|
+
def render(rows):
|
|
35
|
+
if not rows:
|
|
36
|
+
output.warn("No projects found.")
|
|
37
|
+
return
|
|
38
|
+
table = output.make_table("Projects",
|
|
39
|
+
["", "Name", "Slug", "Repo", "ID"])
|
|
40
|
+
for p in rows:
|
|
41
|
+
marker = "[green]●[/green]" if p.get("id") == active_id else ""
|
|
42
|
+
repo = "-"
|
|
43
|
+
if p.get("githubRepoOwner") and p.get("githubRepoName"):
|
|
44
|
+
repo = f"{p['githubRepoOwner']}/{p['githubRepoName']}"
|
|
45
|
+
table.add_row(
|
|
46
|
+
marker,
|
|
47
|
+
p.get("name", "-"),
|
|
48
|
+
p.get("slug") or "-",
|
|
49
|
+
repo,
|
|
50
|
+
output.short(p.get("id"), 38),
|
|
51
|
+
)
|
|
52
|
+
output.console.print(table)
|
|
53
|
+
|
|
54
|
+
output.emit(ctx.obj, items, render)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@projects.command(name="get")
|
|
58
|
+
@click.argument("identifier", required=False)
|
|
59
|
+
@click.pass_context
|
|
60
|
+
def get_project(ctx: click.Context, identifier: str | None) -> None:
|
|
61
|
+
"""Show details for a project (defaults to the active project)."""
|
|
62
|
+
items = _fetch_projects(ctx)
|
|
63
|
+
if identifier:
|
|
64
|
+
match = match_resource(items, identifier)
|
|
65
|
+
else:
|
|
66
|
+
cfg = get_config(ctx)
|
|
67
|
+
active = cfg.project or {}
|
|
68
|
+
match = match_resource(items, active.get("id", "")) if active else None
|
|
69
|
+
if not match:
|
|
70
|
+
raise click.ClickException(
|
|
71
|
+
"Project not found. Pass an id/slug or run `openhack-cli projects use <id>`."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def render(p):
|
|
75
|
+
output.console.print(f"[bold]{p.get('name')}[/bold] "
|
|
76
|
+
f"[dim]{p.get('slug') or ''}[/dim]")
|
|
77
|
+
output.console.print(f" ID: {p.get('id')}")
|
|
78
|
+
output.console.print(f" Org: {p.get('ownerOrg')}")
|
|
79
|
+
if p.get("githubRepoOwner") and p.get("githubRepoName"):
|
|
80
|
+
output.console.print(
|
|
81
|
+
f" Repo: {p['githubRepoOwner']}/{p['githubRepoName']}"
|
|
82
|
+
)
|
|
83
|
+
if p.get("createdAt"):
|
|
84
|
+
output.console.print(f" Created: {p.get('createdAt')}")
|
|
85
|
+
|
|
86
|
+
output.emit(ctx.obj, match, render)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@projects.command()
|
|
90
|
+
@click.argument("identifier")
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def use(ctx: click.Context, identifier: str) -> None:
|
|
93
|
+
"""Set the active project (by id, slug, or name)."""
|
|
94
|
+
items = _fetch_projects(ctx)
|
|
95
|
+
match = match_resource(items, identifier)
|
|
96
|
+
if not match:
|
|
97
|
+
raise click.ClickException(
|
|
98
|
+
f"No project matching '{identifier}'. Try `openhack-cli projects list`."
|
|
99
|
+
)
|
|
100
|
+
cfg = get_config(ctx)
|
|
101
|
+
cfg.set("project", {
|
|
102
|
+
"id": match.get("id"),
|
|
103
|
+
"slug": match.get("slug"),
|
|
104
|
+
"name": match.get("name"),
|
|
105
|
+
})
|
|
106
|
+
cfg.save()
|
|
107
|
+
output.success(f"Active project set to [bold]{match.get('name')}[/bold]")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@projects.command()
|
|
111
|
+
@click.option("--name", required=True, help="Project name.")
|
|
112
|
+
@click.option("--slug", default=None, help="Project slug (optional).")
|
|
113
|
+
@click.option("--org", default=None,
|
|
114
|
+
help="Org id to create the project under (defaults to active org).")
|
|
115
|
+
@click.pass_context
|
|
116
|
+
def create(ctx: click.Context, name: str, slug: str | None, org: str | None) -> None:
|
|
117
|
+
"""Create a new project."""
|
|
118
|
+
org_id = resolve_org_id(ctx, org)
|
|
119
|
+
client = get_client(ctx)
|
|
120
|
+
body = {"name": name, "orgId": org_id}
|
|
121
|
+
if slug:
|
|
122
|
+
body["slug"] = slug
|
|
123
|
+
data = client.post("/api/projects", json=body)
|
|
124
|
+
project = data.get("project", data) if isinstance(data, dict) else data
|
|
125
|
+
|
|
126
|
+
def render(p):
|
|
127
|
+
output.success(f"Created project [bold]{p.get('name')}[/bold]")
|
|
128
|
+
output.console.print(f" ID: {p.get('id')}")
|
|
129
|
+
|
|
130
|
+
output.emit(ctx.obj, project, render)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""`openhack-cli scans` — list scans, inspect a scan, and trigger full scans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import output
|
|
8
|
+
from ..context import get_client, resolve_project_id
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def scans() -> None:
|
|
13
|
+
"""List and inspect security scans."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@scans.command(name="list")
|
|
17
|
+
@click.argument("project", required=False)
|
|
18
|
+
@click.option("--limit", default=20, show_default=True, help="Max scans to return.")
|
|
19
|
+
@click.option("--offset", default=0, show_default=True, help="Pagination offset.")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def list_scans(ctx: click.Context, project: str | None, limit: int, offset: int) -> None:
|
|
22
|
+
"""List scans for a project (defaults to the active project)."""
|
|
23
|
+
project_id = resolve_project_id(ctx, project)
|
|
24
|
+
client = get_client(ctx)
|
|
25
|
+
data = client.get(
|
|
26
|
+
f"/api/projects/{project_id}/scans",
|
|
27
|
+
params={"limit": limit, "offset": offset},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def render(payload):
|
|
31
|
+
scan_rows = payload.get("scans", []) if isinstance(payload, dict) else payload
|
|
32
|
+
if not scan_rows:
|
|
33
|
+
output.warn("No scans found.")
|
|
34
|
+
return
|
|
35
|
+
table = output.make_table(
|
|
36
|
+
"Scans",
|
|
37
|
+
["#", "Type", "Status", "Branch/PR", "Findings",
|
|
38
|
+
"C", "H", "M", "L", "Created"],
|
|
39
|
+
)
|
|
40
|
+
for s in scan_rows:
|
|
41
|
+
ref = s.get("branch") or (
|
|
42
|
+
f"PR #{s['prNumber']}" if s.get("prNumber") else "-"
|
|
43
|
+
)
|
|
44
|
+
table.add_row(
|
|
45
|
+
str(s.get("number", "-")),
|
|
46
|
+
s.get("scanType") or "-",
|
|
47
|
+
output.status_label(s.get("scanStatus")),
|
|
48
|
+
output.short(ref, 24),
|
|
49
|
+
str(s.get("vulnerabilityCount", 0)),
|
|
50
|
+
_count(s.get("criticalCount"), "bright_red"),
|
|
51
|
+
_count(s.get("highCount"), "red"),
|
|
52
|
+
_count(s.get("mediumCount"), "yellow"),
|
|
53
|
+
_count(s.get("lowCount"), "cyan"),
|
|
54
|
+
output.short(s.get("createdAt"), 19),
|
|
55
|
+
)
|
|
56
|
+
output.console.print(table)
|
|
57
|
+
if isinstance(payload, dict) and payload.get("consolidatedCounts"):
|
|
58
|
+
cc = payload["consolidatedCounts"]
|
|
59
|
+
output.console.print(
|
|
60
|
+
f"\n[dim]Consolidated:[/dim] {cc.get('total', 0)} total — "
|
|
61
|
+
f"{output.severity_label('critical')} {cc.get('critical', 0)} "
|
|
62
|
+
f"{output.severity_label('high')} {cc.get('high', 0)} "
|
|
63
|
+
f"{output.severity_label('medium')} {cc.get('medium', 0)} "
|
|
64
|
+
f"{output.severity_label('low')} {cc.get('low', 0)}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
output.emit(ctx.obj, data, render)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@scans.command(name="get")
|
|
71
|
+
@click.argument("scan_id")
|
|
72
|
+
@click.option("--project", default=None, help="Project id (defaults to active).")
|
|
73
|
+
@click.option("--findings/--no-findings", default=True,
|
|
74
|
+
help="Show the per-finding breakdown.")
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def get_scan(ctx: click.Context, scan_id: str, project: str | None,
|
|
77
|
+
findings: bool) -> None:
|
|
78
|
+
"""Show a single scan and its findings."""
|
|
79
|
+
project_id = resolve_project_id(ctx, project)
|
|
80
|
+
client = get_client(ctx)
|
|
81
|
+
data = client.get(f"/api/projects/{project_id}/scans/{scan_id}")
|
|
82
|
+
|
|
83
|
+
def render(payload):
|
|
84
|
+
s = payload.get("scan", payload) if isinstance(payload, dict) else payload
|
|
85
|
+
output.console.print(
|
|
86
|
+
f"[bold]Scan #{s.get('number')}[/bold] "
|
|
87
|
+
f"{output.status_label(s.get('scanStatus'))}"
|
|
88
|
+
)
|
|
89
|
+
if s.get("prNumber"):
|
|
90
|
+
output.console.print(
|
|
91
|
+
f" PR: #{s['prNumber']} {s.get('prTitle') or ''}"
|
|
92
|
+
)
|
|
93
|
+
output.console.print(
|
|
94
|
+
f" Findings: {s.get('vulnerabilityCount', 0)} — "
|
|
95
|
+
f"{output.severity_label('critical')} {s.get('criticalCount', 0)} "
|
|
96
|
+
f"{output.severity_label('high')} {s.get('highCount', 0)} "
|
|
97
|
+
f"{output.severity_label('medium')} {s.get('mediumCount', 0)} "
|
|
98
|
+
f"{output.severity_label('low')} {s.get('lowCount', 0)}"
|
|
99
|
+
)
|
|
100
|
+
if s.get("failureReason"):
|
|
101
|
+
output.error(f" Failure: {s['failureReason']}")
|
|
102
|
+
if findings and s.get("findings"):
|
|
103
|
+
table = output.make_table(
|
|
104
|
+
None, ["#", "Severity", "Title", "Location"]
|
|
105
|
+
)
|
|
106
|
+
for f in sorted(
|
|
107
|
+
s["findings"],
|
|
108
|
+
key=lambda f: output.SEVERITY_ORDER.get(
|
|
109
|
+
(f.get("severity") or "").lower(), 9
|
|
110
|
+
),
|
|
111
|
+
):
|
|
112
|
+
loc = f.get("filePath") or "-"
|
|
113
|
+
if f.get("lineNumber"):
|
|
114
|
+
loc = f"{loc}:{f['lineNumber']}"
|
|
115
|
+
table.add_row(
|
|
116
|
+
str(f.get("number", "-")),
|
|
117
|
+
output.severity_label(f.get("severity")),
|
|
118
|
+
output.short(f.get("title"), 60),
|
|
119
|
+
output.short(loc, 40),
|
|
120
|
+
)
|
|
121
|
+
output.console.print(table)
|
|
122
|
+
|
|
123
|
+
output.emit(ctx.obj, data, render)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@scans.command(name="trigger-full")
|
|
127
|
+
@click.argument("project", required=False)
|
|
128
|
+
@click.option("--branch", default=None,
|
|
129
|
+
help="Branch to scan (defaults to the repo default branch).")
|
|
130
|
+
@click.pass_context
|
|
131
|
+
def trigger_full(ctx: click.Context, project: str | None, branch: str | None) -> None:
|
|
132
|
+
"""Trigger a full-repository scan."""
|
|
133
|
+
project_id = resolve_project_id(ctx, project)
|
|
134
|
+
client = get_client(ctx)
|
|
135
|
+
body = {}
|
|
136
|
+
if branch:
|
|
137
|
+
body["branch"] = branch
|
|
138
|
+
data = client.post(f"/api/projects/{project_id}/scans/trigger-full", json=body)
|
|
139
|
+
|
|
140
|
+
def render(payload):
|
|
141
|
+
output.success(payload.get("message", "Full scan triggered."))
|
|
142
|
+
if payload.get("scanId"):
|
|
143
|
+
output.console.print(f" Scan ID: {payload['scanId']}")
|
|
144
|
+
if payload.get("branch"):
|
|
145
|
+
output.console.print(f" Branch: {payload['branch']}")
|
|
146
|
+
|
|
147
|
+
output.emit(ctx.obj, data, render)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _count(value, color: str) -> str:
|
|
151
|
+
value = value or 0
|
|
152
|
+
if not value:
|
|
153
|
+
return "[dim]0[/dim]"
|
|
154
|
+
return f"[{color}]{value}[/{color}]"
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""`openhack-cli vulns` — view vulnerabilities (findings) and triage groups.
|
|
2
|
+
|
|
3
|
+
Actual finding details (title, severity, location) are sourced from scans, since
|
|
4
|
+
that's what the CLI token can read. `vulns list` aggregates findings across a
|
|
5
|
+
project's recent scans and de-duplicates by fingerprint, mirroring the dashboard
|
|
6
|
+
vulnerability view. `vulns groups` shows the triage-status groups.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from .. import output
|
|
17
|
+
from ..context import get_client, resolve_project_id
|
|
18
|
+
|
|
19
|
+
VULN_SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
20
|
+
VULN_STATUSES = ["new", "triaged", "in_progress", "fixed", "verified", "closed",
|
|
21
|
+
"wont_fix", "false_positive"]
|
|
22
|
+
|
|
23
|
+
# Scalar CLI flag (dest) -> API body field for report/edit.
|
|
24
|
+
VULN_SCALAR_FIELDS = {
|
|
25
|
+
"title": "title",
|
|
26
|
+
"severity": "severity",
|
|
27
|
+
"description": "description",
|
|
28
|
+
"impact": "impact",
|
|
29
|
+
"poc": "poc",
|
|
30
|
+
"recommendation": "recommendation",
|
|
31
|
+
"category": "category",
|
|
32
|
+
"affected_component": "affectedComponent",
|
|
33
|
+
"cwe": "cweId",
|
|
34
|
+
"cvss_score": "cvssScore",
|
|
35
|
+
"cvss_vector": "cvssVector",
|
|
36
|
+
"severity_justification": "severityJustification",
|
|
37
|
+
"prerequisites": "prerequisites",
|
|
38
|
+
"relevant_code": "relevantCode",
|
|
39
|
+
"file_path": "filePath",
|
|
40
|
+
"line_number": "lineNumber",
|
|
41
|
+
"vulnerability_type": "vulnerabilityType",
|
|
42
|
+
"status": "status",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group()
|
|
47
|
+
def vulns() -> None:
|
|
48
|
+
"""List, report, and edit project vulnerabilities."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@vulns.command(name="list")
|
|
52
|
+
@click.argument("project", required=False)
|
|
53
|
+
@click.option("--severity", default=None,
|
|
54
|
+
type=click.Choice(["critical", "high", "medium", "low", "info"]),
|
|
55
|
+
help="Filter by severity.")
|
|
56
|
+
@click.option("--scan-limit", default=20, show_default=True,
|
|
57
|
+
help="How many recent scans to aggregate findings from.")
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def list_vulns(ctx: click.Context, project: str | None, severity: str | None,
|
|
60
|
+
scan_limit: int) -> None:
|
|
61
|
+
"""List vulnerabilities across a project's recent scans (de-duplicated)."""
|
|
62
|
+
project_id = resolve_project_id(ctx, project)
|
|
63
|
+
client = get_client(ctx)
|
|
64
|
+
data = client.get(
|
|
65
|
+
f"/api/projects/{project_id}/scans", params={"limit": scan_limit}
|
|
66
|
+
)
|
|
67
|
+
scan_rows = data.get("scans", []) if isinstance(data, dict) else (data or [])
|
|
68
|
+
|
|
69
|
+
# De-dupe findings across scans by fingerprint (fall back to id).
|
|
70
|
+
seen: dict[str, dict] = {}
|
|
71
|
+
for s in scan_rows:
|
|
72
|
+
for f in s.get("findings", []) or []:
|
|
73
|
+
key = f.get("fingerprint") or f.get("id")
|
|
74
|
+
if key and key not in seen:
|
|
75
|
+
enriched = dict(f)
|
|
76
|
+
enriched["_scan"] = s.get("number")
|
|
77
|
+
seen[key] = enriched
|
|
78
|
+
items = list(seen.values())
|
|
79
|
+
if severity:
|
|
80
|
+
items = [f for f in items if (f.get("severity") or "").lower() == severity]
|
|
81
|
+
items.sort(key=lambda f: output.SEVERITY_ORDER.get(
|
|
82
|
+
(f.get("severity") or "").lower(), 9))
|
|
83
|
+
|
|
84
|
+
def render(rows):
|
|
85
|
+
if not rows:
|
|
86
|
+
output.warn("No vulnerabilities found.")
|
|
87
|
+
return
|
|
88
|
+
table = output.make_table(
|
|
89
|
+
"Vulnerabilities",
|
|
90
|
+
["Severity", "Title", "Location", "Category", "Scan"],
|
|
91
|
+
)
|
|
92
|
+
for f in rows:
|
|
93
|
+
loc = f.get("filePath") or "-"
|
|
94
|
+
if f.get("lineNumber"):
|
|
95
|
+
loc = f"{loc}:{f['lineNumber']}"
|
|
96
|
+
table.add_row(
|
|
97
|
+
output.severity_label(f.get("severity")),
|
|
98
|
+
output.short(f.get("title"), 56),
|
|
99
|
+
output.short(loc, 36),
|
|
100
|
+
output.short(f.get("category"), 18),
|
|
101
|
+
f"#{f['_scan']}" if f.get("_scan") else "-",
|
|
102
|
+
)
|
|
103
|
+
output.console.print(table)
|
|
104
|
+
output.console.print(f"\n[dim]{len(rows)} unique vulnerabilities[/dim]")
|
|
105
|
+
|
|
106
|
+
output.emit(ctx.obj, items, render)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@vulns.command(name="groups")
|
|
110
|
+
@click.argument("project", required=False)
|
|
111
|
+
@click.pass_context
|
|
112
|
+
def list_groups(ctx: click.Context, project: str | None) -> None:
|
|
113
|
+
"""List vulnerability triage groups and their status."""
|
|
114
|
+
project_id = resolve_project_id(ctx, project)
|
|
115
|
+
client = get_client(ctx)
|
|
116
|
+
data = client.get(f"/api/projects/{project_id}/vulnerability-groups")
|
|
117
|
+
groups = data.get("groups", []) if isinstance(data, dict) else (data or [])
|
|
118
|
+
|
|
119
|
+
def render(rows):
|
|
120
|
+
if not rows:
|
|
121
|
+
output.warn("No vulnerability groups found.")
|
|
122
|
+
return
|
|
123
|
+
table = output.make_table(
|
|
124
|
+
"Vulnerability Groups",
|
|
125
|
+
["Group", "Status", "Assignee", "Updated"],
|
|
126
|
+
)
|
|
127
|
+
for g in rows:
|
|
128
|
+
assignee = (g.get("assignee") or {}).get("displayName") or "-"
|
|
129
|
+
table.add_row(
|
|
130
|
+
output.short(g.get("groupKey"), 40),
|
|
131
|
+
output.status_label(g.get("status")),
|
|
132
|
+
output.short(assignee, 20),
|
|
133
|
+
output.short(g.get("updatedAt"), 19),
|
|
134
|
+
)
|
|
135
|
+
output.console.print(table)
|
|
136
|
+
|
|
137
|
+
output.emit(ctx.obj, groups, render)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
141
|
+
# Reporting / editing manual vulnerabilities on a project's Vulnerabilities page
|
|
142
|
+
# --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _vuln_field_options(fn):
|
|
145
|
+
"""Shared vulnerability-field flags for `vulns report` and `vulns edit`.
|
|
146
|
+
|
|
147
|
+
``--code-path``, ``--endpoint`` and ``--parameter`` are repeatable and map to
|
|
148
|
+
the array fields ``codePaths`` / ``affectedEndpoints`` / ``affectedParameter``.
|
|
149
|
+
"""
|
|
150
|
+
options = [
|
|
151
|
+
click.option("--from-file", "from_file", default=None,
|
|
152
|
+
help="Load fields from a JSON file ('-' for stdin). "
|
|
153
|
+
"Keys are API field names (camelCase). Flags override."),
|
|
154
|
+
click.option("--title", default=None, help="Vulnerability title."),
|
|
155
|
+
click.option("--severity", default=None, type=click.Choice(VULN_SEVERITIES),
|
|
156
|
+
help="Severity (default: medium on create)."),
|
|
157
|
+
click.option("--description", default=None),
|
|
158
|
+
click.option("--impact", default=None),
|
|
159
|
+
click.option("--poc", default=None, help="Proof of concept (markdown)."),
|
|
160
|
+
click.option("--recommendation", default=None),
|
|
161
|
+
click.option("--category", default=None,
|
|
162
|
+
help='Category label, e.g. "SQLi", "XSS", "IDOR".'),
|
|
163
|
+
click.option("--code-path", "code_paths", multiple=True,
|
|
164
|
+
help="Source location as path:line (repeatable)."),
|
|
165
|
+
click.option("--endpoint", "endpoints", multiple=True,
|
|
166
|
+
help="Affected endpoint/URL (repeatable)."),
|
|
167
|
+
click.option("--parameter", "parameters", multiple=True,
|
|
168
|
+
help="Affected parameter (repeatable)."),
|
|
169
|
+
click.option("--affected-component", default=None,
|
|
170
|
+
help='e.g. "API", "Frontend", "Database".'),
|
|
171
|
+
click.option("--cwe", default=None, help="CWE id, e.g. CWE-89."),
|
|
172
|
+
click.option("--cvss-score", default=None, help="CVSS 3.1 score, e.g. 8.6."),
|
|
173
|
+
click.option("--cvss-vector", default=None),
|
|
174
|
+
click.option("--severity-justification", default=None,
|
|
175
|
+
help="Reasoning when severity differs from the CVSS band."),
|
|
176
|
+
click.option("--prerequisites", default=None,
|
|
177
|
+
help="What's needed to exploit this."),
|
|
178
|
+
click.option("--relevant-code", default=None),
|
|
179
|
+
click.option("--file-path", default=None),
|
|
180
|
+
click.option("--line-number", default=None, type=int),
|
|
181
|
+
click.option("--vulnerability-type", default=None),
|
|
182
|
+
click.option("--status", default=None, type=click.Choice(VULN_STATUSES),
|
|
183
|
+
help="Workflow status (default: new on create)."),
|
|
184
|
+
]
|
|
185
|
+
for option in reversed(options):
|
|
186
|
+
fn = option(fn)
|
|
187
|
+
return fn
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _build_vuln_body(from_file, flags, code_paths, endpoints, parameters) -> dict:
|
|
191
|
+
"""Assemble a vulnerability body from an optional JSON file + flag overrides."""
|
|
192
|
+
body: dict = {}
|
|
193
|
+
if from_file:
|
|
194
|
+
try:
|
|
195
|
+
raw = sys.stdin.read() if from_file == "-" else open(from_file).read()
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise click.ClickException(f"Cannot read --from-file: {exc}")
|
|
198
|
+
try:
|
|
199
|
+
loaded = json.loads(raw)
|
|
200
|
+
except json.JSONDecodeError as exc:
|
|
201
|
+
raise click.ClickException(f"--from-file is not valid JSON: {exc}")
|
|
202
|
+
if not isinstance(loaded, dict):
|
|
203
|
+
raise click.ClickException("--from-file must contain a JSON object.")
|
|
204
|
+
body.update(loaded)
|
|
205
|
+
# Scalar flags override JSON.
|
|
206
|
+
for dest, key in VULN_SCALAR_FIELDS.items():
|
|
207
|
+
val = flags.get(dest)
|
|
208
|
+
if val is not None:
|
|
209
|
+
body[key] = val
|
|
210
|
+
# Repeatable array flags.
|
|
211
|
+
if code_paths:
|
|
212
|
+
body["codePaths"] = list(code_paths)
|
|
213
|
+
if endpoints:
|
|
214
|
+
body["affectedEndpoints"] = list(endpoints)
|
|
215
|
+
if parameters:
|
|
216
|
+
# API expects affectedParameter as a JSON-encoded array string.
|
|
217
|
+
body["affectedParameter"] = json.dumps(list(parameters))
|
|
218
|
+
return body
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_paths(value) -> list[str]:
|
|
222
|
+
"""filePath may be a plain string or a JSON-encoded array; normalize to a list."""
|
|
223
|
+
if not value:
|
|
224
|
+
return []
|
|
225
|
+
if isinstance(value, list):
|
|
226
|
+
return value
|
|
227
|
+
s = str(value)
|
|
228
|
+
if s.startswith("["):
|
|
229
|
+
try:
|
|
230
|
+
parsed = json.loads(s)
|
|
231
|
+
return parsed if isinstance(parsed, list) else [s]
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
return [s]
|
|
234
|
+
return [s]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@vulns.command(name="report")
|
|
238
|
+
@click.option("--project", default=None,
|
|
239
|
+
help="Project id (defaults to the active project).")
|
|
240
|
+
@_vuln_field_options
|
|
241
|
+
@click.pass_context
|
|
242
|
+
def report_vuln(ctx: click.Context, project: str | None, from_file: str | None,
|
|
243
|
+
code_paths, endpoints, parameters, **flags) -> None:
|
|
244
|
+
"""Report a new vulnerability to a project's Vulnerabilities page.
|
|
245
|
+
|
|
246
|
+
Provide fields via flags or --from-file (flags override file keys). The
|
|
247
|
+
vulnerability appears immediately in the web UI with scanSource "manual".
|
|
248
|
+
"""
|
|
249
|
+
project_id = resolve_project_id(ctx, project)
|
|
250
|
+
client = get_client(ctx)
|
|
251
|
+
|
|
252
|
+
body = _build_vuln_body(from_file, flags, code_paths, endpoints, parameters)
|
|
253
|
+
if not body.get("title"):
|
|
254
|
+
raise click.ClickException("Title is required (--title or 'title' in --from-file).")
|
|
255
|
+
|
|
256
|
+
res = client.post(f"/api/projects/{project_id}/vulnerabilities", json=body)
|
|
257
|
+
created = res.get("finding", res) if isinstance(res, dict) else res
|
|
258
|
+
|
|
259
|
+
def render(f):
|
|
260
|
+
output.success(
|
|
261
|
+
f"Reported vulnerability #{f.get('number')} "
|
|
262
|
+
f"[bold]{f.get('title')}[/bold] {output.severity_label(f.get('severity'))}"
|
|
263
|
+
)
|
|
264
|
+
output.console.print(f" ID: {f.get('id')}")
|
|
265
|
+
output.console.print(" [dim]Now visible on the project's Vulnerabilities page.[/dim]")
|
|
266
|
+
|
|
267
|
+
output.emit(ctx.obj, created, render)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@vulns.command(name="edit")
|
|
271
|
+
@click.argument("finding_id")
|
|
272
|
+
@click.option("--project", default=None,
|
|
273
|
+
help="Project id (defaults to the active project).")
|
|
274
|
+
@_vuln_field_options
|
|
275
|
+
@click.pass_context
|
|
276
|
+
def edit_vuln(ctx: click.Context, finding_id: str, project: str | None,
|
|
277
|
+
from_file: str | None, code_paths, endpoints, parameters,
|
|
278
|
+
**flags) -> None:
|
|
279
|
+
"""Edit an existing vulnerability (PATCH; only sends the fields you pass)."""
|
|
280
|
+
project_id = resolve_project_id(ctx, project)
|
|
281
|
+
client = get_client(ctx)
|
|
282
|
+
|
|
283
|
+
body = _build_vuln_body(from_file, flags, code_paths, endpoints, parameters)
|
|
284
|
+
if not body:
|
|
285
|
+
raise click.ClickException(
|
|
286
|
+
"Nothing to update. Pass at least one field flag or --from-file."
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
res = client.patch(
|
|
290
|
+
f"/api/projects/{project_id}/findings/{finding_id}", json=body
|
|
291
|
+
)
|
|
292
|
+
updated = res.get("finding", res) if isinstance(res, dict) else res
|
|
293
|
+
|
|
294
|
+
def render(f):
|
|
295
|
+
output.success(
|
|
296
|
+
f"Updated vulnerability #{f.get('number')} [bold]{f.get('title')}[/bold]"
|
|
297
|
+
)
|
|
298
|
+
output.console.print(f" Fields changed: {', '.join(sorted(body.keys()))}")
|
|
299
|
+
|
|
300
|
+
output.emit(ctx.obj, updated, render)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@vulns.command(name="get")
|
|
304
|
+
@click.argument("finding_id")
|
|
305
|
+
@click.option("--project", default=None,
|
|
306
|
+
help="Project id (defaults to the active project).")
|
|
307
|
+
@click.pass_context
|
|
308
|
+
def get_vuln(ctx: click.Context, finding_id: str, project: str | None) -> None:
|
|
309
|
+
"""Show full details of a single vulnerability."""
|
|
310
|
+
project_id = resolve_project_id(ctx, project)
|
|
311
|
+
client = get_client(ctx)
|
|
312
|
+
res = client.get(f"/api/projects/{project_id}/findings/{finding_id}")
|
|
313
|
+
finding = res.get("finding", res) if isinstance(res, dict) else res
|
|
314
|
+
|
|
315
|
+
def render(_payload):
|
|
316
|
+
f = finding
|
|
317
|
+
output.console.print(
|
|
318
|
+
f"[bold]#{f.get('number')} {f.get('title')}[/bold] "
|
|
319
|
+
f"{output.severity_label(f.get('severity'))} "
|
|
320
|
+
f"{output.status_label(f.get('status'))}"
|
|
321
|
+
)
|
|
322
|
+
for label, key in [("Category", "category"), ("Component", "affectedComponent"),
|
|
323
|
+
("CWE", "cweId"), ("CVSS", "cvssScore"),
|
|
324
|
+
("Prerequisites", "prerequisites")]:
|
|
325
|
+
if f.get(key):
|
|
326
|
+
output.console.print(f" {label}: {f[key]}")
|
|
327
|
+
paths = _parse_paths(f.get("filePath"))
|
|
328
|
+
if paths:
|
|
329
|
+
output.console.print(f" Code paths: {', '.join(paths)}")
|
|
330
|
+
if f.get("affectedEndpoints"):
|
|
331
|
+
eps = f["affectedEndpoints"]
|
|
332
|
+
output.console.print(
|
|
333
|
+
f" Endpoints: {', '.join(eps) if isinstance(eps, list) else eps}"
|
|
334
|
+
)
|
|
335
|
+
for label, key in [("Description", "description"), ("Impact", "impact"),
|
|
336
|
+
("PoC", "poc"), ("Recommendation", "recommendation"),
|
|
337
|
+
("Severity justification", "severityJustification")]:
|
|
338
|
+
if f.get(key):
|
|
339
|
+
output.console.print(f"\n[bold]{label}[/bold]\n{f[key]}")
|
|
340
|
+
|
|
341
|
+
output.emit(ctx.obj, res, render)
|