Extending Copa with WebAssembly: Building Sandboxed Scanner Plugins

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 include debian, 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, or apk.
  • 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