Back to skill

Security audit

Skill Review

Security checks for vulnerabilities and agentic risk

Overview

The skill is broadly a ClawHub report scraper, but it has under-disclosed credential use, third-party VirusTotal access/caching, and automatic warning suppressions.

Install only if you are comfortable with it reading all local skills in the configured skills directory, contacting ClawHub and VirusTotal, using a VirusTotal API key from the environment or ~/.openclaw/.env, writing reports and vt-cache data, and applying bundled suppressions. Review suppressions.json before relying on the report, and run it in a network environment where browser navigation to scan links is acceptable.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (2)

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/skill_review.py:458
Finding
Weak VirusTotal URL Validation Permits Arbitrary Browser Navigation<![CDATA[ ## Vulnerability Details **File Location**: `scripts/skill_review.py:458-503` **Vulnerability Type**: Improper URL validation and server-side request forgery-like browser navigation **Risk Level**: Medium ### Vulnerable Code ```python def _scrape_virustotal(page: Any, vt_url: str, *, api_key: str | None = None) -> str | None: """Get VirusTotal analysis for a file. Prefers the VT v3 API (clean, structured) when api_key is available. Falls back to Playwright scraping of the VT GUI page otherwise. Results are cached by file hash — repeated queries for the same version return instantly without API calls. """ if not vt_url or "virustotal.com" not in vt_url: return None file_hash = _hash_from_vt_url(vt_url) if not file_hash: return None # ... try: page.goto(vt_url, wait_until="domcontentloaded", timeout=30000) ``` ### Technical Analysis The function treats a URL as trusted whenever the literal substring `virustotal.com` occurs anywhere in the URL. This does not verify the parsed hostname. For example, all of the following can satisfy the substring check while resolving to a host not controlled by VirusTotal: ```text https://virustotal.com.attacker.example/gui/file/<64-character-hash> https://attacker.example/virustotal.com/gui/file/<64-character-hash> http://virustotal.com.attacker.example/gui/file/<64-character-hash> ``` The URL is obtained from remotely rendered ClawHub page content and is subsequently passed to Playwright's `page.goto`. An attacker able to manipulate the VirusTotal report link can therefore cause the browser to navigate to an attacker-selected endpoint. The hash extraction requirement does not prevent exploitation because an attacker can include a valid-looking 64-character hexadecimal value in the path. Redirect destinations are not validated either. Even an initially accepted URL could redirect the browser to another host. ### Attack Path 1. An attacker c ...[truncated 1393 chars]
Remediation
<![CDATA[ ## Remediation Suggestions Parse and validate the URL before using it: ```python from urllib.parse import urlsplit def _validate_vt_url(value: str) -> str | None: try: parsed = urlsplit(value) except ValueError: return None if parsed.scheme != "https": return None if parsed.hostname not in {"virustotal.com", "www.virustotal.com"}: return None if parsed.username is not None or parsed.password is not None: return None if parsed.port not in (None, 443): return None if not re.fullmatch(r"/gui/file/[0-9a-fA-F]{64}/?", parsed.path): return None return value ``` Additional hardening should include: 1. Reject all non-HTTPS URLs. 2. Compare the parsed hostname exactly rather than using substring matching. 3. Reject embedded credentials, unexpected ports, fragments, and unexpected paths. 4. Validate every redirect destination and abort navigation if it leaves the approved hostname set. 5. Use Playwright request routing to block requests to loopback, private, link-local, and other non-public address ranges. 6. Prefer constructing the canonical VirusTotal URL from the extracted hash rather than navigating to the remote link verbatim: ```python vt_url = f"https://www.virustotal.com/gui/file/{file_hash}" ``` 7. Consider using a separate disposable browser context with JavaScript disabled where feasible for the scraping fallback. ]]>

other

Note
Location
scripts/skill_review.py:81
Finding
Bundled Automatic Suppressions Downgrade Security Findings<![CDATA[ ## Vulnerability Details **File Location**: `scripts/skill_review.py:81-100, 110-157, 809-816, 861-866`; `suppressions.json:1-42` **Vulnerability Type**: Security report integrity weakening **Risk Level**: Low ### Vulnerable Code The project automatically locates and loads its bundled suppression file: ```python def _load_suppressions(path: str | None) -> dict[str, list[dict[str, str]]]: """Load suppressions file — known-acceptable findings to ignore. Format: { "slug": [{"scanner": "VirusTotal|OpenClaw", "reason": "..."}] } A slug with any suppression entry will have its corresponding scanner status changed to "Acknowledged" in output (instead of Suspicious/Malicious). """ if not path: # Auto-detect: look next to this script, then /tmp for candidate in [ Path(__file__).resolve().parent.parent / "suppressions.json", Path(__file__).resolve().parent / "suppressions.json", ]: if candidate.exists(): path = str(candidate) break ``` Matching findings have their original severity replaced: ```python for rule in rules: scanner = rule.get("scanner", "").strip() pattern = rule.get("pattern", "").strip() if scanner == "VirusTotal" and vt_status in suppressible: if pattern: haystack = (vt_analysis or "").lower() if pattern.lower() not in haystack: continue # Pattern doesn't match — don't suppress vt_status = "Acknowledged" applied.append(rule) elif scanner == "OpenClaw" and oc_status in suppressible: if pattern: haystack = ((oc_summary or "") + " " + (oc_guidance or "")).lower() if pattern.lower() not in haystack: continue oc_status = "Acknowledged" applied.append(rule) ``` For example, the bundled configuration acknowledges findings ...[truncated 2877 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Make suppressions explicitly opt-in. Do not auto-load the bundled file unless the user provides `--suppressions`. 2. Preserve the original status in every output format: ```json { "originalStatus": "Suspicious", "disposition": "Acknowledged", "suppression": { "reason": "...", "reviewedAt": "...", "expiresAt": "..." } } ``` 3. Do not replace the scanner's severity. Treat acknowledgment as a separate review disposition. 4. Replace substring matching with stable finding identifiers where supported. 5. Bind each suppression to the reviewed skill version, artifact hash, or VirusTotal file hash. 6. Add mandatory expiration dates and reviewer identities to suppression entries. 7. Require a new review whenever the skill version, file hash, or scanner finding changes. 8. Display the original severity prominently in both the Markdown index and detailed sections. 9. Validate the suppression schema and reject unconditional or malformed rules. 10. Document suppression behavior in `SKILL.md`, including that suppressions can alter dashboard-facing results. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
Findings (7)

Ae1

High
Category
analysis-evasion
Content
- Enumerates local skills under `~/Developer/Skills` (folders that contain `SKILL.md`).
Confidence
100% confidence
Finding
Referenced artifact was not completely inspected

Ae1

High
Category
analysis-evasion
Content
- Enumerates local skills under `~/Developer/Skills` (folders that contain `SKILL.md`).
Confidence
100% confidence
Finding
Referenced artifact was not completely inspected

Credential Access

High
Category
Privilege Escalation
Content
key = os.environ.get("VIRUSTOTAL_API_KEY")
    if key:
        return key.strip()
    env_path = Path.home() / ".openclaw" / ".env"
    if env_path.exists():
        for line in env_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Lp3

Medium
Category
MCP Least Privilege
Confidence
89% confidence
Finding
The skill documentation describes capabilities to read local skills from the filesystem, access an environment variable (`VIRUSTOTAL_API_KEY`), fetch remote ClawHub pages, and write a report to `/tmp/`, but it does not declare any explicit tool scope such as `permissions` or `allowed-tools`. That mismatch increases the risk of overbroad execution in agent environments because consumers cannot easily constrain or review what the skill is permitted to access before running it.

Context-Inappropriate Capability

Medium
Confidence
89% confidence
Finding
The script accesses a VirusTotal API key from both environment variables and a local ~/.openclaw/.env file, which exceeds the minimal access implied by a ClawHub page-scraping/reporting skill. Reading secrets from a user home directory expands trust boundaries and can unexpectedly consume sensitive credentials during routine execution, especially when the user did not explicitly opt into API use.

Missing User Warnings

Medium
Confidence
88% confidence
Finding
The code silently loads a VirusTotal API key from ambient sources without any user-facing notice, consent, or audit signal. Even if the key is used only for legitimate lookups, undisclosed credential usage is dangerous because it can surprise operators, leak billing/quota context, and violate least-surprise and least-privilege expectations.

Description-Behavior Mismatch

Medium
Confidence
93% confidence
Finding
The implementation materially exceeds the declared behavior of scraping ClawHub pages by directly contacting VirusTotal and scraping its GUI, then persisting results in a local cache. This broadens network interaction and data retention in ways users may not expect, creating privacy, compliance, and operational risk if run in sensitive environments.

Static analysis

No suspicious patterns detected.