splitagent 0.0.3__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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"""Compile a session into a professional penetration-test report.
|
|
2
|
+
|
|
3
|
+
The document structure is owned by code, not by the model: the AI only fills
|
|
4
|
+
structured ``Finding`` fields, so two sessions with the same data produce the
|
|
5
|
+
same document. Output formats: Markdown, printable HTML (A4) and JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from splitagent.config import ReportSettings
|
|
18
|
+
from splitagent.core.models import SEVERITY_ORDER, SessionState
|
|
19
|
+
from splitagent.report.cvss import describe
|
|
20
|
+
|
|
21
|
+
REPORT_VERSION = "1.0"
|
|
22
|
+
CLASSIFICATION = "Confidential"
|
|
23
|
+
|
|
24
|
+
SEVERITY_BADGE = {
|
|
25
|
+
"critical": "CRITICAL",
|
|
26
|
+
"high": "HIGH",
|
|
27
|
+
"medium": "MEDIUM",
|
|
28
|
+
"low": "LOW",
|
|
29
|
+
"info": "INFO",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Severity model, as published in the report so the reader can reproduce a score.
|
|
33
|
+
SEVERITY_MODEL: list[tuple[str, str, str]] = [
|
|
34
|
+
("Critical", "9.0 - 10.0", "Direct, unauthenticated path to data or control. Fix first."),
|
|
35
|
+
("High", "7.0 - 8.9", "Serious impact, usually needs one precondition."),
|
|
36
|
+
("Medium", "4.0 - 6.9", "Real but constrained, or requires chaining."),
|
|
37
|
+
("Low", "0.1 - 3.9", "Limited impact on its own; still worth recording."),
|
|
38
|
+
("Informational", "0.0", "No direct security impact; hardening or hygiene observation."),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Remediation priority and target SLA per severity.
|
|
42
|
+
REMEDIATION_SLA: dict[str, tuple[str, str]] = {
|
|
43
|
+
"critical": ("P0", "24-48 hours"),
|
|
44
|
+
"high": ("P1", "7 days"),
|
|
45
|
+
"medium": ("P2", "30 days"),
|
|
46
|
+
"low": ("P3", "90 days"),
|
|
47
|
+
"info": ("P4", "best effort"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
METHODOLOGY_STANDARDS = [
|
|
51
|
+
"OWASP Web Security Testing Guide (WSTG) and ASVS for web applications",
|
|
52
|
+
"OWASP API Security Top 10 for APIs",
|
|
53
|
+
"PTES (Penetration Testing Execution Standard) and NIST SP 800-115 for structure",
|
|
54
|
+
"MITRE ATT&CK for adversary techniques",
|
|
55
|
+
"CVSS v3.1 for severity scoring",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
METHODOLOGY_PHASES = (
|
|
59
|
+
"reconnaissance, enumeration, vulnerability analysis, controlled "
|
|
60
|
+
"non-destructive exploitation, post-exploitation review and reporting"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _now() -> str:
|
|
65
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sort_findings(state: SessionState) -> list:
|
|
69
|
+
return sorted(
|
|
70
|
+
state.findings,
|
|
71
|
+
key=lambda f: (
|
|
72
|
+
SEVERITY_ORDER.get(f.severity, 99),
|
|
73
|
+
-(f.cvss_score or 0.0),
|
|
74
|
+
f.title,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _raw_severity(finding: Any) -> str:
|
|
80
|
+
severity = (finding.severity or "info").lower()
|
|
81
|
+
return severity if severity in SEVERITY_ORDER else "info"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _affected_asset(finding: Any) -> str:
|
|
85
|
+
return finding.endpoint or finding.target or ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _status_label(state: SessionState, finding: Any) -> str:
|
|
89
|
+
if finding.status == "false-positive":
|
|
90
|
+
return "False positive"
|
|
91
|
+
if finding.status == "accepted":
|
|
92
|
+
return "Risk accepted"
|
|
93
|
+
if state.finding_is_closed(finding):
|
|
94
|
+
return "Fixed, verified"
|
|
95
|
+
if any(m.finding_id == finding.id for m in state.mitigations):
|
|
96
|
+
return "Mitigation proposed"
|
|
97
|
+
return "Open"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _overall_risk(state: SessionState) -> str:
|
|
101
|
+
for severity in ("critical", "high", "medium", "low"):
|
|
102
|
+
if any(
|
|
103
|
+
_raw_severity(f) == severity and not state.finding_is_closed(f) for f in state.findings
|
|
104
|
+
):
|
|
105
|
+
return severity.capitalize()
|
|
106
|
+
return "Low" if state.findings else "Informational"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _severity_counts(state: SessionState) -> dict[str, int]:
|
|
110
|
+
counts = dict.fromkeys(("critical", "high", "medium", "low", "info"), 0)
|
|
111
|
+
for finding in state.findings:
|
|
112
|
+
counts[_raw_severity(finding)] += 1
|
|
113
|
+
return counts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _exec_summary(state: SessionState) -> str:
|
|
117
|
+
counts = _severity_counts(state)
|
|
118
|
+
total = len(state.findings)
|
|
119
|
+
if not total:
|
|
120
|
+
return (
|
|
121
|
+
f"An automated penetration test of {state.target or 'the target'} was performed "
|
|
122
|
+
"within the authorised scope. No security findings were recorded during the "
|
|
123
|
+
"engagement. The tested surface is reported as clean, subject to the scope and "
|
|
124
|
+
"limitations stated in this document."
|
|
125
|
+
)
|
|
126
|
+
parts = [
|
|
127
|
+
f"An automated penetration test of {state.target or 'the target'} was performed "
|
|
128
|
+
f"within the authorised scope between {state.started_at} and {state.ended_at or 'now'}."
|
|
129
|
+
]
|
|
130
|
+
parts.append(
|
|
131
|
+
f"The assessment identified {total} finding(s): {counts['critical']} critical, "
|
|
132
|
+
f"{counts['high']} high, {counts['medium']} medium, {counts['low']} low and "
|
|
133
|
+
f"{counts['info']} informational. Overall risk is rated "
|
|
134
|
+
f"**{_overall_risk(state)}**."
|
|
135
|
+
)
|
|
136
|
+
top = _sort_findings(state)[:3]
|
|
137
|
+
if top:
|
|
138
|
+
named = "; ".join(f"{f.id} {f.title or 'untitled'}" for f in top)
|
|
139
|
+
parts.append(f"The most significant findings are: {named}.")
|
|
140
|
+
breakdown = state.resilience_breakdown()
|
|
141
|
+
parts.append(
|
|
142
|
+
f"Resilience score: **{state.resilience_score()}/100** "
|
|
143
|
+
f"({breakdown['findings_closed']} of {breakdown['findings_total']} issues confirmed "
|
|
144
|
+
"closed after re-testing)."
|
|
145
|
+
)
|
|
146
|
+
if breakdown["verified_is_zero"]:
|
|
147
|
+
parts.append(
|
|
148
|
+
"Nothing is verified yet: every mitigation below is a proposal. The score counts "
|
|
149
|
+
"only issues re-tested after a control was applied, so it will not move until the "
|
|
150
|
+
"fixes are deployed and the audit is re-run."
|
|
151
|
+
)
|
|
152
|
+
elif breakdown["mitigations_proposed"]:
|
|
153
|
+
parts.append(
|
|
154
|
+
f"{breakdown['mitigations_verified']} control(s) verified as applied; "
|
|
155
|
+
f"{breakdown['mitigations_proposed']} still only proposed and not counted."
|
|
156
|
+
)
|
|
157
|
+
if breakdown["open_critical"]:
|
|
158
|
+
parts.append(
|
|
159
|
+
"Open critical issues requiring urgent action: "
|
|
160
|
+
+ ", ".join(breakdown["open_critical"])
|
|
161
|
+
+ "."
|
|
162
|
+
)
|
|
163
|
+
return " ".join(parts)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _report_context(
|
|
167
|
+
state: SessionState, settings: ReportSettings, generated_at: str | None = None
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""The structured content shared by every renderer."""
|
|
170
|
+
findings = _sort_findings(state)
|
|
171
|
+
mitigations_by_finding: dict[str, list] = {}
|
|
172
|
+
for mitigation in state.mitigations:
|
|
173
|
+
mitigations_by_finding.setdefault(mitigation.finding_id, []).append(mitigation)
|
|
174
|
+
return {
|
|
175
|
+
"findings": findings,
|
|
176
|
+
"mitigations_by_finding": mitigations_by_finding,
|
|
177
|
+
"counts": _severity_counts(state),
|
|
178
|
+
"overall_risk": _overall_risk(state),
|
|
179
|
+
"exec_summary": _exec_summary(state),
|
|
180
|
+
"breakdown": state.resilience_breakdown(),
|
|
181
|
+
"settings": settings,
|
|
182
|
+
"generated_at": generated_at or _now(),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------------------- #
|
|
187
|
+
# Markdown
|
|
188
|
+
# --------------------------------------------------------------------------- #
|
|
189
|
+
def _md_finding_block(state: SessionState, finding: Any, settings: ReportSettings) -> list[str]:
|
|
190
|
+
severity = _raw_severity(finding)
|
|
191
|
+
cvss = (
|
|
192
|
+
f"{finding.cvss_score:.1f} ({finding.cvss_vector})"
|
|
193
|
+
if finding.cvss_vector
|
|
194
|
+
else f"{finding.cvss_score:.1f}"
|
|
195
|
+
)
|
|
196
|
+
lines = [
|
|
197
|
+
f"### {finding.id} - {finding.title or 'Untitled'} [{SEVERITY_BADGE[severity]}]",
|
|
198
|
+
"",
|
|
199
|
+
"| Field | Value |",
|
|
200
|
+
"| --- | --- |",
|
|
201
|
+
f"| Severity | {severity.capitalize()} |",
|
|
202
|
+
f"| CVSS | {cvss} |",
|
|
203
|
+
f"| CWE | {finding.cwe or 'Not provided'} |",
|
|
204
|
+
f"| OWASP | {finding.owasp or 'Not provided'} |",
|
|
205
|
+
f"| Affected asset | {_affected_asset(finding) or 'Not provided'} |",
|
|
206
|
+
f"| Category | {finding.category} |",
|
|
207
|
+
f"| Status | {_status_label(state, finding)} |",
|
|
208
|
+
f"| Confidence | {finding.confidence} |",
|
|
209
|
+
f"| Discovered in round | {finding.round} |",
|
|
210
|
+
"",
|
|
211
|
+
f"**Description.** {finding.description or 'Not provided'}",
|
|
212
|
+
"",
|
|
213
|
+
f"**Impact.** {finding.impact or 'Not provided'}",
|
|
214
|
+
"",
|
|
215
|
+
]
|
|
216
|
+
if finding.reproduction:
|
|
217
|
+
lines.append("**Steps to reproduce**")
|
|
218
|
+
lines.append("")
|
|
219
|
+
lines.append(finding.reproduction)
|
|
220
|
+
lines.append("")
|
|
221
|
+
if settings.include_evidence and finding.evidence:
|
|
222
|
+
lines.append("**Evidence**")
|
|
223
|
+
lines.append("")
|
|
224
|
+
lines.append("```")
|
|
225
|
+
lines.append(finding.evidence)
|
|
226
|
+
lines.append("```")
|
|
227
|
+
lines.append("")
|
|
228
|
+
if finding.recommendation:
|
|
229
|
+
lines.append(f"**Remediation.** {finding.recommendation}")
|
|
230
|
+
lines.append("")
|
|
231
|
+
return lines
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_markdown(
|
|
235
|
+
state: SessionState, settings: ReportSettings, *, generated_at: str | None = None
|
|
236
|
+
) -> str:
|
|
237
|
+
ctx = _report_context(state, settings, generated_at)
|
|
238
|
+
findings = ctx["findings"]
|
|
239
|
+
counts = ctx["counts"]
|
|
240
|
+
lines: list[str] = []
|
|
241
|
+
|
|
242
|
+
lines.append(f"# Penetration Test Report - {state.name}")
|
|
243
|
+
lines.append("")
|
|
244
|
+
lines.append("## Document control")
|
|
245
|
+
lines.append("")
|
|
246
|
+
lines.append("| Field | Value |")
|
|
247
|
+
lines.append("| --- | --- |")
|
|
248
|
+
lines.append(f"| Report version | {REPORT_VERSION} |")
|
|
249
|
+
lines.append(f"| Classification | {CLASSIFICATION} |")
|
|
250
|
+
lines.append(f"| Target | {state.target or 'n/a'} ({state.target_kind}) |")
|
|
251
|
+
lines.append(f"| Scope | {', '.join(state.scope) or 'n/a'} |")
|
|
252
|
+
lines.append(f"| Session | `{state.id}` |")
|
|
253
|
+
lines.append(f"| Model | {state.provider or 'n/a'} / {state.model or 'n/a'} |")
|
|
254
|
+
lines.append(f"| Started | {state.started_at} |")
|
|
255
|
+
lines.append(f"| Ended | {state.ended_at or 'in progress'} |")
|
|
256
|
+
lines.append(f"| Report date | {ctx['generated_at']} |")
|
|
257
|
+
lines.append("| Prepared by | SplitAgent (automated) |")
|
|
258
|
+
lines.append("")
|
|
259
|
+
|
|
260
|
+
lines.append("## 1. Executive summary")
|
|
261
|
+
lines.append("")
|
|
262
|
+
lines.append(ctx["exec_summary"])
|
|
263
|
+
lines.append("")
|
|
264
|
+
lines.append("| Severity | Count | Status |")
|
|
265
|
+
lines.append("| --- | --- | --- |")
|
|
266
|
+
for severity in ("critical", "high", "medium", "low", "info"):
|
|
267
|
+
lines.append(f"| {severity.capitalize()} | {counts[severity]} | see findings |")
|
|
268
|
+
lines.append(f"| **Total** | **{len(findings)}** | |")
|
|
269
|
+
lines.append("")
|
|
270
|
+
|
|
271
|
+
lines.append("## 2. Scope and rules of engagement")
|
|
272
|
+
lines.append("")
|
|
273
|
+
lines.append(f"- **In scope:** {', '.join(state.scope) or state.target or 'n/a'}")
|
|
274
|
+
lines.append("- **Out of scope:** any host not listed above.")
|
|
275
|
+
lines.append(f"- **Testing window:** {state.started_at} to {state.ended_at or 'now'}.")
|
|
276
|
+
lines.append(
|
|
277
|
+
"- **Authorisation:** testing was performed by the asset owner or an authorised "
|
|
278
|
+
"party. All activity was non-destructive."
|
|
279
|
+
)
|
|
280
|
+
lines.append("")
|
|
281
|
+
|
|
282
|
+
lines.append("## 3. Methodology and standards")
|
|
283
|
+
lines.append("")
|
|
284
|
+
lines.append(f"The engagement followed {METHODOLOGY_PHASES}.")
|
|
285
|
+
lines.append("")
|
|
286
|
+
for standard in METHODOLOGY_STANDARDS:
|
|
287
|
+
lines.append(f"- {standard}")
|
|
288
|
+
lines.append("")
|
|
289
|
+
|
|
290
|
+
lines.append("## 4. Severity model")
|
|
291
|
+
lines.append("")
|
|
292
|
+
lines.append("| Severity | CVSS v3.1 | Meaning |")
|
|
293
|
+
lines.append("| --- | --- | --- |")
|
|
294
|
+
for name, cvss_range, meaning in SEVERITY_MODEL:
|
|
295
|
+
lines.append(f"| {name} | {cvss_range} | {meaning} |")
|
|
296
|
+
lines.append("")
|
|
297
|
+
|
|
298
|
+
lines.append("## 5. Findings summary")
|
|
299
|
+
lines.append("")
|
|
300
|
+
if not findings:
|
|
301
|
+
lines.append("_No findings were recorded._")
|
|
302
|
+
lines.append("")
|
|
303
|
+
else:
|
|
304
|
+
lines.append("| ID | Title | Severity | CVSS | Affected asset | Status |")
|
|
305
|
+
lines.append("| --- | --- | --- | --- | --- | --- |")
|
|
306
|
+
for finding in findings:
|
|
307
|
+
lines.append(
|
|
308
|
+
f"| `{finding.id}` | {finding.title or 'Untitled'} | "
|
|
309
|
+
f"{SEVERITY_BADGE[_raw_severity(finding)]} | {finding.cvss_score:.1f} | "
|
|
310
|
+
f"{_affected_asset(finding) or '-'} | {_status_label(state, finding)} |"
|
|
311
|
+
)
|
|
312
|
+
lines.append("")
|
|
313
|
+
|
|
314
|
+
lines.append("## 6. Detailed findings")
|
|
315
|
+
lines.append("")
|
|
316
|
+
if not findings:
|
|
317
|
+
lines.append("_No findings were recorded._")
|
|
318
|
+
lines.append("")
|
|
319
|
+
for finding in findings:
|
|
320
|
+
lines.extend(_md_finding_block(state, finding, settings))
|
|
321
|
+
for mitigation in ctx["mitigations_by_finding"].get(finding.id, []):
|
|
322
|
+
lines.append(
|
|
323
|
+
f"**Mitigation {mitigation.id}** ({mitigation.kind}, {mitigation.status}) - "
|
|
324
|
+
f"{mitigation.title}"
|
|
325
|
+
)
|
|
326
|
+
lines.append("")
|
|
327
|
+
if mitigation.description:
|
|
328
|
+
lines.append(mitigation.description)
|
|
329
|
+
lines.append("")
|
|
330
|
+
if settings.include_patches and mitigation.content:
|
|
331
|
+
lines.append("```")
|
|
332
|
+
lines.append(mitigation.content)
|
|
333
|
+
lines.append("```")
|
|
334
|
+
lines.append("")
|
|
335
|
+
|
|
336
|
+
lines.append("## 7. Remediation roadmap")
|
|
337
|
+
lines.append("")
|
|
338
|
+
if not findings:
|
|
339
|
+
lines.append("_No remediation required._")
|
|
340
|
+
lines.append("")
|
|
341
|
+
else:
|
|
342
|
+
lines.append("| Priority | Finding | Severity | Target SLA |")
|
|
343
|
+
lines.append("| --- | --- | --- | --- |")
|
|
344
|
+
for finding in findings:
|
|
345
|
+
severity = _raw_severity(finding)
|
|
346
|
+
priority, sla = REMEDIATION_SLA[severity]
|
|
347
|
+
lines.append(
|
|
348
|
+
f"| {priority} | `{finding.id}` {finding.title or 'Untitled'} | "
|
|
349
|
+
f"{severity.capitalize()} | {sla} |"
|
|
350
|
+
)
|
|
351
|
+
lines.append("")
|
|
352
|
+
|
|
353
|
+
lines.append("## 8. Retest and status tracking")
|
|
354
|
+
lines.append("")
|
|
355
|
+
lines.append("| ID | Original severity | Status |")
|
|
356
|
+
lines.append("| --- | --- | --- |")
|
|
357
|
+
for finding in findings:
|
|
358
|
+
lines.append(
|
|
359
|
+
f"| `{finding.id}` | {SEVERITY_BADGE[_raw_severity(finding)]} | "
|
|
360
|
+
f"{_status_label(state, finding)} |"
|
|
361
|
+
)
|
|
362
|
+
if not findings:
|
|
363
|
+
lines.append("| - | - | - |")
|
|
364
|
+
lines.append("")
|
|
365
|
+
|
|
366
|
+
lines.append("## 9. Appendices")
|
|
367
|
+
lines.append("")
|
|
368
|
+
if state.usage:
|
|
369
|
+
lines.append("**Model usage:** " + ", ".join(f"{k}={v}" for k, v in state.usage.items()))
|
|
370
|
+
lines.append("")
|
|
371
|
+
if state.notes:
|
|
372
|
+
lines.append("**Analyst notes**")
|
|
373
|
+
lines.append("")
|
|
374
|
+
for note in state.notes:
|
|
375
|
+
lines.append(f"- {note}")
|
|
376
|
+
lines.append("")
|
|
377
|
+
lines.append("**Round timeline**")
|
|
378
|
+
lines.append("")
|
|
379
|
+
for round_ in state.rounds:
|
|
380
|
+
lines.append(f"- **Round {round_.index} - Red:** {round_.red_summary or '(none)'}")
|
|
381
|
+
lines.append(f"- **Round {round_.index} - Blue:** {round_.blue_summary or '(none)'}")
|
|
382
|
+
if not state.rounds:
|
|
383
|
+
lines.append("- No rounds recorded.")
|
|
384
|
+
lines.append("")
|
|
385
|
+
|
|
386
|
+
lines.append("## 10. Limitations and disclaimer")
|
|
387
|
+
lines.append("")
|
|
388
|
+
lines.append(
|
|
389
|
+
"This report reflects automated, non-destructive testing within the authorised scope. "
|
|
390
|
+
"Automated tools do not replace human judgement: absence of a finding does not prove "
|
|
391
|
+
"absence of a vulnerability. Evidence may contain sensitive values captured during "
|
|
392
|
+
"testing and must be reviewed before distribution. The report must be reviewed by a "
|
|
393
|
+
"qualified human before remediation decisions are made."
|
|
394
|
+
)
|
|
395
|
+
lines.append("")
|
|
396
|
+
lines.append("---")
|
|
397
|
+
lines.append("")
|
|
398
|
+
lines.append("_Generated by SplitAgent._")
|
|
399
|
+
lines.append("")
|
|
400
|
+
return "\n".join(lines)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --------------------------------------------------------------------------- #
|
|
404
|
+
# HTML (printable A4)
|
|
405
|
+
# --------------------------------------------------------------------------- #
|
|
406
|
+
_HTML_CSS = """
|
|
407
|
+
:root {
|
|
408
|
+
--ink:#1c1c22; --muted:#5c5c68; --faint:#8a8a96;
|
|
409
|
+
--line:#e2e2e8; --panel:#f7f7fa; --accent:#3b4fd8;
|
|
410
|
+
--critical:#c0223b; --high:#d9752a; --medium:#b8890f; --low:#2f8f4e;
|
|
411
|
+
--info:#2b7fb8;
|
|
412
|
+
}
|
|
413
|
+
* { box-sizing:border-box; }
|
|
414
|
+
@page { size:A4; margin:18mm 16mm; }
|
|
415
|
+
html, body { margin:0; padding:0; background:#fff; color:var(--ink); }
|
|
416
|
+
body { font-family:"Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
|
|
417
|
+
font-size:11.5px; line-height:1.65; }
|
|
418
|
+
.wrap { max-width:820px; margin:0 auto; padding:28px 22px 64px; }
|
|
419
|
+
|
|
420
|
+
.cover { border-bottom:3px solid var(--accent); padding-bottom:22px; margin-bottom:26px; }
|
|
421
|
+
.cover .class { display:inline-block; font-size:10px; letter-spacing:0.16em;
|
|
422
|
+
text-transform:uppercase; color:var(--critical); border:1px solid var(--critical);
|
|
423
|
+
border-radius:999px; padding:2px 10px; margin-bottom:14px; }
|
|
424
|
+
.cover h1 { font-size:30px; margin:0 0 6px; letter-spacing:-0.02em; }
|
|
425
|
+
.cover .target { font-size:15px; color:var(--muted); margin:0 0 14px; }
|
|
426
|
+
.cover .line { font-size:11px; color:var(--faint); }
|
|
427
|
+
|
|
428
|
+
h2 { font-size:16px; margin:30px 0 10px; padding-bottom:6px;
|
|
429
|
+
border-bottom:1px solid var(--line); letter-spacing:-0.01em; }
|
|
430
|
+
h3 { font-size:13px; margin:22px 0 8px; }
|
|
431
|
+
p { margin:0 0 10px; }
|
|
432
|
+
small { color:var(--faint); }
|
|
433
|
+
code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:11px; }
|
|
434
|
+
|
|
435
|
+
table { width:100%; border-collapse:collapse; margin:8px 0 14px; }
|
|
436
|
+
th, td { text-align:left; padding:7px 9px; border-bottom:1px solid var(--line);
|
|
437
|
+
vertical-align:top; font-size:11px; }
|
|
438
|
+
th { color:var(--muted); font-weight:600; text-transform:uppercase; font-size:9.5px;
|
|
439
|
+
letter-spacing:0.06em; background:var(--panel); }
|
|
440
|
+
td.field { color:var(--muted); width:150px; white-space:nowrap; }
|
|
441
|
+
|
|
442
|
+
.badge { display:inline-block; padding:1px 8px; border-radius:999px; font-size:9.5px;
|
|
443
|
+
font-weight:700; letter-spacing:0.04em; color:#fff; }
|
|
444
|
+
.badge.critical { background:var(--critical); }
|
|
445
|
+
.badge.high { background:var(--high); }
|
|
446
|
+
.badge.medium { background:var(--medium); }
|
|
447
|
+
.badge.low { background:var(--low); }
|
|
448
|
+
.badge.info { background:var(--info); }
|
|
449
|
+
|
|
450
|
+
.cards { display:flex; gap:10px; flex-wrap:wrap; margin:12px 0 4px; }
|
|
451
|
+
.card { border:1px solid var(--line); border-radius:10px; padding:10px 14px; min-width:110px; }
|
|
452
|
+
.card .n { font-size:22px; font-weight:700; }
|
|
453
|
+
.card .l { font-size:9.5px; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); }
|
|
454
|
+
|
|
455
|
+
.finding { border:1px solid var(--line); border-left:4px solid var(--accent);
|
|
456
|
+
border-radius:10px; padding:16px 18px; margin:14px 0; page-break-inside:avoid; }
|
|
457
|
+
.finding.critical { border-left-color:var(--critical); }
|
|
458
|
+
.finding.high { border-left-color:var(--high); }
|
|
459
|
+
.finding.medium { border-left-color:var(--medium); }
|
|
460
|
+
.finding.low { border-left-color:var(--low); }
|
|
461
|
+
.finding.info { border-left-color:var(--info); }
|
|
462
|
+
.finding h3 { margin-top:0; }
|
|
463
|
+
.finding .subhead { font-size:10px; text-transform:uppercase; letter-spacing:0.06em;
|
|
464
|
+
color:var(--muted); margin:12px 0 4px; font-weight:700; }
|
|
465
|
+
pre { background:var(--panel); border:1px solid var(--line); border-radius:6px;
|
|
466
|
+
padding:10px; overflow-x:auto; font-size:10.5px; white-space:pre-wrap; word-break:break-word; }
|
|
467
|
+
blockquote { margin:10px 0; padding:10px 14px; background:var(--panel);
|
|
468
|
+
border-left:3px solid var(--accent); border-radius:6px; color:var(--muted); }
|
|
469
|
+
.rec { color:var(--low); font-weight:600; }
|
|
470
|
+
footer { margin-top:36px; padding-top:12px; border-top:1px solid var(--line);
|
|
471
|
+
color:var(--faint); font-size:10px; }
|
|
472
|
+
@media print { .wrap { max-width:none; padding:0; } a { color:inherit; text-decoration:none; } }
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def build_html(
|
|
477
|
+
state: SessionState, settings: ReportSettings, *, generated_at: str | None = None
|
|
478
|
+
) -> str:
|
|
479
|
+
ctx = _report_context(state, settings, generated_at)
|
|
480
|
+
findings = ctx["findings"]
|
|
481
|
+
counts = ctx["counts"]
|
|
482
|
+
|
|
483
|
+
def esc(value: Any) -> str:
|
|
484
|
+
return html.escape(str(value or ""))
|
|
485
|
+
|
|
486
|
+
def cell(value: Any) -> str:
|
|
487
|
+
return esc(value) if value else "<span class='muted'>Not provided</span>"
|
|
488
|
+
|
|
489
|
+
# Executive summary paragraphs.
|
|
490
|
+
summary_html = "".join(f"<p>{esc(part)}</p>" for part in ctx["exec_summary"].split(". "))
|
|
491
|
+
|
|
492
|
+
# Findings summary table.
|
|
493
|
+
summary_rows = (
|
|
494
|
+
"".join(
|
|
495
|
+
f"<tr><td><code>{esc(f.id)}</code></td><td>{esc(f.title or 'Untitled')}</td>"
|
|
496
|
+
f"<td><span class='badge {_raw_severity(f)}'>{SEVERITY_BADGE[_raw_severity(f)]}</span></td>"
|
|
497
|
+
f"<td>{f.cvss_score:.1f}</td><td>{esc(_affected_asset(f) or '-')}</td>"
|
|
498
|
+
f"<td>{esc(_status_label(state, f))}</td></tr>"
|
|
499
|
+
for f in findings
|
|
500
|
+
)
|
|
501
|
+
or "<tr><td colspan='6'>No findings recorded.</td></tr>"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Detailed findings.
|
|
505
|
+
cards: list[str] = []
|
|
506
|
+
for finding in findings:
|
|
507
|
+
severity = _raw_severity(finding)
|
|
508
|
+
cvss = (
|
|
509
|
+
f"{finding.cvss_score:.1f} ({esc(finding.cvss_vector)})"
|
|
510
|
+
if finding.cvss_vector
|
|
511
|
+
else f"{finding.cvss_score:.1f}"
|
|
512
|
+
)
|
|
513
|
+
mitigations = ""
|
|
514
|
+
for mitigation in ctx["mitigations_by_finding"].get(finding.id, []):
|
|
515
|
+
content = (
|
|
516
|
+
f"<pre>{esc(mitigation.content)}</pre>"
|
|
517
|
+
if settings.include_patches and mitigation.content
|
|
518
|
+
else ""
|
|
519
|
+
)
|
|
520
|
+
mitigations += (
|
|
521
|
+
f"<div class='mitigation'><strong>{esc(mitigation.title)}</strong> "
|
|
522
|
+
f"<em>({esc(mitigation.kind)} / {esc(mitigation.status)})</em>"
|
|
523
|
+
f"<p>{esc(mitigation.description)}</p>{content}</div>"
|
|
524
|
+
)
|
|
525
|
+
evidence = (
|
|
526
|
+
f"<div class='subhead'>Evidence</div><pre>{esc(finding.evidence)}</pre>"
|
|
527
|
+
if settings.include_evidence and finding.evidence
|
|
528
|
+
else ""
|
|
529
|
+
)
|
|
530
|
+
reproduction = (
|
|
531
|
+
f"<div class='subhead'>Steps to reproduce</div><pre>{esc(finding.reproduction)}</pre>"
|
|
532
|
+
if finding.reproduction
|
|
533
|
+
else ""
|
|
534
|
+
)
|
|
535
|
+
cards.append(
|
|
536
|
+
f"<section class='finding {severity}'>"
|
|
537
|
+
f"<h3><code>{esc(finding.id)}</code> · {esc(finding.title or 'Untitled')} "
|
|
538
|
+
f"<span class='badge {severity}'>{SEVERITY_BADGE[severity]}</span></h3>"
|
|
539
|
+
"<table>"
|
|
540
|
+
f"<tr><td class='field'>Severity</td><td>{severity.capitalize()}</td></tr>"
|
|
541
|
+
f"<tr><td class='field'>CVSS</td><td>{cvss}</td></tr>"
|
|
542
|
+
f"<tr><td class='field'>CWE</td><td>{cell(finding.cwe)}</td></tr>"
|
|
543
|
+
f"<tr><td class='field'>OWASP</td><td>{cell(finding.owasp)}</td></tr>"
|
|
544
|
+
f"<tr><td class='field'>Affected asset</td><td>{cell(_affected_asset(finding))}</td></tr>"
|
|
545
|
+
f"<tr><td class='field'>Category</td><td>{esc(finding.category)}</td></tr>"
|
|
546
|
+
f"<tr><td class='field'>Status</td><td>{esc(_status_label(state, finding))}</td></tr>"
|
|
547
|
+
f"<tr><td class='field'>Confidence</td><td>{esc(finding.confidence)}</td></tr>"
|
|
548
|
+
f"<tr><td class='field'>Discovered in round</td><td>{finding.round}</td></tr>"
|
|
549
|
+
"</table>"
|
|
550
|
+
f"<div class='subhead'>Description</div><p>{cell(finding.description)}</p>"
|
|
551
|
+
f"<div class='subhead'>Impact</div><p>{cell(finding.impact)}</p>"
|
|
552
|
+
f"{reproduction}{evidence}"
|
|
553
|
+
f"<div class='subhead'>Remediation</div><p class='rec'>{cell(finding.recommendation)}</p>"
|
|
554
|
+
f"{mitigations}</section>"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Severity model / remediation / retest.
|
|
558
|
+
severity_rows = "".join(
|
|
559
|
+
f"<tr><td><span class='badge {name.lower() if name != 'Informational' else 'info'}'>"
|
|
560
|
+
f"{name.upper() if name != 'Informational' else 'INFO'}</span></td>"
|
|
561
|
+
f"<td>{esc(cvss_range)}</td><td>{esc(meaning)}</td></tr>"
|
|
562
|
+
for name, cvss_range, meaning in SEVERITY_MODEL
|
|
563
|
+
)
|
|
564
|
+
roadmap_rows = (
|
|
565
|
+
"".join(
|
|
566
|
+
f"<tr><td>{REMEDIATION_SLA[_raw_severity(f)][0]}</td>"
|
|
567
|
+
f"<td><code>{esc(f.id)}</code> {esc(f.title or 'Untitled')}</td>"
|
|
568
|
+
f"<td>{_raw_severity(f).capitalize()}</td>"
|
|
569
|
+
f"<td>{esc(REMEDIATION_SLA[_raw_severity(f)][1])}</td></tr>"
|
|
570
|
+
for f in findings
|
|
571
|
+
)
|
|
572
|
+
or "<tr><td colspan='4'>No remediation required.</td></tr>"
|
|
573
|
+
)
|
|
574
|
+
retest_rows = (
|
|
575
|
+
"".join(
|
|
576
|
+
f"<tr><td><code>{esc(f.id)}</code></td>"
|
|
577
|
+
f"<td><span class='badge {_raw_severity(f)}'>{SEVERITY_BADGE[_raw_severity(f)]}</span></td>"
|
|
578
|
+
f"<td>{esc(_status_label(state, f))}</td></tr>"
|
|
579
|
+
for f in findings
|
|
580
|
+
)
|
|
581
|
+
or "<tr><td colspan='3'>No findings recorded.</td></tr>"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
notes = "".join(f"<li>{esc(n)}</li>" for n in state.notes)
|
|
585
|
+
timeline = (
|
|
586
|
+
"".join(
|
|
587
|
+
f"<li><strong>Round {r.index} · Red:</strong> {esc(r.red_summary or '(none)')}<br>"
|
|
588
|
+
f"<strong>Round {r.index} · Blue:</strong> {esc(r.blue_summary or '(none)')}</li>"
|
|
589
|
+
for r in state.rounds
|
|
590
|
+
)
|
|
591
|
+
or "<li>No rounds recorded.</li>"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return f"""<!doctype html>
|
|
595
|
+
<html lang="en">
|
|
596
|
+
<head>
|
|
597
|
+
<meta charset="utf-8">
|
|
598
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
599
|
+
<title>Penetration Test Report - {esc(state.name)}</title>
|
|
600
|
+
<style>{_HTML_CSS}</style>
|
|
601
|
+
</head>
|
|
602
|
+
<body>
|
|
603
|
+
<div class="wrap">
|
|
604
|
+
<header class="cover">
|
|
605
|
+
<div class="class">{CLASSIFICATION}</div>
|
|
606
|
+
<h1>Penetration Test Report</h1>
|
|
607
|
+
<p class="target">{esc(state.name)} · {esc(state.target or "n/a")}</p>
|
|
608
|
+
<div class="line">Report version {REPORT_VERSION} · session <code>{esc(state.id)}</code>
|
|
609
|
+
· generated {esc(ctx["generated_at"])}</div>
|
|
610
|
+
</header>
|
|
611
|
+
|
|
612
|
+
<h2>Document control</h2>
|
|
613
|
+
<table>
|
|
614
|
+
<tr><td class="field">Report version</td><td>{REPORT_VERSION}</td></tr>
|
|
615
|
+
<tr><td class="field">Classification</td><td>{CLASSIFICATION}</td></tr>
|
|
616
|
+
<tr><td class="field">Target</td><td>{esc(state.target or "n/a")} ({esc(state.target_kind)})</td></tr>
|
|
617
|
+
<tr><td class="field">Scope</td><td>{esc(", ".join(state.scope) or "n/a")}</td></tr>
|
|
618
|
+
<tr><td class="field">Model</td><td>{esc(state.provider or "n/a")} / {esc(state.model or "n/a")}</td></tr>
|
|
619
|
+
<tr><td class="field">Started</td><td>{esc(state.started_at)}</td></tr>
|
|
620
|
+
<tr><td class="field">Ended</td><td>{esc(state.ended_at or "in progress")}</td></tr>
|
|
621
|
+
<tr><td class="field">Prepared by</td><td>SplitAgent (automated)</td></tr>
|
|
622
|
+
</table>
|
|
623
|
+
|
|
624
|
+
<h2>1. Executive summary</h2>
|
|
625
|
+
<p><strong>Overall risk: {esc(ctx["overall_risk"])}</strong></p>
|
|
626
|
+
<div class="cards">
|
|
627
|
+
<div class="card"><div class="n">{len(findings)}</div><div class="l">Findings</div></div>
|
|
628
|
+
<div class="card"><div class="n">{counts["critical"]}</div><div class="l">Critical</div></div>
|
|
629
|
+
<div class="card"><div class="n">{counts["high"]}</div><div class="l">High</div></div>
|
|
630
|
+
<div class="card"><div class="n">{counts["medium"]}</div><div class="l">Medium</div></div>
|
|
631
|
+
<div class="card"><div class="n">{state.resilience_score()}</div><div class="l">Resilience</div></div>
|
|
632
|
+
</div>
|
|
633
|
+
{summary_html}
|
|
634
|
+
|
|
635
|
+
<h2>2. Scope and rules of engagement</h2>
|
|
636
|
+
<table>
|
|
637
|
+
<tr><td class="field">In scope</td><td>{esc(", ".join(state.scope) or state.target or "n/a")}</td></tr>
|
|
638
|
+
<tr><td class="field">Out of scope</td><td>Any host not listed above.</td></tr>
|
|
639
|
+
<tr><td class="field">Testing window</td><td>{esc(state.started_at)} to {esc(state.ended_at or "now")}</td></tr>
|
|
640
|
+
<tr><td class="field">Authorisation</td><td>Performed by the asset owner or an authorised
|
|
641
|
+
party. All activity was non-destructive.</td></tr>
|
|
642
|
+
</table>
|
|
643
|
+
|
|
644
|
+
<h2>3. Methodology and standards</h2>
|
|
645
|
+
<p>The engagement followed {esc(METHODOLOGY_PHASES)}.</p>
|
|
646
|
+
<ul>{"".join(f"<li>{esc(s)}</li>" for s in METHODOLOGY_STANDARDS)}</ul>
|
|
647
|
+
|
|
648
|
+
<h2>4. Severity model</h2>
|
|
649
|
+
<table><thead><tr><th>Severity</th><th>CVSS v3.1</th><th>Meaning</th></tr></thead>
|
|
650
|
+
<tbody>{severity_rows}</tbody></table>
|
|
651
|
+
|
|
652
|
+
<h2>5. Findings summary</h2>
|
|
653
|
+
<table><thead><tr><th>ID</th><th>Title</th><th>Severity</th><th>CVSS</th>
|
|
654
|
+
<th>Affected asset</th><th>Status</th></tr></thead><tbody>{summary_rows}</tbody></table>
|
|
655
|
+
|
|
656
|
+
<h2>6. Detailed findings</h2>
|
|
657
|
+
{"".join(cards) or "<p>No findings were recorded.</p>"}
|
|
658
|
+
|
|
659
|
+
<h2>7. Remediation roadmap</h2>
|
|
660
|
+
<table><thead><tr><th>Priority</th><th>Finding</th><th>Severity</th><th>Target SLA</th></tr></thead>
|
|
661
|
+
<tbody>{roadmap_rows}</tbody></table>
|
|
662
|
+
|
|
663
|
+
<h2>8. Retest and status tracking</h2>
|
|
664
|
+
<table><thead><tr><th>ID</th><th>Original severity</th><th>Status</th></tr></thead>
|
|
665
|
+
<tbody>{retest_rows}</tbody></table>
|
|
666
|
+
|
|
667
|
+
<h2>9. Appendices</h2>
|
|
668
|
+
<p><strong>Model usage:</strong> {esc(", ".join(f"{k}={v}" for k, v in state.usage.items()) or "n/a")}</p>
|
|
669
|
+
<div class="subhead">Analyst notes</div>
|
|
670
|
+
<ul>{notes or "<li>None.</li>"}</ul>
|
|
671
|
+
<div class="subhead">Round timeline</div>
|
|
672
|
+
<ul>{timeline}</ul>
|
|
673
|
+
|
|
674
|
+
<h2>10. Limitations and disclaimer</h2>
|
|
675
|
+
<blockquote>This report reflects automated, non-destructive testing within the authorised
|
|
676
|
+
scope. Automated tools do not replace human judgement: absence of a finding does not prove
|
|
677
|
+
absence of a vulnerability. Evidence may contain sensitive values captured during testing
|
|
678
|
+
and must be reviewed before distribution. The report must be reviewed by a qualified human
|
|
679
|
+
before remediation decisions are made.</blockquote>
|
|
680
|
+
|
|
681
|
+
<footer>Generated by SplitAgent · {esc(ctx["generated_at"])}</footer>
|
|
682
|
+
</div>
|
|
683
|
+
</body>
|
|
684
|
+
</html>
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def build_json(state: SessionState) -> str:
|
|
689
|
+
payload: dict[str, Any] = state.to_dict()
|
|
690
|
+
payload["metrics"] = {
|
|
691
|
+
"resilience_score": state.resilience_score(),
|
|
692
|
+
"resilience": state.resilience_breakdown(),
|
|
693
|
+
"severity_counts": state.severity_counts(),
|
|
694
|
+
"finding_count": len(state.findings),
|
|
695
|
+
"mitigation_count": len(state.mitigations),
|
|
696
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
697
|
+
}
|
|
698
|
+
return json.dumps(payload, indent=2, ensure_ascii=False)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def safe_stem(value: str, fallback: str = "report") -> str:
|
|
702
|
+
"""A filesystem-safe fragment: no separators, no traversal, no control chars."""
|
|
703
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]", "_", str(value or "")).strip("._")
|
|
704
|
+
return cleaned[:80] or fallback
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def write_reports(
|
|
708
|
+
state: SessionState, settings: ReportSettings, output_dir: Path | None = None
|
|
709
|
+
) -> list[Path]:
|
|
710
|
+
directory = output_dir or Path(settings.output_dir)
|
|
711
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
712
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
713
|
+
stem = f"{safe_stem(state.name, 'splitagent')}-{safe_stem(state.id, 'session')}-{stamp}"
|
|
714
|
+
written: list[Path] = []
|
|
715
|
+
for fmt in settings.formats:
|
|
716
|
+
fmt = fmt.lower()
|
|
717
|
+
if fmt in ("md", "markdown"):
|
|
718
|
+
path = directory / f"{stem}.md"
|
|
719
|
+
path.write_text(build_markdown(state, settings), encoding="utf-8")
|
|
720
|
+
written.append(path)
|
|
721
|
+
elif fmt == "html":
|
|
722
|
+
path = directory / f"{stem}.html"
|
|
723
|
+
path.write_text(build_html(state, settings), encoding="utf-8")
|
|
724
|
+
written.append(path)
|
|
725
|
+
elif fmt == "json":
|
|
726
|
+
path = directory / f"{stem}.json"
|
|
727
|
+
path.write_text(build_json(state), encoding="utf-8")
|
|
728
|
+
written.append(path)
|
|
729
|
+
return written
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def cvss_detail(vector: str) -> dict[str, Any]:
|
|
733
|
+
return describe(vector)
|