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.
Files changed (118) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/__main__.py +7 -0
  3. codegraph/agent_brain.py +253 -2
  4. codegraph/agent_capabilities.py +1348 -67
  5. codegraph/agent_rules.py +2 -1
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture_rules.py +123 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/change_impact.py +320 -110
  22. codegraph/changelog.py +170 -0
  23. codegraph/cli.py +1605 -62
  24. codegraph/cli_output.py +17 -2
  25. codegraph/context.py +135 -24
  26. codegraph/context_freshness.py +2 -0
  27. codegraph/database/__init__.py +14 -0
  28. codegraph/database/extractor.py +712 -17
  29. codegraph/database/interrogation.py +388 -2
  30. codegraph/database/schema_drift.py +862 -0
  31. codegraph/dataflow_propagation.py +118 -0
  32. codegraph/dead_code.py +387 -0
  33. codegraph/epistemic.py +15 -0
  34. codegraph/evidence_contract.py +34 -3
  35. codegraph/federation.py +245 -0
  36. codegraph/frameworks.py +234 -12
  37. codegraph/frameworks_nestjs.py +502 -0
  38. codegraph/frameworks_nextjs.py +337 -0
  39. codegraph/frameworks_react.py +457 -0
  40. codegraph/freshness.py +2 -0
  41. codegraph/frontend_html.py +109 -0
  42. codegraph/git.py +2 -0
  43. codegraph/git_state.py +2 -0
  44. codegraph/graph/traversal.py +150 -7
  45. codegraph/graph_csr.py +218 -0
  46. codegraph/incremental.py +150 -0
  47. codegraph/indexing/__init__.py +7 -1
  48. codegraph/indexing/classifier.py +5 -0
  49. codegraph/indexing/indexer.py +292 -38
  50. codegraph/indexing/languages/__init__.py +1 -0
  51. codegraph/indexing/languages/cpp.py +457 -0
  52. codegraph/indexing/languages/go.py +459 -0
  53. codegraph/indexing/languages/jvm.py +583 -0
  54. codegraph/indexing/languages/rust.py +507 -0
  55. codegraph/indexing/models.py +15 -1
  56. codegraph/indexing/parser.py +385 -9
  57. codegraph/indexing/pool.py +136 -0
  58. codegraph/indexing/protocols.py +220 -0
  59. codegraph/indexing/scanner.py +23 -0
  60. codegraph/indexing/templates.py +418 -0
  61. codegraph/interrogation.py +207 -39
  62. codegraph/lsp.py +318 -0
  63. codegraph/lsp_bridge/__init__.py +16 -0
  64. codegraph/lsp_bridge/bridge.py +102 -0
  65. codegraph/lsp_bridge/client.py +207 -0
  66. codegraph/lsp_bridge/models.py +71 -0
  67. codegraph/mcp/server.py +502 -29
  68. codegraph/memory/trajectory.py +282 -0
  69. codegraph/models.py +7 -0
  70. codegraph/monorepo.py +3 -0
  71. codegraph/multi_agent/__init__.py +13 -0
  72. codegraph/multi_agent/coordinator.py +229 -0
  73. codegraph/multi_agent/models.py +76 -0
  74. codegraph/optimizer.py +42 -5
  75. codegraph/polymorphism.py +95 -0
  76. codegraph/pr_review.py +296 -0
  77. codegraph/process_lifecycle.py +50 -12
  78. codegraph/reachability.py +198 -0
  79. codegraph/reachability_index.py +74 -0
  80. codegraph/refactor/__init__.py +48 -0
  81. codegraph/refactor/models.py +114 -0
  82. codegraph/refactor/patcher.py +348 -0
  83. codegraph/refactor/renamer.py +844 -0
  84. codegraph/refactor/transactions.py +414 -0
  85. codegraph/repair.py +233 -0
  86. codegraph/resolver.py +1357 -161
  87. codegraph/retrieval_policy.py +22 -1
  88. codegraph/roblox.py +2174 -0
  89. codegraph/route_composer.py +7 -0
  90. codegraph/route_topology.py +172 -0
  91. codegraph/runtime/__init__.py +25 -0
  92. codegraph/runtime/collector.py +155 -0
  93. codegraph/runtime/db_watcher.py +191 -0
  94. codegraph/runtime/ingestor.py +30 -10
  95. codegraph/runtime/otel_receiver.py +208 -0
  96. codegraph/runtime/port_inspector.py +304 -0
  97. codegraph/runtime/runner.py +155 -6
  98. codegraph/runtime/trace_linker.py +175 -0
  99. codegraph/sandbox.py +215 -0
  100. codegraph/search/hybrid.py +26 -0
  101. codegraph/security/supply_chain.py +188 -0
  102. codegraph/security/taint.py +421 -0
  103. codegraph/semantic_decorators.py +34 -3
  104. codegraph/structural_diff.py +2 -0
  105. codegraph/styles.py +144 -0
  106. codegraph/symbol_history.py +2 -0
  107. codegraph/test_gen.py +437 -0
  108. codegraph/tsconfig.py +236 -0
  109. codegraph/ui/__init__.py +6 -0
  110. codegraph/ui/server.py +876 -0
  111. codegraph/watcher.py +323 -0
  112. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/METADATA +365 -67
  113. codegraph_engine-3.0.1.dist-info/RECORD +164 -0
  114. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/WHEEL +1 -1
  115. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  116. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/entry_points.txt +0 -0
  117. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/licenses/LICENSE +0 -0
  118. {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
+ )
@@ -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
@@ -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