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.
Files changed (37) hide show
  1. codedna/__init__.py +3 -0
  2. codedna/analyzers/__init__.py +1 -0
  3. codedna/analyzers/ai_analyzer.py +111 -0
  4. codedna/analyzers/architecture_detector.py +150 -0
  5. codedna/analyzers/cache_manager.py +70 -0
  6. codedna/analyzers/code_smell_detector.py +302 -0
  7. codedna/analyzers/complexity_analyzer.py +329 -0
  8. codedna/analyzers/dependency_mapper.py +171 -0
  9. codedna/analyzers/developer_analyzer.py +148 -0
  10. codedna/analyzers/diff_engine.py +395 -0
  11. codedna/analyzers/dna_generator.py +308 -0
  12. codedna/analyzers/evolution_engine.py +193 -0
  13. codedna/analyzers/github_analyzer.py +86 -0
  14. codedna/analyzers/language_detector.py +151 -0
  15. codedna/analyzers/repo_cloner.py +91 -0
  16. codedna/analyzers/security_detector.py +271 -0
  17. codedna/analyzers/structure_analyzer.py +91 -0
  18. codedna/ci/__init__.py +5 -0
  19. codedna/ci/gatekeeper.py +421 -0
  20. codedna/cli.py +839 -0
  21. codedna/compliance/__init__.py +5 -0
  22. codedna/compliance/sbom.py +410 -0
  23. codedna/config.py +167 -0
  24. codedna/mcp/__init__.py +5 -0
  25. codedna/mcp/protocol.py +65 -0
  26. codedna/mcp/server.py +393 -0
  27. codedna/remediation/__init__.py +5 -0
  28. codedna/remediation/planner.py +244 -0
  29. codedna/visualization/__init__.py +1 -0
  30. codedna/visualization/html_export.py +507 -0
  31. codedna/visualization/renderer.py +373 -0
  32. codedna_analyzer-1.4.0.dist-info/METADATA +425 -0
  33. codedna_analyzer-1.4.0.dist-info/RECORD +37 -0
  34. codedna_analyzer-1.4.0.dist-info/WHEEL +5 -0
  35. codedna_analyzer-1.4.0.dist-info/entry_points.txt +2 -0
  36. codedna_analyzer-1.4.0.dist-info/licenses/LICENSE +21 -0
  37. codedna_analyzer-1.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,151 @@
