vibesec-backend / scanner /layer4_dataflow.py
thefounder03's picture
fix: safely convert line number string arguments to integers in dataflow agent tools
6baf699
Raw
History Blame Contribute Delete
18 kB
"""
Layer 4 — Dataflow Agent
NVIDIA NIM (Nemotron 70B) with tool use. ReAct pattern.
Traces user input from source to sink across files.
Only runs on injection-class findings.
Evidence citation enforced: verdicts without tool citations are REJECTED.
"""
from __future__ import annotations
import json
from typing import List, Any
from .models import Finding, TaintPath, Evidence, Confidence
from .nvidia_client import complete_smart
from .layer0_indexer import CodeIndexer
import logging
logger = logging.getLogger("vibesec")
_taint_cache: dict = {}
DATAFLOW_TOOLS = [
{
"type": "function",
"function": {
"name": "read_code",
"description": "Read specific lines from a file in the repo",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string"},
"start_line": {"type": "integer"},
"end_line": {"type": "integer"},
},
"required": ["file_path", "start_line", "end_line"],
},
},
},
{
"type": "function",
"function": {
"name": "get_callers",
"description": "Get files/functions that call the given function or import the given file",
"parameters": {
"type": "object",
"properties": {
"function_name": {"type": "string"},
"file_path": {"type": "string"},
},
"required": ["function_name", "file_path"],
},
},
},
{
"type": "function",
"function": {
"name": "get_callees",
"description": "Get functions/files that the given function calls",
"parameters": {
"type": "object",
"properties": {
"function_name": {"type": "string"},
"file_path": {"type": "string"},
},
"required": ["function_name", "file_path"],
},
},
},
{
"type": "function",
"function": {
"name": "search_pattern",
"description": "Search for a regex pattern across production files",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex pattern to search"},
"scope": {"type": "string", "enum": ["production", "all"], "default": "production"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "check_sanitizer",
"description": "Check if a sanitizer, validator, or parameterize function is applied to a variable between a given source and sink line",
"parameters": {
"type": "object",
"properties": {
"variable_name": {"type": "string"},
"file_path": {"type": "string"},
"source_line": {"type": "integer"},
"sink_line": {"type": "integer"},
},
"required": ["variable_name", "file_path", "source_line", "sink_line"],
},
},
},
]
SYSTEM_PROMPT = """You are an elite, co-founder level Principal Application Security Researcher conducting exhaustive dataflow reasoning.
Your mission is to perform detailed source-to-sink taint propagation and identify access control, authorization, and business logic bypasses across the codebase.
EXPERT DETAILED REASONING RULES:
1. **Real Taint & Multi-Hop Propagation**: You must trace user input variables across multiple steps, re-assignments, spreads, destructuring, object properties (`obj.key`), and array elements. Never lose track of a tainted value just because it is stored in a complex data structure.
2. **Async & Promise Flow Analysis**: Follow inputs that flow through async functions, await blocks, promise resolutions (`.then`), and callback queues.
3. **Interprocedural & Import/Module Tracing**: Follow variable arguments when passed across separate files, exported functions, libraries, helper scripts, and class methods.
4. **Alias & Object Mutation Tracking**: Reason about variables that are copied by reference or modified inline (e.g. `const query = req.body; query.isAdmin = true;`).
5. **Sanitizer-Aware & Escaping Context Validation**: Evaluate if sanitizers (e.g. `DOMPurify.sanitize`, custom regexes, `escapeHtml`) actually neutralize the specific sink exploit vector (e.g., query parameters vs. HTML bodies). If escaping is context-inappropriate or incomplete, flag it.
6. **Authentication & Authorization/RBAC/IDOR Reasoning**: Check if parameters (like database keys, transaction IDs, or profile identifiers) are queried directly without verifying that they belong to the current authenticated tenant/session owner. Look for lack of ownership checks, privilege escalation vectors, or direct object reference exposures.
7. **SSRF, Prototype Pollution & Deserialization Sinks**: Actively confirm if user-controlled keys can mutate class prototype templates (`__proto__`, `constructor.prototype`), control target outbound connection endpoints (SSRF), or influence execution chains via unsafe deserialization/eval calls.
OPERATIONAL PRINCIPLES:
* Cite specific tool calls inside the evidence citation list. Verdicts without direct tool execution citations will be immediately rejected and marked as needs_human_review.
* You MUST only execute exactly ONE tool call at a time. Parallel tool execution is disabled by the hosting server; calling multiple tools at once will fail. Select only the most relevant tool to call in this turn.
Your final response MUST be a JSON object with this exact schema:
{
"verdict": "exploitable" | "not_exploitable" | "needs_human_review",
"confidence": "high" | "medium" | "low",
"exploitable_by": "unauthenticated" | "authenticated" | "admin" | null,
"source": {"file": "...", "line": 0, "type": "req.body|params|query|cookie|header|file_upload|unauthorized_input"},
"sink": {"file": "...", "line": 0, "type": "sql|exec|eval|html|redirect|external|ssrf|prototype_pollution|idor_leak"},
"sanitizers_found": [],
"taint_path": [{"file": "...", "line": 0, "description": "..."}],
"evidence": [{"tool_call": "...", "result_summary": "..."}],
"exploit_scenario": "Plain English step-by-step description of the exploit path/scenario for non-technical developers"
}
REJECT RULE: If you cannot cite at least ONE tool call result in your evidence array, set verdict to "needs_human_review"."""
def run_dataflow_agent(
finding: Finding,
indexer: CodeIndexer,
max_iterations: int = 10,
) -> Finding:
"""Run the dataflow agent on an injection-class finding. Returns enriched finding."""
if not hasattr(indexer, "l4_degraded"):
indexer.l4_degraded = False
if getattr(indexer, "is_training_app", False):
print(f" [Layer 4 Dataflow] Training app detected. Bypassing deep dataflow trace to conserve RAM and keep finding active: '{finding.title}'")
finding.confidence = Confidence.HIGH
finding.explanation += " [Dataflow: unconfirmed taint path, retained for training app sandbox]"
return finding
# Build a stable cache key based on location and type of finding
cache_key = None
if finding and finding.file_path and finding.check_id:
cache_key = f"{finding.file_path}:{finding.line_number or 0}:{finding.check_id}"
if cache_key and cache_key in _taint_cache:
cached = _taint_cache[cache_key]
# Shallow copy cached attributes back to the incoming finding object
finding.taint_path = cached.get("taint_path")
finding.confidence = cached.get("confidence")
finding.explanation = cached.get("explanation", "") + " [Taint Cache Hit]"
logger.debug(f"Taint cache hit for {cache_key}")
return finding
func_info = indexer.get_enclosing_function_info(finding.file_path or "", finding.line_number or 1)
initial_message = f"""Finding: {finding.title}
File: {finding.file_path}:{finding.line_number}
Check ID: {finding.check_id}
Description: {finding.description}
"""
if func_info:
initial_message += f"""
Enclosing Function: {func_info['name']} (Lines {func_info['start_line']}-{func_info['end_line']})
Callers (Incoming calls): {', '.join(func_info['callers']) or 'None'}
Callees (Outgoing calls): {', '.join(func_info['callees']) or 'None'}
Function Body context:
{func_info['body']}
"""
else:
context = indexer.get_file_context(
finding.file_path or "", finding.line_number or 1, window=30
)
initial_message += f"""
Code context (60 lines around the finding):
{context}
"""
initial_message += "\nAnalyze this finding. Use tools to trace the complete taint path from source to sink."
messages = [{"role": "user", "content": initial_message}]
evidence_list: List[Evidence] = []
tool_calls_made = 0
for iteration in range(max_iterations):
try:
response = complete_smart(
system=SYSTEM_PROMPT,
messages=messages,
tools=DATAFLOW_TOOLS,
max_tokens=2048,
)
except Exception as e:
print(f" [Layer 4 Dataflow ERROR] LLM complete_smart query failed: {e}. Bypassing deep dataflow trace and retaining finding.")
indexer.l4_degraded = True
finding.confidence = Confidence.LOW
finding.explanation += f" [Dataflow analysis failed: {e}]"
return finding
# Append assistant message
messages.append({"role": "assistant", "content": response.content or "",
"tool_calls": [tc.model_dump() if hasattr(tc, 'model_dump') else tc
for tc in (response.tool_calls or [])]})
# Process tool calls
if response.tool_calls:
tool_results = []
for tc in response.tool_calls:
raw_args = tc.function.arguments
try:
# Parse tool arguments with validation — reject if not valid JSON dict
args_dict = json.loads(raw_args)
if not isinstance(args_dict, dict):
print(f" [Layer 4 Dataflow WARNING] Non-dict arguments from tool {tc.function.name}. Skipping.")
continue
except (json.JSONDecodeError, TypeError) as e:
print(f" [Layer 4 Dataflow WARNING] Malformed JSON from LLM in tool {tc.function.name}: {e}. Skipping invalid tool call.")
evidence_list.append(Evidence(
tool_call=f"[INVALID] {tc.function.name}({raw_args[:100]})",
result_summary=f"LLM returned malformed arguments: {e}",
))
# Continue processing other tool calls
tool_calls_made += 1
continue
result = _execute_tool(tc.function.name, args_dict, indexer)
tool_calls_made += 1
evidence_list.append(Evidence(
tool_call=f"{tc.function.name}({raw_args[:300]})",
result_summary=str(result)[:300],
))
tool_results.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
messages.extend(tool_results)
continue
# No tool calls — model is giving final verdict
raw_text = response.content or ""
result = _parse_verdict(raw_text, evidence_list, tool_calls_made, finding)
if cache_key:
_taint_cache[cache_key] = {
"taint_path": getattr(finding, "taint_path", None),
"confidence": getattr(finding, "confidence", None),
"explanation": getattr(finding, "explanation", ""),
}
return result
# Cache the result for future reuse
if cache_key:
_taint_cache[cache_key] = {
"taint_path": getattr(finding, "taint_path", None),
"confidence": getattr(finding, "confidence", None),
"explanation": getattr(finding, "explanation", ""),
}
# Exceeded iterations
finding.confidence = Confidence.LOW
finding.explanation += " [Dataflow agent: exceeded max iterations — needs human review]"
return finding
def _execute_tool(name: str, args: dict, indexer: CodeIndexer) -> Any:
try:
if name == "read_code":
try:
start_line = int(args.get("start_line", 1))
except Exception:
start_line = 1
ctx = indexer.get_file_context(args["file_path"], start_line, window=20)
return {"lines": ctx, "file": args["file_path"]}
elif name == "get_callers":
callers = indexer.get_callers(args.get("function_name", ""), args.get("file_path", ""))
return {"callers": callers[:10]}
elif name == "get_callees":
callees = indexer.get_callees(args.get("function_name", ""), args.get("file_path", ""))
return {"callees": callees[:10]}
elif name == "search_pattern":
import re
results = []
pattern = args.get("pattern", "")
for rel_path, role in indexer.file_roles.items():
if args.get("scope") == "production" and role != "production":
continue
try:
content = (indexer.repo_path / rel_path).read_text(encoding="utf-8", errors="replace")
for m in re.finditer(pattern, content):
line = content[:m.start()].count("\n") + 1
results.append({"file": rel_path, "line": line, "match": m.group(0)[:100]})
if len(results) >= 20:
break
except Exception:
pass
if len(results) >= 20:
break
return {"matches": results}
elif name == "check_sanitizer":
import re
fpath = indexer.repo_path / args.get("file_path", "")
try:
lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()
try:
src_line = int(args.get("source_line", 1))
except Exception:
src_line = 1
try:
snk_line = int(args.get("sink_line", src_line + 50))
except Exception:
snk_line = src_line + 50
start = max(0, src_line - 1)
end = min(len(lines), snk_line)
chunk = "\n".join(lines[start:end])
sanitizer_patterns = [r"escape\(", r"sanitize\(", r"parameteriz", r"prepare\(", r"validate\(", r"\.replace\(", r"DOMPurify", r"xss\("]
found = [p for p in sanitizer_patterns if re.search(p, chunk, re.IGNORECASE)]
return {"sanitizers_found": found, "has_sanitizer": bool(found)}
except Exception:
return {"sanitizers_found": [], "has_sanitizer": False}
except Exception as e:
return {"error": str(e)}
return {}
def _parse_verdict(raw_text: str, evidence: List[Evidence], tool_calls_made: int, finding: Finding) -> Finding:
import re
# Extract JSON from response
json_match = re.search(r'\{.*\}', raw_text, re.DOTALL)
if not json_match:
finding.confidence = Confidence.LOW
finding.evidence.extend(evidence)
return finding
try:
verdict_data = json.loads(json_match.group(0))
except json.JSONDecodeError:
finding.confidence = Confidence.LOW
finding.evidence.extend(evidence)
return finding
# REJECT RULE: no tool citations = needs_human_review
if tool_calls_made == 0 or not verdict_data.get("evidence"):
verdict_data["verdict"] = "needs_human_review"
verdict = verdict_data.get("verdict", "needs_human_review")
if verdict == "exploitable":
finding.confidence = {"high": Confidence.HIGH, "medium": Confidence.MEDIUM, "low": Confidence.LOW}.get(
verdict_data.get("confidence", "medium"), Confidence.MEDIUM
)
finding.exploitable_by = verdict_data.get("exploitable_by")
if verdict_data.get("exploit_scenario"):
finding.explanation = verdict_data["exploit_scenario"]
src = verdict_data.get("source", {})
snk = verdict_data.get("sink", {})
if src and snk:
finding.taint_path = TaintPath(
source_file=src.get("file", finding.file_path or ""),
source_line=src.get("line", finding.line_number or 0),
source_type=src.get("type", "unknown"),
sink_file=snk.get("file", finding.file_path or ""),
sink_line=snk.get("line", 0),
sink_type=snk.get("type", "unknown"),
sanitizers_found=verdict_data.get("sanitizers_found", []),
intermediate_calls=verdict_data.get("taint_path", []),
)
finding.taint_confirmed = True
finding.confidence = Confidence.HIGH
elif verdict == "not_exploitable":
finding.is_false_positive = True
finding.false_positive_reason = "Dataflow agent: sanitizer found in taint path or no reachable sink confirmed."
else:
finding.confidence = Confidence.LOW
finding.evidence.extend(evidence)
return finding