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
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Software Bill of Materials (SBOM) Engine for CodeDNA.
|
|
2
|
+
|
|
3
|
+
Discovers multi-ecosystem project dependencies and generates industry-standard
|
|
4
|
+
CycloneDX (v1.5 JSON) and SPDX (v2.3 JSON) software manifests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
IGNORE_DIRS = {
|
|
21
|
+
".git", ".svn", ".hg", "node_modules", "venv", ".venv", "env",
|
|
22
|
+
"__pycache__", ".pytest_cache", "build", "dist", "target", "vendor"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SBOMGenerator:
|
|
27
|
+
"""Discovers project dependencies and exports CycloneDX / SPDX SBOM specifications."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, repo_path: str | Path = ".") -> None:
|
|
30
|
+
self.repo_path = Path(repo_path).resolve()
|
|
31
|
+
|
|
32
|
+
def discover_dependencies(self) -> List[Dict[str, Any]]:
|
|
33
|
+
"""Scans workspace manifests across Python, Node.js, Rust, and Go ecosystems."""
|
|
34
|
+
dependencies: List[Dict[str, Any]] = []
|
|
35
|
+
seen_keys = set()
|
|
36
|
+
|
|
37
|
+
for root, dirs, files in os.walk(self.repo_path):
|
|
38
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
|
|
39
|
+
|
|
40
|
+
# 1. Python: pyproject.toml
|
|
41
|
+
if "pyproject.toml" in files:
|
|
42
|
+
pyproject_path = Path(root) / "pyproject.toml"
|
|
43
|
+
deps = self._parse_pyproject(pyproject_path)
|
|
44
|
+
for d in deps:
|
|
45
|
+
key = (d["name"], d.get("version", "*"), d["ecosystem"])
|
|
46
|
+
if key not in seen_keys:
|
|
47
|
+
seen_keys.add(key)
|
|
48
|
+
dependencies.append(d)
|
|
49
|
+
|
|
50
|
+
# 2. Python: requirements.txt
|
|
51
|
+
for f in files:
|
|
52
|
+
if f == "requirements.txt" or f.endswith(".requirements.txt"):
|
|
53
|
+
req_path = Path(root) / f
|
|
54
|
+
deps = self._parse_requirements_txt(req_path)
|
|
55
|
+
for d in deps:
|
|
56
|
+
key = (d["name"], d.get("version", "*"), d["ecosystem"])
|
|
57
|
+
if key not in seen_keys:
|
|
58
|
+
seen_keys.add(key)
|
|
59
|
+
dependencies.append(d)
|
|
60
|
+
|
|
61
|
+
# 3. Node.js: package.json & package-lock.json
|
|
62
|
+
if "package.json" in files:
|
|
63
|
+
pkg_path = Path(root) / "package.json"
|
|
64
|
+
lock_path = Path(root) / "package-lock.json" if "package-lock.json" in files else None
|
|
65
|
+
deps = self._parse_package_json(pkg_path, lock_path)
|
|
66
|
+
for d in deps:
|
|
67
|
+
key = (d["name"], d.get("version", "*"), d["ecosystem"])
|
|
68
|
+
if key not in seen_keys:
|
|
69
|
+
seen_keys.add(key)
|
|
70
|
+
dependencies.append(d)
|
|
71
|
+
|
|
72
|
+
# 4. Rust: Cargo.toml & Cargo.lock
|
|
73
|
+
if "Cargo.toml" in files:
|
|
74
|
+
cargo_path = Path(root) / "Cargo.toml"
|
|
75
|
+
cargo_lock = Path(root) / "Cargo.lock" if "Cargo.lock" in files else None
|
|
76
|
+
deps = self._parse_cargo_toml(cargo_path, cargo_lock)
|
|
77
|
+
for d in deps:
|
|
78
|
+
key = (d["name"], d.get("version", "*"), d["ecosystem"])
|
|
79
|
+
if key not in seen_keys:
|
|
80
|
+
seen_keys.add(key)
|
|
81
|
+
dependencies.append(d)
|
|
82
|
+
|
|
83
|
+
# 5. Go: go.mod
|
|
84
|
+
if "go.mod" in files:
|
|
85
|
+
go_mod_path = Path(root) / "go.mod"
|
|
86
|
+
deps = self._parse_go_mod(go_mod_path)
|
|
87
|
+
for d in deps:
|
|
88
|
+
key = (d["name"], d.get("version", "*"), d["ecosystem"])
|
|
89
|
+
if key not in seen_keys:
|
|
90
|
+
seen_keys.add(key)
|
|
91
|
+
dependencies.append(d)
|
|
92
|
+
|
|
93
|
+
dependencies.sort(key=lambda x: (x["ecosystem"], x["name"]))
|
|
94
|
+
return dependencies
|
|
95
|
+
|
|
96
|
+
def _parse_pyproject(self, path: Path) -> List[Dict[str, Any]]:
|
|
97
|
+
results = []
|
|
98
|
+
try:
|
|
99
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
100
|
+
# 1. Standard PEP 621 dependencies = [...]
|
|
101
|
+
pep621_match = re.search(r"dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL)
|
|
102
|
+
if pep621_match:
|
|
103
|
+
raw_deps = pep621_match.group(1)
|
|
104
|
+
for item in re.finditer(r'["\']([A-Za-z0-9_.\-]+)(?:([><=~!^]+.*?)?)?["\']', raw_deps):
|
|
105
|
+
name = item.group(1).strip()
|
|
106
|
+
ver = item.group(2).strip() if item.group(2) else "*"
|
|
107
|
+
results.append({
|
|
108
|
+
"name": name,
|
|
109
|
+
"version": ver,
|
|
110
|
+
"ecosystem": "pypi",
|
|
111
|
+
"manifest": str(path.relative_to(self.repo_path)),
|
|
112
|
+
"scope": "required",
|
|
113
|
+
"purl": f"pkg:pypi/{name.lower()}@{ver}" if ver != "*" else f"pkg:pypi/{name.lower()}"
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
# 2. Poetry tool.poetry.dependencies
|
|
117
|
+
poetry_match = re.search(r"\[tool\.poetry\.dependencies\](.*?)(?:\[|\Z)", content, re.DOTALL)
|
|
118
|
+
if poetry_match:
|
|
119
|
+
for line in poetry_match.group(1).splitlines():
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line or line.startswith("#") or line.startswith("python"):
|
|
122
|
+
continue
|
|
123
|
+
m = re.match(r'([A-Za-z0-9_.\-]+)\s*=\s*["\']?([^"\']+)["\']?', line)
|
|
124
|
+
if m:
|
|
125
|
+
name = m.group(1).strip()
|
|
126
|
+
ver = m.group(2).strip()
|
|
127
|
+
results.append({
|
|
128
|
+
"name": name,
|
|
129
|
+
"version": ver,
|
|
130
|
+
"ecosystem": "pypi",
|
|
131
|
+
"manifest": str(path.relative_to(self.repo_path)),
|
|
132
|
+
"scope": "required",
|
|
133
|
+
"purl": f"pkg:pypi/{name.lower()}@{ver}"
|
|
134
|
+
})
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.debug("Failed to parse pyproject.toml at %s: %s", path, e)
|
|
137
|
+
return results
|
|
138
|
+
|
|
139
|
+
def _parse_requirements_txt(self, path: Path) -> List[Dict[str, Any]]:
|
|
140
|
+
results = []
|
|
141
|
+
try:
|
|
142
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
143
|
+
for line in content.splitlines():
|
|
144
|
+
line = line.strip()
|
|
145
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
146
|
+
continue
|
|
147
|
+
m = re.match(r"^([A-Za-z0-9_.\-]+)(?:([><=~!^]+[A-Za-z0-9_.\-+]+))?", line)
|
|
148
|
+
if m:
|
|
149
|
+
name = m.group(1).strip()
|
|
150
|
+
ver = m.group(2).strip() if m.group(2) else "*"
|
|
151
|
+
results.append({
|
|
152
|
+
"name": name,
|
|
153
|
+
"version": ver,
|
|
154
|
+
"ecosystem": "pypi",
|
|
155
|
+
"manifest": str(path.relative_to(self.repo_path)),
|
|
156
|
+
"scope": "required",
|
|
157
|
+
"purl": f"pkg:pypi/{name.lower()}@{ver}" if ver != "*" else f"pkg:pypi/{name.lower()}"
|
|
158
|
+
})
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.debug("Failed to parse requirements.txt at %s: %s", path, e)
|
|
161
|
+
return results
|
|
162
|
+
|
|
163
|
+
def _parse_package_json(self, path: Path, lock_path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
164
|
+
results = []
|
|
165
|
+
locked_versions: Dict[str, str] = {}
|
|
166
|
+
if lock_path and lock_path.exists():
|
|
167
|
+
try:
|
|
168
|
+
lock_data = json.loads(lock_path.read_text(encoding="utf-8", errors="ignore"))
|
|
169
|
+
packages = lock_data.get("packages", {})
|
|
170
|
+
for pkg_key, pkg_info in packages.items():
|
|
171
|
+
name = pkg_key.replace("node_modules/", "")
|
|
172
|
+
if name and "version" in pkg_info:
|
|
173
|
+
locked_versions[name] = pkg_info["version"]
|
|
174
|
+
for name, info in lock_data.get("dependencies", {}).items():
|
|
175
|
+
if "version" in info:
|
|
176
|
+
locked_versions[name] = info["version"]
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.debug("Failed to parse package-lock.json: %s", e)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
data = json.loads(path.read_text(encoding="utf-8", errors="ignore"))
|
|
182
|
+
manifest_str = str(path.relative_to(self.repo_path))
|
|
183
|
+
|
|
184
|
+
# Runtime dependencies
|
|
185
|
+
for name, ver in data.get("dependencies", {}).items():
|
|
186
|
+
resolved_ver = locked_versions.get(name, str(ver))
|
|
187
|
+
results.append({
|
|
188
|
+
"name": name,
|
|
189
|
+
"version": resolved_ver,
|
|
190
|
+
"declared_version": str(ver),
|
|
191
|
+
"ecosystem": "npm",
|
|
192
|
+
"manifest": manifest_str,
|
|
193
|
+
"scope": "required",
|
|
194
|
+
"purl": f"pkg:npm/{name}@{resolved_ver}"
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
# Dev dependencies
|
|
198
|
+
for name, ver in data.get("devDependencies", {}).items():
|
|
199
|
+
resolved_ver = locked_versions.get(name, str(ver))
|
|
200
|
+
results.append({
|
|
201
|
+
"name": name,
|
|
202
|
+
"version": resolved_ver,
|
|
203
|
+
"declared_version": str(ver),
|
|
204
|
+
"ecosystem": "npm",
|
|
205
|
+
"manifest": manifest_str,
|
|
206
|
+
"scope": "optional",
|
|
207
|
+
"purl": f"pkg:npm/{name}@{resolved_ver}"
|
|
208
|
+
})
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.debug("Failed to parse package.json at %s: %s", path, e)
|
|
211
|
+
return results
|
|
212
|
+
|
|
213
|
+
def _parse_cargo_toml(self, path: Path, lock_path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
214
|
+
results = []
|
|
215
|
+
try:
|
|
216
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
217
|
+
in_deps = False
|
|
218
|
+
for line in content.splitlines():
|
|
219
|
+
line = line.strip()
|
|
220
|
+
if line.startswith("[dependencies]") or line.startswith("[dev-dependencies]"):
|
|
221
|
+
in_deps = True
|
|
222
|
+
continue
|
|
223
|
+
if line.startswith("[") and in_deps:
|
|
224
|
+
in_deps = False
|
|
225
|
+
continue
|
|
226
|
+
if in_deps and "=" in line and not line.startswith("#"):
|
|
227
|
+
name, val = line.split("=", 1)
|
|
228
|
+
name = name.strip()
|
|
229
|
+
val = val.strip().strip('"\'')
|
|
230
|
+
ver = val
|
|
231
|
+
if "version" in val:
|
|
232
|
+
m = re.search(r'version\s*=\s*["\']([^"\']+)["\']', val)
|
|
233
|
+
if m:
|
|
234
|
+
ver = m.group(1)
|
|
235
|
+
results.append({
|
|
236
|
+
"name": name,
|
|
237
|
+
"version": ver,
|
|
238
|
+
"ecosystem": "cargo",
|
|
239
|
+
"manifest": str(path.relative_to(self.repo_path)),
|
|
240
|
+
"scope": "required",
|
|
241
|
+
"purl": f"pkg:cargo/{name}@{ver}"
|
|
242
|
+
})
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.debug("Failed to parse Cargo.toml at %s: %s", path, e)
|
|
245
|
+
return results
|
|
246
|
+
|
|
247
|
+
def _parse_go_mod(self, path: Path) -> List[Dict[str, Any]]:
|
|
248
|
+
results = []
|
|
249
|
+
try:
|
|
250
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
251
|
+
in_require = False
|
|
252
|
+
for line in content.splitlines():
|
|
253
|
+
line = line.strip()
|
|
254
|
+
if line.startswith("require ("):
|
|
255
|
+
in_require = True
|
|
256
|
+
continue
|
|
257
|
+
if in_require and line == ")":
|
|
258
|
+
in_require = False
|
|
259
|
+
continue
|
|
260
|
+
if in_require and line and not line.startswith("//"):
|
|
261
|
+
parts = line.split()
|
|
262
|
+
if len(parts) >= 2:
|
|
263
|
+
name = parts[0]
|
|
264
|
+
ver = parts[1]
|
|
265
|
+
results.append({
|
|
266
|
+
"name": name,
|
|
267
|
+
"version": ver,
|
|
268
|
+
"ecosystem": "golang",
|
|
269
|
+
"manifest": str(path.relative_to(self.repo_path)),
|
|
270
|
+
"scope": "required",
|
|
271
|
+
"purl": f"pkg:golang/{name}@{ver}"
|
|
272
|
+
})
|
|
273
|
+
elif line.startswith("require ") and not in_require:
|
|
274
|
+
parts = line.replace("require ", "").split()
|
|
275
|
+
if len(parts) >= 2:
|
|
276
|
+
name = parts[0]
|
|
277
|
+
ver = parts[1]
|
|
278
|
+
results.append({
|
|
279
|
+
"name": name,
|
|
280
|
+
"version": ver,
|
|
281
|
+
"ecosystem": "golang",
|
|
282
|
+
"manifest": str(path.relative_to(self.repo_path)),
|
|
283
|
+
"scope": "required",
|
|
284
|
+
"purl": f"pkg:golang/{name}@{ver}"
|
|
285
|
+
})
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.debug("Failed to parse go.mod at %s: %s", path, e)
|
|
288
|
+
return results
|
|
289
|
+
|
|
290
|
+
def generate_cyclonedx_json(self) -> Dict[str, Any]:
|
|
291
|
+
"""Generates an OASIS / OWASP CycloneDX v1.5 JSON standard document."""
|
|
292
|
+
deps = self.discover_dependencies()
|
|
293
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
294
|
+
root_name = self.repo_path.name or "application"
|
|
295
|
+
|
|
296
|
+
components = []
|
|
297
|
+
dependencies_refs = []
|
|
298
|
+
|
|
299
|
+
for d in deps:
|
|
300
|
+
comp_id = f"{d['ecosystem']}-{d['name']}-{d['version']}".replace("/", "-")
|
|
301
|
+
comp = {
|
|
302
|
+
"bom-ref": comp_id,
|
|
303
|
+
"type": "library",
|
|
304
|
+
"name": d["name"],
|
|
305
|
+
"version": d["version"],
|
|
306
|
+
"purl": d["purl"],
|
|
307
|
+
"scope": d["scope"],
|
|
308
|
+
"properties": [
|
|
309
|
+
{"name": "codedna:ecosystem", "value": d["ecosystem"]},
|
|
310
|
+
{"name": "codedna:manifest", "value": d["manifest"]}
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
components.append(comp)
|
|
314
|
+
dependencies_refs.append(comp_id)
|
|
315
|
+
|
|
316
|
+
doc = {
|
|
317
|
+
"bomFormat": "CycloneDX",
|
|
318
|
+
"specVersion": "1.5",
|
|
319
|
+
"serialNumber": f"urn:uuid:{uuid.uuid4()}",
|
|
320
|
+
"version": 1,
|
|
321
|
+
"metadata": {
|
|
322
|
+
"timestamp": now,
|
|
323
|
+
"tools": [
|
|
324
|
+
{
|
|
325
|
+
"vendor": "CodeDNA",
|
|
326
|
+
"name": "codedna-sbom-engine",
|
|
327
|
+
"version": "1.4.0"
|
|
328
|
+
}
|
|
329
|
+
],
|
|
330
|
+
"component": {
|
|
331
|
+
"type": "application",
|
|
332
|
+
"name": root_name,
|
|
333
|
+
"version": "latest",
|
|
334
|
+
"bom-ref": f"app-{root_name}"
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
"components": components,
|
|
338
|
+
"dependencies": [
|
|
339
|
+
{
|
|
340
|
+
"ref": f"app-{root_name}",
|
|
341
|
+
"dependsOn": dependencies_refs
|
|
342
|
+
}
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
return doc
|
|
346
|
+
|
|
347
|
+
def generate_spdx_json(self) -> Dict[str, Any]:
|
|
348
|
+
"""Generates a Linux Foundation SPDX v2.3 JSON specification document."""
|
|
349
|
+
deps = self.discover_dependencies()
|
|
350
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
351
|
+
doc_name = self.repo_path.name or "codedna-project"
|
|
352
|
+
|
|
353
|
+
packages = [
|
|
354
|
+
{
|
|
355
|
+
"name": doc_name,
|
|
356
|
+
"SPDXID": "SPDXRef-RootPackage",
|
|
357
|
+
"versionInfo": "1.0.0",
|
|
358
|
+
"downloadLocation": "NOASSERTION",
|
|
359
|
+
"filesAnalyzed": False,
|
|
360
|
+
"licenseConcluded": "NOASSERTION",
|
|
361
|
+
"licenseDeclared": "NOASSERTION"
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
relationships = [
|
|
365
|
+
{
|
|
366
|
+
"spdxElementId": "SPDXRef-DOCUMENT",
|
|
367
|
+
"relatedSpdxElement": "SPDXRef-RootPackage",
|
|
368
|
+
"relationshipType": "DESCRIBES"
|
|
369
|
+
}
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
for idx, d in enumerate(deps, start=1):
|
|
373
|
+
spdx_id = f"SPDXRef-Package-{idx}-{re.sub(r'[^A-Za-z0-9.-]', '-', d['name'])}"
|
|
374
|
+
pkg = {
|
|
375
|
+
"name": d["name"],
|
|
376
|
+
"SPDXID": spdx_id,
|
|
377
|
+
"versionInfo": d["version"],
|
|
378
|
+
"downloadLocation": "NOASSERTION",
|
|
379
|
+
"filesAnalyzed": False,
|
|
380
|
+
"licenseConcluded": "NOASSERTION",
|
|
381
|
+
"licenseDeclared": "NOASSERTION",
|
|
382
|
+
"externalRefs": [
|
|
383
|
+
{
|
|
384
|
+
"referenceCategory": "PACKAGE-MANAGER",
|
|
385
|
+
"referenceType": "purl",
|
|
386
|
+
"referenceLocator": d["purl"]
|
|
387
|
+
}
|
|
388
|
+
]
|
|
389
|
+
}
|
|
390
|
+
packages.append(pkg)
|
|
391
|
+
relationships.append({
|
|
392
|
+
"spdxElementId": "SPDXRef-RootPackage",
|
|
393
|
+
"relatedSpdxElement": spdx_id,
|
|
394
|
+
"relationshipType": "DEPENDS_ON"
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
doc = {
|
|
398
|
+
"spdxVersion": "SPDX-2.3",
|
|
399
|
+
"dataLicense": "CC0-1.0",
|
|
400
|
+
"SPDXID": "SPDXRef-DOCUMENT",
|
|
401
|
+
"name": f"{doc_name}-SBOM",
|
|
402
|
+
"documentNamespace": f"https://spdx.org/spdxdocs/{doc_name}-{uuid.uuid4()}",
|
|
403
|
+
"creationInfo": {
|
|
404
|
+
"creators": ["Tool: CodeDNA-1.4.0", "Organization: CodeDNA Intelligence"],
|
|
405
|
+
"created": now
|
|
406
|
+
},
|
|
407
|
+
"packages": packages,
|
|
408
|
+
"relationships": relationships
|
|
409
|
+
}
|
|
410
|
+
return doc
|
codedna/config.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""CodeDNA Configuration Loader — supports codedna.toml and pyproject.toml [tool.codedna]."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CodeDNAConfig:
|
|
14
|
+
"""Configuration options for CodeDNA analysis, security, and CI quality gates."""
|
|
15
|
+
|
|
16
|
+
# Gatekeeper policies
|
|
17
|
+
min_health: str = "Fair"
|
|
18
|
+
max_critical_smells: int = 0
|
|
19
|
+
max_smells: int | None = None
|
|
20
|
+
fail_on_secrets: bool = True
|
|
21
|
+
fail_on_circular: bool = False
|
|
22
|
+
max_density: float | None = None
|
|
23
|
+
max_drift: float = 20.0
|
|
24
|
+
max_cyclomatic_complexity: int | None = None
|
|
25
|
+
min_maintainability_index: float | None = None
|
|
26
|
+
|
|
27
|
+
# Path exclusions
|
|
28
|
+
ignore_paths: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
# Security whitelisting
|
|
31
|
+
whitelisted_secrets: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
# Smell thresholds
|
|
34
|
+
max_file_lines: int = 500
|
|
35
|
+
max_function_lines: int = 80
|
|
36
|
+
god_class_methods: int = 15
|
|
37
|
+
large_module_files: int = 20
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def gate(self) -> CodeDNAConfig:
|
|
41
|
+
"""Alias to self for structured access to gatekeeper configuration."""
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def smells(self) -> CodeDNAConfig:
|
|
46
|
+
"""Alias to self for structured access to smell thresholds."""
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def is_path_ignored(self, rel_path: str) -> bool:
|
|
50
|
+
"""Check if relative file path matches any ignore glob patterns."""
|
|
51
|
+
normalized = rel_path.replace("\\", "/").strip("/")
|
|
52
|
+
for pattern in self.ignore_paths:
|
|
53
|
+
norm_pattern = pattern.replace("\\", "/").strip("/")
|
|
54
|
+
clean_base = norm_pattern.rstrip("/*")
|
|
55
|
+
if (
|
|
56
|
+
normalized == clean_base
|
|
57
|
+
or normalized.startswith(clean_base + "/")
|
|
58
|
+
or fnmatch.fnmatch(normalized, norm_pattern)
|
|
59
|
+
or fnmatch.fnmatch(Path(normalized).name, norm_pattern)
|
|
60
|
+
):
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def is_secret_whitelisted(self, secret_text: str) -> bool:
|
|
65
|
+
"""Check if detected secret matches any whitelisted keywords or tokens."""
|
|
66
|
+
clean = secret_text.strip()
|
|
67
|
+
for allowed in self.whitelisted_secrets:
|
|
68
|
+
if allowed in clean or fnmatch.fnmatch(clean, allowed):
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_config(root_path: Path | str) -> CodeDNAConfig:
|
|
74
|
+
"""Load configuration from codedna.toml, .codedna.toml, or pyproject.toml."""
|
|
75
|
+
root = Path(root_path)
|
|
76
|
+
candidates = [
|
|
77
|
+
root / "codedna.toml",
|
|
78
|
+
root / ".codedna.toml",
|
|
79
|
+
root / "pyproject.toml",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
for candidate in candidates:
|
|
83
|
+
if candidate.is_file():
|
|
84
|
+
try:
|
|
85
|
+
content = candidate.read_bytes()
|
|
86
|
+
data = tomllib.loads(content.decode("utf-8", errors="replace"))
|
|
87
|
+
|
|
88
|
+
section: dict[str, Any] = {}
|
|
89
|
+
if candidate.name == "pyproject.toml":
|
|
90
|
+
section = data.get("tool", {}).get("codedna", {})
|
|
91
|
+
else:
|
|
92
|
+
section = data.get("codedna", data)
|
|
93
|
+
|
|
94
|
+
if not section and candidate.name == "pyproject.toml":
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
smell_cfg = section.get("smells", {})
|
|
98
|
+
gate_cfg = section.get("gate", section)
|
|
99
|
+
|
|
100
|
+
return CodeDNAConfig(
|
|
101
|
+
min_health=str(gate_cfg.get("min_health", section.get("min_health", "Fair"))),
|
|
102
|
+
max_critical_smells=int(gate_cfg.get("max_critical_smells", section.get("max_critical_smells", 0))),
|
|
103
|
+
max_smells=int(gate_cfg["max_smells"]) if "max_smells" in gate_cfg and gate_cfg["max_smells"] is not None else None,
|
|
104
|
+
fail_on_secrets=bool(gate_cfg.get("fail_on_secrets", section.get("fail_on_secrets", True))),
|
|
105
|
+
fail_on_circular=bool(gate_cfg.get("fail_on_circular", section.get("fail_on_circular", False))),
|
|
106
|
+
max_density=float(gate_cfg["max_density"]) if "max_density" in gate_cfg and gate_cfg["max_density"] is not None else None,
|
|
107
|
+
max_drift=float(gate_cfg.get("max_drift", section.get("max_drift", 20.0))),
|
|
108
|
+
max_cyclomatic_complexity=int(gate_cfg["max_cyclomatic_complexity"]) if "max_cyclomatic_complexity" in gate_cfg and gate_cfg["max_cyclomatic_complexity"] is not None else None,
|
|
109
|
+
min_maintainability_index=float(gate_cfg["min_maintainability_index"]) if "min_maintainability_index" in gate_cfg and gate_cfg["min_maintainability_index"] is not None else None,
|
|
110
|
+
ignore_paths=list(section.get("ignore_paths", [])),
|
|
111
|
+
whitelisted_secrets=list(section.get("whitelisted_secrets", [])),
|
|
112
|
+
max_file_lines=int(smell_cfg.get("max_file_lines", section.get("max_file_lines", 500))),
|
|
113
|
+
max_function_lines=int(smell_cfg.get("max_function_lines", section.get("max_function_lines", 80))),
|
|
114
|
+
god_class_methods=int(smell_cfg.get("god_class_methods", section.get("god_class_methods", 15))),
|
|
115
|
+
large_module_files=int(smell_cfg.get("large_module_files", section.get("large_module_files", 20))),
|
|
116
|
+
)
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
return CodeDNAConfig()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_default_config_toml() -> str:
|
|
124
|
+
"""Generate default starter codedna.toml file content."""
|
|
125
|
+
return '''# CodeDNA Configuration File (codedna.toml)
|
|
126
|
+
# https://github.com/shenald-dev/codedna
|
|
127
|
+
|
|
128
|
+
# Paths to exclude from analysis, smell checks, and security scans
|
|
129
|
+
ignore_paths = [
|
|
130
|
+
"tests/*",
|
|
131
|
+
"docs/*",
|
|
132
|
+
"build/*",
|
|
133
|
+
"dist/*",
|
|
134
|
+
"*.min.js",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
# Whitelist false-positive tokens or development test fixtures
|
|
138
|
+
whitelisted_secrets = [
|
|
139
|
+
"PUBLIC_KEY",
|
|
140
|
+
"EXAMPLE_KEY",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
[gate]
|
|
144
|
+
# Minimum acceptable health rating ("Healthy", "Fair", "Needs Attention", "Critical")
|
|
145
|
+
min_health = "Fair"
|
|
146
|
+
|
|
147
|
+
# Maximum allowable critical smells (0 enforces zero-tolerance)
|
|
148
|
+
max_critical_smells = 0
|
|
149
|
+
|
|
150
|
+
# Maximum allowable total smells (omit or leave empty for no cap)
|
|
151
|
+
# max_smells = 50
|
|
152
|
+
|
|
153
|
+
# Fail CI immediately if secrets/credentials or circular dependencies are found
|
|
154
|
+
fail_on_secrets = true
|
|
155
|
+
fail_on_circular = false
|
|
156
|
+
|
|
157
|
+
# Maximum acceptable genetic drift percentage during drift regression gates (0-100)
|
|
158
|
+
max_drift = 20.0
|
|
159
|
+
|
|
160
|
+
[smells]
|
|
161
|
+
# Thresholds for architectural smell detection
|
|
162
|
+
max_file_lines = 500
|
|
163
|
+
max_function_lines = 80
|
|
164
|
+
god_class_methods = 15
|
|
165
|
+
large_module_files = 20
|
|
166
|
+
'''
|
|
167
|
+
|
codedna/mcp/__init__.py
ADDED
codedna/mcp/protocol.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Model Context Protocol (MCP) JSON-RPC 2.0 schemas and protocol primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class MCPToolDefinition:
|
|
11
|
+
"""Definition of an exposed MCP tool with JSON Schema parameters."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
description: str
|
|
15
|
+
inputSchema: dict[str, Any] # noqa: N815
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict[str, Any]:
|
|
18
|
+
return asdict(self)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class JSONRPCRequest:
|
|
23
|
+
"""Standard JSON-RPC 2.0 request payload."""
|
|
24
|
+
|
|
25
|
+
jsonrpc: str
|
|
26
|
+
id: str | int | None
|
|
27
|
+
method: str
|
|
28
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: Any) -> JSONRPCRequest:
|
|
32
|
+
if not isinstance(data, dict):
|
|
33
|
+
return cls(jsonrpc="2.0", id=None, method="", params={})
|
|
34
|
+
raw_params = data.get("params")
|
|
35
|
+
params = raw_params if isinstance(raw_params, dict) else {}
|
|
36
|
+
return cls(
|
|
37
|
+
jsonrpc=str(data.get("jsonrpc", "2.0")),
|
|
38
|
+
id=data.get("id"),
|
|
39
|
+
method=str(data.get("method", "")),
|
|
40
|
+
params=params,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class JSONRPCResponse:
|
|
46
|
+
"""Standard JSON-RPC 2.0 response payload."""
|
|
47
|
+
|
|
48
|
+
jsonrpc: str = "2.0"
|
|
49
|
+
id: str | int | None = None
|
|
50
|
+
result: Any = None
|
|
51
|
+
error: dict[str, Any] | None = None
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
data: dict[str, Any] = {"jsonrpc": self.jsonrpc, "id": self.id}
|
|
55
|
+
if self.error is not None:
|
|
56
|
+
data["error"] = self.error
|
|
57
|
+
else:
|
|
58
|
+
data["result"] = self.result
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Standard Error Codes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
METHOD_NOT_FOUND = -32601
|