Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/agentsec/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def scan(
config = AgentsecConfig(
targets=[ScanTarget(path=target_path)],
scanners=scanner_configs,
output_format=output,
output_format=output, # type: ignore[arg-type]
output_path=Path(output_file) if output_file else None,
fail_on_severity=fail_on if fail_on != "none" else None,
policy_path=Path(policy) if policy else None,
Expand Down
2 changes: 1 addition & 1 deletion src/agentsec/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from pathlib import Path
from typing import Any

import yaml
import yaml # type: ignore[import-untyped]

from agentsec.models.findings import (
Finding,
Expand Down
300 changes: 300 additions & 0 deletions src/agentsec/scanners/installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
findings.extend(self._scan_memory_config(context))
findings.extend(self._scan_multi_agent_config(context))
findings.extend(self._scan_audit_config(context))
findings.extend(self._scan_hook_hijacking(context))

# Pick up deferred findings (e.g., malformed JSON detected during config loading)
deferred = context.metadata.pop("_deferred_findings", [])
Expand Down Expand Up @@ -2019,3 +2020,302 @@
return inst_parts < fix_parts
except (ValueError, AttributeError):
return False

# ------------------------------------------------------------------
# Hook hijacking detection (CVE-2025-59536)
# ------------------------------------------------------------------

# Patterns in hook commands that indicate network exfiltration
_HOOK_EXFIL_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"\bcurl\b", re.I),
re.compile(r"\bwget\b", re.I),
re.compile(r"\bnc\b"),
re.compile(r"\bncat\b"),
re.compile(r"\bnetcat\b"),
re.compile(r"requests\.(get|post|put)", re.I),
re.compile(r"urllib\.request"),
re.compile(r"http\.client"),
re.compile(r"\bfetch\("),
]

# Patterns that indicate reading sensitive files or environment
_HOOK_SENSITIVE_READ_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"cat\s+.*\.(env|pem|key|crt|secret)", re.I),
re.compile(r"cat\s+.*/\.ssh/", re.I),
re.compile(r"cat\s+.*/\.aws/", re.I),
re.compile(r"\$\{?\w*SECRET\w*\}?", re.I),
re.compile(r"\$\{?\w*API_KEY\w*\}?", re.I),
re.compile(r"\$\{?\w*TOKEN\w*\}?", re.I),
re.compile(r"os\.environ", re.I),
re.compile(r"printenv\b"),
re.compile(r"\benv\s*\|"),
]

# Patterns that indicate config tampering from hooks
_HOOK_CONFIG_TAMPER_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"autoApprove", re.I),
re.compile(r"bypassPermissions", re.I),
re.compile(r"dangerouslyDisableSandbox", re.I),
re.compile(r"settings\.json", re.I),
re.compile(r"settings\.local\.json", re.I),
re.compile(r"\.cursor/mcp\.json", re.I),
re.compile(r"\.vscode/settings", re.I),
]

# Claude Code hook directories and settings files
_HOOK_LOCATIONS: list[str] = [
".claude/settings.json",
".claude/settings.local.json",
".claude/hooks",
]

def _scan_hook_hijacking(self, context: ScanContext) -> list[Finding]:
"""Detect malicious hooks in Claude Code and similar agent configs.

Checks for CVE-2025-59536 attack patterns: project-level hooks that
execute shell commands with network access, read sensitive files, or
tamper with security settings.
"""
findings: list[Finding] = []
target = context.target_path

# Check Claude Code settings files for hook definitions
for settings_rel in [
".claude/settings.json",
".claude/settings.local.json",
]:
settings_path = target / settings_rel
if not settings_path.exists():
continue

try:
data = json.loads(settings_path.read_text())
except (json.JSONDecodeError, OSError):
continue

hooks = data.get("hooks", {})
if not hooks:
continue

for event_name, hook_list in hooks.items():
if not isinstance(hook_list, list):
continue
for hook_group in hook_list:
if not isinstance(hook_group, dict):
continue
for hook in hook_group.get("hooks", []):
if not isinstance(hook, dict):
continue
hook_type = hook.get("type", "")
command = hook.get("command", "")
prompt = hook.get("prompt", "")
content = command or prompt

if not content:
continue

findings.extend(
self._analyze_hook_command(
content,
event_name,
hook_type,
settings_path,
context,
)
)

