Back to skill

Security audit

Notion Content Pipeline

Security checks for vulnerabilities and agentic risk

Overview

The skill mostly does the Notion sync it advertises, but it also has under-scoped automation that can overwrite content, mutate Notion records, and execute a sibling fact-checker script outside the package.

Install only if you are comfortable giving it a Notion integration token that can create, edit, and archive pages in the shared workspace. Configure the parent page, sync-map path, and pipeline database ID explicitly, keep the sync map protected, avoid relying on dry-run as a no-execution mode, and review backups before using pull, push overwrite, or pipeline advance. The sibling fact-checker execution should be removed or pinned before routine use.

Vulnerability Patterns
  • Insecure DependenciesIntroduces malicious components through unsafe dependency sources
  • 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 (3)

T08 · Insecure Dependencies

Error
Location
scripts/pipeline_advance.py:223
Finding
Unverified External Script Execution, Including During Dry-Run<![CDATA[ ## Vulnerability Details **File Location**: `scripts/pipeline_advance.py`, lines 43–44, 61–62, 223–237, and 362 **Vulnerability Type**: Unverified execution of code outside the audited Skill package **Risk Level**: High ### Complete Code Snippet ```python _SKILL_DIR = _SCRIPTS_DIR.parent _WORKSPACE = _SKILL_DIR.parent.parent # workspace/skills/<skill>/scripts → workspace FACT_CHECKER_SCRIPT = _WORKSPACE / "skills" / "fact-checker" / "scripts" / "fact_check.py" PYTHON = "/Users/loki/.pyenv/versions/3.14.3/bin/python3" def run_factcheck(file_path: Path) -> Optional[str]: """Run fact_check.py if available. Returns report string or None.""" if not FACT_CHECKER_SCRIPT.exists(): return None result = subprocess.run( [PYTHON, str(FACT_CHECKER_SCRIPT), str(file_path)], capture_output=True, text=True, timeout=120, ) output = result.stdout if result.stderr: output += f"\n[stderr]: {result.stderr[:200]}" return output.strip() if output.strip() else "(no output)" # The subprocess is reached even when dry_run is True. report = run_factcheck(file_path) ``` ### Technical Analysis The Skill invokes a sibling `fact_check.py` script that is outside its own audited package. The only validation is an existence check. It does not verify the external file's ownership, permissions, expected version, cryptographic digest, or provenance. The Python interpreter is also selected through a hardcoded absolute path outside the Skill's trust boundary. If either that interpreter or the sibling script can be replaced or modified, invoking the pipeline executes attacker-controlled code with the privileges of the user running the Skill. The fact-check call is not conditioned on `dry_run`. Consequently, `--dry-run`, which is documented as a preview that should not make changes, can still execute arbitrary external code and allow any network or filesystem side effects implemented by that external c ...[truncated 1622 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Package the fact-checking implementation as an audited, versioned dependency instead of locating mutable code at a predictable sibling path. 2. Pin the accepted dependency version and verify a cryptographic digest or signed manifest before execution. 3. Validate that the script and interpreter are owned by an expected user and are not group- or world-writable. 4. Replace the machine-specific interpreter path with `sys.executable` or a securely configured, verified interpreter. 5. Require explicit user opt-in before invoking code outside the Skill package. 6. Do not call `run_factcheck()` when `dry_run` is enabled: ```python if dry_run: report = None print(" [dry-run] Would run fact-checker") else: report = run_factcheck(file_path) ``` 7. Run the fact-checker in a restricted subprocess environment: - Remove unrelated credentials from `env`. - Restrict filesystem access where sandboxing is available. - Disable network access unless fact-checking explicitly requires it. 8. Treat nonzero subprocess exit codes and timeouts as failures rather than accepting partial output without validation. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
scripts/notion_content_sync.py:296
Finding
Existing Notion Page Is Archived Before Replacement Is Successfully Created<![CDATA[ ## Vulnerability Details **File Location**: `scripts/notion_content_sync.py`, lines 296–310 **Vulnerability Type**: Non-atomic destructive overwrite and unvalidated page reference **Risk Level**: High ### Complete Code Snippet ```python existing_id = sync_map.get(rel) if existing_id and overwrite: print(f" Archiving existing page {existing_id}, creating fresh...") _archive_page(existing_id, key) time.sleep(0.5) page = _req("post", "/pages", key, json={ "parent": {"page_id": sandbox_id}, "properties": { "title": {"title": [{"text": {"content": title}}]}, }, "children": blocks[:100], }, timeout=30) page_id = page["id"] ``` The page identifier originates from a mutable local JSON file: ```python def _load_map(sync_map_path: Path) -> dict: if sync_map_path.exists(): return json.loads(sync_map_path.read_text()) return {} ``` ### Technical Analysis The overwrite sequence archives the currently mapped Notion page before creating and fully populating its replacement. The operations are not transactional. Any network failure, API validation error, timeout, process termination, or block-append failure after archival can leave the existing page archived without a valid replacement. Additionally, `existing_id` is loaded directly from the local sync map and is used in a state-changing API request without checking that: - It is a valid Notion page identifier. - It represents the expected page. - It belongs to the configured parent page. - It was originally created or managed by this Skill. A user or process able to modify the sync-map file can redirect the archival operation to another page accessible to the Notion integration. ### Attack Path #### Availability failure path 1. A Markdown file is already associated with an existing Notion page. 2. The user invokes `push` with the default overwrite behavior. 3. The Skill archives the existing page. 4. Creation of the replacement fails because of a con ...[truncated 1212 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Reverse the overwrite sequence: - Create the replacement page first. - Append all remaining blocks. - Verify that creation and population succeeded. - Persist the new mapping safely. - Archive the old page only after all prior operations succeed. 2. If archival fails after replacement creation, retain both pages and report a recoverable cleanup error rather than risking content loss. 3. Add rollback handling that can unarchive the old page if a later critical operation fails. 4. Before archival, retrieve the mapped page and verify that: - The identifier has valid syntax. - The page is accessible. - Its parent matches the configured parent. - Its title or a dedicated Skill-managed marker matches the expected document. 5. Store a stable marker or custom property on managed Notion pages so ownership can be verified independently of the local map. 6. Protect the sync map using restrictive filesystem permissions and atomic writes: ```python temp_path.write_text(json.dumps(mapping, indent=2)) os.replace(temp_path, sync_map_path) ``` 7. Consider making non-destructive version creation the default and requiring an explicit confirmation flag before archiving old content. ]]>

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/pipeline_advance.py:57
Finding
Conflicting Hardcoded Database Fallback Can Modify an Unintended Notion Pipeline<![CDATA[ ## Vulnerability Details **File Location**: `scripts/pipeline_advance.py`, lines 57–59 and 392–404; conflicting declaration in `SKILL.md`, lines 26–31 and 198–202 **Vulnerability Type**: Unsafe hardcoded remote-resource fallback and ambiguous record selection **Risk Level**: Medium ### Complete Code Snippet The implementation silently falls back to this database: ```python PIPELINE_DB_ID = os.environ.get( "NOTION_PIPELINE_DB_ID", "312c9a82-0734-81bd-81a6-e58d0365e404" ) ``` It subsequently queries and updates that database: ```python pipeline_page_id, current_status = lookup_pipeline_page(title, PIPELINE_DB_ID, key) status_line = "N/A" if pipeline_page_id is None: print(f" ⚠️ No pipeline DB entry found for title: '{title}'") print(f" Run: python3 create_pipeline_db.py and add '{title}' manually.") status_line = "No pipeline entry found" else: new_status = STATUS_TRANSITIONS.get(current_status or "") if new_status: update_pipeline_status(pipeline_page_id, new_status, key, dry_run=dry_run) status_line = f"{current_status} → {new_status}" ``` The title lookup selects the first matching result: ```python results = data.get("results", []) if not results: return None, None page = results[0] page_id = page["id"] ``` However, `SKILL.md` declares a different Content Pipeline database: ```text Content Pipeline DB: 322eb552-581a-8111-8f6a-d042dd048ec8 ``` ### Technical Analysis When `NOTION_PIPELINE_DB_ID` is absent, the script does not fail closed. It silently uses database ID `312c9a82-0734-81bd-81a6-e58d0365e404`, while the Skill documentation identifies `322eb552-581a-8111-8f6a-d042dd048ec8` as the Content Pipeline database. This configuration conflict means the implementation may query and mutate a remote database outside the scope described to the user. The mutation is automatic for records in `Draft` or `Humanized` state. The lookup is also based only on the Markdown title and uses the firs ...[truncated 1216 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Remove the hardcoded database fallback and require explicit configuration: ```python PIPELINE_DB_ID = os.environ.get("NOTION_PIPELINE_DB_ID") if not PIPELINE_DB_ID: raise RuntimeError("NOTION_PIPELINE_DB_ID must be explicitly configured") ``` 2. Maintain one canonical database identifier in configuration rather than duplicating it across code and documentation. 3. If fixed database IDs are operationally required, validate the configured value against an explicit allowlist and clearly display the selected database before mutation. 4. Query the database metadata and verify its expected title or another stable identifier before updating records. 5. Require a unique property such as slug, page ID, or dedicated external identifier for lookup instead of title alone. 6. If a title-based query returns more than one result, abort and require the user to select the intended record. 7. Add a confirmation option before status mutation and provide a mode that performs the lookup without applying changes. 8. Add automated configuration-consistency tests that compare documented defaults with implementation values. ]]>
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Rogue AgentSelf-Modification, Session Persistence
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
Findings (21)

Tainted flow: 'key' from os.environ.get (line 35, credential/environment) → requests.post (network output)

Critical
Category
Data Flow
Content
"Tags": {"rich_text": {}},
        },
    }
    r = requests.post(f"{NOTION_API}/databases", headers=headers(key), json=payload)
    if r.status_code != 200:
        print(f"ERROR creating database: {r.status_code}: {r.text[:300]}", file=sys.stderr)
        sys.exit(1)
Confidence
90% confidence
Finding
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Tainted flow: 'key' from os.environ.get (line 35, credential/environment) → requests.post (network output)

Critical
Category
Data Flow
Content
"Tags": {"rich_text": [{"text": {"content": tags}}]},
        },
    }
    r = requests.post(f"{NOTION_API}/pages", headers=headers(key), json=payload)
    if r.status_code != 200:
        print(f"  ERROR adding '{title[:40]}': {r.status_code}: {r.text[:200]}", file=sys.stderr)
        return None
Confidence
90% confidence
Finding
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Tp4

High
Category
MCP Tool Poisoning
Confidence
98% confidence
Finding
The declared description presents a broader bidirectional markdown/Notion synchronization skill with local file operations and mapping management. The supplied code chunk is much narrower: it creates a Notion database with specific properties and optionally seeds records from a JSON file. While pipeline database creation is one part of the declared purpose, the primary sync behaviors described are absent from this code. There is no undeclared suspicious capability beyond Notion database/page creation and local reading of an optional seed JSON file, but the description does not accurately represent what this code chunk actually does, so this is a mismatch.

Tp4

High
Category
MCP Tool Poisoning
Confidence
96% confidence
Finding
The implemented core sync behavior largely matches the declared Markdown↔Notion push/pull functionality, including per-file push/pull, batch push-all, and tracking mappings. However, the description also claims support for managing a content pipeline database and creating a pipeline DB with specific properties and statuses. None of that exists in the provided code. The code only creates ordinary Notion pages under a parent page, sets a title property, appends content blocks, pulls page blocks back to Markdown, lists tracked mappings, and archives old pages on overwrite. Because a significant declared capability—pipeline database creation/management—is absent, the description does not accurately represent the full behavior/capabilities of the code.

Lp3

Medium
Category
MCP Least Privilege
Confidence
95% confidence
Finding
The skill advertises capabilities that include environment access, file read/write, shell execution, and outbound network use, but it does not declare any explicit tool scope such as allowed-tools or permissions. That omission weakens least-privilege controls and makes it easier for an agent or operator to invoke the skill with broader access than users may expect, especially given it handles local files and a live API token.

Missing User Warnings

Medium
Confidence
97% confidence
Finding
The documentation exposes a live 1Password secret retrieval path and instructs operators to populate an API key from it, without emphasizing handling restrictions. Even though the secret value is not embedded, publishing operational secret locations and normalizing direct retrieval increases the risk of credential misuse, accidental disclosure in logs, and unsafe copy/paste patterns in shared agent environments.

Session Persistence

Medium
Category
Rogue Agent
Content
## Scripts

- `scripts/notion_content_sync.py` — push/pull individual files or all at once
- `scripts/create_pipeline_db.py` — create a Notion database for content pipeline tracking
- `scripts/pipeline_advance.py` — **full round-trip advance**: pull → humanize → fact-check → push → status update

## Configuration
Confidence
82% confidence
Finding
The skill explicitly creates and updates persistent local state, including sync maps and derivative artifacts from pull/humanize/fact-check workflows, and also creates remote Notion pages/databases. Persistent state can become a security issue when retention, location, and lifecycle are not tightly controlled, because it may leak document metadata, page IDs, workflow history, or modified content across sessions.

Description-Behavior Mismatch

Medium
Confidence
95% confidence
Finding
The skill says it is scoped to markdown↔Notion sync, but it also documents an automated workflow that modifies local content and invokes an external fact-checker script. This scope expansion increases the attack surface because a user expecting simple sync may instead trigger local file transformations and secondary script execution with broader side effects.

Missing User Warnings

Medium
Confidence
91% confidence
Finding
This markdown file explains an overwrite workflow that archives an old page, creates a new page, and updates references, which can materially affect user data. The section provides operational steps but does not include any warning or caution that the process modifies existing Notion content and may disrupt links, history, or recovery expectations.

External Transmission

Medium
Category
Data Exfiltration
Content
sys.exit(1)

NOTION_VERSION = "2022-06-28"
NOTION_API = "https://api.notion.com/v1"


def get_key() -> str:
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
sys.exit(1)

NOTION_VERSION = "2022-06-28"
NOTION_API = "https://api.notion.com/v1"


def get_key() -> str:
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Tainted flow: 'payload' from requests.post (line 114, network input) → requests.post (network output)

Medium
Category
Data Flow
Content
"Tags": {"rich_text": {}},
        },
    }
    r = requests.post(f"{NOTION_API}/databases", headers=headers(key), json=payload)
    if r.status_code != 200:
        print(f"ERROR creating database: {r.status_code}: {r.text[:300]}", file=sys.stderr)
        sys.exit(1)
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.

