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.
@@ -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
- if self._has_tool_decorator(node):
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