codedna-analyzer 1.4.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.
- codedna/__init__.py +3 -0
- codedna/analyzers/__init__.py +1 -0
- codedna/analyzers/ai_analyzer.py +111 -0
- codedna/analyzers/architecture_detector.py +150 -0
- codedna/analyzers/cache_manager.py +70 -0
- codedna/analyzers/code_smell_detector.py +302 -0
- codedna/analyzers/complexity_analyzer.py +329 -0
- codedna/analyzers/dependency_mapper.py +171 -0
- codedna/analyzers/developer_analyzer.py +148 -0
- codedna/analyzers/diff_engine.py +395 -0
- codedna/analyzers/dna_generator.py +308 -0
- codedna/analyzers/evolution_engine.py +193 -0
- codedna/analyzers/github_analyzer.py +86 -0
- codedna/analyzers/language_detector.py +151 -0
- codedna/analyzers/repo_cloner.py +91 -0
- codedna/analyzers/security_detector.py +271 -0
- codedna/analyzers/structure_analyzer.py +91 -0
- codedna/ci/__init__.py +5 -0
- codedna/ci/gatekeeper.py +421 -0
- codedna/cli.py +839 -0
- codedna/compliance/__init__.py +5 -0
- codedna/compliance/sbom.py +410 -0
- codedna/config.py +167 -0
- codedna/mcp/__init__.py +5 -0
- codedna/mcp/protocol.py +65 -0
- codedna/mcp/server.py +393 -0
- codedna/remediation/__init__.py +5 -0
- codedna/remediation/planner.py +244 -0
- codedna/visualization/__init__.py +1 -0
- codedna/visualization/html_export.py +507 -0
- codedna/visualization/renderer.py +373 -0
- codedna_analyzer-1.4.0.dist-info/METADATA +425 -0
- codedna_analyzer-1.4.0.dist-info/RECORD +37 -0
- codedna_analyzer-1.4.0.dist-info/WHEEL +5 -0
- codedna_analyzer-1.4.0.dist-info/entry_points.txt +2 -0
- codedna_analyzer-1.4.0.dist-info/licenses/LICENSE +21 -0
- codedna_analyzer-1.4.0.dist-info/top_level.txt +1 -0
codedna/mcp/server.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""CodeDNA MCP Server — JSON-RPC 2.0 stdio server for AI client inter-connectivity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from codedna import __version__
|
|
12
|
+
from codedna.analyzers.architecture_detector import ArchitectureDetector
|
|
13
|
+
from codedna.analyzers.complexity_analyzer import ComplexityAnalyzer
|
|
14
|
+
from codedna.analyzers.dependency_mapper import DependencyMapper
|
|
15
|
+
from codedna.analyzers.diff_engine import DiffEngine
|
|
16
|
+
from codedna.analyzers.language_detector import LanguageDetector
|
|
17
|
+
from codedna.analyzers.security_detector import SecurityDetector
|
|
18
|
+
from codedna.analyzers.structure_analyzer import StructureAnalyzer
|
|
19
|
+
from codedna.ci.gatekeeper import Gatekeeper
|
|
20
|
+
from codedna.config import load_config
|
|
21
|
+
|
|
22
|
+
from .protocol import (
|
|
23
|
+
METHOD_NOT_FOUND,
|
|
24
|
+
JSONRPCRequest,
|
|
25
|
+
JSONRPCResponse,
|
|
26
|
+
MCPToolDefinition,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("codedna.mcp")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CodeDNAMCPServer:
|
|
33
|
+
"""Stdio and programmatic Model Context Protocol server for CodeDNA."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.server_name = "codedna-mcp"
|
|
37
|
+
self.server_version = __version__
|
|
38
|
+
self._register_tools()
|
|
39
|
+
|
|
40
|
+
def _register_tools(self) -> None:
|
|
41
|
+
self.tools: dict[str, MCPToolDefinition] = {
|
|
42
|
+
"codedna_analyze": MCPToolDefinition(
|
|
43
|
+
name="codedna_analyze",
|
|
44
|
+
description="Run complete reverse-engineering DNA analysis on a repository path or directory.",
|
|
45
|
+
inputSchema={
|
|
46
|
+
"type": "object",
|
|
47
|
+
"properties": {
|
|
48
|
+
"path": {"type": "string", "description": "Local path to codebase root", "default": "."},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
),
|
|
52
|
+
"codedna_inspect_architecture": MCPToolDefinition(
|
|
53
|
+
name="codedna_inspect_architecture",
|
|
54
|
+
description="Detect architecture patterns (MVC, Layered, Clean/Hexagonal, Microservices, Event-Driven) and infrastructure traits.",
|
|
55
|
+
inputSchema={
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"path": {"type": "string", "description": "Local directory path", "default": "."},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
"codedna_security_audit": MCPToolDefinition(
|
|
63
|
+
name="codedna_security_audit",
|
|
64
|
+
description="Scan codebase for hardcoded credentials, AI API keys, Shannon high-entropy secrets, and injection vulnerabilities.",
|
|
65
|
+
inputSchema={
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"path": {"type": "string", "description": "Local directory path", "default": "."},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
"codedna_complexity_metrics": MCPToolDefinition(
|
|
73
|
+
name="codedna_complexity_metrics",
|
|
74
|
+
description="Analyze McCabe Cyclomatic Complexity, Cognitive Complexity, and Maintainability Index (MI) across source functions.",
|
|
75
|
+
inputSchema={
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"path": {"type": "string", "description": "Local directory path", "default": "."},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
),
|
|
82
|
+
"codedna_dependency_graph": MCPToolDefinition(
|
|
83
|
+
name="codedna_dependency_graph",
|
|
84
|
+
description="Build internal dependency graph, detect circular cycles, coupling density, and PageRank centrality.",
|
|
85
|
+
inputSchema={
|
|
86
|
+
"type": "object",
|
|
87
|
+
"properties": {
|
|
88
|
+
"path": {"type": "string", "description": "Local directory path", "default": "."},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
"codedna_quality_gate": MCPToolDefinition(
|
|
93
|
+
name="codedna_quality_gate",
|
|
94
|
+
description="Evaluate repository against CI/CD architectural quality gates and security policies.",
|
|
95
|
+
inputSchema={
|
|
96
|
+
"type": "object",
|
|
97
|
+
"properties": {
|
|
98
|
+
"path": {"type": "string", "description": "Local directory path", "default": "."},
|
|
99
|
+
"min_health": {"type": "string", "enum": ["Healthy", "Fair", "Needs Attention", "Critical"], "default": "Fair"},
|
|
100
|
+
"fail_on_secrets": {"type": "boolean", "default": True},
|
|
101
|
+
"fail_on_circular": {"type": "boolean", "default": False},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
),
|
|
105
|
+
"codedna_drift_diff": MCPToolDefinition(
|
|
106
|
+
name="codedna_drift_diff",
|
|
107
|
+
description="Compare two codebases or branches to calculate genetic drift, similarity percentage, and architectural deltas.",
|
|
108
|
+
inputSchema={
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"baseline": {"type": "string", "description": "Baseline directory or DNA JSON profile"},
|
|
112
|
+
"target": {"type": "string", "description": "Target directory or DNA JSON profile"},
|
|
113
|
+
},
|
|
114
|
+
"required": ["baseline", "target"],
|
|
115
|
+
},
|
|
116
|
+
),
|
|
117
|
+
"codedna_generate_sbom": MCPToolDefinition(
|
|
118
|
+
name="codedna_generate_sbom",
|
|
119
|
+
description="Generate CycloneDX 1.5 or SPDX 2.3 Software Bill of Materials (SBOM) across Python, Node.js, Rust, and Go dependencies.",
|
|
120
|
+
inputSchema={
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"path": {"type": "string", "description": "Local codebase path", "default": "."},
|
|
124
|
+
"format": {"type": "string", "enum": ["cyclonedx", "spdx"], "default": "cyclonedx"},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
),
|
|
128
|
+
"codedna_plan_remediation": MCPToolDefinition(
|
|
129
|
+
name="codedna_plan_remediation",
|
|
130
|
+
description="Plan and optionally apply automated, deterministic code remediation diffs for security flaws, bare exceptions, and wildcard pins.",
|
|
131
|
+
inputSchema={
|
|
132
|
+
"type": "object",
|
|
133
|
+
"properties": {
|
|
134
|
+
"path": {"type": "string", "description": "Local codebase path", "default": "."},
|
|
135
|
+
"apply_fixes": {"type": "boolean", "description": "Apply autofixes directly to disk", "default": False},
|
|
136
|
+
"rules": {"type": "array", "items": {"type": "string"}, "description": "Filter by rule IDs"},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def handle_message(self, message_str: str) -> str | None:
|
|
143
|
+
"""Handle raw JSON-RPC string message and return JSON string response."""
|
|
144
|
+
try:
|
|
145
|
+
data = json.loads(message_str)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
err_resp = JSONRPCResponse(id=None, error={"code": -32700, "message": f"Parse error: {e}"})
|
|
148
|
+
return json.dumps(err_resp.to_dict())
|
|
149
|
+
|
|
150
|
+
if not isinstance(data, (dict, list)):
|
|
151
|
+
err_resp = JSONRPCResponse(id=None, error={"code": -32600, "message": "Invalid Request: expected JSON object or array"})
|
|
152
|
+
return json.dumps(err_resp.to_dict())
|
|
153
|
+
|
|
154
|
+
if isinstance(data, list):
|
|
155
|
+
responses = []
|
|
156
|
+
for item in data:
|
|
157
|
+
if not isinstance(item, dict):
|
|
158
|
+
responses.append(JSONRPCResponse(id=None, error={"code": -32600, "message": "Invalid Request: expected object"}).to_dict())
|
|
159
|
+
continue
|
|
160
|
+
req = JSONRPCRequest.from_dict(item)
|
|
161
|
+
res = self.handle_request(req)
|
|
162
|
+
if res:
|
|
163
|
+
responses.append(res.to_dict())
|
|
164
|
+
return json.dumps(responses) if responses else None
|
|
165
|
+
|
|
166
|
+
req = JSONRPCRequest.from_dict(data)
|
|
167
|
+
res = self.handle_request(req)
|
|
168
|
+
return json.dumps(res.to_dict()) if res else None
|
|
169
|
+
|
|
170
|
+
def handle_request(self, req: JSONRPCRequest) -> JSONRPCResponse | None:
|
|
171
|
+
"""Dispatch JSON-RPC request to appropriate MCP handler."""
|
|
172
|
+
method = req.method
|
|
173
|
+
|
|
174
|
+
if method == "initialize":
|
|
175
|
+
return JSONRPCResponse(
|
|
176
|
+
id=req.id,
|
|
177
|
+
result={
|
|
178
|
+
"protocolVersion": "2024-11-05",
|
|
179
|
+
"serverInfo": {
|
|
180
|
+
"name": self.server_name,
|
|
181
|
+
"version": self.server_version,
|
|
182
|
+
},
|
|
183
|
+
"capabilities": {
|
|
184
|
+
"tools": {"listChanged": False},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
elif method == "notifications/initialized":
|
|
190
|
+
# Handshake notification, no response required
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
elif method == "ping":
|
|
194
|
+
return JSONRPCResponse(id=req.id, result={})
|
|
195
|
+
|
|
196
|
+
elif method == "tools/list":
|
|
197
|
+
return JSONRPCResponse(
|
|
198
|
+
id=req.id,
|
|
199
|
+
result={
|
|
200
|
+
"tools": [t.to_dict() for t in self.tools.values()],
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
elif method == "tools/call":
|
|
205
|
+
params = req.params
|
|
206
|
+
tool_name = params.get("name", "")
|
|
207
|
+
args = params.get("arguments", {})
|
|
208
|
+
|
|
209
|
+
if tool_name not in self.tools:
|
|
210
|
+
return JSONRPCResponse(
|
|
211
|
+
id=req.id,
|
|
212
|
+
error={"code": METHOD_NOT_FOUND, "message": f"Tool '{tool_name}' not found"},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
content_text = self._execute_tool(tool_name, args)
|
|
217
|
+
return JSONRPCResponse(
|
|
218
|
+
id=req.id,
|
|
219
|
+
result={
|
|
220
|
+
"content": [{"type": "text", "text": content_text}],
|
|
221
|
+
"isError": False,
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return JSONRPCResponse(
|
|
226
|
+
id=req.id,
|
|
227
|
+
result={
|
|
228
|
+
"content": [{"type": "text", "text": f"Error executing '{tool_name}': {e}"}],
|
|
229
|
+
"isError": True,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
else:
|
|
234
|
+
return JSONRPCResponse(
|
|
235
|
+
id=req.id,
|
|
236
|
+
error={"code": METHOD_NOT_FOUND, "message": f"Method '{method}' not supported"},
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _execute_tool(self, name: str, args: dict[str, Any]) -> str:
|
|
240
|
+
"""Execute selected tool and return serialized response text."""
|
|
241
|
+
target_path = Path(args.get("path", ".")).resolve()
|
|
242
|
+
|
|
243
|
+
if name == "codedna_analyze":
|
|
244
|
+
config = load_config(target_path)
|
|
245
|
+
langs = LanguageDetector().detect(target_path)
|
|
246
|
+
struct = StructureAnalyzer().analyze(target_path)
|
|
247
|
+
arch = ArchitectureDetector().detect(target_path)
|
|
248
|
+
sec = SecurityDetector(config=config).detect(target_path)
|
|
249
|
+
cmp = ComplexityAnalyzer().analyze(target_path, config=config)
|
|
250
|
+
deps = DependencyMapper().map(target_path)
|
|
251
|
+
|
|
252
|
+
summary = {
|
|
253
|
+
"source": str(target_path),
|
|
254
|
+
"primary_language": langs.get("primary", "Unknown"),
|
|
255
|
+
"system_type": arch.get("primary_pattern", "Unknown"),
|
|
256
|
+
"total_files": struct.get("total_files", 0),
|
|
257
|
+
"total_lines": langs.get("total_lines", 0),
|
|
258
|
+
"maintainability_index": cmp.get("maintainability_index", 100.0),
|
|
259
|
+
"maintainability_rank": cmp.get("maintainability_rank", "A"),
|
|
260
|
+
"critical_security_vulnerabilities": sec.get("total_critical", 0),
|
|
261
|
+
"has_secrets": sec.get("has_secrets", False),
|
|
262
|
+
"circular_dependencies": deps.get("has_circular_deps", False),
|
|
263
|
+
"dependency_density": deps.get("density", 0.0),
|
|
264
|
+
}
|
|
265
|
+
return json.dumps(summary, indent=2)
|
|
266
|
+
|
|
267
|
+
elif name == "codedna_inspect_architecture":
|
|
268
|
+
arch = ArchitectureDetector().detect(target_path)
|
|
269
|
+
return json.dumps(arch, indent=2)
|
|
270
|
+
|
|
271
|
+
elif name == "codedna_security_audit":
|
|
272
|
+
config = load_config(target_path)
|
|
273
|
+
sec = SecurityDetector(config=config).detect(target_path)
|
|
274
|
+
return json.dumps(sec, indent=2)
|
|
275
|
+
|
|
276
|
+
elif name == "codedna_complexity_metrics":
|
|
277
|
+
config = load_config(target_path)
|
|
278
|
+
cmp = ComplexityAnalyzer().analyze(target_path, config=config)
|
|
279
|
+
return json.dumps(cmp, indent=2)
|
|
280
|
+
|
|
281
|
+
elif name == "codedna_dependency_graph":
|
|
282
|
+
deps = DependencyMapper().map(target_path)
|
|
283
|
+
return json.dumps(deps, indent=2)
|
|
284
|
+
|
|
285
|
+
elif name == "codedna_quality_gate":
|
|
286
|
+
config = load_config(target_path)
|
|
287
|
+
langs = LanguageDetector().detect(target_path)
|
|
288
|
+
struct = StructureAnalyzer().analyze(target_path)
|
|
289
|
+
arch = ArchitectureDetector().detect(target_path)
|
|
290
|
+
deps = DependencyMapper().map(target_path)
|
|
291
|
+
sec = SecurityDetector(config=config).detect(target_path)
|
|
292
|
+
cmp = ComplexityAnalyzer().analyze(target_path, config=config)
|
|
293
|
+
|
|
294
|
+
mock_profile = {
|
|
295
|
+
"health": {
|
|
296
|
+
"overall": "Healthy" if sec.get("total_critical", 0) == 0 else "Critical",
|
|
297
|
+
"severity_counts": {"critical": sec.get("total_critical", 0), "warning": 0, "info": 0},
|
|
298
|
+
},
|
|
299
|
+
"dependencies": deps,
|
|
300
|
+
"security": sec,
|
|
301
|
+
"complexity": cmp,
|
|
302
|
+
"structure_stats": struct,
|
|
303
|
+
"architecture": arch,
|
|
304
|
+
"languages": langs,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
gatekeeper = Gatekeeper(
|
|
308
|
+
min_health=args.get("min_health", config.min_health),
|
|
309
|
+
fail_on_secrets=args.get("fail_on_secrets", config.fail_on_secrets),
|
|
310
|
+
fail_on_circular=args.get("fail_on_circular", config.fail_on_circular),
|
|
311
|
+
max_cyclomatic_complexity=config.max_cyclomatic_complexity,
|
|
312
|
+
min_maintainability_index=config.min_maintainability_index,
|
|
313
|
+
max_density=config.max_density,
|
|
314
|
+
max_critical_smells=config.max_critical_smells,
|
|
315
|
+
max_smells=config.max_smells,
|
|
316
|
+
)
|
|
317
|
+
result = gatekeeper.evaluate(mock_profile)
|
|
318
|
+
return json.dumps(result.to_dict(), indent=2)
|
|
319
|
+
|
|
320
|
+
elif name == "codedna_drift_diff":
|
|
321
|
+
baseline_path = Path(args["baseline"]).resolve()
|
|
322
|
+
target_path_b = Path(args["target"]).resolve()
|
|
323
|
+
|
|
324
|
+
# Helper to load or analyze
|
|
325
|
+
def _get_profile(p: Path) -> dict:
|
|
326
|
+
if p.is_file() and p.suffix == ".json":
|
|
327
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
328
|
+
cfg = load_config(p)
|
|
329
|
+
return {
|
|
330
|
+
"metadata": {"source": str(p)},
|
|
331
|
+
"languages": LanguageDetector().detect(p),
|
|
332
|
+
"structure_stats": StructureAnalyzer().analyze(p),
|
|
333
|
+
"architecture": ArchitectureDetector().detect(p),
|
|
334
|
+
"dependencies": DependencyMapper().map(p),
|
|
335
|
+
"security": SecurityDetector(config=cfg).detect(p),
|
|
336
|
+
"health": {"overall": "Healthy", "severity_counts": {"critical": 0, "warning": 0, "info": 0}},
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
prof_a = _get_profile(baseline_path)
|
|
340
|
+
prof_b = _get_profile(target_path_b)
|
|
341
|
+
diff = DiffEngine().compare(prof_a, prof_b)
|
|
342
|
+
return json.dumps(diff, indent=2)
|
|
343
|
+
|
|
344
|
+
elif name == "codedna_generate_sbom":
|
|
345
|
+
from codedna.compliance.sbom import SBOMGenerator
|
|
346
|
+
|
|
347
|
+
fmt = args.get("format", "cyclonedx").lower()
|
|
348
|
+
generator = SBOMGenerator(target_path)
|
|
349
|
+
if fmt == "spdx":
|
|
350
|
+
doc = generator.generate_spdx_json()
|
|
351
|
+
else:
|
|
352
|
+
doc = generator.generate_cyclonedx_json()
|
|
353
|
+
return json.dumps(doc, indent=2)
|
|
354
|
+
|
|
355
|
+
elif name == "codedna_plan_remediation":
|
|
356
|
+
from codedna.remediation.planner import RemediationPlanner
|
|
357
|
+
|
|
358
|
+
planner = RemediationPlanner(target_path)
|
|
359
|
+
rules = args.get("rules")
|
|
360
|
+
actions = planner.plan(rule_filter=rules)
|
|
361
|
+
apply_fixes = args.get("apply_fixes", False)
|
|
362
|
+
if apply_fixes:
|
|
363
|
+
apply_res = planner.apply(actions, dry_run=False)
|
|
364
|
+
return json.dumps({
|
|
365
|
+
"plan": [a.to_dict() for a in actions],
|
|
366
|
+
"application": apply_res,
|
|
367
|
+
}, indent=2)
|
|
368
|
+
return json.dumps({
|
|
369
|
+
"total_remediations": len(actions),
|
|
370
|
+
"autofixable_count": sum(1 for a in actions if a.autofixable),
|
|
371
|
+
"actions": [a.to_dict() for a in actions],
|
|
372
|
+
}, indent=2)
|
|
373
|
+
|
|
374
|
+
return "{}"
|
|
375
|
+
|
|
376
|
+
def run_stdio(self) -> None:
|
|
377
|
+
"""Run standard I/O loop consuming line-delimited JSON-RPC from stdin."""
|
|
378
|
+
sys.stderr.write(f"🧬 CodeDNA MCP Server v{self.server_version} running on stdio...\n")
|
|
379
|
+
sys.stderr.flush()
|
|
380
|
+
|
|
381
|
+
for line in sys.stdin:
|
|
382
|
+
line = line.strip()
|
|
383
|
+
if not line:
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
response_str = self.handle_message(line)
|
|
387
|
+
if response_str:
|
|
388
|
+
sys.stdout.write(response_str + "\n")
|
|
389
|
+
sys.stdout.flush()
|
|
390
|
+
except Exception as e:
|
|
391
|
+
err_resp = JSONRPCResponse(id=None, error={"code": -32603, "message": f"Internal error: {e}"})
|
|
392
|
+
sys.stdout.write(json.dumps(err_resp.to_dict()) + "\n")
|
|
393
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Automated Intelligent Remediation Planner for CodeDNA.
|
|
2
|
+
|
|
3
|
+
Translates security vulnerabilities, code smells, and quality gate failures
|
|
4
|
+
into structured, deterministic, reviewable remediation diffs and patches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
IGNORE_DIRS = {
|
|
20
|
+
".git", ".svn", ".hg", "node_modules", "venv", ".venv", "env",
|
|
21
|
+
"__pycache__", ".pytest_cache", "build", "dist", "target"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RemediationAction:
|
|
27
|
+
rule_id: str
|
|
28
|
+
title: str
|
|
29
|
+
file_path: str
|
|
30
|
+
line_number: int
|
|
31
|
+
severity: str
|
|
32
|
+
description: str
|
|
33
|
+
original_text: str
|
|
34
|
+
proposed_text: str
|
|
35
|
+
unified_diff: str
|
|
36
|
+
autofixable: bool = True
|
|
37
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"rule_id": self.rule_id,
|
|
42
|
+
"title": self.title,
|
|
43
|
+
"file": self.file_path,
|
|
44
|
+
"line": self.line_number,
|
|
45
|
+
"severity": self.severity,
|
|
46
|
+
"description": self.description,
|
|
47
|
+
"original_text": self.original_text,
|
|
48
|
+
"proposed_text": self.proposed_text,
|
|
49
|
+
"diff": self.unified_diff,
|
|
50
|
+
"autofixable": self.autofixable,
|
|
51
|
+
"metadata": self.metadata,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RemediationPlanner:
|
|
56
|
+
"""Analyzes codebase findings and plans safe, deterministic code patches."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, repo_path: str | Path = ".") -> None:
|
|
59
|
+
self.repo_path = Path(repo_path).resolve()
|
|
60
|
+
|
|
61
|
+
def plan(self, rule_filter: Optional[List[str]] = None) -> List[RemediationAction]:
|
|
62
|
+
"""Scans the repository and produces prioritized remediation proposals."""
|
|
63
|
+
actions: List[RemediationAction] = []
|
|
64
|
+
|
|
65
|
+
for root, dirs, files in os.walk(self.repo_path):
|
|
66
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
|
|
67
|
+
|
|
68
|
+
for fname in files:
|
|
69
|
+
fpath = Path(root) / fname
|
|
70
|
+
rel_path = str(fpath.relative_to(self.repo_path))
|
|
71
|
+
|
|
72
|
+
# Python remediations
|
|
73
|
+
if fname.endswith(".py"):
|
|
74
|
+
actions.extend(self._scan_python_file(fpath, rel_path))
|
|
75
|
+
|
|
76
|
+
# Node.js package.json wildcard remediations
|
|
77
|
+
elif fname == "package.json":
|
|
78
|
+
actions.extend(self._scan_package_json(fpath, rel_path))
|
|
79
|
+
|
|
80
|
+
if rule_filter:
|
|
81
|
+
allowed = set(r.upper() for r in rule_filter)
|
|
82
|
+
actions = [a for a in actions if a.rule_id.upper() in allowed]
|
|
83
|
+
|
|
84
|
+
actions.sort(key=lambda a: (0 if a.severity == "critical" else 1 if a.severity == "warning" else 2, a.file_path, a.line_number))
|
|
85
|
+
return actions
|
|
86
|
+
|
|
87
|
+
def _scan_python_file(self, path: Path, rel_path: str) -> List[RemediationAction]:
|
|
88
|
+
actions: List[RemediationAction] = []
|
|
89
|
+
try:
|
|
90
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
91
|
+
lines = content.splitlines(keepends=True)
|
|
92
|
+
|
|
93
|
+
# 1. Bare except: -> except Exception:
|
|
94
|
+
for idx, line in enumerate(lines):
|
|
95
|
+
bare_match = re.search(r"^([ \t]*)except[ \t]*:", line)
|
|
96
|
+
if bare_match:
|
|
97
|
+
indent = bare_match.group(1)
|
|
98
|
+
orig_line = line
|
|
99
|
+
fixed_line = f"{indent}except Exception:\n" if line.endswith("\n") else f"{indent}except Exception:"
|
|
100
|
+
diff = self._make_diff(orig_line, fixed_line, rel_path)
|
|
101
|
+
actions.append(RemediationAction(
|
|
102
|
+
rule_id="SMELL-BARE-EXCEPT",
|
|
103
|
+
title="Narrow Bare Exception Handler",
|
|
104
|
+
file_path=rel_path,
|
|
105
|
+
line_number=idx + 1,
|
|
106
|
+
severity="warning",
|
|
107
|
+
description="Bare 'except:' catches system interrupts (KeyboardInterrupt, SystemExit). Narrow to 'except Exception:'.",
|
|
108
|
+
original_text=orig_line.strip(),
|
|
109
|
+
proposed_text=fixed_line.strip(),
|
|
110
|
+
unified_diff=diff,
|
|
111
|
+
autofixable=True
|
|
112
|
+
))
|
|
113
|
+
|
|
114
|
+
# 2. Dangerous eval / exec calls with user input or direct strings
|
|
115
|
+
for idx, line in enumerate(lines):
|
|
116
|
+
if re.search(r"\b(eval|exec)\s*\(", line) and not line.strip().startswith("#"):
|
|
117
|
+
actions.append(RemediationAction(
|
|
118
|
+
rule_id="SEC-DANGEROUS-DYNAMIC-EXEC",
|
|
119
|
+
title="Refactor Dynamic Code Execution",
|
|
120
|
+
file_path=rel_path,
|
|
121
|
+
line_number=idx + 1,
|
|
122
|
+
severity="critical",
|
|
123
|
+
description=f"Direct invocation of {line.strip()[:30]}... allows arbitrary code execution. Refactor to ast.literal_eval or structured parsers.",
|
|
124
|
+
original_text=line.strip(),
|
|
125
|
+
proposed_text="# TODO: Replace dynamic execution with safe ast.literal_eval or parser",
|
|
126
|
+
unified_diff=self._make_diff(line, f"# Refactor dynamic exec: {line}", rel_path),
|
|
127
|
+
autofixable=False
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
# 3. High-entropy hardcoded secret assignment
|
|
131
|
+
secret_pattern = re.compile(
|
|
132
|
+
r"^([ \t]*)(?:api_key|secret_key|auth_token|password|access_token)\s*=\s*['\"][A-Za-z0-9_\-+/]{24,}['\"]",
|
|
133
|
+
re.IGNORECASE
|
|
134
|
+
)
|
|
135
|
+
for idx, line in enumerate(lines):
|
|
136
|
+
m = secret_pattern.search(line)
|
|
137
|
+
if m and not line.strip().startswith("#"):
|
|
138
|
+
indent = m.group(1)
|
|
139
|
+
var_name = line.split("=")[0].strip()
|
|
140
|
+
env_var = var_name.upper()
|
|
141
|
+
orig_line = line
|
|
142
|
+
fixed_line = f"{indent}{var_name} = os.getenv('{env_var}', '')\n" if line.endswith("\n") else f"{indent}{var_name} = os.getenv('{env_var}', '')"
|
|
143
|
+
diff = self._make_diff(orig_line, fixed_line, rel_path)
|
|
144
|
+
actions.append(RemediationAction(
|
|
145
|
+
rule_id="SEC-HARDCODED-SECRET",
|
|
146
|
+
title="Extract Hardcoded Secret to Environment Variable",
|
|
147
|
+
file_path=rel_path,
|
|
148
|
+
line_number=idx + 1,
|
|
149
|
+
severity="critical",
|
|
150
|
+
description=f"Hardcoded sensitive token assigned to {var_name}. Extract to os.getenv('{env_var}').",
|
|
151
|
+
original_text=orig_line.strip(),
|
|
152
|
+
proposed_text=fixed_line.strip(),
|
|
153
|
+
unified_diff=diff,
|
|
154
|
+
autofixable=True
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.debug("Failed scanning %s for remediation: %s", path, e)
|
|
159
|
+
return actions
|
|
160
|
+
|
|
161
|
+
def _scan_package_json(self, path: Path, rel_path: str) -> List[RemediationAction]:
|
|
162
|
+
actions: List[RemediationAction] = []
|
|
163
|
+
try:
|
|
164
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
165
|
+
lines = content.splitlines(keepends=True)
|
|
166
|
+
for idx, line in enumerate(lines):
|
|
167
|
+
# Pattern: "some-pkg": "*"
|
|
168
|
+
m = re.search(r'([ \t]*"[^"]+"\s*:\s*)"\*"(,?)', line)
|
|
169
|
+
if m:
|
|
170
|
+
prefix = m.group(1)
|
|
171
|
+
comma = m.group(2)
|
|
172
|
+
orig_line = line
|
|
173
|
+
fixed_line = f'{prefix}"^1.0.0"{comma}\n' if line.endswith("\n") else f'{prefix}"^1.0.0"{comma}'
|
|
174
|
+
diff = self._make_diff(orig_line, fixed_line, rel_path)
|
|
175
|
+
actions.append(RemediationAction(
|
|
176
|
+
rule_id="DEP-WILDCARD-PIN",
|
|
177
|
+
title="Pin Wildcard Dependency Version",
|
|
178
|
+
file_path=rel_path,
|
|
179
|
+
line_number=idx + 1,
|
|
180
|
+
severity="warning",
|
|
181
|
+
description="Unrestricted wildcard version '*' exposes builds to supply-chain tampering. Pin to semantic range.",
|
|
182
|
+
original_text=orig_line.strip(),
|
|
183
|
+
proposed_text=fixed_line.strip(),
|
|
184
|
+
unified_diff=diff,
|
|
185
|
+
autofixable=True
|
|
186
|
+
))
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.debug("Failed scanning %s for remediation: %s", path, e)
|
|
189
|
+
return actions
|
|
190
|
+
|
|
191
|
+
def _make_diff(self, original: str, proposed: str, file_path: str) -> str:
|
|
192
|
+
orig_lines = [original if original.endswith("\n") else original + "\n"]
|
|
193
|
+
prop_lines = [proposed if proposed.endswith("\n") else proposed + "\n"]
|
|
194
|
+
diff = difflib.unified_diff(
|
|
195
|
+
orig_lines,
|
|
196
|
+
prop_lines,
|
|
197
|
+
fromfile=f"a/{file_path}",
|
|
198
|
+
tofile=f"b/{file_path}",
|
|
199
|
+
n=1
|
|
200
|
+
)
|
|
201
|
+
return "".join(diff)
|
|
202
|
+
|
|
203
|
+
def apply(self, actions: List[RemediationAction], dry_run: bool = False) -> Dict[str, Any]:
|
|
204
|
+
"""Applies autofixable remediation actions to workspace files."""
|
|
205
|
+
autofixable = [a for a in actions if a.autofixable]
|
|
206
|
+
files_modified = set()
|
|
207
|
+
applied_count = 0
|
|
208
|
+
|
|
209
|
+
# Group actions by file path
|
|
210
|
+
by_file: Dict[str, List[RemediationAction]] = {}
|
|
211
|
+
for a in autofixable:
|
|
212
|
+
by_file.setdefault(a.file_path, []).append(a)
|
|
213
|
+
|
|
214
|
+
for rel_file, file_actions in by_file.items():
|
|
215
|
+
full_path = self.repo_path / rel_file
|
|
216
|
+
if not full_path.exists():
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
content = full_path.read_text(encoding="utf-8")
|
|
221
|
+
modified_content = content
|
|
222
|
+
file_changed = False
|
|
223
|
+
|
|
224
|
+
for act in file_actions:
|
|
225
|
+
if act.original_text and act.original_text in modified_content:
|
|
226
|
+
modified_content = modified_content.replace(act.original_text, act.proposed_text, 1)
|
|
227
|
+
applied_count += 1
|
|
228
|
+
file_changed = True
|
|
229
|
+
|
|
230
|
+
if file_changed:
|
|
231
|
+
files_modified.add(rel_file)
|
|
232
|
+
if not dry_run:
|
|
233
|
+
full_path.write_text(modified_content, encoding="utf-8")
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error("Error applying remediation to %s: %s", rel_file, e)
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"success": True,
|
|
240
|
+
"dry_run": dry_run,
|
|
241
|
+
"applied_count": applied_count,
|
|
242
|
+
"total_candidates": len(actions),
|
|
243
|
+
"files_modified": sorted(list(files_modified)),
|
|
244
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CodeDNA visualization — rendering modules."""
|