Skip to content

Commit b2ac8f7

Browse files
committed
feat(sdk): improve validate with scanner breakdown, --check-tools, and --strict
- Show enabled/disabled scanner names instead of opaque counts - --check-tools flag checks local binary and Docker container availability - --strict flag treats warnings and unavailable tools as errors (exit 2) - Registry override reflected in --check-tools output - Designed for CI preflight: `argus validate --strict --check-tools`
1 parent b9e4ac5 commit b2ac8f7

1 file changed

Lines changed: 155 additions & 18 deletions

File tree

argus/cli.py

Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,18 @@ def _build_validate_parser(subparsers: argparse._SubParsersAction) -> None:
291291
default=None,
292292
help="Path to argus.yml config file (default: auto-detect)",
293293
)
294+
validate_parser.add_argument(
295+
"--check-tools",
296+
action="store_true",
297+
default=False,
298+
help="Also check scanner tool availability (local + Docker)",
299+
)
300+
validate_parser.add_argument(
301+
"--strict",
302+
action="store_true",
303+
default=False,
304+
help="Treat warnings as errors (exit non-zero). Useful in CI.",
305+
)
294306

295307

296308
def _build_report_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -865,30 +877,118 @@ def cmd_validate(args: argparse.Namespace) -> int:
865877
warnings = [e for e in errors if e.level == "warning"]
866878
fatal = [e for e in errors if e.level == "error"]
867879

880+
strict = getattr(args, "strict", False)
881+
868882
if not errors:
869883
print(f"✅ {config_path} is valid")
870-
# Show summary
871-
scanners = data.get("scanners", {})
872-
enabled = sum(1 for s in scanners.values() if isinstance(s, dict) and s.get("enabled", True))
873-
print(f" Scanners: {len(scanners)} configured, {enabled} enabled")
874-
fmt = data.get("reporting", {}).get("formats", ["terminal"])
875-
print(f" Formats: {', '.join(fmt) if isinstance(fmt, list) else fmt}")
876-
print(f" Backend: {data.get('execution', {}).get('backend', 'auto')}")
877-
return EXIT_SUCCESS
884+
else:
885+
for w in warnings:
886+
print(f"⚠️ {w}")
887+
for e in fatal:
888+
print(f"❌ {e}")
878889

879-
for w in warnings:
880-
print(f"⚠️ {w}")
881-
for e in fatal:
882-
print(f"❌ {e}")
890+
if fatal:
891+
print(f"\n{len(fatal)} error(s), {len(warnings)} warning(s). Fix and retry.")
892+
return EXIT_ERROR
883893

884-
if fatal:
885-
print(f"\n{len(fatal)} error(s), {len(warnings)} warning(s). Fix and retry.")
886-
return EXIT_ERROR
894+
if strict and warnings:
895+
print(f"\n{len(warnings)} warning(s) treated as errors (--strict)")
896+
return EXIT_ERROR
897+
898+
print(f"\n{config_path} is valid ({len(warnings)} warning(s))")
899+
900+
# Show summary with enabled/disabled breakdown
901+
scanners = data.get("scanners", {})
902+
enabled_names = []
903+
disabled_names = []
904+
for name, cfg in scanners.items():
905+
if isinstance(cfg, dict) and not cfg.get("enabled", True):
906+
disabled_names.append(name)
907+
else:
908+
enabled_names.append(name)
909+
print(f" Scanners: {len(enabled_names)} enabled, {len(disabled_names)} disabled")
910+
if enabled_names:
911+
print(f" enabled: {', '.join(enabled_names)}")
912+
if disabled_names:
913+
print(f" disabled: {', '.join(disabled_names)}")
914+
fmt = data.get("reporting", {}).get("formats", ["terminal"])
915+
print(f" Formats: {', '.join(fmt) if isinstance(fmt, list) else fmt}")
916+
backend = data.get("execution", {}).get("backend", "auto")
917+
print(f" Backend: {backend}")
918+
919+
# Tool readiness check
920+
if getattr(args, "check_tools", False) and enabled_names:
921+
registry = data.get("execution", {}).get("registry", "")
922+
unavailable = _check_tool_readiness(enabled_names, backend, registry)
923+
if unavailable and strict:
924+
print(f"\n{len(unavailable)} scanner(s) unavailable (--strict)")
925+
return EXIT_ERROR
887926

888-
print(f"\n{config_path} is valid ({len(warnings)} warning(s))")
889927
return EXIT_SUCCESS
890928

891929

