Back to skill

Security audit

Website Manager

Security checks for vulnerabilities and agentic risk

Overview

The skill is mostly coherent for managing Notion-backed websites, but its Netlify deploy helper can accidentally upload files outside the chosen site folder through symlinks.

Install only if you are comfortable giving the agent scoped Notion and Netlify access for website work. Before using the Netlify deploy helper, deploy only reviewed site-output folders, check for symlinks and sensitive files such as .env or keys, and prefer draft deploys until the archive contents are verified.

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

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/netlify_zip_deploy.py:32
Finding

Deployment Archive Follows Symbolic Links and May Exfiltrate Files Outside the Site Directory

Content
View full analysis

Vulnerability Details

File Location: scripts/netlify_zip_deploy.py, lines 32-41
Vulnerability Type: Symbolic-link traversal during deployment archive creation
Risk Level: Medium

Vulnerable Code

python
def build_zip(source_dir: Path) -> Path:
    tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
    tmp.close()
    zip_path = Path(tmp.name)
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
        for path in sorted(source_dir.rglob("*")):
            if path.is_file():
                archive.write(path, path.relative_to(source_dir))
    return zip_path

The resulting archive is subsequently transmitted to Netlify:

python
def create_deploy(site_id: str, token: str, zip_bytes: bytes, draft: bool) -> dict:
    url = f"{API_ROOT}/sites/{site_id}/deploys"
    if draft:
        url += "?draft=true"
    return request("POST", url, token, zip_bytes, "application/zip")

Technical Analysis

Path.is_file() follows symbolic links. If a symbolic link beneath the selected source_dir points to a regular file outside that directory, the condition evaluates to true. ZipFile.write() then opens the symlink path and archives the contents of its external target.

Although path.relative_to(source_dir) controls the name recorded inside the ZIP, it does not verify that the resolved source file remains within source_dir. Consequently, the intended filesystem boundary is not enforced.

This behavior is particularly risky when the deployment tree was cloned, extracted, generated, or otherwise influenced by an untrusted party. The outbound upload itself is necessary for the declared Netlify deployment functionality, and the destination is the fixed official Netlify API. However, the set of files uploaded can exceed the selected deployment directory's legitimate contents.

Attack Path

  1. An attacker supplies or influences a site directory t ...[truncated 1698 chars]
Remediation
View remediation

Remediation Suggestions

Reject symbolic links and verify that every resolved input file remains beneath the resolved deployment root before adding it to the archive:

python
def build_zip(source_dir: Path) -> Path:
    source_root = source_dir.resolve(strict=True)
    tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
    tmp.close()
    zip_path = Path(tmp.name)

    try:
        with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
            for path in sorted(source_root.rglob("*")):
                if path.is_symlink():
                    raise ValueError(
                        f"Refusing to archive symbolic link: {path}"
                    )

                if not path.is_file():
                    continue

                resolved_path = path.resolve(strict=True)
                if not resolved_path.is_relative_to(source_root):
                    raise ValueError(
                        f"File resolves outside deployment root: {path}"
                    )

                archive.write(
                    resolved_path,
                    resolved_path.relative_to(source_root),
                )
        return zip_path
    except Exception:
        zip_path.unlink(missing_ok=True)
        raise

Additional hardening should include:

  1. Apply a deployment allowlist or explicit exclusion rules for .env, private keys, VCS metadata, backup files, and .website-manager metadata.
  2. Display or record a manifest of files before upload so the operator can review the exact deployment contents.
  3. Refuse special filesystem objects, including sockets, devices, and named pipes.
  4. Create the temporary archive with restrictive permissions and ensure cleanup on every error path.
  5. Prefer draft deployments when processing site trees that have not been reviewed.
  6. Document that cloned, extracted, or third-party site directories must be treated as ...[truncated 70 chars]
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
Findings (11)

Tp4

High
Category
MCP Tool Poisoning
Confidence
96% confidence
Finding

The declared description presents a comprehensive website-building and management skill centered on Notion-managed sites and long-term operation. The supplied code instead implements a narrow validation utility: it recursively scans HTML files, checks for original-domain links, mailto target handling, booking iframe presence, absolute-vs-relative links, placeholder hashes, and required SEO head tags, with optional auto-fixes for two link cases. This is related to website QA, but it is materially narrower and different from the claimed primary purpose. There is no evidence of site creation, redesign, publishing, CMS handling, Notion connectivity, automation workflows, or ongoing site operations. Therefore the description does not accurately represent the code chunk's actual behavior.

