Back to skill

Security audit

Ghost-Writer Sync

Security checks for vulnerabilities and agentic risk

Overview

The skill performs the advertised blog-to-vault sync, but it under-discloses and mishandles sensitive Ghost Admin credentials.

Review before installing. Use only a dedicated, revocable Ghost integration key, avoid storing the config in synced or shared folders, do not run show_config where logs are captured, and only configure trusted HTTPS Ghost URLs. The package should ideally be updated to use least-privileged Ghost Content API credentials or clearly disclose Admin API use, redact secrets, restrict config permissions, and avoid shell-style command templates.

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 (4)

T09 · Insecure Skill Coding Practices

Error
Location
SKILL.md:18
Finding
Shell Command Injection Through Unescaped Tool Arguments<![CDATA[ ## Vulnerability Details **File Location**: `SKILL.md`, lines 18-62 **Vulnerability Type**: Shell command injection **Risk Level**: High ### Vulnerable Code ```yaml execution: command: python3 {{SKILL_DIR}}/sync.py sync --config "{{config}}" --vault "{{vault}}" --format "{{format}}" output_format: markdown ``` ```yaml execution: command: python3 {{SKILL_DIR}}/sync.py add-substack --url "{{url}}" --config "{{config}}" output_format: markdown ``` ```yaml execution: command: python3 {{SKILL_DIR}}/sync.py add-ghost --url "{{url}}" --api-key "{{api_key}}" --config "{{config}}" output_format: markdown ``` ```yaml execution: command: python3 {{SKILL_DIR}}/sync.py list --config "{{config}}" output_format: markdown ``` ```yaml execution: command: python3 {{SKILL_DIR}}/sync.py config --config "{{config}}" output_format: markdown ``` ### Technical Analysis User-controlled tool arguments are interpolated directly into command strings. Surrounding arguments with double quotes is not sufficient shell escaping: command substitutions such as `$(...)` may still be evaluated, while an embedded quotation mark can terminate the quoted argument and introduce additional shell syntax. Exploitation depends on whether the Skill runtime executes these command templates through a shell. If it does, the `url`, `api_key`, `config`, `vault`, or `format` values can become command-injection vectors. The vulnerability violates the separation that should exist between executable commands and untrusted argument data. ### Attack Path 1. An attacker supplies or persuades the Agent to use a malicious tool argument, such as a crafted source URL or vault path. 2. The value is inserted into one of the `command` templates without shell-safe argument handling. 3. The Skill runtime passes the expanded command to a shell. 4. The shell interprets command substitution, quotation termination, separators, or redirection contained in the malicious value. 5. The injecte ...[truncated 574 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Configure tool execution as an executable plus an argument array rather than a shell command string. - Invoke Python with shell execution disabled, equivalent to: ```python subprocess.run( ["python3", skill_script, "add-ghost", "--url", url, "--api-key", api_key, "--config", config], shell=False, check=True, ) ``` - Use the Skill framework’s structured argument mechanism if available. - If command strings are unavoidable, apply a framework-supported shell-escaping function to every dynamic value. Do not rely only on double quotes. - Validate URLs, filesystem paths, format values, and configuration names against strict expected formats. - Add security tests using values containing quotation marks, semicolons, command substitutions, newlines, and redirection operators. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
sync.py:398
Finding
Ghost Admin API Credentials Are Exposed Through Command Arguments, Plaintext Configuration, and Configuration Output<![CDATA[ ## Vulnerability Details **File Location**: `sync.py`, lines 398-403, 489-490, and 529-534; `SKILL.md`, lines 43-52 **Vulnerability Type**: Plaintext credential exposure **Risk Level**: High ### Vulnerable Code ```python def save_config(config: dict, config_path: str) -> None: """Save config to JSON file.""" Path(config_path).write_text( json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) ``` ```python if args.command == "config": print(json.dumps(cfg, indent=2)) ``` ```python elif args.command == "add-ghost": if not args.url or not args.api_key: print("Error: --url and --api-key are required for add-ghost", file=sys.stderr) sys.exit(1) cfg.setdefault("sources", {}).setdefault("ghost", []).append({ "url": args.url, "api_key": args.api_key, }) save_config(cfg, args.config) ``` The tool definition also places the key directly in a command argument: ```yaml execution: command: python3 {{SKILL_DIR}}/sync.py add-ghost --url "{{url}}" --api-key "{{api_key}}" --config "{{config}}" output_format: markdown ``` ### Technical Analysis The `add-ghost` command accepts the Ghost key through `--api-key`, stores it directly inside the JSON configuration, and writes that configuration without explicitly restricting filesystem permissions. The `config` command then serializes the entire configuration to standard output without redacting `api_key`. Consequently, the credential may be exposed through: - Shell history or command telemetry. - Process argument inspection while the command is running. - Skill execution logs. - The plaintext JSON configuration. - Backups or synchronization systems containing the configuration. - Output captured when the `config` tool is invoked. The credential format and implementation correspond to an Admin API secret used to derive bearer tokens, making disclosure more consequential than exposure of a public content key. ...[truncated 1242 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Do not accept long-lived secrets as ordinary command-line arguments. - Prompt interactively with `getpass.getpass()` or retrieve the key from an environment variable, operating-system credential store, or dedicated secret manager. - Store only a credential reference in the general configuration. - If file-based secret storage is necessary, use a separate owner-only file and explicitly enforce mode `0600`. - Redact sensitive properties from configuration output, for example by replacing `api_key` with `"***REDACTED***"`. - Ensure logs never include API keys, authorization headers, or generated JWTs. - Prefer a least-privileged Ghost Content API key and public Content API endpoint if they satisfy the synchronization requirement. - Document key rotation and revocation procedures. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
sync.py:286
Finding
Admin Bearer Token Is Sent to an Unrestricted User-Configured URL<![CDATA[ ## Vulnerability Details **File Location**: `sync.py`, lines 286-313 **Vulnerability Type**: Unvalidated credential destination and server-side request forgery **Risk Level**: High ### Vulnerable Code ```python def fetch_ghost_posts(site_url: str, api_key: str, limit: int = 50) -> list[dict]: """Fetch published posts from a Ghost CMS via Content API. Args: site_url: e.g. "https://myblog.ghost.io" api_key: Ghost Content API key ("id:secret" format) limit: max posts to return (default 50) """ token = _ghost_jwt_token(api_key) api_url = site_url.rstrip("/") + f"/ghost/api/admin/posts/?limit={limit}&order=published_at%20desc&formats=html" try: req = urllib.request.Request( api_url, headers={ "Authorization": f"Bearer {token}", "User-Agent": "Ghost-Writer-Sync/1.0", }, ) with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read().decode("utf-8")) except urllib.error.URLError as e: print(f"Error fetching Ghost posts: {e}", file=sys.stderr) return [] ``` The destination is saved without validation: ```python cfg.setdefault("sources", {}).setdefault("ghost", []).append({ "url": args.url, "api_key": args.api_key, }) ``` ### Technical Analysis Network access is necessary for the declared synchronization function. However, the code derives an Admin API bearer token and attaches it to a URL built directly from the user-configured `site_url`. There is no enforcement of HTTPS, no hostname allowlist or binding between the key and its legitimate Ghost host, and no rejection of loopback, link-local, private, or otherwise reserved network destinations. A plaintext `http://` source can therefore expose the token to network interception, while an attacker-controlled host can directly receive it. The unrestricted URL also creates an SSRF primitive. The Skill can ...[truncated 1817 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Require `https` for every authenticated Ghost source and reject plaintext HTTP. - Parse URLs with `urllib.parse.urlsplit()` rather than constructing security decisions through string concatenation. - Reject URLs containing user information, fragments, unexpected ports, or unsupported schemes. - Bind each credential to an explicitly approved canonical hostname. - Resolve the hostname and reject loopback, private, link-local, multicast, unspecified, and reserved IP address ranges. - Revalidate the destination after DNS resolution and on every redirect. - Disable redirects for authenticated requests unless strictly required. Never forward authorization headers across origins. - Consider certificate pinning or explicit host verification for especially sensitive deployments. - Use the least-privileged public Content API endpoint when published content is the only required data. - Avoid returning network error details that might inadvertently expose internal addressing information in shared logs. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
SKILL.md:41
Finding
Documentation Misrepresents Admin API Authentication as Content API Authentication<![CDATA[ ## Vulnerability Details **File Location**: `SKILL.md`, lines 41-44 and 79-89; `sync.py`, lines 245-306 **Vulnerability Type**: Security-sensitive credential type misrepresentation **Risk Level**: Medium ### Vulnerable Code and Documentation The Skill declares: ```yaml - name: add_ghost description: Adds a Ghost blog as a sync source. Requires a Ghost Content API key in id:secret format. ``` The documentation states: ```markdown | Ghost | Content API key (`id:secret`) | Uses Admin API JWT auth | ``` The implementation creates a token for the Admin API: ```python payload = base64.urlsafe_b64encode( json.dumps({"iat": now, "exp": now + 300, "aud": "/admin/"}).encode() ).rstrip(b"=") ``` It then calls an Admin API endpoint: ```python api_url = site_url.rstrip("/") + f"/ghost/api/admin/posts/?limit={limit}&order=published_at%20desc&formats=html" ``` ### Technical Analysis The Skill describes the required credential as a Ghost Content API key while implementing Admin API JWT authentication and calling `/ghost/api/admin/posts/`. Content API keys and Admin API integration keys have materially different security implications. Calling the credential a Content API key can cause users to underestimate its sensitivity and provide a more privileged Admin integration secret without informed consent. This discrepancy also undermines least-privilege review: the declared functionality only requires retrieval of published posts, while the chosen authentication mechanism is associated with the Admin API. ### Attack Path 1. A user reads the tool description and believes the requested key is a lower-risk Content API credential. 2. The user supplies an `id:secret` Admin integration key to satisfy the implementation. 3. The Skill places the key in a command argument and persists it in plaintext configuration. 4. The implementation derives an Admin API token and uses the Admin endpoint. 5. If the key is exposed through the other identified weaknesses, th ...[truncated 543 chars]
Remediation
<![CDATA[ ## Remediation Suggestions - Correctly identify the exact Ghost credential type required by the implementation. - Clearly state that an Admin API integration key is sensitive and must not be treated as a public Content API key. - Prefer the Ghost Content API and a least-privileged Content API key when only published posts are required. - If the Admin API is genuinely necessary, explain why and document the minimum required integration permissions. - Align function names, docstrings, tool descriptions, examples, and endpoint usage so they consistently describe the same authentication mechanism. - Add a migration path that removes previously stored Admin secrets when switching to a lower-privilege API. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
Findings (4)

Lp3

Medium
Category
MCP Least Privilege
Confidence
94% confidence
Finding
The skill exposes network access plus local file read/write behavior but does not declare any explicit tool scope or permission boundaries. That omission increases the risk of over-broad execution in host environments, making it harder for operators and automated policy systems to constrain where it can read, write, or connect.

Intent-Code Divergence

Medium
Confidence
95% confidence
Finding
The helper docstring says it builds a 'Ghost Admin API JWT' and that the 'Ghost Content API accepts these tokens,' while the rest of the code uses an admin endpoint ('/ghost/api/admin/posts/') and the CLI/config documentation labels the credential as a generic 'Ghost API key'. This is an intent-level contradiction because the file header claims Ghost support 'via Content API with Admin API key' (L006), but the implementation actually uses the Admin API and an Admin API token format.

Missing User Warnings

Medium
Confidence
92% confidence
Finding
The skill stores Ghost API keys in plaintext JSON on disk via save_config(), with no warning, permission hardening, or safer secret-handling option. If the config file is readable by other local users, synced to cloud storage, committed to version control, or harvested by other tools in the vault environment, the Ghost Admin credential can be exposed and used to access or modify blog content via the Ghost Admin API.

Intent-Code Divergence

Low
Confidence
88% confidence
Finding
The tool description for `add_ghost` says it requires a Ghost Content API key, which is consistent with a read-only sync tool. However, the Supported Sources table states 'Uses Admin API JWT auth', which implies a different and more privileged API capability. This is an internal documentation contradiction about the skill's intended access level.

Static analysis

No suspicious patterns detected.