Back to skill

Security audit

inbound-qualification

Security checks for vulnerabilities and agentic risk

Overview

The skill largely matches its stated inbound-lead purpose, but its public form can alter existing sales records and posts visitor-provided personal content to Slack without enough safeguards.

Before installing, require a design change that stores public submissions in a staging/submission model or verifies email ownership before updating existing contacts. Escape or send Slack-bound visitor fields as plain text, minimize PII in Slack, restrict the destination channel, and make sure the privacy notice explicitly covers workspace storage, Slack sharing, enrichment, and any CRM sync. Keep the research play disabled until reviewed with a real pilot contact.

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
infra/tools/inbound-form.ts:118
Finding

Unauthenticated Form Submissions Can Overwrite Shared Contact Records

Content
View full analysis

Vulnerability Details

File Location: infra/tools/inbound-form.ts, lines 118–146
Vulnerability Type: Unauthenticated record overwrite
Risk Level: Medium

Vulnerable Code

ts
model.upsert({
  modelUuid: gtmContacts.uuid,
  matchingColumnSlug: "email",
  matchingValue: input.email,
  mappings: [
    { columnSlug: "email", value: input.email },
    { columnSlug: "first_name", value: input.first_name },
    { columnSlug: "last_name", value: input.last_name },
    {
      columnSlug: "name",
      value: `${input.first_name} ${input.last_name}`,
    },
    { columnSlug: "account_id", value: accounts[0].id },
    { columnSlug: "lead_source", value: "website" },
  ],
  customMappings: [
    {
      columnSlug: "inbound_status",
      value: company.company_name
        ? qualified
          ? "qualified"
          : "not_qualified"
        : "unknown_company",
    },
    { columnSlug: "inbound_message", value: input.message },
    { columnSlug: "inbound_page_url", value: input.page_url },
    { columnSlug: "utm_source", value: input.utm_source },
    { columnSlug: "utm_campaign", value: input.utm_campaign },
    { columnSlug: "marketing_consent", value: input.consent },
  ],
});

Technical Analysis

The publicly accessible form accepts an email address without verifying that the submitter controls it. The workflow then uses that address as the sole matching key for an upsert into the shared gtm_contacts model.

If the supplied address already belongs to a contact, the operation updates that existing record with unauthenticated values. The affected fields include the contact's name, account association, lead source, inbound qualification state, message, attribution data, and marketing-consent status.

The origin allowlist, honeypot, minimum-fill delay, and per-IP rate limit provide anti-automation controls, but they do not authenticate the identity of the submitter or prove ownership of the supplied address. Consequently, an ordinary e ...[truncated 1348 chars]

Remediation
View remediation

Remediation Suggestions

  • Do not update authoritative existing-contact fields directly from an unverified public submission.
  • Store each request in a separate immutable submission or staging model, then merge it into a contact only after appropriate verification or review.
  • Require email ownership verification before changing an existing contact.
  • If direct upsert is necessary, restrict public submissions to dedicated inbound fields and preserve existing identity, account association, lead source, and consent data.
  • Treat consent as submission-specific evidence rather than allowing an unverified request to replace an existing contact-level consent value.
  • Add a server-side rule that distinguishes creation from update and requires stronger authorization for updates.
  • Add contract tests proving that a submission using an existing email cannot overwrite protected fields.

T09 · Insecure Skill Coding Practices

Warning
Location
infra/tools/inbound-form.ts:150
Finding

Public Form Input Is Posted to Slack as Unescaped Markdown

Content
View full analysis

Vulnerability Details

File Location: infra/tools/inbound-form.ts, lines 150–154
Vulnerability Type: Slack markup injection
Risk Level: Medium

Vulnerable Code

ts
uses.slack.postMessage({
  channelId: slackChannelId,
  format: "markdown",
  disableUnfurling: true,
  body: `*New inbound${qualified ? ", qualified" : ""}* from ${input.first_name} ${input.last_name} at ${company.company_name ? company.company_name : domain} (${domain})\n${company.employee_count} employees, ${company.hq_country}\n${input.message}\n\nPage: ${input.page_url}`,
});

Technical Analysis

The public form's first_name, last_name, and message fields are interpolated directly into a Slack message configured with format: "markdown". No escaping or neutralization is applied before Slack interprets the resulting content.

