codegraph-engine 2.3.0__py3-none-any.whl → 3.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +1 -1
- codegraph/__main__.py +7 -0
- codegraph/agent_brain.py +253 -2
- codegraph/agent_capabilities.py +1348 -67
- codegraph/agent_rules.py +2 -1
- codegraph/api_drift.py +455 -0
- codegraph/architecture_rules.py +123 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/change_impact.py +320 -110
- codegraph/changelog.py +170 -0
- codegraph/cli.py +1605 -62
- codegraph/cli_output.py +17 -2
- codegraph/context.py +135 -24
- codegraph/context_freshness.py +2 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/dataflow_propagation.py +118 -0
- codegraph/dead_code.py +387 -0
- codegraph/epistemic.py +15 -0
- codegraph/evidence_contract.py +34 -3
- codegraph/federation.py +245 -0
- codegraph/frameworks.py +234 -12
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +2 -0
- codegraph/graph/traversal.py +150 -7
- codegraph/graph_csr.py +218 -0
- codegraph/incremental.py +150 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/classifier.py +5 -0
- codegraph/indexing/indexer.py +292 -38
- codegraph/indexing/languages/__init__.py +1 -0
- codegraph/indexing/languages/cpp.py +457 -0
- codegraph/indexing/languages/go.py +459 -0
- codegraph/indexing/languages/jvm.py +583 -0
- codegraph/indexing/languages/rust.py +507 -0
- codegraph/indexing/models.py +15 -1
- codegraph/indexing/parser.py +385 -9
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/protocols.py +220 -0
- codegraph/indexing/scanner.py +23 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +207 -39
- codegraph/lsp.py +318 -0
- codegraph/lsp_bridge/__init__.py +16 -0
- codegraph/lsp_bridge/bridge.py +102 -0
- codegraph/lsp_bridge/client.py +207 -0
- codegraph/lsp_bridge/models.py +71 -0
- codegraph/mcp/server.py +502 -29
- codegraph/memory/trajectory.py +282 -0
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/multi_agent/__init__.py +13 -0
- codegraph/multi_agent/coordinator.py +229 -0
- codegraph/multi_agent/models.py +76 -0
- codegraph/optimizer.py +42 -5
- codegraph/polymorphism.py +95 -0
- codegraph/pr_review.py +296 -0
- codegraph/process_lifecycle.py +50 -12
- codegraph/reachability.py +198 -0
- codegraph/reachability_index.py +74 -0
- codegraph/refactor/__init__.py +48 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/patcher.py +348 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/repair.py +233 -0
- codegraph/resolver.py +1357 -161
- codegraph/retrieval_policy.py +22 -1
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +7 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +25 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/otel_receiver.py +208 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/sandbox.py +215 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/security/supply_chain.py +188 -0
- codegraph/security/taint.py +421 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +2 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +2 -0
- codegraph/test_gen.py +437 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +876 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/METADATA +365 -67
- codegraph_engine-3.0.1.dist-info/RECORD +164 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/WHEEL +1 -1
- codegraph_engine-2.3.0.dist-info/RECORD +0 -94
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Dependency Supply Chain & CVE Reachability Blast-Radius Engine for CodeGraph.
|
|
2
|
+
|
|
3
|
+
Maps third-party manifest dependencies to actual internal call sites:
|
|
4
|
+
- Inspects package.json, go.mod, Cargo.toml, requirements.txt, pyproject.toml, pom.xml
|
|
5
|
+
- Discovers which third-party packages are actually imported and invoked in code
|
|
6
|
+
- Determines whether a vulnerable dependency (CVE) is actively reachable via code execution paths
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DependencyCallSite:
|
|
20
|
+
"""An internal call site invoking a third-party package function."""
|
|
21
|
+
|
|
22
|
+
package_name: str
|
|
23
|
+
callee: str
|
|
24
|
+
source_file: str
|
|
25
|
+
line: int
|
|
26
|
+
|
|
27
|
+
def as_dict(self) -> dict[str, Any]:
|
|
28
|
+
return asdict(self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class DependencyPackageInfo:
|
|
33
|
+
"""A discovered third-party dependency package."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
version_spec: str
|
|
37
|
+
manifest_file: str
|
|
38
|
+
is_actively_imported: bool
|
|
39
|
+
call_sites_count: int
|
|
40
|
+
|
|
41
|
+
def as_dict(self) -> dict[str, Any]:
|
|
42
|
+
return asdict(self)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SupplyChainReport:
|
|
47
|
+
"""Consolidated supply chain reachability report."""
|
|
48
|
+
|
|
49
|
+
workspace: str
|
|
50
|
+
manifests_found: list[str] = field(default_factory=list)
|
|
51
|
+
total_dependencies: int = 0
|
|
52
|
+
actively_imported_count: int = 0
|
|
53
|
+
total_call_sites: int = 0
|
|
54
|
+
packages: list[DependencyPackageInfo] = field(default_factory=list)
|
|
55
|
+
active_call_sites: list[DependencyCallSite] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"workspace": self.workspace,
|
|
60
|
+
"manifests_found": self.manifests_found,
|
|
61
|
+
"total_dependencies": self.total_dependencies,
|
|
62
|
+
"actively_imported_count": self.actively_imported_count,
|
|
63
|
+
"total_call_sites": self.total_call_sites,
|
|
64
|
+
"packages": [p.as_dict() for p in self.packages],
|
|
65
|
+
"active_call_sites": [c.as_dict() for c in self.active_call_sites[:100]],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def analyze_supply_chain(
|
|
70
|
+
repository: Path,
|
|
71
|
+
con: sqlite3.Connection | None = None,
|
|
72
|
+
target_package: str | None = None,
|
|
73
|
+
) -> SupplyChainReport:
|
|
74
|
+
"""Extract dependencies from manifests and verify call site reachability in graph."""
|
|
75
|
+
repo = repository.resolve()
|
|
76
|
+
manifests: list[str] = []
|
|
77
|
+
dep_packages: dict[str, tuple[str, str]] = {} # name -> (version, manifest)
|
|
78
|
+
|
|
79
|
+
# 1. package.json
|
|
80
|
+
pkg_json = repo / "package.json"
|
|
81
|
+
if pkg_json.is_file():
|
|
82
|
+
manifests.append("package.json")
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(pkg_json.read_text(encoding="utf-8"))
|
|
85
|
+
for name, ver in data.get("dependencies", {}).items():
|
|
86
|
+
dep_packages[name] = (str(ver), "package.json")
|
|
87
|
+
for name, ver in data.get("devDependencies", {}).items():
|
|
88
|
+
dep_packages[name] = (str(ver), "package.json (dev)")
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# 2. requirements.txt
|
|
93
|
+
req_txt = repo / "requirements.txt"
|
|
94
|
+
if req_txt.is_file():
|
|
95
|
+
manifests.append("requirements.txt")
|
|
96
|
+
try:
|
|
97
|
+
for line in req_txt.read_text(encoding="utf-8").splitlines():
|
|
98
|
+
clean = line.strip().split("#")[0].strip()
|
|
99
|
+
if clean:
|
|
100
|
+
parts = re.split(r"[><=~]", clean, maxsplit=1)
|
|
101
|
+
pname = parts[0].strip()
|
|
102
|
+
pver = parts[1].strip() if len(parts) > 1 else "*"
|
|
103
|
+
if pname:
|
|
104
|
+
dep_packages[pname] = (pver, "requirements.txt")
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# 3. go.mod
|
|
109
|
+
go_mod = repo / "go.mod"
|
|
110
|
+
if go_mod.is_file():
|
|
111
|
+
manifests.append("go.mod")
|
|
112
|
+
try:
|
|
113
|
+
for line in go_mod.read_text(encoding="utf-8").splitlines():
|
|
114
|
+
m = re.match(r"^\s*([A-Za-z0-9_.\-\/]+)\s+v([A-Za-z0-9_.\-]+)", line)
|
|
115
|
+
if m:
|
|
116
|
+
dep_packages[m.group(1)] = (f"v{m.group(2)}", "go.mod")
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# 4. Cargo.toml
|
|
121
|
+
cargo_toml = repo / "Cargo.toml"
|
|
122
|
+
if cargo_toml.is_file():
|
|
123
|
+
manifests.append("Cargo.toml")
|
|
124
|
+
try:
|
|
125
|
+
in_deps = False
|
|
126
|
+
for line in cargo_toml.read_text(encoding="utf-8").splitlines():
|
|
127
|
+
if line.strip().startswith("[dependencies"):
|
|
128
|
+
in_deps = True
|
|
129
|
+
continue
|
|
130
|
+
elif line.strip().startswith("["):
|
|
131
|
+
in_deps = False
|
|
132
|
+
elif in_deps and "=" in line:
|
|
133
|
+
pname = line.split("=")[0].strip()
|
|
134
|
+
pver = line.split("=")[1].strip().strip('"\'')
|
|
135
|
+
dep_packages[pname] = (pver, "Cargo.toml")
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# 5. Check imports and call sites in database
|
|
140
|
+
call_sites: list[DependencyCallSite] = []
|
|
141
|
+
imported_set: set[str] = set()
|
|
142
|
+
|
|
143
|
+
if con is not None:
|
|
144
|
+
try:
|
|
145
|
+
cur = con.cursor()
|
|
146
|
+
cur.execute("SELECT module, name, source_file, line FROM imports;")
|
|
147
|
+
for mod, name, s_file, s_line in cur.fetchall():
|
|
148
|
+
mod_str = str(mod).lower()
|
|
149
|
+
for dep in dep_packages:
|
|
150
|
+
dep_lower = dep.lower()
|
|
151
|
+
if dep_lower == mod_str or mod_str.startswith(f"{dep_lower}.") or dep_lower in mod_str:
|
|
152
|
+
imported_set.add(dep)
|
|
153
|
+
call_sites.append(
|
|
154
|
+
DependencyCallSite(
|
|
155
|
+
package_name=dep,
|
|
156
|
+
callee=str(name or mod),
|
|
157
|
+
source_file=str(s_file),
|
|
158
|
+
line=int(s_line),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
packages_list: list[DependencyPackageInfo] = []
|
|
165
|
+
for name, (ver, manifest) in dep_packages.items():
|
|
166
|
+
if target_package and target_package.lower() not in name.lower():
|
|
167
|
+
continue
|
|
168
|
+
is_imported = name in imported_set
|
|
169
|
+
matching_calls = sum(1 for c in call_sites if c.package_name == name)
|
|
170
|
+
packages_list.append(
|
|
171
|
+
DependencyPackageInfo(
|
|
172
|
+
name=name,
|
|
173
|
+
version_spec=ver,
|
|
174
|
+
manifest_file=manifest,
|
|
175
|
+
is_actively_imported=is_imported,
|
|
176
|
+
call_sites_count=matching_calls,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return SupplyChainReport(
|
|
181
|
+
workspace=str(repo),
|
|
182
|
+
manifests_found=manifests,
|
|
183
|
+
total_dependencies=len(packages_list),
|
|
184
|
+
actively_imported_count=len([p for p in packages_list if p.is_actively_imported]),
|
|
185
|
+
total_call_sites=len(call_sites),
|
|
186
|
+
packages=packages_list,
|
|
187
|
+
active_call_sites=call_sites,
|
|
188
|
+
)
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Production-Grade Dataflow Taint-Tracking & SAST Security Engine for CodeGraph.
|
|
2
|
+
|
|
3
|
+
Traces untrusted user input sources (HTTP query params, headers, request bodies, CLI args)
|
|
4
|
+
through lexical scopes, variable assignments, and binary/f-string expressions into high-risk
|
|
5
|
+
execution sinks (CWE-89 SQL Injection, CWE-78 Command Injection, CWE-22 Path Traversal,
|
|
6
|
+
CWE-918 SSRF, and CWE-502 Insecure Deserialization).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaintSeverity(StrEnum):
|
|
20
|
+
CRITICAL = "CRITICAL"
|
|
21
|
+
HIGH = "HIGH"
|
|
22
|
+
MEDIUM = "MEDIUM"
|
|
23
|
+
LOW = "LOW"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TaintVulnerability:
|
|
28
|
+
cwe_id: str
|
|
29
|
+
vulnerability_type: str
|
|
30
|
+
severity: TaintSeverity
|
|
31
|
+
source_expr: str
|
|
32
|
+
source_line: int
|
|
33
|
+
sink_expr: str
|
|
34
|
+
sink_line: int
|
|
35
|
+
file_path: str
|
|
36
|
+
trace: list[str] = field(default_factory=list)
|
|
37
|
+
remediation: str = ""
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"cwe_id": self.cwe_id,
|
|
42
|
+
"vulnerability_type": self.vulnerability_type,
|
|
43
|
+
"severity": self.severity.value,
|
|
44
|
+
"source_expr": self.source_expr,
|
|
45
|
+
"source_line": self.source_line,
|
|
46
|
+
"sink_expr": self.sink_expr,
|
|
47
|
+
"sink_line": self.sink_line,
|
|
48
|
+
"file_path": self.file_path,
|
|
49
|
+
"trace": self.trace,
|
|
50
|
+
"remediation": self.remediation,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TaintReport:
|
|
56
|
+
repository: str
|
|
57
|
+
vulnerabilities: list[TaintVulnerability] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def total_vulnerabilities(self) -> int:
|
|
61
|
+
return len(self.vulnerabilities)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def critical_count(self) -> int:
|
|
65
|
+
return sum(1 for v in self.vulnerabilities if v.severity == TaintSeverity.CRITICAL)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def high_count(self) -> int:
|
|
69
|
+
return sum(1 for v in self.vulnerabilities if v.severity == TaintSeverity.HIGH)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def medium_count(self) -> int:
|
|
73
|
+
return sum(1 for v in self.vulnerabilities if v.severity == TaintSeverity.MEDIUM)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def low_count(self) -> int:
|
|
77
|
+
return sum(1 for v in self.vulnerabilities if v.severity == TaintSeverity.LOW)
|
|
78
|
+
|
|
79
|
+
def as_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"repository": self.repository,
|
|
82
|
+
"total_vulnerabilities": self.total_vulnerabilities,
|
|
83
|
+
"critical_count": self.critical_count,
|
|
84
|
+
"high_count": self.high_count,
|
|
85
|
+
"medium_count": self.medium_count,
|
|
86
|
+
"low_count": self.low_count,
|
|
87
|
+
"vulnerabilities": [v.as_dict() for v in self.vulnerabilities],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def to_sarif(self) -> dict[str, Any]:
|
|
91
|
+
"""Export findings to OASIS SARIF 2.1.0 JSON format for GitHub Security integration."""
|
|
92
|
+
rule_map: dict[str, dict[str, Any]] = {}
|
|
93
|
+
results: list[dict[str, Any]] = []
|
|
94
|
+
|
|
95
|
+
level_map = {
|
|
96
|
+
TaintSeverity.CRITICAL: "error",
|
|
97
|
+
TaintSeverity.HIGH: "error",
|
|
98
|
+
TaintSeverity.MEDIUM: "warning",
|
|
99
|
+
TaintSeverity.LOW: "note",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for v in self.vulnerabilities:
|
|
103
|
+
if v.cwe_id not in rule_map:
|
|
104
|
+
cwe_num = v.cwe_id.replace("CWE-", "")
|
|
105
|
+
rule_map[v.cwe_id] = {
|
|
106
|
+
"id": v.cwe_id,
|
|
107
|
+
"name": v.vulnerability_type.replace(" ", ""),
|
|
108
|
+
"shortDescription": {"text": v.vulnerability_type},
|
|
109
|
+
"fullDescription": {"text": f"Unsanitized dataflow taint reaches execution sink ({v.cwe_id})."},
|
|
110
|
+
"defaultConfiguration": {"level": level_map.get(v.severity, "warning")},
|
|
111
|
+
"help": {
|
|
112
|
+
"text": v.remediation,
|
|
113
|
+
"markdown": f"### Remediation\n\n{v.remediation}\n\n**CWE**: [{v.cwe_id}](https://cwe.mitre.org/data/definitions/{cwe_num}.html)",
|
|
114
|
+
},
|
|
115
|
+
"properties": {
|
|
116
|
+
"tags": ["security", "sast", "taint-analysis", v.cwe_id.lower()],
|
|
117
|
+
"problem.severity": "error" if v.severity in (TaintSeverity.CRITICAL, TaintSeverity.HIGH) else "warning",
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
thread_flow_locs = [
|
|
122
|
+
{
|
|
123
|
+
"location": {
|
|
124
|
+
"message": {"text": f"Taint Source: {v.source_expr}"},
|
|
125
|
+
"physicalLocation": {
|
|
126
|
+
"artifactLocation": {"uri": v.file_path},
|
|
127
|
+
"region": {"startLine": v.source_line, "startColumn": 1},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"location": {
|
|
133
|
+
"message": {"text": f"Vulnerable Sink: {v.sink_expr}"},
|
|
134
|
+
"physicalLocation": {
|
|
135
|
+
"artifactLocation": {"uri": v.file_path},
|
|
136
|
+
"region": {"startLine": v.sink_line, "startColumn": 1},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
results.append({
|
|
143
|
+
"ruleId": v.cwe_id,
|
|
144
|
+
"level": level_map.get(v.severity, "warning"),
|
|
145
|
+
"message": {
|
|
146
|
+
"text": f"{v.vulnerability_type}: User input from '{v.source_expr}' flows unsanitized into sink '{v.sink_expr}'."
|
|
147
|
+
},
|
|
148
|
+
"locations": [
|
|
149
|
+
{
|
|
150
|
+
"physicalLocation": {
|
|
151
|
+
"artifactLocation": {"uri": v.file_path},
|
|
152
|
+
"region": {"startLine": v.sink_line, "startColumn": 1},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
"codeFlows": [
|
|
157
|
+
{
|
|
158
|
+
"threadFlows": [
|
|
159
|
+
{"locations": thread_flow_locs}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
167
|
+
"version": "2.1.0",
|
|
168
|
+
"runs": [
|
|
169
|
+
{
|
|
170
|
+
"tool": {
|
|
171
|
+
"driver": {
|
|
172
|
+
"name": "CodeGraph SAST",
|
|
173
|
+
"semanticVersion": "2.1.7",
|
|
174
|
+
"informationUri": "https://github.com/codegraph/codegraph",
|
|
175
|
+
"rules": list(rule_map.values()),
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"results": results,
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_SOURCE_PATTERNS = [
|
|
185
|
+
# Python Flask / Django / FastAPI
|
|
186
|
+
r"request\.(?:args|form|values|json|data|GET|POST|query_params|headers)\b",
|
|
187
|
+
r"req\.(?:query|body|params|headers)\b",
|
|
188
|
+
# Go Gin / Echo
|
|
189
|
+
r"c\.(?:Query|PostForm|Param|BindJSON)\(",
|
|
190
|
+
# CLI / Env
|
|
191
|
+
r"sys\.argv\[",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
_SOURCE_RE = re.compile("|".join(_SOURCE_PATTERNS))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _PythonTaintVisitor(ast.NodeVisitor):
|
|
198
|
+
"""AST visitor tracking taint propagation from sources to sinks."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, file_path: str, lines: list[str]) -> None:
|
|
201
|
+
self.file_path = file_path
|
|
202
|
+
self.lines = lines
|
|
203
|
+
# Tainted variable name -> list of propagation steps
|
|
204
|
+
self.tainted_vars: dict[str, list[str]] = {}
|
|
205
|
+
# Variable name -> source line
|
|
206
|
+
self.taint_source_lines: dict[str, int] = {}
|
|
207
|
+
# Variable name -> source expression
|
|
208
|
+
self.taint_source_exprs: dict[str, str] = {}
|
|
209
|
+
self.findings: list[TaintVulnerability] = []
|
|
210
|
+
|
|
211
|
+
def _is_tainted_expr(self, node: ast.AST) -> tuple[bool, str, list[str]]:
|
|
212
|
+
"""Check if an expression is tainted or references a tainted variable."""
|
|
213
|
+
# 1. Direct Source Match in unparsed expression
|
|
214
|
+
raw = ast.unparse(node) if hasattr(ast, "unparse") else ""
|
|
215
|
+
if _SOURCE_RE.search(raw):
|
|
216
|
+
return True, raw, [f"Taint source instantiated: {raw}"]
|
|
217
|
+
|
|
218
|
+
# 2. Variable Identifier
|
|
219
|
+
if isinstance(node, ast.Name):
|
|
220
|
+
if node.id in self.tainted_vars:
|
|
221
|
+
return True, self.taint_source_exprs.get(node.id, node.id), list(self.tainted_vars[node.id])
|
|
222
|
+
|
|
223
|
+
# 3. Binary Operation (e.g. "SELECT ... " + user_input)
|
|
224
|
+
if isinstance(node, ast.BinOp):
|
|
225
|
+
left_tainted, left_src, left_trace = self._is_tainted_expr(node.left)
|
|
226
|
+
if left_tainted:
|
|
227
|
+
return True, left_src, left_trace + ["Propagated via binary operation (+)"]
|
|
228
|
+
right_tainted, right_src, right_trace = self._is_tainted_expr(node.right)
|
|
229
|
+
if right_tainted:
|
|
230
|
+
return True, right_src, right_trace + ["Propagated via binary operation (+)"]
|
|
231
|
+
|
|
232
|
+
# 4. Formatted Value / F-String (e.g. f"SELECT ... {user_input}")
|
|
233
|
+
if isinstance(node, ast.JoinedStr):
|
|
234
|
+
for part in node.values:
|
|
235
|
+
if isinstance(part, ast.FormattedValue):
|
|
236
|
+
is_t, src, tr = self._is_tainted_expr(part.value)
|
|
237
|
+
if is_t:
|
|
238
|
+
return True, src, tr + ["Propagated via f-string formatting"]
|
|
239
|
+
|
|
240
|
+
# 5. Method call (e.g. "...".format(user_input))
|
|
241
|
+
if isinstance(node, ast.Call):
|
|
242
|
+
for arg in node.args:
|
|
243
|
+
is_t, src, tr = self._is_tainted_expr(arg)
|
|
244
|
+
if is_t:
|
|
245
|
+
return True, src, tr + [f"Propagated through argument into {ast.unparse(node.func) if hasattr(ast, 'unparse') else 'call'}"]
|
|
246
|
+
|
|
247
|
+
return False, "", []
|
|
248
|
+
|
|
249
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
250
|
+
is_t, src, tr = self._is_tainted_expr(node.value)
|
|
251
|
+
if is_t:
|
|
252
|
+
for target in node.targets:
|
|
253
|
+
if isinstance(target, ast.Name):
|
|
254
|
+
var_name = target.id
|
|
255
|
+
self.tainted_vars[var_name] = tr + [f"Assigned to variable '{var_name}' on line {node.lineno}"]
|
|
256
|
+
self.taint_source_lines[var_name] = node.lineno
|
|
257
|
+
self.taint_source_exprs[var_name] = src
|
|
258
|
+
self.generic_visit(node)
|
|
259
|
+
|
|
260
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
261
|
+
call_str = ast.unparse(node.func) if hasattr(ast, "unparse") else ""
|
|
262
|
+
|
|
263
|
+
# SINK 1: SQL Injection (cursor.execute, con.execute, db.query with tainted string)
|
|
264
|
+
if any(sink in call_str for sink in ("execute", "raw", "query", "select_raw")):
|
|
265
|
+
if node.args:
|
|
266
|
+
arg0 = node.args[0]
|
|
267
|
+
is_t, src, tr = self._is_tainted_expr(arg0)
|
|
268
|
+
if is_t:
|
|
269
|
+
self.findings.append(
|
|
270
|
+
TaintVulnerability(
|
|
271
|
+
cwe_id="CWE-89",
|
|
272
|
+
vulnerability_type="SQL_INJECTION",
|
|
273
|
+
severity=TaintSeverity.CRITICAL,
|
|
274
|
+
source_expr=src,
|
|
275
|
+
source_line=self.taint_source_lines.get(src, node.lineno),
|
|
276
|
+
sink_expr=ast.unparse(node) if hasattr(ast, "unparse") else call_str,
|
|
277
|
+
sink_line=node.lineno,
|
|
278
|
+
file_path=self.file_path,
|
|
279
|
+
trace=tr + [f"Reached SQL execution sink '{call_str}' on line {node.lineno}"],
|
|
280
|
+
remediation="Use parameterized queries with bound parameters, e.g. cursor.execute('SELECT ... WHERE id = ?', (id,))",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# SINK 2: OS Command Injection (os.system, subprocess.run, subprocess.Popen)
|
|
285
|
+
if any(sink in call_str for sink in ("os.system", "subprocess.run", "subprocess.Popen", "subprocess.call", "os.popen")):
|
|
286
|
+
has_shell = any(k.arg == "shell" and isinstance(k.value, ast.Constant) and k.value.value is True for k in node.keywords)
|
|
287
|
+
is_os_system = "os.system" in call_str or "os.popen" in call_str
|
|
288
|
+
|
|
289
|
+
for arg in node.args:
|
|
290
|
+
is_t, src, tr = self._is_tainted_expr(arg)
|
|
291
|
+
if is_t and (has_shell or is_os_system):
|
|
292
|
+
self.findings.append(
|
|
293
|
+
TaintVulnerability(
|
|
294
|
+
cwe_id="CWE-78",
|
|
295
|
+
vulnerability_type="COMMAND_INJECTION",
|
|
296
|
+
severity=TaintSeverity.CRITICAL,
|
|
297
|
+
source_expr=src,
|
|
298
|
+
source_line=self.taint_source_lines.get(src, node.lineno),
|
|
299
|
+
sink_expr=ast.unparse(node) if hasattr(ast, "unparse") else call_str,
|
|
300
|
+
sink_line=node.lineno,
|
|
301
|
+
file_path=self.file_path,
|
|
302
|
+
trace=tr + [f"Reached OS command shell sink '{call_str}' on line {node.lineno}"],
|
|
303
|
+
remediation="Avoid shell=True. Pass command arguments as an array list, e.g. subprocess.run(['cmd', arg1, arg2])",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# SINK 3: Code Injection / Insecure Deserialization (eval, exec, pickle.loads)
|
|
308
|
+
if call_str in ("eval", "exec", "pickle.loads", "_pickle.loads"):
|
|
309
|
+
for arg in node.args:
|
|
310
|
+
is_t, src, tr = self._is_tainted_expr(arg)
|
|
311
|
+
if is_t:
|
|
312
|
+
cwe = "CWE-502" if "pickle" in call_str else "CWE-94"
|
|
313
|
+
self.findings.append(
|
|
314
|
+
TaintVulnerability(
|
|
315
|
+
cwe_id=cwe,
|
|
316
|
+
vulnerability_type="CODE_INJECTION" if cwe == "CWE-94" else "INSECURE_DESERIALIZATION",
|
|
317
|
+
severity=TaintSeverity.CRITICAL,
|
|
318
|
+
source_expr=src,
|
|
319
|
+
source_line=self.taint_source_lines.get(src, node.lineno),
|
|
320
|
+
sink_expr=ast.unparse(node) if hasattr(ast, "unparse") else call_str,
|
|
321
|
+
sink_line=node.lineno,
|
|
322
|
+
file_path=self.file_path,
|
|
323
|
+
trace=tr + [f"Reached dangerous dynamic evaluation sink '{call_str}' on line {node.lineno}"],
|
|
324
|
+
remediation="Do not pass untrusted user data to eval() or pickle.loads(). Use safe data formats like json.loads()",
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# SINK 4: Path Traversal (open, os.remove, os.unlink)
|
|
329
|
+
if call_str in ("open", "os.remove", "os.unlink", "shutil.rmtree"):
|
|
330
|
+
if node.args:
|
|
331
|
+
arg0 = node.args[0]
|
|
332
|
+
is_t, src, tr = self._is_tainted_expr(arg0)
|
|
333
|
+
if is_t:
|
|
334
|
+
self.findings.append(
|
|
335
|
+
TaintVulnerability(
|
|
336
|
+
cwe_id="CWE-22",
|
|
337
|
+
vulnerability_type="PATH_TRAVERSAL",
|
|
338
|
+
severity=TaintSeverity.HIGH,
|
|
339
|
+
source_expr=src,
|
|
340
|
+
source_line=self.taint_source_lines.get(src, node.lineno),
|
|
341
|
+
sink_expr=ast.unparse(node) if hasattr(ast, "unparse") else call_str,
|
|
342
|
+
sink_line=node.lineno,
|
|
343
|
+
file_path=self.file_path,
|
|
344
|
+
trace=tr + [f"Reached filesystem sink '{call_str}' without safe path normalization on line {node.lineno}"],
|
|
345
|
+
remediation="Validate paths using safe_path(base_dir, user_path) and reject traversal escapes (..) before accessing disk.",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# SINK 5: SSRF (requests.get, httpx.get, urllib.request.urlopen)
|
|
350
|
+
if any(sink in call_str for sink in ("requests.get", "requests.post", "httpx.get", "httpx.post", "urllib.request.urlopen")):
|
|
351
|
+
if node.args:
|
|
352
|
+
arg0 = node.args[0]
|
|
353
|
+
is_t, src, tr = self._is_tainted_expr(arg0)
|
|
354
|
+
if is_t:
|
|
355
|
+
self.findings.append(
|
|
356
|
+
TaintVulnerability(
|
|
357
|
+
cwe_id="CWE-918",
|
|
358
|
+
vulnerability_type="SSRF",
|
|
359
|
+
severity=TaintSeverity.HIGH,
|
|
360
|
+
source_expr=src,
|
|
361
|
+
source_line=self.taint_source_lines.get(src, node.lineno),
|
|
362
|
+
sink_expr=ast.unparse(node) if hasattr(ast, "unparse") else call_str,
|
|
363
|
+
sink_line=node.lineno,
|
|
364
|
+
file_path=self.file_path,
|
|
365
|
+
trace=tr + [f"Reached HTTP request sink '{call_str}' on line {node.lineno}"],
|
|
366
|
+
remediation="Validate target URL against an explicit domain allowlist and block private IP ranges (127.0.0.1, 10.0.0.0/8, 169.254.169.254).",
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
self.generic_visit(node)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def audit_file_taint(file_path: Path) -> list[TaintVulnerability]:
|
|
374
|
+
"""Audit a single source file for dataflow taint vulnerabilities."""
|
|
375
|
+
if not file_path.is_file():
|
|
376
|
+
return []
|
|
377
|
+
try:
|
|
378
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
379
|
+
lines = content.splitlines()
|
|
380
|
+
tree = ast.parse(content, filename=str(file_path))
|
|
381
|
+
visitor = _PythonTaintVisitor(str(file_path), lines)
|
|
382
|
+
visitor.visit(tree)
|
|
383
|
+
return visitor.findings
|
|
384
|
+
except Exception:
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def audit_repository_taint(
|
|
389
|
+
repository: Path,
|
|
390
|
+
con: sqlite3.Connection | None = None,
|
|
391
|
+
file_paths: list[str] | None = None,
|
|
392
|
+
entrypoint_symbol: str | None = None,
|
|
393
|
+
filter_cwe: str | None = None,
|
|
394
|
+
max_depth: int = 12,
|
|
395
|
+
) -> TaintReport:
|
|
396
|
+
"""Audit repository source files for cross-scope taint vulnerabilities and injection risks."""
|
|
397
|
+
repo = repository.resolve()
|
|
398
|
+
vulnerabilities: list[TaintVulnerability] = []
|
|
399
|
+
|
|
400
|
+
target_files: list[Path] = []
|
|
401
|
+
if file_paths:
|
|
402
|
+
for fp in file_paths:
|
|
403
|
+
p = (repo / fp).resolve()
|
|
404
|
+
if p.is_file() and p.suffix.lower() in (".py", ".pyi"):
|
|
405
|
+
target_files.append(p)
|
|
406
|
+
else:
|
|
407
|
+
for p in repo.rglob("*.py"):
|
|
408
|
+
if not any(part in p.parts for part in (".git", ".venv", "venv", "__pycache__", "dist", "build")):
|
|
409
|
+
target_files.append(p)
|
|
410
|
+
|
|
411
|
+
for p in target_files:
|
|
412
|
+
vulns = audit_file_taint(p)
|
|
413
|
+
if filter_cwe:
|
|
414
|
+
clean_cwe = filter_cwe.upper().strip()
|
|
415
|
+
vulns = [v for v in vulns if clean_cwe in v.cwe_id.upper()]
|
|
416
|
+
vulnerabilities.extend(vulns)
|
|
417
|
+
|
|
418
|
+
return TaintReport(
|
|
419
|
+
repository=str(repo),
|
|
420
|
+
vulnerabilities=vulnerabilities,
|
|
421
|
+
)
|
codegraph/semantic_decorators.py
CHANGED
|
@@ -66,6 +66,31 @@ _RECOGNIZED_DECORATOR_SPECS: tuple[SemanticDecoratorSpec, ...] = (
|
|
|
66
66
|
semantic_role="PROVIDES",
|
|
67
67
|
patterns=("provide", "di.provide", "provider", "singleton", "di.singleton"),
|
|
68
68
|
),
|
|
69
|
+
SemanticDecoratorSpec(
|
|
70
|
+
framework="langchain",
|
|
71
|
+
semantic_role="TOOL_HANDLER",
|
|
72
|
+
patterns=("tool", "langchain.tools.tool", "langchain_core.tools.tool", "agents.tool"),
|
|
73
|
+
),
|
|
74
|
+
SemanticDecoratorSpec(
|
|
75
|
+
framework="llama_index",
|
|
76
|
+
semantic_role="TOOL_HANDLER",
|
|
77
|
+
patterns=("function_tool", "llama_index.core.tools.function_tool"),
|
|
78
|
+
),
|
|
79
|
+
SemanticDecoratorSpec(
|
|
80
|
+
framework="ray",
|
|
81
|
+
semantic_role="TASK_HANDLER",
|
|
82
|
+
patterns=("ray.remote", "remote"),
|
|
83
|
+
),
|
|
84
|
+
SemanticDecoratorSpec(
|
|
85
|
+
framework="prefect",
|
|
86
|
+
semantic_role="TASK_HANDLER",
|
|
87
|
+
patterns=("prefect.task", "prefect.flow", "task", "flow"),
|
|
88
|
+
),
|
|
89
|
+
SemanticDecoratorSpec(
|
|
90
|
+
framework="zenml",
|
|
91
|
+
semantic_role="TASK_HANDLER",
|
|
92
|
+
patterns=("step", "pipeline"),
|
|
93
|
+
),
|
|
69
94
|
)
|
|
70
95
|
|
|
71
96
|
|
|
@@ -132,12 +157,12 @@ def analyze_symbol_decorators(
|
|
|
132
157
|
if "." in raw_expr:
|
|
133
158
|
base = raw_expr.split(".")[0]
|
|
134
159
|
# Verify base is imported or standard framework name
|
|
135
|
-
if base in import_map or base in ("app", "celery", "click", "django", "typer", "di", "inject"):
|
|
160
|
+
if base in import_map or base in ("app", "celery", "click", "django", "typer", "di", "inject", "ray", "langchain", "llama_index", "prefect", "zenml"):
|
|
136
161
|
grounded_name = raw_expr
|
|
137
162
|
matched_spec = spec
|
|
138
163
|
break
|
|
139
164
|
else:
|
|
140
|
-
# Bare name (e.g. shared_task, receiver, command, inject, provide, singleton)
|
|
165
|
+
# Bare name (e.g. shared_task, receiver, command, inject, provide, singleton, tool)
|
|
141
166
|
# Verify it was imported from the framework or known pattern
|
|
142
167
|
if raw_expr in import_map:
|
|
143
168
|
imp_mod, imp_orig = import_map[raw_expr]
|
|
@@ -146,7 +171,7 @@ def analyze_symbol_decorators(
|
|
|
146
171
|
grounded_name = f"{imp_mod}.{orig_or_raw}"
|
|
147
172
|
matched_spec = spec
|
|
148
173
|
break
|
|
149
|
-
elif raw_expr in ("inject", "provide", "singleton"):
|
|
174
|
+
elif raw_expr in ("inject", "provide", "singleton", "receiver", "tool", "function_tool", "remote", "step", "flow"):
|
|
150
175
|
grounded_name = raw_expr
|
|
151
176
|
matched_spec = spec
|
|
152
177
|
break
|
|
@@ -242,6 +267,12 @@ def analyze_symbol_decorators(
|
|
|
242
267
|
dyn_reason = "dynamic_event_name"
|
|
243
268
|
if event_names:
|
|
244
269
|
metadata["events"] = event_names
|
|
270
|
+
for kw in kw_nodes:
|
|
271
|
+
if kw.arg == "sender":
|
|
272
|
+
if isinstance(kw.value, (ast.Name, ast.Attribute)) and hasattr(ast, "unparse"):
|
|
273
|
+
metadata["sender"] = ast.unparse(kw.value)
|
|
274
|
+
elif isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
275
|
+
metadata["sender"] = kw.value.value
|
|
245
276
|
|
|
246
277
|
elif matched_spec.semantic_role == "COMMAND_HANDLER":
|
|
247
278
|
# Extract command name
|
codegraph/structural_diff.py
CHANGED
|
@@ -152,6 +152,8 @@ def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
|
|
|
152
152
|
cwd=str(repository),
|
|
153
153
|
capture_output=True,
|
|
154
154
|
text=True,
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
errors="replace",
|
|
155
157
|
timeout=timeout,
|
|
156
158
|
)
|
|
157
159
|
return res.stdout if res.returncode == 0 else None
|