agentsec-cli 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.
- agentsec/__init__.py +3 -0
- agentsec/baseline.py +60 -0
- agentsec/cli.py +84 -0
- agentsec/owasp.py +212 -0
- agentsec/parsers/__init__.py +5 -0
- agentsec/parsers/core.py +8 -0
- agentsec/parsers/json_parser.py +69 -0
- agentsec/parsers/toml_parser.py +28 -0
- agentsec/parsers/yaml_parser.py +28 -0
- agentsec/report.py +36 -0
- agentsec/rules/__init__.py +5 -0
- agentsec/rules/additional.py +227 -0
- agentsec/rules/base.py +108 -0
- agentsec/sarif.py +110 -0
- agentsec/scanner.py +119 -0
- agentsec_cli-0.1.0.dist-info/METADATA +161 -0
- agentsec_cli-0.1.0.dist-info/RECORD +21 -0
- agentsec_cli-0.1.0.dist-info/WHEEL +5 -0
- agentsec_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentsec_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentsec_cli-0.1.0.dist-info/top_level.txt +1 -0
agentsec/__init__.py
ADDED
agentsec/baseline.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Baseline (lockfile) management for AgentSec."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_finding_id(finding: dict) -> str:
|
|
10
|
+
"""Compute a stable unique ID for a finding based on rule, file, and server."""
|
|
11
|
+
key = f"{finding['rule']}|{finding['file']}|{finding.get('server', '')}"
|
|
12
|
+
return hashlib.md5(key.encode()).hexdigest()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_baseline(path: str) -> Dict[str, str]:
|
|
16
|
+
"""Load baseline from JSON file. Returns dict of id -> severity."""
|
|
17
|
+
p = Path(path)
|
|
18
|
+
if not p.exists():
|
|
19
|
+
return {}
|
|
20
|
+
with open(p, 'r') as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
return data.get('findings', {})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_baseline(path: str, findings: List[dict]) -> None:
|
|
26
|
+
"""Save current findings as baseline."""
|
|
27
|
+
baseline = {}
|
|
28
|
+
for f in findings:
|
|
29
|
+
fid = compute_finding_id(f)
|
|
30
|
+
baseline[fid] = f['severity']
|
|
31
|
+
with open(path, 'w') as f:
|
|
32
|
+
json.dump({"findings": baseline}, f, indent=2)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compare_findings(findings: List[dict], baseline: Dict[str, str]) -> Tuple[List[dict], List[dict], List[dict]]:
|
|
36
|
+
"""
|
|
37
|
+
Compare current findings against baseline.
|
|
38
|
+
Returns: (new, changed, removed)
|
|
39
|
+
- new: findings not in baseline
|
|
40
|
+
- changed: findings whose severity changed
|
|
41
|
+
- removed: baseline entries not in current findings (as list of dict with 'id' and 'severity')
|
|
42
|
+
"""
|
|
43
|
+
current_ids = set()
|
|
44
|
+
new = []
|
|
45
|
+
changed = []
|
|
46
|
+
|
|
47
|
+
for f in findings:
|
|
48
|
+
fid = compute_finding_id(f)
|
|
49
|
+
current_ids.add(fid)
|
|
50
|
+
if fid not in baseline:
|
|
51
|
+
new.append(f)
|
|
52
|
+
elif baseline[fid] != f['severity']:
|
|
53
|
+
changed.append(f)
|
|
54
|
+
|
|
55
|
+
removed = []
|
|
56
|
+
for fid, sev in baseline.items():
|
|
57
|
+
if fid not in current_ids:
|
|
58
|
+
removed.append({'id': fid, 'severity': sev})
|
|
59
|
+
|
|
60
|
+
return new, changed, removed
|
agentsec/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""CLI entry point for AgentSec."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import click
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .scanner import Scanner
|
|
7
|
+
from .report import print_summary
|
|
8
|
+
from .baseline import load_baseline, save_baseline, compare_findings, compute_finding_id
|
|
9
|
+
from .owasp import format_owasp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def cli():
|
|
14
|
+
"""AgentSec — security scanner for AI agent configs."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cli.command()
|
|
19
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True))
|
|
20
|
+
@click.option("--format", "-f", default="terminal", help="Output format: terminal, json, markdown, sarif")
|
|
21
|
+
@click.option("--severity", default="all", help="Minimum severity: critical, high, medium, low, all")
|
|
22
|
+
@click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low"], case_sensitive=False), help="Exit with code 1 if any finding is at least this severity")
|
|
23
|
+
@click.option("--include-hidden", is_flag=True, help="Include hidden files and directories")
|
|
24
|
+
@click.option("--baseline", type=click.Path(exists=True, dir_okay=False, resolve_path=True), help="Path to baseline JSON file (lockfile). Compare findings against it.")
|
|
25
|
+
@click.option("--update-baseline", type=click.Path(dir_okay=False, resolve_path=True), help="Save current findings as baseline JSON file and exit.")
|
|
26
|
+
@click.option("--show-owasp", is_flag=True, default=False, help="Show OWASP Top 10 for LLM mapping IDs for each finding")
|
|
27
|
+
def scan(path, format, severity, include_hidden, fail_on=None, baseline=None, update_baseline=None, show_owasp=False):
|
|
28
|
+
"""Scan a directory for security risks in AI agent configurations."""
|
|
29
|
+
if format == "terminal":
|
|
30
|
+
click.echo(f" Scanning {path}...")
|
|
31
|
+
scanner = Scanner(Path(path), include_hidden=include_hidden, min_severity=severity)
|
|
32
|
+
findings = scanner.scan()
|
|
33
|
+
|
|
34
|
+
# If update-baseline is provided, save baseline and exit
|
|
35
|
+
if update_baseline:
|
|
36
|
+
save_baseline(update_baseline, findings)
|
|
37
|
+
if format == "terminal":
|
|
38
|
+
click.echo(f" Baseline saved to {update_baseline}")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
# Load baseline if provided
|
|
42
|
+
if baseline:
|
|
43
|
+
baseline_findings = load_baseline(baseline)
|
|
44
|
+
new, changed, removed = compare_findings(findings, baseline_findings)
|
|
45
|
+
if format == "terminal":
|
|
46
|
+
click.echo(f"\n Baseline comparison against {baseline}:")
|
|
47
|
+
click.echo(f" New findings: {len(new)}")
|
|
48
|
+
click.echo(f" Changed severity: {len(changed)}")
|
|
49
|
+
click.echo(f" Removed findings: {len(removed)}")
|
|
50
|
+
if new:
|
|
51
|
+
click.echo("\n New findings:")
|
|
52
|
+
for f in new:
|
|
53
|
+
click.echo(f" [{f['severity']}] {f['rule']} ({f['file']})")
|
|
54
|
+
if changed:
|
|
55
|
+
click.echo("\n Changed findings:")
|
|
56
|
+
for f in changed:
|
|
57
|
+
old_sev = baseline_findings.get(compute_finding_id(f), 'unknown')
|
|
58
|
+
click.echo(f" [{old_sev} -> {f['severity']}] {f['rule']} ({f['file']})")
|
|
59
|
+
if removed:
|
|
60
|
+
click.echo("\n Removed findings (baseline only):")
|
|
61
|
+
for r in removed:
|
|
62
|
+
click.echo(f" [{r['severity']}] id: {r['id']}")
|
|
63
|
+
print_summary(findings, format, show_owasp=show_owasp)
|
|
64
|
+
if new or changed:
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
else:
|
|
67
|
+
# Add OWASP tags to terminal output header if show_owasp
|
|
68
|
+
if format == "terminal" and show_owasp:
|
|
69
|
+
click.echo(" OWASP mapping enabled (LLM = OWASP Top 10 for LLM, AG = OWASP Agentic Security)\n")
|
|
70
|
+
print_summary(findings, format, show_owasp=show_owasp)
|
|
71
|
+
|
|
72
|
+
# Existing fail-on logic
|
|
73
|
+
if fail_on:
|
|
74
|
+
severity_levels = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
75
|
+
min_fail = severity_levels.get(fail_on.lower(), -1)
|
|
76
|
+
if min_fail >= 0:
|
|
77
|
+
for f in findings:
|
|
78
|
+
if severity_levels.get(f["severity"].lower(), -1) >= min_fail:
|
|
79
|
+
click.echo(f"Failing due to {f['severity']} finding: {f['rule']}")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
cli()
|
agentsec/owasp.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""OWASP Top 10 for LLM Applications — rule mapping for AgentSec.
|
|
2
|
+
|
|
3
|
+
Maps each AgentSec rule to the relevant OWASP categories.
|
|
4
|
+
|
|
5
|
+
OWASP Top 10 for LLM Applications (2025):
|
|
6
|
+
https://genai.owasp.org/
|
|
7
|
+
|
|
8
|
+
LLM01 — Prompt Injection
|
|
9
|
+
LLM02 — Sensitive Information Disclosure
|
|
10
|
+
LLM03 — Supply Chain Vulnerabilities
|
|
11
|
+
LLM04 — Data Leakage via External Services
|
|
12
|
+
LLM05 — Insecure Output Handling
|
|
13
|
+
LLM06 — Excessive Agency / Unrestricted Autonomy
|
|
14
|
+
LLM07 — Insecure Plugin / Extension Design
|
|
15
|
+
LLM08 — Excessive Permissions / Overprivileged Access
|
|
16
|
+
LLM09 — Over-reliance / Insufficient Oversight
|
|
17
|
+
LLM10 — Model Theft / Intellectual Property Loss
|
|
18
|
+
|
|
19
|
+
OWASP Agentic Security Top 10 (2025):
|
|
20
|
+
AG01 — Insecure Agent-to-Agent Communication
|
|
21
|
+
AG02 — Unauthorized Tool Access
|
|
22
|
+
AG03 — Agent Impersonation
|
|
23
|
+
AG04 — Task Delegation Abuse
|
|
24
|
+
AG05 — Memory / Prompt Leakage
|
|
25
|
+
AG06 — Inconsistent Authorization
|
|
26
|
+
AG07 — Output Validation Failure
|
|
27
|
+
AG08 — Agent Workflow Manipulation
|
|
28
|
+
AG09 — Inadequate Audit Trail
|
|
29
|
+
AG10 — Privilege Escalation
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Mapping: OWASP ID -> (short name, description)
|
|
33
|
+
OWASP_CATEGORIES = {
|
|
34
|
+
"LLM01": ("Prompt Injection", "Attacker injects malicious instructions via user prompts or indirect inputs"),
|
|
35
|
+
"LLM02": ("Sensitive Information Disclosure", "LLM or agent exposes sensitive data (secrets, PII, internal info)"),
|
|
36
|
+
"LLM03": ("Supply Chain Vulnerabilities", "Compromised dependencies, packages, or third-party components"),
|
|
37
|
+
"LLM04": ("Data Leakage via External Services", "Sensitive data exfiltrated through network calls, APIs, or integrations"),
|
|
38
|
+
"LLM05": ("Insecure Output Handling", "Agent output is not validated before being used in downstream operations"),
|
|
39
|
+
"LLM06": ("Excessive Agency", "Agent has more autonomy than necessary — can perform actions without oversight"),
|
|
40
|
+
"LLM07": ("Insecure Plugin/Extension Design", "Plugins or extensions have weak security boundaries"),
|
|
41
|
+
"LLM08": ("Excessive Permissions", "Agent or tool has overly broad filesystem/network/OS permissions"),
|
|
42
|
+
"LLM09": ("Over-reliance / Insufficient Oversight", "No guardrails, audit, or policy enforcement for agent actions"),
|
|
43
|
+
"LLM10": ("Model Theft / IP Loss", "Risk of proprietary model weights or IP extraction"),
|
|
44
|
+
# Agentic Security
|
|
45
|
+
"AG01": ("Insecure Agent-to-Agent Communication", "Agents communicate without proper authentication or encryption"),
|
|
46
|
+
"AG02": ("Unauthorized Tool Access", "Agent can invoke tools or capabilities without proper authorization gates"),
|
|
47
|
+
"AG03": ("Agent Impersonation", "Attacker impersonates a legitimate agent to gain access"),
|
|
48
|
+
"AG04": ("Task Delegation Abuse", "Malicious tasks can be delegated to sub-agents without validation"),
|
|
49
|
+
"AG05": ("Memory/Prompt Leakage", "Agent memory or prompt context can leak across sessions or users"),
|
|
50
|
+
"AG06": ("Inconsistent Authorization", "Authorization policies are missing, incomplete, or not enforced"),
|
|
51
|
+
"AG07": ("Output Validation Failure", "Agent output is not validated for safety or correctness"),
|
|
52
|
+
"AG08": ("Agent Workflow Manipulation", "Attacker manipulates the agent's workflow or decision chain"),
|
|
53
|
+
"AG09": ("Inadequate Audit Trail", "Agent actions are not logged or traceable"),
|
|
54
|
+
"AG10": ("Privilege Escalation", "Agent can escalate its own privileges beyond intended scope"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Rule-to-OWASP mapping: rule_name -> list of (owasp_id, category_name)
|
|
58
|
+
# Covers all 41 rules from base.py + additional.py (deduplicated)
|
|
59
|
+
RULE_OWASP_MAP = {
|
|
60
|
+
# === Base rules ===
|
|
61
|
+
"MCP shell execution": [
|
|
62
|
+
("LLM06", "Excessive Agency"),
|
|
63
|
+
("AG02", "Unauthorized Tool Access"),
|
|
64
|
+
],
|
|
65
|
+
"MCP filesystem write access": [
|
|
66
|
+
("LLM08", "Excessive Permissions"),
|
|
67
|
+
],
|
|
68
|
+
"Secret exposure": [
|
|
69
|
+
("LLM02", "Sensitive Information Disclosure"),
|
|
70
|
+
],
|
|
71
|
+
"Broad path access": [
|
|
72
|
+
("LLM08", "Excessive Permissions"),
|
|
73
|
+
],
|
|
74
|
+
"Prompt injection risk": [
|
|
75
|
+
("LLM01", "Prompt Injection"),
|
|
76
|
+
],
|
|
77
|
+
"Sensitive file reference": [
|
|
78
|
+
("LLM02", "Sensitive Information Disclosure"),
|
|
79
|
+
],
|
|
80
|
+
"Excessive autonomy": [
|
|
81
|
+
("LLM06", "Excessive Agency"),
|
|
82
|
+
],
|
|
83
|
+
"Unpinned dependency": [
|
|
84
|
+
("LLM03", "Supply Chain Vulnerabilities"),
|
|
85
|
+
],
|
|
86
|
+
"Remote script install": [
|
|
87
|
+
("LLM03", "Supply Chain Vulnerabilities"),
|
|
88
|
+
],
|
|
89
|
+
"Docker socket access": [
|
|
90
|
+
("LLM08", "Excessive Permissions"),
|
|
91
|
+
("AG02", "Unauthorized Tool Access"),
|
|
92
|
+
],
|
|
93
|
+
# === Additional rules ===
|
|
94
|
+
"Network + filesystem access": [
|
|
95
|
+
("LLM04", "Data Leakage via External Services"),
|
|
96
|
+
("AG05", "Memory/Prompt Leakage"),
|
|
97
|
+
],
|
|
98
|
+
"Suspicious tool description": [
|
|
99
|
+
("LLM01", "Prompt Injection"),
|
|
100
|
+
("AG10", "Privilege Escalation"),
|
|
101
|
+
],
|
|
102
|
+
"GitHub token exposure": [
|
|
103
|
+
("LLM02", "Sensitive Information Disclosure"),
|
|
104
|
+
("AG02", "Unauthorized Tool Access"),
|
|
105
|
+
],
|
|
106
|
+
"Communication tool write permission": [
|
|
107
|
+
("LLM04", "Data Leakage via External Services"),
|
|
108
|
+
],
|
|
109
|
+
"Database write/delete permission": [
|
|
110
|
+
("LLM08", "Excessive Permissions"),
|
|
111
|
+
],
|
|
112
|
+
"Excessive autonomy instruction": [
|
|
113
|
+
("LLM06", "Excessive Agency"),
|
|
114
|
+
],
|
|
115
|
+
"Prompt injection in markdown": [
|
|
116
|
+
("LLM01", "Prompt Injection"),
|
|
117
|
+
],
|
|
118
|
+
"MCP OAuth broad scopes": [
|
|
119
|
+
("LLM07", "Insecure Plugin/Extension Design"),
|
|
120
|
+
("AG06", "Inconsistent Authorization"),
|
|
121
|
+
],
|
|
122
|
+
"Web + filesystem access": [
|
|
123
|
+
("LLM04", "Data Leakage via External Services"),
|
|
124
|
+
("LLM08", "Excessive Permissions"),
|
|
125
|
+
],
|
|
126
|
+
"Read repo + network": [
|
|
127
|
+
("LLM04", "Data Leakage via External Services"),
|
|
128
|
+
("AG05", "Memory/Prompt Leakage"),
|
|
129
|
+
],
|
|
130
|
+
"Unknown/untrusted source": [
|
|
131
|
+
("LLM03", "Supply Chain Vulnerabilities"),
|
|
132
|
+
],
|
|
133
|
+
"No policy file": [
|
|
134
|
+
("LLM09", "Over-reliance / Insufficient Oversight"),
|
|
135
|
+
("AG06", "Inconsistent Authorization"),
|
|
136
|
+
],
|
|
137
|
+
"Cursor agent config with dangerous permissions": [
|
|
138
|
+
("LLM08", "Excessive Permissions"),
|
|
139
|
+
("AG02", "Unauthorized Tool Access"),
|
|
140
|
+
],
|
|
141
|
+
"Claude Desktop config with MCP server risks": [
|
|
142
|
+
("LLM07", "Insecure Plugin/Extension Design"),
|
|
143
|
+
],
|
|
144
|
+
"Codex/Cline agent with unrestricted tools": [
|
|
145
|
+
("LLM08", "Excessive Permissions"),
|
|
146
|
+
("AG02", "Unauthorized Tool Access"),
|
|
147
|
+
],
|
|
148
|
+
"Environment variable exposure": [
|
|
149
|
+
("LLM02", "Sensitive Information Disclosure"),
|
|
150
|
+
],
|
|
151
|
+
"Vulnerable dependency pattern": [
|
|
152
|
+
("LLM03", "Supply Chain Vulnerabilities"),
|
|
153
|
+
],
|
|
154
|
+
"Insecure default command": [
|
|
155
|
+
("LLM07", "Insecure Plugin/Extension Design"),
|
|
156
|
+
],
|
|
157
|
+
"Read-only file system in MCP server": [
|
|
158
|
+
("LLM08", "Excessive Permissions"),
|
|
159
|
+
],
|
|
160
|
+
"Missing input validation": [
|
|
161
|
+
("LLM05", "Insecure Output Handling"),
|
|
162
|
+
("AG07", "Output Validation Failure"),
|
|
163
|
+
],
|
|
164
|
+
"Package manager execution": [
|
|
165
|
+
("LLM03", "Supply Chain Vulnerabilities"),
|
|
166
|
+
],
|
|
167
|
+
"Container privileged mode": [
|
|
168
|
+
("LLM08", "Excessive Permissions"),
|
|
169
|
+
("AG02", "Unauthorized Tool Access"),
|
|
170
|
+
],
|
|
171
|
+
"Host mount exposure": [
|
|
172
|
+
("LLM08", "Excessive Permissions"),
|
|
173
|
+
],
|
|
174
|
+
"Browser automation with local file access": [
|
|
175
|
+
("LLM04", "Data Leakage via External Services"),
|
|
176
|
+
("AG05", "Memory/Prompt Leakage"),
|
|
177
|
+
],
|
|
178
|
+
"Dynamic code execution": [
|
|
179
|
+
("LLM06", "Excessive Agency"),
|
|
180
|
+
("AG10", "Privilege Escalation"),
|
|
181
|
+
],
|
|
182
|
+
"Wildcard tool allowlist": [
|
|
183
|
+
("LLM08", "Excessive Permissions"),
|
|
184
|
+
],
|
|
185
|
+
"Telemetry or analytics endpoint": [
|
|
186
|
+
("LLM04", "Data Leakage via External Services"),
|
|
187
|
+
],
|
|
188
|
+
"Credential helper access": [
|
|
189
|
+
("LLM02", "Sensitive Information Disclosure"),
|
|
190
|
+
("AG02", "Unauthorized Tool Access"),
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_owasp(rule_name: str) -> list:
|
|
196
|
+
"""Return OWASP mappings for a rule name. Returns [(owasp_id, category_name), ...] or empty list."""
|
|
197
|
+
return RULE_OWASP_MAP.get(rule_name, [])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_owasp_ids(rule_name: str) -> str:
|
|
201
|
+
"""Return OWASP IDs as a comma-separated string (e.g. 'LLM06, AG02')."""
|
|
202
|
+
mappings = get_owasp(rule_name)
|
|
203
|
+
return ", ".join(owasp_id for owasp_id, _ in mappings)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_owasp(rule_name: str) -> str:
|
|
207
|
+
"""Format OWASP info for terminal output: '[LLM06, AG02]' or empty string."""
|
|
208
|
+
mappings = get_owasp(rule_name)
|
|
209
|
+
if not mappings:
|
|
210
|
+
return ""
|
|
211
|
+
ids = ", ".join(f"{owasp_id}" for owasp_id, _ in mappings)
|
|
212
|
+
return f"[{ids}]"
|
agentsec/parsers/core.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
def parse_mcp_config(content: str, file_path: Path) -> Optional[List[Dict[str, Any]]]:
|
|
6
|
+
"""Parse MCP config from JSON content.
|
|
7
|
+
|
|
8
|
+
Expects a JSON object with an 'mcpServers' key or a direct list of servers.
|
|
9
|
+
Returns a list of server dicts with keys: name, command, args, env, capabilities.
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
data = json.loads(content)
|
|
13
|
+
except json.JSONDecodeError:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
servers = []
|
|
17
|
+
|
|
18
|
+
# Check for mcpServers object
|
|
19
|
+
if isinstance(data, dict) and "mcpServers" in data:
|
|
20
|
+
mcp_servers = data["mcpServers"]
|
|
21
|
+
if isinstance(mcp_servers, dict):
|
|
22
|
+
for name, config in mcp_servers.items():
|
|
23
|
+
if isinstance(config, dict):
|
|
24
|
+
server = {
|
|
25
|
+
"name": name,
|
|
26
|
+
"command": config.get("command", ""),
|
|
27
|
+
"args": config.get("args", []),
|
|
28
|
+
"env": config.get("env", {}),
|
|
29
|
+
"capabilities": _infer_capabilities(config)
|
|
30
|
+
}
|
|
31
|
+
servers.append(server)
|
|
32
|
+
elif isinstance(data, list):
|
|
33
|
+
# Maybe a list of servers
|
|
34
|
+
for item in data:
|
|
35
|
+
if isinstance(item, dict) and "command" in item:
|
|
36
|
+
server = {
|
|
37
|
+
"name": item.get("name", ""),
|
|
38
|
+
"command": item.get("command", ""),
|
|
39
|
+
"args": item.get("args", []),
|
|
40
|
+
"env": item.get("env", {}),
|
|
41
|
+
"capabilities": _infer_capabilities(item)
|
|
42
|
+
}
|
|
43
|
+
servers.append(server)
|
|
44
|
+
return servers if servers else None
|
|
45
|
+
|
|
46
|
+
def _infer_capabilities(config: Dict[str, Any]) -> List[str]:
|
|
47
|
+
"""Infer capabilities from command, args, and env."""
|
|
48
|
+
caps = []
|
|
49
|
+
text = str(config).lower()
|
|
50
|
+
|
|
51
|
+
# Filesystem
|
|
52
|
+
if "filesystem" in text or "write" in text or "edit" in text or "delete" in text or "rm" in text or "mv" in text:
|
|
53
|
+
caps.append("filesystem")
|
|
54
|
+
# Shell
|
|
55
|
+
if "bash" in text or "sh" in text or "powershell" in text or "cmd" in text or "exec" in text or "subprocess" in text or "terminal" in text or "run_command" in text:
|
|
56
|
+
caps.append("shell")
|
|
57
|
+
# Network
|
|
58
|
+
if "http" in text or "https" in text or "curl" in text or "wget" in text or "fetch" in text:
|
|
59
|
+
caps.append("network")
|
|
60
|
+
# Secrets
|
|
61
|
+
if "env" in text or ".env" in text or "secret" in text or "token" in text or "key" in text:
|
|
62
|
+
caps.append("secrets")
|
|
63
|
+
# Slack/email/github
|
|
64
|
+
if "slack" in text or "gmail" in text or "email" in text or "send_message" in text or "post_message" in text or "create_issue" in text or "comment" in text or "reply" in text:
|
|
65
|
+
caps.append("communication")
|
|
66
|
+
# Database
|
|
67
|
+
if "postgres" in text or "mysql" in text or "mongodb" in text or "redis" in text or "delete" in text or "drop" in text or "update" in text or "insert" in text:
|
|
68
|
+
caps.append("database")
|
|
69
|
+
return caps
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from .json_parser import _infer_capabilities
|
|
5
|
+
|
|
6
|
+
def parse_mcp_config(content: str, file_path: Path) -> Optional[List[Dict[str, Any]]]:
|
|
7
|
+
"""Parse MCP config from TOML content."""
|
|
8
|
+
try:
|
|
9
|
+
data = tomllib.loads(content)
|
|
10
|
+
except (tomllib.TOMLDecodeError, TypeError):
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
if not isinstance(data, dict):
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
servers = []
|
|
17
|
+
if "mcpServers" in data and isinstance(data["mcpServers"], dict):
|
|
18
|
+
for name, config in data["mcpServers"].items():
|
|
19
|
+
if isinstance(config, dict):
|
|
20
|
+
server = {
|
|
21
|
+
"name": name,
|
|
22
|
+
"command": config.get("command", ""),
|
|
23
|
+
"args": config.get("args", []),
|
|
24
|
+
"env": config.get("env", {}),
|
|
25
|
+
"capabilities": _infer_capabilities(config)
|
|
26
|
+
}
|
|
27
|
+
servers.append(server)
|
|
28
|
+
return servers if servers else None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from .json_parser import _infer_capabilities
|
|
5
|
+
|
|
6
|
+
def parse_mcp_config(content: str, file_path: Path) -> Optional[List[Dict[str, Any]]]:
|
|
7
|
+
"""Parse MCP config from YAML content."""
|
|
8
|
+
try:
|
|
9
|
+
data = yaml.safe_load(content)
|
|
10
|
+
except yaml.YAMLError:
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
if not isinstance(data, dict):
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
servers = []
|
|
17
|
+
if "mcpServers" in data and isinstance(data["mcpServers"], dict):
|
|
18
|
+
for name, config in data["mcpServers"].items():
|
|
19
|
+
if isinstance(config, dict):
|
|
20
|
+
server = {
|
|
21
|
+
"name": name,
|
|
22
|
+
"command": config.get("command", ""),
|
|
23
|
+
"args": config.get("args", []),
|
|
24
|
+
"env": config.get("env", {}),
|
|
25
|
+
"capabilities": _infer_capabilities(config)
|
|
26
|
+
}
|
|
27
|
+
servers.append(server)
|
|
28
|
+
return servers if servers else None
|
agentsec/report.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Output formatters for AgentSec findings."""
|
|
2
|
+
|
|
3
|
+
def print_summary(findings: list, format: str, show_owasp: bool = False) -> None:
|
|
4
|
+
"""Print findings in the requested format."""
|
|
5
|
+
if format == "terminal":
|
|
6
|
+
for f in findings:
|
|
7
|
+
owasp_tag = f" {f.get('owasp', '')}" if show_owasp and f.get('owasp') else ""
|
|
8
|
+
print(f"[{f['severity'].upper()}]{owasp_tag} {f['rule']}")
|
|
9
|
+
print(f" File: {f['file']}")
|
|
10
|
+
if f.get('server'):
|
|
11
|
+
print(f" Server: {f['server']}")
|
|
12
|
+
print(f" Description: {f['description']}")
|
|
13
|
+
print(f" Recommendation: {f['recommendation']}")
|
|
14
|
+
if show_owasp and f.get('owasp'):
|
|
15
|
+
print(f" OWASP: {f['owasp']}")
|
|
16
|
+
print()
|
|
17
|
+
print(f"Total findings: {len(findings)}")
|
|
18
|
+
elif format == "json":
|
|
19
|
+
import json
|
|
20
|
+
print(json.dumps(findings, indent=2))
|
|
21
|
+
elif format == "markdown":
|
|
22
|
+
print("# AgentSec Report\n")
|
|
23
|
+
for f in findings:
|
|
24
|
+
owasp_tag = f" ({f.get('owasp')})" if show_owasp and f.get('owasp') else ""
|
|
25
|
+
print(f"## {f['severity'].upper()}: {f['rule']}{owasp_tag}")
|
|
26
|
+
print(f"**File:** {f['file']}")
|
|
27
|
+
if f.get('server'):
|
|
28
|
+
print(f"**Server:** {f['server']}")
|
|
29
|
+
print(f"**Description:** {f['description']}")
|
|
30
|
+
print(f"**Recommendation:** {f['recommendation']}")
|
|
31
|
+
if show_owasp and f.get('owasp'):
|
|
32
|
+
print(f"**OWASP:** {f['owasp']}")
|
|
33
|
+
print()
|
|
34
|
+
elif format == "sarif":
|
|
35
|
+
from .sarif import print_sarif
|
|
36
|
+
print_sarif(findings)
|