Connector framework for the OASM platform. A connector packages one external security tool behind a uniform contract: a declarative manifest.yaml describing how it is discovered and scheduled, and a Go binary that adapts the tool's output into the platform's canonical Finding stream.
The same skeleton serves every kind of tool β in-process library embedding, CLI wrapper, or remote API client β because nothing outside the adapter knows which one it is. This document describes the framework itself: architecture, contracts, and the steps to add a connector. It intentionally names no specific tool.
flowchart LR
A["<category>/<connector>/manifest.yaml<br/>+ optional logo.png"]
B["scripts/combine-manifest<br/>discover Β· validate Β· downscale logo Β· base64 Β· sort"]
C["manifest.json<br/>{generatedAt, connectors[]}"]
A -- "walk repo" --> B --> C
C -- "read at startup" --> D["Core / catalog"]
manifest.json is a generated artifact. It is the only thing the platform catalog consumes; the per-connector manifest.yaml files are the source of truth.
flowchart TB
subgraph PLT["Platform"]
CORE["Core<br/>schedules executions"]
WK["Worker<br/>gRPC server + container supervisor"]
DR["DockerRuntime"]
CORE -- "ExecutionCommand{image, inputs, config}" --> WK
WK -- "pull image" --> DR
DR -- "start with injected env" --> CONT
end
subgraph CONT["Connector container (no inbound ports)"]
SDK["sdk/runtime<br/>dial Β· register Β· execute loop"]
ADP["Adapter<br/>Validate / Execute"]
SDK --> ADP
end
WK -. "gRPC bidi stream<br/>WORKER_GRPC_ADDR + token" .-> SDK
ADP -. "out chan Finding" .-> SDK
SDK -. "Result / Done" .-> WK
WK -. "persist findings" .-> CORE
sequenceDiagram
participant C as Connector (SDK runtime)
participant W as Worker
C->>W: Connect() stream
C->>W: Register{token, execution_id, job_id, tool}
W->>C: RegisterAck{accepted, reason}
Note over C,W: rejected β connector exits with a fatal error
W->>C: ExecuteJob{execution_id, job_id, tool, image, trace_id, inputs, config}
loop while the adapter emits
C->>W: Result{execution_id, findings[]}
end
W-->>C: Cancel{execution_id} (optional, aborts mid-run)
C->>W: Done{execution_id, error}
Note over C,W: stream stays open β the worker may reuse the container for another job
Key properties:
- Outbound only. The connector never listens; it dials the Worker address injected into its environment.
- One execution at a time per stream.
Runtimegates executions so per-job process state (restored config, working directory, tool globals) is never raced. - Container reuse (warm pool). After
Donethe process stays alive for the nextExecuteJob, so adapters must tolerate being executed more than once in one process. - Cancellation is a protocol message.
Cancelaborts the running execution's context; the adapter is expected to return promptly, and the SDK bounds the wait. - No silent drops. Every
Findingis validated before transport. An invalid one stops the stream, cancels the execution, and is reported onDoneas a fatal error. - Panics are contained. A panicking adapter is recovered and reported as a retryable error; the process survives to serve the next job.
A multi-module Go monorepo. The root module holds only the manifest tooling; each connector is an independent module that consumes the SDK through a local replace directive.
.
βββ scripts/combine-manifest/ root module β manifest discovery/validation/aggregation
βββ scripts/square-logo/ root module β square, white-background icon helper
βββ sdk/ its own module β the connector SDK (imported by every connector)
β βββ connector/ Adapter interface + Finding type and validation
β βββ runtime/ dial Worker, register, execute loop, stream findings
β βββ env/ Worker-injected configuration and INPUT_* parsing
β βββ transport/ gRPC dialing, plaintext and mTLS credentials
β βββ execution/ per-execution context and stream helpers
β βββ lifecycle/ init β ready β running β shutdown state machine
β βββ logging/ leveled logger
β βββ proto/ connector.proto and the generated stubs in proto/gen
βββ <category>/<connector>/ one module per connector
β βββ manifest.yaml the connector contract (required)
β βββ logo.png optional icon, inlined into manifest.json
β βββ Dockerfile build context is the repo root
β βββ main.go wiring only
β βββ adapter + tests
βββ manifest.json generated β never hand-edit
βββ go.work unifies every module for root-level tooling
βββ Taskfile.yml task runner entry points
Consequences worth respecting:
- A dependency added for one connector lands only in that connector's
go.mod. Never promote a tool-specific dependency into the SDK or the root module. - Module-scoped commands must run inside the module's directory.
go test ./...at the root does not reach the connectors. go.workmakes commands work from the root for convenience, but CI andtask tidydeliberately setGOWORK=offso each module stays independently reproducible.
Written by hand, validated at aggregation time. Unknown fields are rejected, so typos fail the build instead of silently doing nothing.
| Field | Required | Notes |
|---|---|---|
name |
yes | Human display name. Free-form, may contain spaces and uppercase. |
slug |
yes | System-wide unique ID, ^[a-z0-9-]+$. Drives the image tag and container naming. |
version |
yes | Version of the wrapped tool. Used as the default image tag. |
image |
yes | Fully qualified image reference for the build. |
author |
yes | Maintainer. |
pricingTier |
yes | Non-empty list of free / paid (case-normalized). |
shortDescription |
yes | One line, shown in list views. |
description |
yes | Full paragraph for the detail view. |
capabilities |
yes | Ordered list of modes; the first is the default when a workflow step names no category. Each entry is a mapping: name (one of subdomains, http_probe, ports_scanner, vulnerabilities, screenshot, url_discovery, or β for repository scanners β secrets, sca, sast; no duplicates), targetTypes (required: one or more of domain, ip, cidr, git_org, no duplicates β the platform never dispatches an asset of another target type to this mode, whichever workflow runs it), inputs (required: the kinds of data this mode takes, best first β see Inputs and outputs), produces (required: the kinds its findings add, [] when they feed no other step), inputsSchema (optional JSON Schema for this mode's per-run inputs, validated upstream by Core/Worker). Use YAML anchors to share a schema between modes. The first capability also drives directory grouping by category. |
agentTool |
no | Set true to expose the connector as a callable tool to the OASM AI agent. |
homepage |
no | Non-empty string when set. |
repositoryUrl |
no | Non-empty string when set. |
supportUrl |
no | Non-empty string when set. |
configSchema |
no | JSON Schema for the tool profile, shared by every capability. Adapters read it as OASM_CONFIG. |
resourceDefaults |
no | Scheduler hints, e.g. cpu, memory, timeoutSeconds. |
egress |
no | proxy (default) routes the container's traffic through the worker egress proxy; direct skips it, for port scanners whose raw TCP/UDP probes a proxy cannot carry. |
Schemas use plain JSON Schema plus two loosely-conventional annotation keys β title and description for labelling, and ui:placeholder for input hints. Only type, required, properties, items, enum, default, examples and format carry validation weight; annotations are for rendering.
Aggregation rules:
- A
logo.pngsibling is read and downscaled to a 128px long edge if larger (never upscaled or re-encoded when already within budget), then embedded base64 inmanifest.json. An oversizedlogo.pngis rewritten in place, so the repo file is byte-identical to the embedded icon. Logos never appear in YAML. - Duplicate slugs across the repo are a hard error.
manifest.jsonentries are sorted byname, and itsgeneratedAttimestamp makes every regeneration a diff.
The only code every connector must write. Two methods, no lifecycle ceremony beyond constructing it:
type Adapter interface {
// Validate rejects inputs that the tool cannot possibly run, before any
// container or network resource is spent.
Validate(ctx context.Context, inputs map[string]any) error
// Execute runs the tool and streams findings to out. It must honour ctx
// cancellation and must not close out β the runtime owns the channel.
Execute(ctx context.Context, inputs map[string]any, out chan<- Finding) error
}Error convention β the Worker uses this to decide whether to retry:
fatal: β¦β configuration, credentials, or target problems. Retrying will not help.retryable: β¦β transient failures: dial, stream, timeouts, adapter/scanner errors, cancellation.- The SDK applies the prefix to errors it raises itself; adapters are expected to do the same.
The canonical output item. name is required and severity must be one of info, low, medium, high, critical β anything else is a protocol violation, not noise.
| Group | Fields |
|---|---|
| Identity | Name, Severity, Timestamp |
| Content | Description, Solution, Tags, References |
| Scoring | CVEID, CWEID, CVSSScore, CVSSMetrics, EPSSScore |
| Location | MatchedAt, Host, IP |
Timestamp is omitted from the wire when zero rather than shipped as bogus epoch time.
Connectors are never configured by flags or files; everything arrives as environment variables.
| Variable | Required | Purpose |
|---|---|---|
WORKER_GRPC_ADDR |
yes | Worker gRPC endpoint. Missing β fail fast with a fatal error (a connector outside the Worker cannot be routed any work). |
EXECUTION_ID |
yes | Execution identity used to route ExecuteJob. Missing β fail fast, unless the legacy opt-out below is set. |
OASM_ALLOW_LEGACY_NO_EXEC_ID |
no | Set to keep the legacy no-ExecuteJob behavior when EXECUTION_ID is absent. |
WORKER_TOKEN |
no | Registration token; the Worker may reject the Register without it. |
JOB_ID, TOOL, TRACE_ID |
no | Enriched identity, echoed in logs for correlation. |
WORKER_TLS_CA, WORKER_TLS_CERT, WORKER_TLS_KEY |
no | mTLS for the Worker dial. Applied only when all three are set; otherwise the dial is plaintext. |
INPUT_* |
no | Per-run inputs. INPUT_TARGET becomes inputs["target"]; keys are lowercased and the prefix stripped. |
OASM_CONFIG |
no | The job's config profile as JSON. A container reused from the warm pool gets its profile replaced around each ExecuteJob, so adapters must read it at execution time, not at startup. |
LOG_LEVEL / OASM_LOG_LEVEL |
no | Set to debug for the verbose trace (per-result lines, emitted-finding previews, dial details). INFO and above are always on. |
OASM_LOG_COLOR |
no | always forces ANSI colour, never strips it. Unset means "colour only when stderr is a terminal" β which is off in a container, so the Worker's captured logs stay plain text. NO_COLOR (any value) also forces colour off. |
Input precedence: Worker-provided per-job inputs override INPUT_* environment defaults.
Everything the SDK prints goes to stderr, which the Worker tails from the container and keeps as the job's log. For a connector written on this SDK the SDK itself emits the trace:
connector starting/worker configβ process identity, dial address, whether a token/TLS is configured.execute start+effective inputsβ the ExecuteJob identity, plus the inputs and config the adapter actually sees, truncated and with credential-shaped keys redacted (token=<redacted>). Secrets never reach these lines because the platform persists them.first findingand per-resultstreamed result N(debug) β proof the tool produced parseable output, which is what separates "scanned and found nothing" from "output failed to parse".adapter panic: β¦with a stack,adapter error after N result(s) in <duration>,execution done: results=N elapsed=<duration> error=<none|β¦>.
Lines are coloured by level β DEBUG dim, INFO cyan, SUCCESS green, WARN yellow, ERROR red β with the [level] label always present so the text stays greppable when colour is off. SUCCESS is the terminal good-outcome line (registered with worker, execution done: results=N); a run that ends carrying an error logs the same line at WARN. Print a sample of the format (real ANSI, no connector build needed):
cd sdk && OASM_LOG_COLOR=always LOG_LEVEL=debug go test ./logging/ -run TestPrintSample -vEvery line carries trace_id plus execution_id, job_id, tool and image fields, so one job's lines can be grepped out of a warm-pool container that serves many jobs in a single log stream.
- Create the module directory as
<category>/<slug>. The category is free-form and should match the connector'scapabilitiesentry; it becomes the grouping in the catalog. - Create the Go module. Its own
go.mod, module path<repo module path>/<category>/<slug>, requiring the SDK atv0.0.0withreplace <sdk module path> => ../../sdk. Rungo mod tidywithGOWORK=off. - Write
manifest.yamlagainst the contract above, plus an optionallogo.png. Declare each capability'stargetTypesdeliberately: it is what keeps a host scanner away from repositories (and a code scanner away from hosts). Declareinputsas what the adapter really accepts intarget: a connector that listswebmust handle a URL with a port. - Write a multi-stage
Dockerfile. Build context is the repo root, so copysdk/and the connector directory explicitly. Drop bothgo.mod/go.sumfiles first and rungo mod downloadbefore copying sources so dependency layers cache. Build statically (CGO_ENABLED=0), land a single binary on a minimal base image, create an unprivileged user, andUSERit. The image must not expose ports or require a shell. - Implement the adapter.
Validateshould reject malformed inputs early and cheaply.Executeshould stream findings as they are produced rather than buffering the whole run, return onctx.Done(), and never closeout. Keep tool-specific code in this package and nowhere else. - Wire
main.goβ construct the adapter, wrap it, run it, and treat a cancelled context as a clean exit rather than a failure. - Add tests. Adapter tests must run without Docker or network: a fake tool binary or an injected client covers parsing, severity mapping, error mapping, and cancellation. Unit-test the adapter, not the SDK.
- Regenerate and verify β
task manifest && task test. - Publish β via the workflow's manual dispatch (see CI below), which derives the matrix from every
<category>/<slug>/manifest.yaml.
Design notes for the adapter:
- Prefer in-process embedding over a CLI wrapper where the tool offers a library: no subprocess lifecycle, no output-format drift, cancellation propagates naturally. Wrap a CLI only when no usable library exists, and then guard the contract with a fake binary in
testdata/. - Remote-API tools ship no bundled binary at all β the adapter is the client, and credentials belong in
configSchema, never in code or the image. - Map severities explicitly. Collapse the tool's own scale onto the five-value enum with a table, not with string guessing.
- Never leak secrets into findings or logs. Findings are persisted and displayed; config values are not.
A connector may declare several capabilities, each with its own targetTypes and inputsSchema:
capabilities:
- name: ports_scanner
targetTypes: [domain, ip, cidr]
inputs: [host]
produces: [service]
inputsSchema: &target
type: object
required: [target]
properties:
target: { type: string, title: Target host }
- name: vulnerabilities
targetTypes: [domain, ip]
inputs: [host]
produces: [vulnerability]
inputsSchema: *targetA workflow step picks one with category:. The adapter reads the category it was asked to run with connector.Category(ctx): the platform category (secrets, sca and sast all arrive as vulnerabilities, so a connector may declare at most one capability per platform category), or empty for the connector's first capability.
Workers older than the release that introduced ExecuteJob.category never send it, so the adapter falls back to its first capability while the platform still syncs the result as the requested category. Do not ship a connector with more than one capability until every worker runs a release that sends the category.
A workflow chains tools: a port scanner finds services, an HTTP probe finds web
services among them, a web scanner scans those. inputs and produces tell the
platform how a connector fits in that chain.
inputs is a preference list. For every asset of a run, the platform takes the
first kind that has something for it and runs the connector once per item. With
inputs: [web, host], a host with web services on 443 and 8443 gets two runs,
on https://host and https://host:8443; a host nothing has probed yet gets one
run on the bare host. A workflow step may narrow or reorder the list with
input:, but never add to it.
| Kind | As input, target holds |
As output |
|---|---|---|
target |
the domain or IP a target was created with | β |
host |
a hostname or IP (app.example.com) |
new hosts (subdomains) |
service |
host:port of an open port |
open ports |
web |
the base URL of a web service, port kept (https://app.example.com:8443) |
services that answer HTTP |
url |
a discovered URL with its path | discovered URLs |
repository |
β (see Repository scanners) | β |
vulnerability |
β | findings |
The connector still receives one target input, as before; only its content
depends on the kind. Choose inputs by what the adapter does with target:
- strips it to a host (gau, katana):
[host], so a host is not crawled once per port; - scans one web application (nikto, zap, dirsearch):
[web, host]; - probes the host itself and also takes a URL (nuclei):
[host, web]; - needs pages that exist (arjun):
[url, web].
A repository scanner takes [repository], and only it may: the validator
rejects repository without git_org, and any other input on a git_org-only
capability.
A connector that scans source code declares a secrets, sca or sast capability with targetTypes: [git_org]. The platform runs it once per repository of a Git organization target and sends flat inputs:
| Input | Meaning |
|---|---|
repository |
https clone URL, e.g. https://github.com/acme/web |
ref |
branch, tag or commit to scan; empty = default branch |
username, token |
credentials for private repositories (optional) |
Use sdk/gitrepo: ParseInputs validates them (https only, no embedded credentials, no option-like refs) and Clone makes a shallow checkout. The token is handed to git as an in-memory http.extraHeader β never in a URL, an argument or the checkout's config β and token inputs are redacted in SDK logs. Always Close the checkout.
Report code findings with the optional Finding fields FilePath, LineStart/LineEnd, Commit, and for dependencies PackageName/InstalledVersion/FixedVersion. Set Fingerprint to the finding's stable identity within the repository (e.g. rule + file + line): the platform deduplicates on it, so two leaked keys of the same rule stay two findings. Never put the secret value itself in a finding β name the rule and the location.
Prerequisites: Go 1.26+, Task 3.x, and Docker 20.10+ for building or running images (buf/protoc only when regenerating protobuf stubs).
task test # go test per module in Taskfile MODULES
task vet # go vet per module
task fmt # gofmt per module
task tidy # go mod tidy per module with GOWORK=off
task manifest # regenerate manifest.json
task proto # protobuf generation β currently a stub; stubs are committed under sdk/proto/genEvery task is a thin wrapper over go; run the equivalent command directly when working inside a single module.
Tests need no Docker, no network, and no credentials, and are the primary gate: CI runs the same go test per module with GOWORK=off, scoped to the modules a change touched. A connector is not mergeable until its adapter's parsing, error mapping, and cancellation paths are covered.
CI is scoped to what a change actually touches. Both entry points derive their matrix from one script, .github/scripts/detect-changed-connectors.sh, which diffs two revisions and answers two questions: which connector images must be rebuilt, and which Go modules must be tested. Editing vulnerabilities/nuclei/adapter.go costs one build and one test run; the other nine connectors are not touched, and their tags in GHCR keep pointing at the last good image.
A changed path is classified like this:
| Path | Effect |
|---|---|
<category>/<slug>/** |
rebuild and test that connector |
a build-context input of some connector (sdk/** today) |
rebuild and test those connectors |
.dockerignore |
rebuild everything β it filters every build context |
root module files (go.mod, go.work, scripts/**, manifest.json, Taskfile.yml) |
no image; the root module's tests are the gate |
docs, .trivyignore, CI config |
nothing at all |
The build-context inputs are read from each Dockerfile's COPY sources rather than hardcoded, so adding a shared input to the images is picked up without touching the script. If the baseline is unusable β first push, force-push, an unreachable or unresolvable base β the script selects the whole catalogue rather than guessing.
- On push to main (
build-changed-connectors.yml) β the changed connectors are built, scanned, gated, and published asconnector-<slug>:<version>plus:latest. - On pull request (
build-connectors.yml) β the same matrix, withpush: falseand the vulnerability gate reporting only. Nothing is published. - On manual dispatch (
build-connectors.yml) β the whole catalogue, regardless of the diff. Adry_runinput builds and tests without publishing, and animage_taginput overrides the tag. - Registry credentials come from the CI token; local builds never require them.
Because publication is tag-driven, the version field in manifest.yaml is meaningful: bumping it is what produces a new immutable image tag. The detector reads the tag from the checked-in manifest, so the bump and the publish cannot drift apart.
The detector is load-bearing β a wrong answer either skips a publish or rebuilds everything β so it ships with a test suite that runs on every CI invocation:
task ci-test # unit-test the detector
task ci-scope # what the last commit would rebuild- The SDK is the only shared code. Anything tool-specific that leaks upward makes every connector heavier and the contract fuzzier.
manifest.jsonis derived. Regenerate it; never edit or patch it by hand.- The contract is validated, not trusted. Unknown manifest fields, duplicate slugs, unknown, empty or duplicate capabilities, unknown capability fields, capabilities without target types, unknown target types, and invalid pricing tiers all fail the build.
- The container is the boundary. Connectors must run unprivileged with no inbound network surface, and must tolerate warm-pool reuse.
Apache-2.0, with attribution notices in NOTICE.