How to implement a matcher plugin
Last updated October 25, 2026View source (v0.30.0)
A matcher plugin enriches packages after detection. Write one when you want to add vulnerability data, license data, lifecycle information, health signals, or other metadata to Bomly's package registry.
Plugin authoring covers the module model, repository layout, configuration, testing, packaging, and protocol rules shared by every role. This guide covers only what is specific to matchers. The SDK reference for the types used below is the plugin and model packages.
Start from the template
Start from the bomly-plugin-template repository. It ships a working matcher. Replace its Match logic and work through its rename checklist.
The module
This is the template's matcher, trimmed to the essentials:
package plugin
import (
"context"
"fmt"
"github.com/bomly-dev/bomly-sdk/model"
sdkplugin "github.com/bomly-dev/bomly-sdk/plugin"
)
// 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. sdkplugin.BaseMatcher supplies default
// Ready/Applicable implementations (always ready, always applicable).
type Matcher struct {
sdkplugin.BaseMatcher
config Config
host sdkplugin.HostContext
}
func descriptor() sdkplugin.MatcherDescriptor {
return sdkplugin.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: []model.Ecosystem{model.EcosystemNPM, model.EcosystemGo},
// The matcher can return package-update deltas (see below).
Capabilities: []string{sdkplugin.CapabilityPackageUpdates},
ConfigSchema: sdkplugin.MustConfigSchemaFor(Config{}),
}
}
func (m *Matcher) Descriptor() sdkplugin.MatcherDescriptor { return descriptor() }
// Match runs once per scan with the full package registry.
func (m *Matcher) Match(ctx context.Context, req sdkplugin.MatchRequest) (sdkplugin.MatchResult, error) {
stats := sdkplugin.MatcherStats{Name: Name, DisplayName: "Example License Matcher"}
if req.Registry == nil {
return sdkplugin.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 []*model.Package
for _, pkg := range req.Registry.All() {
update := &model.Package{Coordinates: model.Coordinates{PURL: pkg.PURL}}
update.Licenses = []model.PackageLicense{{SPDXExpression: "MIT"}}
updates = append(updates, update)
}
stats.MatchedPackages = len(updates)
stats.Licenses = len(updates)
return sdkplugin.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, model.PackageLicense{SPDXExpression: "MIT"})
stats.MatchedPackages++
stats.Licenses++
}
return sdkplugin.MatchResult{Registry: req.Registry, MatcherStats: stats}, nil
}
// Module packages the matcher for both execution modes.
func Module() sdkplugin.Module {
return sdkplugin.Module{
Kind: sdkplugin.PluginKindMatcher,
Matcher: &sdkplugin.MatcherModule{
Descriptor: descriptor(),
New: func(_ context.Context, host sdkplugin.HostContext) (sdkplugin.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() { runtime.ServeModule(plugin.Module()) }
What each part does
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 aplugin.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).plugin.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.
Use the registry
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, model.PackageLicense{SPDXExpression: "MIT"})
pkg.Vulnerabilities = append(pkg.Vulnerabilities, model.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.
Registry deltas (package-updates-v1)
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
plugin.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.
model.ApplyPackageUpdates implements the same merge the host uses; it is handy in tests and for building a full-registry fallback from the delta path.
Degrade, do not fail
An enrichment failure should not stop a scan. If the upstream service is down or a response does not parse, return partial results: the packages you did enrich, with UnmatchedPackages counted in MatcherStats. Do not return 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.
Configuration
Declare a typed Config struct, advertise it with ConfigSchema, and decode it in New as described in Plugin authoring. Users set the block under plugins.matchers.<name>:
plugins:
matchers:
com.example.license-matcher:
apiBase: https://api.example.com
Make outbound calls through HostContext.HTTPClient() so Bomly's proxy and CA settings apply. Document every endpoint the matcher talks to in its README. If the output is deterministic for a fixed input and service version, cache it inside the plugin; a cache failure is a warning, not an error.
Test it
Unit-test the mapping from service responses into package data, cover both the full-registry and delta paths, then run the conformance suite and the local loop described in Plugin authoring. --matchers +<name> adds the matcher to the default set; --matchers <name> runs only it:
bomly scan --path ./my-project --enrich --matchers +com.example.license-matcher --json
Checklist
In addition to the checklist for every plugin:
- 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.