Files
q3alique acc1b4f3e7 Initial release of codeflow
Static taint-analysis and visualization tool for source-code security review.
Supports deep analysis for Python, JavaScript/TypeScript, Java, Go, and C#,
with structural support for all other languages via the generic extractor.

Outputs: interactive HTML report, LLM-ready Markdown review document,
and optional Burp Suite JSON export. Self-bootstrapping launcher (run.py)
requires no virtual environment.
2026-06-08 00:55:14 +02:00

483 lines
21 KiB
Python

from __future__ import annotations
import re
from pathlib import Path
from codeflow.discovery.file_scanner import FileInfo
from codeflow.analysis.base_extractor import ExtractionResult
from codeflow.analysis.ast_parser import parse_file, query_tree, get_node_text
from codeflow.models.node import Node, NodeType, make_node_id, make_module_node_id
from codeflow.models.edge import Edge, EdgeType
# ── Sink catalogue ────────────────────────────────────────────────────────────
GO_SINK_PATTERNS: dict[str, dict[str, str]] = {
"command_execution": {
"exec.Command": r"exec\.Command\s*\(",
"exec.CommandContext": r"exec\.CommandContext\s*\(",
"os.StartProcess": r"os\.StartProcess\s*\(",
},
"database": {
"db.Query": r"\.Query\s*\(",
"db.QueryRow": r"\.QueryRow\s*\(",
"db.Exec": r"\.Exec\s*\(",
"db.QueryContext": r"\.QueryContext\s*\(",
"db.ExecContext": r"\.ExecContext\s*\(",
"db.QueryRowContext":r"\.QueryRowContext\s*\(",
},
"file_write": {
"os.WriteFile": r"os\.WriteFile\s*\(",
"ioutil.WriteFile": r"ioutil\.WriteFile\s*\(",
"os.Create": r"os\.Create\s*\(",
"os.OpenFile": r"os\.OpenFile\s*\(",
},
"file_read": {
"os.ReadFile": r"os\.ReadFile\s*\(",
"ioutil.ReadFile": r"ioutil\.ReadFile\s*\(",
"os.Open": r"os\.Open\s*\(",
},
"http_request": {
"http.Get": r"http\.Get\s*\(",
"http.Post": r"http\.Post\s*\(",
"http.Do": r"\.Do\s*\(",
},
"deserialization": {
"json.Unmarshal": r"json\.Unmarshal\s*\(",
"json.NewDecoder": r"json\.NewDecoder\s*\(",
"xml.Unmarshal": r"xml\.Unmarshal\s*\(",
"gob.Decode": r"\.Decode\s*\(",
"yaml.Unmarshal": r"yaml\.Unmarshal\s*\(",
},
"code_execution": {
"plugin.Open": r"plugin\.Open\s*\(",
},
"template_injection": {
"template.Execute": r"\.Execute\s*\(",
"template.ExecuteTemplate": r"\.ExecuteTemplate\s*\(",
},
}
_PRIORITY_ORDER = [
"code_execution", "command_execution", "database",
"template_injection", "deserialization",
"file_write", "file_read", "http_request",
]
def _detect_go_framework(source_text: str) -> str:
if "github.com/gin-gonic/gin" in source_text:
return "gin"
if "github.com/labstack/echo" in source_text:
return "echo"
if "github.com/go-chi/chi" in source_text:
return "chi"
if "github.com/gorilla/mux" in source_text:
return "gorilla_mux"
if "net/http" in source_text and (
"http.HandleFunc" in source_text
or "http.Handle" in source_text
or "http.ListenAndServe" in source_text
):
return "net_http"
return "unknown"
class GoExtractor:
def __init__(self):
self.file_stem: str = ""
self.file_path: str = ""
self.framework: str = "unknown"
self.source_bytes: bytes = b""
self.source_lines: list[str] = []
# ── Public entry ──────────────────────────────────────────────────────────
def extract(self, file_info: FileInfo) -> ExtractionResult:
result = ExtractionResult(file_path=file_info.path)
self.file_path = file_info.path
self.file_stem = Path(file_info.path).stem
try:
self.source_bytes = Path(file_info.path).read_bytes()
self.source_lines = (
Path(file_info.path)
.read_text(encoding="utf-8", errors="replace")
.splitlines()
)
except (OSError, IOError):
result.errors.append(f"Cannot read file: {file_info.path}")
return result
tree = parse_file(file_info.path, "go")
if tree is None:
result.errors.append(f"Could not parse: {file_info.path}")
return result
source_text = self.source_bytes.decode("utf-8", errors="replace")
self.framework = _detect_go_framework(source_text)
self._extract_module_node(result)
self._extract_functions(result, tree)
self._extract_entry_points(result, source_text)
self._extract_sources(result, source_text)
self._extract_sinks(result, source_text)
self._extract_env_vars(result, source_text)
self._extract_imports(result, source_text)
self._extract_calls(result, tree)
self._ensure_unique_ids(result)
return result
# ── Helpers ───────────────────────────────────────────────────────────────
def _ensure_unique_ids(self, result: ExtractionResult):
seen: set[str] = set()
deduped: list[Node] = []
for node in result.nodes:
if node.id not in seen:
seen.add(node.id)
deduped.append(node)
result.nodes = deduped
def _find_containing_function(self, line_num: int, result: ExtractionResult) -> str | None:
for n in result.nodes:
if n.node_type in (NodeType.FUNCTION, NodeType.ENTRY_POINT):
if n.line_start and n.line_end and n.line_start <= line_num <= n.line_end:
return n.id
return None
# ── Module node ───────────────────────────────────────────────────────────
def _extract_module_node(self, result: ExtractionResult):
node_id = make_module_node_id(self.file_stem)
result.nodes.append(Node(
id=node_id,
node_type=NodeType.MODULE,
label=Path(self.file_path).name,
file_path=self.file_path,
line_start=1,
line_end=len(self.source_lines),
metadata={"language": "go", "framework": self.framework, "imports": []},
))
# ── Functions & methods ───────────────────────────────────────────────────
def _extract_functions(self, result: ExtractionResult, tree):
module_id = make_module_node_id(self.file_stem)
# Regular top-level functions: func Foo(...)
func_query = """
(function_declaration
name: (identifier) @func_name) @func_decl
"""
for func_node, _ in query_tree(tree, "go", func_query, capture_filter="func_decl"):
name_child = func_node.child_by_field_name("name")
if not name_child:
continue
func_name = get_node_text(name_child, self.source_bytes)
if not func_name:
continue
node_id = make_node_id(self.file_stem, func_name)
result.nodes.append(Node(
id=node_id,
node_type=NodeType.FUNCTION,
label=func_name[:40],
file_path=self.file_path,
line_start=func_node.start_point[0] + 1,
line_end=func_node.end_point[0] + 1,
metadata={"language": "go"},
))
result.edges.append(Edge(
source_id=module_id, target_id=node_id, edge_type=EdgeType.CONTAINS
))
# Methods with receivers: func (h *Handler) Foo(...)
method_query = """
(method_declaration
name: (field_identifier) @method_name) @method_decl
"""
for method_node, _ in query_tree(tree, "go", method_query, capture_filter="method_decl"):
name_child = method_node.child_by_field_name("name")
if not name_child:
continue
method_name = get_node_text(name_child, self.source_bytes)
if not method_name:
continue
# Extract receiver type for qualified name: (h *Handler) → Handler
receiver_type = ""
receiver = method_node.child_by_field_name("receiver")
if receiver:
rec_text = get_node_text(receiver, self.source_bytes)
rec_m = re.search(r"\*?(\w+)", rec_text)
if rec_m:
receiver_type = rec_m.group(1)
qualified = f"{receiver_type}.{method_name}" if receiver_type else method_name
node_id = make_node_id(self.file_stem, qualified)
result.nodes.append(Node(
id=node_id,
node_type=NodeType.FUNCTION,
label=method_name[:40],
file_path=self.file_path,
line_start=method_node.start_point[0] + 1,
line_end=method_node.end_point[0] + 1,
metadata={
"qualified_name": qualified,
"receiver_type": receiver_type,
"language": "go",
},
))
result.edges.append(Edge(
source_id=module_id, target_id=node_id, edge_type=EdgeType.CONTAINS
))
# ── Entry points ──────────────────────────────────────────────────────────
def _extract_entry_points(self, result: ExtractionResult, source_text: str):
# Gin / Echo / Chi / gorilla-mux style: r.GET("/path", handlerFunc)
# Also matches: router.Get("/path", handler) for Chi (lowercase method)
route_re = re.compile(
r"\.(?P<method>GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD|"
r"Get|Post|Put|Delete|Patch|Options|Head)\s*\("
r'\s*"(?P<path>[^"]+)"\s*,\s*(?P<handler>\w+)',
re.MULTILINE,
)
for m in route_re.finditer(source_text):
http_method = m.group("method").upper()
route_path = m.group("path")
handler_name = m.group("handler")
self._promote_to_entry_point(result, handler_name, http_method, route_path)
# net/http: http.HandleFunc("/path", handlerFunc)
hf_re = re.compile(
r'http\.HandleFunc\s*\(\s*"(?P<path>[^"]+)"\s*,\s*(?P<handler>\w+)',
re.MULTILINE,
)
for m in hf_re.finditer(source_text):
route_path = m.group("path")
handler_name = m.group("handler")
self._promote_to_entry_point(result, handler_name, "HTTP", route_path)
# gorilla/mux: r.HandleFunc("/path", handlerFunc).Methods("GET")
gmux_re = re.compile(
r'\.HandleFunc\s*\(\s*"(?P<path>[^"]+)"\s*,\s*(?P<handler>\w+)',
re.MULTILINE,
)
for m in gmux_re.finditer(source_text):
route_path = m.group("path")
handler_name = m.group("handler")
self._promote_to_entry_point(result, handler_name, "HTTP", route_path)
def _promote_to_entry_point(
self, result: ExtractionResult, handler_name: str, http_method: str, route_path: str
):
func_id = make_node_id(self.file_stem, handler_name)
for n in result.nodes:
if n.id == func_id and n.node_type == NodeType.FUNCTION:
n.node_type = NodeType.ENTRY_POINT
n.is_entry_point = True
n.label = f"{http_method} {route_path}"
n.metadata.update({
"http_method": http_method,
"route_path": route_path,
"framework": self.framework,
})
return
# Also try by label (for methods with receivers)
for n in result.nodes:
if n.node_type == NodeType.FUNCTION and n.label == handler_name[:40]:
n.node_type = NodeType.ENTRY_POINT
n.is_entry_point = True
n.label = f"{http_method} {route_path}"
n.metadata.update({
"http_method": http_method,
"route_path": route_path,
"framework": self.framework,
})
return
# ── Sources ───────────────────────────────────────────────────────────────
def _extract_sources(self, result: ExtractionResult, source_text: str):
seen: set[str] = set()
all_patterns: list[tuple[str, str]] = [
# Gin
(r'[cr]\.Param\s*\(\s*"(?P<p>[^"]+)"\s*\)', "url_param"),
(r'[cr]\.Query\s*\(\s*"(?P<p>[^"]+)"\s*\)', "query_string"),
(r'[cr]\.PostForm\s*\(\s*"(?P<p>[^"]+)"\s*\)', "form_field"),
(r'[cr]\.GetHeader\s*\(\s*"(?P<p>[^"]+)"\s*\)', "header"),
(r'[cr]\.Cookie\s*\(\s*"(?P<p>[^"]+)"\s*\)', "cookie"),
(r'[cr]\.BindJSON\s*\(', "json_body"),
(r'[cr]\.ShouldBindJSON\s*\(', "json_body"),
(r'[cr]\.ShouldBind\s*\(', "json_body"),
# Echo
(r'c\.QueryParam\s*\(\s*"(?P<p>[^"]+)"\s*\)', "query_string"),
(r'c\.FormValue\s*\(\s*"(?P<p>[^"]+)"\s*\)', "form_field"),
# net/http
(r'r\.URL\.Query\(\)\.Get\s*\(\s*"(?P<p>[^"]+)"\s*\)', "query_string"),
(r'r\.FormValue\s*\(\s*"(?P<p>[^"]+)"\s*\)', "form_field"),
(r'r\.Header\.Get\s*\(\s*"(?P<p>[^"]+)"\s*\)', "header"),
(r'r\.Cookie\s*\(\s*"(?P<p>[^"]+)"\s*\)', "cookie"),
(r'r\.PathValue\s*\(\s*"(?P<p>[^"]+)"\s*\)', "url_param"), # Go 1.22+
(r'\br\.Body\b', "raw_body"),
# CLI
(r'\bos\.Args\b', "cli_arg"),
(r'flag\.\w+\s*\(\s*"(?P<p>[^"]+)"', "cli_arg"),
]
for pattern, input_type in all_patterns:
for m in re.finditer(pattern, source_text):
gd = m.groupdict()
param_name = gd.get("p") or f"*{input_type}*"
line_num = source_text[: m.start()].count("\n") + 1
containing_func = self._find_containing_function(line_num, result)
if not containing_func:
continue
func_part = containing_func.split("::")[-1]
source_id = make_node_id(self.file_stem, f"{func_part}::{param_name}")
if source_id in seen:
continue
seen.add(source_id)
result.nodes.append(Node(
id=source_id,
node_type=NodeType.SOURCE,
label=f"param: {param_name} [{input_type}]"[:40],
file_path=self.file_path,
line_start=line_num,
line_end=line_num,
metadata={
"param_name": param_name,
"input_type": input_type,
"framework": self.framework,
},
))
result.edges.append(Edge(
source_id=source_id,
target_id=containing_func,
edge_type=EdgeType.DATA_FLOW,
))
# ── Sinks ─────────────────────────────────────────────────────────────────
def _extract_sinks(self, result: ExtractionResult, source_text: str):
seen: set[str] = set()
flat: list[tuple[str, str, str]] = []
for sink_type, funcs in GO_SINK_PATTERNS.items():
for func_name, pattern in funcs.items():
flat.append((pattern, sink_type, func_name))
flat.sort(key=lambda x: _PRIORITY_ORDER.index(x[1]) if x[1] in _PRIORITY_ORDER else 99)
for pattern, sink_type, sink_func in flat:
for m in re.finditer(pattern, source_text):
line_num = source_text[: m.start()].count("\n") + 1
containing_func = self._find_containing_function(line_num, result)
if not containing_func:
continue
sink_id = make_node_id(
self.file_stem, f"SINK::{sink_type}::{sink_func}::{line_num}"
)
if sink_id in seen:
continue
seen.add(sink_id)
result.nodes.append(Node(
id=sink_id,
node_type=NodeType.SINK,
label=f"sink: {sink_func} [{sink_type}]",
file_path=self.file_path,
line_start=line_num,
line_end=line_num,
metadata={"sink_type": sink_type, "function_name": sink_func},
))
result.edges.append(Edge(
source_id=containing_func,
target_id=sink_id,
edge_type=EdgeType.CALLS,
))
# ── Env vars ──────────────────────────────────────────────────────────────
def _extract_env_vars(self, result: ExtractionResult, source_text: str):
for env_re in (
re.compile(r'os\.Getenv\s*\(\s*"(?P<var>\w+)"\s*\)'),
re.compile(r'os\.LookupEnv\s*\(\s*"(?P<var>\w+)"\s*\)'),
):
for m in env_re.finditer(source_text):
var_name = m.group("var")
line_num = source_text[: m.start()].count("\n") + 1
env_id = make_node_id(self.file_stem, f"ENV::{var_name}")
if any(n.id == env_id for n in result.nodes):
continue
result.nodes.append(Node(
id=env_id,
node_type=NodeType.ENV_VAR,
label=f"env: {var_name}",
file_path=self.file_path,
line_start=line_num,
line_end=line_num,
metadata={"var_name": var_name},
))
containing_func = self._find_containing_function(line_num, result)
if containing_func:
result.edges.append(Edge(
source_id=env_id,
target_id=containing_func,
edge_type=EdgeType.DATA_FLOW,
))
# ── Imports ───────────────────────────────────────────────────────────────
def _extract_imports(self, result: ExtractionResult, source_text: str):
module_id = make_module_node_id(self.file_stem)
# Match both single-line `import "pkg"` and grouped imports
import_block_re = re.compile(
r'\bimport\s+(?:"[^"]+"|\((?:[^)]*)\))', re.DOTALL
)
pkg_re = re.compile(r'"(?P<pkg>[^"]+)"')
imports_list: list[str] = []
for block in import_block_re.finditer(source_text):
for pm in pkg_re.finditer(block.group(0)):
imports_list.append(pm.group("pkg"))
for n in result.nodes:
if n.id == module_id:
n.metadata["imports"] = imports_list
break
# ── Call graph ────────────────────────────────────────────────────────────
def _extract_calls(self, result: ExtractionResult, tree):
# Simple call_expression where the function is a plain identifier
query = """
(call_expression
function: (identifier) @func_name) @call
"""
func_map: dict[str, str] = {}
for n in result.nodes:
if n.node_type in (NodeType.FUNCTION, NodeType.ENTRY_POINT):
func_map[n.label] = n.id
qn = n.metadata.get("qualified_name", "")
if qn:
func_map[qn] = n.id
unresolved: list[dict] = []
for call_node, _ in query_tree(tree, "go", query, capture_filter="call"):
func_child = call_node.child_by_field_name("function")
if not func_child:
continue
callee = get_node_text(func_child, self.source_bytes)
if not callee:
continue
line_num = call_node.start_point[0] + 1
caller_id = self._find_containing_function(line_num, result)
if not caller_id:
continue
resolved = False
for fname, fid in func_map.items():
if fname == callee or fid.endswith(f"::{callee}"):
result.edges.append(Edge(
source_id=caller_id,
target_id=fid,
edge_type=EdgeType.CALLS,
))
resolved = True
break
if not resolved:
unresolved.append({"caller_id": caller_id, "callee_name": callee, "line": line_num})
result.metadata["unresolved_calls"] = unresolved
def extract(file_info: FileInfo) -> ExtractionResult:
return GoExtractor().extract(file_info)