Scanner plug-in signing

This page describes how authors produce a signed Scanner plug-in manifest and how operators curate the trust store that the Scanner runtime checks against. It mirrors the schema and behaviour shipped in StellaOps.Scanner.Plugin.Contracts and StellaOps.Scanner.Plugin.Loader.

Why signing

The Scanner host loads language-analyzer plug-ins out of a drop folder (typically plugins/scanner/analyzers/<lang>/). A plug-in is a manifest + an assembly DLL. By default the loader (ScannerPluginLoader) refuses to load any manifest without a valid DSSE signature whose key id resolves to a trust anchor in ScannerPluginLoaderOptions.TrustedKeys. This is the fail-secure posture from CoC §3 DoD.

The signing tool (StellaOps.Scanner.Plugin.SigningTool) is the build-time authoring half of that pipeline: it produces signed manifest.json files that the runtime loader will accept. Runtime verification support (TrustStore and Ed25519AsymmetricSignatureVerifier) lives in StellaOps.Scanner.Plugin.Loader, so Worker/WebService hosts do not depend on the console/tooling assembly.

Runtime analyzer bundle contract

Scanner language analyzer bundles use a restart-time directory contract shared by Worker and WebService path resolution:

plugins/scanner/analyzers/
|-- dotnet/
|   |-- manifest.json
|   |-- StellaOps.Scanner.Analyzers.Lang.DotNet.dll
|   |-- *.deps.json / runtimeconfig / transitive DLLs as required
|-- node/
|   |-- manifest.json
|   `-- ...
`-- <analyzer>/
    |-- manifest.json
    `-- payload files

Rules:

Approved base bundle:

Approved full-analyzer overlay:

Compose mounts the full language set read-only at /app/plugins/scanner/analyzers and points ScannerAnalyzerHost__PluginDirectory at that canonical runtime root. The same overlay mounts the signed base bundle at /app/plugins/scanner/base for ScannerOsAnalyzerHost, so required OS analyzers stay signed and admitted without relying on raw build-output directories.

package-runtime-plugins.ps1 -Module scanner -Profile scanner-runtime publishes every language analyzer from the current source tree, rebuilds the base OS analyzers with their complete dotnet publish dependency closure, stamps the resulting host contracts, signs the manifests, and emits both the base and full-analyzer profiles. The RPM producer additionally fails packaging unless its managed SQLite provider and Linux native assets are present. The build-service-publish.sh scanner-worker path runs that producer after the Worker publish, overlays the producer’s exact contract DLLs into the image context, and only then builds the image. Do not assemble Scanner runtime bundles by copying an old bin/ or ignored analyzer drop tree.

Harness overlay:

Fail-closed behavior is opt-in during the image-staged compatibility window. ScannerAnalyzerHostOptions exposes RequirePluginDirectory, RequireTrustStoreDirectory, RequireTrustedKeys, and RequireLoadedAnalyzers; production compose should enable these once the mounted base bundle and production trust store are wired. Until then, the Worker preserves the existing degraded empty-analyzer behavior when the bundle or trust root is absent, with explicit warning/error logs.

Verbs

dotnet exec .../StellaOps.Scanner.Plugin.SigningTool.dll <verb> ...
VerbPurpose
signRead an unsigned manifest, sign with an Ed25519 key, emit the signed manifest.
verifyRead a signed manifest, look up each signature’s key id in a trust-store directory, exit 0 iff at least one signature verifies.
derive-keyidPrint the conventional key id for an Ed25519 public key file.
export-publicDerive public material from a private seed and write a loader-compatible trust-store entry without exposing the seed.

sign

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll sign \
  --manifest plugins/scanner/analyzers/elixir/manifest.unsigned.json \
  --key      src/__Tests/__Datasets/trust-store/scanner-plugins/dev/seed.hex \
  --stamp-assembly-sha256 \
  --assembly-base-dir plugins/scanner/analyzers/elixir \
  --contract-assembly path/to/StellaOps.Scanner.Analyzers.Lang.dll \
                      path/to/StellaOps.Scanner.Analyzers.LangAdapter.dll \
                      path/to/StellaOps.Scanner.Plugin.Contracts.dll \
  --out      plugins/scanner/analyzers/elixir/manifest.json

verify

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll verify \
  --manifest    plugins/scanner/analyzers/elixir/manifest.json \
  --trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev

Exit codes:

derive-keyid

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll derive-keyid \
  --key src/__Tests/__Datasets/trust-store/scanner-plugins/dev/stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.ed25519
# prints: stella-scanner-plugin-dev-ef2eaaad7b347fe5

export-public

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll export-public \
  --key <offline-release-seed> \
  --output-directory <release-trust-store> \
  --key-id-prefix stella-scanner-plugin-release-

The command writes the raw <keyId>.public.ed25519 file consumed by TrustStore, a base-64 mirror, and index.json. It never writes or prints the private seed.

Key id convention

keyId = <prefix> + first16hex(SHA-256(rawPublicKey))

Default prefix is stella-scanner-plugin-dev-; production hosts should choose a stable, host-specific prefix.

This convention is deterministic: an operator can rebuild the keyId -> publicKey mapping from public keys alone, which means key rotation can be audited without trusting the file names on disk.

Trust-store layout

src/__Tests/__Datasets/trust-store/scanner-plugins/dev/
├── README.md
├── index.json
├── seed.hex                                              (dev only)
├── stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.b64
└── stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.ed25519

A Scanner host wires the Loader trust store into the loader like this:

var trusted = TrustStore.LoadFromDirectory(trustStoreDirectory);
var options = new ScannerPluginLoaderOptions
{
    PluginDirectory = pluginDirectory,
    SearchPattern   = "manifest.json", // or the default "*.manifest.json"
    TrustedKeys     = trusted,
    AllowUnsigned   = false,           // production posture; CoC §3 DoD
};
var loader = new ScannerPluginLoader(options, new Ed25519AsymmetricSignatureVerifier());
var result = await loader.LoadAsync(cancellationToken);

Runtime hosts should use ScannerAnalyzerBundleContract.ProductionTrustStoreRelativePath for production mounts. Examples below that reference the dev trust store are authoring/test smoke checks only.

Production posture

Determinism

Ed25519 signatures are deterministic by RFC 8032 construction. Re-signing the same manifest with the same key produces byte-identical signature bytes, which is what makes the signed manifest.json artefact a reproducible build output suitable for committing into plugins/scanner/.

The canonical-payload computation uses StellaOps.Canonical.Json’s RFC 8785 canonicalization (the same library the verifier consumes), so the canonical bytes the signing tool covers are byte-identical to the canonical bytes the loader reconstructs from the in-record manifest core.

Code-of-conduct notes

Author quick-start

Third-party analyzer authors should start from the docs/dev/templates/scanner-analyzer/ template (Hello-Lang). The template ships with a working signed manifest and a worked-example integration test under StellaOps.Scanner.Plugin.Loader.Tests/HelloLangTemplateIntegrationTests.cs.

Operator workflow (stella scanner plugins)

The StellaOps.Scanner.Plugin.SigningTool console app is intentionally scoped to authors: it owns the private signing key. Operators and CI pipelines that need to enumerate or verify already-signed manifests use the in-CLI verbs under stella scanner plugins instead. The CLI reuses the same Loader ScannerPluginLoader and TrustStore the runtime does, so a result from the CLI is byte-equivalent to what the Scanner host will see at load time.

stella scanner plugins list <plugin-dir> [--trust-store <dir>] [--pattern <glob>] [--allow-unsigned] [--format text|json]
stella scanner plugins verify --manifest <path> [--trust-store <dir>] [--strict] [--format text|json]
stella scanner plugins derive-keyid --public-key <path> [--prefix <prefix>] [--format text|json]

list

Enumerate plug-ins under a directory. One signed manifest.json is expected per immediate subdirectory (the drop-folder convention used by plugins/scanner/analyzers/<lang>/).

stella scanner plugins list plugins/scanner/analyzers/ \
  --trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev

Output (text mode):

ID                              | Version | Languages          | Signed | Key ID                                       | SHA-256 (short)
stellaops.scanner.plugin.elixir | 0.1.0   | elixir,erlang,hex,otp | ok  | stella-scanner-plugin-dev-ef2eaaad7b347fe5  | 96093f1b42b9

Exit codes:

--format json produces a deterministic JSON document with success, count, rejectedCount, plugins[], and rejected[] arrays; useful for CI gates.

verify

Verify a single signed manifest against a trust-store directory. Mirrors the SigningTool’s verify verb but lives in the operator CLI so it doesn’t drag the signing-tool binary onto production hosts.

stella scanner plugins verify \
  --manifest    plugins/scanner/analyzers/elixir/manifest.json \
  --trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev

The default posture is --strict (rejects unsigned manifests). Pass --strict=false for offline-dev rotations.

Exit codes match list.

derive-keyid

Operator-side convenience over KeyMaterial.DeriveKeyId. Useful when curating production trust stores from rotated public keys.

stella scanner plugins derive-keyid \
  --public-key src/__Tests/__Datasets/trust-store/scanner-plugins/dev/stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.ed25519
# prints: stella-scanner-plugin-dev-ef2eaaad7b347fe5

--prefix is supported for production keys; the default is stella-scanner-plugin-dev-.

Smoke recipe

After landing a freshly-signed plug-in into plugins/scanner/analyzers/, operators can run the dev trust store against it as a self-check:

stella scanner plugins list plugins/scanner/analyzers/ \
  --trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev --format json

The result should report success: true, count >= 1, and the new plug-in’s signed: true with the expected key id.