A matcher plugin enriches packages after detection. Use a matcher when you want to add vulnerability data, license data, lifecycle information, health signals, or other package metadata to Bomly's package registry.
A matcher is one sdk.Module with Kind: sdk.PluginKindMatcher. The same module can be compiled into a host build (embedded) or served as a managed plugin binary with sdk.ServeModule — you write the component once. Plugin basics covers the module model, repository contract, configuration, testing, and release flow shared by every role; this guide covers what is specific to matchers.
The Bomly SDK API reference documents sdk.Module, sdk.MatcherModule, the sdk.Matcher interface, sdk.MatchRequest, sdk.MatchResult, the PURL-keyed package registry, and the enrichment types used below.
The bomly-plugin-template repository is a matcher — a complete, working one that annotates every package with a configurable greeting. Click "Use this template" on GitHub, work through its rename checklist, and replace the Match logic. The layout:
plugin/ importable package: descriptor, Config, Matcher, Module()
cmd/<binary-name>/
main.go one line: sdk.ServeModule(plugin.Module())
bomly-plugin.json package manifest ("kind": "matcher")
testdata/ fixture registry for unit tests
.github/workflows/ CI and the release workflow
go.mod pins a released github.com/bomly-dev/bomly-sdk version
This is the template's matcher, trimmed to the essentials:
package plugin
import (
"context"
"fmt"
sdk "github.com/bomly-dev/bomly-sdk"
)
// Name must equal the "id" field in bomly-plugin.json.
const Name = "com.example.license-matcher"
// Config is the matcher's typed configuration block.
type Config struct {
APIBase string `json:"apiBase" doc:"Service endpoint override" default:"https://api.example.com"`
}
// Matcher enriches registry packages. sdk.BaseMatcher supplies default
// Ready/Applicable implementations (always ready, always applicable).
type Matcher struct {
sdk.BaseMatcher
config Config
host sdk.HostContext
}
func descriptor() sdk.MatcherDescriptor {
return sdk.MatcherDescriptor{
Name: Name,
DisplayName: "Example License Matcher",
Aliases: []string{"example-licenses"},
Tags: []string{"license-enrichment", "http"},
// Declare the ecosystems the matcher can actually enrich. An empty
// list reads as "all ecosystems".
SupportedEcosystems: []sdk.Ecosystem{sdk.EcosystemNPM, sdk.EcosystemGo},
// The matcher can return package-update deltas (see below).
Capabilities: []string{sdk.CapabilityPackageUpdates},
ConfigSchema: sdk.MustConfigSchemaFor(Config{}),
}
}
func (m *Matcher) Descriptor() sdk.MatcherDescriptor { return descriptor() }
// Match runs once per scan with the full package registry.
func (m *Matcher) Match(ctx context.Context, req sdk.MatchRequest) (sdk.MatchResult, error) {
stats := sdk.MatcherStats{Name: Name, DisplayName: "Example License Matcher"}
if req.Registry == nil {
return sdk.MatchResult{MatcherStats: stats}, nil
}
if req.AcceptPackageUpdates {
// Delta path: return only the packages we touched. Each update is a
// sparse Package carrying the PURL (the merge key) plus the new data.
var updates []*sdk.Package
for _, pkg := range req.Registry.All() {
update := &sdk.Package{Coordinates: sdk.Coordinates{PURL: pkg.PURL}}
update.Licenses = []sdk.PackageLicense{{SPDXExpression: "MIT"}}
updates = append(updates, update)
}
stats.MatchedPackages = len(updates)
stats.Licenses = len(updates)
return sdk.MatchResult{PackageUpdates: updates, MatcherStats: stats}, nil
}
// Baseline path (protocol v1): enrich in place, echo the full registry.
for _, pkg := range req.Registry.All() {
pkg.Licenses = append(pkg.Licenses, sdk.PackageLicense{SPDXExpression: "MIT"})
stats.MatchedPackages++
stats.Licenses++
}
return sdk.MatchResult{Registry: req.Registry, MatcherStats: stats}, nil
}
// Module packages the matcher for both execution modes.
func Module() sdk.Module {
return sdk.Module{
Kind: sdk.PluginKindMatcher,
Matcher: &sdk.MatcherModule{
Descriptor: descriptor(),
New: func(_ context.Context, host sdk.HostContext) (sdk.Matcher, error) {
matcher := &Matcher{host: host}
if err := host.DecodeConfig(&matcher.config); err != nil {
return nil, fmt.Errorf("decode %s config: %w", Name, err)
}
return matcher, nil
},
},
}
}The binary entrypoint stays one line:
func main() { sdk.ServeModule(plugin.Module()) }Descriptoris the matcher's static registration: name (must equal the manifestid), display name, aliases, tags, supported ecosystems, capabilities, and config schema.Newconstructs the matcher once per execution, with asdk.HostContextfor the logger, HTTP client, runtime info, and configuration.Ready(ctx, req) errorreports whether the matcher can run right now — returnnilwhen ready, or an error explaining the reason (a missing token, an unreachable endpoint).sdk.BaseMatcherembeds an always-ready default; override it when your matcher depends on something that can be absent.Applicable(ctx, req) (bool, error)reports whether the matcher should run for this request (for example, only certain ecosystems).Matchdoes the work: enrich registry packages and return the result withMatcherStats.
Matchers only run during explicit enrichment (--enrich). Honor the context: pass it into every HTTP call so a cancelled scan stops promptly.
Bomly separates dependency instances from package records:
req.Graphcontains dependency nodes and edges — identity and structure.req.Registrycontains canonical package records keyed by PURL. Matchers enrich each package once per PURL, no matter how many dependency instances point at it.
Use Ensure when a package may already exist:
pkg := req.Registry.Ensure("pkg:npm/lodash@4.17.21")
pkg.Licenses = append(pkg.Licenses, sdk.PackageLicense{SPDXExpression: "MIT"})
pkg.Vulnerabilities = append(pkg.Vulnerabilities, sdk.Vulnerability{
ID: "GHSA-example",
Source: "example-feed",
})Prefer canonical PURLs. Auditors and output rendering use PURLs to connect findings, vulnerabilities, and packages. Enrich packages; do not rewrite graph identity.
Matchers can respond in two shapes:
- Full registry (protocol v1 baseline): return
Registrywith every package, enriched or not. Always works. - Deltas: return
PackageUpdatescontaining only the packages you touched. The host merges them into its registry by PURL. Cheaper on the wire for large projects.
The rules:
- Advertise
sdk.CapabilityPackageUpdatesinDescriptor.Capabilities. - Return
PackageUpdatesonly whenreq.AcceptPackageUpdatesis true — that is the host telling you it understands deltas. Older hosts never set it, and you must fall back to the full-registry shape for them. - When
Registryis non-nil in the result, it wins andPackageUpdatesis ignored — return one or the other.
sdk.ApplyPackageUpdates implements the same merge the host uses; it is handy in tests and for building a full-registry fallback from the delta path.
Enrichment failures should not sink a scan. If the upstream service is down or a response does not parse, prefer returning partial results — the packages you did enrich, with UnmatchedPackages counted in MatcherStats — over returning an error. Report a completely unavailable dependency (missing token, unreachable endpoint) through Ready with a clear reason instead of failing mid-Match. Cache lookups that fail are non-fatal: log a warning and continue without the cache.
Declare a typed Config struct with json, doc:, and default: tags, advertise it with ConfigSchema: sdk.MustConfigSchemaFor(Config{}), and decode it in New with host.DecodeConfig(&cfg). Users set the block under plugins.matchers.<name>:
plugins:
matchers:
com.example.license-matcher:
apiBase: https://api.example.comThe same block reaches the component in both execution modes. See Configuration in the plugin guide for details and the deprecated flat form.
Make outbound calls through HostContext.HTTPClient() so Bomly's proxy, no-proxy, and CA settings apply consistently:
client := m.host.HTTPClient().Client(20 * time.Second)Document every endpoint the matcher talks to in its README. If the matcher produces deterministic output for a fixed input and service version, add caching inside the plugin; cache failures are non-fatal.
Unit-test the mapping from service responses into registry package data, cover both the full-registry and delta paths, and run the SDK conformance suite:
func TestConformance(t *testing.T) {
conformance.Test(t, conformance.Config{
Module: Module(),
ManifestPath: filepath.Join("..", "bomly-plugin.json"),
SampleConfig: json.RawMessage(`{"apiBase":"https://example.test"}`),
})
}The template's plugin/plugin_test.go shows a minimal test HostContext stub and fixture-registry loading.
Local development loop:
go build -o ./bin/bomly-plugin-example ./cmd/bomly-plugin-example
bomly plugins install ./bin/bomly-plugin-example --dev
bomly plugins enable com.example.license-matcher
bomly scan --path ./my-project --enrich --matchers +com.example.license-matcher --json
bomly plugins verify com.example.license-matcher
bomly plugins test com.example.license-matcher
bomly plugins doctor com.example.license-matcher--matchers +<name> adds the matcher to the default set; --matchers <name> runs only it. See Testing a plugin for the shared workflow, including conformance.ProbeBinary.
Follow the template's release workflow: one archive per platform named <name>_<version>_<os>_<arch>.tar.gz (.zip on Windows) containing the binary, bomly-plugin.json, README.md, and LICENSE, plus a SHA256SUMS file. The manifest's entrypoint map names the binary per platform, and descriptor.Name must equal the manifest id. See Package and release.
- Enrich
req.Registryby PURL; do not rewrite graph identity. - Return
MatcherStatswith the matcher name and useful counts. - Advertise
SupportedEcosystemsandCapabilitiesaccurately. - Return
PackageUpdatesonly whenreq.AcceptPackageUpdatesis true; otherwise return the full registry. - Degrade to partial results on upstream failures; report hard unavailability through
Ready. - Make network calls through
HostContext.HTTPClient()and document every endpoint. - Honor context cancellation in HTTP calls and long loops.
- Wrap errors with useful context; avoid panics.
- Do not log secrets, tokens, or credentials.
- Add unit tests for response mapping plus both response shapes, and
conformance.Test.