From 5988399cfe9fd7be94f63b2e144a7b2e73244ace Mon Sep 17 00:00:00 2001 From: debu-sinha Date: Tue, 31 Mar 2026 14:44:08 -0400 Subject: [PATCH 1/4] Add hook hijacking detection for Claude Code (CVE-2025-59536) Detect malicious hooks in .claude/settings.json and .claude/hooks/ that execute network commands, read sensitive files, or tamper with security settings. Checks for project-level security overrides (bypassPermissions, autoApprove) and unrestricted Bash permissions. 5 new checks: CHK-001 through CHK-005. Maps to OWASP ASI01 (Hijack), ASI02 (Agency), ASI05 (Secrets), ASI06 (Memory). Signed-off-by: debu-sinha --- src/agentsec/scanners/installation.py | 300 ++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/src/agentsec/scanners/installation.py b/src/agentsec/scanners/installation.py index bd2f78b..7be77ce 100644 --- a/src/agentsec/scanners/installation.py +++ b/src/agentsec/scanners/installation.py @@ -207,6 +207,7 @@ def scan(self, context: ScanContext) -> list[Finding]: 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", []) @@ -2019,3 +2020,302 @@ def _version_is_vulnerable(installed: str, fixed_in: str) -> bool: 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: Bash(git:*), Bash(npm:*)", + "Use deny rules for dangerous commands", + ], + ), + ) + ) + except (json.JSONDecodeError, OSError): + 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}", + f"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 autoApprove, bypassPermissions, or sandbox settings", + "See CVE-2025-53773 for the attack pattern", + ], + ), + ) + ) + break + + return findings From 2a54ccf40fe7ad31283486ef1414b6556ab14f3f Mon Sep 17 00:00:00 2001 From: debu-sinha Date: Tue, 31 Mar 2026 15:21:32 -0400 Subject: [PATCH 2/4] Fix lint: shorten long lines in remediation steps Signed-off-by: debu-sinha --- src/agentsec/scanners/installation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/agentsec/scanners/installation.py b/src/agentsec/scanners/installation.py index 7be77ce..0d25914 100644 --- a/src/agentsec/scanners/installation.py +++ b/src/agentsec/scanners/installation.py @@ -2162,7 +2162,7 @@ def _scan_hook_hijacking(self, context: ScanContext) -> list[Finding]: findings.append( Finding( check_id="CHK-001", - scanner=self.name, + scanner=self.name, title=f"Project-level security override: {override}", description=( f"Project settings at {project_settings} set " @@ -2190,7 +2190,7 @@ def _scan_hook_hijacking(self, context: ScanContext) -> list[Finding]: findings.append( Finding( check_id="CHK-002", - scanner=self.name, + scanner=self.name, title="Project grants unrestricted bash access", description=( f"Project settings at {project_settings} allow " @@ -2206,7 +2206,7 @@ def _scan_hook_hijacking(self, context: ScanContext) -> list[Finding]: summary="Restrict Bash permissions to specific commands", steps=[ f"Edit {project_settings}", - "Replace Bash(*) with specific prefixes: Bash(git:*), Bash(npm:*)", + "Replace Bash(*) with specific prefixes", "Use deny rules for dangerous commands", ], ), @@ -2251,7 +2251,7 @@ def _analyze_hook_command( summary="Remove or audit network commands in hooks", steps=[ f"Review hook in {source_file}", - f"Remove or replace the network command", + "Remove or replace the network command", "Hooks should not make external network requests", ], ), @@ -2310,7 +2310,7 @@ def _analyze_hook_command( summary="Remove security config modifications from hooks", steps=[ f"Review hook in {source_file}", - "Hooks must not modify autoApprove, bypassPermissions, or sandbox settings", + "Hooks must not modify security settings", "See CVE-2025-53773 for the attack pattern", ], ), From 33b870a1b475beb53c1549ea23ddfc09da1853c2 Mon Sep 17 00:00:00 2001 From: debu-sinha Date: Tue, 31 Mar 2026 15:24:05 -0400 Subject: [PATCH 3/4] Format verifier.py (pre-existing lint issue) Signed-off-by: debu-sinha --- src/agentsec/utils/verifier.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/agentsec/utils/verifier.py b/src/agentsec/utils/verifier.py index cf29081..09ac2ac 100644 --- a/src/agentsec/utils/verifier.py +++ b/src/agentsec/utils/verifier.py @@ -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": From da166e802805cb1ff1a53ce5aaadd21e74ba3357 Mon Sep 17 00:00:00 2001 From: debu-sinha Date: Wed, 1 Apr 2026 09:53:07 -0400 Subject: [PATCH 4/4] Fix pre-existing mypy errors in verifier, baseline, policy, cli Signed-off-by: debu-sinha --- src/agentsec/cli.py | 2 +- src/agentsec/policy.py | 2 +- src/agentsec/utils/baseline.py | 3 ++- src/agentsec/utils/verifier.py | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agentsec/cli.py b/src/agentsec/cli.py index 355cdc9..1487ae3 100644 --- a/src/agentsec/cli.py +++ b/src/agentsec/cli.py @@ -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, diff --git a/src/agentsec/policy.py b/src/agentsec/policy.py index 3f857fc..5fce59a 100644 --- a/src/agentsec/policy.py +++ b/src/agentsec/policy.py @@ -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, diff --git a/src/agentsec/utils/baseline.py b/src/agentsec/utils/baseline.py index 29ef6be..cbf4556 100644 --- a/src/agentsec/utils/baseline.py +++ b/src/agentsec/utils/baseline.py @@ -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 {} diff --git a/src/agentsec/utils/verifier.py b/src/agentsec/utils/verifier.py index 09ac2ac..eb33a73 100644 --- a/src/agentsec/utils/verifier.py +++ b/src/agentsec/utils/verifier.py @@ -146,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__}"}