# Check for hook script files in .claude/hooks/
hooks_dir = target / ".claude" / "hooks"
if hooks_dir.is_dir():
for hook_file in hooks_dir.iterdir():
if hook_file.is_file() and not hook_file.name.startswith("."):
try:
content = hook_file.read_text(errors="replace")
if len(content) > 100_000:
continue
findings.extend(
self._analyze_hook_command(
content,
hook_file.stem,
"script",
hook_file,
context,
)
)
except OSError:
continue

# Check for project-level settings overriding user security
project_settings = target / ".claude" / "settings.json"
user_overrides = [
"bypassPermissions",
"dangerouslyDisableSandbox",
"disableAllHooks",
"skipDangerousModePermissionPrompt",
]
if project_settings.exists():
try:
data = json.loads(project_settings.read_text())
perms = data.get("permissions", {})
for override in user_overrides:
if override in str(data):
findings.append(
Finding(
check_id="CHK-001",
scanner=self.name,
title=f"Project-level security override: {override}",
description=(
f"Project settings at {project_settings} set "
f"'{override}', which overrides user security "
f"preferences. A cloned repository should not "
f"disable security controls."
),
severity=FindingSeverity.CRITICAL,
category=FindingCategory.INSECURE_DEFAULT,
file_path=project_settings,
owasp_ids=["ASI01", "ASI06"],
remediation=Remediation(
summary=f"Remove '{override}' from project settings",
steps=[
f"Edit {project_settings}",
f"Remove the '{override}' key",
"Security overrides belong in user settings only",
],
),
)
)
# Check for overly permissive allow rules
allow_rules = perms.get("allow", [])
if any("Bash(*)" in str(r) or "Bash(*):" in str(r) for r in allow_rules):
findings.append(
Finding(
check_id="CHK-002",
scanner=self.name,
title="Project grants unrestricted bash access",
description=(
f"Project settings at {project_settings} allow "
f"unrestricted Bash execution via wildcard permission "
f"rule. This lets the agent run any command without "
f"confirmation."
),
severity=FindingSeverity.HIGH,
category=FindingCategory.INSECURE_DEFAULT,
file_path=project_settings,
owasp_ids=["ASI02", "ASI05"],
remediation=Remediation(
summary="Restrict Bash permissions to specific commands",
steps=[
f"Edit {project_settings}",
"Replace Bash(*) with specific prefixes",
"Use deny rules for dangerous commands",
],
),
)
)
except (json.JSONDecodeError, OSError):

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.

Copilot Autofix

AI 2 months ago

In general, an empty except should either (a) handle the error in a meaningful way (e.g., logging, fallback behavior, metrics) or (b) narrow the caught exception and re-raise if it cannot be safely ignored. Here, the scanner should not crash if a single settings file is unreadable, but it also should not fail silently.

Best minimal fix without changing existing functionality: keep the behavior of “no findings if the file is unreadable/unparsable”, but add a log entry inside the except block explaining that the project settings at project_settings could not be processed and why. We already have a logger defined at the top of the file, so we can reuse it without new imports. We should log at warning (or possibly debug); given this is an unexpected I/O/JSON failure on a security config, warning is reasonable and visible but not fatal.

Concretely, in src/agentsec/scanners/installation.py, in the _scan_hook_hijacking method region around lines 2156–2216, replace:

            except (json.JSONDecodeError, OSError):
                pass

with something like:

            except (json.JSONDecodeError, OSError) as exc:
                logger.warning(
                    "Failed to read or parse project settings at %s: %s",
                    project_settings,
                    exc,
                )

This maintains flow (no exception is propagated) but removes the empty except and improves diagnosability.

Suggested changeset 1
src/agentsec/scanners/installation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/agentsec/scanners/installation.py b/src/agentsec/scanners/installation.py
--- a/src/agentsec/scanners/installation.py
+++ b/src/agentsec/scanners/installation.py
@@ -2212,8 +2212,12 @@
                             ),
                         )
                     )
-            except (json.JSONDecodeError, OSError):
-                pass
+            except (json.JSONDecodeError, OSError) as exc:
+                logger.warning(
+                    "Failed to read or parse project settings at %s: %s",
+                    project_settings,
+                    exc,
+                )
 
         return findings
 
EOF
@@ -2212,8 +2212,12 @@
),
)
)
except (json.JSONDecodeError, OSError):
pass
except (json.JSONDecodeError, OSError) as exc:
logger.warning(
"Failed to read or parse project settings at %s: %s",
project_settings,
exc,
)

