Back to skill

Security audit

Appian Export

Security checks for vulnerabilities and agentic risk

Overview

The skill is meant to export Appian packages, but its implementation can send the Appian API key to an unvalidated download URL and can write ZIP files outside the intended export folder.

Review before installing. Use only with a trusted Appian environment and a minimally privileged API key, and avoid attacker-controlled export names. The skill should validate packageZip against the configured Appian origin and sanitize or generate local filenames before it is treated as safe for normal use.

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

Error
Location
scripts/index.js:111
Finding

Appian API Key Disclosure and SSRF Through an Unvalidated Download URL

Content
View full analysis

Vulnerability Details

File Location: scripts/index.js, lines 111-124 and 174-176
Vulnerability Type: Unvalidated remote URL with credential forwarding
Risk Level: High

Vulnerable Code

js
async function downloadZip(credentials, zipUrl) {
    const res = await fetch(zipUrl, { headers: { 'appian-api-key': credentials.apiKey } });
    if (!res.ok) {
        const text = await res.text().catch(() => '');
        throw new Error(`Download failed [${res.status}]: ${text}`);
    }
    const cd        = res.headers.get('content-disposition') ?? '';
    const nameMatch = cd.match(/filename[^;=\n]*=(['"]?)([^\n"';]+)\1/);
    const rawName   = nameMatch?.[2]?.trim() ?? null;
    const buf       = Buffer.from(await res.arrayBuffer());
    return { buf, rawName };
}
js
if (!pollData.packageZip) throw new Error(`No packageZip URL in response`);
const { buf, rawName } = await downloadZip(credentials, pollData.packageZip);

Technical Analysis

The packageZip value originates in the remote deployment-status response and is passed directly to fetch() without validating its protocol, hostname, port, or origin. The request also includes the sensitive APPIAN_API_KEY in the appian-api-key header.

Consequently, a malicious or compromised Appian endpoint can return an attacker-controlled URL and cause the Skill to disclose the API key. The same behavior provides a server-side request forgery primitive because the URL could reference internal services or loopback addresses reachable from the machine running the Skill.

Redirect handling is not explicitly restricted or validated. The implementation therefore does not establish that every credential-bearing request remains on the configured Appian origin. This also conflicts with the documentation's claim that all requests go only to the configured Appian environment.

Attack Path

  1. An attacker compromises or controls the confi ...[truncated 1341 chars]
Remediation
View remediation

Remediation Suggestions

  1. Parse both APPIAN_BASE_URL and packageZip with the standard URL class.
  2. Require packageZip to use HTTPS.
  3. Require its origin to exactly match an approved origin. Prefer the origin of APPIAN_BASE_URL unless Appian documents a separate, fixed download host that can be explicitly allowlisted.
  4. Reject URLs containing unexpected credentials, ports, protocols, or hostnames.
  5. Do not attach APPIAN_API_KEY to any cross-origin request.
  6. Disable automatic redirects where supported, or process redirects manually and repeat protocol and origin validation for every redirect target before forwarding credentials.
  7. Consider rejecting loopback, link-local, private-network, and cloud metadata destinations if cross-origin download hosts must be supported.
  8. Add tests covering attacker-controlled hosts, protocol-relative URLs, encoded hostnames, redirects, loopback addresses, private addresses, and unexpected ports.

Example validation logic:

js
function validateDownloadUrl(baseUrl, zipUrl) {
    const base = new URL(baseUrl);
    const target = new URL(zipUrl);

    if (target.protocol !== 'https:') {
        throw new Error('The package ZIP URL must use HTTPS');
    }

    if (target.origin !== base.origin) {
        throw new Error('The package ZIP URL is not on the configured Appian origin');
    }

    return target.href;
}

The validated URL should be used for the download, and redirect targets must receive equivalent validation.

T09 · Insecure Skill Coding Practices

Error
Location
scripts/index.js:126
Finding

Arbitrary File Write Through Path Traversal in Export Filenames

Content
View full analysis

Vulnerability Details

File Location: scripts/index.js, lines 126-153
Vulnerability Type: Path traversal and unsafe file overwrite
Risk Level: High

Vulnerable Code