Tainted flow: 'payload' from requests.post (line 114, network input) → requests.post (network output)

Medium
Category
Data Flow
Content
"Tags": {"rich_text": [{"text": {"content": tags}}]},
        },
    }
    r = requests.post(f"{NOTION_API}/pages", headers=headers(key), json=payload)
    if r.status_code != 200:
        print(f"  ERROR adding '{title[:40]}': {r.status_code}: {r.text[:200]}", file=sys.stderr)
        return None
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.

Missing User Warnings

Medium
Confidence
94% confidence
Finding
The pull operation writes remote Notion content directly to the specified local file path without any confirmation, backup, or conflict detection. If the remote page was modified unexpectedly, mapped incorrectly, or influenced by another collaborator, local content can be silently destroyed or replaced, which is especially risky in a two-way sync workflow handling user-authored drafts.

Context-Inappropriate Capability

Medium
Confidence
88% confidence
Finding
The script is a Notion content-pipeline tool but reaches into a sibling skill in the workspace and executes its script directly. This creates a cross-skill trust boundary violation: a compromised or modified fact-checker skill can run arbitrary code whenever this pipeline script is used, which is more dangerous in an agent-skill ecosystem where skills should be isolated and composable only through explicit, trusted interfaces.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
if not FACT_CHECKER_SCRIPT.exists():
        return None

    result = subprocess.run(
        [PYTHON, str(FACT_CHECKER_SCRIPT), str(file_path)],
        capture_output=True,
        text=True,
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Tainted flow: 'humanized_md' from pathlib.Path.read_text (line 349, file read) → pathlib.Path.write_text (file write)

Medium
Category
Data Flow
Content
diff_path = file_path.with_suffix(".humanizer.diff")
            if not dry_run:
                diff_path.write_text(diff_str, encoding="utf-8")
                file_path.write_text(humanized_md, encoding="utf-8")
            else:
                print(f"  [dry-run] Would write humanized text and diff → {diff_path}")
        else:
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.

Description-Behavior Mismatch

Low
Confidence
78% confidence
Finding
The documentation frames the skill as scoped to the content pipeline DB and markdown sync, and explicitly says not to use it for arbitrary database reads. Yet it embeds and promotes use of both a Content Pipeline DB and a Tweet Pipeline DB, suggesting broader database interaction than the stated boundary.

Description-Behavior Mismatch

Low
Confidence
95% confidence
Finding
The manifest describes a content pipeline with statuses "Seed → Draft → Review → Published", but the database created here defines "Seed", "Draft", "Humanized", "In Review", and "Published". This is a semantic mismatch between the advertised workflow and the actual schema the script provisions.

Dynamic attribute access via getattr()

Low
Category
Dangerous Code Execution
Content
def _req(method: str, url: str, key: str, **kwargs) -> dict:
    import requests
    resp = getattr(requests, method)(f"{NOTION_API}{url}", headers=_headers(key), **kwargs)
    if not resp.ok:
        print(f"Notion API error {resp.status_code}: {resp.text[:300]}", file=sys.stderr)
        resp.raise_for_status()
Confidence
50% confidence
Finding
Dynamic getattr() with a non-literal attribute name can access arbitrary object attributes, potentially bypassing access controls.

Context-Inappropriate Capability

Low
Confidence
79% confidence
Finding
The manifest focuses on two-way markdown↔Notion sync, page mapping, and pipeline database workflows. This script additionally modifies markdown content through a 'humanizer' pass and writes `.humanizer.diff` and `.factcheck.txt` artifacts, which are editorial transformation capabilities not clearly justified by the declared sync/pipeline purpose.

Static analysis

No suspicious patterns detected.