Plugin Development, Scanning Chains & Agent Integration
1. Plugin interface
Every scanner implements one Go interface:
type Plugin interface {
Name() string // unique id, e.g. "subfinder"
Type() PluginType // recon | port_scan | http_probe | vuln_scan | …
Version() string
Run(ctx context.Context, input PluginInput, config PluginConfig) (*PluginResult, error)
}
PluginInput
type PluginInput struct {
OrganizationID string
ScanID string
Scope []ScopeItem // {Type: "domain", Value: "example.com"}
Profile string // quick | default | deep | stealth
Options PluginOptions // RateLimit, TimeoutSecs, ActiveScan, TestMode
}
PluginResult
type PluginResult struct {
Plugin string
Status string // "success" | "failed" | "timeout"
Error string
StartedAt time.Time
FinishedAt time.Time
NormalizedEntities []assets.NormalizedEntity
}
NormalizedEntity — the universal output token
type NormalizedEntity struct {
EntityType string // "asset" or "vulnerability"
AssetType AssetType // domain | subdomain | ip | url | service | …
Value string // the discovered value
SourcePlugin string
Confidence float64 // 0.0–1.0
Metadata map[string]any
}
Asset entities are deduplicated on (org_id, type, normalized_value).
Vulnerability entities must carry the following metadata keys:
| Key | Required | Notes |
|---|---|---|
title |
yes | human-readable name |
severity |
yes | critical / high / medium / low / info |
template_id |
yes | becomes external_id; deduplicates the finding |
description |
no | |
remediation |
no | |
matched_url |
no | used to link the vuln to an asset |
2. Creating a plugin step by step
Step 1 — write the plugin file
// backend/internal/plugins/myplugin.go
package plugins
import (
"context"
"os/exec"
"strings"
"time"
"github.com/easm-platform/backend/internal/assets"
)
type MyPlugin struct{}
func (p *MyPlugin) Name() string { return "myplugin" }
func (p *MyPlugin) Type() PluginType { return TypeRecon }
func (p *MyPlugin) Version() string { return "1.0.0" }
func (p *MyPlugin) Run(ctx context.Context, input PluginInput, config PluginConfig) (*PluginResult, error) {
result := &PluginResult{Plugin: p.Name(), StartedAt: time.Now()}
// ── test mode ────────────────────────────────────────────
if tm, _ := config.Options["test_mode"].(bool); tm {
result.Status = "success"
result.NormalizedEntities = []assets.NormalizedEntity{
{EntityType: "asset", AssetType: assets.AssetSubdomain,
Value: "mock.example.com", SourcePlugin: p.Name(), Confidence: 1.0},
}
result.FinishedAt = time.Now()
return result, nil
}
// ── real implementation ──────────────────────────────────
domains := extractDomains(input.Scope)
cmd := exec.CommandContext(ctx, "myscanner", "--json", strings.Join(domains, ","))
out, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
result.Status = "timeout"
} else {
result.Status = "failed"
result.Error = err.Error()
}
result.FinishedAt = time.Now()
return result, nil
}
result.NormalizedEntities = parseOutput(out, p.Name())
result.Status = "success"
result.FinishedAt = time.Now()
return result, nil
}
func extractDomains(scope []ScopeItem) []string {
var out []string
for _, s := range scope {
if s.Type == "domain" || s.Type == "subdomain" {
out = append(out, s.Value)
}
}
return out
}
Step 2 — register in both binaries
In cmd/api/main.go and cmd/worker/main.go, add inside registerPlugins():
registry.Register(&plugins.MyPlugin{}, plugins.PluginConfig{
Plugin: "myplugin",
Enabled: true,
ExecutionMode: plugins.ModeCLI,
TimeoutSecs: 300,
RateLimit: 20,
Retry: 2,
Options: opts, // opts already has test_mode set
})
Step 3 — add to scan profiles
In backend/configs/config.yaml, add the plugin ID to one or more scan_profiles entries:
scan_profiles:
default:
name: Default
description: Balanced EASM baseline scan
plugins:
- subfinder
- dnsx
- httpx
- naabu
- nuclei
- myplugin
Profile validation runs at API and worker startup. A profile that references an unregistered plugin fails startup.
Step 4 — implement test mode
Every plugin must check config.Options["test_mode"].(bool) and return synthetic
data when true. This lets the UI and integration tests work without real binaries.
3. Scanning chains
How the pipeline flows
Scope targets (domains / IPs / ASNs)
│
▼
[recon stage] subfinder, amass
│ → NormalizedEntities (subdomains)
▼
[http_probe stage] httpx
│ → NormalizedEntities (live URLs with technology metadata)
▼
[port_scan stage] nmap
│ → NormalizedEntities (services, open ports)
▼
[vuln_scan stage] nuclei
│ → NormalizedEntities (vulnerabilities)
▼
Worker stores all entities → PostgreSQL
The worker.processResults() function splits NormalizedEntities by EntityType:
- "asset" → assetSvc.Upsert() (deduplicates via ON CONFLICT)
- "vulnerability" → vulnSvc.Create() (looks up the matched asset first)
Plugins do not call each other. The worker calls them sequentially in profile order.
Each plugin gets the same PluginInput (scope items from the scan, not output from previous plugins).
To build a chain where Plugin B uses Plugin A's output, you have two options:
Option A — sequential profile with shared DB (recommended)
Plugin B queries the assets table to read what Plugin A discovered:
// in httpx plugin: read subdomains discovered by subfinder
func (p *HttpxPlugin) Run(ctx context.Context, input PluginInput, config PluginConfig) (*PluginResult, error) {
// input.Scope contains the original scan targets (domains)
// pass them to httpx — httpx will probe them and report live URLs
targets := extractDomains(input.Scope)
// … run httpx against targets
}
This is the default model. Subfinder finds subdomains → they are upserted to the DB → (in a future scan or in the same scan via DB query) httpx probes them.
Option B — compound plugin
One plugin orchestrates several tools internally:
type ReconChainPlugin struct{}
func (p *ReconChainPlugin) Run(ctx context.Context, input PluginInput, config PluginConfig) (*PluginResult, error) {
// step 1: subfinder
subdomains := runSubfinder(ctx, input.Scope)
// step 2: httpx against discovered subdomains
liveURLs := runHttpx(ctx, subdomains)
// combine into NormalizedEntities
return &PluginResult{NormalizedEntities: append(subdomains, liveURLs...)}, nil
}
Execution modes
| Mode | When to use |
|---|---|
ModeCLI |
Binary is on $PATH; plugin uses exec.CommandContext |
ModeAPI |
Tool exposes an HTTP API; plugin makes HTTP calls |
ModeDocker |
Plugin pulls and runs a Docker image |
4. Custom scan profiles
Define custom profiles in backend/configs/config.yaml:
scan_profile_order:
- quick
- default
- custom
scan_profiles:
custom:
name: Custom
description: Custom recon and validation chain
plugins:
- subfinder
- dnsx
- httpx
- myplugin
- nuclei
Profiles control:
- Which plugins run and in what order
- The implied timeout and rate limits from each plugin's PluginConfig
- Scope of asset discovery vs. vulnerability detection
Profile IDs should stay stable because scan records store the profile ID. After editing profile configuration, restart API and worker.
Typical sequence for attack surface management:
quick: recon -> resolver -> http_probe
default: recon -> resolver -> http_probe -> port_scan -> vuln_scan
deep: recon -> resolver -> port_scan -> http_probe -> tls/crawler -> vuln_scan
stealth: low-noise recon -> http_probe
5. Agent integration
Agents are decision modules that run after a scan completes and can trigger further actions.
Agent interface
// backend/internal/agents/agent.go
type AgentContext struct {
ScanID string `json:"scan_id"`
OrganizationID string `json:"organization_id"`
Profile string `json:"profile"`
Scope []string `json:"scope"`
Assets []AgentAsset `json:"assets"`
Vulnerabilities []AgentVuln `json:"vulnerabilities"`
Edges []AgentEdge `json:"edges"`
}
type AgentResponse struct {
AgentType string `json:"agent_type"`
Actions []AgentAction `json:"actions"`
RiskUpdates []RiskUpdate `json:"risk_updates"`
Notes string `json:"notes"`
}
type AgentAction struct {
Type string `json:"type"` // run_scan | flag_asset | update_status
Plugin string `json:"plugin,omitempty"`
Target string `json:"target,omitempty"`
AssetID string `json:"asset_id,omitempty"`
Priority string `json:"priority,omitempty"`
Note string `json:"note,omitempty"`
}
type Agent interface {
Name() string
Analyze(ctx context.Context, ac AgentContext) (*AgentResponse, error)
}
Built-in rule-based agent
Implement Analyze() with pure IF-THEN logic:
type ReconAgent struct{}
func (a *ReconAgent) Name() string { return "recon" }
func (a *ReconAgent) Analyze(ctx context.Context, ac AgentContext) (*AgentResponse, error) {
resp := &AgentResponse{AgentType: a.Name()}
for _, asset := range ac.Assets {
name := strings.ToLower(asset.Value)
// Rule: sensitive subdomain → raise risk + suggest deep scan
for _, kw := range []string{"admin", "dev", "test", "staging", "internal", "vpn"} {
if strings.Contains(name, kw) {
resp.Actions = append(resp.Actions, AgentAction{
Type: "run_scan",
Plugin: "nuclei",
Target: asset.Value,
Priority: "high",
})
resp.RiskUpdates = append(resp.RiskUpdates, RiskUpdate{
AssetID: asset.ID,
Delta: +1.5,
Reason: "sensitive keyword in subdomain name",
})
break
}
}
}
for _, v := range ac.Vulnerabilities {
// Rule: low-confidence finding → needs_review
if v.Confidence < 0.6 {
resp.Actions = append(resp.Actions, AgentAction{
Type: "update_status",
Note: "low-confidence — manual verification recommended",
AssetID: v.AssetID,
})
}
}
return resp, nil
}
LLM-backed agent (Claude API)
Replace the rule engine with an LLM call. The AgentContext becomes the prompt body; the AgentResponse schema is enforced via tool use / structured output:
import "github.com/anthropics/anthropic-sdk-go"
type LLMAgent struct {
client *anthropic.Client
}
func (a *LLMAgent) Analyze(ctx context.Context, ac AgentContext) (*AgentResponse, error) {
contextJSON, _ := json.Marshal(ac)
prompt := fmt.Sprintf(`You are a security analyst agent.
Given the scan results below, identify:
1. The most critical attack paths
2. Likely false positives (confidence < 0.6, no HTTP evidence)
3. Additional scan targets worth investigating
Return a JSON object matching the AgentResponse schema exactly.
Context:
%s`, string(contextJSON))
msg, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.F(anthropic.ModelClaudeSonnet4_5),
MaxTokens: anthropic.Int(4096),
Messages: anthropic.F([]anthropic.MessageParam{
anthropic.UserMessageParam{
Role: anthropic.F(anthropic.MessageParamRoleUser),
Content: anthropic.F([]anthropic.ContentBlockParamUnion{
anthropic.TextBlockParam{Text: anthropic.String(prompt)},
}),
},
}),
})
if err != nil {
return nil, err
}
var resp AgentResponse
if err := json.Unmarshal([]byte(msg.Content[0].Text), &resp); err != nil {
return nil, fmt.Errorf("parse LLM response: %w", err)
}
return &resp, nil
}
For reliable structured output, use the tool-use API instead of free-form text:
tools := []anthropic.ToolParam{{
Name: anthropic.String("report_findings"),
Description: anthropic.String("Report agent findings in structured format"),
InputSchema: anthropic.F(anthropic.ToolInputSchemaParam{
Type: anthropic.F(anthropic.ToolInputSchemaTypeObject),
Properties: anthropic.F(map[string]any{
"actions": map[string]any{"type": "array", ...},
"risk_updates": map[string]any{"type": "array", ...},
"notes": map[string]any{"type": "string"},
}),
}),
}}
msg, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
// …
Tools: anthropic.F(tools),
ToolChoice: anthropic.F(anthropic.ToolChoiceParam{Type: anthropic.F("tool"), Name: anthropic.F("report_findings")}),
})
Wiring agents into the scan pipeline
In worker.go, after processScan() completes:
func (w *Worker) processScan(ctx context.Context, msg QueueMessage) {
// … run plugins …
// Run agents after all plugins finish
for _, agent := range w.agents {
ac := w.buildAgentContext(ctx, msg)
resp, err := agent.Analyze(ctx, ac)
if err != nil {
w.log.Error().Err(err).Str("agent", agent.Name()).Msg("agent error")
continue
}
w.applyAgentResponse(ctx, msg, resp)
}
}
func (w *Worker) applyAgentResponse(ctx context.Context, msg QueueMessage, resp *AgentResponse) {
for _, update := range resp.RiskUpdates {
vulnID, _ := uuid.Parse(update.VulnerabilityID)
// fetch current score, add delta, cap at 10
_ = w.vulnSvc.UpdateRiskScore(ctx, vulnID, update.NewScore)
}
for _, action := range resp.Actions {
switch action.Type {
case "run_scan":
// push a new scan message to the queue for the target
w.enqueueSingleTarget(ctx, msg, action)
case "update_status":
// update vulnerability status to needs_review
}
}
// store full run in agent_runs table
}
Safety constraints
Agents must never:
- Reference targets outside the approved scope
- Set status = "fixed" or status = "confirmed" without human review
- Trigger brute-force (hydra, etc.) unless PluginConfig.Options["brute_force"] = true
- Delete or overwrite existing confirmed/fixed vulnerabilities
All agent runs are stored in agent_runs with the full input and output JSON for auditing.
6. Quick reference
Adding a plugin
- Create
backend/internal/plugins/myplugin.go— implementPlugininterface - Register in
registerPlugins()in bothcmd/api/main.goandcmd/worker/main.go - Add to relevant profiles in
scans/model.go - Implement
test_modebranch
Adding a scanning chain
- Define a new
ScanProfileconstant inscans/model.go - List plugins in
backend/configs/config.yamlunderscan_profilesin the order they should run - Optionally create a compound plugin that internally chains tools
Adding an agent
- Implement the
Agentinterface inbackend/internal/agents/ - Inject the agent list into
WorkerviaNewWorker() - Call
agent.Analyze()at the end ofprocessScan() - Store the run in
agent_runsviaauditSvcor direct DB insert
Testing without real binaries
Set EASM_TEST_MODE=true (or TEST_MODE=true in Docker Compose). Every plugin returns mock entities. No external binaries are invoked.