930+
def _check_tool_readiness(
931+
enabled_names: list[str], backend: str, registry: str = ""
932+
) -> list[str]:
933+
"""Check whether enabled scanners can actually run.
934+
935+
Returns list of scanner names that cannot run.
936+
"""
937+
import shutil
938+
from argus.scanners import SCANNER_REGISTRY
939+
from argus.containers import get_image
940+
941+
docker_available = shutil.which("docker") is not None
942+
unavailable = []
943+
944+
def _resolve_image(image: str) -> str:
945+
"""Apply registry override to an image reference."""
946+
if not registry or not image:
947+
return image
948+
parts = image.split("/", 1)
949+
if len(parts) == 2 and ("." in parts[0] or ":" in parts[0]):
950+
return f"{registry}/{parts[1]}"
951+
return f"{registry}/{image}"
952+
953+
if registry:
954+
print(f"\n Registry: {registry}")
955+
print("\n Tool readiness:")
956+
for name in enabled_names:
957+
cls = SCANNER_REGISTRY.get(name)
958+
if not cls:
959+
print(f" {name}: ⚠️ unknown scanner (not in registry)")
960+
unavailable.append(name)
961+
continue
962+
963+
scanner = cls()
964+
local = scanner.is_available()
965+
raw_image = get_image(name) or getattr(scanner, "container_image", "")
966+
image = _resolve_image(raw_image)
967+
968+
if local:
969+
print(f" {name}: ✅ installed locally")
970+
elif backend == "local":
971+
install_cmd = scanner.install_command() or "see docs"
972+
print(f" {name}: ❌ not found (install: {install_cmd})")
973+
unavailable.append(name)
974+
elif not docker_available:
975+
install_cmd = scanner.install_command() or "see docs"
976+
print(f" {name}: ❌ not found, Docker not available (install: {install_cmd})")
977+
unavailable.append(name)
978+
elif image:
979+
print(f" {name}: 🐳 will use container ({image})")
980+
else:
981+
install_cmd = scanner.install_command() or "see docs"
982+
print(f" {name}: ❌ not found, no container image (install: {install_cmd})")
983+
unavailable.append(name)
984+
985+
if not docker_available and backend != "local":
986+
print("\n ⚠️ Docker not found — scanners without local installs will fail")
987+
print(" Install Docker or set execution.backend: local in argus.yml")
988+
989+
return unavailable
990+
991+
892992
def cmd_collect(args: argparse.Namespace) -> int:
893993
"""Execute the collect subcommand — merge parallel CI results."""
894994
from argus.collect import collect_results
@@ -973,10 +1073,47 @@ def _show_scanner_help(scanner_name: str) -> None:
9731073
sys.exit(EXIT_SUCCESS)
9741074

9751075

1076+
def _show_logo_easter_egg() -> int:
1077+
"""Render the Argus logo banner with a scroll effect.
1078+
1079+
Hidden trigger: `argus __logo`
1080+
Not part of argparse, so it stays out of generated docs/help text.
1081+
"""
1082+
import time
1083+
1084+
try:
1085+
from argus.init import _load_banner
1086+
lines = _load_banner().splitlines()
1087+
except Exception:
1088+
lines = [
1089+
"\033[1;32mA R G U S\033[0m",
1090+
"\033[90mPerception is Protection\033[0m",
1091+
]
1092+
1093+
for line in lines:
1094+
print(line, file=sys.stderr)
1095+
time.sleep(0.06 if line.strip() else 0.15)
1096+
print(file=sys.stderr)
1097+
1098+
return EXIT_SUCCESS
1099+
1100+
9761101
def main(argv: list[str] | None = None) -> None:
9771102
"""CLI entry point. Parse arguments and dispatch to the appropriate subcommand."""
9781103
# Intercept `argus scan <name> --help` before argparse exits
979-
raw_args = argv if argv is not None else sys.argv[1:]
1104+
raw_args = list(argv) if argv is not None else list(sys.argv[1:])
1105+
if len(raw_args) >= 1 and raw_args[0] == "__logo":
1106+
sys.exit(_show_logo_easter_egg())
1107+
1108+
# Hidden inline trigger: allow `argus <command> ... __logo`.
1109+
# Keep this out of argparse so it remains undocumented.
1110+
if "__logo" in raw_args[1:]:
1111+
_show_logo_easter_egg()
1112+
raw_args = [
1113+
token for i, token in enumerate(raw_args)
1114+
if not (i > 0 and token == "__logo")
1115+
]
1116+
9801117
if (len(raw_args) >= 3
9811118
and raw_args[0] == "scan"
9821119
and raw_args[-1] in ("--help", "-h")
@@ -985,7 +1122,7 @@ def main(argv: list[str] | None = None) -> None:
9851122
_show_scanner_help(scanner_name)
9861123

9871124
parser = build_parser()
988-
args = parser.parse_args(argv)
1125+
args = parser.parse_args(raw_args)
9891126

9901127
if args.command is None:
9911128
parser.print_help()

0 commit comments

Comments
 (0)