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 +7 -0
- argus/cli.py +478 -0
- argus/models.py +128 -0
- argus/server.py +841 -0
- argus/tools/__init__.py +38 -0
- argus/tools/ansible.py +341 -0
- argus/tools/dast.py +265 -0
- argus/tools/iac.py +339 -0
- argus/tools/sast.py +335 -0
- argus/tools/sca.py +342 -0
- argus/tools/secrets.py +234 -0
- argus/tools/terraform.py +397 -0
- argus/utils.py +150 -0
- argus_scan-0.1.0.dist-info/METADATA +189 -0
- argus_scan-0.1.0.dist-info/RECORD +18 -0
- argus_scan-0.1.0.dist-info/WHEEL +4 -0
- argus_scan-0.1.0.dist-info/entry_points.txt +3 -0
- argus_scan-0.1.0.dist-info/licenses/LICENSE +21 -0
argus/__init__.py
ADDED
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
|
+
}
|