Skip to content

Plugin Development Guide

Source layout

backend/internal/plugins contains the stable plugin infrastructure: the Plugin interface, input/result models, registry, shared command helpers, and artifact/result contracts.

backend/internal/plugins/wrappers contains concrete tool integrations. Each wrapper converts one external tool into normalized assets, vulnerabilities, metadata, and optional artifacts. Contributors should add new CLI/API tool integrations here, one file per tool when practical.

Plugin interface

type Plugin interface {
    Name() string        
    Type() PluginType    
    Version() string     
    Run(ctx context.Context, input PluginInput, config PluginConfig) (*PluginResult, error)
}

Input / output schemas

PluginInput

{
  "organization_id": "uuid",
  "scan_id": "uuid",
  "scope": [
    { "type": "domain", "value": "example.com" }
  ],
  "profile": "default",
  "options": {
    "rate_limit": 50,
    "timeout_seconds": 300,
    "active_scan": true,
    "test_mode": false
  }
}

timeout_seconds is retained in the plugin configuration model for compatibility and for future explicit opt-in timeout policies. It is not the normal whole-process execution deadline for healthy long-running CLI scanners. The worker-provided context.Context is the authoritative cancellation signal.

PluginResult

{
  "plugin": "subfinder",
  "status": "success",
  "started_at": "2026-04-29T10:00:00Z",
  "finished_at": "2026-04-29T10:02:30Z",
  "normalized_entities": [
    {
      "entity_type": "asset",
      "asset_type": "subdomain",
      "value": "api.example.com",
      "source_plugin": "subfinder",
      "confidence": 0.95,
      "metadata": {}
    }
  ]
}

entity_type is either "asset" or "vulnerability".

Plugin artifacts

Plugins that produce files return PluginResult.Artifacts. The worker owns upload to S3/MinIO through the Files service; plugin code should only create temporary files and describe them.

type PluginArtifact struct {
    Path        string
    Name        string
    Type        string // screenshot, raw_output, report, evidence, artifact, other
    ContentType string
    Metadata    map[string]interface{}
    AssetType   string
    AssetValue  string
    CleanupPath string
}

Use artifacts for screenshots, raw scan artifacts, exported evidence, proof files, or archived plugin outputs. Do not store binary content in PostgreSQL.

For vulnerabilities, metadata must contain:

{
  "template_id": "CVE-…",
  "title": "…",
  "severity": "high",
  "description": "…",
  "remediation": "…",
  "matched_url": "https://…"
}

Creating a new plugin

  1. Create backend/internal/plugins/wrappers/myplugin.go:
package wrappers

import (
    "context"
    "time"

    "github.com/easm-platform/backend/internal/assets"
    "github.com/easm-platform/backend/internal/plugins"
)

type MyPlugin struct{}

func (p *MyPlugin) Name() string            { return "myplugin" }
func (p *MyPlugin) Type() plugins.PluginType { return plugins.TypeRecon }
func (p *MyPlugin) Version() string         { return "1.0.0" }

func (p *MyPlugin) Run(ctx context.Context, input plugins.PluginInput, config plugins.PluginConfig) (*plugins.PluginResult, error) {
    result := &plugins.PluginResult{Plugin: p.Name(), StartedAt: time.Now()}

    if plugins.IsTestMode(config) {
        result.NormalizedEntities = []assets.NormalizedEntity{ /* mock */ }
        return plugins.FinishTestResult(result, p.Name(), len(result.NormalizedEntities)), nil
    }

    // Real implementation: plugins.RunCommand(ctx, "mybinary", args, stdin)
    // or exec.CommandContext(ctx, "mybinary", args...) with the provided ctx.
    // Preserve stdout/stderr in RawOutput/RawError and parse normalized entities.

    result.Status = "success"
    result.FinishedAt = time.Now()
    return result, nil
}
  1. Register it in backend/internal/plugins/wrappers/defaults.go using the existing default registration pattern:
register(&MyPlugin{}, plugins.PluginConfig{
    Enabled: true,
    ExecutionMode: plugins.ModeCLI,
    Retry: 2,
    SupportedAssetTypes: []string{"webapp"},
    SupportedExecutionModes: []plugins.SupportedExecutionMode{
        plugins.ExecutionProfileScan,
        plugins.ExecutionManualScan,
        plugins.ExecutionRetry,
    },
})
  1. Set SupportedAssetTypes to the exact asset types the wrapper can consume. Empty lists reject all manual asset types.

  2. Set SupportedExecutionModes. Use manual_scan only when the plugin is safe and meaningful to run against one selected asset. API and worker startup fail if a plugin declares manual_scan without supported asset types.

  3. If the plugin calls an external CLI, add toolinstaller configuration in backend/configs/config.yaml. Keep tool versions in configuration, not Go code.

  4. Add the plugin ID to one or more scan_profiles entries in backend/configs/config.yaml when the tool should be part of a scan profile. Profile validation requires every referenced plugin to be registered.

  5. Return normalized assets/vulnerabilities, populate RawOutput and RawError, use metadata["technologies"] only for technology details, and add focused tests.

