Back to skill

Security audit

Presidio Pii Scrubber for sensitive info

Security checks for vulnerabilities and agentic risk

Overview

This PII-protection skill has a coherent local anonymization purpose, but it needs review because raw customer data can be sent to environment-controlled endpoints and reversible PII mapping files are handled unsafely.

Review before installing. This skill is not clearly malicious, but it handles sensitive customer data and currently relies on environment variables and caller-supplied session IDs without enough validation. Use only with trusted local Presidio endpoints, avoid untrusted session IDs, run as an unprivileged user, and consider fixing endpoint validation and mapping-file path handling first.

Vulnerability Patterns
  • Unauthorized Access and Privilege EscalationObtains permissions beyond the task's legitimate needs
  • 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
Findings (4)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/presidio-scrub.py:6
Finding
Raw PII Can Be Transmitted to an Unrestricted Environment-Controlled Endpoint<![CDATA[ ## Vulnerability Details **File Location**: `scripts/presidio-scrub.py`, lines 6 and 12–18; sensitive transmission occurs at lines 48–51 **Vulnerability Type**: Unrestricted destination for sensitive-data transmission **Risk Level**: High ### Complete Code Snippet ```python ANALYZER_URL = os.environ.get("PRESIDIO_ANALYZER_URL", "http://localhost:5002") def http_post(url, data): payload = json.dumps(data).encode("utf-8") req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST") try: with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode("utf-8")) except Exception: return None ``` The raw input is subsequently included in the analyzer request: ```python # Analyze payload = {"text": text, "language": "en"} if recognizers: payload["ad_hoc_recognizers"] = recognizers entities = http_post(f"{ANALYZER_URL}/analyze", payload) ``` ### Technical Analysis The Skill is intended to keep customer PII on the local machine, and the default analyzer endpoint is `http://localhost:5002`. However, `PRESIDIO_ANALYZER_URL` can replace that destination with an arbitrary URL. The code does not validate that the resolved host is a loopback address, restrict the URL scheme, require TLS for remote connections, or authenticate the endpoint. The complete unredacted input is placed in the `text` property before analysis. Therefore, destination validation must occur before this request; anonymization cannot protect data that is already sent to an untrusted analyzer. This behavior contradicts the local-only trust boundary documented in `SKILL.md`, which states that data is sent only to localhost. Environment configurability may be operationally useful, but unrestricted remote configuration exceeds the minimum privileges required for the declared local-processing functionality. ### Attack Path 1. An attacker gains control of the environment ...[truncated 1173 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Parse configured URLs with `urllib.parse.urlsplit`. 2. Reject non-HTTP(S) schemes and URLs containing unexpected credentials or malformed hostnames. 3. Resolve the hostname and verify that every resulting address is loopback, such as `127.0.0.0/8` or `::1`, when operating in the default local-only mode. 4. Prefer fixed loopback endpoints unless remote operation is an explicitly enabled feature. 5. If remote analyzers must be supported, require a separate explicit opt-in, HTTPS certificate validation, endpoint authentication, and clear documentation that raw PII leaves the machine. 6. Validate the destination immediately before each connection to reduce hostname-resolution and redirect risks. 7. Disable or validate redirects so an approved loopback endpoint cannot redirect the request to an external host. 8. Update the trust statement to accurately describe any supported non-local behavior. ]]>

T05 · Unauthorized Access and Privilege Escalation

Error
Location
scripts/presidio-scrub.py:27
Finding
Path Traversal in Mapping Creation Enables File Overwrite and Permission Changes<![CDATA[ ## Vulnerability Details **File Location**: `scripts/presidio-scrub.py`, lines 27 and 96–101 **Vulnerability Type**: Path traversal and unsafe file creation **Risk Level**: High ### Complete Code Snippet ```python def main(): session_id = sys.argv[1] if len(sys.argv) > 1 else str(int(time.time())) ``` The unvalidated identifier is later incorporated into a filesystem path: ```python # Save mapping os.makedirs(MAPPING_DIR, exist_ok=True) mf = os.path.join(MAPPING_DIR, f"{session_id}.json") with open(mf, "w") as f: json.dump({"session_id": session_id, "created": int(time.time()), "reverse_map": reverse_map, "entity_count": len(entities_fwd), "entity_types": list(type_counters.keys())}, f, indent=2) os.chmod(mf, 0o600) ``` ### Technical Analysis `session_id` is attacker-controlled command-line input. It is concatenated with `.json` and passed to `os.path.join` without character restrictions, canonicalization, or a check that the resulting path remains beneath `MAPPING_DIR`. A value containing `../` components can escape the intended mappings directory. On POSIX systems, an absolute second path component also causes `os.path.join` to discard the base directory. The ordinary `open(..., "w")` operation follows symbolic links and truncates an existing target. The subsequent `os.chmod` then changes the target's permissions to `0600`. Mapping creation occurs only when Presidio detects at least one entity, but an attacker can satisfy that condition by supplying input containing an obvious email address, phone number, or other recognized PII. ### Attack Path 1. The attacker identifies a writable target whose effective path can end in `.json`, or prepares a symbolic link at a traversable destination. 2. The attacker invokes `presidio-scrub.py` with a session ID containing path traversal components or an absolute path. 3. The attacker submits text that is expected to trigger at least one PII detection. 4. The analyzer returns one or more entities, ...[truncated 958 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Generate session identifiers internally with `secrets.token_urlsafe()` or UUIDv4 rather than accepting arbitrary path-like values. 2. If caller-supplied IDs are necessary, enforce a strict allowlist such as `^[A-Za-z0-9_-]{1,128}$`. 3. Resolve the candidate mapping path and verify with `os.path.commonpath` that it remains under the resolved mapping directory. 4. Reject absolute paths, path separators, `.` and `..` components, null characters, and platform-specific alternate separators. 5. Create files with `os.open` using `O_WRONLY | O_CREAT | O_EXCL` and, where available, `O_NOFOLLOW`. 6. Pass mode `0o600` at file creation rather than applying it afterward. 7. Open the returned file descriptor with `os.fdopen` and write through that descriptor. 8. Ensure `MAPPING_DIR` is owned by the expected account and has mode `0700`. 9. Run the Skill as an unprivileged dedicated user with access only to the required mapping directory. ]]>

T05 · Unauthorized Access and Privilege Escalation

Error
Location
scripts/presidio-restore.py:14
Finding
Path Traversal in Restore Enables Unauthorized JSON Reads and Deletion<![CDATA[ ## Vulnerability Details **File Location**: `scripts/presidio-restore.py`, lines 14–27 and 35–36 **Vulnerability Type**: Path traversal, unsafe file access, and unauthorized deletion **Risk Level**: High ### Complete Code Snippet ```python session_id = args[0] if len(args) > 1: text = " ".join(args[1:]) elif not sys.stdin.isatty(): text = sys.stdin.read().strip() else: print(json.dumps({"error": "No input text"})); sys.exit(1) mf = os.path.join(MAPPING_DIR, f"{session_id}.json") if not os.path.exists(mf): print(json.dumps({"error": f"No mapping for session {session_id}", "text": text})); sys.exit(1) with open(mf) as f: mapping = json.load(f) ``` After processing, the selected path is deleted by default: ```python if not keep: try: os.remove(mf) except OSError: pass ``` ### Technical Analysis The restore script accepts a caller-controlled session ID and uses it directly in a filesystem path. It does not restrict path separators, reject absolute paths, canonicalize the result, or verify containment within `MAPPING_DIR`. If the selected file contains valid JSON, the script reads it and interprets its `reverse_map` object. Unless `--keep` is supplied, it then deletes the path. Symbolic links are followed during reading. The deletion operation removes the selected directory entry, while traversal can select files outside the intended mapping directory. The attacker must target a path whose final constructed name ends in `.json`, but that restriction still includes many configuration, state, and application-data files. ### Attack Path 1. The attacker identifies a readable, valid JSON file outside the mappings directory that is addressable through traversal and has the required filename suffix. 2. The attacker invokes `presidio-restore.py` with a session ID such as a relative traversal path that resolves to that file. 3. The attacker provides any input text through an argument or standard input. 4. The script opens and ...[truncated 747 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Apply the same strict session-ID allowlist used by the scrubber. 2. Resolve both `MAPPING_DIR` and the candidate file, then reject any candidate not contained beneath the mapping directory. 3. Use `lstat` and descriptor-based operations to reject symbolic links and non-regular files. 4. Open files with `O_NOFOLLOW` where supported. 5. Verify the mapping schema, including an expected version and the exact session ID, before using it. 6. Delete files through a verified directory file descriptor rather than an unchecked path. 7. Refuse deletion if the file identity changes between validation, reading, and cleanup. 8. Store mappings in a private `0700` directory and execute the restore process without elevated privileges. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/presidio-scrub.py:27
Finding
Sensitive Mapping Files Are Created with Predictable Names and Race-Prone Permissions<![CDATA[ ## Vulnerability Details **File Location**: `scripts/presidio-scrub.py`, lines 27 and 96–101 **Vulnerability Type**: Unsafe sensitive-file creation and symlink race **Risk Level**: Medium ### Complete Code Snippet ```python session_id = sys.argv[1] if len(sys.argv) > 1 else str(int(time.time())) ``` When no session ID is supplied, the current Unix timestamp is used. The file is then created as follows: ```python # Save mapping os.makedirs(MAPPING_DIR, exist_ok=True) mf = os.path.join(MAPPING_DIR, f"{session_id}.json") with open(mf, "w") as f: json.dump({"session_id": session_id, "created": int(time.time()), "reverse_map": reverse_map, "entity_count": len(entities_fwd), "entity_types": list(type_counters.keys())}, f, indent=2) os.chmod(mf, 0o600) ``` ### Technical Analysis The mapping contains the direct association between anonymized tokens and original PII. It is created with ordinary `open(..., "w")`, which is neither exclusive nor resistant to symbolic links. When the default session ID is used, the filename is based only on the current timestamp and is readily predictable. The requested `0600` permissions are applied only after the JSON content has been written and the file has been closed. Initial permissions therefore depend on the process umask. With an unsafe umask or a shared mapping directory, another local user may have a window in which the file is readable. Because creation is not exclusive, an attacker with write access to the mapping directory can pre-create the expected filename or place a symbolic link there. The write can then overwrite an existing file or follow the link to another target. ### Attack Path 1. A local attacker has access to inspect or modify an inadequately protected `MAPPING_DIR`. 2. The attacker predicts a timestamp-based session filename or observes a caller-selected session ID. 3. The attacker repeatedly checks for the new file to read it before `chmod`, or pre-creates the path as a symbolic link. 4. ...[truncated 706 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Create `MAPPING_DIR` with mode `0700` and verify its ownership and permissions before every use. 2. Generate cryptographically random, non-predictable session identifiers. 3. Use `os.open` with `O_WRONLY | O_CREAT | O_EXCL` and `O_NOFOLLOW` where available. 4. Supply mode `0o600` directly to `os.open`, ensuring restrictive permissions exist from the moment of creation. 5. Use `os.fdopen` to serialize JSON through the securely created descriptor. 6. Refuse to operate if the destination already exists rather than truncating it. 7. Validate with `fstat` that the opened object is a regular file owned by the expected account. 8. Consider encrypting mappings at rest when they may persist beyond a single process operation. 9. Add cleanup for abandoned mappings and avoid the optional `--keep` behavior unless retention is explicitly required and protected. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
  • Rogue AgentSelf-Modification, Session Persistence
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
Findings (18)

External Script Fetching

High
Category
Supply Chain
Content
- Docker running locally (Colima recommended for headless Mac Mini, Docker Desktop also works)
- Python 3 (included with macOS)
- curl (included with macOS)

## Quick Install
Confidence
90% confidence
Finding
Remote code is downloaded and executed. This bypasses code review and could introduce malicious code.

External Script Fetching

High
Category
Supply Chain
Content
emoji: ""
    requires:
      bins:
        - curl
        - python3
        - docker
    files:
Confidence
90% confidence
Finding
Remote code is downloaded and executed. This bypasses code review and could introduce malicious code.

External Transmission

Medium
Category
Data Exfiltration
Content
- Docker running locally (Colima recommended for headless Mac Mini, Docker Desktop also works)
- Python 3 (included with macOS)
- curl (included with macOS)

## Quick Install
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Session Persistence

Medium
Category
Rogue Agent
Content
### 1. Start Presidio containers

Create a docker-compose.yml at ~/.openclaw/presidio/ with analyzer (port 5002:3000) and anonymizer (port 5001:3000). Then run: docker compose up -d

IMPORTANT: Presidio containers listen on port 3000 internally. Map 5002:3000 (analyzer) and 5001:3000 (anonymizer). If you map 5002:5002, you'll get empty replies.
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
- 100% local. Presidio containers run on localhost. No data leaves your machine.
- Fail-closed. If Presidio is down, the skill blocks data queries rather than sending unprotected PII.
- Ephemeral mappings. Token-to-real-value mapping files are created per request, stored with chmod 600, and auto-deleted after restore.
- Vanilla containers. Custom recognizers are passed via API calls, not baked into containers. Pull updated images anytime without losing your config.

## Why Colima over Docker Desktop?
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
- 100% local. Presidio containers run on localhost. No data leaves your machine.
- Fail-closed. If Presidio is down, the skill blocks data queries rather than sending unprotected PII.
- Ephemeral mappings. Token-to-real-value mapping files are created per request, stored with chmod 600, and auto-deleted after restore.
- Vanilla containers. Custom recognizers are passed via API calls, not baked into containers. Pull updated images anytime without losing your config.

## Why Colima over Docker Desktop?
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Lp3

Medium
Category
MCP Least Privilege
Confidence
89% confidence
Finding
The skill declares executable capabilities through required binaries and referenced scripts, but it does not define any explicit tool scope such as permissions or allowed-tools. In an agent environment, this creates unnecessary ambiguity about what the skill is allowed to access, increasing the risk of over-broad shell, network, environment, or file operations during use.

Natural-Language Policy Violations

Medium
Confidence
90% confidence
Finding
This JSON file repeatedly sets "supported_language": "en" for each recognizer, which enforces a single language/locale policy in natural-language configuration. The file provides no indication that users can choose another language or that the English-only restriction is required for a justified regional or compliance reason.

Description-Behavior Mismatch

Medium
Confidence
93% confidence
Finding
The manifest describes scrubbing customer data such as names, phones, emails, addresses, credit cards, and vessel names before sending data to AI models. This config additionally classifies a large curated list of South Florida, Houston, and statewide Florida city/place names as LOCATION, which materially broadens redaction beyond the stated examples and can remove ordinary geographic context that is not necessarily customer PII.

Description-Behavior Mismatch

Medium
Confidence
90% confidence
Finding
The manifest enumerates customer-data categories including names, phones, emails, addresses, credit cards, and vessel names, but this config also detects SEA_COOL_PROJECT identifiers. Project IDs are a distinct business identifier category not mentioned in the description, so the code's effective redaction scope is broader than the skill claims.

External Transmission

Medium
Category
Data Exfiltration
Content
analyzer_ok=false
anonymizer_ok=false

analyzer_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$ANALYZER_URL/analyze" \
  -X POST -H "Content-Type: application/json" \
  -d '{"text":"health check","language":"en"}' 2>/dev/null)
[ "$analyzer_status" = "200" ] && analyzer_ok=true
Confidence
70% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Description-Behavior Mismatch

Medium
Confidence
92% confidence
Finding
The manifest describes a skill focused on protecting PII by scrubbing customer data before model access, but this file performs the inverse operation by replacing tokens with original sensitive values. Reversible pseudonymization is mentioned in the manifest, yet the restoration capability materially expands behavior from protection/scrubbing into re-exposure of PII.

Missing User Warnings

Medium
Confidence
92% confidence
Finding
This code sends the input `text` to HTTP endpoints via `http_post` for analysis, and the script's only docstring describes anonymization and mapping storage, not that raw input is transmitted to separate services. For a code file handling sensitive data, this is a safety-relevant network operation lacking visible user disclosure such as a prompt, log message, or warning comment.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
The script stores a reversible map from pseudonymous tokens back to original PII on disk, creating a concentrated secret that defeats anonymization if accessed by another local user, process, backup system, or compromised account. Although the file mode is set to 0600, the design still materially increases exposure because the mapping persists beyond the immediate operation and is written without any consent, retention, or encryption controls.

Tainted flow: 'mf' from os.environ.get (line 98, credential/environment) → open (file write)

Medium
Category
Data Flow
Content
# Save mapping
    os.makedirs(MAPPING_DIR, exist_ok=True)
    mf = os.path.join(MAPPING_DIR, f"{session_id}.json")
    with open(mf, "w") as f:
        json.dump({"session_id": session_id, "created": int(time.time()), "reverse_map": reverse_map, "entity_count": len(entities_fwd), "entity_types": list(type_counters.keys())}, f, indent=2)
    os.chmod(mf, 0o600)
Confidence
93% confidence
Finding
The file path used for the reversible PII mapping is derived from an environment-controlled base directory and an unsanitized session_id taken from argv. An attacker who can influence either value can cause path traversal or arbitrary file overwrite, which is especially sensitive here because the file contains reversible mappings from anonymized tokens back to original PII.

Natural-Language Policy Violations

Low
Confidence
88% confidence
Finding
The HTML comment includes the statement "I'm praying for you without your consent," which is natural-language content embedded in the skill file and directed at users or maintainers. This introduces an unsolicited personal/religious message that is not justified by the skill's function and can conflict with organizational expectations for neutral, consent-respecting communication.

Missing User Warnings

Low
Confidence
87% confidence
Finding
This code performs outbound HTTP POST requests to analyzer and anonymizer services, which is a safety-relevant network operation for a code file. The script provides no confirmation prompt, visible notice, or explanatory comment/docstring about contacting external/local services, so users running it may not realize it transmits request data over HTTP.

Natural-Language Policy Violations

Low
Confidence
93% confidence
Finding
The request body explicitly sets "language":"en", which imposes a specific language choice in natural-language behavior. There is no indication here that the skill offers language selection or that the English-only constraint is documented as intentional and justified.

Static analysis

No suspicious patterns detected.