2323import json
2424import tempfile
2525from pathlib import Path
26+ from typing import Any
27+
2628from fastapi import APIRouter , HTTPException
2729from pydantic import BaseModel , Field
28- from typing import Optional , List , Dict , Any
29- import os
3030
31- from a2ascanner .core . scanner import Scanner
31+ from a2ascanner .config . config import Config
3232from a2ascanner .core .analyzer_factory import build_core_analyzers
3333from a2ascanner .core .scan_policy import ScanPolicy
34- from a2ascanner .config .config import Config
35- from a2ascanner .utils .http_client import fetch_agent_card
34+ from a2ascanner .core .scanner import Scanner
3635from a2ascanner .exceptions import (
36+ A2AScannerError ,
37+ AuthenticationError ,
3738 NetworkError ,
39+ SSRFError ,
3840 TimeoutError ,
3941 ValidationError ,
40- SSRFError ,
41- AuthenticationError ,
42- A2AScannerError ,
4342)
44- from a2ascanner .utils .logging_config import set_correlation_id , get_logger
43+ from a2ascanner .utils .http_client import fetch_agent_card
44+ from a2ascanner .utils .logging_config import get_logger , set_correlation_id
4545
4646logger = get_logger (__name__ )
4747
5151# API-only fields stripped when the request body is a raw agent card JSON object
5252_SCAN_REQUEST_META_FIELDS = frozenset ({"analyzers" , "policy" })
5353
54- # Directory containing allowed policy YAML files for file-based policies.
55- # Paths provided via the API are resolved relative to this directory and
56- # must not escape it.
57- _POLICY_DIR = Path ("policies" ).resolve ()
5854
55+ def _resolve_policy (policy_name : str | None ) -> ScanPolicy :
56+ """Resolve a policy preset name to a *ScanPolicy*.
5957
60- def _resolve_policy (policy_name : Optional [str ]) -> ScanPolicy :
61- """Resolve a policy name to a ScanPolicy, with safe handling of file-based policies.
62-
63- The policy name may refer to a built-in preset or to a YAML file under
64- the configured _POLICY_DIR directory. When resolving file-based policies,
65- this function enforces that the resulting path is strictly contained
66- within _POLICY_DIR to prevent directory traversal or access to arbitrary
67- files on the filesystem.
58+ Only built-in presets (``strict``, ``balanced``, ``permissive``) are
59+ accepted. Arbitrary file paths are **not** supported via the API to
60+ prevent path-injection attacks. Use the CLI for file-based policies.
6861 """
6962 if not policy_name :
7063 return ScanPolicy .default ()
7164
72- # Normalize simple whitespace-only or empty strings to default behavior.
73- if isinstance (policy_name , str ):
74- policy_name = policy_name .strip ()
75- if not policy_name :
76- return ScanPolicy .default ()
77- else :
78- # Reject non-string values for policy names.
79- logger .warning ("Invalid non-string policy name %r" , policy_name )
80- return ScanPolicy .default ()
81-
82- # Basic validation: reject absolute paths or any path separators to
83- # ensure policy_name is treated as a simple file name/key rather than a path.
84- if os .path .isabs (policy_name ) or os .sep in policy_name or (
85- os .altsep is not None and os .altsep in policy_name
86- ):
87- logger .warning ("Rejected invalid policy name %r" , policy_name )
65+ name = policy_name .strip () if isinstance (policy_name , str ) else ""
66+ if not name :
8867 return ScanPolicy .default ()
8968
90- # First, try resolving the policy as a named preset.
9169 try :
92- return ScanPolicy .from_preset (policy_name )
70+ return ScanPolicy .from_preset (name )
9371 except ValueError :
94- # Fall back to treating the policy name as a file within the
95- # configured policy directory, with path traversal protection.
96- pass
97-
98- # For file-based policies, enforce that the user cannot supply an
99- # absolute path and only YAML files under _POLICY_DIR are allowed.
100- # This defends against directory traversal and access to arbitrary files.
101- if os .path .isabs (policy_name ):
102- logger .warning ("Rejected absolute policy path %r" , policy_name )
72+ logger .warning ("Unknown policy preset %r; falling back to default" , name )
10373 return ScanPolicy .default ()
10474
105- # Avoid obviously dangerous or nonsensical names (null bytes, only separators).
106- if "\x00 " in policy_name or all (ch in (os .sep , os .altsep or "" ) for ch in policy_name ):
107- logger .warning ("Rejected malformed policy path %r" , policy_name )
108- return ScanPolicy .default ()
109-
110- # Optionally, restrict to YAML/YML extensions to narrow what can be loaded.
111- if not policy_name .lower ().endswith ((".yaml" , ".yml" )):
112- logger .warning ("Rejected non-YAML policy path %r" , policy_name )
113- return ScanPolicy .default ()
114-
115- # Treat the policy name as a relative path under _POLICY_DIR and
116- # ensure the resulting path cannot escape that directory.
117- try :
118- candidate = (_POLICY_DIR / policy_name ).resolve ()
119- # Enforce that the resolved candidate remains within the allowed policy directory.
120- if candidate != _POLICY_DIR and _POLICY_DIR not in candidate .parents :
121- logger .warning (
122- "Rejected policy path %r: resolved path %s is outside of %s" ,
123- policy_name ,
124- candidate ,
125- _POLICY_DIR ,
126- )
127- return ScanPolicy .default ()
128- except Exception :
129- logger .warning ("Failed to resolve policy path %r" , policy_name , exc_info = True )
130- return ScanPolicy .default ()
131-
132- try :
133- # Ensure candidate is within the policy directory. Using relative_to
134- # provides a clear containment check and prevents directory traversal.
135- candidate .relative_to (_POLICY_DIR )
136- except ValueError :
137- logger .warning (
138- "Rejected policy path outside of policy directory: %s" , candidate
139- )
140- return ScanPolicy .default ()
141-
142- try :
143- if candidate .is_file ():
144- return ScanPolicy .from_yaml (candidate )
145- except Exception :
146- # Any issues reading or parsing the policy file fall through
147- # to the default policy.
148- logger .warning (
149- "Failed to load policy file %s safely" , candidate , exc_info = True
150- )
151-
152- return ScanPolicy .default ()
153-
15475
15576# Request models
15677class AgentCardScanRequest (BaseModel ):
15778 """Request model for agent card scan."""
15879
159- agent_card_url : Optional [ str ] = Field (None , description = "URL to agent card" )
160- agent_card_json : Optional [ str ] = Field (None , description = "Agent card JSON string" )
161- agent_card_data : Optional [ Dict [ str , Any ]] = Field (
80+ agent_card_url : str | None = Field (None , description = "URL to agent card" )
81+ agent_card_json : str | None = Field (None , description = "Agent card JSON string" )
82+ agent_card_data : dict [ str , Any ] | None = Field (
16283 None , description = "Agent card as dict"
16384 )
164- analyzers : Optional [ List [ str ]] = Field (
85+ analyzers : list [ str ] | None = Field (
16586 None ,
16687 description = "Analyzers to use; omit for all applicable analyzers (except endpoint)" ,
16788 )
168- policy : Optional [ str ] = Field (
89+ policy : str | None = Field (
16990 None ,
17091 description = "Policy preset: strict, balanced, permissive; omit for default" ,
17192 )
@@ -179,11 +100,11 @@ class SourceCodeScanRequest(BaseModel):
179100 """Request model for source code scan."""
180101
181102 directory : str = Field (..., description = "Path to source code directory" )
182- analyzers : Optional [ List [ str ]] = Field (
103+ analyzers : list [ str ] | None = Field (
183104 None ,
184105 description = "Analyzers to use; omit for default selection per file type" ,
185106 )
186- policy : Optional [ str ] = Field (
107+ policy : str | None = Field (
187108 None ,
188109 description = "Policy preset: strict, balanced, permissive; omit for default" ,
189110 )
@@ -193,7 +114,7 @@ class EndpointScanRequest(BaseModel):
193114 """Request model for endpoint scan."""
194115
195116 endpoint_url : str = Field (..., description = "Endpoint URL to scan" )
196- policy : Optional [ str ] = Field (
117+ policy : str | None = Field (
197118 None ,
198119 description = "Policy preset: strict, balanced, permissive; omit for default" ,
199120 )
@@ -203,13 +124,13 @@ class FullScanRequest(BaseModel):
203124 """Request model for full scan."""
204125
205126 directory : str = Field (..., description = "Path to source code directory" )
206- agent_card_url : Optional [ str ] = Field (None , description = "URL to agent card" )
207- endpoint_url : Optional [ str ] = Field (None , description = "Endpoint URL to test" )
208- analyzers : Optional [ List [ str ]] = Field (
127+ agent_card_url : str | None = Field (None , description = "URL to agent card" )
128+ endpoint_url : str | None = Field (None , description = "Endpoint URL to test" )
129+ analyzers : list [ str ] | None = Field (
209130 None ,
210131 description = "Analyzers to use; omit for default selection per file type" ,
211132 )
212- policy : Optional [ str ] = Field (
133+ policy : str | None = Field (
213134 None ,
214135 description = "Policy preset: strict, balanced, permissive; omit for default" ,
215136 )
@@ -219,11 +140,11 @@ class FileContentScanRequest(BaseModel):
219140 """Request model for scanning raw text content as a file."""
220141
221142 content : str = Field (..., description = "Raw text content to scan" )
222- analyzers : Optional [ List [ str ]] = Field (
143+ analyzers : list [ str ] | None = Field (
223144 None ,
224145 description = "Analyzers to use; omit for default selection per content type" ,
225146 )
226- policy : Optional [ str ] = Field (
147+ policy : str | None = Field (
227148 None ,
228149 description = "Policy preset: strict, balanced, permissive; omit for default" ,
229150 )
@@ -235,7 +156,7 @@ class ScanResponse(BaseModel):
235156
236157 success : bool
237158 message : str
238- result : Optional [ Dict [ str , Any ]] = None
159+ result : dict [ str , Any ] | None = None
239160
240161
241162# Routes
0 commit comments