cyberfw 2.0.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.
Files changed (91) hide show
  1. cyberfw/__init__.py +21 -0
  2. cyberfw/__main__.py +5 -0
  3. cyberfw/api.py +426 -0
  4. cyberfw/cli/__init__.py +28 -0
  5. cyberfw/cli/__main__.py +5 -0
  6. cyberfw/cli/app.py +13 -0
  7. cyberfw/cli/common.py +167 -0
  8. cyberfw/cli/launcher.py +500 -0
  9. cyberfw/cli/plugins.py +121 -0
  10. cyberfw/cli/reports.py +127 -0
  11. cyberfw/cli/scan.py +601 -0
  12. cyberfw/cli/schedule.py +151 -0
  13. cyberfw/cli/tools.py +409 -0
  14. cyberfw/cli/triage.py +110 -0
  15. cyberfw/cli/views.py +661 -0
  16. cyberfw/completion.py +126 -0
  17. cyberfw/config.py +199 -0
  18. cyberfw/core/__init__.py +7 -0
  19. cyberfw/core/events.py +126 -0
  20. cyberfw/core/finish.py +143 -0
  21. cyberfw/core/plugins.py +97 -0
  22. cyberfw/core/scan.py +274 -0
  23. cyberfw/core/sessions.py +84 -0
  24. cyberfw/core/steps.py +180 -0
  25. cyberfw/core/tool_run.py +110 -0
  26. cyberfw/core/workspace.py +106 -0
  27. cyberfw/data/pipelines/ports-to-vuln.yaml +29 -0
  28. cyberfw/data/registry.yaml +145 -0
  29. cyberfw/data/tools.lock +286 -0
  30. cyberfw/data/wordlists/common.txt +264 -0
  31. cyberfw/exceptions.py +130 -0
  32. cyberfw/hud.py +532 -0
  33. cyberfw/live_view.py +529 -0
  34. cyberfw/logging.py +103 -0
  35. cyberfw/manager/__init__.py +375 -0
  36. cyberfw/manager/archive.py +86 -0
  37. cyberfw/manager/chromium.py +229 -0
  38. cyberfw/manager/discovery.py +91 -0
  39. cyberfw/manager/github_client.py +368 -0
  40. cyberfw/manager/installer.py +378 -0
  41. cyberfw/manager/lock.py +376 -0
  42. cyberfw/manager/platform_map.py +113 -0
  43. cyberfw/manager/plugins.py +324 -0
  44. cyberfw/manager/registry.py +190 -0
  45. cyberfw/manager/verify.py +143 -0
  46. cyberfw/motion.py +213 -0
  47. cyberfw/pipeline/__init__.py +49 -0
  48. cyberfw/pipeline/context.py +144 -0
  49. cyberfw/pipeline/engine.py +605 -0
  50. cyberfw/pipeline/executor.py +537 -0
  51. cyberfw/pipeline/processes.py +314 -0
  52. cyberfw/pipeline/schemas.py +417 -0
  53. cyberfw/pipelines/__init__.py +74 -0
  54. cyberfw/pipelines/loader.py +125 -0
  55. cyberfw/pipelines/recon_to_vuln.py +50 -0
  56. cyberfw/plugin.py +243 -0
  57. cyberfw/posture/__init__.py +8 -0
  58. cyberfw/posture/mail.py +349 -0
  59. cyberfw/posture/takeover.py +271 -0
  60. cyberfw/posture/tls.py +208 -0
  61. cyberfw/report/__init__.py +6 -0
  62. cyberfw/report/csv_report.py +41 -0
  63. cyberfw/report/diff.py +509 -0
  64. cyberfw/report/html_report.py +1993 -0
  65. cyberfw/report/json_report.py +85 -0
  66. cyberfw/report/run_info.py +52 -0
  67. cyberfw/report/sarif.py +87 -0
  68. cyberfw/resources.py +67 -0
  69. cyberfw/schedule.py +214 -0
  70. cyberfw/threatintel.py +319 -0
  71. cyberfw/tools/__init__.py +69 -0
  72. cyberfw/tools/ffuf.py +62 -0
  73. cyberfw/tools/gitleaks.py +94 -0
  74. cyberfw/tools/gowitness.py +118 -0
  75. cyberfw/tools/httpx_tool.py +28 -0
  76. cyberfw/tools/manifest.py +177 -0
  77. cyberfw/tools/manifest_tool.py +184 -0
  78. cyberfw/tools/naabu.py +35 -0
  79. cyberfw/tools/nmap.py +113 -0
  80. cyberfw/tools/nuclei.py +28 -0
  81. cyberfw/tools/rustscan.py +74 -0
  82. cyberfw/tools/subfinder.py +34 -0
  83. cyberfw/tools/targets.py +44 -0
  84. cyberfw/triage.py +217 -0
  85. cyberfw/ui.py +507 -0
  86. cyberfw/vt.py +323 -0
  87. cyberfw-2.0.0.dist-info/METADATA +1163 -0
  88. cyberfw-2.0.0.dist-info/RECORD +91 -0
  89. cyberfw-2.0.0.dist-info/WHEEL +4 -0
  90. cyberfw-2.0.0.dist-info/entry_points.txt +4 -0
  91. cyberfw-2.0.0.dist-info/licenses/LICENSE +21 -0
