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.
- cyberfw/__init__.py +21 -0
- cyberfw/__main__.py +5 -0
- cyberfw/api.py +426 -0
- cyberfw/cli/__init__.py +28 -0
- cyberfw/cli/__main__.py +5 -0
- cyberfw/cli/app.py +13 -0
- cyberfw/cli/common.py +167 -0
- cyberfw/cli/launcher.py +500 -0
- cyberfw/cli/plugins.py +121 -0
- cyberfw/cli/reports.py +127 -0
- cyberfw/cli/scan.py +601 -0
- cyberfw/cli/schedule.py +151 -0
- cyberfw/cli/tools.py +409 -0
- cyberfw/cli/triage.py +110 -0
- cyberfw/cli/views.py +661 -0
- cyberfw/completion.py +126 -0
- cyberfw/config.py +199 -0
- cyberfw/core/__init__.py +7 -0
- cyberfw/core/events.py +126 -0
- cyberfw/core/finish.py +143 -0
- cyberfw/core/plugins.py +97 -0
- cyberfw/core/scan.py +274 -0
- cyberfw/core/sessions.py +84 -0
- cyberfw/core/steps.py +180 -0
- cyberfw/core/tool_run.py +110 -0
- cyberfw/core/workspace.py +106 -0
- cyberfw/data/pipelines/ports-to-vuln.yaml +29 -0
- cyberfw/data/registry.yaml +145 -0
- cyberfw/data/tools.lock +286 -0
- cyberfw/data/wordlists/common.txt +264 -0
- cyberfw/exceptions.py +130 -0
- cyberfw/hud.py +532 -0
- cyberfw/live_view.py +529 -0
- cyberfw/logging.py +103 -0
- cyberfw/manager/__init__.py +375 -0
- cyberfw/manager/archive.py +86 -0
- cyberfw/manager/chromium.py +229 -0
- cyberfw/manager/discovery.py +91 -0
- cyberfw/manager/github_client.py +368 -0
- cyberfw/manager/installer.py +378 -0
- cyberfw/manager/lock.py +376 -0
- cyberfw/manager/platform_map.py +113 -0
- cyberfw/manager/plugins.py +324 -0
- cyberfw/manager/registry.py +190 -0
- cyberfw/manager/verify.py +143 -0
- cyberfw/motion.py +213 -0
- cyberfw/pipeline/__init__.py +49 -0
- cyberfw/pipeline/context.py +144 -0
- cyberfw/pipeline/engine.py +605 -0
- cyberfw/pipeline/executor.py +537 -0
- cyberfw/pipeline/processes.py +314 -0
- cyberfw/pipeline/schemas.py +417 -0
- cyberfw/pipelines/__init__.py +74 -0
- cyberfw/pipelines/loader.py +125 -0
- cyberfw/pipelines/recon_to_vuln.py +50 -0
- cyberfw/plugin.py +243 -0
- cyberfw/posture/__init__.py +8 -0
- cyberfw/posture/mail.py +349 -0
- cyberfw/posture/takeover.py +271 -0
- cyberfw/posture/tls.py +208 -0
- cyberfw/report/__init__.py +6 -0
- cyberfw/report/csv_report.py +41 -0
- cyberfw/report/diff.py +509 -0
- cyberfw/report/html_report.py +1993 -0
- cyberfw/report/json_report.py +85 -0
- cyberfw/report/run_info.py +52 -0
- cyberfw/report/sarif.py +87 -0
- cyberfw/resources.py +67 -0
- cyberfw/schedule.py +214 -0
- cyberfw/threatintel.py +319 -0
- cyberfw/tools/__init__.py +69 -0
- cyberfw/tools/ffuf.py +62 -0
- cyberfw/tools/gitleaks.py +94 -0
- cyberfw/tools/gowitness.py +118 -0
- cyberfw/tools/httpx_tool.py +28 -0
- cyberfw/tools/manifest.py +177 -0
- cyberfw/tools/manifest_tool.py +184 -0
- cyberfw/tools/naabu.py +35 -0
- cyberfw/tools/nmap.py +113 -0
- cyberfw/tools/nuclei.py +28 -0
- cyberfw/tools/rustscan.py +74 -0
- cyberfw/tools/subfinder.py +34 -0
- cyberfw/tools/targets.py +44 -0
- cyberfw/triage.py +217 -0
- cyberfw/ui.py +507 -0
- cyberfw/vt.py +323 -0
- cyberfw-2.0.0.dist-info/METADATA +1163 -0
- cyberfw-2.0.0.dist-info/RECORD +91 -0
- cyberfw-2.0.0.dist-info/WHEEL +4 -0
- cyberfw-2.0.0.dist-info/entry_points.txt +4 -0
- 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
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)
|
cyberfw/cli/__init__.py
ADDED
|
@@ -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"]
|
cyberfw/cli/__main__.py
ADDED
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()
|