js
function saveZip(buf, rawName, deploymentUuid, exportName, storagePath, cwd) {
    const fileName = rawName ?? exportName
        ?? `appian-export-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.zip`;
    const zipName  = fileName.endsWith('.zip') ? fileName : `${fileName}.zip`;
    const outPath  = path.join(storagePath, zipName);

    fs.mkdirSync(storagePath, { recursive: true });
    fs.writeFileSync(outPath, buf);

    const sizeKb = (buf.length / 1024).toFixed(1);
    console.log(`\n✓ Export complete`);
    console.log(`  ZIP path: ${outPath}`);
    console.log(`  Size:     ${sizeKb} KB`);

    if (cwd !== storagePath) {
        const cwdExports = path.join(cwd, 'appian-exports');
        fs.mkdirSync(cwdExports, { recursive: true });
        const cwdPath = path.join(cwdExports, zipName);
        fs.copyFileSync(outPath, cwdPath);
        console.log(`  Copied to: ${cwdPath}`);
    }

    return { outPath, sizeKb };
}

The remote filename is extracted without sanitization:

js
const cd        = res.headers.get('content-disposition') ?? '';
const nameMatch = cd.match(/filename[^;=\n]*=(['"]?)([^\n"';]+)\1/);
const rawName   = nameMatch?.[2]?.trim() ?? null;

Technical Analysis

saveZip() uses either the server-controlled Content-Disposition filename (rawName) or the caller-controlled exportName as a path component. Neither value is sanitized before being passed to path.join().

A filename containing traversal sequences such as ../../target.zip is normalized outside the intended appian-exports directory. The code does not resolve the final path and verify that it remains beneath storagePath or cwdExports.

The same unsafe filename is used twice:

  • `f ...[truncated 1937 chars]
Remediation
View remediation

Remediation Suggestions

  1. Do not trust the filename supplied in Content-Disposition. Prefer a locally generated filename based on a safe identifier or timestamp.
  2. If a remote or user-supplied filename must be supported, remove directory components with path.basename().
  3. Apply a conservative allowlist, such as letters, digits, spaces, periods, underscores, and hyphens.
  4. Reject empty names, . and .., path separators, control characters, reserved platform names, and excessively long filenames.
  5. Resolve the candidate output path and verify that it remains strictly inside the intended directory before writing.
  6. Apply the containment check independently to both storagePath and cwdExports.
  7. Use an exclusive write mode such as { flag: 'wx' }, or generate a unique destination, when overwriting existing exports is not explicitly required.
  8. Consider protections against symbolic-link attacks if another user or process can modify the destination directories.
  9. Add tests for ../, backslash traversal, nested traversal, absolute-looking paths, Unicode separators, control characters, reserved names, and filename collisions.

Example containment logic:

js
function safeOutputPath(root, suppliedName) {
    const safeName = path.basename(suppliedName)
        .replace(/[^A-Za-z0-9._ -]/g, '_');

    if (!safeName || safeName === '.' || safeName === '..') {
        throw new Error('Invalid export filename');
    }

    const rootPath = path.resolve(root);
    const outputPath = path.resolve(rootPath, safeName);

    if (!outputPath.startsWith(rootPath + path.sep)) {
        throw new Error('Export path escapes the destination directory');
    }

    return outputPath;
}

The preferred design is to generate the local ZIP filename internally and treat the remote filename only as optional display metadata.

Vulnerability Patterns
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Supply ChainUnpinned Dependencies, External Script Fetching, Obfuscated Code
Findings (1)

Undeclared Tool Scope

Medium
Category
MCP Least Privilege
Confidence
90% confidence
Finding

The skill declares access to environment variables and makes outbound network requests, but it does not define any explicit tool scope such as allowed-tools or permissions. This weakens least-privilege controls and makes it harder for the agent platform to constrain what the skill can access, increasing the risk of unintended credential use or unauthorized external requests if the skill is invoked in a broader-than-expected context.

Content

No source excerpt is available for this finding.

Static analysis

Detected: suspicious.env_credential_access

Environment variable access combined with network send.

Critical
Code
suspicious.env_credential_access
Location
scripts/index.js:33