Extending Copa with WebAssembly: Building Sandboxed Scanner Plugins
The Problem with Shell Script Adapters
Copa patches container images by ingesting a vulnerability report, identifying which packages need updating, and running the appropriate package manager inside a scratch patcher container. Out of the box, Copa speaks Trivy’s JSON output format. That covers the common case, but most mature organisations are not running one scanner. They have Grype in their on-premises pipeline, Snyk in their developer IDE workflow, AWS ECR Enhanced Scanning on managed images, and Prisma Cloud across their production registry. All of these scanners produce different JSON schemas. None of them is Trivy.
The adapter layer — the code that translates from scanner X to Copa’s expected format — is the link in the chain that tends to get the least security attention. Today it is almost always a shell script or a Python one-liner:
grype <image> -o json | python3 grype_to_copa.py | copa patch ...
This script runs with full host privileges. It reads files, reaches the network, and inherits every environment variable in the shell — including REGISTRY_TOKEN, AWS_SECRET_ACCESS_KEY, and anything else the CI runner exports. If an attacker compromises the adapter script through a supply chain attack on a transitive Python dependency, they do not just corrupt the vulnerability report. They get a persistent foothold with the same access as the CI system.
Compiling the adapter to WebAssembly and running it under a WASI-capable runtime changes the trust model. The adapter process becomes a WASM module: it reads from stdin, writes to stdout, and cannot reach the host filesystem or network unless Copa’s runner explicitly grants that access. A compromised adapter can corrupt the vulnerability report it produces — that is still bad — but it cannot exfiltrate CI credentials or pivot to other systems.
This article builds a complete Grype → Copa adapter in Rust compiled to wasm32-wasip1, wraps it in a Go plugin runner using Wasmtime’s Go bindings, adds cosign-based binary verification, and explains the WASI capability model that enforces the sandbox.
Threat Model
Threat 1: CVE suppression via adapter manipulation. A compromised adapter script with host access modifies the vulnerability report it feeds to Copa, removing entries for specific CVEs. Copa patches what it is told to patch. If the adapter omits CVE-2024-XXXX from its output, Copa does not attempt to update the affected package. The image ships with a known vulnerability that the scanner found but Copa never saw. With a WASM adapter restricted to stdin/stdout, the blast radius is limited to the JSON the adapter produces — it cannot silently reach back to a registry or C2 server to receive an updated suppress-list.
Threat 2: Credential exfiltration from the adapter process. A malicious third-party scanner adapter, distributed through a plugin registry or pulled from a tampered container image, reads environment variables containing CI credentials, registry tokens, or cloud provider keys. It POSTs these over the network to an attacker-controlled endpoint before returning a plausible (possibly correct) Copa report, so the attack is invisible in the patch output. Under WASI with no wasi:sockets capability and no environment variable access, this attack is not executable at the WASM instruction level.
Threat 3: Resource exhaustion in the adapter. A scanner adapter with pathological or deliberately crafted input triggers uncontrolled memory allocation in the adapter’s JSON deserialiser — allocating gigabytes of WASM linear memory before the host can terminate it. The Wasmtime runtime exposes memory limits and fuel metering that cap both memory footprint and execution cycles, preventing a runaway adapter from OOM-killing the Copa host process.
Implementation
Step 1: Understanding Copa’s Scanner Plugin Interface
Copa’s plugin interface is deliberately simple. Copa invokes the plugin as an external process, passes scanner output on stdin, and expects a Copa-format vulnerability report on stdout. The plugin’s exit code signals success or failure. There is no RPC, no shared library interface, no gRPC — just JSON piped through standard file descriptors.
The JSON schema Copa expects on stdout:
{
"metadata": {
"os": {
"family": "debian",
"name": "12"
}
},
"packages": [
{
"name": "libssl3",
"version": "3.0.11-1~deb12u2",
"type": "deb",
"vulnerabilities": [
{
"id": "CVE-2024-0727",
"severity": "MEDIUM",
"fixedVersion": "3.0.11-1~deb12u4"
}
]
}
]
}
Key fields:
metadata.os.family: The OS family string Copa uses to select the patch strategy. Values Copa recognises includedebian,alpine,centos,rhel.packages[].name: Package name as it appears in the OS package database.packages[].version: Installed version.packages[].type: Package type —deb,rpm, orapk.vulnerabilities[].fixedVersion: The version Copa will attempt to install. If this is empty or omitted, Copa skips the package.
The plugin receives raw scanner output on stdin. For a Grype adapter, that means Grype’s json format output from grype <image> -o json.
Step 2: Building the Grype → Copa Adapter in Rust
Create a new Rust library project:
cargo new --bin grype-copa-adapter
cd grype-copa-adapter
Cargo.toml:
[package]
name = "grype-copa-adapter"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "z"
lto = true
strip = true
No async runtime, no network library, no filesystem crate — the dependency surface is minimal by design. serde and serde_json are the only runtime dependencies. Both are well-audited crates with no transitive network access.
src/main.rs:
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{self, Read, Write};
// --- Grype JSON schema (subset we need) ---
#[derive(Deserialize)]
struct GrypeReport {
matches: Vec<GrypeMatch>,
source: GrypeSource,
}
#[derive(Deserialize)]
struct GrypeSource {
#[serde(rename = "type")]
source_type: String,
target: GrypeTarget,
}
#[derive(Deserialize)]
struct GrypeTarget {
#[serde(rename = "imageID")]
image_id: Option<String>,
#[serde(rename = "os")]
os: Option<GrypeOs>,
}
#[derive(Deserialize)]
struct GrypeOs {
family: String,
name: String,
}
#[derive(Deserialize)]
struct GrypeMatch {
vulnerability: GrypeVulnerability,
artifact: GrypeArtifact,
}
#[derive(Deserialize)]
struct GrypeVulnerability {
id: String,
severity: String,
fix: Option<GrypeFix>,
}
#[derive(Deserialize)]
struct GrypeFix {
versions: Vec<String>,
state: String,
}
#[derive(Deserialize)]
struct GrypeArtifact {
name: String,
version: String,
#[serde(rename = "type")]
artifact_type: String,
}
// --- Copa JSON schema ---
#[derive(Serialize)]
struct CopaReport {
metadata: CopaMetadata,
packages: Vec<CopaPackage>,
}
#[derive(Serialize)]
struct CopaMetadata {
os: CopaOs,
}
#[derive(Serialize)]
struct CopaOs {
family: String,
name: String,
}
#[derive(Serialize)]
struct CopaPackage {
name: String,
version: String,
#[serde(rename = "type")]
pkg_type: String,
vulnerabilities: Vec<CopaVulnerability>,
}
#[derive(Serialize)]
struct CopaVulnerability {
id: String,
severity: String,
#[serde(rename = "fixedVersion")]
fixed_version: String,
}
// --- Transformation logic ---
fn grype_pkg_type_to_copa(grype_type: &str) -> &str {
match grype_type {
"deb" => "deb",
"rpm" => "rpm",
"apk" => "apk",
// Java, python, etc. are not patchable by Copa's OS package manager
_ => "unknown",
}
}
fn transform(grype: GrypeReport) -> CopaReport {
// Extract OS metadata from Grype source target
let (os_family, os_name) = grype
.source
.target
.os
.map(|o| (o.family, o.name))
.unwrap_or_else(|| ("unknown".into(), "unknown".into()));
// Group vulnerabilities by package (name + version + type)
let mut pkg_map: HashMap<(String, String, String), Vec<CopaVulnerability>> = HashMap::new();
for m in grype.matches {
let pkg_type = grype_pkg_type_to_copa(&m.artifact.artifact_type);
// Skip non-OS packages — Copa cannot patch them
if pkg_type == "unknown" {
continue;
}
// Only include vulnerabilities with a fixed version available
let fixed_version = match &m.vulnerability.fix {
Some(fix) if fix.state == "fixed" && !fix.versions.is_empty() => {
fix.versions[0].clone()
}
_ => continue,
};
let vuln = CopaVulnerability {
id: m.vulnerability.id,
severity: m.vulnerability.severity,
fixed_version,
};
let key = (
m.artifact.name.clone(),
m.artifact.version.clone(),
pkg_type.to_string(),
);
pkg_map.entry(key).or_default().push(vuln);
}
let packages = pkg_map
.into_iter()
.map(|((name, version, pkg_type), vulnerabilities)| CopaPackage {
name,
version,
pkg_type,
vulnerabilities,
})
.collect();
CopaReport {
metadata: CopaMetadata {
os: CopaOs {
family: os_family,
name: os_name,
},
},
packages,
}
}
fn main() {
// Read all of stdin — Copa pipes the scanner's raw JSON here
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.expect("failed to read stdin");
// Deserialise Grype report
let grype_report: GrypeReport = match serde_json::from_str(&input) {
Ok(r) => r,
Err(e) => {
eprintln!("grype-copa-adapter: failed to parse Grype JSON: {e}");
std::process::exit(1);
}
};
// Transform to Copa format
let copa_report = transform(grype_report);
// Serialise and write to stdout — Copa reads this
let output = serde_json::to_string_pretty(&copa_report)
.expect("failed to serialise Copa report");
io::stdout()
.write_all(output.as_bytes())
.expect("failed to write stdout");
}
The adapter reads all stdin with read_to_string, deserialises the Grype report, filters out non-OS packages and vulnerabilities without a fixed version (Copa cannot patch what has no fix), groups the rest by package identity, and writes the Copa report to stdout. No file operations, no network calls, no environment variable reads — by construction.
Build for the WASI P1 target:
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
The output is at target/wasm32-wasip1/release/grype-copa-adapter.wasm. Typical size after strip = true and opt-level = "z": 300–600 KB.
Step 3: Verifying the WASI Capability Surface with Wasmtime CLI
Before embedding the plugin in Go, verify that the WASM binary cannot reach the filesystem or network using Wasmtime’s CLI:
# Confirm the plugin reads stdin and writes stdout with no extra grants
grype <image> -o json | wasmtime run \
--allow-read=false \
--allow-write=false \
--allow-net=false \
--allow-env=false \
target/wasm32-wasip1/release/grype-copa-adapter.wasm
Attempt a filesystem access from within the plugin (add a temporary std::fs::read("/etc/passwd") call to main.rs, rebuild, and run):
error: failed to run main module
Caused by:
0: failed to invoke command default
1: error while executing at wasm backtrace:
...
2: WASI errno 2 (ENOENT): No such file or directory
Wasmtime returns ENOENT rather than the file contents because the WASI preopens list is empty — no directories have been granted. The plugin cannot enumerate what it cannot see. A real malicious plugin would receive the same response: WASI filesystem operations fail silently with errno rather than trapping, so the plugin sees an empty filesystem.
Network access is not a WASI socket in wasm32-wasip1 at all — there is no socket API in WASI Preview 1. The plugin cannot open a TCP connection regardless of flags, because the instruction does not exist in the compiled module.
Step 4: Wasmtime Plugin Runner in Go
Copa invokes the scanner plugin as a subprocess. To run a WASM plugin instead of a native binary, wrap the Wasmtime execution in a thin Go shim that Copa calls as its scanner plugin command.
go mod init copa-wasm-runner
go get github.com/bytecodealliance/wasmtime-go/v25
main.go:
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"os"
"github.com/bytecodealliance/wasmtime-go/v25"
)
const (
// Maximum WASM linear memory: 64 MB. Grype JSON for a large image
// is typically < 5 MB; 64 MB gives headroom without OOM risk.
maxMemoryBytes = 64 * 1024 * 1024
// Maximum Wasmtime fuel units. Each WASM instruction consumes one
// unit. 500M units is roughly 0.5 seconds of CPU at 1 GHz — enough
// for any reasonable JSON transformation.
maxFuelUnits = 500_000_000
)
func run(wasmPath string, expectedSHA256 string) error {
// --- Step 1: Verify the WASM binary before loading ---
wasmBytes, err := os.ReadFile(wasmPath)
if err != nil {
return fmt.Errorf("reading wasm plugin: %w", err)
}
if err := verifySHA256(wasmBytes, expectedSHA256); err != nil {
return fmt.Errorf("wasm plugin integrity check failed: %w", err)
}
// --- Step 2: Configure Wasmtime engine with resource limits ---
cfg := wasmtime.NewConfig()
cfg.SetConsumeFuel(true) // enable fuel metering
engine := wasmtime.NewEngineWithConfig(cfg)
store := wasmtime.NewStore(engine)
// Limit linear memory growth
store.Limiter(
maxMemoryBytes, // memory bytes
-1, // table elements (unlimited)
-1, // instances
-1, // tables
-1, // memories
)
// Add fuel — the store will trap if the module exhausts it
if err := store.SetFuel(maxFuelUnits); err != nil {
return fmt.Errorf("setting fuel: %w", err)
}
// --- Step 3: Build restricted WASI context ---
// WasiConfig controls what the WASM module can see of the host.
wasiCfg := wasmtime.NewWasiConfig()
// Connect host stdin → WASM stdin so the plugin reads Grype JSON
wasiCfg.InheritStdin()
// Connect WASM stdout → host stdout so Copa reads Copa JSON
wasiCfg.InheritStdout()
// Connect WASM stderr → host stderr for plugin error messages
wasiCfg.InheritStderr()
// Do NOT call SetEnv — no environment variables visible to plugin
// Do NOT call PreopenDir — no filesystem directories granted
// No network: wasm32-wasip1 has no socket API; nothing to restrict
store.SetWasi(wasiCfg)
// --- Step 4: Compile and instantiate the module ---
module, err := wasmtime.NewModule(engine, wasmBytes)
if err != nil {
return fmt.Errorf("compiling wasm module: %w", err)
}
linker := wasmtime.NewLinker(engine)
if err := linker.DefineWasi(); err != nil {
return fmt.Errorf("defining wasi: %w", err)
}
instance, err := linker.Instantiate(store, module)
if err != nil {
return fmt.Errorf("instantiating wasm module: %w", err)
}
// --- Step 5: Call the plugin's _start (main) function ---
start := instance.GetExport(store, "_start")
if start == nil {
return errors.New("wasm module missing _start export")
}
fn := start.Func()
if fn == nil {
return errors.New("_start export is not a function")
}
if _, err := fn.Call(store); err != nil {
var trapErr *wasmtime.Trap
if errors.As(err, &trapErr) {
return fmt.Errorf("wasm plugin trapped: %s", trapErr.Message())
}
return fmt.Errorf("wasm plugin execution failed: %w", err)
}
return nil
}
func verifySHA256(data []byte, expected string) error {
if expected == "" {
slog.Warn("no expected SHA256 provided; skipping integrity check")
return nil
}
sum := sha256.Sum256(data)
actual := hex.EncodeToString(sum[:])
if actual != expected {
return fmt.Errorf("expected %s, got %s", expected, actual)
}
return nil
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: copa-wasm-runner <plugin.wasm> [expected-sha256]")
os.Exit(1)
}
wasmPath := os.Args[1]
expectedSHA256 := ""
if len(os.Args) >= 3 {
expectedSHA256 = os.Args[2]
}
if err := run(wasmPath, expectedSHA256); err != nil {
slog.Error("plugin runner failed", "err", err)
os.Exit(1)
}
}
Build the runner:
go build -o copa-wasm-runner .
The runner is a static binary that Copa calls as its scanner plugin command. Copa pipes Grype’s JSON to it on stdin; the runner passes that through to the WASM plugin’s stdin; the WASM plugin writes Copa JSON to its stdout; the runner lets that pass through to its stdout; Copa reads the Copa JSON. The WASM plugin never touches the host.
Step 5: Wiring Copa to Use the WASM Plugin Runner
Copa accepts a --scanner-plugin flag specifying the scanner plugin command. Combined with --scanner-file to pass pre-collected scanner output:
# Collect Grype output separately (Grype runs natively, not in WASM)
grype <image>:latest -o json > grype-report.json
# Compute the expected SHA256 of your signed WASM plugin
PLUGIN_SHA=$(sha256sum grype-copa-adapter.wasm | awk '{print $1}')
# Run Copa with the WASM plugin runner as the scanner plugin command
# Copa will pipe grype-report.json to copa-wasm-runner's stdin
copa patch \
-i <image>:latest \
-r grype-report.json \
--scanner-plugin "./copa-wasm-runner grype-copa-adapter.wasm $PLUGIN_SHA" \
-t <image>:latest-patched
The --scanner-plugin command receives the raw scanner report on stdin from Copa and must write a Copa-format JSON to stdout. The WASM runner satisfies this contract.
Step 6: Plugin Signing with cosign
SHA256 integrity verification catches accidental corruption. For supply chain threat protection, sign the WASM binary with cosign so that only plugins signed by your build pipeline are trusted:
# Sign the WASM plugin binary (keyless, OIDC-based — runs in CI)
cosign sign-blob \
--bundle grype-copa-adapter.wasm.bundle \
grype-copa-adapter.wasm
# Verify before use (integrate this into copa-wasm-runner or a pre-flight script)
cosign verify-blob \
--bundle grype-copa-adapter.wasm.bundle \
--certificate-identity "https://github.com/your-org/copa-plugins/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
grype-copa-adapter.wasm
For air-gapped environments, use a cosign keypair instead of keyless:
cosign generate-key-pair
cosign sign-blob --key cosign.key --output-signature grype-copa-adapter.wasm.sig grype-copa-adapter.wasm
cosign verify-blob --key cosign.pub --signature grype-copa-adapter.wasm.sig grype-copa-adapter.wasm
Integrate the cosign verification into the Go runner’s run() function before the SHA256 check, so the binary is validated against the build pipeline’s OIDC identity before it is loaded into Wasmtime at all.
Step 7: Testing the Adapter
Unit test the transformation logic in Rust with a sample Grype report:
#[cfg(test)]
mod tests {
use super::*;
fn sample_grype_report() -> GrypeReport {
serde_json::from_str(r#"
{
"matches": [
{
"vulnerability": {
"id": "CVE-2024-0727",
"severity": "MEDIUM",
"fix": { "versions": ["3.0.11-1~deb12u4"], "state": "fixed" }
},
"artifact": {
"name": "libssl3",
"version": "3.0.11-1~deb12u2",
"type": "deb"
}
},
{
"vulnerability": {
"id": "CVE-2024-9999",
"severity": "LOW",
"fix": { "versions": [], "state": "not-fixed" }
},
"artifact": {
"name": "libssl3",
"version": "3.0.11-1~deb12u2",
"type": "deb"
}
},
{
"vulnerability": {
"id": "CVE-2024-1234",
"severity": "HIGH",
"fix": { "versions": ["3.12.2"], "state": "fixed" }
},
"artifact": {
"name": "requests",
"version": "2.28.0",
"type": "python"
}
}
],
"source": {
"type": "image",
"target": {
"imageID": "sha256:abc123",
"os": { "family": "debian", "name": "12" }
}
}
}
"#).unwrap()
}
#[test]
fn filters_unfixed_vulnerabilities() {
let report = transform(sample_grype_report());
let pkg = report.packages.iter().find(|p| p.name == "libssl3").unwrap();
// CVE-2024-9999 has no fix — must be excluded
assert!(!pkg.vulnerabilities.iter().any(|v| v.id == "CVE-2024-9999"));
// CVE-2024-0727 has a fix — must be included
assert!(pkg.vulnerabilities.iter().any(|v| v.id == "CVE-2024-0727"));
}
#[test]
fn excludes_non_os_packages() {
let report = transform(sample_grype_report());
// Python package must not appear in Copa output
assert!(!report.packages.iter().any(|p| p.name == "requests"));
}
#[test]
fn preserves_os_metadata() {
let report = transform(sample_grype_report());
assert_eq!(report.metadata.os.family, "debian");
assert_eq!(report.metadata.os.name, "12");
}
#[test]
fn fixed_version_propagated() {
let report = transform(sample_grype_report());
let pkg = report.packages.iter().find(|p| p.name == "libssl3").unwrap();
let vuln = pkg.vulnerabilities.iter().find(|v| v.id == "CVE-2024-0727").unwrap();
assert_eq!(vuln.fixed_version, "3.0.11-1~deb12u4");
}
}
Run the tests natively (not under WASM):
cargo test
Integration test using the Wasmtime CLI:
cargo build --target wasm32-wasip1 --release
# Use a fixture file as stdin
wasmtime run \
--allow-read=false \
--allow-write=false \
target/wasm32-wasip1/release/grype-copa-adapter.wasm \
< test-fixtures/grype-debian12.json \
| jq '.packages[0].name'
The output should be a valid package name from the fixture. Pipe through jq to confirm the JSON is well-formed before feeding it to Copa.
Expected Behaviour
Plugin operation under the WASM sandbox:
| Operation | Plugin Action | WASM Sandbox Result |
|---|---|---|
| Read stdin | io::stdin().read_to_string() |
Succeeds — Copa pipes scanner JSON to stdin |
| Write stdout | io::stdout().write_all() |
Succeeds — Copa reads Copa-format JSON from stdout |
| Write stderr | eprintln!() |
Succeeds — stderr is inherited for error diagnostics |
Open /etc/passwd |
std::fs::File::open("/etc/passwd") |
ENOENT — no preopens granted, path not visible |
Open /proc/environ |
std::fs::File::open("/proc/environ") |
ENOENT — same; host /proc not accessible |
| Read environment variable | std::env::var("AWS_SECRET_ACCESS_KEY") |
Empty string or NotPresent — env not inherited |
| Open TCP socket | Not possible | Compile-time: wasm32-wasip1 has no socket API |
| DNS resolution | Not possible | Compile-time: no network API in wasm32-wasip1 |
| Exceed memory limit | Allocate > 64 MB | Wasmtime store limiter traps the module |
| Exceed fuel limit | Long computation loop | Wasmtime traps with out-of-fuel error |
Trade-offs
| Dimension | WASM Plugin | Native Script / Binary |
|---|---|---|
| Isolation | Hard sandbox: no filesystem, network, or env access unless explicitly granted | Full host access: same filesystem, network, and environment as the CI runner |
| Supply chain risk | Compromised adapter can corrupt JSON output only | Compromised adapter can exfiltrate credentials, modify other files, pivot to other systems |
| Startup overhead | ~50–150 ms Wasmtime JIT compilation on first run (AOT cache eliminates this on subsequent runs) | ~5–20 ms for a Python interpreter startup, <1 ms for a native binary |
| Throughput | JSON deserialisation in Rust/WASM is fast; single-image adapter runs in under 100 ms | Equivalent Python script: 200–500 ms; native Go binary: < 50 ms |
| Type safety | Rust enforces type safety at compile time; malformed Grype JSON returns a hard error with location | Python dict access fails at runtime with a KeyError that may be silently swallowed |
| Development speed | Slower: Rust type system requires explicit modelling of all schema fields | Faster: Python jq-style one-liners can transform JSON with no schema definition |
| Portability | Single .wasm binary runs on Linux x86_64, arm64, macOS, Windows without recompilation |
Native binary requires per-platform builds; Python requires interpreter installation |
| Sandboxed vs. in-process plugin | Out-of-process: overhead of IPC (stdin/stdout pipe), but full isolation | In-process: zero IPC overhead, but a bug or attack in the plugin affects the host process directly |
| Observability | Stderr available; no direct access to host logging infrastructure | Full access to host logging, metrics agents, tracing SDKs |
Failure Modes
| Failure | Symptom | Diagnosis | Remediation |
|---|---|---|---|
| Plugin produces malformed JSON | Copa exits with a JSON parse error; no patching occurs | Run the adapter manually with a fixture and pipe to jq . to identify the malformed output |
Fix the Rust serialisation code; check that all Option<> fields serialise as expected; run cargo test |
| WASI stdin not connected correctly | Plugin reads zero bytes from stdin; produces empty packages array; Copa applies no patches |
Add a debug log to the adapter: eprintln!("read {} bytes", input.len()); confirm Copa is passing --scanner-file |
Verify Copa version supports --scanner-plugin; confirm the runner calls wasiCfg.InheritStdin() |
| Plugin WASM binary unsigned or signature mismatch | Runner exits before loading the module; Copa plugin command returns non-zero exit code | Check cosign bundle validity with cosign verify-blob; confirm the OIDC issuer and identity match the build pipeline |
Re-sign the plugin from the canonical build pipeline; do not manually modify the .wasm binary after signing |
| Wasmtime version incompatible with wasm32-wasip1 | Module fails to instantiate with “unknown import” or “missing export” errors | Check wasmtime --version and the Cargo version of wasmtime-go; WASI P1 requires Wasmtime ≥ 14 |
Pin the Wasmtime Go bindings version; rebuild with a compatible toolchain |
| Plugin exhausts fuel limit | Wasmtime traps with “all fuel consumed”; Copa plugin command returns non-zero | Large Grype JSON (thousands of matches) may exceed 500M fuel units | Increase maxFuelUnits in the runner; profile which transformation loop is expensive |
| Plugin OOM in WASM linear memory | Wasmtime store limiter traps; runner logs “memory limit exceeded” | Grype report with extremely large match count causes WASM allocator to request more than 64 MB | Increase maxMemoryBytes or stream the Grype JSON rather than reading it all into a String |
| Grype OS metadata missing | metadata.os.family is “unknown”; Copa cannot determine patch strategy |
Grype’s JSON does not always populate target.os for all image types |
Fall back to parsing the distro field from grype.distro; add a manual override flag to the runner |