How to implement a detector plugin
Last updated October 25, 2026View source (v0.30.0)
A detector plugin turns project evidence into a Bomly dependency graph. Write one when Bomly needs a new way to read dependency data: a new package manager, a specialized manifest format, or an internal dependency source.
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 detectors. 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. To turn it into a detector, change the module kind, the descriptor, and the role method; the layout, manifest, tests, and release workflow stay the same, with "kind": "detector" in bomly-plugin.json.
The module
The component lives in an importable plugin/ package that exports Module():
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.bun-lock-detector"
// Config is the detector's typed configuration block.
type Config struct {
IncludeDev bool `json:"includeDev" doc:"Include devDependencies in the graph" default:"false"`
}
// Detector reads bun.lock evidence and returns a dependency graph.
// sdkplugin.BaseDetector supplies default Ready/Applicable implementations
// (always ready, always applicable).
type Detector struct {
sdkplugin.BaseDetector
config Config
}
func descriptor() sdkplugin.DetectorDescriptor {
return sdkplugin.DetectorDescriptor{
Name: Name,
DisplayName: "Bun Lock Detector",
Aliases: []string{"bun-lock"},
Tags: []string{"dependency-detection", "bun"},
// Directories recursive discovery must never descend into for this
// ecosystem (see "Discovery metadata" below).
IgnoredDirectories: []string{"node_modules"},
ConfigSchema: sdkplugin.MustConfigSchemaFor(Config{}),
}
}
func support() []sdkplugin.PackageManagerSupport {
return []sdkplugin.PackageManagerSupport{
sdkplugin.Support(model.PackageManagerOther, "bun.lock", "bun.lockb", "package.json"),
}
}
func (d *Detector) Descriptor() sdkplugin.DetectorDescriptor { return descriptor() }
func (d *Detector) PackageManagerSupport() []sdkplugin.PackageManagerSupport { return support() }
// ResolveGraph reads the request and returns one or more manifest-scoped graphs.
func (d *Detector) ResolveGraph(ctx context.Context, req sdkplugin.DetectionRequest) (sdkplugin.DetectionResult, error) {
graph := model.New()
// The constructor is the only mint for a node's identity: it derives the
// ID from the canonical package URL, so never assemble one by hand.
dep, err := model.NewDependencyNode(model.Coordinates{
Ecosystem: model.EcosystemNPM,
Name: "is-odd",
Version: "3.0.1",
PURL: "pkg:npm/is-odd@3.0.1",
})
if err != nil {
return sdkplugin.DetectionResult{}, fmt.Errorf("build node: %w", err)
}
dep.FoundBy = Name
if err := graph.AddNode(dep); err != nil {
return sdkplugin.DetectionResult{}, fmt.Errorf("add node: %w", err)
}
return sdkplugin.DetectionResult{
SubprojectInfo: req.Subproject,
RootExecutionTarget: req.ExecutionTarget,
Graphs: model.SingleGraphContainer(graph, model.ManifestMetadata{
Path: "bun.lock",
}),
}, nil
}
// Module packages the detector for both execution modes.
func Module() sdkplugin.Module {
return sdkplugin.Module{
Kind: sdkplugin.PluginKindDetector,
Detector: &sdkplugin.DetectorModule{
Descriptor: descriptor(),
Support: support(),
New: func(_ context.Context, host sdkplugin.HostContext) (sdkplugin.Detector, error) {
detector := &Detector{}
if err := host.DecodeConfig(&detector.config); err != nil {
return nil, fmt.Errorf("decode %s config: %w", Name, err)
}
return detector, nil
},
},
}
}
The binary entrypoint stays one line:
package main
import (
"github.com/bomly-dev/bomly-sdk/runtime"
"example.com/bomly-plugin-bun-lock/plugin"
)
func main() { runtime.ServeModule(plugin.Module()) }
What each part does
Descriptoris the detector's static registration: name (must equal the manifestid), display name, aliases, tags, supported ecosystems and managers, discovery metadata, and config schema.Support(or thePackageManagerSupportmethod) tells Bomly which package managers and evidence files can plan this detector — declared on the module so Bomly can plan without constructing the component.Newconstructs the detector once per execution, with aplugin.HostContextfor the logger, HTTP client, runtime info, and configuration.Ready(ctx, req) errorreports whether the detector can run right now. Returnnilwhen ready; return an error whose message explains the reason (for examplefmt.Errorf("bun executable not found on PATH")) when it cannot.plugin.BaseDetectorembeds an always-ready default.Applicable(ctx, req) (bool, error)reports whether the detector should run for this request (right project shape, right evidence present).ResolveGraphdoes the work: read evidence, build graphs, return them.
Honor the context in every method: probing, parsing, and subprocess work should stop promptly when the scan is cancelled.
Detector chains and hand-off
Bomly plans detector chains per package manager and runs them first-success: when the planned primary detector reports not ready or not applicable, or fails, the next detector in the chain gets the request. Syft is typically the last fallback for ecosystems it covers.
Hand off gracefully instead of failing hard:
- Report a missing toolchain through
Readywith a clear reason, not through aResolveGrapherror. The reason appears in scan output when a fallback is used (FallbackReasonon the result the fallback produces). - Report "this project is not for me" through
Applicable, not through an empty graph. - Reserve
ResolveGrapherrors for real failures on projects the detector should have handled.
Discovery metadata
Detector plugins take part in project discovery and scan planning through descriptor fields. Bomly aggregates them across every registered detector, built-in or external:
PackageManagerSupport.EvidencePatterns— file names (such asbun.lock) whose presence plans this detector for a directory.DetectorDescriptor.IgnoredDirectories— directory basename globs recursive discovery (--recursive) must not descend into (a Node detector declaresnode_modules, a Maven detector declarestarget).DetectorDescriptor.IgnoredDirectoryMarkers— file names that mark a directory as ignored regardless of its name (pyvenv.cfgmarks a Python virtualenv).plugin.Support(...).WithMultiModule()— declares that the detector natively expands nested workspace or reactor modules from a root manifest, so recursive discovery does not scan the same modules twice.
All of these are optional; older plugins that omit them keep working.
Build the graph
Use SDK graph helpers instead of constructing graph internals by hand:
parent, err := model.NewDependencyNode(model.Coordinates{Name: "app", Version: "0.0.0", PURL: "pkg:generic/app@0.0.0"})
if err != nil {
return sdkplugin.DetectionResult{}, err
}
child, err := model.NewDependencyNode(model.Coordinates{Name: "lodash", Version: "4.17.21", PURL: "pkg:npm/lodash@4.17.21"})
if err != nil {
return sdkplugin.DetectionResult{}, err
}
graph := model.New()
if err := graph.AddNode(parent); err != nil {
return sdkplugin.DetectionResult{}, err
}
if err := graph.AddNode(child); err != nil {
return sdkplugin.DetectionResult{}, err
}
// Edges join node IDs, which only the constructors mint.
if err := graph.AddEdge(parent.NodeID(), child.NodeID()); err != nil {
return sdkplugin.DetectionResult{}, err
}
Prefer canonical PURLs and fill Coordinates where possible — matchers enrich packages by PURL, and findings reference them the same way. Return req.Subproject and req.ExecutionTarget in the result so Bomly keeps it tied to the planned scan target. Use DetectionResult.Warnings for non-fatal problems worth surfacing (the graph is usable, but something about the project will degrade an install elsewhere).
Optional capabilities
Install-first. A detector that must prepare dependencies before reading them (for example, running a resolving install) implements plugin.InstallFirstDetector (Install(ctx, req) error) and sets SupportsInstallFirst in its descriptor. Do not install package managers themselves; Bomly assumes required package managers already exist.
Remediation hints. A detector that understands package-manager fix strategies can advertise them and contribute read-only evidence after vulnerability enrichment:
// In the descriptor:
RemediationCapabilities: []sdkplugin.RemediationCapability{{
SupportedManagers: []model.PackageManager{model.PackageManagerNPM},
Actions: []model.RemediationAction{
model.RemediationActionDirectBump,
model.RemediationActionTransitiveOverride,
},
}},
Then implement plugin.DetectorRemediationProvider on the detector:
func (d *Detector) RemediationHints(ctx context.Context, req sdkplugin.RemediationHintRequest) (sdkplugin.RemediationHintResponse, error)
Return hints only for dependency IDs and manifest paths this detector produced. Hints may name supported strategies and give plain-language package-manager advice. They must not choose a fix version, edit files, run commands, or make network calls — Bomly validates every hint and the central remediation component chooses the final action. Detectors without the capability are simply never asked.
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.detectors.<name>:
plugins:
detectors:
com.example.bun-lock-detector:
includeDev: true
Test it
Unit-test parsing and graph construction against testdata/ fixtures, then run the conformance suite and the local loop described in Plugin authoring. Select the detector with --detectors <name> to run only it, or --detectors +<name> to add it to the default chain:
bomly scan --path ./my-project --detectors com.example.bun-lock-detector --json
Checklist
In addition to the checklist for every plugin:
- Declare accurate package-manager support, evidence patterns, and discovery metadata.
- Report a missing toolchain through
Readywith a clear reason, so the chain can hand off. - Build nodes with the SDK constructors and prefer canonical PURLs with filled
Coordinates. - Keep remediation hints read-only and within the advertised capabilities.