File size: 17,962 Bytes
6cb82c6
 
 
 
 
 
 
 
 
1948258
6cb82c6
 
 
 
3357d94
 
 
 
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18b82c3
6cb82c6
18b82c3
6cb82c6
18b82c3
 
 
 
 
 
 
 
 
 
 
45406d8
6cb82c6
 
 
 
 
 
18b82c3
 
6cb82c6
 
 
18b82c3
6cb82c6
 
 
 
 
 
 
5474df8
6cb82c6
 
d46e192
 
 
d5349e0
 
 
 
 
6cb82c6
3357d94
 
 
 
 
 
 
 
 
 
 
 
 
 
c972e32
6cb82c6
 
 
 
 
c972e32
 
 
 
 
 
6cb82c6
c972e32
 
 
 
 
 
 
 
6cb82c6
 
c972e32
 
 
6cb82c6
 
 
 
 
 
a00e93c
 
 
 
 
 
 
 
 
d46e192
a00e93c
 
 
6cb82c6
 
 
 
 
 
 
 
 
 
295bbd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6cb82c6
 
295bbd1
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
3357d94
 
 
 
 
 
6cb82c6
 
3357d94
 
 
 
 
 
 
 
6cb82c6
 
 
 
 
 
 
 
6baf699
 
 
 
 
6cb82c6
 
 
 
 
 
 
 
1948258
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6baf699
 
 
 
 
 
 
 
 
 
6cb82c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb8254f
 
6cb82c6
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
"""
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