If the plugin is enabled and includes plugins.ExecutionManualScan, it automatically appears in GET /api/v1/plugins/manual-capabilities and in the frontend Run Plugin menu. No frontend code change is required.

Observed Asset State

Scanner-owned technical state belongs under assets.metadata.asset_info. Plugins should emit observed-state patches when they can provide reliable normalized data.

Observed-state patches are partial:

  • merge updates only provided scalar/object/set paths;
  • replace_set replaces one producer-declared complete set path;
  • remove explicitly removes a path or set element;
  • omitted fields are never interpreted as removal.

Same field path policy is last writer wins. Unrelated asset_info fields written by other plugins are preserved by the asset service's dedicated deep merge.

Do not store operator-owned context such as description, owner, team, environment, or tags in asset_info. Those fields belong to assets.managed_context.

Observed-state patches create backend scan snapshots and field-level scan changes. Plugins should be explicit about merge, replace_set, and remove; omitted fields are not removals.

Canonical writers:

Plugin Canonical observed state
dnsx DNS A/AAAA/CNAME/NS/MX sets on Domain/Subdomain assets; PTR sets on IP assets.
naabu Service state and transport for open ports.
nmap IP OS name/family/version/accuracy when available and Service name/product/version/state/transport.
httpx WebApp HTTP status, server, selected security headers, CSP, safe cookie flags, technologies, favicon hash, and simhash page fuzzy hash.
katana WebApp paths using non-authoritative merge semantics.
tlsx Certificate fingerprint, serial number, subject/issuer fields where emitted, SANs, validity, and observed TLS version on Service assets when IP and port are known.

See Asset Observed State for the canonical schema and patch semantics.

Manual execution metadata

Manual plugin execution is registry-driven. The frontend does not maintain a hardcoded plugin list. It calls GET /api/v1/plugins/manual-capabilities, then filters the returned capabilities by selected asset type.

Use these execution modes:

Mode Meaning
profile_scan Plugin can run as part of a configured scan profile.
manual_scan Plugin can run against one selected asset from the UI/API.
retry Plugin jobs can be retried through normal job retry flow.

Manual target normalization happens before plugin execution. Service assets stored as host:port/protocol are passed to wrappers as host:port for manual scans.

Research Assets and hxresearch

The top-level hxresearch/ directory is the project knowledge repository for proprietary detections, advisories, writeups, PoCs, datasets, and expert templates. Worker containers mount it read-only at /opt/hxeasm/hxresearch.

Current integration:

  • nuclei_custom_templates runs only Nuclei templates from hxresearch/nuclei.
  • It is registered but not included in built-in scan profiles.
  • Plugin authors may add future wrappers that consume hxresearch/datasets, hxresearch/advisories, or other curated research assets.

Plugin authors should keep this separation clear:

  • wrappers implement execution and normalization
  • scan profiles decide when wrappers run
  • hxresearch/ stores research content and detection knowledge

Current and planned Nuclei modes:

nuclei                  -> standard ProjectDiscovery templates
nuclei_custom_templates -> hxresearch/nuclei templates only
nuclei_hybrid           -> future ProjectDiscovery + hxresearch mode

See hxresearch.md for the canonical research repository model.

Plugin types

Type constant Purpose
TypeRecon Subdomain / domain discovery
TypeResolver DNS resolution
TypeHTTPProbe Live HTTP service detection
TypePortScan Port/service scanning
TypeCrawler Web app crawling
TypeVulnScan Vulnerability detection
TypeTechDetect Technology fingerprinting
TypeLeakCheck Credential/secret exposure
TypePhishingCheck Phishing indicator analysis
TypeCustom Any other purpose

Execution modes

Mode When
ModeCLI Binary is on $PATH; plugin uses exec.Command
ModeAPI Binary exposes HTTP API; plugin makes HTTP calls
ModeDocker Plugin pulls and runs a Docker image

Error handling

  • Always populate PluginResult.RawOutput and PluginResult.RawError with stdout/stderr from CLI tools, including failure cases.
  • For CLI command failures, return a PluginResult with Status = "failed" or Status = "timeout", set result.Error, and return a non-nil Go error. The worker preserves the returned result output while marking the scan job failed.
  • Treat the passed ctx as authoritative. User cancellation and worker shutdown cancel this context and should result in Status = "cancelled" / result.Error = "execution cancelled" when the wrapper observes it.
  • Do not create fixed whole-process wall-clock deadlines around normal scanner execution. Healthy long-running tools such as Nuclei may run for hours. Scanner-native request, host, or page timeouts remain appropriate.
  • Use Status = "timeout" only for an explicit timeout policy or a scanner-native timeout that should be represented as a job timeout. Do not conflate context.Canceled with timeout.
  • Avoid parser retry loops that execute the same external command more than once unless explicit retry logic exists at the scan/job level.

