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,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)