An external submitter can therefore provide Slack formatting, special mention syntax, or deceptive formatted links. Setting disableUnfurling: true only suppresses link previews; it does not disable Markdown interpretation or neutralize special Slack syntax.

This crosses the public visitor-to-internal Slack trust boundary. Although the destination channel is fixed, internal recipients receive attacker-controlled content in an interpreted format.

Attack Path

  1. The attacker opens the legitimate form from the permitted website origin.
  2. The attacker supplies a valid work-format email and places Slack markup in a name or message field.
  3. The attacker waits long enough to pass the minimum-fill check and submits the form.
  4. The workflow interpolates the malicious value into the Slack message without escaping it.
  5. The Slack connector posts the message with Markdown processing enabled.
  6. Slack renders the attacker-controlled syntax inside the internal channel, potentially presenting deceptive links, spoofed formatting, or disruptive mentions.

Impact Assessment

The attacker cannot choose an arbitrary Slack channel or obtain ...[truncated 486 chars]

Remediation
View remediation

Remediation Suggestions

  • Escape all Slack control characters and special mention syntax in visitor-controlled fields before interpolation.
  • Prefer a Slack API mode that sends external values as plain text without Markdown interpretation.
  • If Markdown is required for the fixed template, encode untrusted values separately and apply formatting only to trusted static text.
  • Explicitly neutralize channel, user, group, and broadcast mention forms supported by Slack.
  • Validate and constrain URLs before rendering them as links.
  • Apply reasonable server-side length limits to names and messages to reduce abuse and channel disruption.
  • Add tests using crafted Markdown, mention syntax, and formatted links to confirm they are displayed literally.
Vulnerability Patterns
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Trigger AbuseOverly Broad Trigger, Shadow Command Trigger, Keyword Baiting Trigger
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
Findings (15)

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
5. **Plan, then stop.** `node --import tsx evals/contract.mjs` from this skill's folder, then

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
5. **Plan, then stop.** `node --import tsx evals/contract.mjs` from this skill's folder, then

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
5. **Plan, then stop.** `node --import tsx evals/contract.mjs` from this skill's folder, then

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
| ICP rules (`infra/tools/inbound-form.ts`) | derived | the ICP in `context/`: headcount band and ISO country codes, confirmed with one live enrichment | Decide

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
| ICP rules (`infra/tools/inbound-form.ts`) | derived | the ICP in `context/`: headcount band and ISO country codes, confirmed with one live enrichment | Decide

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
rch` | The form's rules and the Slack post are enough | Delete `infra/plays/research-qualified-leads.ts`, `infra/agents/lead-researcher.ts` and the Anthropic co

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

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

md
rch` | The form's rules and the Slack post are enough | Delete `infra/plays/research-qualified-leads.ts`, `infra/agents/lead-researcher.ts` and the Anthropic co

Vague Triggers

Medium
Category
Not specified by scanner
Confidence
90% confidence
Finding

The manifest-style description lists triggers such as "handle inbound leads" and "inbound lead flow," which are broad enough to overlap with many general sales or marketing requests. Although some skip conditions are provided, the trigger scope is still not narrowly bounded to specific actions or contexts, increasing the risk of unintended invocation.

Content

No source excerpt is available for this finding.

Whitespace Padding

Medium
Category
Prompt Injection
Confidence
70% confidence
Finding

Large whitespace padding was detected (a block of blank lines or a long run of spaces). This can push injected instructions below or to the right of the visible area so a human reviewer never sees them while the agent still reads them. Manual review of the hidden content is recommended.

Content

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

md
**Derive before you ask.** An input with a lookup is looked up, not asked.

| Input                                     | Kind    | How it is answered                                                                              | Why it matters                                                                                            |
| ----------------------------------------- | ------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| ICP rules (`infra/tools/inbound-form.ts`) | derived | the ICP in `context/`: headcount band and ISO country codes, confirmed with one live enrichment | Decides who sees the booking link. Rules nobody can read back are the reason inbound stops being trusted. |
| site origin (`publicForm.allowedOrigins`) | derived | the website app's `site.json` `canonicalUrl`, without the trailing slash                        | Every other origin is refused with 403. A missing origin is a form that never submits.                    |

Whitespace Padding

