From 3fee860baaa3dfe24b95035742978a00d54e7bbe Mon Sep 17 00:00:00 2001 From: Rodrigo Zigante <11444736+rzigante@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:29:05 -0400 Subject: [PATCH] Add quiet scan mode --- README.md | 3 ++ docs/usage.md | 4 +++ src/raguard/cli.py | 35 +++++++++++++---------- src/raguard/reporters/console.py | 48 ++++++++++++++++++++------------ tests/test_cli_extended.py | 30 ++++++++++++++++++++ tests/test_cli_extra.py | 27 ++++++++++++++++-- tests/test_reporters.py | 27 ++++++++++++++++++ 7 files changed, 140 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index abd353e..31c7a38 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ raguard scan http://localhost:8000 --type chroma --format json --output results. # CI mode (fails on high+ risk) raguard scan http://localhost:8000 --ci --threshold high +# Quiet mode (only findings/errors) +raguard scan http://localhost:8000 --quiet + # Generate MCPGuard policies raguard policy http://localhost:8000 --type qdrant -o policies.yaml diff --git a/docs/usage.md b/docs/usage.md index b6befc9..845b418 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -14,6 +14,9 @@ raguard scan http://localhost:8000 --ci --threshold high # Select specific detectors raguard scan http://localhost:8000 --detectors "data_poisoning,prompt_leakage" + +# Quiet mode (only findings/errors) +raguard scan http://localhost:8000 --quiet ``` ## Report @@ -42,6 +45,7 @@ raguard policy http://localhost:8000 --type qdrant -o policies.yaml | `--output` | Output file path (for json/html/sarif) | stdout | | `--threshold` | Minimum severity: low, medium, high, critical | medium | | `--ci` | Exit with code 1 if findings >= threshold | false | +| `--quiet` | Suppress non-error output and print findings only | false | | `--detectors` | Comma-separated detector names | all | | `--api-key` | API key for the target | env or none | | `--collection` | Collection name for vector DBs | default | diff --git a/src/raguard/cli.py b/src/raguard/cli.py index cba9694..515cd31 100644 --- a/src/raguard/cli.py +++ b/src/raguard/cli.py @@ -97,11 +97,6 @@ def _validate_api_key(key: str | None) -> str | None: return key if key is None else "" if not ALLOWED_API_KEY_RE.match(key): raise ValueError("API key contains invalid characters. Use RAGUARD_API_KEY environment variable for security.") - console.print( - "[yellow]Warning:[/] API key passed via CLI argument. " - "This is visible in process listings. " - "Use the RAGUARD_API_KEY environment variable instead." - ) return key @@ -116,12 +111,19 @@ def _validate_detectors(detectors_str: str | None) -> str | None: return detectors_str -def _get_api_key(cli_key: str | None) -> str | None: +def _get_api_key(cli_key: str | None, quiet: bool = False) -> str | None: """Get API key from CLI arg or environment variable.""" env_key = os.environ.get("RAGUARD_API_KEY") if cli_key and env_key: - console.print("[yellow]Warning:[/] Both --api-key and RAGUARD_API_KEY env var set. Using env var.") + if not quiet: + console.print("[yellow]Warning:[/] Both --api-key and RAGUARD_API_KEY env var set. Using env var.") return env_key + if cli_key and not quiet: + console.print( + "[yellow]Warning:[/] API key passed via CLI argument. " + "This is visible in process listings. " + "Use the RAGUARD_API_KEY environment variable instead." + ) return cli_key or env_key @@ -169,12 +171,13 @@ def scan( "high", "--threshold", help="CI failure threshold: low, medium, high, critical", callback=_validate_threshold ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output; print findings only"), detectors: str = typer.Option( None, "--detectors", "-d", help="Comma-separated list of detectors to run", callback=_validate_detectors ), ) -> None: """Scan a RAG system for security vulnerabilities.""" - settings = RAGuardSettings(verbose=verbose, debug=verbose) + settings = RAGuardSettings(verbose=verbose and not quiet, debug=verbose and not quiet) target_type_map = { "chroma": TargetType.CHROMA, @@ -183,7 +186,7 @@ def scan( "generic": TargetType.GENERIC, } - resolved_api_key = _get_api_key(api_key) + resolved_api_key = _get_api_key(api_key, quiet=quiet) config = RAGTargetConfig( url=target, @@ -206,7 +209,7 @@ async def run_scan() -> None: # Render output if fmt == "rich": - reporter = ConsoleReporter() + reporter = ConsoleReporter(quiet=quiet) reporter.render(report) elif fmt == "json": reporter = JSONReporter() @@ -214,7 +217,8 @@ async def run_scan() -> None: if output: safe_path = _validate_output_path(output) _safe_write_text(safe_path, text) - console.print(f"[green]Output saved to[/] {output}") + if not quiet: + console.print(f"[green]Output saved to[/] {output}") else: print(text) elif fmt == "html": @@ -222,7 +226,8 @@ async def run_scan() -> None: if output: safe_path = _validate_output_path(output) _safe_write_text(safe_path, reporter.render(report)) - console.print(f"[green]HTML report saved to[/] {output}") + if not quiet: + console.print(f"[green]HTML report saved to[/] {output}") else: print(reporter.render(report)) elif fmt == "sarif": @@ -231,7 +236,8 @@ async def run_scan() -> None: if output: safe_path = _validate_output_path(output) _safe_write_text(safe_path, text) - console.print(f"[green]SARIF report saved to[/] {output}") + if not quiet: + console.print(f"[green]SARIF report saved to[/] {output}") else: print(text) else: @@ -246,7 +252,8 @@ async def run_scan() -> None: if cat_val >= threshold_val: console.print(f"[red]CI FAILED:[/] Risk {report.risk_category} >= threshold {threshold}") raise typer.Exit(1) - console.print(f"[green]CI PASSED:[/] Risk {report.risk_category} < threshold {threshold}") + if not quiet: + console.print(f"[green]CI PASSED:[/] Risk {report.risk_category} < threshold {threshold}") asyncio.run(run_scan()) diff --git a/src/raguard/reporters/console.py b/src/raguard/reporters/console.py index b45ea68..64dffab 100644 --- a/src/raguard/reporters/console.py +++ b/src/raguard/reporters/console.py @@ -12,6 +12,9 @@ class ConsoleReporter: """Rich console reporter for RAGuard scan results.""" + def __init__(self, quiet: bool = False) -> None: + self.quiet = quiet + def render(self, report: RAGScanReport) -> str: """Render scan report to Rich console output. @@ -24,6 +27,12 @@ def render(self, report: RAGScanReport) -> str: console = Console() output_parts = [] + if self.quiet: + if report.findings: + console.print(self._findings_table(report)) + output_parts.append(f"{len(report.findings)} findings") + return "\n".join(output_parts) + color_map = { "none": "green", "low": "yellow", @@ -49,24 +58,27 @@ def render(self, report: RAGScanReport) -> str: output_parts.append(report.summary) if report.findings: - table = Table(title=f"Findings ({len(report.findings)})") - table.add_column("Severity", style="bold") - table.add_column("Attack Type", style="cyan") - table.add_column("Title") - table.add_column("Detector") - table.add_column("Risk") - - for f in report.findings: - sev_style = "red" if f.severity in (Severity.CRITICAL, Severity.HIGH) else "yellow" - table.add_row( - f"[{sev_style}]{f.severity.value.upper()}[/{sev_style}]", - f.attack_type.value, - f.title[:50], - f.detector, - str(f.risk_score), - ) - - console.print(table) + console.print(self._findings_table(report)) output_parts.append(f"{len(report.findings)} findings") return "\n".join(output_parts) + + def _findings_table(self, report: RAGScanReport) -> Table: + table = Table(title=f"Findings ({len(report.findings)})") + table.add_column("Severity", style="bold") + table.add_column("Attack Type", style="cyan") + table.add_column("Title") + table.add_column("Detector") + table.add_column("Risk") + + for finding in report.findings: + sev_style = "red" if finding.severity in (Severity.CRITICAL, Severity.HIGH) else "yellow" + table.add_row( + f"[{sev_style}]{finding.severity.value.upper()}[/{sev_style}]", + finding.attack_type.value, + finding.title[:50], + finding.detector, + str(finding.risk_score), + ) + + return table diff --git a/tests/test_cli_extended.py b/tests/test_cli_extended.py index 112a141..225cd6f 100644 --- a/tests/test_cli_extended.py +++ b/tests/test_cli_extended.py @@ -51,6 +51,27 @@ def test_scan_with_verbose_flag(self) -> None: ) assert result.exit_code == 0 + def test_scan_with_quiet_flag(self) -> None: + result = runner.invoke( + app, + ["scan", "http://localhost:8000", "--quiet"], + ) + assert result.exit_code == 0 + assert "Findings" in result.stdout + assert "RAGuard Scan Results" not in result.stdout + assert "Risk Score" not in result.stdout + assert "Summary" not in result.stdout + + def test_scan_quiet_with_output_file_suppresses_save_message(self, tmp_path: Path) -> None: + output_file = tmp_path / "results.json" + result = runner.invoke( + app, + ["scan", "http://localhost:8000", "--format", "json", "--quiet", "-o", str(output_file)], + ) + assert result.exit_code == 0 + assert output_file.exists() + assert result.stdout == "" + def test_scan_with_api_key(self) -> None: result = runner.invoke( app, @@ -58,6 +79,15 @@ def test_scan_with_api_key(self) -> None: ) assert result.exit_code == 0 + def test_scan_quiet_with_api_key_suppresses_warning(self) -> None: + result = runner.invoke( + app, + ["scan", "http://localhost:8000", "--quiet", "--api-key", "test-key"], + ) + assert result.exit_code == 0 + assert "Warning" not in result.stdout + assert "Findings" in result.stdout + def test_scan_with_custom_collection(self) -> None: result = runner.invoke( app, diff --git a/tests/test_cli_extra.py b/tests/test_cli_extra.py index a089e1b..6a9f8c2 100644 --- a/tests/test_cli_extra.py +++ b/tests/test_cli_extra.py @@ -7,7 +7,7 @@ import pytest -from raguard.cli import _safe_write_text, _validate_api_key, main +from raguard.cli import _get_api_key, _safe_write_text, _validate_api_key, main class TestValidateApiKey: @@ -21,10 +21,33 @@ def test_invalid_chars(self) -> None: with pytest.raises(ValueError, match="API key contains invalid characters"): _validate_api_key("bad key!") - def test_valid_key_prints_warning(self) -> None: + def test_valid_key_validates_without_printing_warning(self) -> None: with patch("raguard.cli.console.print") as mock_print: result = _validate_api_key("valid-key-123") assert result == "valid-key-123" + mock_print.assert_not_called() + + +class TestGetApiKey: + def test_cli_key_prints_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("RAGUARD_API_KEY", raising=False) + with patch("raguard.cli.console.print") as mock_print: + result = _get_api_key("valid-key-123") + assert result == "valid-key-123" + mock_print.assert_called_once() + + def test_quiet_cli_key_suppresses_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("RAGUARD_API_KEY", raising=False) + with patch("raguard.cli.console.print") as mock_print: + result = _get_api_key("valid-key-123", quiet=True) + assert result == "valid-key-123" + mock_print.assert_not_called() + + def test_env_key_wins_over_cli_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("RAGUARD_API_KEY", "env-key") + with patch("raguard.cli.console.print") as mock_print: + result = _get_api_key("cli-key") + assert result == "env-key" mock_print.assert_called_once() diff --git a/tests/test_reporters.py b/tests/test_reporters.py index 3394979..7966a97 100644 --- a/tests/test_reporters.py +++ b/tests/test_reporters.py @@ -132,3 +132,30 @@ def test_render_returns_string(self, capsys: pytest.CaptureFixture) -> None: assert "RAGuard Scan Results" in output or "Scan Results" in output captured = capsys.readouterr() assert "Test finding" in captured.out + + def test_quiet_render_prints_findings_only(self, capsys: pytest.CaptureFixture) -> None: + reporter = ConsoleReporter(quiet=True) + report = create_test_report() + output = reporter.render(report) + + assert output == "2 findings" + captured = capsys.readouterr() + assert "Findings (2)" in captured.out + assert "Test finding" in captured.out + assert "RAGuard Scan Results" not in captured.out + assert "Summary" not in captured.out + assert "Risk Score" not in captured.out + + def test_quiet_render_without_findings_prints_nothing(self, capsys: pytest.CaptureFixture) -> None: + reporter = ConsoleReporter(quiet=True) + report = RAGScanReport( + target_url="http://test.com", + target_type=TargetType.GENERIC, + findings=[], + ) + + output = reporter.render(report) + + assert output == "" + captured = capsys.readouterr() + assert captured.out == ""