Back to skill

Security audit

Blog Composer

Security checks across malware telemetry and agentic risk

Overview

This is a real blog-authoring tool, but it exposes high-impact publish/delete/file operations and local credential/process access with weak scoping and disclosure.

Review carefully before installing. Only run it in a trusted local environment, do not expose its web server to a network, and expect it to read/write your Jekyll posts, run git commands, push to GitHub Pages, call external research/AI services, and look for a hard-coded MiniMax API key path.

SkillSpector

By NVIDIA
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
Findings (34)

subprocess module call

Medium
Category
Dangerous Code Execution
Content
return content[m.end():] if m else content

def git_run(*args, cwd=BLOG_DIR):
    r = subprocess.run(["git"] + list(args), cwd=cwd, capture_output=True, text=True, timeout=30)
    return r.returncode == 0, r.stdout.strip(), r.stderr.strip()

def markdown_to_html(text):
Confidence
92% confidence
Finding
r = subprocess.run(["git"] + list(args), cwd=cwd, capture_output=True, text=True, timeout=30)

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
f"&srsearch={urllib.parse.quote(topic)}&srlimit=6&format=json"
                   f"&prop=extracts&exintro=1&explaintext=1")
            req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Sol Blog Composer)"})
            with urllib.request.urlopen(req, timeout=10) as res:
                data = json.loads(res.read())
                for r in data.get("query", {}).get("search", []):
                    snippet = re.sub(r'<[^>]+>', '', r.get("snippet", ""))
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=10) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
data=data, headers=headers, method="POST"
        )
        try:
            with urllib.request.urlopen(req, timeout=120) as res:
                result = json.loads(res.read())
                # MiniMax returns content as a list of blocks (thinking + text)
                text_parts = []
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=120) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
f"&srsearch={urllib.parse.quote(topic)}&srlimit=6&format=json"
                   f"&prop=extracts&exintro=1&explaintext=1")
            req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Sol Blog Composer)"})
            with urllib.request.urlopen(req, timeout=10) as res:
                data = json.loads(res.read())
                for r in data.get("query", {}).get("search", []):
                    snippet = re.sub(r'<[^>]+>', '', r.get("snippet", ""))
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=10) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
data=data, headers=headers, method="POST"
        )
        try:
            with urllib.request.urlopen(req, timeout=120) as res:
                result = json.loads(res.read())
                # MiniMax returns content as a list of blocks (thinking + text)
                text_parts = []
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=120) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
f"&prop=extracts&exintro=1&explaintext=1"
                )
                req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Sol Blog Composer)"})
                with urllib.request.urlopen(req, timeout=10) as res:
                    data = json.loads(res.read())
                    results = data.get("query", {}).get("search", [])
                    if results:
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=10) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
f"&prop=extracts&exintro=1&explaintext=1"
                )
                req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Sol Blog Composer)"})
                with urllib.request.urlopen(req, timeout=10) as res:
                    data = json.loads(res.read())
                    results = data.get("query", {}).get("search", [])
                    if results:
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=10) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
def do_http():
                    try:
                        with urllib.request.urlopen(req, timeout=120) as res:
                            result = json.loads(res.read())
                            content = result["content"][0]["text"].strip()
                            gen_state["text"] = content
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=120) as res:

Tainted flow: 'req' from open (line 1727, file read) → urllib.request.urlopen (network output)

High
Category
Data Flow
Content
def do_http():
                    try:
                        with urllib.request.urlopen(req, timeout=120) as res:
                            result = json.loads(res.read())
                            content = result["content"][0]["text"].strip()
                            gen_state["text"] = content
Confidence
80% confidence
Finding
with urllib.request.urlopen(req, timeout=120) as res:

Lp1

High
Category
MCP Least Privilege
Confidence
96% confidence
Finding
The skill advertises only file.read, file.write, and http.request, but it also executes local commands via subprocess. In this context that matters because the command execution is used to alter repository state and push to remotes, so the effective capability exceeds what a reviewer or user would expect from the declared permissions.

Tp4

High
Category
MCP Tool Poisoning
Confidence
96% confidence
Finding
The skill metadata presents a narrow Jekyll authoring tool, but the analyzed behavior reportedly includes secret-file access, outbound web research, local model execution via subprocess, and an HTTP server with state-changing endpoints. That mismatch is dangerous because users and policy systems may grant file/network permissions expecting simple blog editing while hidden capabilities expand the attack surface to secret exposure, remote content injection, and unintended code/process execution.