Medium
Category
Prompt Injection
Confidence
70% confidence
Finding

Large whitespace padding was detected (a block of blank lines or a long run of spaces). This can push injected instructions below or to the right of the visible area so a human reviewer never sees them while the agent still reads them. Manual review of the hidden content is recommended.

Content

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

md
The code is a worked example. These reshapes are expected, and the agent offers them rather than
waiting to be asked. Every one costs something.

| Variation               | When it is right                                          | How                                                                                                                                          | What it costs                                                                              |
| ----------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `crm-backed`            | Contacts belong in HubSpot, Salesforce or Attio           | Replace the two `model.upsert` calls with the CRM connector's upsert (matched on domain and email), same fields ([data](references/data.md)) | The worked example no longer deploys on a bare workspace; CRM properties must exist first. |
| `agent-qualification`   | The ICP does not reduce to headcount and country          | Replace the rules with an agent that reads the ICP from `context/` and returns a verdict and a reason                                        | Each submission pays for a model call, and the answer is less predictable.                 |

Whitespace Padding

Medium
Category
Prompt Injection
Confidence
70% confidence
Finding

Large whitespace padding was detected (a block of blank lines or a long run of spaces). This can push injected instructions below or to the right of the visible area so a human reviewer never sees them while the agent still reads them. Manual review of the hidden content is recommended.

Content

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

md
| `agent-qualification`   | The ICP does not reduce to headcount and country          | Replace the rules with an agent that reads the ICP from `context/` and returns a verdict and a reason                                        | Each submission pays for a model call, and the answer is less predictable.                 |
| `owner-routing`         | More than one rep takes inbound                           | Assign `owner_id` on the contact by territory or round robin before the Slack post, and mention the owner                                    | An owner table to keep current, and a fallback when nobody matches.                        |
| `turnstile`             | Spam gets through the honeypot, time-trap and rate limit  | `publicForm.spam.captchaProvider: "turnstile"`, the site key, `captchaSecret: env("TURNSTILE_SECRET")`, the widget on the page               | A third-party script on the page, with its own privacy disclosure.                         |
| `accept-personal-email` | A form that is not about the company (newsletter, events) | Drop the free-mail refusal, skip enrichment for those domains                                                                                | No company to qualify or route; those contacts arrive without an account.                  |
| `no-deep-research`      | The form's rules and the Slack post are enough            | Delete `infra/plays/research-qualified-leads.ts`, `infra/agents/lead-researcher.ts` and the Anthropic connector                              | No tier or brief on the contact; the team researches by hand.                              |
| `research-every-submitter` | Not-qualified leads are worth a look too               | Drop the `inbound_status` condition from the research play's filter                                                                         | An agent call per submission, including the ones the rules already turned away.            |

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
90% confidence
Finding

This workflow posts personally identifiable information and enrichment output to Slack, including name, email, tiering, rationale, and sources, without any evidence in this file of consent checks, minimization, or restricted routing. In an inbound lead-processing skill, that creates a real privacy and data-governance risk because Slack channels often have broad membership, retention, and downstream app access, making unnecessary disclosure of lead data easy.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
93% confidence
Finding

The workflow posts user-submitted personal data to Slack, including the submitter’s full name, company/domain, free-form message, and page URL, but this file shows no user-facing disclosure or consent specific to that sharing. Because the form is public-facing and intended for website visitors, sending PII and potentially sensitive free-text content into a chat system increases the risk of overexposure, broader internal access, retention issues, and accidental onward sharing.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
91% confidence
Finding

This markdown file explains that submissions from work emails are upserted into shared data models with fields including email, name, message, page URL, UTM data, and marketing consent. For markdown files, SQP-2 applies when the skill description omits warnings about behaviors affecting user data or privacy, and no explicit warning or disclosure is present in this section.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
93% confidence
Finding

This section states that shared-model upserts become CRM connector upserts and maps contact/company fields into HubSpot properties. Transmitting user and company data to an external CRM affects privacy and data handling, but the markdown provides no explicit warning or disclosure about that behavior.

Content

No source excerpt is available for this finding.

Static analysis

Detected: suspicious.dynamic_code_execution

Dynamic code execution detected.

Critical
Code
suspicious.dynamic_code_execution
Location
evals/contract.mjs:119