Skip to content

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:

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

  1. Create backend/internal/plugins/myplugin.go — implement Plugin interface
  2. Register in registerPlugins() in both cmd/api/main.go and cmd/worker/main.go
  3. Add to relevant profiles in scans/model.go
  4. Implement test_mode branch

Adding a scanning chain

  1. Define a new ScanProfile constant in scans/model.go
  2. List plugins in backend/configs/config.yaml under scan_profiles in the order they should run
  3. Optionally create a compound plugin that internally chains tools

Adding an agent

  1. Implement the Agent interface in backend/internal/agents/
  2. Inject the agent list into Worker via NewWorker()
  3. Call agent.Analyze() at the end of processScan()
  4. Store the run in agent_runs via auditSvc or 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.