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.
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from mcp_audit.models.report import ScanReport, SecurityDiff
6
+
7
+
8
+ def render_json(report: ScanReport | SecurityDiff) -> str:
9
+ return json.dumps(report.as_dict(), indent=2, sort_keys=True)
@@ -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."""