1
+ """Language Detector — identifies programming languages used in a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ try:
10
+ MAX_FILE_SIZE = int(os.environ.get("CODEDNA_MAX_FILE_SIZE", 5 * 1024 * 1024))
11
+ except ValueError:
12
+ MAX_FILE_SIZE = 5 * 1024 * 1024
13
+
14
+ # Extension → Language mapping
15
+ LANGUAGE_MAP: dict[str, str] = {
16
+ ".py": "Python",
17
+ ".js": "JavaScript",
18
+ ".ts": "TypeScript",
19
+ ".tsx": "TypeScript",
20
+ ".jsx": "JavaScript",
21
+ ".java": "Java",
22
+ ".kt": "Kotlin",
23
+ ".go": "Go",
24
+ ".rs": "Rust",
25
+ ".rb": "Ruby",
26
+ ".php": "PHP",
27
+ ".cs": "C#",
28
+ ".cpp": "C++",
29
+ ".c": "C",
30
+ ".h": "C",
31
+ ".hpp": "C++",
32
+ ".swift": "Swift",
33
+ ".scala": "Scala",
34
+ ".r": "R",
35
+ ".m": "Objective-C",
36
+ ".lua": "Lua",
37
+ ".sh": "Shell",
38
+ ".bash": "Shell",
39
+ ".zsh": "Shell",
40
+ ".ps1": "PowerShell",
41
+ ".sql": "SQL",
42
+ ".html": "HTML",
43
+ ".css": "CSS",
44
+ ".scss": "SCSS",
45
+ ".less": "LESS",
46
+ ".vue": "Vue",
47
+ ".svelte": "Svelte",
48
+ ".dart": "Dart",
49
+ ".yml": "YAML",
50
+ ".yaml": "YAML",
51
+ ".toml": "TOML",
52
+ ".json": "JSON",
53
+ ".xml": "XML",
54
+ ".md": "Markdown",
55
+ ".proto": "Protobuf",
56
+ ".graphql": "GraphQL",
57
+ ".tf": "Terraform",
58
+ ".sol": "Solidity",
59
+ ".zig": "Zig",
60
+ ".ex": "Elixir",
61
+ ".exs": "Elixir",
62
+ ".erl": "Erlang",
63
+ ".hs": "Haskell",
64
+ ".clj": "Clojure",
65
+ }
66
+
67
+ IGNORE_DIRS = {
68
+ "node_modules", ".git", "__pycache__", ".venv", "venv",
69
+ "dist", "build", ".next", ".nuxt", "target", "vendor",
70
+ ".tox", ".mypy_cache", ".pytest_cache", "coverage",
71
+ ".idea", ".vscode", "env", ".env",
72
+ }
73
+
74
+
75
+ class LanguageDetector:
76
+ """Detects programming languages in a repository by file extension analysis."""
77
+
78
+ def detect(self, repo_path: Path) -> dict[str, any]:
79
+ """Analyze language distribution in a repository.
80
+
81
+ Returns:
82
+ Dict with language counts, percentages, and primary language.
83
+ """
84
+ counter: Counter = Counter()
85
+ line_counter: Counter = Counter()
86
+ total_files = 0
87
+ overall_lines = 0
88
+
89
+ for file_path in self._walk_files(repo_path):
90
+ ext = file_path.suffix.lower()
91
+ lang = LANGUAGE_MAP.get(ext)
92
+ if lang:
93
+ counter[lang] += 1
94
+ total_files += 1
95
+ try:
96
+ with file_path.open("rb") as f:
97
+ lines = 0
98
+ last_chunk = b''
99
+ for chunk in iter(lambda: f.read(65536), b''):
100
+ lines += chunk.count(b'\n')
101
+ last_chunk = chunk
102
+ if last_chunk and not last_chunk.endswith(b'\n'):
103
+ lines += 1
104
+ line_counter[lang] += lines
105
+ overall_lines += lines
106
+ except OSError:
107
+ pass
108
+
109
+ if not counter:
110
+ return {"languages": {}, "primary": "Unknown", "total_files": 0}
111
+
112
+ total_lines = overall_lines or 1
113
+
114
+ languages = {}
115
+ for lang, count in counter.most_common():
116
+ lines = line_counter.get(lang, 0)
117
+ languages[lang] = {
118
+ "files": count,
119
+ "lines": lines,
120
+ "percentage": round((lines / total_lines) * 100, 1),
121
+ }
122
+
123
+ primary = counter.most_common(1)[0][0]
124
+
125
+ return {
126
+ "languages": languages,
127
+ "primary": primary,
128
+ "total_files": total_files,
129
+ "total_lines": overall_lines,
130
+ }
131
+
132
+ def _walk_files(self, root: Path):
133
+ """Walk files, skipping ignored directories."""
134
+ stack = [root]
135
+ while stack:
136
+ current = stack.pop()
137
+ try:
138
+ for item in current.iterdir():
139
+ if item.name in IGNORE_DIRS:
140
+ continue
141
+ if item.is_dir():
142
+ stack.append(item)
143
+ elif item.is_file():
144
+ try:
145
+ if item.stat().st_size <= MAX_FILE_SIZE:
146
+ yield item
147
+ except OSError:
148
+ pass
149
+ except PermissionError:
150
+ pass
151
+
@@ -0,0 +1,91 @@
1
+ """Repository Cloner — clones and caches GitHub repositories for analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from urllib.parse import unquote, urlparse
8
+
9
+ from git import Repo
10
+ from rich.console import Console
11
+
12
+ from .cache_manager import CacheManager
13
+
14
+
15
+ class RepoCloner:
16
+ """Clones a Git repository to a local cache directory for analysis."""
17
+
18
+ def __init__(self, cache_dir: str | None = None, console=None):
19
+ """Initialize the RepoCloner.
20
+
21
+ Args:
22
+ cache_dir: Optional custom cache directory path.
23
+ console: Optional rich Console instance. If None, it will be lazily
24
+ instantiated.
25
+ """
26
+ if console is None:
27
+ self.console = Console()
28
+ else:
29
+ self.console = console
30
+
31
+ if cache_dir is None:
32
+ base_dir = Path(CacheManager().cache_dir)
33
+ self.cache_dir = base_dir / "repos"
34
+ self.cache_dir.mkdir(exist_ok=True)
35
+ else:
36
+ self.cache_dir = Path(cache_dir)
37
+
38
+ def clone(self, source: str) -> Path:
39
+ """Clone a repository from URL or resolve a local path.
40
+
41
+ Args:
42
+ source: GitHub URL or local filesystem path.
43
+
44
+ Returns:
45
+ Path to the cloned/resolved repository.
46
+ """
47
+ # Security: Prevent Git command injection by ensuring source doesn't start with '-'
48
+ # and looks like a valid URL or local path.
49
+ source = source.strip()
50
+ if source.startswith("-"):
51
+ raise ValueError(f"Invalid repository source: {source}")
52
+
53
+ if source.startswith("ext::"):
54
+ raise ValueError(f"Invalid repository source: {source}")
55
+
56
+ local_path = Path(source)
57
+ if local_path.exists() and (local_path / ".git").exists():
58
+ self.console.print(f" 📂 Using local repository: [cyan]{local_path}[/]")
59
+ return local_path
60
+
61
+ parsed = urlparse(source)
62
+
63
+ # Git supports diverse URI formats like SCP style (git@github.com:user/repo.git)
64
+ # where urlparse might incorrectly identify the domain or windows drive as a scheme.
65
+ # We instead check for explicit unauthorized schemes and allow others to pass to Git
66
+ if parsed.scheme:
67
+ # Reject known dangerous schemes
68
+ scheme = parsed.scheme.lower()
69
+ # block some remote helpers and risky protocols, we already blocked ext::
70
+ if scheme in ("ext", "ftp", "ftps", "telnet", "fd"):
71
+ raise ValueError(f"Unauthorized URL scheme: {parsed.scheme}")
72
+
73
+ # Clone from URL
74
+ unquoted_path = unquote(parsed.path or source)
75
+ repo_name = os.path.basename(unquoted_path.rstrip("/")).replace(".git", "")
76
+ if not repo_name or repo_name in (".", ".."):
77
+ raise ValueError(f"Invalid repository name: {repo_name}")
78
+
79
+ dest = self.cache_dir / repo_name
80
+
81
+ if dest.exists():
82
+ self.console.print(f" ♻️ Using cached clone: [cyan]{dest}[/]")
83
+ return dest
84
+
85
+ self.console.print(f" 📥 Cloning [cyan]{source}[/] ...")
86
+ Repo.clone_from(source, str(dest), depth=100)
87
+ return dest
88
+
89
+ def cleanup(self) -> None:
90
+ """Remove the cache directory (No-op in V3 to allow persistent caching)."""
91
+ pass
@@ -0,0 +1,271 @@
1
+ """Security & Vulnerability Scanner — detects hardcoded secrets and risky patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import math
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .language_detector import IGNORE_DIRS
14
+
15
+ if TYPE_CHECKING:
16
+ from codedna.config import CodeDNAConfig
17
+
18
+ try:
19
+ MAX_FILE_SIZE = int(os.environ.get("CODEDNA_MAX_FILE_SIZE", 5 * 1024 * 1024))
20
+ except ValueError:
21
+ logging.getLogger(__name__).warning("Invalid CODEDNA_MAX_FILE_SIZE value. Using default 5MB.")
22
+ MAX_FILE_SIZE = 5 * 1024 * 1024
23
+
24
+ def calculate_shannon_entropy(text: str) -> float:
25
+ """Compute Shannon Entropy H(S) = -sum(p * log2(p)) for credential detection."""
26
+ if not text:
27
+ return 0.0
28
+ length = len(text)
29
+ counts: dict[str, int] = {}
30
+ for char in text:
31
+ counts[char] = counts.get(char, 0) + 1
32
+ entropy = 0.0
33
+ for count in counts.values():
34
+ p = count / length
35
+ entropy -= p * math.log2(p)
36
+ return round(entropy, 3)
37
+
38
+ SECRET_PATTERNS = {
39
+ "AWS Access Key": re.compile(r"((?:AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16})"),
40
+ "Generic API Key / Token": re.compile(r"(?:[Kk]ey|[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]w|[Aa]uth|KEY|TOKEN|SECRET|PASSWORD|PW|AUTH)[-\s_:=]+['\"]([0-9a-zA-Z\-_]{20,})['\"]"), # noqa: E501
41
+ "OpenAI / LLM Key": re.compile(r"(sk-[A-Za-z0-9_-]{32,})"),
42
+ "Anthropic API Key": re.compile(r"(sk-ant-[A-Za-z0-9_-]{32,})"),
43
+ "Hugging Face Token": re.compile(r"(hf_[A-Za-z0-9]{30,})"),
44
+ "Pinecone API Key": re.compile(r"(pcsk_[A-Za-z0-9_]{30,})"),
45
+ "RSA Private Key": re.compile(r"-----BEGIN " + r"RSA PRIVATE KEY-----"),
46
+ "SSH Private Key": re.compile(r"-----BEGIN " + r"OPENSSH PRIVATE KEY-----"),
47
+ "GitHub Token": re.compile(r"((?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36})"),
48
+ "Stripe Secret Key": re.compile(r"(sk_" + r"live_[0-9a-zA-Z]{24})"),
49
+ "Slack Token": re.compile(r"(xox[baprs]-[0-9]{10,12}-[0-9]{10,12}-[a-zA-Z0-9]{24})"),
50
+ "Google Gemini / AI Studio Key": re.compile(r"(AIzaSy[0-9A-Za-z\-_]{30,35})"),
51
+ "Google API Key": re.compile(r"(AIza(?!Sy)[0-9A-Za-z\-_]{35})"),
52
+ "Cohere API Key": re.compile(r"(co_[0-9a-zA-Z]{40})"),
53
+ "Mistral API Key": re.compile(r"(mis_[0-9a-zA-Z]{32})"),
54
+ "Perplexity API Key": re.compile(r"(pplx-[0-9a-zA-Z]{48})"),
55
+ "DeepSeek API Key": re.compile(r"(sk-[0-9a-f]{32})"),
56
+ "Groq API Key": re.compile(r"(gsk_[0-9a-zA-Z]{52})"),
57
+ "Database Credential URI": re.compile(r"""((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s'"]+:([^@\s'"]+)@[^\s'"]+)""", re.IGNORECASE),
58
+ "Discord Bot Token": re.compile(r"[MND][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}"),
59
+ "SendGrid API Key": re.compile(r"SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}"),
60
+ "MailChimp API Key": re.compile(r"[0-9a-f]{32}-us[0-9]{1,2}"),
61
+ }
62
+
63
+ VULN_PATTERNS = {
64
+ "Command Injection Risk": (re.compile(r"subprocess\.(?:call|Popen|run)\s*\([^)]*?shell\s*=\s*True[^)]*\)", re.IGNORECASE), "shell=True"),
65
+ "Raw SQL String Injection": (re.compile(r'''f["']SELECT[^{}\n]+?WHERE[^{}\n]+?\{[^}]+\}''', re.IGNORECASE), "SELECT"),
66
+ "Insecure Deserialization": (re.compile(r"(?:pickle\.(?:loads|load)|_pickle\.(?:loads|load)|yaml\.load\([^)]*?Loader\s*=\s*(?:yaml\.)?Loader|marshal\.loads)"), "pickle"),
67
+ "Path Traversal Risk": (re.compile(r"(?:open|file)\s*\([^)]*?(?:request\.|params\[|req\.query|user_input)"), "open"),
68
+ "Weak Cryptographic Hash (MD5/SHA1)": (re.compile(r"hashlib\.(?:md5|sha1)\s*\(\s*(?!\s*usedforsecurity\s*=\s*False)"), "hashlib"),
69
+ }
70
+
71
+ SECRET_HINTS = {
72
+ "AWS Access Key": ("AKIA", "ABIA", "ACCA", "ASIA"),
73
+ "Generic API Key / Token": ("key", "token", "secret", "password", "pw", "auth", "KEY", "TOKEN", "SECRET", "PASSWORD", "PW", "AUTH", "Key", "Token", "Secret", "Password", "Pw", "Auth"),
74
+ "OpenAI / LLM Key": ("sk-",),
75
+ "Anthropic API Key": ("sk-ant-",),
76
+ "Hugging Face Token": ("hf_",),
77
+ "Pinecone API Key": ("pcsk_",),
78
+ "RSA Private Key": ("-----BEGIN " + "RSA PRIVATE KEY-----",),
79
+ "SSH Private Key": ("-----BEGIN " + "OPENSSH PRIVATE KEY-----",),
80
+ "GitHub Token": ("ghp_", "gho_", "ghu_", "ghs_", "ghr_"),
81
+ "Stripe Secret Key": ("sk_" + "live_",),
82
+ "Slack Token": ("xox",),
83
+ "Google API Key": ("AIza",),
84
+ "Google Gemini / AI Studio Key": ("AIzaSy",),
85
+ "Cohere API Key": ("co_",),
86
+ "Mistral API Key": ("mis_",),
87
+ "Perplexity API Key": ("pplx-",),
88
+ "DeepSeek API Key": ("sk-",),
89
+ "Groq API Key": ("gsk_",),
90
+ "Database Credential URI": ("://",),
91
+ "SendGrid API Key": ("SG.",),
92
+ "MailChimp API Key": ("-us",),
93
+ }
94
+
95
+ # String literal token pattern for entropy analysis
96
+ QUOTED_TOKEN_PATTERN = re.compile(r"""['"]([A-Za-z0-9+/=_-]{24,100})['"]""")
97
+
98
+
99
+ class SecurityDetector:
100
+ """Scans codebase for security vulnerabilities and hardcoded secrets."""
101
+
102
+ def __init__(self, config: CodeDNAConfig | None = None) -> None:
103
+ self.config = config
104
+
105
+ def detect(self, repo_path: Path, config: CodeDNAConfig | None = None) -> dict:
106
+ """Scan repository for security issues.
107
+
108
+ Returns:
109
+ Dict containing detected secrets and vulnerabilities.
110
+ """
111
+ active_config = config or self.config
112
+ vulnerabilities = []
113
+
114
+ for file_path in self._walk_source(repo_path):
115
+ try:
116
+ relative = str(file_path.relative_to(repo_path))
117
+ if active_config and active_config.is_path_ignored(relative):
118
+ continue
119
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
120
+ except (OSError, ValueError):
121
+ continue
122
+
123
+ # Check for secrets
124
+ self._scan_secrets(content, relative, active_config, vulnerabilities)
125
+
126
+ # Check for code injection vulnerabilities in source code files
127
+ source_code_exts = {".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".rb", ".php", ".c", ".cpp"}
128
+ if file_path.suffix.lower() in source_code_exts:
129
+ self._scan_injections(content, relative, vulnerabilities)
130
+
131
+ # Check for high-entropy secret candidates via Shannon entropy (skip lockfiles)
132
+ is_lockfile = file_path.name in ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "uv.lock", "poetry.lock")
133
+ if not is_lockfile:
134
+ self._scan_entropy(content, relative, active_config, vulnerabilities)
135
+
136
+ # Check dependency manifests for outdated or naive flags
137
+ if file_path.name == "package.json":
138
+ self._check_package_manifest(content, relative, vulnerabilities)
139
+ elif file_path.name == "requirements.txt":
140
+ self._check_requirements(content, relative, vulnerabilities)
141
+
142
+ return {
143
+ "vulnerabilities": vulnerabilities,
144
+ "total_critical": len([v for v in vulnerabilities if v["severity"] == "critical"]),
145
+ "has_secrets": len([v for v in vulnerabilities if v["type"] == "Hardcoded Secret"]) > 0,
146
+ }
147
+
148
+ def _scan_secrets(self, content: str, relative: str, active_config: CodeDNAConfig | None, vulnerabilities: list) -> None:
149
+ """Scan file content for predefined secret patterns."""
150
+ for secret_type, pattern in SECRET_PATTERNS.items():
151
+ hints = SECRET_HINTS.get(secret_type)
152
+ if hints and not any(hint in content for hint in hints):
153
+ continue
154
+
155
+ last_idx = 0
156
+ current_line = 1
157
+ for match in pattern.finditer(content):
158
+ matched_value = match.group(0)
159
+ if active_config and active_config.is_secret_whitelisted(matched_value):
160
+ continue
161
+
162
+ if len(matched_value) > 10:
163
+ masked = f"{matched_value[:4]}***{matched_value[-4:]}"
164
+ else:
165
+ masked = "***"
166
+
167
+ start_idx = match.start()
168
+ current_line += content.count('\n', last_idx, start_idx)
169
+ last_idx = start_idx
170
+
171
+ vulnerabilities.append({
172
+ "type": "Hardcoded Secret",
173
+ "severity": "critical",
174
+ "file": f"{relative}:{current_line}",
175
+ "detail": f"Detected {secret_type} ({masked})",
176
+ })
177
+
178
+ def _scan_injections(self, content: str, relative: str, vulnerabilities: list) -> None:
179
+ """Scan source code for dangerous dynamic execution and injection patterns."""
180
+ for vuln_type, (v_pattern, v_hint) in VULN_PATTERNS.items():
181
+ if v_hint in content:
182
+ last_idx = 0
183
+ current_line = 1
184
+ for match in v_pattern.finditer(content):
185
+ start_idx = match.start()
186
+ current_line += content.count('\n', last_idx, start_idx)
187
+ last_idx = start_idx
188
+ vulnerabilities.append({
189
+ "type": vuln_type,
190
+ "severity": "critical",
191
+ "file": f"{relative}:{current_line}",
192
+ "detail": f"Insecure dynamic execution vector: {vuln_type}",
193
+ })
194
+
195
+ def _scan_entropy(self, content: str, relative: str, active_config: CodeDNAConfig | None, vulnerabilities: list) -> None:
196
+ """Scan string literals for high Shannon entropy candidate tokens."""
197
+ for match in QUOTED_TOKEN_PATTERN.finditer(content):
198
+ candidate = match.group(1)
199
+ if len(candidate) >= 28 and not candidate.startswith(("data:", "http", "sha256-", "sha512-", "file:", "assets/", "urn:", "git+")):
200
+ entropy = calculate_shannon_entropy(candidate)
201
+ if entropy >= 4.6:
202
+ if active_config and active_config.is_secret_whitelisted(candidate):
203
+ continue
204
+ masked = f"{candidate[:4]}***{candidate[-4:]}"
205
+ already_flagged = any(masked in v.get("detail", "") for v in vulnerabilities if v.get("file", "").startswith(relative))
206
+ if not already_flagged:
207
+ start_idx = match.start()
208
+ line_num = content.count('\n', 0, start_idx) + 1
209
+ vulnerabilities.append({
210
+ "type": "High-Entropy Secret Candidate",
211
+ "severity": "warning",
212
+ "file": f"{relative}:{line_num}",
213
+ "detail": f"High Shannon entropy token ({entropy:.2f} bits/char, {masked})",
214
+ })
215
+
216
+ def _walk_source(self, root: Path):
217
+ stack = [root]
218
+ while stack:
219
+ current = stack.pop()
220
+ try:
221
+ for item in current.iterdir():
222
+ if item.name in IGNORE_DIRS or item.name.startswith(".git") or item.is_symlink():
223
+ continue
224
+ if item.is_dir():
225
+ stack.append(item)
226
+ elif item.is_file():
227
+ if item.suffix.lower() not in (".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".tar", ".gz", ".sqlite", ".db"): # noqa: E501
228
+ try:
229
+ if item.stat().st_size <= MAX_FILE_SIZE:
230
+ yield item
231
+ except OSError:
232
+ pass
233
+ except PermissionError:
234
+ pass
235
+
236
+ def _check_package_manifest(self, content: str, relative: str, vulnerabilities: list):
237
+ """Scans package.json for known bad practices or dangerous dependencies."""
238
+ try:
239
+ data = json.loads(content)
240
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
241
+ if "node-ipc" in deps and "10" in str(deps["node-ipc"]):
242
+ vulnerabilities.append({
243
+ "type": "Vulnerable Dependency",
244
+ "severity": "critical",
245
+ "file": relative,
246
+ "detail": "node-ipc heavily compromised version detected."
247
+ })
248
+ # Check for overly broad wildcard resolutions
249
+ for pkg, ver in deps.items():
250
+ if ver == "*":
251
+ vulnerabilities.append({
252
+ "type": "Security Risk",
253
+ "severity": "warning",
254
+ "file": relative,
255
+ "detail": f"Dependency '{pkg}' uses unrestricted version wildcard '*'"
256
+ })
257
+ except Exception:
258
+ pass
259
+
260
+ def _check_requirements(self, content: str, relative: str, vulnerabilities: list):
261
+ """Scans requirements.txt for outdated known flags."""
262
+ lines = content.splitlines()
263
+ for idx, line in enumerate(lines):
264
+ line = line.strip().lower()
265
+ if line.startswith("django<2") or line.startswith("django==") and "1." in line:
266
+ vulnerabilities.append({
267
+ "type": "Vulnerable Dependency",
268
+ "severity": "critical",
269
+ "file": f"{relative}:{idx + 1}",
270
+ "detail": "Extremely outdated Django version detected."
271
+ })
@@ -0,0 +1,91 @@
1
+ """Structure Analyzer — builds a file tree and detects module boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .language_detector import IGNORE_DIRS
8
+
9
+
10
+ class StructureAnalyzer:
11
+ """Analyzes the directory structure of a repository."""
12
+
13
+ def analyze(self, repo_path: Path) -> dict:
14
+ """Build structural analysis of the repository.
15
+
16
+ Returns:
17
+ Dict with file tree, depth stats, module boundaries, and top-level layout.
18
+ """
19
+ tree = {}
20
+ modules = []
21
+ max_depth = 0
22
+ total_depth = 0
23
+ depth_count = 0
24
+ total_dirs = 0
25
+ total_files = 0
26
+
27
+ stack = [(repo_path, tree, 0)]
28
+ while stack:
29
+ current_path, current_dict, current_depth = stack.pop()
30
+
31
+ try:
32
+ items = sorted(current_path.iterdir(), key=lambda p: (p.is_file(), p.name))
33
+ except PermissionError:
34
+ continue
35
+
36
+ dirs_to_process = []
37
+ found_markers = []
38
+ current_dir_file_count = 0
39
+ for item in items:
40
+ if item.is_file():
41
+ current_dir_file_count += 1
42
+
43
+ if item.name in IGNORE_DIRS or item.name.startswith("."):
44
+ continue
45
+
46
+ if item.is_dir():
47
+ total_dirs += 1
48
+
49
+ if current_depth < 3 and current_dict is not None:
50
+ new_dict = {}
51
+ current_dict[f"📁 {item.name}/"] = new_dict
52
+ dirs_to_process.append((item, new_dict, current_depth + 1))
53
+ else:
54
+ if current_dict is not None and "..." not in current_dict:
55
+ current_dict["..."] = None
56
+ dirs_to_process.append((item, None, current_depth + 1))
57
+ else:
58
+ total_files += 1
59
+ if current_depth < 3 and current_dict is not None:
60
+ current_dict[f"📄 {item.name}"] = None
61
+
62
+ if item.suffix in (".py", ".js", ".ts", ".go", ".rs", ".java"):
63
+ max_depth = max(max_depth, current_depth)
64
+ total_depth += current_depth
65
+ depth_count += 1
66
+
67
+ if item.name in ("__init__.py", "package.json", "go.mod", "Cargo.toml", "build.gradle"):
68
+ found_markers.append(item.name)
69
+
70
+ if found_markers:
71
+ try:
72
+ module_path = str(current_path.relative_to(repo_path))
73
+ for marker in found_markers:
74
+ modules.append({
75
+ "path": module_path,
76
+ "marker": marker,
77
+ "file_count": current_dir_file_count,
78
+ })
79
+ except ValueError:
80
+ pass
81
+
82
+ stack.extend(reversed(dirs_to_process))
83
+
84
+ return {
85
+ "tree": tree,
86
+ "modules": modules,
87
+ "max_depth": max_depth,
88
+ "avg_depth": round(total_depth / depth_count, 1) if depth_count > 0 else 0,
89
+ "total_dirs": total_dirs,
90
+ "total_files": total_files,
91
+ }
codedna/ci/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CodeDNA CI — continuous integration & gatekeeper utilities."""
2
+
3
+ from .gatekeeper import Gatekeeper, GatekeeperResult
4
+
5
+ __all__ = ["Gatekeeper", "GatekeeperResult"]