cyberfw/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """cyberfw — Integrated Cybersecurity Framework.
2
+
3
+ A console orchestrator for offensive-security tools with the "Zero-Friction
4
+ Installation" paradigm: every external binary is fetched precompiled from
5
+ GitHub Releases, so the user only needs Python, Git and curl/wget.
6
+
7
+ Everything is reachable through the ``cyberfw`` CLI (see :mod:`cyberfw.cli`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ __version__ = "2.0.0"
15
+
16
+ # A library's records go where its caller's logging sends them: the CLI sets up
17
+ # its handlers, a script that sets up none gets nothing — not Python's
18
+ # last-resort handler printing warnings to stderr.
19
+ logging.getLogger("cyberfw").addHandler(logging.NullHandler())
20
+
21
+ __all__ = ["__version__"]
cyberfw/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m cyberfw` — the same app as the `cyberfw` command."""
2
+
3
+ from cyberfw.cli import app
4
+
5
+ app()
cyberfw/api.py ADDED
@@ -0,0 +1,426 @@
1
+ """cyberfw's Python API: run a pipeline or one tool, plan a pipeline, read and compare saved runs.
2
+
3
+ Stable within a major version: the functions, types and errors in ``__all__``.
4
+ ``cyberfw.core`` and ``cyberfw.cli`` are internal. Nothing here prints, prompts
5
+ or calls ``sys.exit``: results come back as data, progress goes to an optional
6
+ :class:`RunObserver`, and logging is the caller's to set up. The calls are
7
+ synchronous and run their own event loop — call them from plain code, not from
8
+ inside a running asyncio loop.
9
+
10
+ Example::
11
+
12
+ from cyberfw import api
13
+
14
+ outcome = api.run_pipeline("recon-to-vuln", "example.com", save=False)
15
+ print(outcome.succeeded, len(outcome.result.records), outcome.exit_code())
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from cyberfw.config import Settings, load_settings
26
+ from cyberfw.core.events import (
27
+ MailSummary,
28
+ Note,
29
+ RunObserver,
30
+ StepEvent,
31
+ StepName,
32
+ StepSummary,
33
+ Stream,
34
+ TakeoverSummary,
35
+ TlsSummary,
36
+ VtSummary,
37
+ )
38
+ from cyberfw.core.finish import finalize, run_info
39
+ from cyberfw.core.scan import (
40
+ Blocker,
41
+ Plan,
42
+ PlanStage,
43
+ choose_wordlist,
44
+ execute_pipeline,
45
+ plan_of,
46
+ prepare_pipeline,
47
+ )
48
+ from cyberfw.core.sessions import checked_session, open_session
49
+ from cyberfw.core.steps import post_scan
50
+ from cyberfw.core.tool_run import check_tool, keep_tool_run, run_tool_once
51
+ from cyberfw.core.workspace import open_manager
52
+ from cyberfw.exceptions import (
53
+ CyberfwError,
54
+ PreflightError,
55
+ RegistryError,
56
+ ReportError,
57
+ ToolNotFoundError,
58
+ )
59
+ from cyberfw.manager.plugins import PluginInfo, load_plugins
60
+ from cyberfw.pipeline.engine import NodeResult, PipelineResult, StageEvent
61
+ from cyberfw.pipeline.schemas import ToolRecord
62
+ from cyberfw.report import diff as rundiff
63
+ from cyberfw.report.csv_report import to_csv
64
+ from cyberfw.report.diff import RunDiff
65
+ from cyberfw.report.run_info import RunInfo
66
+ from cyberfw.report.sarif import to_sarif
67
+ from cyberfw.threatintel import IntelSummary
68
+
69
+ __all__ = [
70
+ "Blocker",
71
+ "CyberfwError",
72
+ "IntelSummary",
73
+ "MailSummary",
74
+ "NodeResult",
75
+ "Note",
76
+ "PipelineResult",
77
+ "Plan",
78
+ "PlanStage",
79
+ "PluginInfo",
80
+ "PreflightError",
81
+ "RegistryError",
82
+ "ReportError",
83
+ "RunDiff",
84
+ "RunInfo",
85
+ "RunObserver",
86
+ "RunOutcome",
87
+ "SavedRun",
88
+ "Settings",
89
+ "StageEvent",
90
+ "StepEvent",
91
+ "StepName",
92
+ "StepSummary",
93
+ "Stream",
94
+ "TakeoverSummary",
95
+ "TlsSummary",
96
+ "ToolNotFoundError",
97
+ "ToolRecord",
98
+ "VtSummary",
99
+ "compare_runs",
100
+ "export_csv",
101
+ "export_sarif",
102
+ "list_plugins",
103
+ "load_run",
104
+ "plan_pipeline",
105
+ "run_pipeline",
106
+ "run_tool",
107
+ ]
108
+
109
+
110
+ @dataclass
111
+ class RunOutcome:
112
+ """What a run produced: its result, its facts, where it was kept, what changed."""
113
+
114
+ #: The stages, their records and timing; hosts kept out of scope in ``excluded``.
115
+ result: PipelineResult
116
+ run: RunInfo
117
+ session_id: str
118
+ session_dir: Path
119
+ #: The reports written, by format (``json``, ``html``); empty when the run was not saved.
120
+ reports: dict[str, Path]
121
+ #: What changed since the earlier run it was compared with, if it was.
122
+ changes: RunDiff | None
123
+ #: Every note the run sent, in order.
124
+ notes: list[Note] = field(default_factory=list)
125
+
126
+ @property
127
+ def succeeded(self) -> bool:
128
+ """Every stage ran and finished cleanly."""
129
+ return self.result.succeeded()
130
+
131
+ def alert_hits(self, severity: str = "low") -> int:
132
+ """New findings at ``severity`` or worse since the earlier run; 0 without a comparison."""
133
+ _check_severity(severity)
134
+ return self.changes.new_findings_at_least(severity) if self.changes is not None else 0
135
+
136
+ def exit_code(self, alert_severity: str | None = None) -> int:
137
+ """The CLI's exit code for this run: 1 when it failed — a broken scan is
138
+ reported as broken, never as "nothing new" — 3 when ``alert_severity`` is
139
+ given and there are new findings at it or worse, else 0."""
140
+ if not self.succeeded:
141
+ return 1
142
+ if alert_severity is not None and self.alert_hits(alert_severity):
143
+ return 3
144
+ return 0
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class SavedRun:
149
+ """A saved run: its ``report.json`` and what it holds."""
150
+
151
+ path: Path
152
+ payload: dict[str, Any]
153
+
154
+
155
+ def _check_list(name: str, values: Sequence[str] | None) -> None:
156
+ # A bare str is a Sequence[str] too, of one-character items: as `exclude` its "*"
157
+ # would take every host out of scope, as `inputs` it would scan single letters.
158
+ if isinstance(values, str):
159
+ raise TypeError(f"{name} takes a list of strings, e.g. [{values!r}], not a bare str")
160
+
161
+
162
+ def _check_severity(severity: str) -> None:
163
+ if severity not in rundiff.REAL:
164
+ raise ValueError(f"severity must be one of {', '.join(rundiff.REAL)}, got {severity!r}")
165
+
166
+
167
+ def _scan_control_overrides(
168
+ *, severity: str, tags: str, rate_limit: int | None, exclude: list[str]
169
+ ) -> dict[str, object]:
170
+ """The ``Settings`` fields to override for this run — only the controls the
171
+ caller actually set, so an unset flag leaves the env/YAML/default in place."""
172
+ overrides: dict[str, object] = {}
173
+ if severity:
174
+ overrides["severity"] = severity
175
+ if tags:
176
+ overrides["tags"] = tags
177
+ if rate_limit is not None:
178
+ overrides["rate_limit"] = rate_limit
179
+ if exclude:
180
+ overrides["exclude"] = exclude
181
+ return overrides
182
+
183
+
184
+ def _settings(
185
+ settings: Settings | None,
186
+ *,
187
+ severity: str = "",
188
+ tags: str = "",
189
+ rate_limit: int | None = None,
190
+ exclude: Sequence[str] = (),
191
+ ) -> Settings:
192
+ """The settings for one call: as given, else loaded the way the CLI loads them;
193
+ the scan controls the caller set override them for this call only, checked as
194
+ the settings check them (a rate limit below 1 is a :class:`ValueError`)."""
195
+ _check_list("exclude", exclude)
196
+ settings = settings if settings is not None else load_settings()
197
+ overrides = _scan_control_overrides(severity=severity, tags=tags, rate_limit=rate_limit, exclude=list(exclude))
198
+ if overrides:
199
+ settings = settings.model_copy()
200
+ for key, value in overrides.items():
201
+ setattr(settings, key, value) # Settings validates every assignment
202
+ settings.ensure_dirs()
203
+ return settings
204
+
205
+
206
+ class _Relay(RunObserver):
207
+ """Passes every event on to the caller's observer and keeps the notes for the outcome."""
208
+
209
+ def __init__(self, observer: RunObserver | None) -> None:
210
+ self._observer = observer if observer is not None else RunObserver()
211
+ self.notes: list[Note] = []
212
+
213
+ def on_plan(self, plan: Plan) -> None:
214
+ self._observer.on_plan(plan)
215
+
216
+ def on_stage(self, event: StageEvent) -> None:
217
+ self._observer.on_stage(event)
218
+
219
+ def on_record(self, record: ToolRecord) -> None:
220
+ self._observer.on_record(record)
221
+
222
+ def on_output(self, line: str, stream: Stream) -> None:
223
+ self._observer.on_output(line, stream)
224
+
225
+ def on_step(self, event: StepEvent) -> None:
226
+ self._observer.on_step(event)
227
+
228
+ def on_note(self, note: Note) -> None:
229
+ self.notes.append(note)
230
+ self._observer.on_note(note)
231
+
232
+
233
+ def run_pipeline(
234
+ name: str,
235
+ target: str,
236
+ *,
237
+ settings: Settings | None = None,
238
+ ffuf: bool = False,
239
+ wordlist: str | None = None,
240
+ gowitness: bool | None = None,
241
+ max_httpx: int = 0,
242
+ severity: str = "",
243
+ tags: str = "",
244
+ rate_limit: int | None = None,
245
+ exclude: Sequence[str] = (),
246
+ mail: bool = True,
247
+ takeover: bool = True,
248
+ intel: bool = True,
249
+ vt_key: str | None = None,
250
+ vt_limit: int = 20,
251
+ baseline: str | None = None,
252
+ compare: bool = True,
253
+ session: str | None = None,
254
+ save: bool | Callable[[], bool] = True,
255
+ force_start: bool = False,
256
+ observer: RunObserver | None = None,
257
+ ) -> RunOutcome:
258
+ """Run a pipeline against ``target`` and finish it: the checks after the scan,
259
+ triage, the comparison with an earlier run, and the reports.
260
+
261
+ ``settings`` default to the CLI's (env, ``config.local.yaml``); ``severity``,
262
+ ``tags``, ``rate_limit`` and ``exclude`` override them for this call only.
263
+ VirusTotal runs only with ``vt_key`` — the API never looks for a key or asks.
264
+ ``baseline`` names the earlier run to compare with (else the newest of the same
265
+ pipeline and seed); ``compare=False`` skips the comparison. ``save`` keeps the
266
+ reports; a callable is asked after the comparison, before anything is written.
267
+ A session this call made is removed when the run is not saved.
268
+
269
+ Raises :class:`ValueError` (no target; a session that is not a plain name; a
270
+ rate limit below 1), :class:`TypeError` (``exclude`` as one bare str),
271
+ :class:`RegistryError` (an unknown pipeline, or a tool it names),
272
+ :class:`PreflightError` (stages cannot run and ``force_start`` is not set),
273
+ :class:`ReportError` (the reports cannot be written) and
274
+ :class:`CyberfwError` (the engine itself failed).
275
+ """
276
+ if not target:
277
+ raise ValueError("a pipeline needs a target")
278
+ if session:
279
+ checked_session(session)
280
+ settings = _settings(settings, severity=severity, tags=tags, rate_limit=rate_limit, exclude=exclude)
281
+ relay = _Relay(observer)
282
+ manager = open_manager(settings)
283
+ try:
284
+ prepared = prepare_pipeline(
285
+ name, target, settings=settings, manager=manager, ffuf=ffuf, gowitness=gowitness, max_httpx=max_httpx
286
+ )
287
+ relay.on_plan(plan_of(prepared))
288
+ for warning in prepared.warnings:
289
+ relay.on_note(Note("warn", warning))
290
+ if prepared.blocking and not force_start:
291
+ raise PreflightError(prepared.blocking)
292
+ chosen = choose_wordlist(settings, wordlist, ffuf=ffuf, observer=relay)
293
+ run_session = open_session(settings.reports_dir, kind="pipeline", name=name, session=session, stream=True)
294
+ except BaseException:
295
+ manager.close()
296
+ raise
297
+ try:
298
+ result = execute_pipeline(prepared, session=run_session, wordlist=chosen, observer=relay)
299
+ post_scan(
300
+ result, seed=target, settings=settings, mail=mail, takeover=takeover, intel=intel,
301
+ vt_key=vt_key, vt_limit=vt_limit, observer=relay,
302
+ )
303
+ finished = finalize(
304
+ result, manager=manager, settings=settings, name=name, seed=target,
305
+ tools={node.tool for node in prepared.nodes}, session=run_session, compare=compare,
306
+ baseline=baseline, save=save, observer=relay,
307
+ )
308
+ except BaseException:
309
+ # The run never finished: release the records store (what streamed stays on disk).
310
+ if run_session.context is not None:
311
+ run_session.context.close()
312
+ raise
313
+ return RunOutcome(
314
+ result=result, run=finished.run, session_id=run_session.id, session_dir=run_session.dir,
315
+ reports=finished.reports, changes=finished.changes, notes=relay.notes,
316
+ )
317
+
318
+
319
+ def plan_pipeline(
320
+ name: str,
321
+ target: str | None = None,
322
+ *,
323
+ settings: Settings | None = None,
324
+ ffuf: bool = False,
325
+ gowitness: bool | None = None,
326
+ max_httpx: int = 0,
327
+ ) -> Plan:
328
+ """What :func:`run_pipeline` would run, without running it: each stage, the
329
+ stage it reads, its tool's state, what blocks and what only weakens the run.
330
+ Raises :class:`RegistryError` for an unknown pipeline or a tool it names."""
331
+ settings = _settings(settings)
332
+ manager = open_manager(settings)
333
+ try:
334
+ return plan_of(prepare_pipeline(
335
+ name, target, settings=settings, manager=manager, ffuf=ffuf, gowitness=gowitness, max_httpx=max_httpx
336
+ ))
337
+ finally:
338
+ manager.close()
339
+
340
+
341
+ def run_tool(
342
+ tool: str,
343
+ *,
344
+ target: str | None = None,
345
+ inputs: Sequence[str] | None = None,
346
+ wordlist: str | None = None,
347
+ settings: Settings | None = None,
348
+ severity: str = "",
349
+ tags: str = "",
350
+ rate_limit: int | None = None,
351
+ parse: bool = True,
352
+ session: str | None = None,
353
+ save: bool | Callable[[], bool] = False,
354
+ observer: RunObserver | None = None,
355
+ ) -> RunOutcome:
356
+ """Run one registered tool on its own against ``target`` and/or ``inputs`` (a
357
+ list of hosts). It needs one of them; an empty list leaves the tool nothing
358
+ to work on, so it finds nothing — what ``run --list <empty file>`` does. Without
359
+ ``parse`` every stdout line is a raw record. ``save=True`` streams the records
360
+ into ``reports/<session>/`` as they arrive and writes both reports; a callable
361
+ is asked after the run — only when it produced records — before anything is
362
+ written; ``False`` writes nothing.
363
+
364
+ Raises :class:`ValueError` (neither a target nor inputs; a target the tool
365
+ cannot take; a session that is not a plain name; a rate limit below 1),
366
+ :class:`TypeError` (``inputs`` as one bare str), :class:`RegistryError` (an
367
+ unknown tool) and :class:`ToolNotFoundError` (a tool that is not installed).
368
+ """
369
+ if not target and inputs is None:
370
+ raise ValueError("run_tool needs a target or inputs (a list of hosts)")
371
+ _check_list("inputs", inputs)
372
+ if session:
373
+ checked_session(session)
374
+ settings = _settings(settings, severity=severity, tags=tags, rate_limit=rate_limit)
375
+ relay = _Relay(observer)
376
+ manager = open_manager(settings)
377
+ try:
378
+ check_tool(manager, tool, target)
379
+ except BaseException:
380
+ manager.close()
381
+ raise
382
+ run_session = open_session(settings.reports_dir, kind="run", name=tool, session=session, stream=save is True)
383
+ ran = run_tool_once(
384
+ tool, settings=settings, manager=manager, target=target, inputs=inputs or (), wordlist=wordlist,
385
+ parse=parse, session=run_session, observer=relay,
386
+ )
387
+ keep = save if isinstance(save, bool) else bool(ran.records) and save()
388
+ reports = keep_tool_run(ran, settings=settings, manager=manager) if keep else {}
389
+ return RunOutcome(
390
+ result=ran.result,
391
+ run=run_info(manager, name=tool, seed=target, session_id=run_session.id, tools={tool}),
392
+ session_id=run_session.id, session_dir=run_session.dir, reports=reports, changes=None, notes=relay.notes,
393
+ )
394
+
395
+
396
+ def load_run(ref: str | Path, *, settings: Settings | None = None) -> SavedRun:
397
+ """A saved run, by session id, its directory or its ``report.json`` (looked up
398
+ under ``reports/``). Raises :class:`FileNotFoundError` when there is none and
399
+ :class:`ValueError` when the file is not a cyberfw report."""
400
+ settings = settings if settings is not None else load_settings()
401
+ path = rundiff.resolve_report(str(ref), settings.reports_dir)
402
+ return SavedRun(path=path, payload=rundiff.load_report(path))
403
+
404
+
405
+ def compare_runs(old: SavedRun, new: SavedRun) -> RunDiff:
406
+ """What changed from ``old`` to ``new``: findings, ports, subdomains, certificates, exploitation."""
407
+ return rundiff.compare(old.payload, new.payload)
408
+
409
+
410
+ def export_sarif(run: SavedRun) -> dict[str, Any]:
411
+ """The run's findings as a SARIF 2.1.0 document (for CI / code scanning)."""
412
+ return to_sarif(run.payload)
413
+
414
+
415
+ def export_csv(run: SavedRun) -> str:
416
+ """Every record of the run as CSV (for triage in a spreadsheet)."""
417
+ return to_csv(run.payload)
418
+
419
+
420
+ def list_plugins(settings: Settings | None = None) -> list[PluginInfo]:
421
+ """Every plugin tool the installed packages declare — its package, version and
422
+ kind (``manifest`` / ``code``), its state and why, the extra environment
423
+ variables it asks for — and each package ``plugins.yaml`` enables that is missing.
424
+ Reads the packages' metadata; only enabled packages are loaded."""
425
+ settings = settings if settings is not None else load_settings()
426
+ return list(load_plugins(settings).infos)
@@ -0,0 +1,28 @@
1
+ """Typer CLI — the public face of cyberfw.
2
+
3
+ Three commands, matching the acceptance criteria in the technical specification:
4
+
5
+ * ``cyberfw init [tools...]`` — fetch precompiled binaries into ``tools_bin/``
6
+ (zero-friction: only Python, Git and curl/wget are required of the user).
7
+ * ``cyberfw run <tool> -t <target> (-l <hosts>)`` — run a single registered tool
8
+ through its adapter and stream validated JSONL records.
9
+ * ``cyberfw pipeline <name> -t <domain>`` — run a ready-made pipeline
10
+ (e.g. ``recon-to-vuln``) and render HTML + JSON reports.
11
+
12
+ A crashed/failed child never aborts the CLI: :class:`PipelineEngine` converts
13
+ stage failures into a ``NodeResult`` and the command exits with a helpful status.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+
20
+ from cyberfw.cli.app import app
21
+
22
+ # Importing the command modules registers their commands on `app` in this
23
+ # order — the order `cyberfw --help` lists them. The launcher comes last: it
24
+ # adds the callback that opens the menu when no command is given.
25
+ for _module in ('tools', 'plugins', 'reports', 'triage', 'scan', 'schedule', 'launcher'):
26
+ importlib.import_module(f"cyberfw.cli.{_module}")
27
+
28
+ __all__ = ["app"]
@@ -0,0 +1,5 @@
1
+ """`python -m cyberfw.cli` — the same app as the `cyberfw` command."""
2
+
3
+ from cyberfw.cli import app
4
+
5
+ app()
cyberfw/cli/app.py ADDED
@@ -0,0 +1,13 @@
1
+ """The Typer application every command module registers its commands on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(
8
+ name="cyberfw",
9
+ help="Integrated Cybersecurity Framework — orchestrates precompiled offensive tools.",
10
+ no_args_is_help=False,
11
+ rich_markup_mode="rich",
12
+ add_completion=False,
13
+ )
cyberfw/cli/common.py ADDED
@@ -0,0 +1,167 @@
1
+ """Plumbing the commands share: bootstrap, registry and lock paths, prompts, terminal and screen helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Any, TextIO
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+ from rich.prompt import Confirm as _RichConfirm
12
+ from rich.prompt import Prompt as _RichPrompt
13
+ from rich.text import Text, TextType
14
+
15
+ from cyberfw import completion
16
+ from cyberfw.config import Settings, load_settings
17
+ from cyberfw.core.sessions import checked_session
18
+ from cyberfw.core.workspace import open_manager, tool_state
19
+ from cyberfw.logging import console, get_logger, setup_logging
20
+ from cyberfw.manager import ToolManager
21
+
22
+ LOG = get_logger("cli")
23
+
24
+
25
+ class Prompt(_RichPrompt):
26
+ """Every question asked the same way: ``» Target (domain, URL or host):``.
27
+
28
+ Callers still pass the plain wording (it comes from the adapters), so a
29
+ test that patches ``cyberfw.cli.Prompt.ask`` sees exactly that wording.
30
+ Questions go to the app's console unless told otherwise: Rich's global
31
+ console has no theme, and the lead-in's style would quietly drop.
32
+ A question asked inside :func:`completion.offering` reads its answer with
33
+ Tab completion when a person is at the terminal.
34
+ """
35
+
36
+ def __init__(self, prompt: TextType = "", *, console: Console | None = None, **kwargs: Any) -> None:
37
+ super().__init__(prompt, console=console or _app_console(), **kwargs)
38
+
39
+ def make_prompt(self, default: Any) -> Text:
40
+ return Text.assemble(("» ", "accent"), super().make_prompt(default))
41
+
42
+ @classmethod
43
+ def get_input(cls, console: Console, prompt: TextType, password: bool, stream: TextIO | None = None) -> str:
44
+ options = completion.offered()
45
+ if options and not password and stream is None and completion.available(console):
46
+ return completion.read_line(console, prompt, options)
47
+ return super().get_input(console, prompt, password, stream=stream)
48
+
49
+
50
+ class Confirm(_RichConfirm):
51
+ """A yes/no question with the same ``» `` lead-in as :class:`Prompt`."""
52
+
53
+ def __init__(self, prompt: TextType = "", *, console: Console | None = None, **kwargs: Any) -> None:
54
+ super().__init__(prompt, console=console or _app_console(), **kwargs)
55
+
56
+ def make_prompt(self, default: Any) -> Text:
57
+ return Text.assemble(("» ", "accent"), super().make_prompt(default))
58
+
59
+
60
+ def _app_console() -> Console:
61
+ """The themed console, looked up at call time (tests swap it)."""
62
+ return console
63
+
64
+
65
+ # -- shared plumbing ------------------------------------------------------------
66
+ #: ``-v/--verbose`` — raise this run's log level to DEBUG so the resolve /
67
+ #: download / extract steps are traced. Shared by init/status/run/pipeline.
68
+ VerboseOption = Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--verbose",
72
+ "-v",
73
+ help="Trace what runs under the hood at DEBUG (release resolution, downloads, extraction).",
74
+ ),
75
+ ]
76
+
77
+
78
+ def _setup(*, verbose: bool = False) -> Settings:
79
+ """Load the settings, make sure their directories exist, wire logging.
80
+
81
+ ``verbose`` forces this process's log level to DEBUG regardless of the
82
+ configured ``log_level``, so ``-v`` surfaces the install/download trace
83
+ without a permanent config change.
84
+ """
85
+ settings = load_settings()
86
+ if verbose and settings.log_level != "DEBUG":
87
+ settings = settings.model_copy(update={"log_level": "DEBUG"})
88
+ settings.ensure_dirs()
89
+ setup_logging(level=settings.log_level, log_dir=settings.logs_dir, log_to_file=settings.log_to_file)
90
+ return settings
91
+
92
+
93
+ def _bootstrap(*, verbose: bool = False) -> tuple[Settings, ToolManager]:
94
+ """:func:`_setup`, and a tool manager over the registry in use."""
95
+ settings = _setup(verbose=verbose)
96
+ return settings, open_manager(settings)
97
+
98
+
99
+ def _is_interactive() -> bool:
100
+ """True when there is a user at the keyboard to answer a prompt.
101
+
102
+ A pipe, a CI job or a `< /dev/null` run must never block on a question.
103
+ """
104
+ return bool(console.is_interactive)
105
+
106
+
107
+ def _wants_to_save(decided: bool | None, *, default: bool, what: str) -> bool:
108
+ """Whether to keep this run's artefacts: the flag if given, else ask, else default."""
109
+ if decided is not None:
110
+ return decided
111
+ if not _is_interactive():
112
+ return default
113
+ return bool(Confirm.ask(f"Save the {what} report?", default=default))
114
+
115
+
116
+ def _after_live() -> None:
117
+ """``Live`` leaves its last frame on screen; on a pipe that frame ends
118
+ without a newline, and the next line would be glued to its last row."""
119
+ if not console.is_terminal:
120
+ console.line()
121
+
122
+
123
+ def _live_fps(settings: Settings) -> int:
124
+ """Redraws per second for a live view: smooth when animating, else just current."""
125
+ return 12 if settings.animations else 4
126
+
127
+
128
+ def _checked_session(session: str) -> str:
129
+ """``--session`` as a plain directory name under ``reports/``, else a usage error (exit 2)."""
130
+ try:
131
+ return checked_session(session)
132
+ except ValueError:
133
+ console.print(f"[err]--session must be a plain directory name under reports/, got {escape(repr(session))}.[/err]")
134
+ raise typer.Exit(2) from None
135
+
136
+
137
+ def _tool_version(manager: ToolManager, name: str) -> str:
138
+ return str((manager.install_state(name) or {}).get("version", ""))
139
+
140
+
141
+ def _tool_states(manager: ToolManager, tools_dir: Path) -> list[str]:
142
+ """One state per registered tool, in registry order."""
143
+ return [tool_state(manager, name, tools_dir) for name in manager.registry.names()]
144
+
145
+
146
+ def _clear_screen() -> None:
147
+ """Clear the screen *and* the scrollback, so nothing is left to scroll back to.
148
+
149
+ ``console.clear()`` erases only the visible screen (CSI 2J) and homes the
150
+ cursor; the frame that was there stays in the terminal's scrollback. CSI 3J
151
+ erases that too, so the launcher reopens on a truly blank screen. It is sent
152
+ only to a real terminal — the same gate Rich's own ``clear`` uses — so a
153
+ dumb terminal gets neither code; a pipe/CI never reaches here (callers guard
154
+ on :func:`_is_interactive`)."""
155
+ console.clear()
156
+ if console.is_terminal and not console.is_dumb_terminal:
157
+ console.file.write("\x1b[3J")
158
+ console.file.flush()
159
+
160
+
161
+ def _fresh_screen() -> None:
162
+ """Title the window and clear it — only for a person at a terminal; a pipe,
163
+ a log or a test keeps its plain transcript."""
164
+ if not _is_interactive():
165
+ return
166
+ console.set_window_title("CS-TOOL")
167
+ _clear_screen()