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,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
|
+
}
|