Content

No source excerpt is available for this finding.

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

Scanner excerpt · SKILL.md (reported line 120)May include surrounding context.

md
- `scripts/generate_blueprint.py` to generate the default site/CMS/publish blueprint

Undeclared Tool Scope

Medium
Category
MCP Least Privilege
Confidence
91% confidence
Finding

The skill advertises privileged behavior such as outbound network requests, persistent file writes, remote deploys, and use of environment credentials, but it does not declare an explicit tool/permission scope like allowed-tools or permissions. That creates an authorization ambiguity where an agent runtime may grant broader capabilities than reviewers or users expect, increasing the chance of unintended secret access, filesystem modification, or external deployment actions.

Content

No source excerpt is available for this finding.

Autonomous Decision Making

Medium
Category
Excessive Agency
Confidence
80% confidence
Finding

Skill enables autonomous high-impact decisions without human-in-the-loop verification. Critical operations (destructive commands, financial transactions, data deletion) should require explicit user confirmation.

Content

Scanner excerpt · references/default-stack.md (reported line 75)May include surrounding context.

md
## Default rule

Do not ask the user to choose among five architectures.
Choose this one first, explain it briefly, and move forward.

External Transmission

Medium
Category
Data Exfiltration
Confidence
60% confidence
Finding

Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Content

Scanner excerpt · scripts/create_notion_cms.py (reported line 33)May include surrounding context.

python
NOTION_VERSION = "2026-03-11"
API_ROOT = "https://api.notion.com/v1"
DEFAULT_SAVE_JSON = ".website-manager/notion.json"

Tainted flow: 'result' from os.environ.get (line 492, credential/environment) → pathlib.Path.write_text (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/create_notion_cms.py (reported line 504)May include surrounding context.

python
if args.save_json != "-":
        save_path = Path(args.save_json)
        save_path.parent.mkdir(parents=True, exist_ok=True)
        save_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
    print(json.dumps(result, indent=2))
    return 0

External Transmission

Medium
Category
Data Exfiltration
Confidence
60% confidence
Finding

Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Content

Scanner excerpt · scripts/netlify_zip_deploy.py (reported line 31)May include surrounding context.

python
from pathlib import Path


API_ROOT = "https://api.netlify.com/api/v1"
DEFAULT_SAVE_JSON = ".website-manager/deploy.json"

Tainted flow: 'result' from os.environ.get (line 128, credential/environment) → pathlib.Path.write_text (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/netlify_zip_deploy.py (reported line 140)May include surrounding context.

python
if args.save_json != "-":
            save_path = Path(args.save_json)
            save_path.parent.mkdir(parents=True, exist_ok=True)
            save_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
        print(json.dumps(result, indent=2))
        return 0 if state == "ready" else 1
    except urllib.error.HTTPError as exc:

Missing User Warnings

Low
Category
Not specified by scanner
Confidence
85% confidence
Finding

This markdown file explicitly requires NOTION_ACCESS_TOKEN or NOTION_TOKEN for automated CMS creation, which involves sensitive credentials. However, the description provides no warning about securely storing, limiting, or protecting these credentials, despite the category requiring warnings for behaviours affecting privacy or system integrity.

Content

No source excerpt is available for this finding.

Missing User Warnings

Low
Category
Not specified by scanner
Confidence
81% confidence
Finding

The document instructs the agent to provision a default CMS layout in Notion and persist returned identifiers to .website-manager/notion.json, which affects both an external service and the local filesystem. The markdown does not warn users that this automation will create content in Notion and write configuration data locally.

Content

No source excerpt is available for this finding.

Missing User Warnings

Low
Category
Not specified by scanner
Confidence
86% confidence
Finding

This Python file supports a --fix mode that rewrites HTML files in place via path.write_text(...). Although the flag name suggests repair behavior, the code does not provide an explicit warning, confirmation prompt, or comment/docstring disclosure that running with --fix will modify files on disk.

Content

No source excerpt is available for this finding.

Static analysis

No suspicious patterns detected.