Expanded EASM plugin catalog

Plugin Input assets Output assets Created relations Mode Notes
subfinder domain subdomain domain contains subdomain passive Existing plugin.
amass domain subdomain domain contains subdomain passive/active Runs amass enum -d <domain> and parses the default text stdout format for compatibility with Amass versions that do not support -json.
alterx domain, subdomain candidate subdomain parent generated_candidate candidate passive Experimental; disabled by default because candidates require confirmation.
resolver domain, subdomain ip domain resolves_to ip passive Existing Go resolver plugin.
dnsx domain, subdomain, ip ip domain resolves_to ip; IP PTR enrichment passive ProjectDiscovery DNS resolver.
shuffledns domain, subdomain ip, subdomain domain resolves_to ip passive Experimental; disabled by default because resolver/wordlist configuration is required.
asnmap asn, org_name scope input scan output only no canonical Asset edge passive Experimental; disabled by default. ASN/CIDR remain scope concepts and are not emitted as Assets.
httpx domain, subdomain, ip, service webapp service serves webapp; degraded host serves webapp only when no service can be identified active-light Emits canonical WebApp origins plus host, ip, port, scheme where available; detected technologies are stored in WebApp metadata.web.technologies.
httpx_screenshot webapp, service, ip screenshot files file metadata linked to scan/job/asset active-light Stores screenshots through Files/S3 artifact upload; requires Chromium in the worker image and uses httpx -system-chrome.
naabu ip, cidr, domain, subdomain service ip exposes service active Fast port discovery.
nmap ip, cidr, domain, subdomain ip, service ip exposes service active Existing plugin.
nmap_vulns service, ip, domain, subdomain vulnerability asset vulnerability storage active, profile-disabled NSE vulnerability checks against known services; registered for custom profiles and manual execution, not built-in profiles.
tlsx webapp, domain, subdomain, service, ip certificate WebApp has_certificate certificate when known; service fallback otherwise active-light Certificate inventory keyed by SHA-256 fingerprint. Subject, issuer, SAN, serial, validity, host, IP, port, and scheme are metadata.
katana webapp webapp metadata related WebApp edge only for distinct WebApp discoveries active Web crawler; paths and parameters are stored in WebApp metadata, not Assets.
vhost_brute webapp subdomain normal parent Domain relation from generic Subdomain processing active-light Native Go VHost discovery. Uses the current runtime PluginInput.Scope, bundled SecLists prefix wordlist, candidate HTTP Host/SNI, and baseline response comparison. Not included in built-in profiles by default.
nuclei webapp, domain, subdomain, ip vulnerability asset vulnerability storage active Standard Nuclei templates.
nuclei_custom_templates webapp, domain, subdomain, ip, service vulnerability asset vulnerability storage active, profile-disabled Uses only hxresearch/nuclei; not included in built-in profiles.
uncover domain, subdomain, ip service ip exposes service passive/API Experimental; disabled by default because provider API keys are usually required.

Canonical Asset types are organization, domain, subdomain, ip, service, webapp, and certificate. Manual plugins should declare only operational Asset types they can consume: domain, subdomain, ip, service, webapp, or certificate. Scopes remain separate and may still include scope-specific types such as cidr, ip_range, asn, and org_name; HTTP(S) scope input uses webapp.

Profile-scan scope input is normalized before wrappers run. Approved IPv4 ip_range Scope values are expanded at scan execution time into individual ip scope items with a 65,536-address limit per range and per scan. Plugins should consume the ip scope type they already support; they should not add plugin-specific range parsers. CIDR input is not expanded by this mechanism and remains a cidr scope item for wrappers that support it.

Canonical graph order is organization -> domain -> subdomain -> ip -> service -> webapp -> certificate. Supported current graph relations include: owns, contains, resolves_to, exposes, serves, has_certificate, generated_candidate, and related_to. has_path, has_parameter, uses_technology, announces, and discovered_url are deprecated; new plugins must not create them.

Tool versions and scan profile composition are configured in backend/configs/config.yaml; plugin code does not hardcode tool release versions or profile membership. Experimental tools are registered but disabled by default in normal profiles and tool installer config.

Canonical graph relation rules

Wrappers should emit enough metadata for the worker to build this chain:

organization -> domain -> subdomain -> ip -> service -> webapp -> certificate

Required metadata by output type:

