Security Review of PR-Submitted Wasm Plugins: Capability Auditing and Binary Signing
The Problem
Plugin ecosystems that accept community-contributed Wasm binaries via pull request present a security challenge that source-code review alone cannot address. The source code in the PR is what the reviewer reads; the binary in the PR is what the runtime executes. These are not always the same thing.
The standard Wasm plugin contribution workflow looks like this: a contributor opens a PR adding or updating a plugin, the diff shows changes to Rust, Go, or C++ source code, a reviewer reads the source diff and approves, the binary is merged and deployed. The problem is that the reviewer never examines the binary itself — and the binary is what matters at runtime.
A Wasm module’s behaviour is defined by its import section, which lists every host function the plugin calls, and its export section, which defines what the host can call into the plugin. A plugin that was previously harmless can be updated to request a new host function import — one that provides filesystem access, network access, or environment variable reading — without the source code change making this obvious. The import section of the compiled binary is the ground truth; the source code is an input, and attackers can submit pre-compiled binaries that do not correspond to the source code in the PR at all.
This is not a theoretical concern. The npm ecosystem has seen multiple cases of packages that included pre-built native binaries whose behaviour differed from the published source. Wasm plugin ecosystems face the same pattern: the review process focuses on the readable artifact (source code) while the executable artifact (compiled binary) receives no systematic scrutiny.
The specific ecosystems where this matters most are those that ship contributor-submitted Wasm binaries to end users or into runtime environments with significant permissions:
- Envoy proxy WASM filters: Envoy loads WASM filters that can intercept, modify, or exfiltrate HTTP traffic at the proxy level
- Extism plugins: the Extism framework enables host applications to call into plugins, with a host function API that can expose filesystem, network, and key-value store access
- OPA WASM bundles: Open Policy Agent supports Wasm-compiled policy bundles; a malicious bundle can produce incorrect policy decisions
- Custom Wasm extension hosts: any application that uses Wasmtime, wasmer, or WasmEdge to load contributor-supplied modules faces the same class of risk
The reviewer examining source code cannot easily answer the question that matters: does this binary’s import section match the capabilities implied by the source code? Answering that question requires binary analysis tools that are not part of the default code review workflow.
Threat Model
Adversary 1 — PR submits binary with expanded capability imports. The attacker submits a PR that appears to be a bug fix or performance improvement. The source code changes are benign. The .wasm binary in the PR, however, has been compiled with additional host function imports — specifically, an Extism host function that provides read access to the host filesystem. The source code does not call this function; the import is conditionally activated by a symbol that the attacker controls post-deployment. Standard source code review passes. The binary ships.
Adversary 2 — PR submits binary that doesn’t match the source. The attacker submits a PR where the .wasm binary was compiled from different source code than the source files in the PR. The source files shown in the diff are innocuous; the binary contains malicious logic. This attack exploits the fact that most plugin ecosystems accept pre-compiled binaries as a convenience, since not every reviewer has the toolchain needed to compile the source and compare the result.
Adversary 3 — Compromised plugin repository maintainer deploys modified binary. A legitimate maintainer’s account is compromised. The attacker uses the account to push a new release tag and update the distributed plugin binary, adding a new import that exfiltrates environment variables available to the Wasm runtime. The source code repository is not updated, so the binary change is not visible in a standard diff. The signed binary check — if present — fails because the new binary is not signed with the project key, providing the detection signal.
Adversary 4 — Capability creep via incremental import additions. The attacker is a legitimate contributor who adds one new host function import per release, each one defensible in isolation (“we need filesystem access to load configuration”), until the plugin has an import profile that would have been rejected if requested upfront. This is the boiling-frog pattern applied to Wasm capability expansion.
Without controls: binary capability expansion is invisible to source code reviewers; binary/source mismatch is undetectable; compromised maintainer binary substitution ships to users. With controls: import section diff catches capability expansion; cosign signing gate catches binary substitution; WASI capability allowlist prevents runtime use of imports not in the approved set.
Hardening Configuration
Step 1 — Extract and diff import sections with wasm-tools
wasm-tools is the authoritative Wasm binary analysis toolkit. Install it and integrate it into the PR review pipeline:
# Install wasm-tools
cargo install wasm-tools
# Extract the import section from a Wasm binary as JSON
wasm-tools dump plugin.wasm --json | jq '.imports'
# More targeted: extract only the import section
wasm-tools parse plugin.wasm \
| wasm-tools print \
| grep -A 100 '^\(import' \
| head -200
For structured import extraction suitable for diffing:
#!/usr/bin/env bash
# scripts/extract-wasm-imports.sh
# Extracts the import list from a .wasm file as a sorted newline-separated list.
# Usage: extract-wasm-imports.sh plugin.wasm
set -euo pipefail
WASM_FILE="$1"
wasm-tools dump "$WASM_FILE" --json 2>/dev/null \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
imports = data.get('imports', [])
for imp in imports:
module = imp.get('module', '')
name = imp.get('name', '')
kind = imp.get('kind', 'unknown')
print(f'{module}::{name} ({kind})')
" | sort
To diff the import sections of the new binary against the previous version in CI:
#!/usr/bin/env bash
# scripts/diff-wasm-imports.sh
# Compares import sections between two .wasm files.
# Exit code 0: no new imports. Exit code 1: new imports detected.
set -euo pipefail
OLD_WASM="$1"
NEW_WASM="$2"
OLD_IMPORTS=$(bash scripts/extract-wasm-imports.sh "$OLD_WASM")
NEW_IMPORTS=$(bash scripts/extract-wasm-imports.sh "$NEW_WASM")
ADDED=$(comm -13 <(echo "$OLD_IMPORTS") <(echo "$NEW_IMPORTS"))
REMOVED=$(comm -23 <(echo "$OLD_IMPORTS") <(echo "$NEW_IMPORTS"))
if [ -n "$ADDED" ]; then
echo "NEW IMPORTS DETECTED — requires security review:"
echo "$ADDED" | while read -r import; do
echo " + $import"
done
exit 1
fi
if [ -n "$REMOVED" ]; then
echo "REMOVED IMPORTS (capability reduction — informational):"
echo "$REMOVED" | while read -r import; do
echo " - $import"
done
fi
echo "OK: import section unchanged."
exit 0
Step 2 — GitHub Actions workflow for Wasm PR security checks
# .github/workflows/wasm-plugin-security.yml
name: Wasm Plugin Security Review
on:
pull_request:
paths:
- "plugins/**/*.wasm"
- "filters/**/*.wasm"
- "bundles/**/*.wasm"
permissions:
contents: read
pull-requests: write
checks: write
jobs:
validate-wasm-binary:
name: Validate Wasm binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install wabt and wasm-tools
run: |
# wabt for wasm-validate
sudo apt-get install -y wabt
# wasm-tools via cargo
cargo install wasm-tools --quiet
- name: Find modified .wasm files
id: find_wasm
run: |
CHANGED_WASM=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \
| grep '\.wasm$' || true)
echo "changed_files<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGED_WASM" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Validate binary well-formedness (wasm-validate)
if: steps.find_wasm.outputs.changed_files != ''
run: |
while IFS= read -r wasm_file; do
[ -z "$wasm_file" ] && continue
echo "Validating: $wasm_file"
wasm-validate "$wasm_file"
echo "OK: $wasm_file is valid WebAssembly"
done <<< "${{ steps.find_wasm.outputs.changed_files }}"
- name: Extract and diff import sections
id: import_diff
if: steps.find_wasm.outputs.changed_files != ''
run: |
FINDINGS=""
while IFS= read -r wasm_file; do
[ -z "$wasm_file" ] && continue
# Get the previous version of this binary from git
BASE_BRANCH="origin/${{ github.base_ref }}"
if git show "$BASE_BRANCH:$wasm_file" > /tmp/old.wasm 2>/dev/null; then
echo "Diffing imports for: $wasm_file"
if ! bash scripts/diff-wasm-imports.sh /tmp/old.wasm "$wasm_file" \
> /tmp/diff_output.txt 2>&1; then
FINDING=$(cat /tmp/diff_output.txt)
FINDINGS="$FINDINGS\n**$wasm_file**:\n\`\`\`\n$FINDING\n\`\`\`\n"
fi
else
echo "New file (no previous version): $wasm_file"
echo "Full import list:"
bash scripts/extract-wasm-imports.sh "$wasm_file"
IMPORTS=$(bash scripts/extract-wasm-imports.sh "$wasm_file")
FINDINGS="$FINDINGS\n**$wasm_file** (new plugin — full import list):\n\`\`\`\n$IMPORTS\n\`\`\`\n"
fi
done <<< "${{ steps.find_wasm.outputs.changed_files }}"
# Save findings for comment step
printf '%b' "$FINDINGS" > /tmp/wasm_findings.txt
if [ -s /tmp/wasm_findings.txt ]; then
echo "has_findings=true" >> $GITHUB_OUTPUT
else
echo "has_findings=false" >> $GITHUB_OUTPUT
fi
- name: Post import analysis as PR comment
if: always() && steps.find_wasm.outputs.changed_files != ''
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const findings = fs.existsSync('/tmp/wasm_findings.txt')
? fs.readFileSync('/tmp/wasm_findings.txt', 'utf8').trim()
: '';
const hasFindings = findings.length > 0;
const body = hasFindings
? `## Wasm Import Analysis\n\n${findings}\n\n` +
`**Action required**: New or changed imports must be reviewed against the ` +
`[WASI capability allowlist](.github/wasm-capability-allowlist.yml) and ` +
`explicitly approved by a platform maintainer before merge.\n\n` +
`A separate PR must update the capability allowlist if new capabilities are justified.`
: `## Wasm Import Analysis\n\n` +
`All modified .wasm files have unchanged import sections. No new host function ` +
`capabilities requested.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
if (hasFindings) {
core.setFailed('New Wasm imports detected — security review required');
}
verify-wasm-signature:
name: Verify cosign signature
runs-on: ubuntu-latest
# Only run signature check on main branch merge — PRs submit unsigned binaries
# Signature is added as part of the merge automation after approval
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Verify signatures on all .wasm files
env:
COSIGN_PUBLIC_KEY: ${{ secrets.WASM_SIGNING_PUBLIC_KEY }}
run: |
find . -name '*.wasm' | while read -r wasm_file; do
echo "Verifying: $wasm_file"
cosign verify-blob \
--key "$COSIGN_PUBLIC_KEY" \
--signature "${wasm_file}.sig" \
"$wasm_file" \
&& echo "OK: $wasm_file signature valid" \
|| { echo "FAIL: $wasm_file not signed or signature invalid"; exit 1; }
done
Step 3 — cosign signing gate for merged binaries
After a PR is approved and before the binary is deployed, a signing workflow runs that signs the binary with the project key. Deployment pipelines require a valid cosign signature as a precondition:
#!/usr/bin/env bash
# scripts/sign-wasm-plugins.sh
# Signs all .wasm files with the project cosign key.
# Run by the post-merge automation, not manually.
set -euo pipefail
# COSIGN_KEY_PATH is the path to the project's cosign private key
# In CI, this is loaded from a secrets manager — never committed
COSIGN_KEY_PATH="${COSIGN_KEY_PATH:?COSIGN_KEY_PATH must be set}"
find . -name '*.wasm' | while read -r wasm_file; do
echo "Signing: $wasm_file"
cosign sign-blob \
--key "$COSIGN_KEY_PATH" \
--output-signature "${wasm_file}.sig" \
"$wasm_file"
echo "Signature written: ${wasm_file}.sig"
done
At deployment time, the Wasm runtime loader verifies the signature before loading:
// Runtime loader — verify cosign signature before instantiation
// Using sigstore-rs for Rust-based Wasm runtimes
use sigstore::cosign::{CosignCapabilities, ClientBuilder};
use std::path::Path;
async fn load_verified_wasm(plugin_path: &Path) -> anyhow::Result<Vec<u8>> {
let wasm_bytes = std::fs::read(plugin_path)?;
let sig_path = plugin_path.with_extension("wasm.sig");
let signature = std::fs::read_to_string(&sig_path)
.map_err(|_| anyhow::anyhow!("Missing .sig file for {:?}", plugin_path))?;
// Verify against project's public key (embedded at build time)
let public_key = include_str!("../keys/wasm-signing.pub");
let client = ClientBuilder::default().build()?;
client
.verify_blob_with_key(
&wasm_bytes,
signature.trim(),
public_key,
)
.await
.map_err(|e| anyhow::anyhow!("Wasm signature verification failed: {e}"))?;
Ok(wasm_bytes)
}
Step 4 — WASI capability allowlist enforced at the host level
Define a canonical capability allowlist. Any Wasm module that imports a function not on this list is rejected at load time, regardless of what the PR reviewer approved:
# .github/wasm-capability-allowlist.yml
# Defines the maximum capability set for community-contributed plugins.
# Host-level enforcement is the final control; this file documents the policy.
# New capability additions require a separate PR approved by core-maintainers.
version: "1.0"
approved_imports:
- module: "wasi:io/poll@0.2.0"
functions: ["poll"]
notes: "Required for async I/O operations"
- module: "wasi:io/streams@0.2.0"
functions: ["read", "write", "flush", "drop-input-stream", "drop-output-stream"]
notes: "Stream I/O — permitted for plugins that process data"
- module: "extism:env/v1"
functions: ["input_length", "input_load_u8", "output_set", "error_set", "log"]
notes: "Extism core ABI — required for all plugins"
# NOT in allowlist — requires explicit justification and separate PR:
# - wasi:filesystem — filesystem read/write
# - wasi:sockets — network access
# - wasi:environment — environment variable access
# - wasi:process — process exit/spawn
# - any host function prefixed with 'priv::' or 'internal::'
deny_imports:
- module: "wasi:filesystem"
reason: "Filesystem access not permitted for community plugins"
- module: "wasi:sockets"
reason: "Network access not permitted for community plugins"
- module: "wasi:environment"
reason: "Environment variable access not permitted for community plugins"
- module: "wasi:process"
reason: "Process control not permitted for community plugins"
Enforce the allowlist at host instantiation in Wasmtime:
use wasmtime::{Config, Engine, Linker, Module, Store};
use std::collections::HashSet;
fn build_restricted_linker(
engine: &Engine,
allowlist: &HashSet<(String, String)>,
) -> anyhow::Result<Linker<()>> {
let mut linker: Linker<()> = Linker::new(engine);
// Only add host functions that are on the allowlist
for (module_name, func_name) in allowlist {
match (module_name.as_str(), func_name.as_str()) {
("extism:env/v1", "input_length") => {
linker.func_wrap(module_name, func_name, |_: i64| -> i64 { 0 })?;
}
("extism:env/v1", "log") => {
linker.func_wrap(module_name, func_name, |ptr: i64, len: i64| {
// log implementation
})?;
}
// ... other allowed functions
_ => {}
}
}
Ok(linker)
}
fn instantiate_community_plugin(
module_bytes: &[u8],
allowlist: &HashSet<(String, String)>,
) -> anyhow::Result<()> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let module = Module::new(&engine, module_bytes)?;
// Check module imports against allowlist BEFORE instantiation
for import in module.imports() {
let key = (import.module().to_string(), import.name().to_string());
if !allowlist.contains(&key) {
anyhow::bail!(
"Plugin imports disallowed function: {}::{} — not in capability allowlist",
import.module(),
import.name()
);
}
}
let linker = build_restricted_linker(&engine, allowlist)?;
let mut store = Store::new(&engine, ());
linker.instantiate(&mut store, &module)?;
Ok(())
}
Expected Behaviour After Hardening
When a contributor opens a PR that modifies or adds a .wasm file, the GitHub Actions workflow runs three checks automatically:
First, wasm-validate verifies the binary is well-formed WebAssembly. A malformed binary that is not valid WebAssembly is rejected before further analysis.
Second, the import section diff runs against the previous version of the binary (or shows the full import list for new plugins). If new imports are detected — any host function not previously present — the check fails with a detailed list of the new capabilities being requested. The PR cannot be merged without a human maintainer explicitly reviewing and approving the import expansion. If the new import is justified, a separate PR to update wasm-capability-allowlist.yml must be opened and approved by the core maintainers before the plugin PR can merge.
Third, after the PR is merged, the post-merge signing workflow runs and produces a .sig file for each binary. Any binary without a valid cosign signature is rejected by the deployment pipeline before it reaches the runtime.
At runtime, the Wasmtime instantiation code checks the module’s import section against the runtime capability allowlist before instantiation. A binary that slips through the PR process and attempts to use a disallowed import is rejected at load time with an error that is logged and alerted on.
Trade-offs and Operational Considerations
| Consideration | Detail |
|---|---|
| Toolchain availability | wasm-tools and wabt must be available in the CI environment. Both are packaged for common distributions and have pre-built binaries for major platforms. Add them to your base CI Docker image to avoid per-run installation latency. |
| Source/binary correspondence | The import diff detects capability changes but cannot verify that the binary was compiled from the source code in the PR. Reproducible builds are the solution: require that binaries can be reproduced from the source by running a documented build command, and add a CI step that compiles from source and compares the result with the submitted binary. |
| Capability allowlist maintenance | The allowlist is a living document. As the plugin ecosystem grows, new legitimate capabilities will be needed. The process for allowlist expansion (separate PR, core maintainer approval) should be documented and followed without shortcuts — a rushed allowlist expansion is equivalent to no allowlist. |
| Binary size limits | Large Wasm binaries take longer to analyse. Set a file size limit (e.g., 10 MB) on accepted plugin binaries; unusually large binaries warrant manual analysis with wasm2wat before acceptance. |
| Signing key management | The cosign project signing key must be stored in a secrets manager and accessible only to the post-merge CI workflow. Key rotation requires re-signing all existing binaries. Document the rotation procedure and test it before it becomes urgent. |
| Extism vs WASI capability models | Extism’s capability model is defined by which host functions the plugin author imports; the host grants access by registering the function. WASI’s capability model uses the WASI-defined module and function names. The allowlist must cover both models. |
| False positives on legitimate capability expansion | A legitimate feature addition that genuinely requires a new capability will trigger the import expansion check. This is a feature, not a bug — it forces an explicit, documented, human-approved decision about capability expansion rather than letting it happen silently. |
Failure Modes
| Failure Mode | Cause | Detection | Mitigation |
|---|---|---|---|
| Binary submitted with no corresponding source | PR includes a .wasm file but no source code changes; reviewer cannot assess correctness | Import diff runs but cannot compare against source intent | Require source code for all submitted binaries; add CI check that .wasm files must be accompanied by source files in the same PR, or reference a reproducible build tag |
| Import allowlist check bypassed via custom module name | Plugin uses an import with a non-standard module name that mimics a host function via reflection or indirection | Import section diff shows the import but allowlist check may not match | Allowlist should use prefix matching for module names in addition to exact matching; deny any import with module name not explicitly on the approved list |
| Cosign key compromise | Project signing key is extracted from CI secrets | Signed binaries from attacker using compromised key pass verification | Use a hardware-backed key in a managed signing service (e.g., Sigstore Fulcio, AWS Signer); enable Sigstore transparency log for audit trail of all signing operations |
| wasm-validate passes malformed-but-valid binary | A binary is syntactically valid WebAssembly but semantically malicious | Import diff is the primary control; validation is a necessary but not sufficient check | wasm-validate is a baseline check; import diff and capability allowlist are the primary controls — do not rely on validation alone |
| Signing step skipped due to CI failure | Post-merge signing workflow fails; binary ships unsigned | Deployment pipeline signature check fires and blocks deployment | Deployment pipeline must enforce signature requirement; make unsigned binary deployment a hard failure, not a warning |
| Capability allowlist not updated after host API expansion | Host gains new sensitive functions but allowlist is not updated to explicitly deny them | New imports using new host functions pass the allowlist check (implicit allow) | Default allowlist policy should be deny-by-default: any import not on the explicit allow list is rejected, regardless of whether it is on the deny list |