T09 · Insecure Skill Coding Practices
- Location
scripts/before.py:58- Finding
Persistent Plaintext Export of HubSpot CRM Personal Data
- Content
View full analysis
- Remediation
View remediation
Security audit
Security checks for vulnerabilities and agentic risk
This skill appears to do the advertised HubSpot cleanup, but it deletes CRM contacts and leaves local CSV audit files that users should handle carefully.
Install only if you intend to let this skill delete HubSpot contacts that lack email addresses. Use a least-privileged HubSpot private app token limited to contacts read/write, review the before-state CSV and count before typing DELETE, confirm the safety threshold fits your expected cleanup size, and keep generated CSV files out of source control, backups, and shared folders unless protected.
scripts/before.py:58Persistent Plaintext Export of HubSpot CRM Personal Data
scripts/before.py:1Dependencies Are Not Locked to Audited Versions
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
print(f" Filter: email NOT_HAS_PROPERTY")
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
if after:
payload["after"] = after
resp = requests.post(url, headers=headers, json=payload)
if resp.status_code != 200:
print(f" Stopped at {len(all_contacts)} (status {resp.status_code})")
break
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
payload["after"] = after
url = f"{BASE}/crm/v3/objects/contacts/search"
resp = requests.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
print(f" Batch {batch_num}/{total_batches}: deleting {len(batch)} contacts...", end=" ")
resp = requests.post(delete_url, headers=headers, json=delete_payload)
if resp.status_code == 204:
deleted_count += len(batch)
This code chunk is an after-state verification script, not the deletion automation described. It searches for contacts without email and reports whether any remain, optionally comparing the result to a local CSV file. There is no call to a HubSpot batch archive or delete endpoint, so the primary behavior materially differs from the declared purpose of deleting contacts. The HubSpot resource is related, but the capability in this chunk is audit/verification rather than deletion.
The declared description says the skill deletes no-email contacts from HubSpot and is fully automated via Search and Batch Archive APIs. The supplied code chunk only performs discovery and reporting: it queries contacts missing email addresses, collects their metadata, saves the results to a CSV, and prints lifecycle/source summaries. There is no deletion or archive API call in this chunk, and the script explicitly says the next step is to review the CSV and then run another script to delete. That makes the actual behavior materially different from the declared primary purpose, with an undeclared data export capability as well.
The core purpose matches: the code targets HubSpot contacts with no email address and deletes them via the CRM search and batch archive endpoints. However, the description says the process is 'Fully automated,' while the script pauses for manual confirmation by requiring the user to type 'DELETE' before any deletion occurs. That is a meaningful behavior mismatch. Additionally, the script stores contact IDs and deletion statuses in a local CSV file, which is an extra data export/logging behavior not mentioned in the description. This logging is somewhat ancillary, but it still represents undeclared handling of CRM data. Resources accessed remain consistent with HubSpot plus local filesystem for logging, and there are no unrelated triggers.
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.
## Prerequisites
- A HubSpot private app access token with `crm.objects.contacts.read` and `crm.objects.contacts.write` scopes
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
The skill executes code that reads environment secrets, writes local files, and makes network requests, but it does not declare any tool or permission scope. That omission weakens reviewability and least-privilege controls, making it easier for an agent runtime to grant broader capabilities than users expect for a destructive CRM cleanup task.
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
if after:
payload["after"] = after
resp = requests.post(
f"{BASE}/crm/v3/objects/contacts/search",
headers=headers, json=payload,
)
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
batch = all_ids[i : i + BATCH_SIZE]
delete_payload = {"inputs": [{"id": cid} for cid in batch]}
resp = requests.post(
f"{BASE}/crm/v3/objects/contacts/batch/archive",
headers=headers, json=delete_payload,
)
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print("=" * 60)
print()
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
data = response.json()
The skill metadata says it deletes no-email contacts, but this file exports detailed CRM contact data to a local CSV instead. That mismatch increases risk because operators may run the skill expecting deletion-only behavior while it creates a local dataset containing personal and business metadata that can persist, be copied, or be mishandled.
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
print(f" Batch {batch_num}/{total_batches}: deleting {len(batch)} contacts...", end=" ")
resp = requests.post(delete_url, headers=headers, json=delete_payload)
if resp.status_code == 204:
deleted_count += len(batch)
The docstring explicitly describes a 'Before State' script that counts contacts with no email and exports them to CSV for audit. This directly conflicts with the stated skill intent in the manifest, which frames the skill as fully automated deletion of such contacts.
No suspicious patterns detected.