Output asset Required metadata Why
subdomain parent_domain Creates domain contains subdomain.
ip domain or host, record_type when available Creates host resolves_to IP.
service ip, port, protocol Creates IP exposes service. Service values normalize to ip:port/protocol.
webapp host, ip, port, scheme when available; paths/parameters under metadata.web Creates service serves WebApp. Direct host serves WebApp is only a fallback when service data is missing.
certificate SHA-256 fingerprint identity; host, ip, port, subject, issuer, SAN, and validity as metadata Creates has_certificate, preferring WebApp when known and service as fallback.
WebApp paths/parameters metadata.web.paths, metadata.web.parameters, parent_url Stored as WebApp metadata, not separate Asset nodes.

Do not create direct IP/subdomain/domain-to-WebApp edges when a service can be found or created. Do not create path, parameter, technology, ASN, CIDR, IP range, or port Assets.

Technology metadata model

Do not emit AssetTechnology normalized entities or create uses_technology edges. Detected technologies belong in metadata on the asset they describe:

  • WebApp assets: metadata.technologies = ["nginx", "React"]
  • Service assets: metadata.technologies = ["OpenSSH 8.9"] plus product, version, and service fields where available

The canonical key is metadata.technologies; legacy keys tech and technology are normalized away at asset upsert. This reduces graph noise and keeps technology details close to the reachable asset.

Safe plugin options

Plugins can declare typed option metadata in PluginConfig.OptionSchema. The MVP schema supports string, select, number, boolean, string_list, and header_list. Use AllowedValues for dropdown/select choices and Min/Max for numeric bounds.

Options are validated by the backend before being saved to plugin_settings. The worker injects validated values into PluginInput.Settings. Existing wrappers can ignore this field.

Wrappers must not concatenate user-controlled settings into shell command strings. If a CLI needs an option such as an HTTP header, pass it as separate arguments to exec.CommandContext, for example "-H", "X-Test: demo".

Exposing custom headers in the Settings UI

To make a plugin configurable from Settings -> Scans -> Plugins, register an option schema in backend/internal/plugins/wrappers/defaults.go.

Example:

register(&MyPlugin{}, plugins.PluginConfig{
    Enabled: true,
    ExecutionMode: plugins.ModeCLI,
    SupportedAssetTypes: []string{"webapp"},
    SupportedExecutionModes: []plugins.SupportedExecutionMode{
        plugins.ExecutionManualScan,
        plugins.ExecutionRetry,
    },
    OptionSchema: []plugins.PluginOptionSchema{
        {
            Key:         "custom_headers",
            Label:       "Custom HTTP headers",
            Type:        plugins.OptionHeaderList,
            Description: "Additional non-secret HTTP headers passed to the scanner.",
        },
    },
})

The frontend renders OptionHeaderList as a table with Header Name and Header Value columns. Admins can add rows and save them from the plugin details drawer or from the selected stage in the Scan Profile builder. Required options are shown in the profile builder with a required options badge.

In the wrapper, read headers from structured settings and append them as separate exec arguments:

headers, err := plugins.HeaderListFromSettings(input.Settings, "custom_headers")
if err != nil {
    result.Error = err.Error()
    return plugins.FinishResult(result, "failed"), nil
}
for _, header := range headers {
    args = append(args, "-H", fmt.Sprintf("%s: %s", header.Name, header.Value))
}

Never concatenate settings into a shell command. Use exec.CommandContext(ctx, binary, args...) or the shared command helpers with an argument slice. Pass the worker-provided context through unchanged so Cancel Scan and worker shutdown can terminate the subprocess.

For select and bounded number options, declare the allowed values directly in the schema:

minRetries, maxRetries := 0.0, 5.0
register(&MyPlugin{}, plugins.PluginConfig{
    Enabled: true,
    ExecutionMode: plugins.ModeCLI,
    SupportedAssetTypes: []string{"service"},
    SupportedExecutionModes: []plugins.SupportedExecutionMode{plugins.ExecutionProfileScan},
    OptionSchema: []plugins.PluginOptionSchema{
        {
            Key:           "timing",
            Label:         "Timing",
            Type:          plugins.OptionSelect,
            Default:       "T3",
            AllowedValues: []string{"T2", "T3", "T4"},
            Description:   "Nmap timing template.",
        },
        {
            Key:         "max_retries",
            Label:       "Max retries",
            Type:        plugins.OptionNumber,
            Default:     1,
            Min:         &minRetries,
            Max:         &maxRetries,
            Description: "Retry bound passed as a structured CLI argument.",
        },
    },
})

The frontend renders select options as dropdowns and bounded numbers as numeric inputs. The backend rejects values outside the declared allowlist or numeric range before saving them.

header_list rejects unsafe header names and CR/LF in values. Do not use plugin settings for secrets until server-side secret storage exists.