argus-scan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
argus/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """argus-mcp: MCP server for code security testing.
2
+
3
+ Provides SAST, DAST, SCA, secret scanning, and IaC security scanning
4
+ via the Model Context Protocol.
5
+ """
6
+ __version__ = "0.1.0"
7
+ __all__ = ["__version__"]
argus/cli.py ADDED
@@ -0,0 +1,478 @@
1
+ """Standalone CLI for argus-mcp.
2
+
3
+ Runs security scans directly from the terminal — no AI, no token,
4
+ no subscription needed. Just Python + the open-source scanner tools.
5
+
6
+ Usage:
7
+ argus scan sast /path/to/code
8
+ argus scan sca /path/to/project
9
+ argus scan secrets /path/to/repo
10
+ argus scan iac /path/to/infra
11
+ argus scan terraform /path/to/tf
12
+ argus scan ansible /path/to/playbooks
13
+ argus scan all /path/to/project
14
+ argus scan dast http://localhost:3000
15
+ argus scan container nginx:latest
16
+ argus tools # list installed scanners
17
+ argus mcp # start MCP server (for AI clients)
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import asyncio
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from argus.models import AggregatedReport
28
+ from argus.utils import format_markdown_report, is_tool_available
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="argus",
34
+ description=(
35
+ "Code security scanner — SAST, DAST, SCA, Secrets, IaC, Terraform, Ansible.\n"
36
+ "All scanners are open-source. No AI token or subscription needed for CLI use."
37
+ ),
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ parser.add_argument("--version", action="store_true", help="Print version and exit")
41
+
42
+ sub = parser.add_subparsers(dest="command", metavar="command")
43
+
44
+ # ── scan ────────────────────────────────────────────────────────────────
45
+ scan_p = sub.add_parser("scan", help="Run a security scan")
46
+ scan_sub = scan_p.add_subparsers(dest="scan_type", metavar="type")
47
+
48
+ # Common flags shared by most scan subcommands
49
+ def _add_common(p: argparse.ArgumentParser, has_target: bool = True) -> None:
50
+ if has_target:
51
+ p.add_argument("target", help="Path to file, directory, URL, or image to scan")
52
+ p.add_argument(
53
+ "--format", "-f",
54
+ choices=["markdown", "json", "table"],
55
+ default="markdown",
56
+ help="Output format (default: markdown)",
57
+ )
58
+ p.add_argument(
59
+ "--timeout", "-t",
60
+ type=int, default=300,
61
+ help="Timeout in seconds per tool (default: 300)",
62
+ )
63
+ p.add_argument(
64
+ "--output", "-o",
65
+ metavar="FILE",
66
+ help="Write output to FILE instead of stdout",
67
+ )
68
+ p.add_argument(
69
+ "--min-severity", "-s",
70
+ choices=["critical", "high", "medium", "low", "info"],
71
+ default="low",
72
+ help="Only show findings at or above this severity (default: low)",
73
+ )
74
+ p.add_argument(
75
+ "--tools",
76
+ metavar="TOOL",
77
+ nargs="+",
78
+ help="Run only specific tools (space-separated)",
79
+ )
80
+ p.add_argument(
81
+ "--fail-on",
82
+ choices=["critical", "high", "medium", "low", "never"],
83
+ default="never",
84
+ help="Exit with code 1 if findings at this severity or above are found",
85
+ )
86
+
87
+ sast_p = scan_sub.add_parser("sast", help="Static Application Security Testing")
88
+ _add_common(sast_p)
89
+ sast_p.add_argument("--semgrep-config", default="auto", help="Semgrep ruleset (default: auto)")
90
+
91
+ sca_p = scan_sub.add_parser("sca", help="Software Composition Analysis (dependencies)")
92
+ _add_common(sca_p)
93
+
94
+ sec_p = scan_sub.add_parser("secrets", help="Secret / credential scanning")
95
+ _add_common(sec_p)
96
+
97
+ iac_p = scan_sub.add_parser("iac", help="Infrastructure-as-Code scanning")
98
+ _add_common(iac_p)
99
+ iac_p.add_argument("--framework", help="IaC framework: terraform, kubernetes, dockerfile, helm, ansible…")
100
+
101
+ tf_p = scan_sub.add_parser("terraform", help="Terraform-specific security scan")
102
+ _add_common(tf_p)
103
+
104
+ ans_p = scan_sub.add_parser("ansible", help="Ansible playbook / role security scan")
105
+ _add_common(ans_p)
106
+ ans_p.add_argument(
107
+ "--profile",
108
+ choices=["min", "basic", "moderate", "safety", "shared", "production"],
109
+ default="safety",
110
+ help="ansible-lint profile (default: safety)",
111
+ )
112
+
113
+ dast_p = scan_sub.add_parser("dast", help="Dynamic Application Security Testing (live URL)")
114
+ _add_common(dast_p)
115
+
116
+ ctr_p = scan_sub.add_parser("container", help="Container image vulnerability scan")
117
+ _add_common(ctr_p)
118
+
119
+ all_p = scan_sub.add_parser("all", help="Run all applicable scans")
120
+ _add_common(all_p)
121
+ all_p.add_argument("--url", metavar="URL", help="Also run DAST against this URL")
122
+ all_p.add_argument("--image", metavar="IMAGE", help="Also scan this container image")
123
+
124
+ # ── tools ───────────────────────────────────────────────────────────────
125
+ sub.add_parser("tools", help="List installed security tools and their status")
126
+
127
+ # ── mcp ─────────────────────────────────────────────────────────────────
128
+ mcp_p = sub.add_parser("mcp", help="Start the MCP server (for AI clients like Cursor / Claude)")
129
+ mcp_p.add_argument(
130
+ "--config",
131
+ action="store_true",
132
+ help="Print ready-to-paste MCP client config JSON and exit",
133
+ )
134
+
135
+ return parser
136
+
137
+
138
+ # ── Output helpers ────────────────────────────────────────────────────────────
139
+
140
+ _SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4, "unknown": 5}
141
+ _SEV_COLORS = {
142
+ "critical": "\033[91m", # bright red
143
+ "high": "\033[31m", # red
144
+ "medium": "\033[33m", # yellow
145
+ "low": "\033[34m", # blue
146
+ "info": "\033[37m", # grey
147
+ "unknown": "\033[37m",
148
+ }
149
+ _RESET = "\033[0m"
150
+ _BOLD = "\033[1m"
151
+
152
+
153
+ def _supports_color() -> bool:
154
+ return sys.stdout.isatty()
155
+
156
+
157
+ def _color(text: str, sev: str) -> str:
158
+ if not _supports_color():
159
+ return text
160
+ return f"{_SEV_COLORS.get(sev, '')}{text}{_RESET}"
161
+
162
+
163
+ def _print_table(report_dict: dict) -> None:
164
+ """Print a compact table of findings to stdout."""
165
+ summary = report_dict.get("summary", {})
166
+ target = report_dict.get("target", "")
167
+
168
+ print(f"\n{_BOLD}Target:{_RESET} {target}")
169
+ print(f"{_BOLD}Total findings:{_RESET} {summary.get('total_findings', 0)}\n")
170
+
171
+ # Severity summary bar
172
+ sev_parts = []
173
+ for sev in ("critical", "high", "medium", "low", "info"):
174
+ count = summary.get("by_severity", {}).get(sev, 0)
175
+ if count:
176
+ sev_parts.append(_color(f"{count} {sev}", sev))
177
+ if sev_parts:
178
+ print(" " + " ".join(sev_parts))
179
+ print()
180
+
181
+ col_w = {"sev": 10, "tool": 18, "file": 35, "line": 6, "title": 55}
182
+
183
+ header = (
184
+ f"{'Severity':<{col_w['sev']}} "
185
+ f"{'Tool':<{col_w['tool']}} "
186
+ f"{'File':<{col_w['file']}} "
187
+ f"{'Line':<{col_w['line']}} "
188
+ f"Title"
189
+ )
190
+ print(_BOLD + header + _RESET if _supports_color() else header)
191
+ print("─" * (sum(col_w.values()) + 8))
192
+
193
+ for result in report_dict.get("results", []):
194
+ if not result.get("tool_available"):
195
+ print(f" ⚠ {result['tool']} — not installed")
196
+ continue
197
+ for f in result.get("findings", []):
198
+ sev = f.get("severity", "unknown")
199
+ file_short = Path(f.get("file", "")).name or f.get("file", "")
200
+ if len(file_short) > col_w["file"]:
201
+ file_short = "…" + file_short[-(col_w["file"] - 1):]
202
+ title = f.get("title", "")
203
+ if len(title) > col_w["title"]:
204
+ title = title[:col_w["title"] - 1] + "…"
205
+
206
+ row = (
207
+ f"{sev:<{col_w['sev']}} "
208
+ f"{result['tool']:<{col_w['tool']}} "
209
+ f"{file_short:<{col_w['file']}} "
210
+ f"{str(f.get('line', '')):<{col_w['line']}} "
211
+ f"{title}"
212
+ )
213
+ print(_color(row, sev))
214
+
215
+ print()
216
+
217
+
218
+ def _should_fail(report_dict: dict, fail_on: str) -> bool:
219
+ if fail_on == "never":
220
+ return False
221
+ threshold = _SEV_ORDER.get(fail_on, 99)
222
+ for result in report_dict.get("results", []):
223
+ for f in result.get("findings", []):
224
+ if _SEV_ORDER.get(f.get("severity", "unknown"), 99) <= threshold:
225
+ return True
226
+ return False
227
+
228
+
229
+ def _filter_severity(report_dict: dict, min_sev: str) -> dict:
230
+ """Remove findings below min_sev from report dict."""
231
+ threshold = _SEV_ORDER.get(min_sev, 99)
232
+ import copy
233
+ report = copy.deepcopy(report_dict)
234
+ for result in report.get("results", []):
235
+ result["findings"] = [
236
+ f for f in result.get("findings", [])
237
+ if _SEV_ORDER.get(f.get("severity", "unknown"), 99) <= threshold
238
+ ]
239
+ return report
240
+
241
+
242
+ def _emit(report_dict: dict, fmt: str, output_file: str | None, min_sev: str, fail_on: str) -> int:
243
+ """Render the report and return the exit code."""
244
+ filtered = _filter_severity(report_dict, min_sev)
245
+
246
+ if fmt == "json":
247
+ text = json.dumps(filtered, indent=2)
248
+ elif fmt == "table":
249
+ import io, contextlib
250
+ buf = io.StringIO()
251
+ with contextlib.redirect_stdout(buf):
252
+ _print_table(filtered)
253
+ text = buf.getvalue()
254
+ else:
255
+ text = format_markdown_report(filtered)
256
+
257
+ if output_file:
258
+ Path(output_file).write_text(text)
259
+ print(f"Report written to: {output_file}", file=sys.stderr)
260
+ if fmt == "table":
261
+ _print_table(filtered) # still show table on stdout
262
+ else:
263
+ if fmt == "table":
264
+ _print_table(filtered)
265
+ else:
266
+ print(text)
267
+
268
+ return 1 if _should_fail(filtered, fail_on) else 0
269
+
270
+
271
+ # ── Scan runners ──────────────────────────────────────────────────────────────
272
+
273
+ async def _run_scan(args: argparse.Namespace) -> int:
274
+ scan_type = args.scan_type
275
+ timeout = args.timeout
276
+ fmt = args.format
277
+ tools = getattr(args, "tools", None)
278
+ min_sev = getattr(args, "min_severity", "low")
279
+ fail_on = getattr(args, "fail_on", "never")
280
+ out_file = getattr(args, "output", None)
281
+
282
+ print(f"Running {scan_type.upper()} scan on: {args.target}", file=sys.stderr)
283
+
284
+ if scan_type == "sast":
285
+ from argus.tools.sast import run_all_sast, run_bandit, run_semgrep, run_eslint_security
286
+ if tools:
287
+ tasks, results = [], []
288
+ if "semgrep" in tools: tasks.append(run_semgrep(args.target, config=args.semgrep_config, timeout=timeout))
289
+ if "bandit" in tools: tasks.append(run_bandit(args.target, timeout=timeout))
290
+ if "eslint" in tools: tasks.append(run_eslint_security(args.target, timeout=timeout))
291
+ results = await asyncio.gather(*tasks)
292
+ else:
293
+ results = await run_all_sast(args.target, semgrep_config=args.semgrep_config, timeout=timeout)
294
+
295
+ elif scan_type == "sca":
296
+ from argus.tools.sca import run_all_sca
297
+ results = await run_all_sca(args.target, timeout=timeout)
298
+
299
+ elif scan_type == "secrets":
300
+ from argus.tools.secrets import run_all_secrets
301
+ results = await run_all_secrets(args.target, timeout=timeout)
302
+
303
+ elif scan_type == "iac":
304
+ from argus.tools.iac import run_all_iac, run_checkov
305
+ if getattr(args, "framework", None):
306
+ results = [await run_checkov(args.target, framework=args.framework, timeout=timeout)]
307
+ else:
308
+ results = await run_all_iac(args.target, timeout=timeout)
309
+
310
+ elif scan_type == "terraform":
311
+ from argus.tools.terraform import run_all_terraform
312
+ results = await run_all_terraform(args.target, timeout=timeout, tools=tools)
313
+
314
+ elif scan_type == "ansible":
315
+ from argus.tools.ansible import run_all_ansible
316
+ results = await run_all_ansible(
317
+ args.target, timeout=timeout,
318
+ tools=tools,
319
+ ansible_lint_profile=getattr(args, "profile", "safety"),
320
+ )
321
+
322
+ elif scan_type == "dast":
323
+ from argus.tools.dast import run_all_dast
324
+ results = await run_all_dast(args.target, timeout=max(timeout, 600))
325
+
326
+ elif scan_type == "container":
327
+ from argus.tools.iac import run_trivy_image
328
+ results = [await run_trivy_image(args.target, timeout=timeout)]
329
+
330
+ elif scan_type == "all":
331
+ from argus.tools.sast import run_all_sast
332
+ from argus.tools.sca import run_all_sca
333
+ from argus.tools.secrets import run_all_secrets
334
+ from argus.tools.iac import run_all_iac, run_trivy_image
335
+ from argus.tools.dast import run_all_dast
336
+
337
+ batches = await asyncio.gather(
338
+ run_all_sast(args.target, timeout=timeout),
339
+ run_all_sca(args.target, timeout=timeout),
340
+ run_all_secrets(args.target, timeout=timeout),
341
+ run_all_iac(args.target, timeout=timeout),
342
+ return_exceptions=True,
343
+ )
344
+ results = [r for batch in batches if isinstance(batch, list) for r in batch]
345
+
346
+ if getattr(args, "url", None):
347
+ dast = await run_all_dast(args.url, timeout=600)
348
+ results.extend(dast)
349
+ if getattr(args, "image", None):
350
+ ctr = await run_trivy_image(args.image, timeout=timeout)
351
+ results.append(ctr)
352
+ else:
353
+ print(f"Unknown scan type: {scan_type}", file=sys.stderr)
354
+ return 2
355
+
356
+ report = AggregatedReport(target=args.target, results=list(results))
357
+ report_dict = report.to_dict()
358
+
359
+ total = report_dict["summary"]["total_findings"]
360
+ unavailable = report_dict["summary"]["tools_unavailable"]
361
+ print(f"Scan complete. {total} finding(s) found.", file=sys.stderr)
362
+ if unavailable:
363
+ print(f"Tools not installed (skipped): {', '.join(unavailable)}", file=sys.stderr)
364
+ print(" Run 'argus tools' to see install instructions.", file=sys.stderr)
365
+
366
+ return _emit(report_dict, fmt, out_file, min_sev, fail_on)
367
+
368
+
369
+ def _cmd_tools() -> None:
370
+ """Print a table of all tools and their availability."""
371
+ TOOLS = {
372
+ # tool_name: (category, install_hint)
373
+ "semgrep": ("SAST", "pip install semgrep"),
374
+ "bandit": ("SAST (Python)", "pip install bandit"),
375
+ "flake8": ("SAST (Python)", "pip install flake8 flake8-bandit"),
376
+ "eslint": ("SAST (JS/TS)", "npm install -g eslint eslint-plugin-security"),
377
+ "trivy": ("SCA / IaC / Container", "brew install trivy OR see aquasecurity.github.io/trivy"),
378
+ "safety": ("SCA (Python)", "pip install safety"),
379
+ "pip-audit": ("SCA (Python)", "pip install pip-audit"),
380
+ "npm": ("SCA (Node.js)", "Install Node.js from nodejs.org"),
381
+ "gitleaks": ("Secrets", "brew install gitleaks"),
382
+ "detect-secrets": ("Secrets", "pip install detect-secrets"),
383
+ "trufflehog": ("Secrets", "brew install trufflehog"),
384
+ "checkov": ("IaC / Terraform / Ansible", "pip install checkov"),
385
+ "terrascan": ("IaC", "brew install terrascan"),
386
+ "tfsec": ("Terraform", "brew install tfsec"),
387
+ "tflint": ("Terraform", "brew install tflint"),
388
+ "terraform": ("Terraform validate", "brew install terraform"),
389
+ "kics": ("IaC / Terraform / Ansible", "brew install kics"),
390
+ "ansible-lint": ("Ansible", "pip install ansible-lint"),
391
+ "nikto": ("DAST", "brew install nikto"),
392
+ "docker": ("DAST (ZAP) / Container", "Install Docker Desktop"),
393
+ }
394
+
395
+ installed, missing = [], []
396
+ for tool, (category, hint) in TOOLS.items():
397
+ if is_tool_available(tool):
398
+ installed.append((tool, category))
399
+ else:
400
+ missing.append((tool, category, hint))
401
+
402
+ use_color = _supports_color()
403
+ green = "\033[32m" if use_color else ""
404
+ red = "\033[31m" if use_color else ""
405
+ reset = _RESET if use_color else ""
406
+ bold = _BOLD if use_color else ""
407
+
408
+ print(f"\n{bold}Installed ({len(installed)}/{len(TOOLS)}){reset}\n")
409
+ for tool, cat in installed:
410
+ print(f" {green}✔{reset} {tool:<20} {cat}")
411
+
412
+ if missing:
413
+ print(f"\n{bold}Not installed{reset}\n")
414
+ for tool, cat, hint in missing:
415
+ print(f" {red}✘{reset} {tool:<20} {cat}")
416
+ print(f" install: {hint}")
417
+
418
+ print()
419
+
420
+
421
+ # ── Entry point ───────────────────────────────────────────────────────────────
422
+
423
+ def main(argv: list[str] | None = None) -> int:
424
+ parser = build_parser()
425
+ args = parser.parse_args(argv)
426
+
427
+ if args.version:
428
+ from argus import __version__
429
+ print(f"argus-mcp {__version__}")
430
+ return 0
431
+
432
+ if args.command is None:
433
+ parser.print_help()
434
+ return 0
435
+
436
+ if args.command == "tools":
437
+ _cmd_tools()
438
+ return 0
439
+
440
+ if args.command == "mcp":
441
+ if getattr(args, "config", False):
442
+ _print_mcp_config()
443
+ return 0
444
+ # Start the MCP server
445
+ from argus.server import run as run_server
446
+ run_server()
447
+ return 0
448
+
449
+ if args.command == "scan":
450
+ if args.scan_type is None:
451
+ parser.parse_args(["scan", "--help"])
452
+ return 0
453
+ return asyncio.run(_run_scan(args))
454
+
455
+ parser.print_help()
456
+ return 0
457
+
458
+
459
+ def _print_mcp_config() -> None:
460
+ print(
461
+ '{\n'
462
+ ' "mcpServers": {\n'
463
+ ' "argus": {\n'
464
+ ' "command": "argus",\n'
465
+ ' "args": ["mcp"]\n'
466
+ ' }\n'
467
+ ' }\n'
468
+ '}'
469
+ )
470
+
471
+
472
+ def run() -> None:
473
+ """Synchronous entry point (called by pip-installed script)."""
474
+ sys.exit(main())
475
+
476
+
477
+ if __name__ == "__main__":
478
+ sys.exit(main())
argus/models.py ADDED
@@ -0,0 +1,128 @@
1
+ """Shared data models for scan results."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+
9
+ class Severity(str, Enum):
10
+ CRITICAL = "critical"
11
+ HIGH = "high"
12
+ MEDIUM = "medium"
13
+ LOW = "low"
14
+ INFO = "info"
15
+ UNKNOWN = "unknown"
16
+
17
+
18
+ class ScanType(str, Enum):
19
+ SAST = "sast"
20
+ DAST = "dast"
21
+ SCA = "sca"
22
+ SECRETS = "secrets"
23
+ IAC = "iac"
24
+ CONTAINER = "container"
25
+
26
+
27
+ @dataclass
28
+ class Finding:
29
+ title: str
30
+ severity: Severity
31
+ scan_type: ScanType
32
+ tool: str
33
+ file: str = ""
34
+ line: int = 0
35
+ column: int = 0
36
+ description: str = ""
37
+ cwe: str = ""
38
+ cve: str = ""
39
+ fix_guidance: str = ""
40
+ rule_id: str = ""
41
+ references: list[str] = field(default_factory=list)
42
+ raw: dict[str, Any] = field(default_factory=dict)
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "title": self.title,
47
+ "severity": self.severity.value,
48
+ "scan_type": self.scan_type.value,
49
+ "tool": self.tool,
50
+ "file": self.file,
51
+ "line": self.line,
52
+ "column": self.column,
53
+ "description": self.description,
54
+ "cwe": self.cwe,
55
+ "cve": self.cve,
56
+ "fix_guidance": self.fix_guidance,
57
+ "rule_id": self.rule_id,
58
+ "references": self.references,
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class ScanResult:
64
+ tool: str
65
+ scan_type: ScanType
66
+ target: str
67
+ findings: list[Finding] = field(default_factory=list)
68
+ errors: list[str] = field(default_factory=list)
69
+ tool_available: bool = True
70
+ raw_output: str = ""
71
+ metadata: dict[str, Any] = field(default_factory=dict)
72
+
73
+ @property
74
+ def summary(self) -> dict[str, int]:
75
+ counts: dict[str, int] = {s.value: 0 for s in Severity}
76
+ for f in self.findings:
77
+ counts[f.severity.value] += 1
78
+ counts["total"] = len(self.findings)
79
+ return counts
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ return {
83
+ "tool": self.tool,
84
+ "scan_type": self.scan_type.value,
85
+ "target": self.target,
86
+ "tool_available": self.tool_available,
87
+ "summary": self.summary,
88
+ "findings": [f.to_dict() for f in self.findings],
89
+ "errors": self.errors,
90
+ "metadata": self.metadata,
91
+ }
92
+
93
+
94
+ @dataclass
95
+ class AggregatedReport:
96
+ target: str
97
+ results: list[ScanResult] = field(default_factory=list)
98
+
99
+ @property
100
+ def summary(self) -> dict[str, Any]:
101
+ all_findings: list[Finding] = []
102
+ for r in self.results:
103
+ all_findings.extend(r.findings)
104
+
105
+ by_severity: dict[str, int] = {s.value: 0 for s in Severity}
106
+ by_type: dict[str, int] = {t.value: 0 for t in ScanType}
107
+ by_tool: dict[str, int] = {}
108
+
109
+ for f in all_findings:
110
+ by_severity[f.severity.value] += 1
111
+ by_type[f.scan_type.value] += 1
112
+ by_tool[f.tool] = by_tool.get(f.tool, 0) + 1
113
+
114
+ return {
115
+ "total_findings": len(all_findings),
116
+ "by_severity": by_severity,
117
+ "by_scan_type": by_type,
118
+ "by_tool": by_tool,
119
+ "tools_run": [r.tool for r in self.results],
120
+ "tools_unavailable": [r.tool for r in self.results if not r.tool_available],
121
+ }
122
+
123
+ def to_dict(self) -> dict[str, Any]:
124
+ return {
125
+ "target": self.target,
126
+ "summary": self.summary,
127
+ "results": [r.to_dict() for r in self.results],
128
+ }