Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
35 changes: 21 additions & 14 deletions src/raguard/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -206,23 +209,25 @@ async def run_scan() -> None:

# Render output
if fmt == "rich":
reporter = ConsoleReporter()
reporter = ConsoleReporter(quiet=quiet)
reporter.render(report)
elif fmt == "json":
reporter = JSONReporter()
text = reporter.render(report)
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":
reporter = HTMLReporter()
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":
Expand All @@ -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:
Expand All @@ -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())

Expand Down
48 changes: 30 additions & 18 deletions src/raguard/reporters/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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",
Expand All @@ -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
30 changes: 30 additions & 0 deletions tests/test_cli_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,43 @@ 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,
["scan", "http://localhost:8000", "--api-key", "test-key"],
)
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,
Expand Down
27 changes: 25 additions & 2 deletions tests/test_cli_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()


Expand Down
27 changes: 27 additions & 0 deletions tests/test_reporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""