Skip to content

Commit 00640f0

Browse files
committed
fix: resolve CI failures from Copilot autofix side-effects
- Revert broken path validation in scan_policy.py from_yaml() that caused infinite recursion (default() -> from_yaml(absolute_path) -> reject absolute -> default() -> ...) - Simplify _resolve_policy() in routes.py to only accept preset names, eliminating CodeQL py/path-injection alerts entirely - Add static_analyzer to wizard _KNOWN_ANALYZERS set - Fix severity ordering in reporters (CRITICAL before HIGH) - Use dynamic version in SARIF reporter - Add permissions block to python-tests workflow (CodeQL alert #8) - Clean up deprecated typing imports (ruff auto-fix)
1 parent f930e68 commit 00640f0

8 files changed

Lines changed: 52 additions & 136 deletions

File tree

.github/workflows/python-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
- main
77
pull_request:
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
runs-on: ubuntu-latest

a2ascanner/api/routes.py

Lines changed: 33 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,25 @@
2323
import json
2424
import tempfile
2525
from pathlib import Path
26+
from typing import Any
27+
2628
from fastapi import APIRouter, HTTPException
2729
from 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
3232
from a2ascanner.core.analyzer_factory import build_core_analyzers
3333
from 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
3635
from 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

4646
logger = get_logger(__name__)
4747

@@ -51,121 +51,42 @@
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
15677
class 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

a2ascanner/cli/wizard.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
# Registry keys returned by :meth:`Scanner.get_available_analyzers` / ``--analyzers``.
3434
_KNOWN_ANALYZERS = frozenset(
3535
(
36+
"yara",
3637
"static_analyzer",
3738
"spec",
3839
"endpoint",
@@ -82,7 +83,7 @@ def run_wizard() -> int:
8283

8384
console.print(
8485
"\n[dim]Analyzers: leave empty to run all enabled by policy; "
85-
"or comma-separated registry keys: static_analyzer, spec, endpoint, llm, heuristic.[/dim]"
86+
"or comma-separated registry keys: yara, spec, endpoint, llm, heuristic.[/dim]"
8687
)
8788
analyzers_raw = Prompt.ask("Restrict analyzers (optional)", default="").strip()
8889
if analyzers_raw:

a2ascanner/core/reporters/html_reporter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ..models import ScanResult
2727

2828

29-
_SEVERITY_ORDER = ("HIGH", "CRITICAL", "MEDIUM", "LOW", "UNKNOWN", "SAFE")
29+
_SEVERITY_ORDER = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN", "SAFE")
3030

3131
_CSS = """
3232
:root {

a2ascanner/core/reporters/markdown_reporter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from ..models import ScanResult
2626

2727

28-
_SEVERITY_ORDER = ("HIGH", "CRITICAL", "MEDIUM", "LOW", "UNKNOWN", "SAFE")
28+
_SEVERITY_ORDER = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN", "SAFE")
2929

3030

3131
class MarkdownReporter:

a2ascanner/core/reporters/sarif_reporter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from typing import Any, Dict, List
2525
from urllib.parse import quote
2626

27+
import a2ascanner
28+
2729
from ..models import ScanResult
2830

2931

@@ -122,7 +124,7 @@ def generate_report(self, data: ScanResult) -> str:
122124
"driver": {
123125
"name": "a2a-scanner",
124126
"informationUri": "https://github.com/cisco-ai-defense/a2a-scanner",
125-
"version": "1.0.0",
127+
"version": a2ascanner.__version__,
126128
"rules": rule_list,
127129
}
128130
},

a2ascanner/core/reporters/table_reporter.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,13 @@ def _render_security_table(
125125
table.add_column("Severity", style="bold", width=8)
126126
table.add_column("Summary", width=26)
127127

128-
severity_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
128+
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
129129
sorted_findings = sorted(
130-
findings, key=lambda t: severity_order.get(t[1].severity, 3)
130+
findings, key=lambda t: severity_order.get(t[1].severity, 4)
131131
)
132132

133133
for idx, finding in sorted_findings:
134-
if finding.severity == "HIGH":
134+
if finding.severity in ("CRITICAL", "HIGH"):
135135
severity_style = "bold red"
136136
elif finding.severity == "MEDIUM":
137137
severity_style = "bold yellow"
@@ -244,13 +244,13 @@ def _render_spec_table(
244244
table.add_column("Severity", style="bold", width=8)
245245
table.add_column("Description", width=45)
246246

247-
severity_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
247+
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
248248
sorted_findings = sorted(
249-
findings, key=lambda t: severity_order.get(t[1].severity, 3)
249+
findings, key=lambda t: severity_order.get(t[1].severity, 4)
250250
)
251251

252252
for idx, finding in sorted_findings:
253-
if finding.severity == "HIGH":
253+
if finding.severity in ("CRITICAL", "HIGH"):
254254
severity_style = "bold red"
255255
elif finding.severity == "MEDIUM":
256256
severity_style = "bold yellow"

a2ascanner/core/scan_policy.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -144,22 +144,11 @@ def preset_names(cls) -> list[str]:
144144
def from_yaml(cls, path: str | Path) -> ScanPolicy:
145145
"""Load policy from a YAML file, merged on top of defaults.
146146
147-
This method is sometimes called with paths that may be influenced by
148-
user input (for example via API policy selection). To avoid unsafe
149-
filesystem access when it is used directly, we defensively reject
150-
obviously dangerous paths here. Callers that need more advanced
151-
policies (such as directory-based allowlists) should perform those
152-
checks before calling this method.
147+
Callers that accept user-supplied paths **must** validate and
148+
constrain them before calling this method (e.g. restrict to a
149+
known directory or an allowlist of preset names).
153150
"""
154151
path = Path(path)
155-
156-
# Basic hardening: reject absolute paths, parent-directory traversal,
157-
# or embedded null bytes, which are not expected for policy files and
158-
# could be used to target unexpected locations.
159-
if path.is_absolute() or ".." in path.parts or "\x00" in str(path):
160-
logger.warning("Rejected unsafe policy path %r", str(path))
161-
return cls.default()
162-
163152
with open(path, encoding="utf-8") as f:
164153
data = yaml.safe_load(f) or {}
165154
return cls._from_dict(data)

0 commit comments

Comments
 (0)