mcp-capdiff 0.1.1__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.
- mcp_audit/__init__.py +6 -0
- mcp_audit/__main__.py +6 -0
- mcp_audit/analysis/__init__.py +1 -0
- mcp_audit/analysis/diff.py +235 -0
- mcp_audit/analysis/scan.py +29 -0
- mcp_audit/cli/__init__.py +1 -0
- mcp_audit/cli/main.py +112 -0
- mcp_audit/models/__init__.py +12 -0
- mcp_audit/models/capability.py +73 -0
- mcp_audit/models/finding.py +87 -0
- mcp_audit/models/report.py +120 -0
- mcp_audit/policy/__init__.py +1 -0
- mcp_audit/policy/evaluator.py +29 -0
- mcp_audit/policy/loader.py +138 -0
- mcp_audit/reporters/__init__.py +1 -0
- mcp_audit/reporters/json.py +9 -0
- mcp_audit/reporters/manifest.py +30 -0
- mcp_audit/reporters/sarif.py +94 -0
- mcp_audit/reporters/terminal.py +97 -0
- mcp_audit/rules/__init__.py +1 -0
- mcp_audit/rules/engine.py +177 -0
- mcp_audit/scanners/__init__.py +1 -0
- mcp_audit/scanners/source/__init__.py +1 -0
- mcp_audit/scanners/source/python.py +441 -0
- mcp_capdiff-0.1.1.dist-info/METADATA +173 -0
- mcp_capdiff-0.1.1.dist-info/RECORD +30 -0
- mcp_capdiff-0.1.1.dist-info/WHEEL +5 -0
- mcp_capdiff-0.1.1.dist-info/entry_points.txt +2 -0
- mcp_capdiff-0.1.1.dist-info/licenses/LICENSE +21 -0
- mcp_capdiff-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from mcp_audit import REPORT_SCHEMA_VERSION, RULESET_VERSION, __version__
|
|
6
|
+
from mcp_audit.models.capability import Tool
|
|
7
|
+
from mcp_audit.models.finding import Finding, Severity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class ScanReport:
|
|
12
|
+
target: str
|
|
13
|
+
server_name: str
|
|
14
|
+
tools: list[Tool] = field(default_factory=list)
|
|
15
|
+
findings: list[Finding] = field(default_factory=list)
|
|
16
|
+
result: str = "pass"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def risk_score(self) -> int:
|
|
20
|
+
weights = {
|
|
21
|
+
Severity.LOW: 5,
|
|
22
|
+
Severity.MEDIUM: 15,
|
|
23
|
+
Severity.HIGH: 30,
|
|
24
|
+
Severity.CRITICAL: 45,
|
|
25
|
+
}
|
|
26
|
+
return min(100, sum(weights[finding.severity] for finding in self.findings))
|
|
27
|
+
|
|
28
|
+
def counts_by_severity(self) -> dict[str, int]:
|
|
29
|
+
counts = {severity.name.lower(): 0 for severity in Severity}
|
|
30
|
+
for finding in self.findings:
|
|
31
|
+
counts[finding.severity.name.lower()] += 1
|
|
32
|
+
return counts
|
|
33
|
+
|
|
34
|
+
def as_dict(self) -> dict[str, object]:
|
|
35
|
+
return {
|
|
36
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
37
|
+
"report_type": "scan",
|
|
38
|
+
"versions": {"cli": __version__, "rules": RULESET_VERSION},
|
|
39
|
+
"target": self.target,
|
|
40
|
+
"server": {"name": self.server_name},
|
|
41
|
+
"summary": {
|
|
42
|
+
"tools": len(self.tools),
|
|
43
|
+
"findings": len(self.findings),
|
|
44
|
+
"risk_score": self.risk_score,
|
|
45
|
+
"result": self.result,
|
|
46
|
+
"severity_counts": self.counts_by_severity(),
|
|
47
|
+
},
|
|
48
|
+
"tools": [tool.as_dict() for tool in self.tools],
|
|
49
|
+
"findings": [finding.as_dict() for finding in self.findings],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class CapabilityChange:
|
|
55
|
+
tool: str
|
|
56
|
+
capability: str
|
|
57
|
+
before: object
|
|
58
|
+
after: object
|
|
59
|
+
|
|
60
|
+
def as_dict(self) -> dict[str, object]:
|
|
61
|
+
return {
|
|
62
|
+
"tool": self.tool,
|
|
63
|
+
"capability": self.capability,
|
|
64
|
+
"before": self.before,
|
|
65
|
+
"after": self.after,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class SecurityDiff:
|
|
71
|
+
target: str
|
|
72
|
+
base: str
|
|
73
|
+
server_name: str
|
|
74
|
+
risk_before: int
|
|
75
|
+
risk_after: int
|
|
76
|
+
tools: list[Tool] = field(default_factory=list)
|
|
77
|
+
findings: list[Finding] = field(default_factory=list)
|
|
78
|
+
new_tools: list[str] = field(default_factory=list)
|
|
79
|
+
removed_tools: list[str] = field(default_factory=list)
|
|
80
|
+
changed_tools: list[str] = field(default_factory=list)
|
|
81
|
+
capability_changes: list[CapabilityChange] = field(default_factory=list)
|
|
82
|
+
new_findings: list[Finding] = field(default_factory=list)
|
|
83
|
+
resolved_findings: list[Finding] = field(default_factory=list)
|
|
84
|
+
result: str = "pass"
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def risk_score(self) -> int:
|
|
88
|
+
return self.risk_after
|
|
89
|
+
|
|
90
|
+
def counts_by_severity(self) -> dict[str, int]:
|
|
91
|
+
counts = {severity.name.lower(): 0 for severity in Severity}
|
|
92
|
+
for finding in self.findings:
|
|
93
|
+
counts[finding.severity.name.lower()] += 1
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
def as_dict(self) -> dict[str, object]:
|
|
97
|
+
return {
|
|
98
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
99
|
+
"report_type": "diff",
|
|
100
|
+
"versions": {"cli": __version__, "rules": RULESET_VERSION},
|
|
101
|
+
"target": self.target,
|
|
102
|
+
"base": self.base,
|
|
103
|
+
"server": {"name": self.server_name},
|
|
104
|
+
"summary": {
|
|
105
|
+
"risk_before": self.risk_before,
|
|
106
|
+
"risk_after": self.risk_after,
|
|
107
|
+
"result": self.result,
|
|
108
|
+
"new_tools": len(self.new_tools),
|
|
109
|
+
"changed_tools": len(self.changed_tools),
|
|
110
|
+
"new_findings": len(self.new_findings),
|
|
111
|
+
"resolved_findings": len(self.resolved_findings),
|
|
112
|
+
},
|
|
113
|
+
"new_tools": self.new_tools,
|
|
114
|
+
"removed_tools": self.removed_tools,
|
|
115
|
+
"changed_tools": self.changed_tools,
|
|
116
|
+
"capability_changes": [change.as_dict() for change in self.capability_changes],
|
|
117
|
+
"new_findings": [finding.as_dict() for finding in self.new_findings],
|
|
118
|
+
"resolved_findings": [finding.as_dict() for finding in self.resolved_findings],
|
|
119
|
+
"regression_findings": [finding.as_dict() for finding in self.findings],
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Policy loading and evaluation."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
from mcp_audit.models.finding import Finding, Severity
|
|
6
|
+
from mcp_audit.models.report import ScanReport, SecurityDiff
|
|
7
|
+
from mcp_audit.policy.loader import Policy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_policy(
|
|
11
|
+
report: ScanReport | SecurityDiff, policy: Policy, fail_on: Severity | None = None
|
|
12
|
+
) -> ScanReport | SecurityDiff:
|
|
13
|
+
report.findings = [
|
|
14
|
+
finding
|
|
15
|
+
for finding in report.findings
|
|
16
|
+
if not any(
|
|
17
|
+
suppression.matches(finding) and suppression.is_active(date.today())
|
|
18
|
+
for suppression in policy.suppressions
|
|
19
|
+
)
|
|
20
|
+
]
|
|
21
|
+
thresholds = (
|
|
22
|
+
{severity for severity in Severity if severity >= fail_on}
|
|
23
|
+
if fail_on
|
|
24
|
+
else policy.fail_on
|
|
25
|
+
)
|
|
26
|
+
report.result = "fail" if any(finding.severity in thresholds for finding in report.findings) else "pass"
|
|
27
|
+
if policy.max_risk_score is not None and report.risk_score > policy.max_risk_score:
|
|
28
|
+
report.result = "fail"
|
|
29
|
+
return report
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from mcp_audit.models.finding import Finding, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SUPPORTED_RULES = {
|
|
14
|
+
"MCP001",
|
|
15
|
+
"MCP002",
|
|
16
|
+
"MCP003",
|
|
17
|
+
"MCP004",
|
|
18
|
+
"MCP005",
|
|
19
|
+
"MCP007",
|
|
20
|
+
"MCP010",
|
|
21
|
+
"MCP016",
|
|
22
|
+
"MCP017",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Suppression:
|
|
28
|
+
rule_id: str
|
|
29
|
+
reason: str
|
|
30
|
+
tool: str | None = None
|
|
31
|
+
expires: date | None = None
|
|
32
|
+
|
|
33
|
+
def matches(self, finding: Finding) -> bool:
|
|
34
|
+
return self.rule_id == finding.rule_id and (self.tool is None or self.tool == finding.tool)
|
|
35
|
+
|
|
36
|
+
def is_active(self, as_of: date | None = None) -> bool:
|
|
37
|
+
return self.expires is None or self.expires >= (as_of or date.today())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class Policy:
|
|
42
|
+
fail_on: set[Severity] = field(default_factory=lambda: {Severity.CRITICAL, Severity.HIGH})
|
|
43
|
+
max_risk_score: int | None = None
|
|
44
|
+
suppressions: list[Suppression] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def expired_suppressions(self, as_of: date | None = None) -> list[Suppression]:
|
|
47
|
+
return [suppression for suppression in self.suppressions if not suppression.is_active(as_of)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_policy(path: str | None) -> Policy:
|
|
51
|
+
if not path:
|
|
52
|
+
return Policy()
|
|
53
|
+
policy_path = Path(path)
|
|
54
|
+
if not policy_path.exists():
|
|
55
|
+
raise FileNotFoundError(f"policy file not found: {policy_path}")
|
|
56
|
+
try:
|
|
57
|
+
payload = yaml.safe_load(policy_path.read_text(encoding="utf-8")) or {}
|
|
58
|
+
except yaml.YAMLError as exc:
|
|
59
|
+
raise ValueError(f"invalid YAML in policy file: {exc}") from exc
|
|
60
|
+
return _parse_policy(payload)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_small_yaml_subset(text: str) -> Policy:
|
|
64
|
+
"""Compatibility helper retained for callers of the pre-0.1 parser."""
|
|
65
|
+
try:
|
|
66
|
+
payload = yaml.safe_load(text) or {}
|
|
67
|
+
except yaml.YAMLError as exc:
|
|
68
|
+
raise ValueError(f"invalid YAML policy: {exc}") from exc
|
|
69
|
+
return _parse_policy(payload)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_policy(payload: Any) -> Policy:
|
|
73
|
+
if not isinstance(payload, dict):
|
|
74
|
+
raise ValueError("policy document must be a YAML mapping")
|
|
75
|
+
policy = Policy()
|
|
76
|
+
policy_config = payload.get("policy", {})
|
|
77
|
+
if policy_config and not isinstance(policy_config, dict):
|
|
78
|
+
raise ValueError("policy must be a mapping")
|
|
79
|
+
ci_config = policy_config.get("ci", policy_config) if policy_config else {}
|
|
80
|
+
if ci_config and not isinstance(ci_config, dict):
|
|
81
|
+
raise ValueError("policy.ci must be a mapping")
|
|
82
|
+
|
|
83
|
+
if "fail_on" in ci_config:
|
|
84
|
+
values = ci_config["fail_on"]
|
|
85
|
+
if not isinstance(values, list) or not values:
|
|
86
|
+
raise ValueError("policy.ci.fail_on must be a non-empty list")
|
|
87
|
+
policy.fail_on = {Severity.parse(str(value)) for value in values}
|
|
88
|
+
if "max_risk_score" in ci_config:
|
|
89
|
+
score = ci_config["max_risk_score"]
|
|
90
|
+
if not isinstance(score, int) or isinstance(score, bool) or not 0 <= score <= 100:
|
|
91
|
+
raise ValueError("policy.ci.max_risk_score must be an integer from 0 to 100")
|
|
92
|
+
policy.max_risk_score = score
|
|
93
|
+
|
|
94
|
+
policy.suppressions = _parse_suppressions(payload.get("suppress", []))
|
|
95
|
+
return policy
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_suppressions(value: Any) -> list[Suppression]:
|
|
99
|
+
if value in ({}, [], None):
|
|
100
|
+
return []
|
|
101
|
+
entries: list[dict[str, Any]] = []
|
|
102
|
+
if isinstance(value, list):
|
|
103
|
+
for item in value:
|
|
104
|
+
if not isinstance(item, dict):
|
|
105
|
+
raise ValueError("each suppression must be a mapping")
|
|
106
|
+
entries.append(item)
|
|
107
|
+
elif isinstance(value, dict):
|
|
108
|
+
for rule_id, config in value.items():
|
|
109
|
+
if not isinstance(config, dict):
|
|
110
|
+
raise ValueError(f"suppression {rule_id} must be a mapping")
|
|
111
|
+
entries.append({"rule": rule_id, **config})
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError("suppress must be a list or mapping")
|
|
114
|
+
|
|
115
|
+
suppressions: list[Suppression] = []
|
|
116
|
+
for entry in entries:
|
|
117
|
+
rule_id = str(entry.get("rule", "")).strip().upper()
|
|
118
|
+
if rule_id not in SUPPORTED_RULES:
|
|
119
|
+
raise ValueError(f"unknown suppression rule: {rule_id or '<empty>'}")
|
|
120
|
+
reason = str(entry.get("reason", "")).strip()
|
|
121
|
+
if not reason:
|
|
122
|
+
raise ValueError(f"suppression {rule_id} requires a non-empty reason")
|
|
123
|
+
tool_value = entry.get("tool")
|
|
124
|
+
tool = str(tool_value).strip() if tool_value is not None else None
|
|
125
|
+
expires = _parse_expiry(entry.get("expires"), rule_id)
|
|
126
|
+
suppressions.append(Suppression(rule_id=rule_id, tool=tool or None, reason=reason, expires=expires))
|
|
127
|
+
return suppressions
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_expiry(value: Any, rule_id: str) -> date | None:
|
|
131
|
+
if value in (None, ""):
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(value, date):
|
|
134
|
+
return value
|
|
135
|
+
try:
|
|
136
|
+
return date.fromisoformat(str(value))
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise ValueError(f"suppression {rule_id} expires must use YYYY-MM-DD") from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Report renderers."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mcp_audit import MANIFEST_SCHEMA_VERSION, RULESET_VERSION
|
|
4
|
+
from mcp_audit.models.report import ScanReport
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def render_manifest(report: ScanReport) -> str:
|
|
8
|
+
lines = [
|
|
9
|
+
f'schema_version: "{MANIFEST_SCHEMA_VERSION}"',
|
|
10
|
+
f'rules_version: "{RULESET_VERSION}"',
|
|
11
|
+
"server:",
|
|
12
|
+
f" name: {report.server_name}",
|
|
13
|
+
"tools:",
|
|
14
|
+
]
|
|
15
|
+
for tool in report.tools:
|
|
16
|
+
capability = tool.capability
|
|
17
|
+
lines.extend(
|
|
18
|
+
[
|
|
19
|
+
f" {tool.name}:",
|
|
20
|
+
f" source: {tool.source}",
|
|
21
|
+
f" data_access: [{', '.join(sorted(capability.data_access))}]",
|
|
22
|
+
f" side_effect: {capability.side_effect}",
|
|
23
|
+
f" filesystem: {capability.filesystem}",
|
|
24
|
+
f" network: {capability.network}",
|
|
25
|
+
f" network_destination: {capability.network_destination or 'none'}",
|
|
26
|
+
f" execution: {capability.execution}",
|
|
27
|
+
f" requires_approval: {str(capability.requires_approval).lower()}",
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcp_audit import RULESET_VERSION, __version__
|
|
7
|
+
from mcp_audit.models.report import ScanReport, SecurityDiff
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_sarif(report: ScanReport | SecurityDiff) -> str:
|
|
11
|
+
rules: dict[str, dict[str, object]] = {}
|
|
12
|
+
results = []
|
|
13
|
+
for finding in report.findings:
|
|
14
|
+
rules.setdefault(
|
|
15
|
+
finding.rule_id,
|
|
16
|
+
{
|
|
17
|
+
"id": finding.rule_id,
|
|
18
|
+
"name": finding.title,
|
|
19
|
+
"shortDescription": {"text": finding.title},
|
|
20
|
+
"help": {"text": finding.recommendation},
|
|
21
|
+
"properties": {"security-severity": _security_severity(finding.severity.name.lower())},
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
result = {
|
|
25
|
+
"ruleId": finding.rule_id,
|
|
26
|
+
"level": _level(finding.severity.name.lower()),
|
|
27
|
+
"message": {"text": _message(finding)},
|
|
28
|
+
"locations": [
|
|
29
|
+
{
|
|
30
|
+
"physicalLocation": {
|
|
31
|
+
"artifactLocation": {"uri": _artifact_uri(finding.location.path)},
|
|
32
|
+
"region": _region(finding),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
if finding.path:
|
|
38
|
+
result["properties"] = {"capabilityPath": finding.path}
|
|
39
|
+
results.append(result)
|
|
40
|
+
sarif = {
|
|
41
|
+
"version": "2.1.0",
|
|
42
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
43
|
+
"runs": [
|
|
44
|
+
{
|
|
45
|
+
"tool": {
|
|
46
|
+
"driver": {
|
|
47
|
+
"name": "MCP Audit",
|
|
48
|
+
"semanticVersion": __version__,
|
|
49
|
+
"informationUri": "https://github.com/robbyfa/MCP-Audit",
|
|
50
|
+
"rules": list(rules.values()),
|
|
51
|
+
"properties": {"rulesVersion": RULESET_VERSION},
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"results": results,
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
return json.dumps(sarif, indent=2, sort_keys=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _message(finding) -> str:
|
|
62
|
+
parts = [finding.message]
|
|
63
|
+
if finding.impact:
|
|
64
|
+
parts.append(f"Risk: {finding.impact}")
|
|
65
|
+
parts.append(f"Remediation: {finding.recommendation}")
|
|
66
|
+
return "\n\n".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _region(finding) -> dict[str, object]:
|
|
70
|
+
region: dict[str, object] = {"startLine": finding.location.line}
|
|
71
|
+
if finding.location.column is not None:
|
|
72
|
+
region["startColumn"] = finding.location.column
|
|
73
|
+
snippet = next((item.snippet for item in finding.evidence if item.snippet), None)
|
|
74
|
+
if snippet:
|
|
75
|
+
region["snippet"] = {"text": snippet}
|
|
76
|
+
return region
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _artifact_uri(path: str) -> str:
|
|
80
|
+
candidate = Path(path)
|
|
81
|
+
if candidate.is_absolute():
|
|
82
|
+
try:
|
|
83
|
+
return candidate.relative_to(Path.cwd()).as_posix()
|
|
84
|
+
except ValueError:
|
|
85
|
+
return candidate.as_uri()
|
|
86
|
+
return candidate.as_posix()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _level(severity: str) -> str:
|
|
90
|
+
return "error" if severity in {"critical", "high"} else "warning"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _security_severity(severity: str) -> str:
|
|
94
|
+
return {"critical": "9.5", "high": "8.0", "medium": "5.0", "low": "2.0"}[severity]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mcp_audit.models.finding import Severity
|
|
4
|
+
from mcp_audit.models.report import ScanReport, SecurityDiff
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def render_terminal(report: ScanReport) -> str:
|
|
8
|
+
counts = report.counts_by_severity()
|
|
9
|
+
lines = [
|
|
10
|
+
"MCP AUDIT",
|
|
11
|
+
f"Server: {report.server_name}",
|
|
12
|
+
f"Target: {report.target}",
|
|
13
|
+
"",
|
|
14
|
+
f"Tools discovered: {len(report.tools)}",
|
|
15
|
+
"",
|
|
16
|
+
"Risk findings",
|
|
17
|
+
f"CRITICAL {counts['critical']}",
|
|
18
|
+
f"HIGH {counts['high']}",
|
|
19
|
+
f"MEDIUM {counts['medium']}",
|
|
20
|
+
f"LOW {counts['low']}",
|
|
21
|
+
f"Risk score: {report.risk_score}",
|
|
22
|
+
"",
|
|
23
|
+
]
|
|
24
|
+
for finding in sorted(report.findings, key=lambda item: item.severity, reverse=True):
|
|
25
|
+
lines.extend(_finding_lines(finding))
|
|
26
|
+
lines.append(f"Result: {report.result.upper()}")
|
|
27
|
+
return "\n".join(lines)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_diff_terminal(report: SecurityDiff) -> str:
|
|
31
|
+
lines = [
|
|
32
|
+
"MCP SECURITY DIFF",
|
|
33
|
+
f"Base: {report.base}",
|
|
34
|
+
f"Target: {report.target}",
|
|
35
|
+
"",
|
|
36
|
+
"Risk",
|
|
37
|
+
f"{report.risk_before} -> {report.risk_after}",
|
|
38
|
+
"",
|
|
39
|
+
"New tools",
|
|
40
|
+
*([f"+ {name}" for name in report.new_tools] or ["(none)"]),
|
|
41
|
+
"",
|
|
42
|
+
"Removed tools",
|
|
43
|
+
*([f"- {name}" for name in report.removed_tools] or ["(none)"]),
|
|
44
|
+
"",
|
|
45
|
+
"Changed tools",
|
|
46
|
+
*([f"~ {name}" for name in report.changed_tools] or ["(none)"]),
|
|
47
|
+
"",
|
|
48
|
+
"Capability changes",
|
|
49
|
+
*(
|
|
50
|
+
[f"~ {change.tool}.{change.capability}: {change.before} -> {change.after}" for change in report.capability_changes]
|
|
51
|
+
or ["(none)"]
|
|
52
|
+
),
|
|
53
|
+
"",
|
|
54
|
+
"New findings",
|
|
55
|
+
*(
|
|
56
|
+
[f"+ {finding.rule_id} {finding.severity.name} {finding.tool or 'n/a'}" for finding in report.new_findings]
|
|
57
|
+
or ["(none)"]
|
|
58
|
+
),
|
|
59
|
+
"",
|
|
60
|
+
"Resolved findings",
|
|
61
|
+
*(
|
|
62
|
+
[f"- {finding.rule_id} {finding.severity.name} {finding.tool or 'n/a'}" for finding in report.resolved_findings]
|
|
63
|
+
or ["(none)"]
|
|
64
|
+
),
|
|
65
|
+
"",
|
|
66
|
+
]
|
|
67
|
+
regressions = [finding for finding in report.findings if finding.rule_id in {"MCP016", "MCP017"}]
|
|
68
|
+
if regressions:
|
|
69
|
+
lines.append("Regression details")
|
|
70
|
+
for finding in regressions:
|
|
71
|
+
lines.extend(_finding_lines(finding))
|
|
72
|
+
lines.append(f"Result: {report.result.upper()}")
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _finding_lines(finding) -> list[str]:
|
|
77
|
+
path = " -> ".join(finding.path)
|
|
78
|
+
lines = [
|
|
79
|
+
f"{finding.severity.name} {finding.rule_id}",
|
|
80
|
+
f"Tool: {finding.tool or 'n/a'}",
|
|
81
|
+
"",
|
|
82
|
+
finding.title,
|
|
83
|
+
finding.message,
|
|
84
|
+
f"Location: {finding.location.path}:{finding.location.line}",
|
|
85
|
+
]
|
|
86
|
+
if path:
|
|
87
|
+
lines.append(f"Path: {path}")
|
|
88
|
+
if finding.evidence:
|
|
89
|
+
lines.append("Evidence:")
|
|
90
|
+
for item in finding.evidence[:5]:
|
|
91
|
+
lines.append(f" - {item.message}")
|
|
92
|
+
if item.snippet:
|
|
93
|
+
lines.append(f" {item.snippet}")
|
|
94
|
+
if finding.impact:
|
|
95
|
+
lines.extend(["Risk:", finding.impact])
|
|
96
|
+
lines.extend(["Suggested remediation:", finding.recommendation, ""])
|
|
97
|
+
return lines
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Security rules."""
|