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.
@@ -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)