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.
Files changed (19) hide show
  1. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/PKG-INFO +8 -3
  2. defectdojo_cli2-0.1.20/defectdojo_cli2/Reports.py +434 -0
  3. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/__init__.py +1 -0
  4. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/__main__.py +5 -0
  5. defectdojo_cli2-0.1.20/defectdojo_cli2/templates/report.html +377 -0
  6. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/pyproject.toml +10 -2
  7. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/AUTHORS +0 -0
  8. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/LICENSE +0 -0
  9. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/README.md +0 -0
  10. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/Announcements.py +0 -0
  11. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/ApiToken.py +0 -0
  12. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/EnvDefaults.py +0 -0
  13. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/ImportLanguages.py +0 -0
  14. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/Products.py +0 -0
  15. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/ReImportScan.py +0 -0
  16. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/engagements.py +0 -0
  17. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/findings.py +0 -0
  18. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/tests.py +0 -0
  19. {defectdojo_cli2-0.1.18 → defectdojo_cli2-0.1.20}/defectdojo_cli2/util.py +0 -0
@@ -1,18 +1,23 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: defectdojo-cli2
3
- Version: 0.1.18
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.9.0,<0.10.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 />&nbsp;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.18"
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.9.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"