agent-audit 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.
- agent_audit/__init__.py +3 -0
- agent_audit/__main__.py +13 -0
- agent_audit/cli/__init__.py +1 -0
- agent_audit/cli/commands/__init__.py +1 -0
- agent_audit/cli/commands/init.py +44 -0
- agent_audit/cli/commands/inspect.py +236 -0
- agent_audit/cli/commands/scan.py +329 -0
- agent_audit/cli/formatters/__init__.py +1 -0
- agent_audit/cli/formatters/json.py +138 -0
- agent_audit/cli/formatters/sarif.py +155 -0
- agent_audit/cli/formatters/terminal.py +221 -0
- agent_audit/cli/main.py +34 -0
- agent_audit/config/__init__.py +1 -0
- agent_audit/config/ignore.py +477 -0
- agent_audit/core_utils/__init__.py +1 -0
- agent_audit/models/__init__.py +18 -0
- agent_audit/models/finding.py +159 -0
- agent_audit/models/risk.py +77 -0
- agent_audit/models/tool.py +182 -0
- agent_audit/rules/__init__.py +6 -0
- agent_audit/rules/engine.py +503 -0
- agent_audit/rules/loader.py +160 -0
- agent_audit/scanners/__init__.py +5 -0
- agent_audit/scanners/base.py +32 -0
- agent_audit/scanners/config_scanner.py +390 -0
- agent_audit/scanners/mcp_config_scanner.py +321 -0
- agent_audit/scanners/mcp_inspector.py +421 -0
- agent_audit/scanners/python_scanner.py +544 -0
- agent_audit/scanners/secret_scanner.py +521 -0
- agent_audit/utils/__init__.py +21 -0
- agent_audit/utils/compat.py +98 -0
- agent_audit/utils/mcp_client.py +343 -0
- agent_audit/version.py +3 -0
- agent_audit-0.1.0.dist-info/METADATA +219 -0
- agent_audit-0.1.0.dist-info/RECORD +37 -0
- agent_audit-0.1.0.dist-info/WHEEL +4 -0
- agent_audit-0.1.0.dist-info/entry_points.txt +3 -0
agent_audit/__init__.py
ADDED
agent_audit/__main__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Entry point for running agent-audit as a module."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# Windows event loop policy fix - must be set before any asyncio imports
|
|
6
|
+
if sys.platform == "win32":
|
|
7
|
+
import asyncio
|
|
8
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
9
|
+
|
|
10
|
+
from agent_audit.cli.main import cli
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
cli()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI module for agent-audit."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands for agent-audit."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Init command for creating configuration files."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from agent_audit.config.ignore import create_default_config
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.option('--force', '-f', is_flag=True, help='Overwrite existing config')
|
|
16
|
+
def init(force: bool):
|
|
17
|
+
"""
|
|
18
|
+
Initialize agent-audit configuration.
|
|
19
|
+
|
|
20
|
+
Creates a .agent-audit.yaml file in the current directory with
|
|
21
|
+
default settings and example ignore rules.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
|
|
25
|
+
agent-audit init
|
|
26
|
+
|
|
27
|
+
agent-audit init --force
|
|
28
|
+
"""
|
|
29
|
+
config_path = Path('.agent-audit.yaml')
|
|
30
|
+
|
|
31
|
+
if config_path.exists() and not force:
|
|
32
|
+
console.print(f"[yellow]Configuration file already exists: {config_path}[/yellow]")
|
|
33
|
+
console.print("Use --force to overwrite")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
config_content = create_default_config()
|
|
37
|
+
config_path.write_text(config_content, encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
console.print(f"[green]Created configuration file: {config_path}[/green]")
|
|
40
|
+
console.print()
|
|
41
|
+
console.print("Edit this file to:")
|
|
42
|
+
console.print(" - Add allowed hosts for network destinations")
|
|
43
|
+
console.print(" - Configure ignore rules for false positives")
|
|
44
|
+
console.print(" - Set scan exclusion patterns")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Inspect command for probing MCP servers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
from agent_audit.scanners.mcp_inspector import MCPInspector, MCPInspectionResult
|
|
13
|
+
from agent_audit.utils.mcp_client import TransportType
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_inspection_result(result: MCPInspectionResult, output_format: str = "terminal"):
|
|
19
|
+
"""Render inspection result to the console."""
|
|
20
|
+
if output_format == "json":
|
|
21
|
+
_render_json(result)
|
|
22
|
+
else:
|
|
23
|
+
_render_terminal(result)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _render_terminal(result: MCPInspectionResult):
|
|
27
|
+
"""Render result as Rich terminal output."""
|
|
28
|
+
# Status indicator
|
|
29
|
+
if result.connected:
|
|
30
|
+
status = "[green]✓ Connected[/green]"
|
|
31
|
+
border_style = "blue" if result.risk_score < 5 else "red"
|
|
32
|
+
else:
|
|
33
|
+
status = "[red]✗ Failed[/red]"
|
|
34
|
+
border_style = "red"
|
|
35
|
+
|
|
36
|
+
# Header panel
|
|
37
|
+
header_text = (
|
|
38
|
+
f"[bold]MCP Server Inspection[/bold]\n"
|
|
39
|
+
f"Server: {result.server_name}"
|
|
40
|
+
)
|
|
41
|
+
if result.server_version:
|
|
42
|
+
header_text += f" v{result.server_version}"
|
|
43
|
+
header_text += "\n"
|
|
44
|
+
header_text += f"Status: {status} | Response: {result.response_time_ms:.0f}ms\n"
|
|
45
|
+
header_text += f"Risk Score: {result.risk_score:.1f}/10"
|
|
46
|
+
|
|
47
|
+
console.print(Panel.fit(header_text, border_style=border_style))
|
|
48
|
+
|
|
49
|
+
if not result.connected:
|
|
50
|
+
console.print(f"[red]Error: {result.connection_error}[/red]")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Capabilities
|
|
54
|
+
if result.capabilities_declared:
|
|
55
|
+
caps = ", ".join(result.capabilities_declared) or "none"
|
|
56
|
+
console.print(f"\n[dim]Capabilities:[/dim] {caps}")
|
|
57
|
+
|
|
58
|
+
# Tools table
|
|
59
|
+
console.print(f"\n[bold]Tools ({result.tool_count})[/bold]")
|
|
60
|
+
|
|
61
|
+
if result.tools:
|
|
62
|
+
tool_table = Table(show_header=True, header_style="bold cyan")
|
|
63
|
+
tool_table.add_column("Tool", style="cyan")
|
|
64
|
+
tool_table.add_column("Permissions", style="yellow")
|
|
65
|
+
tool_table.add_column("Risk", justify="center")
|
|
66
|
+
tool_table.add_column("Validation")
|
|
67
|
+
|
|
68
|
+
risk_emoji = {
|
|
69
|
+
1: "🟢", # SAFE
|
|
70
|
+
2: "🟢", # LOW
|
|
71
|
+
3: "🟡", # MEDIUM
|
|
72
|
+
4: "🟠", # HIGH
|
|
73
|
+
5: "🔴", # CRITICAL
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for tool in result.tools:
|
|
77
|
+
perms = ", ".join(p.name for p in tool.permissions) or "none"
|
|
78
|
+
risk_value = tool.risk_level.value if hasattr(tool.risk_level, 'value') else 1
|
|
79
|
+
risk = risk_emoji.get(risk_value, "⚪")
|
|
80
|
+
validation = "✅" if tool.has_input_validation else "❌"
|
|
81
|
+
|
|
82
|
+
# Truncate description if needed
|
|
83
|
+
tool_name = tool.name
|
|
84
|
+
if len(tool_name) > 30:
|
|
85
|
+
tool_name = tool_name[:27] + "..."
|
|
86
|
+
|
|
87
|
+
tool_table.add_row(tool_name, perms, risk, validation)
|
|
88
|
+
|
|
89
|
+
console.print(tool_table)
|
|
90
|
+
else:
|
|
91
|
+
console.print("[dim]No tools exposed[/dim]")
|
|
92
|
+
|
|
93
|
+
# Resources
|
|
94
|
+
if result.resources:
|
|
95
|
+
console.print(f"\n[bold]Resources ({result.resource_count})[/bold]")
|
|
96
|
+
for res in result.resources[:10]: # Limit display
|
|
97
|
+
uri = res.get('uri', 'unknown')
|
|
98
|
+
console.print(f" 📄 {uri}")
|
|
99
|
+
if result.resource_count > 10:
|
|
100
|
+
console.print(f" [dim]... and {result.resource_count - 10} more[/dim]")
|
|
101
|
+
|
|
102
|
+
# Prompts
|
|
103
|
+
if result.prompts:
|
|
104
|
+
console.print(f"\n[bold]Prompts ({result.prompt_count})[/bold]")
|
|
105
|
+
for prompt in result.prompts[:10]:
|
|
106
|
+
name = prompt.get('name', 'unknown')
|
|
107
|
+
console.print(f" 💬 {name}")
|
|
108
|
+
if result.prompt_count > 10:
|
|
109
|
+
console.print(f" [dim]... and {result.prompt_count - 10} more[/dim]")
|
|
110
|
+
|
|
111
|
+
# Security findings
|
|
112
|
+
if result.findings:
|
|
113
|
+
console.print(f"\n[bold red]Security Findings ({len(result.findings)})[/bold red]")
|
|
114
|
+
|
|
115
|
+
severity_colors = {
|
|
116
|
+
"critical": "red",
|
|
117
|
+
"high": "red",
|
|
118
|
+
"medium": "yellow",
|
|
119
|
+
"low": "blue"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for finding in result.findings:
|
|
123
|
+
severity = finding.get("severity", "medium")
|
|
124
|
+
color = severity_colors.get(severity, "white")
|
|
125
|
+
desc = finding.get("description", "Unknown issue")
|
|
126
|
+
tool_name = finding.get("tool", "")
|
|
127
|
+
|
|
128
|
+
if tool_name:
|
|
129
|
+
console.print(f" [{color}]⚠ {severity.upper()}[/{color}]: {desc} (tool: {tool_name})")
|
|
130
|
+
else:
|
|
131
|
+
console.print(f" [{color}]⚠ {severity.upper()}[/{color}]: {desc}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _render_json(result: MCPInspectionResult):
|
|
135
|
+
"""Render result as JSON."""
|
|
136
|
+
import json
|
|
137
|
+
|
|
138
|
+
output = {
|
|
139
|
+
"server_name": result.server_name,
|
|
140
|
+
"server_version": result.server_version,
|
|
141
|
+
"transport": result.transport.value,
|
|
142
|
+
"connected": result.connected,
|
|
143
|
+
"connection_error": result.connection_error,
|
|
144
|
+
"response_time_ms": result.response_time_ms,
|
|
145
|
+
"risk_score": result.risk_score,
|
|
146
|
+
"capabilities": result.capabilities_declared,
|
|
147
|
+
"tool_count": result.tool_count,
|
|
148
|
+
"tools": [t.to_dict() for t in result.tools],
|
|
149
|
+
"resource_count": result.resource_count,
|
|
150
|
+
"resources": result.resources,
|
|
151
|
+
"prompt_count": result.prompt_count,
|
|
152
|
+
"prompts": result.prompts,
|
|
153
|
+
"findings": result.findings,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.print_json(json.dumps(output, indent=2))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def run_inspect_async(
|
|
160
|
+
target: str,
|
|
161
|
+
transport: Optional[str],
|
|
162
|
+
timeout: int,
|
|
163
|
+
output_format: str
|
|
164
|
+
) -> int:
|
|
165
|
+
"""Run the inspection asynchronously."""
|
|
166
|
+
inspector = MCPInspector(timeout=timeout)
|
|
167
|
+
|
|
168
|
+
# Determine transport type
|
|
169
|
+
transport_type: Optional[TransportType] = None
|
|
170
|
+
if transport:
|
|
171
|
+
transport_type = TransportType(transport)
|
|
172
|
+
|
|
173
|
+
result = await inspector.inspect(target, transport_type)
|
|
174
|
+
render_inspection_result(result, output_format)
|
|
175
|
+
|
|
176
|
+
# Return exit code based on risk
|
|
177
|
+
if not result.connected:
|
|
178
|
+
return 2 # Connection failure
|
|
179
|
+
elif result.risk_score >= 7.0:
|
|
180
|
+
return 1 # High risk
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def run_inspect(
|
|
185
|
+
target: str,
|
|
186
|
+
transport: Optional[str],
|
|
187
|
+
timeout: int,
|
|
188
|
+
output_format: str
|
|
189
|
+
) -> int:
|
|
190
|
+
"""Run the inspection."""
|
|
191
|
+
return asyncio.run(run_inspect_async(target, transport, timeout, output_format))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@click.command()
|
|
195
|
+
@click.argument('transport_type', type=click.Choice(['stdio', 'sse']), required=True)
|
|
196
|
+
@click.argument('target', nargs=-1, required=True)
|
|
197
|
+
@click.option('--timeout', '-t', default=30, help='Connection timeout in seconds')
|
|
198
|
+
@click.option('--format', '-f', 'output_format',
|
|
199
|
+
type=click.Choice(['terminal', 'json']), default='terminal',
|
|
200
|
+
help='Output format')
|
|
201
|
+
def inspect(transport_type: str, target: tuple, timeout: int, output_format: str):
|
|
202
|
+
"""
|
|
203
|
+
Inspect a running MCP server and analyze its tools.
|
|
204
|
+
|
|
205
|
+
TRANSPORT_TYPE is either 'stdio' or 'sse'.
|
|
206
|
+
|
|
207
|
+
For stdio, TARGET is the command to run the server:
|
|
208
|
+
|
|
209
|
+
agent-audit inspect stdio -- python my_mcp_server.py
|
|
210
|
+
|
|
211
|
+
For sse, TARGET is the URL:
|
|
212
|
+
|
|
213
|
+
agent-audit inspect sse https://example.com/sse
|
|
214
|
+
|
|
215
|
+
The inspector connects to the server, retrieves its tool definitions,
|
|
216
|
+
and analyzes them for security risks WITHOUT executing any tools.
|
|
217
|
+
"""
|
|
218
|
+
# Join target parts back together
|
|
219
|
+
target_str = ' '.join(target)
|
|
220
|
+
|
|
221
|
+
# Handle the "--" separator for stdio
|
|
222
|
+
if target_str.startswith('-- '):
|
|
223
|
+
target_str = target_str[3:]
|
|
224
|
+
|
|
225
|
+
if not target_str:
|
|
226
|
+
console.print("[red]Error: No target specified[/red]")
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
exit_code = run_inspect(
|
|
230
|
+
target=target_str,
|
|
231
|
+
transport=transport_type,
|
|
232
|
+
timeout=timeout,
|
|
233
|
+
output_format=output_format
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
sys.exit(exit_code)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Scan command implementation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from agent_audit.models.finding import Finding
|
|
10
|
+
from agent_audit.models.risk import Severity
|
|
11
|
+
from agent_audit.rules.engine import RuleEngine
|
|
12
|
+
|
|
13
|
+
from agent_audit.scanners.python_scanner import PythonScanner
|
|
14
|
+
from agent_audit.scanners.mcp_config_scanner import MCPConfigScanner
|
|
15
|
+
from agent_audit.scanners.secret_scanner import SecretScanner
|
|
16
|
+
from agent_audit.config.ignore import (
|
|
17
|
+
IgnoreManager, load_baseline, filter_by_baseline, save_baseline
|
|
18
|
+
)
|
|
19
|
+
from agent_audit.cli.formatters.terminal import format_scan_results
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_scan(
|
|
25
|
+
path: Path,
|
|
26
|
+
output_format: str,
|
|
27
|
+
output_path: Optional[Path],
|
|
28
|
+
min_severity: str,
|
|
29
|
+
additional_rules: List[str],
|
|
30
|
+
fail_on_severity: str,
|
|
31
|
+
baseline_path: Optional[Path] = None,
|
|
32
|
+
save_baseline_path: Optional[Path] = None,
|
|
33
|
+
verbose: bool = False,
|
|
34
|
+
quiet: bool = False
|
|
35
|
+
) -> int:
|
|
36
|
+
"""
|
|
37
|
+
Run the security scan.
|
|
38
|
+
|
|
39
|
+
Returns exit code: 0 for success, 1 for findings at fail_on level.
|
|
40
|
+
"""
|
|
41
|
+
# Initialize ignore manager first to get exclude patterns
|
|
42
|
+
ignore_manager = IgnoreManager()
|
|
43
|
+
config_loaded = ignore_manager.load(path)
|
|
44
|
+
|
|
45
|
+
if verbose and config_loaded:
|
|
46
|
+
console.print(f"[dim]Loaded config from: {ignore_manager._loaded_from}[/dim]")
|
|
47
|
+
|
|
48
|
+
# Get exclude patterns from config
|
|
49
|
+
exclude_patterns = ignore_manager.get_exclude_patterns()
|
|
50
|
+
|
|
51
|
+
# Initialize scanners with exclude patterns
|
|
52
|
+
python_scanner = PythonScanner(exclude_patterns=exclude_patterns)
|
|
53
|
+
mcp_scanner = MCPConfigScanner()
|
|
54
|
+
secret_scanner = SecretScanner(exclude_paths=exclude_patterns)
|
|
55
|
+
|
|
56
|
+
# Initialize rule engine
|
|
57
|
+
rule_engine = RuleEngine()
|
|
58
|
+
|
|
59
|
+
# Find rules directory - check multiple possible locations
|
|
60
|
+
possible_rules_dirs = [
|
|
61
|
+
# Relative to this file (installed package)
|
|
62
|
+
Path(__file__).parent.parent.parent.parent.parent.parent.parent / "rules" / "builtin",
|
|
63
|
+
# Relative to project root (development)
|
|
64
|
+
Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent / "rules" / "builtin",
|
|
65
|
+
# Relative to current working directory
|
|
66
|
+
Path.cwd() / "rules" / "builtin",
|
|
67
|
+
# Go up from cwd if in packages/audit
|
|
68
|
+
Path.cwd().parent.parent / "rules" / "builtin",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for rules_dir in possible_rules_dirs:
|
|
72
|
+
if rules_dir.exists():
|
|
73
|
+
rule_engine.add_builtin_rules_dir(rules_dir)
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
rule_engine.load_rules()
|
|
77
|
+
|
|
78
|
+
# Collect all findings
|
|
79
|
+
all_findings: List[Finding] = []
|
|
80
|
+
scanned_files = 0
|
|
81
|
+
|
|
82
|
+
# Run Python scanner
|
|
83
|
+
if not quiet:
|
|
84
|
+
console.print("[dim]Scanning Python files...[/dim]")
|
|
85
|
+
|
|
86
|
+
python_results = python_scanner.scan(path)
|
|
87
|
+
for result in python_results:
|
|
88
|
+
scanned_files += 1
|
|
89
|
+
|
|
90
|
+
# Generate findings from dangerous patterns
|
|
91
|
+
findings = rule_engine.evaluate_dangerous_patterns(
|
|
92
|
+
result.dangerous_patterns,
|
|
93
|
+
result.source_file
|
|
94
|
+
)
|
|
95
|
+
all_findings.extend(findings)
|
|
96
|
+
|
|
97
|
+
# Check for credentials in source
|
|
98
|
+
try:
|
|
99
|
+
source = Path(result.source_file).read_text(encoding='utf-8')
|
|
100
|
+
cred_findings = rule_engine.evaluate_credentials(source, result.source_file)
|
|
101
|
+
all_findings.extend(cred_findings)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Evaluate tool permissions
|
|
106
|
+
if result.tools:
|
|
107
|
+
perm_findings = rule_engine.evaluate_permission_scope(
|
|
108
|
+
result.tools,
|
|
109
|
+
result.source_file
|
|
110
|
+
)
|
|
111
|
+
all_findings.extend(perm_findings)
|
|
112
|
+
|
|
113
|
+
# Run MCP config scanner
|
|
114
|
+
if not quiet:
|
|
115
|
+
console.print("[dim]Scanning MCP configurations...[/dim]")
|
|
116
|
+
|
|
117
|
+
mcp_results = mcp_scanner.scan(path)
|
|
118
|
+
for result in mcp_results:
|
|
119
|
+
scanned_files += 1
|
|
120
|
+
|
|
121
|
+
# Convert server configs to dicts for rule engine
|
|
122
|
+
server_dicts = []
|
|
123
|
+
for server in result.servers:
|
|
124
|
+
server_dict = {
|
|
125
|
+
'name': server.name,
|
|
126
|
+
'url': server.url,
|
|
127
|
+
'command': server.command,
|
|
128
|
+
'args': server.args,
|
|
129
|
+
'env': server.env,
|
|
130
|
+
'verified': server.verified,
|
|
131
|
+
'_line': server._line,
|
|
132
|
+
}
|
|
133
|
+
server_dicts.append(server_dict)
|
|
134
|
+
|
|
135
|
+
mcp_findings = rule_engine.evaluate_mcp_config(server_dicts, result.source_file)
|
|
136
|
+
all_findings.extend(mcp_findings)
|
|
137
|
+
|
|
138
|
+
# Run secret scanner
|
|
139
|
+
if not quiet:
|
|
140
|
+
console.print("[dim]Scanning for secrets...[/dim]")
|
|
141
|
+
|
|
142
|
+
secret_results = secret_scanner.scan(path)
|
|
143
|
+
for result in secret_results:
|
|
144
|
+
for secret in result.secrets:
|
|
145
|
+
from agent_audit.models.risk import Location, Category
|
|
146
|
+
from agent_audit.models.finding import Remediation
|
|
147
|
+
|
|
148
|
+
finding = Finding(
|
|
149
|
+
rule_id="AGENT-004",
|
|
150
|
+
title="Hardcoded Credentials",
|
|
151
|
+
description=f"Found {secret.pattern_name}",
|
|
152
|
+
severity=Severity.CRITICAL if secret.severity == "critical" else
|
|
153
|
+
Severity.HIGH if secret.severity == "high" else Severity.MEDIUM,
|
|
154
|
+
category=Category.CREDENTIAL_EXPOSURE,
|
|
155
|
+
location=Location(
|
|
156
|
+
file_path=result.source_file,
|
|
157
|
+
start_line=secret.line_number,
|
|
158
|
+
end_line=secret.line_number,
|
|
159
|
+
start_column=secret.start_col,
|
|
160
|
+
end_column=secret.end_col,
|
|
161
|
+
snippet=secret.line_content
|
|
162
|
+
),
|
|
163
|
+
cwe_id="CWE-798",
|
|
164
|
+
remediation=Remediation(
|
|
165
|
+
description="Use environment variables or a secrets manager"
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
all_findings.append(finding)
|
|
169
|
+
|
|
170
|
+
# Apply ignore rules
|
|
171
|
+
for finding in all_findings:
|
|
172
|
+
ignore_manager.apply_to_finding(finding)
|
|
173
|
+
|
|
174
|
+
# Filter by baseline if provided
|
|
175
|
+
if baseline_path and baseline_path.exists():
|
|
176
|
+
baseline = load_baseline(baseline_path)
|
|
177
|
+
all_findings = filter_by_baseline(all_findings, baseline)
|
|
178
|
+
if not quiet:
|
|
179
|
+
console.print(f"[dim]Filtered by baseline: {baseline_path}[/dim]")
|
|
180
|
+
|
|
181
|
+
# Filter by minimum severity
|
|
182
|
+
severity_order = {
|
|
183
|
+
'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4
|
|
184
|
+
}
|
|
185
|
+
min_sev_value = severity_order.get(min_severity.lower(), 0)
|
|
186
|
+
all_findings = [
|
|
187
|
+
f for f in all_findings
|
|
188
|
+
if severity_order.get(f.severity.value, 0) >= min_sev_value
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# Save baseline if requested
|
|
192
|
+
if save_baseline_path:
|
|
193
|
+
save_baseline(all_findings, save_baseline_path)
|
|
194
|
+
if not quiet:
|
|
195
|
+
console.print(f"[dim]Saved baseline to: {save_baseline_path}[/dim]")
|
|
196
|
+
|
|
197
|
+
# Output results
|
|
198
|
+
if output_format == "terminal":
|
|
199
|
+
format_scan_results(
|
|
200
|
+
all_findings,
|
|
201
|
+
str(path),
|
|
202
|
+
scanned_files,
|
|
203
|
+
verbose=verbose,
|
|
204
|
+
quiet=quiet
|
|
205
|
+
)
|
|
206
|
+
elif output_format == "json":
|
|
207
|
+
from agent_audit.cli.formatters.json import format_json
|
|
208
|
+
json_output = format_json(all_findings, str(path), scanned_files)
|
|
209
|
+
if output_path:
|
|
210
|
+
output_path.write_text(json_output, encoding="utf-8")
|
|
211
|
+
else:
|
|
212
|
+
console.print(json_output)
|
|
213
|
+
elif output_format == "sarif":
|
|
214
|
+
from agent_audit.cli.formatters.sarif import SARIFFormatter
|
|
215
|
+
formatter = SARIFFormatter()
|
|
216
|
+
if output_path:
|
|
217
|
+
formatter.save(all_findings, output_path)
|
|
218
|
+
if not quiet:
|
|
219
|
+
console.print(f"[dim]SARIF output saved to: {output_path}[/dim]")
|
|
220
|
+
else:
|
|
221
|
+
console.print(formatter.format_to_string(all_findings))
|
|
222
|
+
elif output_format == "markdown":
|
|
223
|
+
_output_markdown(all_findings, str(path), output_path)
|
|
224
|
+
|
|
225
|
+
# Determine exit code based on fail_on severity
|
|
226
|
+
fail_sev_value = severity_order.get(fail_on_severity.lower(), 3) # Default: high
|
|
227
|
+
actionable_findings = [f for f in all_findings if f.is_actionable()]
|
|
228
|
+
max_severity = max(
|
|
229
|
+
(severity_order.get(f.severity.value, 0) for f in actionable_findings),
|
|
230
|
+
default=0
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if max_severity >= fail_sev_value:
|
|
234
|
+
return 1
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _output_markdown(findings: List[Finding], scan_path: str, output_path: Optional[Path]):
|
|
239
|
+
"""Output findings as Markdown."""
|
|
240
|
+
lines = [
|
|
241
|
+
"# Agent Audit Security Report",
|
|
242
|
+
"",
|
|
243
|
+
f"**Scanned:** `{scan_path}`",
|
|
244
|
+
f"**Findings:** {len(findings)}",
|
|
245
|
+
"",
|
|
246
|
+
"## Findings",
|
|
247
|
+
"",
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
for finding in findings:
|
|
251
|
+
sev = finding.severity.value.upper()
|
|
252
|
+
lines.append(f"### [{sev}] {finding.rule_id}: {finding.title}")
|
|
253
|
+
lines.append("")
|
|
254
|
+
lines.append(f"**Location:** `{finding.location.file_path}:{finding.location.start_line}`")
|
|
255
|
+
lines.append("")
|
|
256
|
+
if finding.location.snippet:
|
|
257
|
+
lines.append("```python")
|
|
258
|
+
lines.append(finding.location.snippet)
|
|
259
|
+
lines.append("```")
|
|
260
|
+
lines.append("")
|
|
261
|
+
lines.append(finding.description)
|
|
262
|
+
lines.append("")
|
|
263
|
+
if finding.remediation:
|
|
264
|
+
lines.append(f"**Fix:** {finding.remediation.description}")
|
|
265
|
+
lines.append("")
|
|
266
|
+
lines.append("---")
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
md_content = "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
if output_path:
|
|
272
|
+
output_path.write_text(md_content, encoding="utf-8")
|
|
273
|
+
else:
|
|
274
|
+
console.print(md_content)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@click.command()
|
|
278
|
+
@click.argument('path', type=click.Path(exists=True), default='.')
|
|
279
|
+
@click.option('--format', '-f', 'output_format',
|
|
280
|
+
type=click.Choice(['terminal', 'json', 'sarif', 'markdown']),
|
|
281
|
+
default='terminal', help='Output format')
|
|
282
|
+
@click.option('--output', '-o', type=click.Path(), help='Output file path')
|
|
283
|
+
@click.option('--severity', '-s',
|
|
284
|
+
type=click.Choice(['critical', 'high', 'medium', 'low', 'info']),
|
|
285
|
+
default='low', help='Minimum severity to report')
|
|
286
|
+
@click.option('--rules', '-r', type=click.Path(exists=True),
|
|
287
|
+
multiple=True, help='Additional rule files')
|
|
288
|
+
@click.option('--fail-on',
|
|
289
|
+
type=click.Choice(['critical', 'high', 'medium', 'low']),
|
|
290
|
+
default='high', help='Exit with error if findings at this level')
|
|
291
|
+
@click.option('--baseline', type=click.Path(),
|
|
292
|
+
help='Baseline file - only report new findings')
|
|
293
|
+
@click.option('--save-baseline', type=click.Path(),
|
|
294
|
+
help='Save current findings as baseline')
|
|
295
|
+
@click.pass_context
|
|
296
|
+
def scan(ctx: click.Context, path: str, output_format: str, output: Optional[str],
|
|
297
|
+
severity: str, rules: tuple, fail_on: str, baseline: Optional[str],
|
|
298
|
+
save_baseline: Optional[str]):
|
|
299
|
+
"""
|
|
300
|
+
Scan agent code and configurations for security issues.
|
|
301
|
+
|
|
302
|
+
PATH is the directory or file to scan. Defaults to current directory.
|
|
303
|
+
|
|
304
|
+
Examples:
|
|
305
|
+
|
|
306
|
+
agent-audit scan ./my-agent
|
|
307
|
+
|
|
308
|
+
agent-audit scan . --format sarif --output results.sarif
|
|
309
|
+
|
|
310
|
+
agent-audit scan . --severity critical --fail-on critical
|
|
311
|
+
|
|
312
|
+
agent-audit scan . --baseline baseline.json
|
|
313
|
+
|
|
314
|
+
agent-audit scan . --save-baseline baseline.json
|
|
315
|
+
"""
|
|
316
|
+
exit_code = run_scan(
|
|
317
|
+
path=Path(path),
|
|
318
|
+
output_format=output_format,
|
|
319
|
+
output_path=Path(output) if output else None,
|
|
320
|
+
min_severity=severity,
|
|
321
|
+
additional_rules=list(rules),
|
|
322
|
+
fail_on_severity=fail_on,
|
|
323
|
+
baseline_path=Path(baseline) if baseline else None,
|
|
324
|
+
save_baseline_path=Path(save_baseline) if save_baseline else None,
|
|
325
|
+
verbose=ctx.obj.get('verbose', False),
|
|
326
|
+
quiet=ctx.obj.get('quiet', False)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
ctx.exit(exit_code)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Output formatters for agent-audit."""
|