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
mcp_audit/__init__.py
ADDED
mcp_audit/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis entry points."""
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mcp_audit.analysis.scan import scan_path
|
|
8
|
+
from mcp_audit.models.capability import Tool
|
|
9
|
+
from mcp_audit.models.finding import Evidence, Finding, Location, Severity
|
|
10
|
+
from mcp_audit.models.report import CapabilityChange, ScanReport, SecurityDiff
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def diff_against_base(base: str, target: str | Path = ".") -> SecurityDiff:
|
|
14
|
+
target_path = Path(target).resolve()
|
|
15
|
+
repo_root = _git_root(target_path.parent if target_path.is_file() else target_path)
|
|
16
|
+
relative_target = target_path.relative_to(repo_root)
|
|
17
|
+
head = scan_path(target_path)
|
|
18
|
+
with tempfile.TemporaryDirectory(prefix="mcp-audit-base-") as temp_dir:
|
|
19
|
+
base_root = Path(temp_dir) / "base"
|
|
20
|
+
_export_git_tree(base, base_root, repo_root)
|
|
21
|
+
base_target = base_root / relative_target
|
|
22
|
+
base_report = (
|
|
23
|
+
scan_path(base_target)
|
|
24
|
+
if base_target.exists()
|
|
25
|
+
else ScanReport(target=str(base_target), server_name=head.server_name)
|
|
26
|
+
)
|
|
27
|
+
if not head.tools and not base_report.tools:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"no MCP tools discovered in either {target_path} or baseline {base}; "
|
|
30
|
+
"check the target path, filesystem access, and supported decorators"
|
|
31
|
+
)
|
|
32
|
+
return _build_diff(base, base_report, head)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _git_root(cwd: Path) -> Path:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
38
|
+
cwd=cwd,
|
|
39
|
+
check=True,
|
|
40
|
+
text=True,
|
|
41
|
+
stdout=subprocess.PIPE,
|
|
42
|
+
stderr=subprocess.PIPE,
|
|
43
|
+
)
|
|
44
|
+
return Path(result.stdout.strip()).resolve()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _export_git_tree(base: str, destination: Path, cwd: Path) -> None:
|
|
48
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
archive = subprocess.run(
|
|
50
|
+
["git", "archive", base],
|
|
51
|
+
cwd=cwd,
|
|
52
|
+
check=True,
|
|
53
|
+
stdout=subprocess.PIPE,
|
|
54
|
+
stderr=subprocess.PIPE,
|
|
55
|
+
)
|
|
56
|
+
subprocess.run(
|
|
57
|
+
["tar", "-x", "-C", str(destination)],
|
|
58
|
+
input=archive.stdout,
|
|
59
|
+
check=True,
|
|
60
|
+
stdout=subprocess.PIPE,
|
|
61
|
+
stderr=subprocess.PIPE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_diff(base_name: str, base: ScanReport, head: ScanReport) -> SecurityDiff:
|
|
66
|
+
base_tools = {tool.name: tool for tool in base.tools}
|
|
67
|
+
head_tools = {tool.name: tool for tool in head.tools}
|
|
68
|
+
new_tools = sorted(head_tools.keys() - base_tools.keys())
|
|
69
|
+
removed_tools = sorted(base_tools.keys() - head_tools.keys())
|
|
70
|
+
changed_tools = sorted(
|
|
71
|
+
name
|
|
72
|
+
for name in head_tools.keys() & base_tools.keys()
|
|
73
|
+
if _tool_signature(head_tools[name]) != _tool_signature(base_tools[name])
|
|
74
|
+
)
|
|
75
|
+
capability_changes = _capability_changes(base_tools, head_tools)
|
|
76
|
+
|
|
77
|
+
base_findings = {_finding_key(finding): finding for finding in base.findings}
|
|
78
|
+
head_findings = {_finding_key(finding): finding for finding in head.findings}
|
|
79
|
+
new_findings = [head_findings[key] for key in sorted(head_findings.keys() - base_findings.keys())]
|
|
80
|
+
resolved_findings = [base_findings[key] for key in sorted(base_findings.keys() - head_findings.keys())]
|
|
81
|
+
regressions = _security_regressions(base_tools, head_tools, new_tools, capability_changes)
|
|
82
|
+
actionable = _deduplicate([*new_findings, *regressions])
|
|
83
|
+
|
|
84
|
+
return SecurityDiff(
|
|
85
|
+
target=head.target,
|
|
86
|
+
base=base_name,
|
|
87
|
+
server_name=head.server_name,
|
|
88
|
+
risk_before=base.risk_score,
|
|
89
|
+
risk_after=head.risk_score,
|
|
90
|
+
tools=head.tools,
|
|
91
|
+
findings=actionable,
|
|
92
|
+
new_tools=new_tools,
|
|
93
|
+
removed_tools=removed_tools,
|
|
94
|
+
changed_tools=changed_tools,
|
|
95
|
+
capability_changes=capability_changes,
|
|
96
|
+
new_findings=new_findings,
|
|
97
|
+
resolved_findings=resolved_findings,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _capability_changes(base_tools: dict[str, Tool], head_tools: dict[str, Tool]) -> list[CapabilityChange]:
|
|
102
|
+
changes: list[CapabilityChange] = []
|
|
103
|
+
for name in sorted(base_tools.keys() & head_tools.keys()):
|
|
104
|
+
before = base_tools[name].capability.as_dict()
|
|
105
|
+
after = head_tools[name].capability.as_dict()
|
|
106
|
+
for capability in sorted(before.keys() | after.keys()):
|
|
107
|
+
if before.get(capability) != after.get(capability):
|
|
108
|
+
changes.append(CapabilityChange(name, capability, before.get(capability), after.get(capability)))
|
|
109
|
+
return changes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _security_regressions(
|
|
113
|
+
base_tools: dict[str, Tool],
|
|
114
|
+
head_tools: dict[str, Tool],
|
|
115
|
+
new_tools: list[str],
|
|
116
|
+
changes: list[CapabilityChange],
|
|
117
|
+
) -> list[Finding]:
|
|
118
|
+
findings: list[Finding] = []
|
|
119
|
+
for name in new_tools:
|
|
120
|
+
tool = head_tools[name]
|
|
121
|
+
if _is_high_impact(tool):
|
|
122
|
+
findings.append(
|
|
123
|
+
Finding(
|
|
124
|
+
rule_id="MCP016",
|
|
125
|
+
title="Capability escalation",
|
|
126
|
+
severity=Severity.HIGH,
|
|
127
|
+
tool=name,
|
|
128
|
+
message="A new tool introduces a high-impact security capability.",
|
|
129
|
+
impact="The MCP server's authority expands beyond the reviewed baseline.",
|
|
130
|
+
recommendation="Review the capability, require approval where appropriate, and update the approved manifest.",
|
|
131
|
+
location=_location(tool.source),
|
|
132
|
+
evidence=[Evidence(message=f"New capabilities: {tool.capability.as_dict()}", kind="capability_diff")],
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
changed_names = {change.tool for change in changes}
|
|
136
|
+
for name in sorted(changed_names):
|
|
137
|
+
previous = base_tools[name]
|
|
138
|
+
tool = head_tools[name]
|
|
139
|
+
if previous.capability.requires_approval and not tool.capability.requires_approval:
|
|
140
|
+
findings.append(
|
|
141
|
+
Finding(
|
|
142
|
+
rule_id="MCP017",
|
|
143
|
+
title="Approval removed",
|
|
144
|
+
severity=Severity.HIGH,
|
|
145
|
+
tool=name,
|
|
146
|
+
message="A previously approval-gated tool no longer requires approval.",
|
|
147
|
+
impact="High-impact actions may now execute without human confirmation.",
|
|
148
|
+
recommendation="Restore the approval boundary or document the reviewed policy exception.",
|
|
149
|
+
location=_location(tool.source),
|
|
150
|
+
evidence=[Evidence(message="requires_approval: true -> false", kind="capability_diff")],
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
expanded = [change for change in changes if change.tool == name and _is_expansion(change)]
|
|
154
|
+
if expanded:
|
|
155
|
+
findings.append(
|
|
156
|
+
Finding(
|
|
157
|
+
rule_id="MCP016",
|
|
158
|
+
title="Capability escalation",
|
|
159
|
+
severity=Severity.HIGH,
|
|
160
|
+
tool=name,
|
|
161
|
+
message="A tool capability expanded relative to the Git baseline.",
|
|
162
|
+
impact="Existing clients may gain access to broader resources or side effects.",
|
|
163
|
+
recommendation="Constrain the capability or approve and document the expanded baseline.",
|
|
164
|
+
location=_location(tool.source),
|
|
165
|
+
evidence=[
|
|
166
|
+
Evidence(message=f"{change.capability}: {change.before} -> {change.after}", kind="capability_diff")
|
|
167
|
+
for change in expanded
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return findings
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _tool_signature(tool: Tool) -> tuple[object, ...]:
|
|
175
|
+
return (
|
|
176
|
+
tuple(sorted(tool.capability.as_dict().items(), key=lambda item: item[0])),
|
|
177
|
+
tuple((parameter.name, parameter.annotation, parameter.bounded) for parameter in tool.parameters),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _finding_key(finding: Finding) -> tuple[str, str]:
|
|
182
|
+
return finding.rule_id, finding.tool or ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _deduplicate(findings: list[Finding]) -> list[Finding]:
|
|
186
|
+
result: dict[tuple[str, str], Finding] = {}
|
|
187
|
+
for finding in findings:
|
|
188
|
+
result.setdefault(_finding_key(finding), finding)
|
|
189
|
+
return list(result.values())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_high_impact(tool: Tool) -> bool:
|
|
193
|
+
capability = tool.capability
|
|
194
|
+
return (
|
|
195
|
+
capability.network == "unrestricted_outbound"
|
|
196
|
+
or capability.filesystem == "unrestricted"
|
|
197
|
+
or capability.execution != "none"
|
|
198
|
+
or capability.side_effect in {"external_write", "destructive_action"}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_expansion(change: CapabilityChange) -> bool:
|
|
203
|
+
if change.capability in {"filesystem", "network", "execution", "side_effect"}:
|
|
204
|
+
return _rank(str(change.after)) > _rank(str(change.before))
|
|
205
|
+
if change.capability in {"destructive", "financial"}:
|
|
206
|
+
return change.before is False and change.after is True
|
|
207
|
+
if change.capability == "data_access":
|
|
208
|
+
return bool(set(change.after or []) - set(change.before or []))
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _rank(value: str) -> int:
|
|
213
|
+
return {
|
|
214
|
+
"none": 0,
|
|
215
|
+
"fixed_file": 1,
|
|
216
|
+
"allowlisted_files": 2,
|
|
217
|
+
"allowlisted_hosts": 2,
|
|
218
|
+
"local_write": 2,
|
|
219
|
+
"directory": 3,
|
|
220
|
+
"external_write": 4,
|
|
221
|
+
"multiple_directories": 4,
|
|
222
|
+
"destructive_action": 5,
|
|
223
|
+
"unrestricted": 5,
|
|
224
|
+
"unrestricted_outbound": 5,
|
|
225
|
+
"shell": 5,
|
|
226
|
+
"arbitrary_code": 6,
|
|
227
|
+
}.get(value, 0)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _location(source: str) -> Location:
|
|
231
|
+
path, _, line = source.rpartition(":")
|
|
232
|
+
try:
|
|
233
|
+
return Location(path=path or source, line=int(line or "1"))
|
|
234
|
+
except ValueError:
|
|
235
|
+
return Location(path=source)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from mcp_audit.models.report import ScanReport
|
|
6
|
+
from mcp_audit.rules.engine import evaluate_tools
|
|
7
|
+
from mcp_audit.scanners.source.python import scan_python_sources
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scan_path(path: str | Path, *, require_tools: bool = False) -> ScanReport:
|
|
11
|
+
target = Path(path).resolve()
|
|
12
|
+
tools = scan_python_sources(target)
|
|
13
|
+
if require_tools and not tools:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f"no MCP tools discovered in {target}; check the target path, filesystem access, and supported decorators"
|
|
16
|
+
)
|
|
17
|
+
findings = evaluate_tools(tools)
|
|
18
|
+
return ScanReport(
|
|
19
|
+
target=str(target),
|
|
20
|
+
server_name=_server_name(target),
|
|
21
|
+
tools=tools,
|
|
22
|
+
findings=findings,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _server_name(target: Path) -> str:
|
|
27
|
+
if target.is_file():
|
|
28
|
+
return target.stem
|
|
29
|
+
return target.name
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package."""
|
mcp_audit/cli/main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mcp_audit import __version__
|
|
8
|
+
from mcp_audit.analysis.diff import diff_against_base
|
|
9
|
+
from mcp_audit.analysis.scan import scan_path
|
|
10
|
+
from mcp_audit.models.finding import Severity
|
|
11
|
+
from mcp_audit.policy.evaluator import apply_policy
|
|
12
|
+
from mcp_audit.policy.loader import load_policy
|
|
13
|
+
from mcp_audit.reporters.json import render_json
|
|
14
|
+
from mcp_audit.reporters.manifest import render_manifest
|
|
15
|
+
from mcp_audit.reporters.sarif import render_sarif
|
|
16
|
+
from mcp_audit.reporters.terminal import render_diff_terminal, render_terminal
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = _parser()
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
try:
|
|
23
|
+
if args.command == "scan":
|
|
24
|
+
report = scan_path(args.target, require_tools=not args.allow_empty)
|
|
25
|
+
policy = load_policy(args.policy)
|
|
26
|
+
fail_on = Severity.parse(args.fail_on) if args.fail_on else None
|
|
27
|
+
report = apply_policy(report, policy, fail_on)
|
|
28
|
+
_write(_render(report, args.format), args.output)
|
|
29
|
+
return 1 if report.result == "fail" else 0
|
|
30
|
+
if args.command == "manifest":
|
|
31
|
+
report = scan_path(args.target, require_tools=True)
|
|
32
|
+
_write(render_manifest(report), args.output)
|
|
33
|
+
return 0
|
|
34
|
+
if args.command == "diff":
|
|
35
|
+
report = diff_against_base(args.base, args.target)
|
|
36
|
+
policy = load_policy(args.policy)
|
|
37
|
+
fail_on = Severity.parse(args.fail_on) if args.fail_on else None
|
|
38
|
+
report = apply_policy(report, policy, fail_on)
|
|
39
|
+
_write(_render(report, args.format), args.output)
|
|
40
|
+
return 1 if report.result == "fail" else 0
|
|
41
|
+
if args.command == "policy" and args.policy_command == "check":
|
|
42
|
+
policy = load_policy(args.policy)
|
|
43
|
+
expired = policy.expired_suppressions()
|
|
44
|
+
if expired:
|
|
45
|
+
for suppression in expired:
|
|
46
|
+
print(
|
|
47
|
+
f"Expired suppression: {suppression.rule_id} "
|
|
48
|
+
f"({suppression.tool or 'all tools'}) expired {suppression.expires}",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
return 1
|
|
52
|
+
print(f"Policy OK: {args.policy}")
|
|
53
|
+
return 0
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
print(f"mcp-audit: {exc}", file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
parser.print_help()
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parser() -> argparse.ArgumentParser:
|
|
62
|
+
parser = argparse.ArgumentParser(prog="mcp-audit", description="Security regression scanner for MCP servers.")
|
|
63
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
64
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
65
|
+
|
|
66
|
+
scan = subcommands.add_parser("scan", help="scan local source")
|
|
67
|
+
scan.add_argument("target", nargs="?", default=".")
|
|
68
|
+
scan.add_argument(
|
|
69
|
+
"--allow-empty",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="allow a successful scan when no MCP tools are discovered",
|
|
72
|
+
)
|
|
73
|
+
_report_options(scan)
|
|
74
|
+
|
|
75
|
+
manifest = subcommands.add_parser("manifest", help="generate a capability manifest")
|
|
76
|
+
manifest.add_argument("target", nargs="?", default=".")
|
|
77
|
+
manifest.add_argument("--output")
|
|
78
|
+
|
|
79
|
+
diff = subcommands.add_parser("diff", help="compare the current tree with a git baseline")
|
|
80
|
+
diff.add_argument("target", nargs="?", default=".")
|
|
81
|
+
diff.add_argument("--base", required=True)
|
|
82
|
+
_report_options(diff)
|
|
83
|
+
|
|
84
|
+
policy = subcommands.add_parser("policy", help="policy utilities")
|
|
85
|
+
policy_subcommands = policy.add_subparsers(dest="policy_command")
|
|
86
|
+
check = policy_subcommands.add_parser("check", help="validate that a policy file can be loaded")
|
|
87
|
+
check.add_argument("policy")
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _report_options(parser: argparse.ArgumentParser) -> None:
|
|
92
|
+
parser.add_argument("--format", choices=["terminal", "json", "sarif"], default="terminal")
|
|
93
|
+
parser.add_argument("--output")
|
|
94
|
+
parser.add_argument("--policy")
|
|
95
|
+
parser.add_argument("--fail-on", choices=["low", "medium", "high", "critical"])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render(report, fmt: str) -> str:
|
|
99
|
+
if fmt == "json":
|
|
100
|
+
return render_json(report)
|
|
101
|
+
if fmt == "sarif":
|
|
102
|
+
return render_sarif(report)
|
|
103
|
+
if hasattr(report, "base"):
|
|
104
|
+
return render_diff_terminal(report)
|
|
105
|
+
return render_terminal(report)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write(text: str, output: str | None) -> None:
|
|
109
|
+
if output:
|
|
110
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
111
|
+
else:
|
|
112
|
+
print(text)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from mcp_audit.models.capability import Capability, Tool
|
|
2
|
+
from mcp_audit.models.finding import Finding, Location, Severity
|
|
3
|
+
from mcp_audit.models.report import ScanReport
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Capability",
|
|
7
|
+
"Finding",
|
|
8
|
+
"Location",
|
|
9
|
+
"ScanReport",
|
|
10
|
+
"Severity",
|
|
11
|
+
"Tool",
|
|
12
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from mcp_audit.models.finding import Evidence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class Capability:
|
|
10
|
+
data_access: set[str] = field(default_factory=set)
|
|
11
|
+
side_effect: str = "none"
|
|
12
|
+
filesystem: str = "none"
|
|
13
|
+
network: str = "none"
|
|
14
|
+
network_destination: str | None = None
|
|
15
|
+
execution: str = "none"
|
|
16
|
+
sensitivity: str = "public"
|
|
17
|
+
destructive: bool = False
|
|
18
|
+
financial: bool = False
|
|
19
|
+
requires_approval: bool = False
|
|
20
|
+
audit_metadata: bool = False
|
|
21
|
+
|
|
22
|
+
def as_dict(self) -> dict[str, object]:
|
|
23
|
+
return {
|
|
24
|
+
"data_access": sorted(self.data_access),
|
|
25
|
+
"side_effect": self.side_effect,
|
|
26
|
+
"filesystem": self.filesystem,
|
|
27
|
+
"network": self.network,
|
|
28
|
+
"network_destination": self.network_destination,
|
|
29
|
+
"execution": self.execution,
|
|
30
|
+
"sensitivity": self.sensitivity,
|
|
31
|
+
"destructive": self.destructive,
|
|
32
|
+
"financial": self.financial,
|
|
33
|
+
"requires_approval": self.requires_approval,
|
|
34
|
+
"audit_metadata": self.audit_metadata,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class Parameter:
|
|
40
|
+
name: str
|
|
41
|
+
annotation: str | None = None
|
|
42
|
+
default: str | None = None
|
|
43
|
+
bounded: bool = False
|
|
44
|
+
|
|
45
|
+
def as_dict(self) -> dict[str, object]:
|
|
46
|
+
return {
|
|
47
|
+
"name": self.name,
|
|
48
|
+
"annotation": self.annotation,
|
|
49
|
+
"default": self.default,
|
|
50
|
+
"bounded": self.bounded,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class Tool:
|
|
56
|
+
name: str
|
|
57
|
+
source: str
|
|
58
|
+
context: str = ""
|
|
59
|
+
description: str = ""
|
|
60
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
61
|
+
capability: Capability = field(default_factory=Capability)
|
|
62
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
def as_dict(self) -> dict[str, object]:
|
|
65
|
+
return {
|
|
66
|
+
"name": self.name,
|
|
67
|
+
"source": self.source,
|
|
68
|
+
"context": self.context,
|
|
69
|
+
"description": self.description,
|
|
70
|
+
"parameters": [parameter.as_dict() for parameter in self.parameters],
|
|
71
|
+
"capabilities": self.capability.as_dict(),
|
|
72
|
+
"evidence": [item.as_dict() for item in self.evidence],
|
|
73
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Severity(IntEnum):
|
|
8
|
+
LOW = 1
|
|
9
|
+
MEDIUM = 2
|
|
10
|
+
HIGH = 3
|
|
11
|
+
CRITICAL = 4
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def parse(cls, value: str) -> "Severity":
|
|
15
|
+
normalized = value.strip().upper()
|
|
16
|
+
try:
|
|
17
|
+
return cls[normalized]
|
|
18
|
+
except KeyError as exc:
|
|
19
|
+
valid = ", ".join(severity.name.lower() for severity in cls)
|
|
20
|
+
raise ValueError(f"unknown severity '{value}', expected one of: {valid}") from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Location:
|
|
25
|
+
path: str
|
|
26
|
+
line: int = 1
|
|
27
|
+
column: int | None = None
|
|
28
|
+
end_line: int | None = None
|
|
29
|
+
end_column: int | None = None
|
|
30
|
+
|
|
31
|
+
def as_dict(self) -> dict[str, object]:
|
|
32
|
+
result: dict[str, object] = {"path": self.path, "line": self.line}
|
|
33
|
+
if self.column is not None:
|
|
34
|
+
result["column"] = self.column
|
|
35
|
+
if self.end_line is not None:
|
|
36
|
+
result["end_line"] = self.end_line
|
|
37
|
+
if self.end_column is not None:
|
|
38
|
+
result["end_column"] = self.end_column
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class Evidence:
|
|
44
|
+
message: str
|
|
45
|
+
location: Location | None = None
|
|
46
|
+
snippet: str | None = None
|
|
47
|
+
kind: str = "code"
|
|
48
|
+
|
|
49
|
+
def as_dict(self) -> dict[str, object]:
|
|
50
|
+
return {
|
|
51
|
+
"kind": self.kind,
|
|
52
|
+
"message": self.message,
|
|
53
|
+
"location": self.location.as_dict() if self.location else None,
|
|
54
|
+
"snippet": self.snippet,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class Finding:
|
|
60
|
+
rule_id: str
|
|
61
|
+
title: str
|
|
62
|
+
severity: Severity
|
|
63
|
+
tool: str | None
|
|
64
|
+
message: str
|
|
65
|
+
recommendation: str
|
|
66
|
+
location: Location
|
|
67
|
+
impact: str = ""
|
|
68
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
69
|
+
path: list[str] = field(default_factory=list)
|
|
70
|
+
data_classification: list[str] = field(default_factory=list)
|
|
71
|
+
destination: str | None = None
|
|
72
|
+
|
|
73
|
+
def as_dict(self) -> dict[str, object]:
|
|
74
|
+
return {
|
|
75
|
+
"rule_id": self.rule_id,
|
|
76
|
+
"title": self.title,
|
|
77
|
+
"severity": self.severity.name.lower(),
|
|
78
|
+
"tool": self.tool,
|
|
79
|
+
"message": self.message,
|
|
80
|
+
"impact": self.impact,
|
|
81
|
+
"recommendation": self.recommendation,
|
|
82
|
+
"location": self.location.as_dict(),
|
|
83
|
+
"evidence": [item.as_dict() for item in self.evidence],
|
|
84
|
+
"path": self.path,
|
|
85
|
+
"data_classification": self.data_classification,
|
|
86
|
+
"destination": self.destination,
|
|
87
|
+
}
|