Context-Inappropriate Capability

Medium
Confidence
94% confidence
Finding
The skill reads an API key from a hard-coded local secrets file to access a third-party AI service. For a blog-authoring desktop UI, this expands scope into local credential access and creates unnecessary coupling to a specific user's filesystem, which is risky and not transparently disclosed.

Context-Inappropriate Capability

Medium
Confidence
94% confidence
Finding
The duplicate generation path repeats the same hard-coded local secret-file access, compounding the credential-handling issue and increasing maintenance risk. In a skill with file and network permissions, undisclosed secret access is especially concerning because it broadens what local data the skill may touch.

Context-Inappropriate Capability

High
Confidence
85% confidence
Finding
The skill executes local Git and Ollama binaries even though the declared capability set suggests only file and HTTP access. This mismatch is dangerous because operators or users may grant trust based on the manifest while the code can perform broader local actions, including repository modification and model execution.

Scope Creep

High
Confidence
89% confidence
Finding
The code exceeds its declared permissions by invoking local binaries for Git operations. In a skill ecosystem, undeclared capabilities undermine security review and sandbox assumptions, making the component more dangerous than its metadata indicates.

Scope Creep

High
Confidence
89% confidence
Finding
The /generate route launches the local Ollama CLI despite no declared permission for local process execution. This hidden capability matters because arbitrary callers can trigger heavyweight local model runs and generate unreviewed content that may later be saved or published.

Description-Behavior Mismatch

Medium
Confidence
93% confidence
Finding
The skill advertises a blog authoring UI but also performs direct git commit and push to a remote repository, which is a materially more sensitive operation. In context, this is especially dangerous because the HTTP server exposes publishing functionality without any authentication or authorization checks, enabling unauthorized remote content deployment if the server is reachable.

Missing User Warnings

Medium
Confidence
90% confidence
Finding
The skill advertises actions that modify files and publish content but does not clearly warn that it performs data-changing operations. In a blog-management context, this increases the risk of accidental content edits, draft publication, or destructive workflow changes when users may believe they are only previewing or browsing.

Missing User Warnings

Medium
Confidence
90% confidence
Finding
The Generate feature explicitly supports optional web research and sends the user-provided topic, selected post types, tone, length, and research flag to the /generate backend without any clear consent prompt or privacy notice at the point of transmission. In a skill with http.request permission, this can expose potentially sensitive draft ideas or proprietary topics to remote services, especially when 'research first' is enabled by default.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
The Publish button triggers publishPost(), which performs a POST to /publish for GitHub Pages publication immediately, without a confirmation dialog or final review gate. Because this skill has file.write and network capabilities, a mistaken click can cause unintended remote publication of content, including drafts or sensitive material, to a public site.

Missing User Warnings

Medium
Confidence
93% confidence
Finding
The generation feature sends user-provided topic text plus gathered research context to an external AI provider without a clear consent or warning in the UI about third-party transmission. In a blog-authoring tool, users may paste drafts, unpublished ideas, or sensitive text, so silent transmission meaningfully increases privacy and data-handling risk.

Missing User Warnings

Low
Confidence
86% confidence
Finding
Wikipedia research sends the user-entered topic to an external service without a prominent warning. The checkbox enables/disables research, but it does not clearly communicate that the text leaves the local machine, which is a privacy concern rather than a severe exploit path.

Missing User Warnings

Medium
Confidence
95% confidence
Finding
Publishing automatically commits and pushes to the remote repository immediately after save, without a final confirmation dialog before the network operation. In this skill context that is significant because it can expose drafts or unintended changes publicly through GitHub Pages with a single click.

Missing User Warnings

Low
Confidence
84% confidence
Finding
The code accesses a local API key file without any user-facing disclosure that the skill reads sensitive credentials from disk. This weakens transparency and trust, especially in a desktop authoring tool where users would not expect hidden secret-file access.

Missing User Warnings

Medium
Confidence
97% confidence
Finding
The /discard endpoint deletes a file selected by the request without authentication, confirmation, or path validation. In this codebase, filename is joined directly under BLOG_DIR/_posts, so an attacker can likely use path traversal sequences to delete arbitrary files accessible to the server process, making the lack of safeguards especially dangerous.

VirusTotal

65/65 vendors flagged this skill as clean.

View on VirusTotal