defectdojo-cli2 0.1.18__tar.gz → 0.1.20__tar.gz
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.
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/PKG-INFO +8 -3
- defectdojo_cli2-0.1.20/defectdojo_cli2/Reports.py +434 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/__init__.py +1 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/__main__.py +5 -0
- defectdojo_cli2-0.1.20/defectdojo_cli2/templates/report.html +377 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/pyproject.toml +10 -2
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/AUTHORS +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/LICENSE +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/README.md +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/Announcements.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/ApiToken.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/EnvDefaults.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/ImportLanguages.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/Products.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/ReImportScan.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/engagements.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/findings.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/tests.py +0 -0
- {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/util.py +0 -0
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: defectdojo-cli2
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.20
|
|
4
4
|
Summary: CLI Wrapper for DefectDojo using APIv2
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: AUTHORS
|
|
7
|
+
License-File: LICENSE
|
|
6
8
|
Author: Mikke Schirén
|
|
7
9
|
Author-email: mikke.schiren@digitalist.com
|
|
8
10
|
Requires-Python: >=3.13,<4.0
|
|
9
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
12
|
Classifier: Programming Language :: Python :: 3
|
|
11
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
|
|
16
|
+
Requires-Dist: markdown (>=3.7,<4.0)
|
|
12
17
|
Requires-Dist: requests (>=2.32.5,<3.0.0)
|
|
13
18
|
Requires-Dist: rich-argparse (>=1.7.2,<2.0.0)
|
|
14
19
|
Requires-Dist: setuptools (>=78.1.1,<79.0.0)
|
|
15
|
-
Requires-Dist: tabulate (>=0.
|
|
20
|
+
Requires-Dist: tabulate (>=0.10.0,<0.11.0)
|
|
16
21
|
Description-Content-Type: text/markdown
|
|
17
22
|
|
|
18
23
|
# DefectDojo CLI
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from jinja2 import Environment, FileSystemLoader
|
|
7
|
+
import markdown
|
|
8
|
+
from rich_argparse import RichHelpFormatter
|
|
9
|
+
from defectdojo_cli2.util import Util
|
|
10
|
+
from defectdojo_cli2.EnvDefaults import EnvDefaults
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
|
14
|
+
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _convert_bare_urls_to_markdown(text):
|
|
18
|
+
url_pattern = re.compile(r'(?<!\[)(?<!\])(https?://[^\s\)"\'<>]+)')
|
|
19
|
+
return url_pattern.sub(r'[\1](\1)', text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _markdown_convert(text):
|
|
23
|
+
text = _convert_bare_urls_to_markdown(text)
|
|
24
|
+
html = markdown.markdown(
|
|
25
|
+
text,
|
|
26
|
+
extensions=['nl2br', 'tables', 'fenced_code']
|
|
27
|
+
)
|
|
28
|
+
html = html.replace('<h1>', '<h4>').replace('</h1>', '</h4>')
|
|
29
|
+
html = html.replace('<h2>', '<h4>').replace('</h2>', '</h4>')
|
|
30
|
+
html = html.replace('<h3>', '<h4>').replace('</h3>', '</h4>')
|
|
31
|
+
return html
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render_html_from_json(json_data, active_only=False, template_path=None):
|
|
35
|
+
findings = json_data.get("findings", [])
|
|
36
|
+
if active_only:
|
|
37
|
+
findings = [f for f in findings if f.get("active", False)]
|
|
38
|
+
|
|
39
|
+
for finding in findings:
|
|
40
|
+
if finding.get("description"):
|
|
41
|
+
finding["description_html"] = _markdown_convert(finding["description"])
|
|
42
|
+
if finding.get("mitigation"):
|
|
43
|
+
finding["mitigation_html"] = _markdown_convert(finding["mitigation"])
|
|
44
|
+
if finding.get("impact"):
|
|
45
|
+
finding["impact_html"] = _markdown_convert(finding["impact"])
|
|
46
|
+
if finding.get("steps_to_reproduce"):
|
|
47
|
+
finding["steps_to_reproduce_html"] = _markdown_convert(finding["steps_to_reproduce"])
|
|
48
|
+
if finding.get("references"):
|
|
49
|
+
finding["references_html"] = _markdown_convert(finding["references"])
|
|
50
|
+
if finding.get("file_path"):
|
|
51
|
+
finding["file_path_html"] = finding["file_path"]
|
|
52
|
+
if finding.get("line"):
|
|
53
|
+
finding["line_html"] = finding["line"]
|
|
54
|
+
|
|
55
|
+
req_resp = finding.get("request_response", {})
|
|
56
|
+
req_list = req_resp.get("req_resp", [])
|
|
57
|
+
if req_list:
|
|
58
|
+
req_resp_md = ""
|
|
59
|
+
for idx, item in enumerate(req_list):
|
|
60
|
+
if idx > 0:
|
|
61
|
+
req_resp_md += "\n\n---\n\n"
|
|
62
|
+
req_resp_md += "##### Request\n```\n" + item.get("request", "") + "\n```\n\n##### Response\n```\n" + item.get("response", "") + "\n```"
|
|
63
|
+
finding["request_response_html"] = _markdown_convert(req_resp_md)
|
|
64
|
+
|
|
65
|
+
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
66
|
+
for finding in findings:
|
|
67
|
+
sev = finding.get("severity", "Info")
|
|
68
|
+
sev_lower = sev.lower()
|
|
69
|
+
if sev_lower in severity_counts:
|
|
70
|
+
severity_counts[sev_lower] += 1
|
|
71
|
+
|
|
72
|
+
if template_path:
|
|
73
|
+
if os.path.isfile(template_path):
|
|
74
|
+
custom_env = Environment(loader=FileSystemLoader(os.path.dirname(template_path)))
|
|
75
|
+
template = custom_env.get_template(os.path.basename(template_path))
|
|
76
|
+
else:
|
|
77
|
+
raise FileNotFoundError(f"Template file not found: {template_path}")
|
|
78
|
+
else:
|
|
79
|
+
template = jinja_env.get_template("report.html")
|
|
80
|
+
|
|
81
|
+
return template.render(
|
|
82
|
+
report_name=json_data.get("report_name", "Security report"),
|
|
83
|
+
report_info=json_data.get("report_info", ""),
|
|
84
|
+
product=json_data.get("product"),
|
|
85
|
+
engagement=json_data.get("engagement"),
|
|
86
|
+
findings=findings,
|
|
87
|
+
severity_counts=severity_counts,
|
|
88
|
+
team_name=json_data.get("team_name", "Security team"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Reports(object):
|
|
93
|
+
def parse_cli_args(self):
|
|
94
|
+
parser = argparse.ArgumentParser(
|
|
95
|
+
description="Perform <sub_command> related to reports in DefectDojo",
|
|
96
|
+
usage="""defectdojo reports <sub_command> [<args>]
|
|
97
|
+
|
|
98
|
+
You can use the following sub_commands:
|
|
99
|
+
generate-for-engagement Generate a report for an engagement
|
|
100
|
+
generate-for-product Generate a report for a product
|
|
101
|
+
""",
|
|
102
|
+
formatter_class=RichHelpFormatter,
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument("sub_command", help="Sub_command to run")
|
|
105
|
+
args = parser.parse_args(sys.argv[2:3])
|
|
106
|
+
method_name = "_" + args.sub_command.replace("-", "_")
|
|
107
|
+
if not hasattr(self, method_name):
|
|
108
|
+
print("Unrecognized sub_command " + args.sub_command)
|
|
109
|
+
parser.print_help()
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
getattr(self, method_name)()
|
|
112
|
+
|
|
113
|
+
def generate_for_engagement(
|
|
114
|
+
self,
|
|
115
|
+
url,
|
|
116
|
+
api_key,
|
|
117
|
+
engagement_id,
|
|
118
|
+
report_type="HTML",
|
|
119
|
+
include_executive_summary=False,
|
|
120
|
+
include_finding_notes=False,
|
|
121
|
+
include_finding_images=False,
|
|
122
|
+
include_table_of_contents=False,
|
|
123
|
+
title="",
|
|
124
|
+
filename=None,
|
|
125
|
+
**kwargs,
|
|
126
|
+
):
|
|
127
|
+
api_url = url.rstrip("/") + f"/api/v2/engagements/{engagement_id}/generate_report/"
|
|
128
|
+
|
|
129
|
+
payload = {
|
|
130
|
+
"report_type": report_type,
|
|
131
|
+
"include_executive_summary": include_executive_summary,
|
|
132
|
+
"include_finding_notes": include_finding_notes,
|
|
133
|
+
"include_finding_images": include_finding_images,
|
|
134
|
+
"include_table_of_contents": include_table_of_contents,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if title:
|
|
138
|
+
payload["title"] = title
|
|
139
|
+
|
|
140
|
+
payload_json = json.dumps(payload)
|
|
141
|
+
|
|
142
|
+
response = Util().request_apiv2(
|
|
143
|
+
"POST",
|
|
144
|
+
api_url,
|
|
145
|
+
api_key,
|
|
146
|
+
data=payload_json,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if filename and response.status_code == 200:
|
|
150
|
+
with open(filename, "wb") as f:
|
|
151
|
+
f.write(response.content)
|
|
152
|
+
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
def _generate_for_engagement(self):
|
|
156
|
+
parser = argparse.ArgumentParser(
|
|
157
|
+
description="Generate a report for an engagement",
|
|
158
|
+
usage="defectdojo reports generate-for-engagement [<args>]",
|
|
159
|
+
formatter_class=RichHelpFormatter,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
optional = parser._action_groups.pop()
|
|
163
|
+
required = parser.add_argument_group("required arguments")
|
|
164
|
+
|
|
165
|
+
required.add_argument(
|
|
166
|
+
"--url",
|
|
167
|
+
action=EnvDefaults,
|
|
168
|
+
envvar="DEFECTDOJO_URL",
|
|
169
|
+
help="DefectDojo URL",
|
|
170
|
+
required=True,
|
|
171
|
+
)
|
|
172
|
+
required.add_argument(
|
|
173
|
+
"--api_key",
|
|
174
|
+
action=EnvDefaults,
|
|
175
|
+
envvar="DEFECTDOJO_API_KEY",
|
|
176
|
+
help="API v2 Key",
|
|
177
|
+
required=True,
|
|
178
|
+
)
|
|
179
|
+
required.add_argument(
|
|
180
|
+
"--engagement_id",
|
|
181
|
+
action=EnvDefaults,
|
|
182
|
+
envvar="DEFECTDOJO_ENGAGEMENT_ID",
|
|
183
|
+
help="Engagement ID",
|
|
184
|
+
required=True,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
optional.add_argument(
|
|
188
|
+
"--report_type",
|
|
189
|
+
help="Report type",
|
|
190
|
+
choices=["HTML", "JSON"],
|
|
191
|
+
default="HTML",
|
|
192
|
+
)
|
|
193
|
+
optional.add_argument(
|
|
194
|
+
"--include_executive_summary",
|
|
195
|
+
help="Include executive summary in report",
|
|
196
|
+
action="store_true",
|
|
197
|
+
default=False,
|
|
198
|
+
)
|
|
199
|
+
optional.add_argument(
|
|
200
|
+
"--include_finding_notes",
|
|
201
|
+
help="Include finding notes in report",
|
|
202
|
+
action="store_true",
|
|
203
|
+
default=False,
|
|
204
|
+
)
|
|
205
|
+
optional.add_argument(
|
|
206
|
+
"--include_finding_images",
|
|
207
|
+
help="Include finding images in report",
|
|
208
|
+
action="store_true",
|
|
209
|
+
default=False,
|
|
210
|
+
)
|
|
211
|
+
optional.add_argument(
|
|
212
|
+
"--include_table_of_contents",
|
|
213
|
+
help="Include table of contents in report",
|
|
214
|
+
action="store_true",
|
|
215
|
+
default=False,
|
|
216
|
+
)
|
|
217
|
+
optional.add_argument(
|
|
218
|
+
"--title",
|
|
219
|
+
help="Report title",
|
|
220
|
+
default="",
|
|
221
|
+
)
|
|
222
|
+
optional.add_argument(
|
|
223
|
+
"--active",
|
|
224
|
+
help="Filter to active findings only (client-side)",
|
|
225
|
+
action="store_true",
|
|
226
|
+
default=False,
|
|
227
|
+
)
|
|
228
|
+
optional.add_argument(
|
|
229
|
+
"--filename",
|
|
230
|
+
help="Save report to file (default: output to stdout for JSON/HTML)",
|
|
231
|
+
)
|
|
232
|
+
optional.add_argument(
|
|
233
|
+
"--template",
|
|
234
|
+
help="Custom HTML template file path (default: built-in template)",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
parser._action_groups.append(optional)
|
|
238
|
+
args = vars(parser.parse_args(sys.argv[3:]))
|
|
239
|
+
|
|
240
|
+
response = self.generate_for_engagement(**args)
|
|
241
|
+
|
|
242
|
+
if response.status_code == 200:
|
|
243
|
+
active_only = args.get("active", False)
|
|
244
|
+
template_path = args.get("template")
|
|
245
|
+
|
|
246
|
+
if args.get("filename"):
|
|
247
|
+
if args["report_type"] == "HTML":
|
|
248
|
+
json_data = json.loads(response.text)
|
|
249
|
+
if active_only:
|
|
250
|
+
json_data["findings"] = [f for f in json_data["findings"] if f.get("active", False)]
|
|
251
|
+
html_content = _render_html_from_json(json_data, active_only=active_only, template_path=template_path)
|
|
252
|
+
with open(args["filename"], "w") as f:
|
|
253
|
+
f.write(html_content)
|
|
254
|
+
else:
|
|
255
|
+
with open(args["filename"], "wb") as f:
|
|
256
|
+
f.write(response.content)
|
|
257
|
+
print(f"Report saved to {args['filename']}")
|
|
258
|
+
elif args["report_type"] == "JSON":
|
|
259
|
+
try:
|
|
260
|
+
json_out = json.loads(response.text)
|
|
261
|
+
if active_only:
|
|
262
|
+
json_out["findings"] = [f for f in json_out["findings"] if f.get("active", False)]
|
|
263
|
+
print(json.dumps(json_out, indent=4))
|
|
264
|
+
except json.JSONDecodeError:
|
|
265
|
+
print(response.text)
|
|
266
|
+
elif args["report_type"] == "HTML":
|
|
267
|
+
json_data = json.loads(response.text)
|
|
268
|
+
print(_render_html_from_json(json_data, active_only=active_only, template_path=template_path))
|
|
269
|
+
else:
|
|
270
|
+
print(response.text)
|
|
271
|
+
else:
|
|
272
|
+
print(response.text)
|
|
273
|
+
exit(1)
|
|
274
|
+
|
|
275
|
+
def generate_for_product(
|
|
276
|
+
self,
|
|
277
|
+
url,
|
|
278
|
+
api_key,
|
|
279
|
+
product_id,
|
|
280
|
+
report_type="HTML",
|
|
281
|
+
include_executive_summary=False,
|
|
282
|
+
include_finding_notes=False,
|
|
283
|
+
include_finding_images=False,
|
|
284
|
+
include_table_of_contents=False,
|
|
285
|
+
title="",
|
|
286
|
+
filename=None,
|
|
287
|
+
**kwargs,
|
|
288
|
+
):
|
|
289
|
+
api_url = url.rstrip("/") + f"/api/v2/products/{product_id}/generate_report/"
|
|
290
|
+
|
|
291
|
+
payload = {
|
|
292
|
+
"report_type": report_type,
|
|
293
|
+
"include_executive_summary": include_executive_summary,
|
|
294
|
+
"include_finding_notes": include_finding_notes,
|
|
295
|
+
"include_finding_images": include_finding_images,
|
|
296
|
+
"include_table_of_contents": include_table_of_contents,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if title:
|
|
300
|
+
payload["title"] = title
|
|
301
|
+
|
|
302
|
+
payload_json = json.dumps(payload)
|
|
303
|
+
|
|
304
|
+
response = Util().request_apiv2(
|
|
305
|
+
"POST",
|
|
306
|
+
api_url,
|
|
307
|
+
api_key,
|
|
308
|
+
data=payload_json,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if filename and response.status_code == 200:
|
|
312
|
+
with open(filename, "wb") as f:
|
|
313
|
+
f.write(response.content)
|
|
314
|
+
|
|
315
|
+
return response
|
|
316
|
+
|
|
317
|
+
def _generate_for_product(self):
|
|
318
|
+
parser = argparse.ArgumentParser(
|
|
319
|
+
description="Generate a report for a product",
|
|
320
|
+
usage="defectdojo reports generate-for-product [<args>]",
|
|
321
|
+
formatter_class=RichHelpFormatter,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
optional = parser._action_groups.pop()
|
|
325
|
+
required = parser.add_argument_group("required arguments")
|
|
326
|
+
|
|
327
|
+
required.add_argument(
|
|
328
|
+
"--url",
|
|
329
|
+
action=EnvDefaults,
|
|
330
|
+
envvar="DEFECTDOJO_URL",
|
|
331
|
+
help="DefectDojo URL",
|
|
332
|
+
required=True,
|
|
333
|
+
)
|
|
334
|
+
required.add_argument(
|
|
335
|
+
"--api_key",
|
|
336
|
+
action=EnvDefaults,
|
|
337
|
+
envvar="DEFECTDOJO_API_KEY",
|
|
338
|
+
help="API v2 Key",
|
|
339
|
+
required=True,
|
|
340
|
+
)
|
|
341
|
+
required.add_argument(
|
|
342
|
+
"--product_id",
|
|
343
|
+
action=EnvDefaults,
|
|
344
|
+
envvar="DEFECTDOJO_PRODUCT_ID",
|
|
345
|
+
help="Product ID",
|
|
346
|
+
required=True,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
optional.add_argument(
|
|
350
|
+
"--report_type",
|
|
351
|
+
help="Report type",
|
|
352
|
+
choices=["HTML", "JSON"],
|
|
353
|
+
default="HTML",
|
|
354
|
+
)
|
|
355
|
+
optional.add_argument(
|
|
356
|
+
"--include_executive_summary",
|
|
357
|
+
help="Include executive summary in report",
|
|
358
|
+
action="store_true",
|
|
359
|
+
default=False,
|
|
360
|
+
)
|
|
361
|
+
optional.add_argument(
|
|
362
|
+
"--include_finding_notes",
|
|
363
|
+
help="Include finding notes in report",
|
|
364
|
+
action="store_true",
|
|
365
|
+
default=False,
|
|
366
|
+
)
|
|
367
|
+
optional.add_argument(
|
|
368
|
+
"--include_finding_images",
|
|
369
|
+
help="Include finding images in report",
|
|
370
|
+
action="store_true",
|
|
371
|
+
default=False,
|
|
372
|
+
)
|
|
373
|
+
optional.add_argument(
|
|
374
|
+
"--include_table_of_contents",
|
|
375
|
+
help="Include table of contents in report",
|
|
376
|
+
action="store_true",
|
|
377
|
+
default=False,
|
|
378
|
+
)
|
|
379
|
+
optional.add_argument(
|
|
380
|
+
"--title",
|
|
381
|
+
help="Report title",
|
|
382
|
+
default="",
|
|
383
|
+
)
|
|
384
|
+
optional.add_argument(
|
|
385
|
+
"--active",
|
|
386
|
+
help="Filter to active findings only (client-side)",
|
|
387
|
+
action="store_true",
|
|
388
|
+
default=False,
|
|
389
|
+
)
|
|
390
|
+
optional.add_argument(
|
|
391
|
+
"--filename",
|
|
392
|
+
help="Save report to file (default: output to stdout for JSON/HTML)",
|
|
393
|
+
)
|
|
394
|
+
optional.add_argument(
|
|
395
|
+
"--template",
|
|
396
|
+
help="Custom HTML template file path (default: built-in template)",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
parser._action_groups.append(optional)
|
|
400
|
+
args = vars(parser.parse_args(sys.argv[3:]))
|
|
401
|
+
|
|
402
|
+
response = self.generate_for_product(**args)
|
|
403
|
+
|
|
404
|
+
if response.status_code == 200:
|
|
405
|
+
active_only = args.get("active", False)
|
|
406
|
+
template_path = args.get("template")
|
|
407
|
+
if args.get("filename"):
|
|
408
|
+
if args["report_type"] == "HTML":
|
|
409
|
+
json_data = json.loads(response.text)
|
|
410
|
+
if active_only:
|
|
411
|
+
json_data["findings"] = [f for f in json_data["findings"] if f.get("active", False)]
|
|
412
|
+
html_content = _render_html_from_json(json_data, active_only=active_only, template_path=template_path)
|
|
413
|
+
with open(args["filename"], "w") as f:
|
|
414
|
+
f.write(html_content)
|
|
415
|
+
else:
|
|
416
|
+
with open(args["filename"], "wb") as f:
|
|
417
|
+
f.write(response.content)
|
|
418
|
+
print(f"Report saved to {args['filename']}")
|
|
419
|
+
elif args["report_type"] == "JSON":
|
|
420
|
+
try:
|
|
421
|
+
json_out = json.loads(response.text)
|
|
422
|
+
if active_only:
|
|
423
|
+
json_out["findings"] = [f for f in json_out["findings"] if f.get("active", False)]
|
|
424
|
+
print(json.dumps(json_out, indent=4))
|
|
425
|
+
except json.JSONDecodeError:
|
|
426
|
+
print(response.text)
|
|
427
|
+
elif args["report_type"] == "HTML":
|
|
428
|
+
json_data = json.loads(response.text)
|
|
429
|
+
print(_render_html_from_json(json_data, active_only=active_only, template_path=template_path))
|
|
430
|
+
else:
|
|
431
|
+
print(response.text)
|
|
432
|
+
else:
|
|
433
|
+
print(response.text)
|
|
434
|
+
exit(1)
|
|
@@ -7,6 +7,7 @@ from .ApiToken import ApiToken
|
|
|
7
7
|
from .ReImportScan import ReImportScan
|
|
8
8
|
from .Products import Products
|
|
9
9
|
from .ImportLanguages import ImportLanguages
|
|
10
|
+
from .Reports import Reports
|
|
10
11
|
import pkg_resources # part of setuptools
|
|
11
12
|
|
|
12
13
|
__version__ = pkg_resources.get_distribution("defectdojo_cli2").version
|
|
@@ -9,6 +9,7 @@ from defectdojo_cli2 import ApiToken
|
|
|
9
9
|
from defectdojo_cli2 import ImportLanguages
|
|
10
10
|
from defectdojo_cli2 import ReImportScan
|
|
11
11
|
from defectdojo_cli2 import Products
|
|
12
|
+
from defectdojo_cli2 import Reports
|
|
12
13
|
from defectdojo_cli2 import __version__
|
|
13
14
|
|
|
14
15
|
|
|
@@ -28,6 +29,7 @@ class DefectDojoCLI(object):
|
|
|
28
29
|
import_languages Operations related to import languages (import_languages --help for more details)
|
|
29
30
|
reimport_scan Operations related to reimport scans (reimport_scan --help for more details)
|
|
30
31
|
products Operations related to products (products --help for more details)
|
|
32
|
+
reports Operations related to reports (reports --help for more details)
|
|
31
33
|
""",
|
|
32
34
|
formatter_class=RichHelpFormatter,
|
|
33
35
|
)
|
|
@@ -66,6 +68,9 @@ class DefectDojoCLI(object):
|
|
|
66
68
|
def _products(self):
|
|
67
69
|
Products().parse_cli_args()
|
|
68
70
|
|
|
71
|
+
def _reports(self):
|
|
72
|
+
Reports().parse_cli_args()
|
|
73
|
+
|
|
69
74
|
def _tests(self):
|
|
70
75
|
Tests().parse_cli_args()
|
|
71
76
|
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{ report_name }}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--secondary: #64748b;
|
|
10
|
+
--info: #3b82f6;
|
|
11
|
+
--light: #f8fafc;
|
|
12
|
+
--dark: #1a1a1a;
|
|
13
|
+
--border: #e2e8f0;
|
|
14
|
+
--bg-card: #ffffff;
|
|
15
|
+
--bg-body: #f1f5f9;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
19
|
+
|
|
20
|
+
body {
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
22
|
+
background-color: var(--bg-body);
|
|
23
|
+
color: var(--dark);
|
|
24
|
+
line-height: 1.6;
|
|
25
|
+
padding: 2rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
29
|
+
|
|
30
|
+
.header {
|
|
31
|
+
background: var(--dark);
|
|
32
|
+
color: white;
|
|
33
|
+
padding: 2.5rem;
|
|
34
|
+
border-radius: 8px;
|
|
35
|
+
margin-bottom: 2rem;
|
|
36
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem; }
|
|
40
|
+
.header .meta { opacity: 0.9; font-size: 0.95rem; }
|
|
41
|
+
|
|
42
|
+
.grid {
|
|
43
|
+
display: grid;
|
|
44
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
45
|
+
gap: 1.5rem;
|
|
46
|
+
margin-bottom: 2rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.grid-severity {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: repeat(5, 1fr);
|
|
52
|
+
gap: 1rem;
|
|
53
|
+
margin-bottom: 2rem;
|
|
54
|
+
max-width: 100%;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@media (max-width: 900px) { .grid-severity { grid-template-columns: repeat(3, 1fr); } }
|
|
58
|
+
@media (max-width: 600px) { .grid-severity { grid-template-columns: repeat(2, 1fr); } }
|
|
59
|
+
|
|
60
|
+
.grid-single { grid-template-columns: 1fr; }
|
|
61
|
+
|
|
62
|
+
.card-total { background: var(--dark); color: white; text-align: center; }
|
|
63
|
+
.card-total h3 { color: rgba(255, 255, 255, 0.8); }
|
|
64
|
+
.card-total .value { color: white; }
|
|
65
|
+
|
|
66
|
+
.card {
|
|
67
|
+
background: var(--bg-card);
|
|
68
|
+
border-radius: 8px;
|
|
69
|
+
padding: 1.5rem;
|
|
70
|
+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
|
71
|
+
border: 1px solid var(--border);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.card h3 {
|
|
75
|
+
color: var(--secondary);
|
|
76
|
+
font-size: 0.85rem;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
text-transform: uppercase;
|
|
79
|
+
letter-spacing: 0.05em;
|
|
80
|
+
margin-bottom: 0.75rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.card .value { font-size: 1.75rem; font-weight: 700; color: var(--dark); }
|
|
84
|
+
.card .value.critical { color: #7c3aed; }
|
|
85
|
+
.card .value.high { color: #ef4444; }
|
|
86
|
+
.card .value.medium { color: #f59e0b; }
|
|
87
|
+
.card .value.low { color: #10b981; }
|
|
88
|
+
.card .value.info { color: var(--info); }
|
|
89
|
+
|
|
90
|
+
.severity-badge {
|
|
91
|
+
display: inline-block;
|
|
92
|
+
padding: 0.25rem 0.75rem;
|
|
93
|
+
border-radius: 9999px;
|
|
94
|
+
font-size: 0.75rem;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
text-transform: uppercase;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.severity-critical { background: #ede9fe; color: #7c3aed; }
|
|
100
|
+
.severity-high { background: #fee2e2; color: #ef4444; }
|
|
101
|
+
.severity-medium { background: #fef3c7; color: #b45309; }
|
|
102
|
+
.severity-low { background: #d1fae5; color: #047857; }
|
|
103
|
+
.severity-info { background: #dbeafe; color: var(--info); }
|
|
104
|
+
|
|
105
|
+
.section {
|
|
106
|
+
background: var(--bg-card);
|
|
107
|
+
border-radius: 8px;
|
|
108
|
+
padding: 1.5rem;
|
|
109
|
+
margin-bottom: 1.5rem;
|
|
110
|
+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
|
111
|
+
border: 1px solid var(--border);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.section h2 {
|
|
115
|
+
font-size: 1.25rem;
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
margin-bottom: 1rem;
|
|
118
|
+
padding-bottom: 0.75rem;
|
|
119
|
+
border-bottom: 2px solid var(--border);
|
|
120
|
+
color: var(--dark);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.finding {
|
|
124
|
+
padding: 1rem;
|
|
125
|
+
border: 1px solid var(--border);
|
|
126
|
+
border-radius: 8px;
|
|
127
|
+
margin-bottom: 1rem;
|
|
128
|
+
transition: box-shadow 0.2s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.finding:hover { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
|
|
132
|
+
.finding:last-child { margin-bottom: 0; }
|
|
133
|
+
|
|
134
|
+
.finding-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
|
|
135
|
+
.finding-title { font-weight: 600; font-size: 1rem; color: var(--dark); }
|
|
136
|
+
.finding-meta { display: flex; gap: 1rem; font-size: 0.85rem; color: var(--secondary); margin-top: 0.5rem; }
|
|
137
|
+
|
|
138
|
+
.finding-description,
|
|
139
|
+
.finding-mitigation,
|
|
140
|
+
.finding-impact,
|
|
141
|
+
.finding-steps,
|
|
142
|
+
.finding-references,
|
|
143
|
+
.finding-file-path {
|
|
144
|
+
font-size: 0.9rem;
|
|
145
|
+
color: #475569;
|
|
146
|
+
margin-top: 0.75rem;
|
|
147
|
+
padding-top: 0.75rem;
|
|
148
|
+
border-top: 1px solid var(--border);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.finding-request-response {
|
|
152
|
+
font-size: 0.85rem;
|
|
153
|
+
color: #475569;
|
|
154
|
+
margin-top: 0.75rem;
|
|
155
|
+
padding-top: 0.75rem;
|
|
156
|
+
border-top: 1px solid var(--border);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.finding-description pre,
|
|
160
|
+
.finding-mitigation pre,
|
|
161
|
+
.finding-impact pre,
|
|
162
|
+
.finding-steps pre,
|
|
163
|
+
.finding-request-response pre {
|
|
164
|
+
background: var(--light);
|
|
165
|
+
padding: 0.75rem;
|
|
166
|
+
border-radius: 6px;
|
|
167
|
+
overflow-x: auto;
|
|
168
|
+
font-size: 0.8rem;
|
|
169
|
+
margin-top: 0.5rem;
|
|
170
|
+
font-family: "Courier New", Courier, monospace;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.finding-description code,
|
|
174
|
+
.finding-mitigation code,
|
|
175
|
+
.finding-impact code,
|
|
176
|
+
.finding-steps code,
|
|
177
|
+
.finding-file-path code {
|
|
178
|
+
background: var(--light);
|
|
179
|
+
padding: 0.2rem 0.4rem;
|
|
180
|
+
border-radius: 3px;
|
|
181
|
+
font-family: "Courier New", Courier, monospace;
|
|
182
|
+
font-size: 0.85em;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.finding-mitigation h4,
|
|
186
|
+
.finding-impact h4,
|
|
187
|
+
.finding-steps h4,
|
|
188
|
+
.finding-references h4,
|
|
189
|
+
.finding-request-response h4,
|
|
190
|
+
.finding-file-path h4 {
|
|
191
|
+
display: block;
|
|
192
|
+
color: var(--dark);
|
|
193
|
+
margin-bottom: 0.5rem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.product-info { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }
|
|
197
|
+
.product-info .item { padding: 0.5rem 0; }
|
|
198
|
+
.product-info .label { font-size: 0.8rem; color: var(--secondary); font-weight: 500; }
|
|
199
|
+
.product-info .content { font-size: 0.95rem; color: var(--dark); }
|
|
200
|
+
|
|
201
|
+
.footer { background: var(--dark); color: white; padding: 2rem; margin-top: 2rem; text-align: center; }
|
|
202
|
+
|
|
203
|
+
.empty-state { text-align: center; padding: 3rem; color: var(--secondary); }
|
|
204
|
+
li { list-style: circle inside; }
|
|
205
|
+
hr { height: 1px; border-width: 0; color: gray; background-color: gray; margin: 8px 0; }
|
|
206
|
+
|
|
207
|
+
@media print {
|
|
208
|
+
body { padding: 0; background: white; }
|
|
209
|
+
.card, .section { box-shadow: none; border: 1px solid #ddd; }
|
|
210
|
+
}
|
|
211
|
+
</style>
|
|
212
|
+
</head>
|
|
213
|
+
<body>
|
|
214
|
+
<div class="container">
|
|
215
|
+
<div class="header">
|
|
216
|
+
<h1>{{ report_name }}</h1>
|
|
217
|
+
<div class="meta">{{ report_info }}</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div class="grid grid-single">
|
|
221
|
+
<div class="card card-total">
|
|
222
|
+
<h3>Total Findings</h3>
|
|
223
|
+
<div class="value">{{ findings|length }}</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div class="grid grid-severity">
|
|
228
|
+
<div class="card">
|
|
229
|
+
<h3>Critical</h3>
|
|
230
|
+
<div class="value critical">{{ severity_counts.critical }}</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div class="card">
|
|
233
|
+
<h3>High</h3>
|
|
234
|
+
<div class="value high">{{ severity_counts.high }}</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="card">
|
|
237
|
+
<h3>Medium</h3>
|
|
238
|
+
<div class="value medium">{{ severity_counts.medium }}</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="card">
|
|
241
|
+
<h3>Low</h3>
|
|
242
|
+
<div class="value low">{{ severity_counts.low }}</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="card">
|
|
245
|
+
<h3>Info</h3>
|
|
246
|
+
<div class="value info">{{ severity_counts.info }}</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{% if product %}
|
|
251
|
+
<div class="section">
|
|
252
|
+
<h2>Product information</h2>
|
|
253
|
+
<div class="product-info">
|
|
254
|
+
<div class="item">
|
|
255
|
+
<div class="label">Name</div>
|
|
256
|
+
<div class="content">{{ product.name }}</div>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="item">
|
|
259
|
+
<div class="label">ID</div>
|
|
260
|
+
<div class="content">{{ product.id }}</div>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="item">
|
|
263
|
+
<div class="label">Created</div>
|
|
264
|
+
<div class="content">{{ product.created[:10] }}</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="item">
|
|
267
|
+
<div class="label">Findings count</div>
|
|
268
|
+
<div class="content">{{ product.findings_count }}</div>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="item" style="grid-column: span 2">
|
|
271
|
+
<div class="label">Description</div>
|
|
272
|
+
<div class="content">{{ product.description or 'N/A' }}</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
{% endif %} {% if engagement %}
|
|
277
|
+
<div class="section">
|
|
278
|
+
<h2>Engagement Information</h2>
|
|
279
|
+
<div class="product-info">
|
|
280
|
+
<div class="item">
|
|
281
|
+
<div class="label">Name</div>
|
|
282
|
+
<div class="content">{{ engagement.name }}</div>
|
|
283
|
+
</div>
|
|
284
|
+
<div class="item">
|
|
285
|
+
<div class="label">Status</div>
|
|
286
|
+
<div class="content">{{ engagement.status }}</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="item">
|
|
289
|
+
<div class="label">Target start</div>
|
|
290
|
+
<div class="content">{{ engagement.target_start or 'N/A' }}</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="item">
|
|
293
|
+
<div class="label">Target end</div>
|
|
294
|
+
<div class="content">{{ engagement.target_end or 'N/A' }}</div>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="item">
|
|
297
|
+
<div class="label">Type</div>
|
|
298
|
+
<div class="content">{{ engagement.engagement_type or 'N/A' }}</div>
|
|
299
|
+
</div>
|
|
300
|
+
<div class="item">
|
|
301
|
+
<div class="label">Active</div>
|
|
302
|
+
<div class="content">
|
|
303
|
+
{{ 'Yes' if engagement.active else 'No' }}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
{% endif %}
|
|
309
|
+
|
|
310
|
+
<div class="section">
|
|
311
|
+
<h2>Findings ({{ findings|length }})</h2>
|
|
312
|
+
{% if findings %} {% for finding in findings %}
|
|
313
|
+
<div class="finding">
|
|
314
|
+
<div class="finding-header">
|
|
315
|
+
<h3 class="finding-title">{{ finding.title }}</h3>
|
|
316
|
+
<span class="severity-badge severity-{{ finding.severity|lower }}"
|
|
317
|
+
>{{ finding.severity }}</span
|
|
318
|
+
>
|
|
319
|
+
</div>
|
|
320
|
+
<div class="finding-meta">
|
|
321
|
+
<span>ID: {{ finding.id }}</span>
|
|
322
|
+
<span>Date: {{ finding.date }}</span>
|
|
323
|
+
<span>Active: {{ 'Yes' if finding.active else 'No' }}</span>
|
|
324
|
+
<span>Verified: {{ 'Yes' if finding.verified else 'No' }}</span>
|
|
325
|
+
</div>
|
|
326
|
+
{% if finding.description_html %}
|
|
327
|
+
<div class="finding-description">
|
|
328
|
+
{{ finding.description_html|safe }}
|
|
329
|
+
</div>
|
|
330
|
+
{% endif %} {% if finding.mitigation_html %}
|
|
331
|
+
<div class="finding-mitigation">
|
|
332
|
+
<h4>Mitigation</h4>
|
|
333
|
+
{{ finding.mitigation_html|safe }}
|
|
334
|
+
</div>
|
|
335
|
+
{% endif %} {% if finding.impact_html %}
|
|
336
|
+
<div class="finding-impact">
|
|
337
|
+
<h4>Impact</h4>
|
|
338
|
+
{{ finding.impact_html|safe }}
|
|
339
|
+
</div>
|
|
340
|
+
{% endif %} {% if finding.steps_to_reproduce_html %}
|
|
341
|
+
<div class="finding-steps">
|
|
342
|
+
<h4>Steps to Reproduce</h4>
|
|
343
|
+
{{ finding.steps_to_reproduce_html|safe }}
|
|
344
|
+
</div>
|
|
345
|
+
{% endif %} {% if finding.file_path_html %}
|
|
346
|
+
<div class="finding-file-path">
|
|
347
|
+
<p>
|
|
348
|
+
<h4>File</h4>
|
|
349
|
+
<code
|
|
350
|
+
>{{ finding.file_path_html }} {% if finding.line_html %}<br /> Line:
|
|
351
|
+
{{ finding.line_html }} {% endif %}</code
|
|
352
|
+
>
|
|
353
|
+
</p>
|
|
354
|
+
</div>
|
|
355
|
+
{% endif %} {% if finding.request_response_html %}
|
|
356
|
+
<div class="finding-request-response">
|
|
357
|
+
<h4>Request and response</h4>
|
|
358
|
+
{{ finding.request_response_html|safe }}
|
|
359
|
+
</div>
|
|
360
|
+
{% endif %} {% if finding.references_html %}
|
|
361
|
+
<div class="finding-references">
|
|
362
|
+
<h4>References:</h4>
|
|
363
|
+
{{ finding.references_html|safe }}
|
|
364
|
+
</div>
|
|
365
|
+
{% endif %}
|
|
366
|
+
</div>
|
|
367
|
+
{% endfor %} {% else %}
|
|
368
|
+
<div class="empty-state">No findings to display.</div>
|
|
369
|
+
{% endif %}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div class="footer">
|
|
373
|
+
Generated by DefectDojo CLI | {{ team_name }}
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</body>
|
|
377
|
+
</html>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "defectdojo-cli2"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.20"
|
|
4
4
|
description = "CLI Wrapper for DefectDojo using APIv2"
|
|
5
5
|
authors = ["Mikke Schirén <mikke.schiren@digitalist.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -10,9 +10,11 @@ packages = [{include = "defectdojo_cli2"}]
|
|
|
10
10
|
[tool.poetry.dependencies]
|
|
11
11
|
python = "^3.13"
|
|
12
12
|
requests = "^2.32.5"
|
|
13
|
-
tabulate = "^0.
|
|
13
|
+
tabulate = "^0.10.0"
|
|
14
14
|
rich-argparse = "^1.7.2"
|
|
15
15
|
setuptools = "^78.1.1"
|
|
16
|
+
jinja2 = "^3.1.4"
|
|
17
|
+
markdown = "^3.7"
|
|
16
18
|
|
|
17
19
|
[tool.poetry.scripts]
|
|
18
20
|
defectdojo = "defectdojo_cli2.__main__:main"
|
|
@@ -22,6 +24,12 @@ pytest = "^8.3.3"
|
|
|
22
24
|
requests-mock = "^1.12.1"
|
|
23
25
|
flake8-pyproject = "^1.2.3"
|
|
24
26
|
|
|
27
|
+
[tool.poetry.group.docs]
|
|
28
|
+
optional = true
|
|
29
|
+
|
|
30
|
+
[tool.poetry.group.docs.dependencies]
|
|
31
|
+
mkdocs-material = "^9.5.0"
|
|
32
|
+
|
|
25
33
|
[build-system]
|
|
26
34
|
requires = ["poetry-core"]
|
|
27
35
|
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|