agent-audit 0.1.0__py3-none-any.whl → 0.2.0__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.
- agent_audit/cli/commands/scan.py +41 -30
- agent_audit/cli/formatters/json.py +2 -2
- agent_audit/cli/formatters/sarif.py +9 -3
- agent_audit/models/finding.py +19 -7
- agent_audit/models/risk.py +13 -0
- agent_audit/rules/builtin/owasp_agentic.yaml +126 -0
- agent_audit/rules/builtin/owasp_agentic_v2.yaml +832 -0
- agent_audit/rules/engine.py +60 -1
- agent_audit/scanners/base.py +5 -3
- agent_audit/scanners/config_scanner.py +1 -1
- agent_audit/scanners/mcp_config_scanner.py +4 -3
- agent_audit/scanners/mcp_inspector.py +5 -4
- agent_audit/scanners/python_scanner.py +668 -7
- agent_audit/utils/mcp_client.py +1 -0
- agent_audit/version.py +1 -1
- {agent_audit-0.1.0.dist-info → agent_audit-0.2.0.dist-info}/METADATA +49 -35
- {agent_audit-0.1.0.dist-info → agent_audit-0.2.0.dist-info}/RECORD +19 -17
- {agent_audit-0.1.0.dist-info → agent_audit-0.2.0.dist-info}/WHEEL +0 -0
- {agent_audit-0.1.0.dist-info → agent_audit-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -198,6 +198,82 @@ class PythonScanner(BaseScanner):
|
|
|
198
198
|
class PythonASTVisitor(ast.NodeVisitor):
|
|
199
199
|
"""AST visitor that extracts security-relevant information."""
|
|
200
200
|
|
|
201
|
+
# Prompt-related function names for ASI-01 detection
|
|
202
|
+
PROMPT_FUNCTIONS: Set[str] = {
|
|
203
|
+
'SystemMessage', 'HumanMessage', 'AIMessage',
|
|
204
|
+
'ChatPromptTemplate', 'PromptTemplate',
|
|
205
|
+
'SystemMessagePromptTemplate',
|
|
206
|
+
'from_messages', 'from_template',
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Variable names that typically hold system prompts
|
|
210
|
+
SYSTEM_PROMPT_VARNAMES: Set[str] = {
|
|
211
|
+
'system_prompt', 'system_message', 'instructions',
|
|
212
|
+
'system_instructions', 'agent_prompt', 'system_content',
|
|
213
|
+
'sys_prompt', 'base_prompt',
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Memory write functions for ASI-06 detection
|
|
217
|
+
MEMORY_WRITE_FUNCTIONS: Set[str] = {
|
|
218
|
+
'add_documents', 'add_texts', 'upsert', 'insert',
|
|
219
|
+
'persist', 'save_context', 'add_message', 'add_memory',
|
|
220
|
+
'store', 'put', 'set',
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Unbounded memory classes for ASI-06 detection
|
|
224
|
+
UNBOUNDED_MEMORY_CLASSES: Set[str] = {
|
|
225
|
+
'ConversationBufferMemory',
|
|
226
|
+
'ConversationSummaryMemory',
|
|
227
|
+
'ChatMessageHistory',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Agent constructor functions for ASI-08/10 detection
|
|
231
|
+
AGENT_CONSTRUCTORS: Set[str] = {
|
|
232
|
+
'AgentExecutor', 'initialize_agent', 'create_react_agent',
|
|
233
|
+
'create_openai_functions_agent', 'Crew',
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Auto-approval keywords for ASI-03 detection
|
|
237
|
+
AUTO_APPROVAL_KEYWORDS: Set[str] = {
|
|
238
|
+
'trust_all_tools', 'auto_approve', 'no_confirm',
|
|
239
|
+
'skip_approval', 'dangerously_skip_permissions',
|
|
240
|
+
'no_interactive', 'trust_all',
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Multi-agent classes for ASI-07 detection
|
|
244
|
+
MULTI_AGENT_CLASSES: Set[str] = {
|
|
245
|
+
'GroupChat', 'GroupChatManager', 'ConversableAgent',
|
|
246
|
+
'Crew', 'Agent',
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Authentication-related keyword arguments for ASI-07 detection
|
|
250
|
+
AUTH_KEYWORDS: Set[str] = {
|
|
251
|
+
'authentication', 'tls', 'verify', 'auth',
|
|
252
|
+
'secure_channel', 'ssl', 'https',
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Agent communication context keywords for ASI-07 detection
|
|
256
|
+
AGENT_COMM_KEYWORDS: Set[str] = {
|
|
257
|
+
'agent', 'delegate', 'handoff', 'message',
|
|
258
|
+
'endpoint', 'server', 'worker', 'peer',
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Transparency-related keyword arguments for ASI-09 detection
|
|
262
|
+
TRANSPARENCY_KEYWORDS: Set[str] = {
|
|
263
|
+
'return_intermediate_steps', 'verbose',
|
|
264
|
+
'return_source_documents', 'include_reasoning',
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# External call functions for ASI-08 tool error handling detection
|
|
268
|
+
EXTERNAL_CALL_FUNCTIONS: Set[str] = {
|
|
269
|
+
'requests.get', 'requests.post', 'requests.put', 'requests.delete',
|
|
270
|
+
'httpx.get', 'httpx.post', 'httpx.put', 'httpx.delete',
|
|
271
|
+
'urllib.request.urlopen', 'aiohttp.ClientSession',
|
|
272
|
+
'subprocess.run', 'subprocess.Popen', 'subprocess.call',
|
|
273
|
+
'os.system', 'os.popen',
|
|
274
|
+
'open',
|
|
275
|
+
}
|
|
276
|
+
|
|
201
277
|
def __init__(self, file_path: Path, source: str):
|
|
202
278
|
self.file_path = file_path
|
|
203
279
|
self.source = source
|
|
@@ -215,6 +291,8 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
215
291
|
self._current_function_params: Set[str] = set()
|
|
216
292
|
# Track current class for tool detection
|
|
217
293
|
self._current_class: Optional[str] = None
|
|
294
|
+
# Track if inside @tool decorated function (ASI-05)
|
|
295
|
+
self._in_tool_function: bool = False
|
|
218
296
|
|
|
219
297
|
def visit_Import(self, node: ast.Import):
|
|
220
298
|
"""Track imports like 'import subprocess'."""
|
|
@@ -259,6 +337,7 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
259
337
|
"""Visit function definitions to detect @tool decorators."""
|
|
260
338
|
old_func = self._current_function
|
|
261
339
|
old_params = self._current_function_params
|
|
340
|
+
old_in_tool = self._in_tool_function
|
|
262
341
|
|
|
263
342
|
self._current_function = node.name
|
|
264
343
|
self._current_function_params = {
|
|
@@ -266,20 +345,38 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
266
345
|
}
|
|
267
346
|
|
|
268
347
|
# Check for @tool decorator
|
|
269
|
-
|
|
348
|
+
is_tool = self._has_tool_decorator(node)
|
|
349
|
+
self._in_tool_function = is_tool
|
|
350
|
+
|
|
351
|
+
if is_tool:
|
|
270
352
|
tool = self._extract_tool_from_function(node)
|
|
271
353
|
if tool:
|
|
272
354
|
self.tools.append(tool)
|
|
273
355
|
|
|
356
|
+
# ASI-08: Check for tool without error handling
|
|
357
|
+
err_finding = self._check_tool_without_error_handling(node)
|
|
358
|
+
if err_finding:
|
|
359
|
+
self.dangerous_patterns.append(err_finding)
|
|
360
|
+
|
|
274
361
|
self.generic_visit(node)
|
|
275
362
|
|
|
276
363
|
self._current_function = old_func
|
|
277
364
|
self._current_function_params = old_params
|
|
365
|
+
self._in_tool_function = old_in_tool
|
|
278
366
|
|
|
279
367
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
280
368
|
"""Handle async function definitions the same as regular functions."""
|
|
281
|
-
# Reuse the same logic as FunctionDef
|
|
282
|
-
self.visit_FunctionDef(node) # type: ignore
|
|
369
|
+
# Reuse the same logic as FunctionDef - cast to satisfy type checker
|
|
370
|
+
self.visit_FunctionDef(node) # type: ignore[arg-type]
|
|
371
|
+
|
|
372
|
+
def visit_Assign(self, node: ast.Assign):
|
|
373
|
+
"""Visit assignment statements to detect system prompt concatenation."""
|
|
374
|
+
# ASI-01: Check for system prompt constructed via string operations
|
|
375
|
+
finding = self._check_system_prompt_concat(node)
|
|
376
|
+
if finding:
|
|
377
|
+
self.dangerous_patterns.append(finding)
|
|
378
|
+
|
|
379
|
+
self.generic_visit(node)
|
|
283
380
|
|
|
284
381
|
def visit_Call(self, node: ast.Call):
|
|
285
382
|
"""Visit function calls to detect dangerous patterns."""
|
|
@@ -322,6 +419,61 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
322
419
|
}
|
|
323
420
|
self.dangerous_patterns.append(pattern)
|
|
324
421
|
|
|
422
|
+
# === OWASP Agentic Top 10 detections ===
|
|
423
|
+
|
|
424
|
+
# ASI-01: Prompt injection vector
|
|
425
|
+
pi_finding = self._check_prompt_injection_vector(node)
|
|
426
|
+
if pi_finding:
|
|
427
|
+
self.dangerous_patterns.append(pi_finding)
|
|
428
|
+
|
|
429
|
+
# ASI-03: Excessive tools / auto-approval
|
|
430
|
+
et_finding = self._check_excessive_tools(node)
|
|
431
|
+
if et_finding:
|
|
432
|
+
self.dangerous_patterns.append(et_finding)
|
|
433
|
+
|
|
434
|
+
# ASI-05: Unsandboxed code execution in tool context
|
|
435
|
+
rce_finding = self._check_unsandboxed_code_exec(node)
|
|
436
|
+
if rce_finding:
|
|
437
|
+
self.dangerous_patterns.append(rce_finding)
|
|
438
|
+
|
|
439
|
+
# ASI-06: Memory poisoning
|
|
440
|
+
mem_finding = self._check_memory_poisoning(node)
|
|
441
|
+
if mem_finding:
|
|
442
|
+
self.dangerous_patterns.append(mem_finding)
|
|
443
|
+
|
|
444
|
+
mem_unbound = self._check_unbounded_memory(node)
|
|
445
|
+
if mem_unbound:
|
|
446
|
+
self.dangerous_patterns.append(mem_unbound)
|
|
447
|
+
|
|
448
|
+
# ASI-08: Missing circuit breaker
|
|
449
|
+
cb_finding = self._check_missing_circuit_breaker(node)
|
|
450
|
+
if cb_finding:
|
|
451
|
+
self.dangerous_patterns.append(cb_finding)
|
|
452
|
+
|
|
453
|
+
# ASI-10: Missing kill switch / observability
|
|
454
|
+
ks_finding = self._check_missing_kill_switch(node)
|
|
455
|
+
if ks_finding:
|
|
456
|
+
self.dangerous_patterns.append(ks_finding)
|
|
457
|
+
|
|
458
|
+
obs_finding = self._check_missing_observability(node)
|
|
459
|
+
if obs_finding:
|
|
460
|
+
self.dangerous_patterns.append(obs_finding)
|
|
461
|
+
|
|
462
|
+
# ASI-07: Inter-agent communication without authentication
|
|
463
|
+
ia_finding = self._check_multi_agent_no_auth(node)
|
|
464
|
+
if ia_finding:
|
|
465
|
+
self.dangerous_patterns.append(ia_finding)
|
|
466
|
+
|
|
467
|
+
# ASI-07: Unencrypted agent communication
|
|
468
|
+
tls_finding = self._check_agent_comm_no_tls(node)
|
|
469
|
+
if tls_finding:
|
|
470
|
+
self.dangerous_patterns.append(tls_finding)
|
|
471
|
+
|
|
472
|
+
# ASI-09: Opaque agent output
|
|
473
|
+
oa_finding = self._check_opaque_agent_output(node)
|
|
474
|
+
if oa_finding:
|
|
475
|
+
self.dangerous_patterns.append(oa_finding)
|
|
476
|
+
|
|
325
477
|
self.generic_visit(node)
|
|
326
478
|
|
|
327
479
|
def _has_tool_decorator(self, node: ast.FunctionDef) -> bool:
|
|
@@ -375,8 +527,8 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
375
527
|
for item in node.body:
|
|
376
528
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
377
529
|
if item.name in ('_run', '_arun', 'run', 'arun'):
|
|
378
|
-
permissions.update(self._analyze_function_permissions(item))
|
|
379
|
-
has_validation = has_validation or self._check_input_validation(item)
|
|
530
|
+
permissions.update(self._analyze_function_permissions(item)) # type: ignore[arg-type]
|
|
531
|
+
has_validation = has_validation or self._check_input_validation(item) # type: ignore[arg-type]
|
|
380
532
|
|
|
381
533
|
tool = ToolDefinition(
|
|
382
534
|
name=node.name,
|
|
@@ -486,8 +638,8 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
486
638
|
return self._imported_names.get(name, name)
|
|
487
639
|
|
|
488
640
|
elif isinstance(node.func, ast.Attribute):
|
|
489
|
-
parts = []
|
|
490
|
-
current = node.func
|
|
641
|
+
parts: List[str] = []
|
|
642
|
+
current: ast.expr = node.func
|
|
491
643
|
while isinstance(current, ast.Attribute):
|
|
492
644
|
parts.append(current.attr)
|
|
493
645
|
current = current.value
|
|
@@ -542,3 +694,512 @@ class PythonASTVisitor(ast.NodeVisitor):
|
|
|
542
694
|
if 0 < lineno <= len(self.source_lines):
|
|
543
695
|
return self.source_lines[lineno - 1].strip()
|
|
544
696
|
return ""
|
|
697
|
+
|
|
698
|
+
# =========================================================================
|
|
699
|
+
# OWASP Agentic Top 10 Detection Methods
|
|
700
|
+
# =========================================================================
|
|
701
|
+
|
|
702
|
+
def _check_prompt_injection_vector(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
703
|
+
"""
|
|
704
|
+
ASI-01: Detect prompt functions containing f-strings or format calls.
|
|
705
|
+
|
|
706
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
707
|
+
"""
|
|
708
|
+
func_name = self._get_call_name(node)
|
|
709
|
+
if not func_name:
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
# Check if this is a prompt-related function
|
|
713
|
+
is_prompt_func = any(pf in func_name for pf in self.PROMPT_FUNCTIONS)
|
|
714
|
+
if not is_prompt_func:
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
# Check arguments for f-strings (JoinedStr)
|
|
718
|
+
for arg in node.args:
|
|
719
|
+
if isinstance(arg, ast.JoinedStr):
|
|
720
|
+
return {
|
|
721
|
+
'type': 'prompt_injection_fstring',
|
|
722
|
+
'function': func_name,
|
|
723
|
+
'line': node.lineno,
|
|
724
|
+
'snippet': self._get_line(node.lineno),
|
|
725
|
+
'risk': 'User input may be interpolated into prompt via f-string',
|
|
726
|
+
'owasp_id': 'ASI-01',
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
# Check keyword arguments for f-strings
|
|
730
|
+
for kw in node.keywords:
|
|
731
|
+
if kw.arg in ('content', 'template', 'messages', 'system_message'):
|
|
732
|
+
if isinstance(kw.value, ast.JoinedStr):
|
|
733
|
+
return {
|
|
734
|
+
'type': 'prompt_injection_fstring_kwarg',
|
|
735
|
+
'function': func_name,
|
|
736
|
+
'keyword': kw.arg,
|
|
737
|
+
'line': node.lineno,
|
|
738
|
+
'snippet': self._get_line(node.lineno),
|
|
739
|
+
'owasp_id': 'ASI-01',
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
# Check for .format() calls in arguments
|
|
743
|
+
for arg in node.args:
|
|
744
|
+
if isinstance(arg, ast.Call):
|
|
745
|
+
inner_name = self._get_call_name(arg)
|
|
746
|
+
if inner_name and inner_name.endswith('.format'):
|
|
747
|
+
return {
|
|
748
|
+
'type': 'prompt_injection_format',
|
|
749
|
+
'function': func_name,
|
|
750
|
+
'line': node.lineno,
|
|
751
|
+
'snippet': self._get_line(node.lineno),
|
|
752
|
+
'owasp_id': 'ASI-01',
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
def _check_system_prompt_concat(self, node: ast.Assign) -> Optional[Dict[str, Any]]:
|
|
758
|
+
"""
|
|
759
|
+
ASI-01: Detect system_prompt variable constructed via string concatenation.
|
|
760
|
+
|
|
761
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
762
|
+
"""
|
|
763
|
+
for target in node.targets:
|
|
764
|
+
if not isinstance(target, ast.Name):
|
|
765
|
+
continue
|
|
766
|
+
varname = target.id.lower()
|
|
767
|
+
if varname not in self.SYSTEM_PROMPT_VARNAMES:
|
|
768
|
+
continue
|
|
769
|
+
|
|
770
|
+
# Check for f-string
|
|
771
|
+
if isinstance(node.value, ast.JoinedStr):
|
|
772
|
+
return {
|
|
773
|
+
'type': 'system_prompt_fstring',
|
|
774
|
+
'variable': target.id,
|
|
775
|
+
'line': node.lineno,
|
|
776
|
+
'snippet': self._get_line(node.lineno),
|
|
777
|
+
'owasp_id': 'ASI-01',
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
# Check for + concatenation
|
|
781
|
+
if isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Add):
|
|
782
|
+
return {
|
|
783
|
+
'type': 'system_prompt_concat',
|
|
784
|
+
'variable': target.id,
|
|
785
|
+
'line': node.lineno,
|
|
786
|
+
'snippet': self._get_line(node.lineno),
|
|
787
|
+
'owasp_id': 'ASI-01',
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
# Check for .format() call
|
|
791
|
+
if isinstance(node.value, ast.Call):
|
|
792
|
+
call_name = self._get_call_name(node.value)
|
|
793
|
+
if call_name and call_name.endswith('.format'):
|
|
794
|
+
return {
|
|
795
|
+
'type': 'system_prompt_format',
|
|
796
|
+
'variable': target.id,
|
|
797
|
+
'line': node.lineno,
|
|
798
|
+
'snippet': self._get_line(node.lineno),
|
|
799
|
+
'owasp_id': 'ASI-01',
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return None
|
|
803
|
+
|
|
804
|
+
def _check_excessive_tools(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
805
|
+
"""
|
|
806
|
+
ASI-03: Detect agents with too many tools or auto-approval mode.
|
|
807
|
+
|
|
808
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
809
|
+
"""
|
|
810
|
+
func_name = self._get_call_name(node)
|
|
811
|
+
if not func_name:
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
if not any(ac in func_name for ac in self.AGENT_CONSTRUCTORS):
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
# Check tools parameter for excessive count
|
|
818
|
+
for kw in node.keywords:
|
|
819
|
+
if kw.arg == 'tools':
|
|
820
|
+
if isinstance(kw.value, ast.List) and len(kw.value.elts) > 10:
|
|
821
|
+
return {
|
|
822
|
+
'type': 'excessive_tools',
|
|
823
|
+
'function': func_name,
|
|
824
|
+
'tool_count': len(kw.value.elts),
|
|
825
|
+
'line': node.lineno,
|
|
826
|
+
'snippet': self._get_line(node.lineno),
|
|
827
|
+
'owasp_id': 'ASI-03',
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
# Check for auto-approval keywords
|
|
831
|
+
if kw.arg:
|
|
832
|
+
arg_lower = kw.arg.lower().replace('-', '_')
|
|
833
|
+
if arg_lower in self.AUTO_APPROVAL_KEYWORDS:
|
|
834
|
+
if isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
835
|
+
return {
|
|
836
|
+
'type': 'auto_approval',
|
|
837
|
+
'keyword': kw.arg,
|
|
838
|
+
'function': func_name,
|
|
839
|
+
'line': node.lineno,
|
|
840
|
+
'snippet': self._get_line(node.lineno),
|
|
841
|
+
'owasp_id': 'ASI-03',
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
def _check_unsandboxed_code_exec(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
847
|
+
"""
|
|
848
|
+
ASI-05: Detect eval/exec inside @tool decorated functions.
|
|
849
|
+
|
|
850
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
851
|
+
"""
|
|
852
|
+
func_name = self._get_call_name(node)
|
|
853
|
+
if not func_name:
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
if func_name not in ('eval', 'exec', 'compile'):
|
|
857
|
+
return None
|
|
858
|
+
|
|
859
|
+
# Only flag if inside a tool function
|
|
860
|
+
if self._in_tool_function:
|
|
861
|
+
return {
|
|
862
|
+
'type': 'unsandboxed_code_exec_in_tool',
|
|
863
|
+
'function': func_name,
|
|
864
|
+
'line': node.lineno,
|
|
865
|
+
'snippet': self._get_line(node.lineno),
|
|
866
|
+
'owasp_id': 'ASI-05',
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
def _check_memory_poisoning(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
872
|
+
"""
|
|
873
|
+
ASI-06: Detect unsanitized writes to vector databases or memory stores.
|
|
874
|
+
|
|
875
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
876
|
+
"""
|
|
877
|
+
func_name = self._get_call_name(node)
|
|
878
|
+
if not func_name:
|
|
879
|
+
return None
|
|
880
|
+
|
|
881
|
+
# Extract method name (last part after dot)
|
|
882
|
+
method_name = func_name.split('.')[-1] if '.' in func_name else func_name
|
|
883
|
+
|
|
884
|
+
if method_name not in self.MEMORY_WRITE_FUNCTIONS:
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
# Check if arguments contain variable references (potential user input)
|
|
888
|
+
has_variable_input = False
|
|
889
|
+
for arg in node.args:
|
|
890
|
+
if isinstance(arg, (ast.Name, ast.Subscript, ast.Attribute)):
|
|
891
|
+
has_variable_input = True
|
|
892
|
+
break
|
|
893
|
+
if isinstance(arg, ast.List):
|
|
894
|
+
for elt in arg.elts:
|
|
895
|
+
if isinstance(elt, (ast.Name, ast.Subscript, ast.Attribute)):
|
|
896
|
+
has_variable_input = True
|
|
897
|
+
break
|
|
898
|
+
|
|
899
|
+
if has_variable_input:
|
|
900
|
+
return {
|
|
901
|
+
'type': 'unsanitized_memory_write',
|
|
902
|
+
'function': func_name,
|
|
903
|
+
'line': node.lineno,
|
|
904
|
+
'snippet': self._get_line(node.lineno),
|
|
905
|
+
'owasp_id': 'ASI-06',
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return None
|
|
909
|
+
|
|
910
|
+
def _check_unbounded_memory(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
911
|
+
"""
|
|
912
|
+
ASI-06: Detect unbounded memory configurations.
|
|
913
|
+
|
|
914
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
915
|
+
"""
|
|
916
|
+
func_name = self._get_call_name(node)
|
|
917
|
+
if not func_name:
|
|
918
|
+
return None
|
|
919
|
+
|
|
920
|
+
if func_name not in self.UNBOUNDED_MEMORY_CLASSES:
|
|
921
|
+
return None
|
|
922
|
+
|
|
923
|
+
# Check if memory has bounds configured
|
|
924
|
+
has_limit = False
|
|
925
|
+
for kw in node.keywords:
|
|
926
|
+
if kw.arg in ('k', 'max_token_limit', 'max_history', 'window_size'):
|
|
927
|
+
has_limit = True
|
|
928
|
+
break
|
|
929
|
+
|
|
930
|
+
if not has_limit:
|
|
931
|
+
return {
|
|
932
|
+
'type': 'unbounded_memory',
|
|
933
|
+
'class': func_name,
|
|
934
|
+
'line': node.lineno,
|
|
935
|
+
'snippet': self._get_line(node.lineno),
|
|
936
|
+
'owasp_id': 'ASI-06',
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return None
|
|
940
|
+
|
|
941
|
+
def _check_missing_circuit_breaker(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
942
|
+
"""
|
|
943
|
+
ASI-08: Detect AgentExecutor without max_iterations or timeout.
|
|
944
|
+
|
|
945
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
946
|
+
"""
|
|
947
|
+
func_name = self._get_call_name(node)
|
|
948
|
+
if not func_name:
|
|
949
|
+
return None
|
|
950
|
+
|
|
951
|
+
if func_name not in self.AGENT_CONSTRUCTORS:
|
|
952
|
+
return None
|
|
953
|
+
|
|
954
|
+
has_limit = False
|
|
955
|
+
for kw in node.keywords:
|
|
956
|
+
if kw.arg in ('max_iterations', 'max_execution_time', 'max_steps', 'timeout'):
|
|
957
|
+
has_limit = True
|
|
958
|
+
break
|
|
959
|
+
|
|
960
|
+
if not has_limit:
|
|
961
|
+
return {
|
|
962
|
+
'type': 'missing_circuit_breaker',
|
|
963
|
+
'function': func_name,
|
|
964
|
+
'line': node.lineno,
|
|
965
|
+
'snippet': self._get_line(node.lineno),
|
|
966
|
+
'owasp_id': 'ASI-08',
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return None
|
|
970
|
+
|
|
971
|
+
def _check_missing_kill_switch(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
972
|
+
"""
|
|
973
|
+
ASI-10: Detect agent without kill switch (execution limits).
|
|
974
|
+
|
|
975
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
976
|
+
"""
|
|
977
|
+
func_name = self._get_call_name(node)
|
|
978
|
+
if not func_name:
|
|
979
|
+
return None
|
|
980
|
+
|
|
981
|
+
if func_name not in self.AGENT_CONSTRUCTORS:
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
kw_names = {kw.arg for kw in node.keywords if kw.arg}
|
|
985
|
+
|
|
986
|
+
# Check for kill switch params
|
|
987
|
+
kill_switch_params = {'max_iterations', 'max_execution_time', 'timeout', 'early_stopping_method'}
|
|
988
|
+
has_kill_switch = bool(kw_names & kill_switch_params)
|
|
989
|
+
|
|
990
|
+
if not has_kill_switch:
|
|
991
|
+
return {
|
|
992
|
+
'type': 'no_kill_switch',
|
|
993
|
+
'function': func_name,
|
|
994
|
+
'line': node.lineno,
|
|
995
|
+
'snippet': self._get_line(node.lineno),
|
|
996
|
+
'owasp_id': 'ASI-10',
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return None
|
|
1000
|
+
|
|
1001
|
+
def _check_missing_observability(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
1002
|
+
"""
|
|
1003
|
+
ASI-10: Detect agent without observability (callbacks, verbose, logging).
|
|
1004
|
+
|
|
1005
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
1006
|
+
"""
|
|
1007
|
+
func_name = self._get_call_name(node)
|
|
1008
|
+
if not func_name:
|
|
1009
|
+
return None
|
|
1010
|
+
|
|
1011
|
+
if func_name not in self.AGENT_CONSTRUCTORS:
|
|
1012
|
+
return None
|
|
1013
|
+
|
|
1014
|
+
kw_names = {kw.arg for kw in node.keywords if kw.arg}
|
|
1015
|
+
|
|
1016
|
+
# Check for observability params
|
|
1017
|
+
observability_params = {'callbacks', 'callback_manager', 'verbose'}
|
|
1018
|
+
has_observability = bool(kw_names & observability_params)
|
|
1019
|
+
|
|
1020
|
+
if not has_observability:
|
|
1021
|
+
return {
|
|
1022
|
+
'type': 'no_observability',
|
|
1023
|
+
'function': func_name,
|
|
1024
|
+
'line': node.lineno,
|
|
1025
|
+
'snippet': self._get_line(node.lineno),
|
|
1026
|
+
'owasp_id': 'ASI-10',
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return None
|
|
1030
|
+
|
|
1031
|
+
def _check_multi_agent_no_auth(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
1032
|
+
"""
|
|
1033
|
+
ASI-07: Detect multi-agent classes without authentication configuration.
|
|
1034
|
+
|
|
1035
|
+
Checks GroupChat, GroupChatManager, ConversableAgent (autogen) or
|
|
1036
|
+
Crew, Agent (crewai) instantiation for missing auth-related params.
|
|
1037
|
+
|
|
1038
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
1039
|
+
"""
|
|
1040
|
+
func_name = self._get_call_name(node)
|
|
1041
|
+
if not func_name:
|
|
1042
|
+
return None
|
|
1043
|
+
|
|
1044
|
+
# Check if this is a multi-agent class
|
|
1045
|
+
if func_name not in self.MULTI_AGENT_CLASSES:
|
|
1046
|
+
return None
|
|
1047
|
+
|
|
1048
|
+
# Check for authentication-related keyword arguments
|
|
1049
|
+
kw_names = {kw.arg.lower() for kw in node.keywords if kw.arg}
|
|
1050
|
+
has_auth = bool(kw_names & self.AUTH_KEYWORDS)
|
|
1051
|
+
|
|
1052
|
+
if not has_auth:
|
|
1053
|
+
return {
|
|
1054
|
+
'type': 'multi_agent_no_auth',
|
|
1055
|
+
'function': func_name,
|
|
1056
|
+
'line': node.lineno,
|
|
1057
|
+
'snippet': self._get_line(node.lineno),
|
|
1058
|
+
'owasp_id': 'ASI-07',
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
def _check_agent_comm_no_tls(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
1064
|
+
"""
|
|
1065
|
+
ASI-07: Detect agent communication over unencrypted HTTP.
|
|
1066
|
+
|
|
1067
|
+
Checks for http:// string literals in contexts related to agent communication.
|
|
1068
|
+
|
|
1069
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
1070
|
+
"""
|
|
1071
|
+
# Check string arguments for http:// URLs
|
|
1072
|
+
for arg in node.args:
|
|
1073
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
1074
|
+
if arg.value.startswith('http://'):
|
|
1075
|
+
# Check if in agent communication context
|
|
1076
|
+
func_name = self._get_call_name(node) or ''
|
|
1077
|
+
in_agent_context = any(
|
|
1078
|
+
kw in func_name.lower() for kw in self.AGENT_COMM_KEYWORDS
|
|
1079
|
+
)
|
|
1080
|
+
# Also check current function name
|
|
1081
|
+
if self._current_function:
|
|
1082
|
+
in_agent_context = in_agent_context or any(
|
|
1083
|
+
kw in self._current_function.lower()
|
|
1084
|
+
for kw in self.AGENT_COMM_KEYWORDS
|
|
1085
|
+
)
|
|
1086
|
+
# Check keyword argument names
|
|
1087
|
+
for kw in node.keywords:
|
|
1088
|
+
if kw.arg and any(
|
|
1089
|
+
ak in kw.arg.lower() for ak in self.AGENT_COMM_KEYWORDS
|
|
1090
|
+
):
|
|
1091
|
+
in_agent_context = True
|
|
1092
|
+
break
|
|
1093
|
+
|
|
1094
|
+
if in_agent_context:
|
|
1095
|
+
return {
|
|
1096
|
+
'type': 'agent_comm_no_tls',
|
|
1097
|
+
'url': arg.value,
|
|
1098
|
+
'line': node.lineno,
|
|
1099
|
+
'snippet': self._get_line(node.lineno),
|
|
1100
|
+
'owasp_id': 'ASI-07',
|
|
1101
|
+
'confidence': 0.6, # Lower confidence
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
# Also check keyword arguments
|
|
1105
|
+
for kw in node.keywords:
|
|
1106
|
+
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
1107
|
+
if kw.value.value.startswith('http://'):
|
|
1108
|
+
if kw.arg and any(
|
|
1109
|
+
ak in kw.arg.lower() for ak in self.AGENT_COMM_KEYWORDS
|
|
1110
|
+
):
|
|
1111
|
+
return {
|
|
1112
|
+
'type': 'agent_comm_no_tls',
|
|
1113
|
+
'url': kw.value.value,
|
|
1114
|
+
'keyword': kw.arg,
|
|
1115
|
+
'line': node.lineno,
|
|
1116
|
+
'snippet': self._get_line(node.lineno),
|
|
1117
|
+
'owasp_id': 'ASI-07',
|
|
1118
|
+
'confidence': 0.6,
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return None
|
|
1122
|
+
|
|
1123
|
+
def _check_opaque_agent_output(self, node: ast.Call) -> Optional[Dict[str, Any]]:
|
|
1124
|
+
"""
|
|
1125
|
+
ASI-09: Detect AgentExecutor without transparency configuration.
|
|
1126
|
+
|
|
1127
|
+
Checks for missing return_intermediate_steps, verbose, or similar params.
|
|
1128
|
+
|
|
1129
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
1130
|
+
"""
|
|
1131
|
+
func_name = self._get_call_name(node)
|
|
1132
|
+
if not func_name:
|
|
1133
|
+
return None
|
|
1134
|
+
|
|
1135
|
+
# Only check AgentExecutor specifically for transparency
|
|
1136
|
+
if func_name != 'AgentExecutor':
|
|
1137
|
+
return None
|
|
1138
|
+
|
|
1139
|
+
# Check for transparency-related keyword arguments
|
|
1140
|
+
kw_names = {kw.arg for kw in node.keywords if kw.arg}
|
|
1141
|
+
has_transparency = bool(kw_names & self.TRANSPARENCY_KEYWORDS)
|
|
1142
|
+
|
|
1143
|
+
if not has_transparency:
|
|
1144
|
+
return {
|
|
1145
|
+
'type': 'opaque_agent_output',
|
|
1146
|
+
'function': func_name,
|
|
1147
|
+
'line': node.lineno,
|
|
1148
|
+
'snippet': self._get_line(node.lineno),
|
|
1149
|
+
'owasp_id': 'ASI-09',
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return None
|
|
1153
|
+
|
|
1154
|
+
def _check_tool_without_error_handling(
|
|
1155
|
+
self, node: ast.FunctionDef
|
|
1156
|
+
) -> Optional[Dict[str, Any]]:
|
|
1157
|
+
"""
|
|
1158
|
+
ASI-08: Detect @tool decorated functions without try/except.
|
|
1159
|
+
|
|
1160
|
+
Checks if a tool function calls external operations (network, file, subprocess)
|
|
1161
|
+
without proper error handling.
|
|
1162
|
+
|
|
1163
|
+
Args:
|
|
1164
|
+
node: Function definition AST node
|
|
1165
|
+
|
|
1166
|
+
Returns a finding dict if vulnerable, None otherwise.
|
|
1167
|
+
"""
|
|
1168
|
+
# Must have @tool decorator
|
|
1169
|
+
if not self._has_tool_decorator(node):
|
|
1170
|
+
return None
|
|
1171
|
+
|
|
1172
|
+
# Check if function calls external operations
|
|
1173
|
+
has_external_call = False
|
|
1174
|
+
for child in ast.walk(node):
|
|
1175
|
+
if isinstance(child, ast.Call):
|
|
1176
|
+
call_name = self._get_call_name(child)
|
|
1177
|
+
if call_name:
|
|
1178
|
+
# Check full name or method name suffix
|
|
1179
|
+
if call_name in self.EXTERNAL_CALL_FUNCTIONS or any(
|
|
1180
|
+
call_name.endswith(f'.{fn.split(".")[-1]}')
|
|
1181
|
+
for fn in self.EXTERNAL_CALL_FUNCTIONS
|
|
1182
|
+
):
|
|
1183
|
+
has_external_call = True
|
|
1184
|
+
break
|
|
1185
|
+
|
|
1186
|
+
if not has_external_call:
|
|
1187
|
+
return None
|
|
1188
|
+
|
|
1189
|
+
# Check if function has try/except
|
|
1190
|
+
has_try_except = False
|
|
1191
|
+
for child in ast.walk(node):
|
|
1192
|
+
if isinstance(child, ast.Try):
|
|
1193
|
+
has_try_except = True
|
|
1194
|
+
break
|
|
1195
|
+
|
|
1196
|
+
if not has_try_except:
|
|
1197
|
+
return {
|
|
1198
|
+
'type': 'tool_without_error_handling',
|
|
1199
|
+
'function': node.name,
|
|
1200
|
+
'line': node.lineno,
|
|
1201
|
+
'snippet': self._get_line(node.lineno),
|
|
1202
|
+
'owasp_id': 'ASI-08',
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return None
|