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,603 @@
|
|
|
1
|
+
"""`openhack-cli pentest` — manage pentesting engagements and their findings.
|
|
2
|
+
|
|
3
|
+
Engagements are organization-scoped. Findings live inside an engagement and
|
|
4
|
+
support full CRUD plus cross-references (links) between related findings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from .. import output
|
|
15
|
+
from ..client import APIError
|
|
16
|
+
from ..context import get_client, resolve_org_id
|
|
17
|
+
|
|
18
|
+
# Allowed relationship types for linking findings.
|
|
19
|
+
LINK_TYPES = ["related", "chains", "duplicate", "depends"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
def pentest() -> None:
|
|
24
|
+
"""Manage pentesting engagements."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pentest.command(name="list")
|
|
28
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
29
|
+
@click.pass_context
|
|
30
|
+
def list_engagements(ctx: click.Context, org: str | None) -> None:
|
|
31
|
+
"""List pentesting engagements for an organization."""
|
|
32
|
+
org_id = resolve_org_id(ctx, org)
|
|
33
|
+
client = get_client(ctx)
|
|
34
|
+
data = client.get(f"/api/orgs/{org_id}/pentesting")
|
|
35
|
+
items = data.get("engagements", []) if isinstance(data, dict) else (data or [])
|
|
36
|
+
|
|
37
|
+
def render(rows):
|
|
38
|
+
if not rows:
|
|
39
|
+
output.warn("No pentesting engagements found.")
|
|
40
|
+
return
|
|
41
|
+
table = output.make_table(
|
|
42
|
+
"Pentesting Engagements",
|
|
43
|
+
["Title", "Status", "Findings", "C", "H", "M", "L", "ID"],
|
|
44
|
+
)
|
|
45
|
+
for e in rows:
|
|
46
|
+
table.add_row(
|
|
47
|
+
output.short(e.get("title"), 36),
|
|
48
|
+
output.status_label(e.get("status")),
|
|
49
|
+
str(e.get("findingCount", 0)),
|
|
50
|
+
str(e.get("criticalCount", 0)),
|
|
51
|
+
str(e.get("highCount", 0)),
|
|
52
|
+
str(e.get("mediumCount", 0)),
|
|
53
|
+
str(e.get("lowCount", 0)),
|
|
54
|
+
output.short(e.get("id"), 38),
|
|
55
|
+
)
|
|
56
|
+
output.console.print(table)
|
|
57
|
+
|
|
58
|
+
output.emit(ctx.obj, items, render)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pentest.command(name="get")
|
|
62
|
+
@click.argument("engagement_id")
|
|
63
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
64
|
+
@click.pass_context
|
|
65
|
+
def get_engagement(ctx: click.Context, engagement_id: str, org: str | None) -> None:
|
|
66
|
+
"""Show a pentesting engagement and its members."""
|
|
67
|
+
org_id = resolve_org_id(ctx, org)
|
|
68
|
+
client = get_client(ctx)
|
|
69
|
+
data = client.get(f"/api/orgs/{org_id}/pentesting/{engagement_id}")
|
|
70
|
+
|
|
71
|
+
def render(payload):
|
|
72
|
+
e = payload.get("engagement", payload) if isinstance(payload, dict) else payload
|
|
73
|
+
output.console.print(f"[bold]{e.get('title')}[/bold] "
|
|
74
|
+
f"{output.status_label(e.get('status'))}")
|
|
75
|
+
output.console.print(f" ID: {e.get('id')}")
|
|
76
|
+
if e.get("companyName"):
|
|
77
|
+
output.console.print(f" Company: {e['companyName']}")
|
|
78
|
+
if e.get("scope"):
|
|
79
|
+
output.console.print(f" Scope: {output.short(e['scope'], 80)}")
|
|
80
|
+
output.console.print(
|
|
81
|
+
f" Findings: {e.get('findingCount', 0)} — "
|
|
82
|
+
f"{output.severity_label('critical')} {e.get('criticalCount', 0)} "
|
|
83
|
+
f"{output.severity_label('high')} {e.get('highCount', 0)} "
|
|
84
|
+
f"{output.severity_label('medium')} {e.get('mediumCount', 0)} "
|
|
85
|
+
f"{output.severity_label('low')} {e.get('lowCount', 0)} "
|
|
86
|
+
f"{output.severity_label('info')} {e.get('infoCount', 0)}"
|
|
87
|
+
)
|
|
88
|
+
members = payload.get("members", []) if isinstance(payload, dict) else []
|
|
89
|
+
if members:
|
|
90
|
+
output.console.print(f" Members: {len(members)}")
|
|
91
|
+
|
|
92
|
+
output.emit(ctx.obj, data, render)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pentest.command(name="findings")
|
|
96
|
+
@click.argument("engagement_id", required=False)
|
|
97
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
98
|
+
@click.option("--severity", default=None,
|
|
99
|
+
type=click.Choice(["critical", "high", "medium", "low", "info"]),
|
|
100
|
+
help="Filter by severity.")
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def list_findings(ctx: click.Context, engagement_id: str | None,
|
|
103
|
+
org: str | None, severity: str | None) -> None:
|
|
104
|
+
"""List vulnerabilities (findings) in a pentest engagement.
|
|
105
|
+
|
|
106
|
+
With no ENGAGEMENT_ID, uses the most recent engagement in the org.
|
|
107
|
+
"""
|
|
108
|
+
org_id = resolve_org_id(ctx, org)
|
|
109
|
+
client = get_client(ctx)
|
|
110
|
+
|
|
111
|
+
# Default to the latest engagement (most recently created).
|
|
112
|
+
if not engagement_id:
|
|
113
|
+
data = client.get(f"/api/orgs/{org_id}/pentesting")
|
|
114
|
+
engagements = data.get("engagements", []) if isinstance(data, dict) else (data or [])
|
|
115
|
+
if not engagements:
|
|
116
|
+
raise click.ClickException("No pentest engagements in this org.")
|
|
117
|
+
latest = max(engagements, key=lambda e: e.get("createdAt") or "")
|
|
118
|
+
engagement_id = latest["id"]
|
|
119
|
+
if not ctx.obj.get("json"):
|
|
120
|
+
output.info(f"Latest engagement: [bold]{latest.get('title')}[/bold]")
|
|
121
|
+
|
|
122
|
+
res = client.get(f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings")
|
|
123
|
+
items = res.get("findings", []) if isinstance(res, dict) else (res or [])
|
|
124
|
+
if severity:
|
|
125
|
+
items = [f for f in items if (f.get("severity") or "").lower() == severity]
|
|
126
|
+
items.sort(key=lambda f: output.SEVERITY_ORDER.get(
|
|
127
|
+
(f.get("severity") or "").lower(), 9))
|
|
128
|
+
|
|
129
|
+
def render(rows):
|
|
130
|
+
if not rows:
|
|
131
|
+
output.warn("No findings in this engagement.")
|
|
132
|
+
return
|
|
133
|
+
table = output.make_table(
|
|
134
|
+
"Pentest Findings",
|
|
135
|
+
["#", "Severity", "Title", "Category", "Component", "CVSS", "Status"],
|
|
136
|
+
)
|
|
137
|
+
for f in rows:
|
|
138
|
+
table.add_row(
|
|
139
|
+
str(f.get("number", "-")),
|
|
140
|
+
output.severity_label(f.get("severity")),
|
|
141
|
+
output.short(f.get("title"), 46),
|
|
142
|
+
output.short(f.get("category"), 16),
|
|
143
|
+
output.short(f.get("affectedComponent"), 18),
|
|
144
|
+
str(f.get("cvssScore") or "-"),
|
|
145
|
+
output.status_label(f.get("status")),
|
|
146
|
+
)
|
|
147
|
+
output.console.print(table)
|
|
148
|
+
output.console.print(f"\n[dim]{len(rows)} findings[/dim]")
|
|
149
|
+
|
|
150
|
+
output.emit(ctx.obj, items, render)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pentest.group(name="finding")
|
|
154
|
+
def finding() -> None:
|
|
155
|
+
"""Get or create individual pentest findings."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Maps CLI flags -> API request body fields for finding creation.
|
|
159
|
+
_FINDING_FIELDS = {
|
|
160
|
+
"title": "title",
|
|
161
|
+
"severity": "severity",
|
|
162
|
+
"description": "description",
|
|
163
|
+
"impact": "impact",
|
|
164
|
+
"poc": "poc",
|
|
165
|
+
"recommendation": "recommendation",
|
|
166
|
+
"relevant_code": "relevantCode",
|
|
167
|
+
"category": "category",
|
|
168
|
+
"cwe": "cweId",
|
|
169
|
+
"cvss_score": "cvssScore",
|
|
170
|
+
"cvss_vector": "cvssVector",
|
|
171
|
+
"affected_url": "affectedUrl",
|
|
172
|
+
"affected_parameter": "affectedParameter",
|
|
173
|
+
"affected_component": "affectedComponent",
|
|
174
|
+
"severity_justification": "severityJustification",
|
|
175
|
+
"internal_notes": "internalNotes",
|
|
176
|
+
"auth_required": "authRequired",
|
|
177
|
+
"unguessable_parameter_required": "unguessableParameterRequired",
|
|
178
|
+
"prerequisites": "prerequisites",
|
|
179
|
+
"status": "status",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@finding.command(name="get")
|
|
184
|
+
@click.argument("engagement_id")
|
|
185
|
+
@click.argument("finding_id")
|
|
186
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
187
|
+
@click.pass_context
|
|
188
|
+
def get_finding(ctx: click.Context, engagement_id: str, finding_id: str,
|
|
189
|
+
org: str | None) -> None:
|
|
190
|
+
"""Show full details of a single pentest finding."""
|
|
191
|
+
org_id = resolve_org_id(ctx, org)
|
|
192
|
+
client = get_client(ctx)
|
|
193
|
+
res = client.get(
|
|
194
|
+
f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings/{finding_id}"
|
|
195
|
+
)
|
|
196
|
+
finding = res.get("finding", res) if isinstance(res, dict) else res
|
|
197
|
+
reporter = res.get("reporter") if isinstance(res, dict) else None
|
|
198
|
+
# relatedFindings may live on the finding or alongside it, depending on the API.
|
|
199
|
+
related = (finding.get("relatedFindings") if isinstance(finding, dict) else None) \
|
|
200
|
+
or (res.get("relatedFindings") if isinstance(res, dict) else None) or []
|
|
201
|
+
|
|
202
|
+
def render(_payload):
|
|
203
|
+
f = finding
|
|
204
|
+
output.console.print(
|
|
205
|
+
f"[bold]#{f.get('number')} {f.get('title')}[/bold] "
|
|
206
|
+
f"{output.severity_label(f.get('severity'))}"
|
|
207
|
+
)
|
|
208
|
+
for label, key in [("Category", "category"), ("Component", "affectedComponent"),
|
|
209
|
+
("URL", "affectedUrl"), ("Parameter", "affectedParameter"),
|
|
210
|
+
("CWE", "cweId"), ("CVSS", "cvssScore"),
|
|
211
|
+
("Auth required", "authRequired"),
|
|
212
|
+
("Unguessable param", "unguessableParameterRequired"),
|
|
213
|
+
("Prerequisites", "prerequisites"),
|
|
214
|
+
("Status", "status")]:
|
|
215
|
+
if f.get(key) is not None:
|
|
216
|
+
output.console.print(f" {label}: {f[key]}")
|
|
217
|
+
if reporter:
|
|
218
|
+
who = reporter.get("displayName") or reporter.get("email") or "-"
|
|
219
|
+
email = f" <{reporter['email']}>" if reporter.get("email") else ""
|
|
220
|
+
output.console.print(f" Reporter: {who}{email}")
|
|
221
|
+
for label, key in [("Description", "description"), ("Impact", "impact"),
|
|
222
|
+
("PoC", "poc"), ("Recommendation", "recommendation"),
|
|
223
|
+
("Severity justification", "severityJustification")]:
|
|
224
|
+
if f.get(key):
|
|
225
|
+
output.console.print(f"\n[bold]{label}[/bold]\n{f[key]}")
|
|
226
|
+
if f.get("internalNotes"):
|
|
227
|
+
output.console.print(
|
|
228
|
+
f"\n[bold yellow]Internal notes[/bold yellow] "
|
|
229
|
+
f"[dim](not in report)[/dim]\n{f['internalNotes']}"
|
|
230
|
+
)
|
|
231
|
+
_render_related(related)
|
|
232
|
+
|
|
233
|
+
# JSON mode returns the full response (finding + reporter) per the spec.
|
|
234
|
+
output.emit(ctx.obj, res, render)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _finding_field_options(fn):
|
|
238
|
+
"""Shared finding-field flags for `finding create` and `finding update`.
|
|
239
|
+
|
|
240
|
+
Booleans are tri-state (default None) so an unset flag is never sent — the
|
|
241
|
+
fields stay optional and `update` only touches what you pass.
|
|
242
|
+
"""
|
|
243
|
+
options = [
|
|
244
|
+
click.option("--from-json", "from_json", default=None,
|
|
245
|
+
help="Load the body from a JSON file ('-' for stdin). "
|
|
246
|
+
"Keys are API field names (camelCase). Flags override."),
|
|
247
|
+
click.option("--title", default=None, help="Finding title."),
|
|
248
|
+
click.option("--severity", default=None,
|
|
249
|
+
type=click.Choice(["critical", "high", "medium", "low", "info"]),
|
|
250
|
+
help="Severity (analyst-assigned, independent of CVSS)."),
|
|
251
|
+
click.option("--severity-justification", default=None,
|
|
252
|
+
help="Business-impact reasoning for the chosen severity "
|
|
253
|
+
"(required by the server when severity doesn't match "
|
|
254
|
+
"the CVSS score's band)."),
|
|
255
|
+
click.option("--description", default=None),
|
|
256
|
+
click.option("--impact", default=None),
|
|
257
|
+
click.option("--poc", default=None, help="Proof of concept."),
|
|
258
|
+
click.option("--recommendation", default=None),
|
|
259
|
+
click.option("--relevant-code", default=None),
|
|
260
|
+
click.option("--category", default=None),
|
|
261
|
+
click.option("--cwe", default=None, help="CWE id, e.g. CWE-200."),
|
|
262
|
+
click.option("--cvss-score", default=None, help="CVSS score, e.g. 5.3."),
|
|
263
|
+
click.option("--cvss-vector", default=None),
|
|
264
|
+
click.option("--affected-url", default=None),
|
|
265
|
+
click.option("--affected-parameter", default=None),
|
|
266
|
+
click.option("--affected-component", default=None),
|
|
267
|
+
click.option("--auth-required/--no-auth-required", "auth_required",
|
|
268
|
+
default=None,
|
|
269
|
+
help="Whether authentication is required to exploit."),
|
|
270
|
+
click.option("--unguessable-parameter-required/"
|
|
271
|
+
"--no-unguessable-parameter-required",
|
|
272
|
+
"unguessable_parameter_required", default=None,
|
|
273
|
+
help="Whether an unguessable parameter (e.g. UUID) is required."),
|
|
274
|
+
click.option("--prerequisites", default=None,
|
|
275
|
+
help="Free-text description of other exploitation prerequisites."),
|
|
276
|
+
click.option("--internal-notes", default=None,
|
|
277
|
+
help="Internal notes — documented but excluded from the "
|
|
278
|
+
"client-facing PDF report (e.g. redaction reminders)."),
|
|
279
|
+
click.option("--related", default=None,
|
|
280
|
+
help="Comma-separated finding numbers/ids to link to this "
|
|
281
|
+
"finding (type 'related'). Use `finding link` for typed "
|
|
282
|
+
"or annotated links."),
|
|
283
|
+
click.option("--status", default=None, help="Finding status."),
|
|
284
|
+
]
|
|
285
|
+
for option in reversed(options):
|
|
286
|
+
fn = option(fn)
|
|
287
|
+
return fn
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_refs(value: str) -> list[str]:
|
|
291
|
+
"""Split a comma-separated --to/--related value into individual refs."""
|
|
292
|
+
return [r.strip() for r in str(value).split(",") if r.strip()]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _link_body(ref: str, link_type: str, note: str | None) -> dict:
|
|
296
|
+
"""Build a links-POST body. Numeric refs go as relatedFindingNumber."""
|
|
297
|
+
body: dict = {"type": link_type}
|
|
298
|
+
if note:
|
|
299
|
+
body["note"] = note
|
|
300
|
+
if ref.isdigit():
|
|
301
|
+
body["relatedFindingNumber"] = int(ref)
|
|
302
|
+
else:
|
|
303
|
+
body["relatedFindingId"] = ref
|
|
304
|
+
return body
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _apply_links(ctx, client, org_id: str, engagement_id: str, finding_id: str,
|
|
308
|
+
refs: list[str], link_type: str, note: str | None) -> list[dict]:
|
|
309
|
+
"""POST one link per ref; return per-ref outcomes (never raises on API error)."""
|
|
310
|
+
base = f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings/{finding_id}/links"
|
|
311
|
+
outcomes = []
|
|
312
|
+
for ref in refs:
|
|
313
|
+
try:
|
|
314
|
+
client.post(base, json=_link_body(ref, link_type, note))
|
|
315
|
+
outcomes.append({"ref": ref, "ok": True})
|
|
316
|
+
except APIError as exc:
|
|
317
|
+
outcomes.append({"ref": ref, "ok": False, "error": exc.message})
|
|
318
|
+
return outcomes
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _resolve_ref_to_id(client, org_id: str, engagement_id: str, ref: str,
|
|
322
|
+
_cache: dict) -> str:
|
|
323
|
+
"""Resolve a finding number to its id (for the DELETE path). Ids pass through."""
|
|
324
|
+
if not ref.isdigit():
|
|
325
|
+
return ref
|
|
326
|
+
if "by_number" not in _cache:
|
|
327
|
+
data = client.get(
|
|
328
|
+
f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings"
|
|
329
|
+
)
|
|
330
|
+
rows = data.get("findings", []) if isinstance(data, dict) else (data or [])
|
|
331
|
+
_cache["by_number"] = {str(f.get("number")): f.get("id") for f in rows}
|
|
332
|
+
fid = _cache["by_number"].get(ref)
|
|
333
|
+
if not fid:
|
|
334
|
+
raise click.ClickException(f"No finding #{ref} in this engagement.")
|
|
335
|
+
return fid
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _render_related(related: list[dict]) -> None:
|
|
339
|
+
"""Render the 'Related findings' section of a finding."""
|
|
340
|
+
if not related:
|
|
341
|
+
return
|
|
342
|
+
output.console.print("\n[bold]Related findings[/bold]")
|
|
343
|
+
for r in related:
|
|
344
|
+
note = f' ({r.get("type")}: "{r["note"]}")' if r.get("note") \
|
|
345
|
+
else (f' ({r.get("type")})' if r.get("type") else "")
|
|
346
|
+
output.console.print(
|
|
347
|
+
f" → #{r.get('number')} {output.severity_label(r.get('severity'))} "
|
|
348
|
+
f"{output.short(r.get('title'), 56)}{note}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _report_links(outcomes: list[dict]) -> None:
|
|
353
|
+
"""Report link outcomes to stderr (keeps --json stdout clean)."""
|
|
354
|
+
for o in outcomes:
|
|
355
|
+
if o["ok"]:
|
|
356
|
+
output.success(f"Linked → {o['ref']}")
|
|
357
|
+
else:
|
|
358
|
+
output.error(f"Link → {o['ref']} failed: {o['error']}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _build_finding_body(from_json: str | None, flags: dict) -> dict:
|
|
362
|
+
"""Assemble a finding request body from an optional JSON file + flag overrides."""
|
|
363
|
+
body: dict = {}
|
|
364
|
+
if from_json:
|
|
365
|
+
try:
|
|
366
|
+
raw = sys.stdin.read() if from_json == "-" else open(from_json).read()
|
|
367
|
+
except OSError as exc:
|
|
368
|
+
raise click.ClickException(f"Cannot read --from-json file: {exc}")
|
|
369
|
+
try:
|
|
370
|
+
loaded = json.loads(raw)
|
|
371
|
+
except json.JSONDecodeError as exc:
|
|
372
|
+
raise click.ClickException(f"--from-json is not valid JSON: {exc}")
|
|
373
|
+
if not isinstance(loaded, dict):
|
|
374
|
+
raise click.ClickException("--from-json must contain a JSON object.")
|
|
375
|
+
body.update(loaded)
|
|
376
|
+
# Flags override JSON; map CLI flag names -> API body keys.
|
|
377
|
+
for flag_name, api_key in _FINDING_FIELDS.items():
|
|
378
|
+
val = flags.get(flag_name)
|
|
379
|
+
if val is not None:
|
|
380
|
+
body[api_key] = val
|
|
381
|
+
return body
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@finding.command(name="create")
|
|
385
|
+
@click.argument("engagement_id")
|
|
386
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
387
|
+
@_finding_field_options
|
|
388
|
+
@click.pass_context
|
|
389
|
+
def create_finding(ctx: click.Context, engagement_id: str, org: str | None,
|
|
390
|
+
from_json: str | None, **flags) -> None:
|
|
391
|
+
"""Create a finding in a pentest engagement.
|
|
392
|
+
|
|
393
|
+
Supply fields via flags, or load a whole finding with --from-json (handy for
|
|
394
|
+
reproducing an existing finding verbatim). Flag values override JSON keys.
|
|
395
|
+
"""
|
|
396
|
+
org_id = resolve_org_id(ctx, org)
|
|
397
|
+
client = get_client(ctx)
|
|
398
|
+
|
|
399
|
+
body = _build_finding_body(from_json, flags)
|
|
400
|
+
if not body.get("title"):
|
|
401
|
+
raise click.ClickException("Title is required (--title or JSON 'title').")
|
|
402
|
+
if not body.get("severity"):
|
|
403
|
+
raise click.ClickException("Severity is required (--severity or JSON 'severity').")
|
|
404
|
+
|
|
405
|
+
res = client.post(
|
|
406
|
+
f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings", json=body
|
|
407
|
+
)
|
|
408
|
+
created = res.get("finding", res) if isinstance(res, dict) else res
|
|
409
|
+
|
|
410
|
+
# Optional one-shot linking via --related (type "related").
|
|
411
|
+
related = flags.get("related")
|
|
412
|
+
link_outcomes = (
|
|
413
|
+
_apply_links(ctx, client, org_id, engagement_id, created.get("id"),
|
|
414
|
+
_parse_refs(related), "related", None)
|
|
415
|
+
if related else []
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def render(f):
|
|
419
|
+
output.success(
|
|
420
|
+
f"Created finding #{f.get('number')} "
|
|
421
|
+
f"[bold]{f.get('title')}[/bold] {output.severity_label(f.get('severity'))}"
|
|
422
|
+
)
|
|
423
|
+
output.console.print(f" ID: {f.get('id')}")
|
|
424
|
+
|
|
425
|
+
output.emit(ctx.obj, created, render)
|
|
426
|
+
_report_links(link_outcomes)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@finding.command(name="update")
|
|
430
|
+
@click.argument("engagement_id")
|
|
431
|
+
@click.argument("finding_id")
|
|
432
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
433
|
+
@_finding_field_options
|
|
434
|
+
@click.pass_context
|
|
435
|
+
def update_finding(ctx: click.Context, engagement_id: str, finding_id: str,
|
|
436
|
+
org: str | None, from_json: str | None, **flags) -> None:
|
|
437
|
+
"""Update fields on an existing finding (PATCH; only sends what you pass).
|
|
438
|
+
|
|
439
|
+
Use this to enrich existing findings — e.g. set --auth-required,
|
|
440
|
+
--unguessable-parameter-required, and --prerequisites — without recreating
|
|
441
|
+
them.
|
|
442
|
+
"""
|
|
443
|
+
org_id = resolve_org_id(ctx, org)
|
|
444
|
+
client = get_client(ctx)
|
|
445
|
+
|
|
446
|
+
related = flags.get("related")
|
|
447
|
+
body = _build_finding_body(from_json, flags)
|
|
448
|
+
if not body and not related:
|
|
449
|
+
raise click.ClickException(
|
|
450
|
+
"Nothing to update. Pass at least one field flag, --related, or --from-json."
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
base = f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings/{finding_id}"
|
|
454
|
+
updated = {"id": finding_id}
|
|
455
|
+
if body:
|
|
456
|
+
res = client.patch(base, json=body)
|
|
457
|
+
updated = res.get("finding", res) if isinstance(res, dict) else res
|
|
458
|
+
|
|
459
|
+
link_outcomes = (
|
|
460
|
+
_apply_links(ctx, client, org_id, engagement_id, finding_id,
|
|
461
|
+
_parse_refs(related), "related", None)
|
|
462
|
+
if related else []
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def render(f):
|
|
466
|
+
if body:
|
|
467
|
+
output.success(
|
|
468
|
+
f"Updated finding #{f.get('number')} [bold]{f.get('title')}[/bold]"
|
|
469
|
+
)
|
|
470
|
+
output.console.print(f" Fields changed: {', '.join(sorted(body.keys()))}")
|
|
471
|
+
|
|
472
|
+
output.emit(ctx.obj, updated, render)
|
|
473
|
+
# Link outcomes (success/failure per ref) are reported on stderr below.
|
|
474
|
+
_report_links(link_outcomes)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@finding.command(name="delete")
|
|
478
|
+
@click.argument("engagement_id")
|
|
479
|
+
@click.argument("finding_id")
|
|
480
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
481
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
482
|
+
@click.pass_context
|
|
483
|
+
def delete_finding(ctx: click.Context, engagement_id: str, finding_id: str,
|
|
484
|
+
org: str | None, yes: bool) -> None:
|
|
485
|
+
"""Delete a finding from an engagement."""
|
|
486
|
+
org_id = resolve_org_id(ctx, org)
|
|
487
|
+
if not yes and not ctx.obj.get("json"):
|
|
488
|
+
click.confirm(f"Delete finding {finding_id}?", abort=True)
|
|
489
|
+
client = get_client(ctx)
|
|
490
|
+
res = client.delete(
|
|
491
|
+
f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings/{finding_id}"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def render(_payload):
|
|
495
|
+
output.success("Finding deleted.")
|
|
496
|
+
|
|
497
|
+
output.emit(ctx.obj, res or {"success": True}, render)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@finding.command(name="link")
|
|
501
|
+
@click.argument("engagement_id")
|
|
502
|
+
@click.argument("finding_id")
|
|
503
|
+
@click.option("--to", "to_refs", required=True,
|
|
504
|
+
help="Finding number(s)/id(s) to link to, comma-separated (e.g. 1,3).")
|
|
505
|
+
@click.option("--type", "link_type", type=click.Choice(LINK_TYPES),
|
|
506
|
+
default="related", show_default=True, help="Relationship type.")
|
|
507
|
+
@click.option("--note", default=None, help="Optional reason for the link.")
|
|
508
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
509
|
+
@click.pass_context
|
|
510
|
+
def link_finding(ctx: click.Context, engagement_id: str, finding_id: str,
|
|
511
|
+
to_refs: str, link_type: str, note: str | None,
|
|
512
|
+
org: str | None) -> None:
|
|
513
|
+
"""Link a finding to one or more related findings (bidirectional).
|
|
514
|
+
|
|
515
|
+
Links are mirrored by the server, so `link 12 --to 1` also makes finding 1
|
|
516
|
+
show finding 12. Refs may be finding numbers (preferred) or ids.
|
|
517
|
+
"""
|
|
518
|
+
org_id = resolve_org_id(ctx, org)
|
|
519
|
+
client = get_client(ctx)
|
|
520
|
+
refs = _parse_refs(to_refs)
|
|
521
|
+
if not refs:
|
|
522
|
+
raise click.ClickException("--to requires at least one finding number or id.")
|
|
523
|
+
|
|
524
|
+
outcomes = _apply_links(ctx, client, org_id, engagement_id, finding_id,
|
|
525
|
+
refs, link_type, note)
|
|
526
|
+
|
|
527
|
+
def render(rows):
|
|
528
|
+
for o in rows:
|
|
529
|
+
if o["ok"]:
|
|
530
|
+
output.success(f"Linked → {o['ref']} ({link_type})")
|
|
531
|
+
else:
|
|
532
|
+
output.error(f"Link → {o['ref']} failed: {o['error']}")
|
|
533
|
+
|
|
534
|
+
output.emit(ctx.obj, outcomes, render)
|
|
535
|
+
if any(not o["ok"] for o in outcomes):
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@finding.command(name="unlink")
|
|
540
|
+
@click.argument("engagement_id")
|
|
541
|
+
@click.argument("finding_id")
|
|
542
|
+
@click.option("--to", "to_refs", required=True,
|
|
543
|
+
help="Finding number(s)/id(s) to unlink, comma-separated.")
|
|
544
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
545
|
+
@click.pass_context
|
|
546
|
+
def unlink_finding(ctx: click.Context, engagement_id: str, finding_id: str,
|
|
547
|
+
to_refs: str, org: str | None) -> None:
|
|
548
|
+
"""Remove a link between findings (either direction)."""
|
|
549
|
+
org_id = resolve_org_id(ctx, org)
|
|
550
|
+
client = get_client(ctx)
|
|
551
|
+
refs = _parse_refs(to_refs)
|
|
552
|
+
if not refs:
|
|
553
|
+
raise click.ClickException("--to requires at least one finding number or id.")
|
|
554
|
+
|
|
555
|
+
base = f"/api/orgs/{org_id}/pentesting/{engagement_id}/findings/{finding_id}/links"
|
|
556
|
+
cache: dict = {}
|
|
557
|
+
outcomes = []
|
|
558
|
+
for ref in refs:
|
|
559
|
+
try:
|
|
560
|
+
related_id = _resolve_ref_to_id(client, org_id, engagement_id, ref, cache)
|
|
561
|
+
client.delete(f"{base}/{related_id}")
|
|
562
|
+
outcomes.append({"ref": ref, "ok": True})
|
|
563
|
+
except click.ClickException as exc:
|
|
564
|
+
outcomes.append({"ref": ref, "ok": False, "error": exc.message})
|
|
565
|
+
except APIError as exc:
|
|
566
|
+
outcomes.append({"ref": ref, "ok": False, "error": exc.message})
|
|
567
|
+
|
|
568
|
+
def render(rows):
|
|
569
|
+
for o in rows:
|
|
570
|
+
if o["ok"]:
|
|
571
|
+
output.success(f"Unlinked → {o['ref']}")
|
|
572
|
+
else:
|
|
573
|
+
output.error(f"Unlink → {o['ref']} failed: {o['error']}")
|
|
574
|
+
|
|
575
|
+
output.emit(ctx.obj, outcomes, render)
|
|
576
|
+
if any(not o["ok"] for o in outcomes):
|
|
577
|
+
sys.exit(1)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@pentest.command()
|
|
581
|
+
@click.option("--title", required=True, help="Engagement title.")
|
|
582
|
+
@click.option("--start-date", default=None, help="Start date (YYYY-MM-DD).")
|
|
583
|
+
@click.option("--end-date", default=None, help="End date (YYYY-MM-DD).")
|
|
584
|
+
@click.option("--org", default=None, help="Org id (defaults to active org).")
|
|
585
|
+
@click.pass_context
|
|
586
|
+
def create(ctx: click.Context, title: str, start_date: str | None,
|
|
587
|
+
end_date: str | None, org: str | None) -> None:
|
|
588
|
+
"""Create a new pentesting engagement."""
|
|
589
|
+
org_id = resolve_org_id(ctx, org)
|
|
590
|
+
client = get_client(ctx)
|
|
591
|
+
body: dict = {"title": title}
|
|
592
|
+
if start_date:
|
|
593
|
+
body["startDate"] = start_date
|
|
594
|
+
if end_date:
|
|
595
|
+
body["endDate"] = end_date
|
|
596
|
+
data = client.post(f"/api/orgs/{org_id}/pentesting", json=body)
|
|
597
|
+
engagement = data.get("engagement", data) if isinstance(data, dict) else data
|
|
598
|
+
|
|
599
|
+
def render(e):
|
|
600
|
+
output.success(f"Created engagement [bold]{e.get('title')}[/bold]")
|
|
601
|
+
output.console.print(f" ID: {e.get('id')}")
|
|
602
|
+
|
|
603
|
+
output.emit(ctx.obj, engagement, render)
|