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
- 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
}
- Register it in
backend/internal/plugins/wrappers/defaults.gousing 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,
},
})
-
Set
SupportedAssetTypesto the exact asset types the wrapper can consume. Empty lists reject all manual asset types. -
Set
SupportedExecutionModes. Usemanual_scanonly when the plugin is safe and meaningful to run against one selected asset. API and worker startup fail if a plugin declaresmanual_scanwithout supported asset types. -
If the plugin calls an external CLI, add toolinstaller configuration in
backend/configs/config.yaml. Keep tool versions in configuration, not Go code. -
Add the plugin ID to one or more
scan_profilesentries inbackend/configs/config.yamlwhen the tool should be part of a scan profile. Profile validation requires every referenced plugin to be registered. -
Return normalized assets/vulnerabilities, populate
RawOutputandRawError, usemetadata["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:
mergeupdates only provided scalar/object/set paths;replace_setreplaces one producer-declared complete set path;removeexplicitly 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_templatesruns only Nuclei templates fromhxresearch/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.RawOutputandPluginResult.RawErrorwith stdout/stderr from CLI tools, including failure cases. - For CLI command failures, return a
PluginResultwithStatus = "failed"orStatus = "timeout", setresult.Error, and return a non-nil Go error. The worker preserves the returned result output while marking the scan job failed. - Treat the passed
ctxas authoritative. User cancellation and worker shutdown cancel this context and should result inStatus = "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 conflatecontext.Canceledwith 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"]plusproduct,version, andservicefields 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.