Back to skill

Security audit

公众号写稿 & 改写润色(AI)

Security checks for vulnerabilities and agentic risk

Overview

This writing skill is mostly coherent, but it can send sensitive drafts and API keys to arbitrary configured model endpoints and has a preset path bug that can read Markdown files outside the intended preset folders.

Review `article.yaml` and any draft package before running the skill, especially `default_structure` and `default_closing_block`. Use a dedicated low-quota API key, configure only trusted HTTPS model endpoints, and avoid passing confidential reference documents unless you are comfortable sending their full text to that provider.

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/write.py:263
Finding
Preset path traversal can disclose arbitrary local Markdown files<![CDATA[ ## Vulnerability Details **File Location**: `scripts/write.py:263-281, 293-304` **Vulnerability Type**: Path traversal leading to local file disclosure **Risk Level**: Medium ### Vulnerable Code ```python def _find_preset_file( preset_dirs: list[Path], subdir: str, name: str, exts: list[str], ) -> Path | None: for root in preset_dirs: d = root / subdir if not d.exists(): continue for ext in exts: p = d / f"{name}{ext}" if p.exists(): return p return None def _load_closing_block(screening: dict, article_cfg: dict) -> str: """ Closing block: preset selection reads default_closing_block from article.yaml. If no preset is selected, use the inline closing_block from merged context. """ default_name = _coerce_single_preset( "default_closing_block", article_cfg.get("default_closing_block"), ) if default_name: preset_dirs = _preset_dirs(_aws_root()) found = _find_preset_file( preset_dirs, "closing-blocks", default_name, [".md"], ) if found: _info(f"Loading closing block preset: {found}") return found.read_text(encoding="utf-8") return (screening.get("closing_block") or "").strip() ``` The same unsafe lookup is also used by `_load_structure_template()` for `default_structure`. ### Technical Analysis Preset names read from the article's `article.yaml` are appended directly to a preset directory: ```python p = d / f"{name}{ext}" ``` The code does not reject: - `..` path components; - forward or backward path separators; - absolute paths; - paths that resolve outside the intended preset directory. It also does not resolve the resulting candidate and verify that the candidate remains a descendant of `presets/structures` or `presets/closing-blocks`. For example, a preset value conceptually equivalent to ...[truncated 2006 chars]
Remediation
<![CDATA[ ## Remediation Suggestions Treat preset values strictly as logical names rather than paths. 1. Reject empty names, absolute paths, `..`, `/`, and `\`. 2. Restrict names to an explicit safe character set, such as letters, digits, spaces, underscores, and hyphens. 3. Resolve the candidate and verify that it remains below the intended preset directory. 4. Require `candidate.is_file()` rather than only `candidate.exists()`. 5. Apply the same validation to both structure and closing-block presets. 6. Add regression tests for traversal, absolute paths, encoded separators, and valid Unicode preset names. Example hardening: ```python _SAFE_PRESET_NAME = re.compile(r"^[\w -]+$", re.UNICODE) def _safe_preset_candidate( preset_dir: Path, name: str, ext: str, ) -> Path | None: if ( not name or Path(name).is_absolute() or ".." in Path(name).parts or "/" in name or "\\" in name or not _SAFE_PRESET_NAME.fullmatch(name) ): _err("Preset name contains prohibited path characters") root = preset_dir.resolve() candidate = (root / f"{name}{ext}").resolve() try: candidate.relative_to(root) except ValueError: _err("Preset path escapes the permitted preset directory") return candidate if candidate.is_file() else None ``` Where supported, `candidate.is_relative_to(root)` can be used instead of the `relative_to()` exception pattern. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/write.py:314
Finding
Unrestricted API endpoint permits plaintext transmission of credentials and article content<![CDATA[ ## Vulnerability Details **File Location**: `scripts/write.py:314-356` **Vulnerability Type**: Missing transport and destination validation for sensitive network requests **Risk Level**: Medium ### Vulnerable Code ```python def _detect_api_type(model_cfg: dict) -> str: """ Protocol detection priority: 1) Explicit provider, if configured 2) Automatic detection from base_url path characteristics """ p = (model_cfg.get("provider") or "").strip().lower() allowed = {"openai", "volcengine", "qwen", "gemini"} if p: if p not in allowed: _err( f"Unrecognized writing_model.provider: {p}; " "use openai | volcengine | qwen | gemini" ) return p base_url = (model_cfg.get("base_url") or "").strip().lower() if "/v1beta/models/" in base_url and ":generatecontent" in base_url: return "gemini" if ( "dashscope.aliyuncs.com" in base_url and "/compatible-mode/v1/chat/completions" in base_url ): return "qwen" if ( "volces.com" in base_url and "ark." in base_url and "/api/v3/chat/completions" in base_url ): return "volcengine" if "/v1/chat/completions" in base_url: return "openai" _err( "Unable to detect protocol type from writing_model.base_url/model." ) def _post_json( url: str, body: dict, api_key: str, timeout: int = 300, ) -> dict: data = json.dumps(body, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( url, data=data, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", "User-Agent": "aws-article-writer/1.0", }, ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read()) except urllib.error.HTTPError as e: error_body = e.read().dec ...[truncated 3119 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Require HTTPS for all external model endpoints by default. 2. Permit HTTP only through an explicit, prominently warned opt-in intended for trusted local development. 3. Parse the URL with `urllib.parse.urlsplit()` and reject embedded credentials, fragments, unsupported schemes, and malformed hosts. 4. Resolve the hostname and reject loopback, link-local, multicast, unspecified, and cloud-metadata addresses by default. 5. Consider rejecting private-network destinations unless the user explicitly enables a trusted internal proxy mode. 6. Provide an optional hostname allowlist for known providers and user-approved gateways. 7. Revalidate the destination after DNS resolution and on every redirect. 8. Disable automatic redirects or allow them only when the destination has the same trusted origin. Never forward bearer credentials across origins. 9. Display the final scheme, hostname, port, and endpoint immediately before sending sensitive material. 10. Recommend dedicated, minimally scoped API keys with spending limits and rotation procedures. 11. Add an explicit confirmation step when the endpoint hostname changes. 12. Preserve the existing local-only `prompt` mode as the privacy-preserving alternative. Example initial validation: ```python from urllib.parse import urlsplit import ipaddress import socket def _validate_endpoint(url: str, allow_private: bool = False) -> None: parsed = urlsplit(url) if parsed.scheme != "https": _err("Model endpoint must use HTTPS") if not parsed.hostname: _err("Model endpoint has no valid hostname") if parsed.username or parsed.password: _err("Credentials must not be embedded in the model endpoint URL") for result in socket.getaddrinfo(parsed.hostname, parsed.port or 443): address = ipaddress.ip_address(result[4][0]) prohibited = ( address.is_loopback or address.is_link_local or address.is_multicas ...[truncated 376 chars]
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
Findings (7)

Lp3

Medium
Category
MCP Least Privilege
Confidence
89% confidence
Finding
The skill explicitly documents sensitive capabilities including reading local files, writing drafts, and sending article content plus an API key to a user-configured external endpoint, but it does not declare any explicit tool scope or allowed-tools boundary. That mismatch weakens least-privilege controls and makes misuse or overreach harder to constrain or audit, especially because the skill can access credentials and exfiltrate repository content over the network.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
The documentation states that full local reference documents are injected into the model prompt, but it does not clearly warn that this content will be transmitted to a configured external OpenAI-compatible endpoint. In this skill context, users may supply internal product documents under `.aws-article/products/...`, so omission of an explicit data egress warning creates a real confidentiality risk, especially when using third-party or proxy model providers.

Missing User Warnings

Medium
Confidence
93% confidence
Finding
The script sends full article text, user instructions, image analysis, and optionally entire reference documents to third-party LLM endpoints via `_post_json` without an explicit runtime consent gate or warning at the transmission point. Because `base_url` is configurable and may target arbitrary external providers or gateways, users can unintentionally exfiltrate proprietary drafts, internal product materials, or sensitive business content outside the local environment.

Natural-Language Policy Violations

Medium
Confidence
96% confidence
Finding
The manifest description is written exclusively in Chinese and describes the skill as writing WeChat public-account articles, which implies a fixed language/locale orientation without any opt-in or alternative. Under the policy rule, language or locale constraints should either offer user choice or be explicitly justified as region-specific.

Context-Inappropriate Capability

Medium
Confidence
96% confidence
Finding
The manifest grants shell access to a skill whose stated purpose is article writing and rewriting, and the manifest provides no functional justification for executing local commands. Unnecessary shell capability materially expands the attack surface: prompt-influenced instructions or downstream skill logic could invoke arbitrary commands, access local data, or chain with filesystem/network permissions for exfiltration or system modification.

Natural-Language Policy Violations

Low
Confidence
88% confidence
Finding
Line L092 states that image descriptions must be written in Chinese. This is a natural-language locale requirement, and the document does not provide user opt-in or explain that the skill is intentionally limited to a Chinese-only regional context.

Natural-Language Policy Violations

Low
Confidence
81% confidence
Finding
SQP-3 适用于所有文件类型。该文件整体以中文规定使用方式与输出场景,但未说明这是可选语言还是仅适用于特定中文团队/区域,存在未获用户选择即固定语言/locale 的组织政策风险。

Static analysis

No suspicious patterns detected.