return findings

Copilot is powered by AI and may make mistakes. Always verify output.
pass

return findings

def _analyze_hook_command(
self,
content: str,
event_name: str,
hook_type: str,
source_file: Path,
context: ScanContext,
) -> list[Finding]:
"""Analyze a hook command/script for malicious patterns."""
findings: list[Finding] = []

# Check for network exfiltration
for pattern in self._HOOK_EXFIL_PATTERNS:
if pattern.search(content):
findings.append(
Finding(
check_id="CHK-003",
scanner=self.name,
title=f"Hook with network access: {event_name}",
description=(
f"Hook '{event_name}' (type={hook_type}) in "
f"{source_file} contains network commands "
f"(matched: {pattern.pattern}). Hooks execute "
f"automatically during agent operation and can "
f"exfiltrate data to external servers."
),
severity=FindingSeverity.CRITICAL,
category=FindingCategory.DATA_EXFILTRATION_RISK,
file_path=source_file,
owasp_ids=["ASI01", "ASI05"],
remediation=Remediation(
summary="Remove or audit network commands in hooks",
steps=[
f"Review hook in {source_file}",
"Remove or replace the network command",
"Hooks should not make external network requests",
],
),
)
)
break

# Check for sensitive data access
for pattern in self._HOOK_SENSITIVE_READ_PATTERNS:
if pattern.search(content):
findings.append(
Finding(
check_id="CHK-004",
scanner=self.name,
title=f"Hook reads sensitive data: {event_name}",
description=(
f"Hook '{event_name}' (type={hook_type}) in "
f"{source_file} accesses sensitive files or "
f"environment variables (matched: {pattern.pattern})."
),
severity=FindingSeverity.HIGH,
category=FindingCategory.EXPOSED_CREDENTIALS,
file_path=source_file,
owasp_ids=["ASI05"],
remediation=Remediation(
summary="Remove sensitive data access from hooks",
steps=[
f"Review hook in {source_file}",
"Hooks should not read secrets, keys, or credentials",
],
),
)
)
break

# Check for config tampering
for pattern in self._HOOK_CONFIG_TAMPER_PATTERNS:
if pattern.search(content):
findings.append(
Finding(
check_id="CHK-005",
scanner=self.name,
title=f"Hook modifies security settings: {event_name}",
description=(
f"Hook '{event_name}' (type={hook_type}) in "
f"{source_file} references security configuration "
f"(matched: {pattern.pattern}). This could disable "
f"approval prompts or sandbox protections "
f"(CVE-2025-53773 pattern)."
),
severity=FindingSeverity.CRITICAL,
category=FindingCategory.INSECURE_CONFIG,
file_path=source_file,
owasp_ids=["ASI01", "ASI06"],
remediation=Remediation(
summary="Remove security config modifications from hooks",
steps=[
f"Review hook in {source_file}",
"Hooks must not modify security settings",
"See CVE-2025-53773 for the attack pattern",
],
),
)
)
break

return findings
3 changes: 2 additions & 1 deletion src/agentsec/utils/baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ def load_baseline(path: Path) -> dict[str, dict[str, str]]:
data = json.loads(path.read_text())
if not isinstance(data, dict):
return {}
return data.get("findings", {})
result: dict[str, dict[str, str]] = data.get("findings", {})
return result
except (json.JSONDecodeError, OSError):
return {}

Expand Down
8 changes: 4 additions & 4 deletions src/agentsec/utils/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,8 @@ def compute_passive_hints(
pass

# --- Overall risk level ---
if (
hints.get("hint_file_type") == "template"
or hints.get("hint_context", "").startswith("near_revocation_word")
if hints.get("hint_file_type") == "template" or hints.get("hint_context", "").startswith(
"near_revocation_word"
):
hints["hint_risk_level"] = "low"
elif hints.get("hint_file_age") == "stale":
Expand Down Expand Up @@ -147,7 +146,8 @@ def verify_secret(secret_type: str, secret_value: str) -> dict[str, str]:
return {"verified": "unknown", "verify_method": "verifier_not_implemented"}

try:
return fn(secret_value)
result: dict[str, str] = fn(secret_value)
return result
except Exception as e:
logger.debug("Verification failed for %s: %s", secret_type, e)
return {"verified": "error", "verify_method": f"exception:{type(e).__name__}"}
Expand Down
Loading