Open-source security scanning sensor for the OpenCTEM Continuous Threat Exposure Management (CTEM) platform. Formerly the OpenCTEM Agent: see Upgrading from the agent release.
Product documentation: docs.openctem.io.
The OpenCTEM sensor (openctemio-sensor) runs security tools close to what
they scan and reports the results to the OpenCTEM platform. It is usually run
as a long-lived daemon that the platform dispatches scans to; it can also run
a single scan and exit.
- Tools: nuclei and the recon tools (subfinder, dnsx, naabu, httpx, katana) in the default image; semgrep, trivy and betterleaks in their own images or on the host.
- Modes: daemon (server-controlled), one job (Kubernetes Job), one-shot and standalone. See Modes.
- Safety: an SSRF guard on every target, a tool sandbox, a sensor-local policy the platform cannot change, signed custom templates and rate ceilings. See Scanner safety model.
- CI/CD: CI scanning lives in openctemio/ci
(
openctem-ci, theghcr.io/openctemio/ci-<tool>images, the GitHub Action and reusable workflow, and the GitLab templates). They report with the CI job's OIDC identity (no stored API key).
| Tool | Category | Description |
|---|---|---|
| Nuclei | DAST | Template-based vulnerability scanning |
| Nuclei (validate) | Validation | Non-destructive re-verification of a finding's own template (CTEM Stage 4) |
| Subfinder | Recon | Passive subdomain enumeration |
| DNSx | Recon | DNS resolution and records |
| Naabu | Recon | Port scanning (TCP connect) |
| HTTPx | Recon | HTTP/TLS probing and fingerprinting |
| Katana | Recon | Web crawling |
| Semgrep | SAST | Static code analysis |
| CodeQL | SAST | Code analysis when the codeql CLI is installed on the host (not in the images) |
| Trivy | SCA / IaC / container | trivy (filesystem), trivy-config, trivy-image, trivy-full |
| Betterleaks | Secrets | Secret detection (replaces gitleaks) |
| Tenable.sc | Connector | Pulls hosts and vulnerabilities from a Tenable.sc in your network (docs/TENABLE_SC.md) |
openctemio-sensor -list-tools lists the scanners and whether each binary is
installed on this host; -check-tools shows installation instructions and
-install-tools installs missing ones interactively.
Images are published to ghcr.io/openctemio/sensor (mirrored to Docker Hub
as openctemio/sensor) as <version>-<variant> and latest-<variant>. The
default variant is also the plain tag: sensor:<version> and
sensor:latest.
| Variant | Tools | Default command |
|---|---|---|
default |
nuclei, subfinder, dnsx, naabu, httpx, katana | -daemon -enable-commands -verbose (server-controlled sensor; runs and reports every installed tool, SENSOR_TOOLS optionally narrows them) |
semgrep, trivy, betterleaks, nuclei |
that tool | -tool <tool> --help (pass a command line) |
Every image is smoke-tested before it is published
(scripts/image-smoke-test.sh): each bundled tool must run and
openctemio-sensor -list-tools must report it available.
Archives for Linux, macOS and Windows are attached to each
GitHub release as
openctemio-sensor_<version>_<os>_<arch>.tar.gz (and .zip), where
<version> is the tag without the leading v:
VERSION=0.11.0 # a release tag without the leading v
curl -sSLO https://github.com/openctemio/sensor/releases/download/v${VERSION}/openctemio-sensor_${VERSION}_linux_amd64.tar.gz
tar xzf openctemio-sensor_${VERSION}_linux_amd64.tar.gz openctemio-sensor
sudo install -m 0755 openctemio-sensor /usr/local/bin/
openctemio-sensor -versionThe archive holds only the sensor; the scanners it runs must be installed on
the host (openctemio-sensor -check-tools).
git clone https://github.com/openctemio/sensor.git
cd sensor
make build # or: go build -o openctemio-sensor .Release images are signed by digest with cosign
keyless signing: the signature's certificate names this repository's
docker-publish.yml workflow at the release tag, issued from GitHub's OIDC
token. No long-lived signing key exists. Check an image before you run it:
cosign verify ghcr.io/openctemio/sensor:v0.11.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/openctemio/sensor/.github/workflows/docker-publish.yml@refs/tags/v0.11.0Release archives: checksums.txt is signed the same way by release.yml
(checksums.txt.sigstore.json):
cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/openctemio/sensor/.github/workflows/release.yml@refs/tags/v0.11.0
sha256sum -c checksums.txt --ignore-missingImages and archives published before signing was added carry no signature.
The default image runs the server-controlled daemon. It connects to the platform, reports every scanner installed in the image (with versions) on its heartbeat, and runs the scans the platform dispatches to it:
docker run -d --name openctem-sensor --restart unless-stopped \
-e API_URL=https://openctem.example.com \
-v openctem-outbox:/var/lib/openctem/outbox \
-v openctem-state:/var/lib/openctem/state \
-v openctem-content:/var/lib/openctem/content \
ghcr.io/openctemio/sensor:latestThe same with Docker Compose:
services:
sensor:
image: ghcr.io/openctemio/sensor:latest
restart: unless-stopped
environment:
API_URL: https://openctem.example.com
# SENSOR_CA_FINGERPRINT: <from the install snippet>
# SENSOR_ALLOW_PRIVATE_TARGETS: "1" # only to scan RFC 1918 / ULA addresses
volumes:
- outbox:/var/lib/openctem/outbox # results not yet accepted by the platform
- state:/var/lib/openctem/state # the paired identity and key (keep it)
- content:/var/lib/openctem/content # scanner content cache (disposable)
volumes:
outbox:
state:
content:- Keep the outbox and state volumes. The sensor writes every result to the outbox before sending it and deletes it only once the platform accepted it; without a volume, results still queued when the container is recreated are lost. The state volume holds the sensor's identity: losing it means pairing again. Give each sensor its own volumes; a second sensor on the same outbox refuses to start.
openctem-contentcaches scanner content (trivy DB, nuclei templates, semgrep rules). It can be deleted and is downloaded again.- Without
API_URLthe daemon exits with code 2 and names the missing setting. WithoutAPI_KEYit pairs (below). - The sensor detects its scanners at start-up and reports them on every
heartbeat; the platform dispatches a scan only to sensors that report its
tool installed.
SENSOR_TOOLS(or-tools) is an optional allowlist: only those scanners run and are reported. - Code scanners (betterleaks, semgrep, trivy fs) get a repository asset's
name, resolved inside
SENSOR_SCAN_ROOTS(default: the working directory,/scanin the default image). Mount repositories there. - A dispatched scan starts within one heartbeat (see Heartbeat doorbell).
A sensor started without API_KEY pairs with the platform on first start
(api RFC-052). It creates its own Ed25519 key, never sends it, and prints:
Pair this sensor in OpenCTEM: Sensors > Pair a sensor
Code: K7QM-4ZTD
Fingerprint: 512 · tiger · violet · anchor (expires 10:42)
An administrator enters the code under Sensors > Pair a sensor, checks
that the console shows the same fingerprint (and the host and source address
it expects), confirms that the fingerprint matches, re-authenticates and
approves. The sensor then signs every request with its key; nothing secret is
typed or pasted. With Docker, read the code with docker logs openctem-sensor.
openctemio-sensor pairpairs and exits (for a host prepared before the daemon runs);openctemio-sensor pair <CODE>attaches to a code an administrator created with Expect a sensor and prints the fingerprint to compare in the console.openctemio-sensor pair -repairreplaces a lost or compromised key of a paired sensor; an administrator approves it again and the old key is revoked.- The identity lives in
<state dir>/identity/(signing.key,identity.json): 0600 files in a 0700 directory owned by the sensor's user. Looser permissions stop the sensor with the exactchmod/chownto run. - Pairing pins the platform's TLS identity: the key of the trust anchor of
the chain the sensor verified is stored in
identity.json(platform_tls_pin), and every later platform request (v2, the v3 HTTPS binding, certificate requests) must reach the same anchor, with no fallback to the trust store. A TLS-inspecting proxy or a certificate from another CA is refused ("platform certificate does not match the pin stored at pairing"); re-pair, or setSENSOR_CA_FINGERPRINT, after a deliberate CA change. A sensor paired before this release is not pinned until it is paired again or setsSENSOR_CA_FINGERPRINT. - The install snippet may carry
SENSOR_CA_FINGERPRINT(the SHA-256 of the platform CA the sensor must see in the TLS chain; it overrides the pin stored at pairing) andSENSOR_PLATFORM_KEY(the thumbprint of the platform's pairing key). Both are public values that stop a fake platform at first contact. WithSENSOR_CA_FINGERPRINT,API_URLmust name the platform by the host name in its certificate, not by an IP address (pairrefuses an IP URL; the daemon warns in its config report). - Pairing also pins the platform job signer's keys the hello lists
(
job_signing_keysinidentity.json). Such a sensor runs only commands the separate job signer signed for it. See Signed jobs. - An approved sensor starts as New: passive work only, no credentials, until an administrator promotes it.
- A sensor can still use a bearer API key (
API_KEY, created under Settings > Sensors). See API key renewal.
| Mode | Command | What it does |
|---|---|---|
| Daemon | -daemon -enable-commands (the default image's command) |
Long-running, server-controlled: heartbeats, claims the commands the platform dispatches, delivers results through the outbox |
| One job | -job <command id> |
Runs one platform command and exits (a Kubernetes Job). See One job, then exit |
| One-shot | -tool <tool> / -tools a,b with -push |
Runs the scanners on -target once and pushes the results (needs API_URL and API_KEY) |
| Standalone | -standalone -tool <tool> |
Runs locally and never contacts the platform; prints a summary, or with -json the CTIS reports (to stdout, or to -output <file>) |
A daemon without -enable-commands runs scheduled scans only of the targets
you configure (-target or targets: in the config file). The former
-platform mode was removed; the flag is refused with a message naming
-daemon -enable-commands.
# One scan, results pushed to the platform
openctemio-sensor -tool semgrep -target ./src -push
# Several scanners
openctemio-sensor -tools semgrep,betterleaks,trivy -target . -push -verbose
# Local only, results to a file
openctemio-sensor -standalone -tool betterleaks -target . -json -output results.json
# Daemon with a config file
openctemio-sensor -daemon -enable-commands -config sensor.yamlIn a container, arguments replace the image's default command. Use the image that carries the tool:
docker run --rm -v "$(pwd)":/scan -w /scan \
-e API_URL=https://openctem.example.com -e API_KEY \
ghcr.io/openctemio/sensor:latest-semgrep -tool semgrep -target /scan -pushA one-shot run whose key is rejected exits with code 78 (EX_CONFIG);
see Rejected key and connection failures.
API_URL is the API base URL, the address whose /health answers
{"status":"healthy"}. It is not the web UI: the UI's /api/v1 proxy does
not forward the sensor's credentials, and a current UI answers sensor
requests with 421 WRONG_ENDPOINT.
The sensor reaches a platform on loopback, a private network, a Docker
network name (http://api:8080) or a Kubernetes service name without any
extra setting. Only cloud-metadata and link-local addresses are refused. The
OPENCTEM_SDK_HTTPSEC_ALLOW_PRIVATE / ..._ALLOW_LOOPBACK workarounds that
the agent release needed are no longer required; remove them, because they
also widen what scan targets may reach.
A plain http:// URL to anything but loopback works but prints a warning that
credentials are sent in clear text. Use https:// outside a private network.
The images run as the non-root user openctem, so update-ca-certificates
cannot run inside them. Any of these make the sensor trust your CA:
| Method | Example |
|---|---|
SENSOR_CA_CERT_FILE (platform and content downloads only) |
-v /path/ca.pem:/certs/ca.pem:ro -e SENSOR_CA_CERT_FILE=/certs/ca.pem |
Mount the CA into /etc/ssl/certs (everything, scanners included) |
-v /path/ca.pem:/etc/ssl/certs/my-ca.pem:ro |
SSL_CERT_DIR |
-v /path/ca.pem:/certs/my-ca.pem:ro -e SSL_CERT_DIR=/certs |
SSL_CERT_FILE |
-v /path/ca.pem:/certs/my-ca.pem:ro -e SSL_CERT_FILE=/certs/my-ca.pem |
The public CAs keep working with each of these. Mounting into
/usr/local/share/ca-certificates/ does not work (it needs
update-ca-certificates). Without the CA the sensor logs
x509: certificate signed by unknown authority and keeps retrying. In
Kubernetes, mount the CA from a ConfigMap at /etc/ssl/certs/<name>.pem with
subPath.
A sensor has three kinds of outbound traffic, each with its own setting (api RFC-034):
| Traffic | Setting | When unset |
|---|---|---|
| To the platform | SENSOR_CONTROL_PROXY (a proxy URL, or direct) |
HTTPS_PROXY / HTTP_PROXY / NO_PROXY |
| Content and feeds (nuclei templates, semgrep rules, trivy's database, KEV/EPSS) | SENSOR_CONTENT_PROXY (a proxy URL, or direct) |
the platform setting above |
| Scanners to their targets | SENSOR_SCAN_PROXY: inherit or direct |
inherit |
- Proxy URLs may be
http://,https://,socks5://orsocks5h://, withuser:password@when the proxy needs authentication.NO_PROXYis the bypass list. Anhttps://platform is reached through an HTTP proxy with aCONNECTtunnel, so TLS stays end to end. - The usual setup sends everything outbound through one proxy:
-e HTTPS_PROXY=http://proxy.example.com:3128 -e NO_PROXY=openctem.example.com,.svc. - With
inherit(the default), scanner processes get the sameHTTP(S)_PROXYandNO_PROXY, and the sensor prints a warning at start: their traffic to targets then goes through the proxy unlessNO_PROXYlists the target, which is rarely wanted for internal targets. SetSENSOR_SCAN_PROXY=directso scanners connect directly (content downloads keep using the proxy), orSENSOR_SCAN_PROXY=inheritto keep the inheritance on purpose (the warning stops). - Content downloads check the target address before they use the proxy, so the proxy cannot be used to reach private or cloud-metadata addresses.
- For a TLS-inspecting proxy, mount its CA and set
SENSOR_CA_CERT_FILE. It is trusted for the platform and for content downloads; scanner processes readSSL_CERT_FILE.
| Variable | Description | Default |
|---|---|---|
API_URL |
Backend API base URL (or -api-url flag) |
- |
API_KEY |
Legacy bearer API key (or -api-key flag). Unset: the sensor pairs and signs its requests with its own key |
- |
SENSOR_ID |
Sensor identifier (or -sensor-id flag) |
auto |
SENSOR_TOOLS |
Optional allowlist: comma-separated scanners when -tool/-tools is not given. A server-controlled daemon without one runs every installed scanner and reports them to the platform |
- (every installed tool) |
SENSOR_ADAPTER_DIRS |
Directories of operator-installed tools (each a tool.yaml with its program), :-separated |
- |
SENSOR_NAME |
Platform-mode sensor name (or -name flag) |
auto |
SENSOR_MAX_JOBS |
Cap on commands run at once, 1-100 (or -max-concurrent, sensor.max_jobs); the live count follows CPU, memory and tool costs |
no cap |
SENSOR_DRAIN_GRACE |
On SIGTERM, how long running scans may finish before they are stopped and handed back to the platform (allow it plus ~15 s in stop_grace_period / terminationGracePeriodSeconds) |
30s |
SENSOR_STATE_DIR |
Local state: the paired identity (identity/), the renewed API key (sensor-credentials.json, see "API key renewal") and the tool cost history (tool-costs.json). Mount a persistent volume |
/var/lib/openctem/state when writable, else ~/.openctem |
SENSOR_PROTOCOL |
Sensor protocol (or -protocol, server.protocol): auto or v2, which are the same; v1 is retired and refused |
auto |
SENSOR_TRANSPORT |
Transport of sensor protocol v3 for a paired sensor: auto (gRPC over mutual TLS, then HTTPS where gRPC is blocked, then protocol v2 on a platform without v3), or force grpc, https or v2. An identity refusal never falls back. The client certificate and the pinned platform CA are kept in identity/ |
auto |
SENSOR_CA_CERT_FILE |
PEM file with the platform's private CA (or a TLS-inspecting proxy's CA), trusted for platform requests and content downloads | - |
SENSOR_CA_FINGERPRINT |
SHA-256 fingerprint of the platform's CA certificate (from the install snippet); pins platform TLS to it. API_URL must then use a host name |
- |
SENSOR_PLATFORM_KEY |
Thumbprint of the platform's pairing key (from the install snippet); pairing refuses another key | - |
SENSOR_REQUIRE_LOCAL_POLICY |
true: without a local policy, refuse every job with network targets, custom templates or callbacks; false: the older behavior. See Without a policy |
true for a sensor paired by this release or later, else false |
SENSOR_JOB_SIGNING_KEYS |
The platform job signer's keys, comma-separated: key ids (SHA256:<hex>, from openctem-signer pubkey; the public key then comes from the platform's hello) or base64 Ed25519 public keys. Added to the keys pinned at pairing. With a key pinned, every signed job is verified before it runs. Needs a paired sensor. See Signed jobs |
the keys pinned at pairing |
SENSOR_JOB_SIGNING_ROOT |
The installation's offline job-signing root: its key id (SHA256:<hex>, printed by openctem-signer root keygen) or base64 Ed25519 public key. Signer keys are then taken from the root-signed key set the platform serves, so they rotate without pairing again. Overrides the root pinned at pairing. See Signed jobs |
the root pinned at pairing |
SENSOR_REQUIRE_SIGNED_JOBS |
true: refuse every command without a valid job signature (refusal rule job_signature); false: run unsigned commands, still verify signed ones. true without a pinned key stops the sensor at start. See Signed jobs |
true when pairing pinned signer keys, else false |
PLATFORM_KEY_AUTORENEW |
API key auto-renewal (or -key-autorenew): true, false, or unset. See API key renewal |
on when the state directory persists |
SENSOR_CONTROL_PROXY, SENSOR_CONTENT_PROXY, SENSOR_SCAN_PROXY |
Outbound proxies. See Through an HTTP proxy | - |
REGION |
Deployment region (or -region flag; AWS_REGION is also read) |
default |
SENSOR_JOB_ID |
Run one platform command and exit (or -job). See One job, then exit |
- |
SENSOR_SCANNER_PRIORITY |
Priority of scanner processes: low (nice +10, lowest best-effort I/O, OOM-killed before the sensor) or normal |
low |
SENSOR_PROTECT_FROM_OOM |
true protects the sensor process itself from the OOM killer (Linux; needs CAP_SYS_RESOURCE) |
false |
SENSOR_TOOL_RUNTIME |
How the tools ported to the tool contract (sdk-go pkg/tool) run: out-of-process (the sensor re-executes itself per task in the tool sandbox) or in-process (rollback) |
out-of-process |
SENSOR_ALLOW_PRIVATE_TARGETS |
Set 1 to allow scanning RFC1918 / IPv6 ULA targets. IMDS / loopback / CGNAT stay blocked regardless. See Scanner safety model. |
off |
SENSOR_SCAN_ROOTS |
Directories (:-separated) that filesystem targets of dispatched code scans (betterleaks, semgrep, trivy fs) must resolve inside; a relative target is taken relative to the first. See Scanner safety model. |
the sensor's working directory (/scan in the images) |
SENSOR_TEMPLATE_SIGNING_KEYS |
Only for a sensor that does not verify signed jobs: the platform's template-signing public keys for this sensor's tenant (base64 Ed25519, comma-separated; from GET /api/v1/scanner-templates/signing-key). Custom templates in a scan run only with a signature one of them verifies. A sensor that verifies signed jobs trusts custom templates through the signed job and does not need it. This fallback will be removed. See Nuclei template trust. |
none: without signed jobs, scans with custom templates fail |
SENSOR_LOCAL_POLICY |
The sensor-local policy file (or -local-policy): targets, ports, tools, job types, custom templates, interactsh, rate and a kill switch, set by the network owner; jobs outside it are refused whatever the platform sends. A policy that cannot be loaded stops the sensor. See Sensor-local policy. |
/etc/openctem/sensor-policy.yaml when it exists, else none |
SENSOR_TENABLE_SC_CONFIG |
The Tenable.sc connector config (or -tenable-sc-config): instances, key files, CA or pins and the operations and repositories the platform may use. A config that cannot be loaded stops the sensor. See Tenable.sc connector. |
/etc/openctem/connectors/tenable-sc.yaml when it exists, else the TENABLE_SC_* shorthand, else off |
SENSOR_ALLOWED_RANGES / SENSOR_ALLOWED_PORTS |
Shorthand policy without a file: targets.allow (comma-separated CIDRs, IPs, names, *.domain) and ports.allow (80,443,8000-8999) |
- |
SENSOR_KILL_SWITCH_FILE |
While this file exists the sensor runs no job and heartbeats "paused by local policy" (also kill_switch_file in the policy) |
- |
SENSOR_DNS_RESOLVERS |
DNS resolvers dnsx, naabu and subfinder use (comma-separated IP or IP:port). Unset: the nameservers in /etc/resolv.conf, as httpx, katana and nuclei use. The tools' built-in public resolver lists are never used, so enumerated names do not go to third-party resolvers and internal or split-horizon names resolve. An invalid value fails recon jobs |
/etc/resolv.conf |
SENSOR_SANDBOX |
How every tool run is confined (see Tool sandbox): auto enforces what the host supports and logs the rest, required refuses to start unless every control is enforced, off runs tools as plain child processes. One-shot runs sandbox only when this is set |
auto (daemon), off (one-shot) |
SENSOR_NUCLEI_MAX_RATE_LIMIT |
Ceiling on nuclei requests per second (-rate-limit). A scan may ask for less, never more |
150 |
SENSOR_NUCLEI_MAX_CONCURRENCY |
Ceiling on nuclei templates in parallel (-c) |
25 |
SENSOR_NUCLEI_MAX_BULK_SIZE |
Ceiling on nuclei hosts in parallel per template (-bs) |
25 |
API_URL, API_KEY and BOOTSTRAP_TOKEN keep their names. The pre-rename
names AGENT_ID, AGENT_NAME, AGENT_ALLOW_PRIVATE_TARGETS and -agent-id
still work (see Upgrading).
-config reads the keys of Config in main.go: sensor:, server:,
outbox:, retry_queue: (deprecated), scanners:, collectors: and
targets:.
sensor:
name: production-scanner
region: default
heartbeat_interval: 1m
enable_commands: true
command_poll_interval: 30s # used only with an API without the heartbeat doorbell
# disable_doorbell: true # poll every command_poll_interval regardless
# max_jobs: 8 # cap on commands run at once, 1-100 (SENSOR_MAX_JOBS); unset: follow CPU/memory
server:
base_url: https://openctem.example.com
api_key: ${API_KEY}
sensor_id: your-sensor-id
timeout: 30s
protocol: auto # auto | v2, the same (SENSOR_PROTOCOL; v1 is refused)
outbox: # undelivered results; on by default with -daemon
dir: /var/lib/openctem/outbox
max_bytes: 1GiB
max_age: 168h
scanners:
- name: semgrep
enabled: true
- name: betterleaks
enabled: true
targets:
- /path/to/projectWith an API that supports it (RFC-023 §9.2a) the daemon's heartbeat answer says when there is work, and the daemon polls only then:
| Heartbeat answer | Sensor |
|---|---|
pending_jobs > 0 |
polls for commands immediately |
next_heartbeat_seconds |
next heartbeat after that long (5 s – 5 min) |
| hints present | no fixed 30 s poll; a safety poll every 5 min |
| no hints (older API) | polls every command_poll_interval, as before |
pause (sensor disabled) |
takes no new jobs, running jobs finish, keeps heartbeating; logs paused by platform; resumes on the first heartbeat without pause |
drain |
like pause, until restart |
rotate_key |
renews the key now (when key auto-renewal is on, see "API key renewal"), saving it to the credentials file |
update, unknown |
logged only |
The daemon keeps its state in SENSOR_STATE_DIR (default
/var/lib/openctem/state when writable, else ~/.openctem). A key it
renews is saved there (sensor-credentials.json, 0600, written atomically)
and used on the next start instead of API_KEY, because the renewal retires
the key the sensor was installed with. If API_KEY is changed to a key an
administrator regenerated, the new API_KEY wins. A file an earlier version
kept in ~/.openctem is moved into the state directory.
Auto-renewal (PLATFORM_KEY_AUTORENEW, -key-autorenew): true / false
force it; unset, it is on when the state directory survives the container
being recreated (outside a container always; inside one, only on a mounted
volume that is not a tmpfs) and off otherwise, with the reason in the start-up
log. Mount a volume at /var/lib/openctem/state (the platform's install
snippets do). The images create the directory but do not declare it a
VOLUME: an anonymous volume is lost with the container. The platform
issues expiring keys only when its SENSOR_KEY_TTL is set; with no TTL a
renewal (once, on the first start with renewal on) yields a key that never
expires and nothing else happens.
Every scanner run by the daemon is confined before it starts (sdk-go
pkg/sensorkit/executor, the process backend). The sensor binary re-executes itself
as a launcher, confines that process, then replaces it with the tool:
- a private, throwaway directory (its HOME and TMPDIR), removed after the run;
- resource limits: memory, processes and threads (a fork bomb stops at its allowance), file size, open files, no core dumps;
- no privilege escalation (no_new_privs);
- Landlock: the tool writes only in its directory and the paths its
wrapper declares (a report directory, nuclei's private configuration), and
cannot read the sensor's credentials file, outbox and its key, local policy,
-configfile or the Tenable.sc connector configuration; - seccomp: ptrace, mount and namespaces, kernel modules, keyrings, bpf, perf and similar syscalls are refused;
- the sensor itself is non-dumpable, so a tool cannot read its memory or
environment through
/proc.
It needs no extra privileges and works under Docker's default seccomp profile
(Landlock needs Linux 5.13 or newer). At start the daemon logs Tool sandbox: auto; private task directory, rlimits, no_new_privs, landlock vN, seccomp true
or a warning naming what the host does not support. Set
SENSOR_SANDBOX=required to refuse to start without all of it. The sensor
never uses a Docker socket.
Protocol. The sensor speaks protocol v2 (/api/v2/sensor/*, api RFC-026
and RFC-029) for everything: heartbeat, commands, suppressions, fingerprint
queries, key renewal and results. The platform retired protocol v1
(/api/v1/agent/*) in 2026-10, so the sensor needs an OpenCTEM API from
2026-10-02 on; against an older one every call fails with "the platform does
not serve sensor protocol v2". SENSOR_PROTOCOL / -protocol /
server.protocol is auto (default) or v2, which are the same; v1 is
refused at start-up. The sensor is identified by its key alone.
Outbox. A daemon writes every result to its outbox before sending it
and deletes it only once the platform accepted it, so a crash, kill -9, an
API outage or a restart loses nothing; the backlog is sent, oldest first, as
soon as a heartbeat gets through. A command is reported complete only after
its results were accepted. Results the platform refuses for good (malformed,
tool not declared, ...) move to dead/ with the reason instead of being
retried forever. The outbox never fills the disk: past the size or age cap the
oldest entries are dropped with a warning in the log, a metric and on the
heartbeat (the API stores it with the sensor).
| Setting | Default | |
|---|---|---|
SENSOR_OUTBOX (outbox.enabled) |
on for -daemon, off for one-shot runs |
on keeps a one-shot run's results for its next run |
SENSOR_OUTBOX_DIR / -outbox-dir (outbox.dir) |
/var/lib/openctem/outbox, else ~/.openctem/outbox |
mount a persistent volume here |
SENSOR_OUTBOX_MAX_BYTES (outbox.max_bytes) |
1GiB (and at most half of the free space) |
e.g. 512MiB |
SENSOR_OUTBOX_MAX_AGE (outbox.max_age) |
168h |
|
SENSOR_OUTBOX_KEY_FILE (outbox.key_file) |
<dir>/outbox.key |
the AES-256-GCM key, created on first start; point it at a mounted secret to keep it off the data volume |
Files are 0600 in a 0700 directory and encrypted; one sensor process per directory (a second one refuses to start). Scanners cannot read the outbox or its key (tool sandbox). Each report carries a stable id the platform uses as its idempotency key, so a backlog sent after an outage, or a send whose answer was lost, is stored exactly once. While the sensor runs, its heartbeat reports the outbox state to the platform (pending results, oldest age, dead letters, evictions). With the sensor stopped:
openctemio-sensor -outbox-status # pending, dead letters with reasons
openctemio-sensor -outbox-requeue-dead # after fixing the cause; the next start delivers them
# in Docker, against the same volume:
docker run --rm -v openctem-outbox:/var/lib/openctem/outbox ghcr.io/openctemio/sensor:latest -outbox-statusUpgrading: the old -retry-queue / RETRY_QUEUE=true now turns the outbox on
(also for one-shot runs), and results an older sensor left in its retry-queue
directory (RETRY_DIR, default ~/.openctem/retry-queue) are imported once.
The daemon checks its key with its first heartbeat. When the platform rejects
it (HTTP 401/403: the key is wrong, revoked, expired or regenerated, or the
sensor was deleted), the daemon stays up: it stops polling for jobs and
checks again after 30 s, doubling to at most 10 min. It logs one line per
attempt, without -verbose:
[connection] the platform rejected the API key (HTTP 401, key rda_5d22…): ... Create or regenerate a key under Settings → Sensors, set API_KEY to it and restart the sensor. Not polling for jobs; next check in 30s (attempt 1)
It carries on by itself once the key is accepted again, for example after
the sensor is re-activated. A 401 API key required means the key never
reached the API: API_URL points at the web UI or at a proxy that strips
the Authorization header. Network failures are logged at 1, 2, 4, 8, ...
consecutive attempts, along with the recovery.
A one-shot run (-push without -daemon, e.g. in CI) exits with code
78 (EX_CONFIG) when its key is rejected, so the job fails with that
message rather than a generic error.
Restart policy: the daemon no longer exits on a rejected key, so
restart: unless-stopped / Kubernetes restartPolicy: Always cannot turn
it into a restart loop. Don't treat exit code 78 as transient in wrappers
that retry one-shot runs.
On start the daemon runs read-only preflight checks and, when the platform
supports it, sends the results to the platform: open the sensor under
Settings → Sensors to see its Setup & health checklist, each problem
with why it matters and the exact fix (environment variable, Compose or Helm
snippet). The same problems are printed at start (Warning: ...) and a
summary line says Preflight: N passed, M warning(s), K failed.
What is checked: each tool's binary (missing or installed but failing to
run), whether the state directory survives a recreate, key renewal, the
scanner proxy, OOM protection, the trust store files (SSL_CERT_FILE,
SSL_CERT_DIR), the local policy and its template keys, unknown or legacy
setting names, unknown keys and unset ${VAR}s in the -config file, and
-daemon without -enable-commands.
What is sent: check ids, codes and typed parameters (a path, a tool or a setting name), and for every declared setting only whether it is set, where it came from and whether its value is valid. Setting values, secrets and target ranges never leave the host. Free text is scrubbed of secret values and URL credentials before it is sent. Nothing the platform sends chooses what is checked, and no check opens a connection.
A scanner binary is pinned and checksum-verified in the image; the content it scans with changes daily. In daemon mode the sensor manages that content itself, so scans use a known, verified version instead of whatever a tool fetches mid-scan:
| Content | Tool | Default source | Verification | Managed |
|---|---|---|---|---|
trivy-db |
trivy | mirror.gcr.io/aquasec/trivy-db:2, then ghcr.io/aquasecurity/trivy-db:2 |
manifest digest resolved first and downloaded by digest (trivy verifies every blob); trivy version must read schema 2; never older than the installed DB unless pinned |
always |
trivy-java-db |
trivy | trivy's default | trivy reads its metadata | SENSOR_CONTENT_TRIVY_JAVA_DB=true (about 800 MB more) |
nuclei-templates |
nuclei | the release baked into the image, then the GitHub release (releases/latest) |
archive sha256 against the release's _checksums.txt; safe extraction; at least 1000 templates that nuclei loads; nuclei -validate passes for all but SENSOR_CONTENT_NUCLEI_MAX_TEMPLATE_ERRORS templates; scans run with -disable-unsigned-templates (signature check) and -disable-update-check |
always |
semgrep-rules |
semgrep | https://semgrep.dev/c/<ruleset> |
YAML check (rules with ids) and semgrep loads the bundle | only when rulesets are chosen (platform policy or SENSOR_CONTENT_SEMGREP_RULESETS) or a local rules path is set; otherwise semgrep keeps --config auto and the sensor reports that as unmanaged |
How it works: every refresh downloads into a staging directory, verifies,
and only then switches the current link atomically. A failed download or
check keeps the current version and is reported. A running scan keeps the
version it started with; the previous version is kept for rollback. Checks
run every 6 hours (±10% jitter, hourly while content is missing, stale or
failing) and on demand when the platform sends a refresh_content command
("Refresh content" on the Sensors page). The heartbeat reports each tool's
content (tools[].content: version, build time, source, digest, last error)
and each result carries the content its scan used (tool.properties.content).
The platform's content policy can set the refresh interval, a maximum age, a pinned version (a trivy DB digest, a nuclei-templates tag) and semgrep rulesets. It can never choose where content comes from: sources are only this host's settings below.
| Variable | Default | |
|---|---|---|
SENSOR_CONTENT |
on |
off: tools fetch their own content, as before |
SENSOR_CONTENT_DIR |
$HOME/.openctem/content (/var/lib/openctem/content in the images) |
mount a volume here |
SENSOR_CONTENT_REFRESH_INTERVAL |
6h |
10m..720h; the policy may override |
SENSOR_CONTENT_KEEP |
1 |
previous versions kept for rollback (0..10) |
SENSOR_CONTENT_TRIVY_DB_REPOSITORY |
see above | comma list, tried in order; registry credentials from TRIVY_USERNAME/TRIVY_PASSWORD |
SENSOR_CONTENT_TRIVY_JAVA_DB / _REPOSITORY |
off / trivy default | |
SENSOR_CONTENT_NUCLEI_TEMPLATES_URL |
GitHub archive | {version} / {bare_version} placeholders; https:// or a local file |
SENSOR_CONTENT_NUCLEI_TEMPLATES_CHECKSUMS_URL |
GitHub release asset | required with a mirror unless _SHA256 is set |
SENSOR_CONTENT_NUCLEI_TEMPLATES_LATEST_URL |
GitHub API (only with the default archive URL) | GitHub-shaped JSON or a plain-text tag |
SENSOR_CONTENT_NUCLEI_TEMPLATES_VERSION / _SHA256 |
none | pin a release / its archive digest |
SENSOR_CONTENT_NUCLEI_TEMPLATES_DIR |
none | a local template directory, installed as is |
SENSOR_CONTENT_NUCLEI_MIN_TEMPLATES |
1000 |
|
SENSOR_CONTENT_NUCLEI_MAX_TEMPLATE_ERRORS |
10 |
templates of a release that may fail nuclei -validate before the release is refused; 0: none |
SENSOR_CONTENT_SEMGREP_RULESETS |
none | e.g. p/default,p/secrets |
SENSOR_CONTENT_SEMGREP_REGISTRY_URL |
https://semgrep.dev |
a registry mirror |
SENSOR_CONTENT_SEMGREP_RULES_PATH |
none | a local rules file or directory |
SENSOR_CONTENT_SEMGREP_SKIP_CHECK |
off | skip the semgrep load check (~1 min for p/default) |
nuclei templates in detail:
- The
defaultandnucleiimages bake one nuclei-templates release, pinned in the Dockerfiles with its archive SHA-256 (NUCLEI_TEMPLATES_VERSION/NUCLEI_TEMPLATES_SHA256) and gated at build byscripts/nuclei-templates-bake.sh: any template that failsnuclei -validatewith the pinned nuclei and is not indocker/nuclei-templates-allowlist.txtfails the build, and so does a scan run that logs an error or a release whose.nuclei-ignorestops excludingdos,local,fuzz,bruteforceortxt-service. The sensor adopts the baked set as its first managed version, with its release and archive digest, so a fresh sensor scans without downloading anything. - Each nuclei run over a managed set gets its own nuclei configuration
directory (
XDG_CONFIG_HOME, removed after the run) naming that set and its release. nuclei confines helper files (payload wordlists, workflow subtemplates) to its configured templates directory: without this, every template that loads one failed with "access to helper file ... denied" (262 templates of v10.4.9, reported as "templates with runtime error"). The run's.nuclei-ignoreis the release's own exclusion list plus the baseline tags above. - Template classes the sensor does not enable for its own set (
code,headlessunless configured,file, self-contained) are skipped by nuclei, not errors. - Every finding carries
template_digest(sha256:of the template file that matched) andtemplate_path; every result carries the release (tool.properties.content: version and archive digest). A nuclei re-verification reportstemplate_digest,templates_versionandtemplates_digestin its evidence. The platform compares them to decide whether a re-check ran the same template content.
openctemio-sensor -content-status prints what is installed;
-content-refresh (with -content-force to re-download) refreshes now, for
example from cron on a host that runs one-shot scans (one-shot runs use
installed content but never download it).
Air-gapped hosts:
- trivy DB: copy the artifact into an internal registry
(
oras copy mirror.gcr.io/aquasec/trivy-db:2 registry.example.com/aquasec/trivy-db:2) and setSENSOR_CONTENT_TRIVY_DB_REPOSITORY=registry.example.com/aquasec/trivy-db:2. - nuclei templates: put
nuclei-templates-vX.Y.Z.tar.gzand the release'snuclei-templates-X.Y.Z_checksums.txton an internal web server or a mounted directory, setSENSOR_CONTENT_NUCLEI_TEMPLATES_URL=file:///mirror/nuclei-templates-{version}.tar.gz,SENSOR_CONTENT_NUCLEI_TEMPLATES_CHECKSUMS_URL=file:///mirror/nuclei-templates-{bare_version}_checksums.txtand pin the version (policy orSENSOR_CONTENT_NUCLEI_TEMPLATES_VERSION). - semgrep:
SENSOR_CONTENT_SEMGREP_RULES_PATH=/mirror/semgrep-rules.yaml.
Disk: about 1.5 GB per trivy DB version (two with the default
SENSOR_CONTENT_KEEP=1, plus 0.8 GB each with the Java DB), about 150 MB per
nuclei-templates version (the baked one lives in the image), a few MB of semgrep rules.
Beyond one-shot scanning, the daemon can re-verify existing findings so the platform can confirm-or-downgrade them without a full rescan.
validate— advertised always. The daemon wraps its command executor with a validating executor that runs a non-intrusive TCP-reachability safe-check forvalidatecommands, regardless of which scanners are enabled (runDaemoninmain.go).validate:nuclei— advertised when the vuln-scan (nuclei) image is present It re-runs a finding's own detection template non-destructively and returnsdetected/not_detected/inconclusive/error(internal/executor/validation.goRunNucleiValidate). If the template is not installed, the result isinconclusive— never a false downgrade.retest:nuclei: advertised withvalidate:nuclei. The platform'sretestcommand lists nuclei findings (the template id of each, and the address it is on). Thenuclei-validatetool checks each address first with a TCP connect, then re-runs each finding's own template with the re-verification's safety flags. A finding whose template matches again isstill_present. One whose template ran against the reachable address and did not match isfixed. Anything else isunverifiable, including an unreachable address and a template that is not installed or is excluded. The platform closes or reopens findings from these verdicts. The command passes the local policy like a scan (checks.allowmust listretestwhen the policy lists check types), and every address passes the validate guard (no loopback, link-local or cloud-metadata target).
-job <command id> (or SENSOR_JOB_ID) runs the one platform command with
that id and exits, so a launcher can start one sensor pod per job. It implies
-daemon -enable-commands. The sensor:
- sets up as a daemon does: identity, manifest, tools, local policy, outbox, and heartbeats, which keep the job's lease and carry cancels;
- claims the command by id;
- runs it with every check a polled command gets (kill switch, served command types, expiry, local policy, the platform's tool policy);
- waits up to 5 minutes for the outbox to deliver the results;
- exits.
It takes no other work.
| Exit | When |
|---|---|
0 |
the job ran and its results were delivered. A scan that failed is reported to the platform as failed; retrying the pod would not change it. |
| non-zero | the platform refused the claim (another tenant's command, held by another sensor, no longer pending), the job was not run (it is released for another sensor), or results were not delivered in time |
Mount the outbox (SENSOR_OUTBOX_DIR) on a persistent volume. Results that are
not delivered before the pod ends are otherwise lost. On SIGTERM the job is
stopped and handed back to the platform.
apiVersion: batch/v1
kind: Job
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: sensor
image: ghcr.io/openctemio/sensor:<version>
args: ["-job", "$(JOB_ID)"]
env:
- {name: JOB_ID, value: "<command id>"}
- {name: API_URL, value: "https://openctem.example.com"}
- {name: SENSOR_OUTBOX_DIR, value: /var/lib/openctem/outbox}
volumeMounts:
- {name: outbox, mountPath: /var/lib/openctem/outbox}
volumes:
- name: outbox
persistentVolumeClaim: {claimName: sensor-outbox}The sensor can pull hosts, vulnerabilities and plugin metadata from a
Tenable.sc in your network, with the Tenable API keys kept on the sensor
(api RFC-047): docs/TENABLE_SC.md. Configure it with
-tenable-sc-config (or SENSOR_TENABLE_SC_CONFIG, or the TENABLE_SC_*
environment shorthand).
The owner of the scanned network sets what this sensor may do, in a
read-only file the platform cannot change (api RFC-040 §5.7):
docs/LOCAL_POLICY.md, template
docs/sensor-policy.example.yaml.
install -o root -g root -m 0644 docs/sensor-policy.example.yaml /etc/openctem/sensor-policy.yaml # then edit it
docker run … -v /etc/openctem:/etc/openctem:ro ghcr.io/openctemio/sensor:<tag> -daemon -enable-commands
touch /etc/openctem/STOP # kill switch: no job runs until the file is removedEvery job is checked after the claim and before any tool runs: its targets
(host names resolved, every address checked), ports, tool, job type, custom
templates and interactsh. A refused job is reported failed with
refused by local policy: <rule>: <detail>. Rate and run time are capped.
A malformed policy stops the sensor. Without a policy the sensor works as
before, reports local_policy: absent and logs warnings; custom templates
and interactsh are off in any policy unless it turns them on.
The platform can sign every command it hands out with a separate job signer
(openctem-signer, its own key, outside the API; api RFC-040 §5.6). The
sensor verifies the signature right after the claim, before the local policy,
the command gate and the tool:
- The envelope is Ed25519 over the exact statement bytes, with a pinned key.
- The statement must name this sensor's organization and id, the command's id, type and lease epoch, the SHA-256 of the payload as received, the tool and targets in it, and the SHA-256 of every custom template it carries.
- It must be issued within the last 2 minutes and not be expired (at most 1 hour), with a nonce not seen before and a sequence number above the last one accepted from that key.
- The last sequence numbers are kept in
<state dir>/job-signing-seq.json. Keep it on the persistent state volume. A corrupt file stops the sensor rather than accept old jobs again.
A command that fails the check, or arrives unsigned while signed jobs are
required, is failed with refusal layer builtin, rule job_signature, and
never runs. The sensor reports jobs.signed in its posture: required,
verified_when_present or off.
- New pairings with a platform that signs jobs pin its signer keys and require signed jobs.
- Sensors paired earlier are unchanged. To require signed jobs on one, set
SENSOR_JOB_SIGNING_KEYSto the signer's key id andSENSOR_REQUIRE_SIGNED_JOBS=true, or pair it again. - Key rotation through the root. When the platform serves a key set
signed by the installation's offline root, pairing pins that root
(
identity.jsonjob_signing_root), or the network owner setsSENSOR_JOB_SIGNING_ROOT. The sensor then accepts job signatures only from keys in the current key set (plusSENSOR_JOB_SIGNING_KEYS). The key set is versioned and expires within 30 days. A lower version is refused, and the accepted one is kept in<state dir>/job-signing-keyset.json, so a rollback is refused across restarts too. A new key set from the platform rotates or revokes signer keys without pairing again. With a root pinned and no valid key set (none, expired or refused), signed jobs are refused with rulejob_keyset. The sensor warns 7 days before the key set expires, and its posture reportsjobs.root,jobs.keyset_versionandjobs.keyset_expires_at. - Without a root, a new signer key is pinned by updating
SENSOR_JOB_SIGNING_KEYSor by pairing again. - A sensor that requires signed jobs refuses every command of a platform that does not sign them.
Scan and validation targets originate from ingested asset data, so every target
passes an SSRF guard before any tool runs
(internal/executor/target_security.go):
- Hard-blocked, never openable: cloud metadata / link-local
(
169.254.0.0/16, incl. IMDS169.254.169.254), loopback (127.0.0.0/8,::1), and carrier-grade NAT (100.64.0.0/10), plus multicast/broadcast. - Blocked by default, opt-in: RFC1918 / IPv6 ULA private space
(
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16). SetSENSOR_ALLOW_PRIVATE_TARGETS=1to scan on-prem/internal targets. IMDS, loopback, and CGNAT stay blocked regardless.
Targets are checked according to the scanner that receives them. Network
scanners (nuclei, the recon tools, and any scanner the sensor does not know)
get the SSRF guard above. Code scanners (betterleaks, semgrep, trivy fs/config)
take a directory: it must resolve, symlinks followed, inside the scan
workspace (SENSOR_SCAN_ROOTS, default the working directory), and never a
sensitive host path (/etc, ~/.ssh, ...). A remote repository URL given to a
code scanner is SSRF-guarded like any network target.
A daemon with -enable-commands scans only what the server dispatches. It
runs scheduled scans of its own only for targets you configure explicitly
(-target, or targets: in the config file).
Additional guards on the vuln-scan path:
- Dangerous-flag blocklist (sdk-go
core.ValidateExtraArgs, CWE-77) — rejects user-supplied tool flags that redirect output, set a proxy, load an attacker-controlled target list / rule / template file, enable the code, file, self-contained or headless template types, switch template signature checks off, upload results to a third party, or override resolvers / interface / source IP. Rate-limit flags are refused too (see below). - Nuclei re-verify is detection-only — the
dos,fuzz,intrusive,brute-forceanddefault-logintemplate tags are excluded, the template must have a safe matcher, runs are bounded by timeout and rate-limited per asset, and every run is logged under its command id (the audit key).
The sensor's own nuclei templates (the managed nuclei-templates release,
or the templates baked into the image) always run with
-disable-unsigned-templates: nuclei skips any template whose
ProjectDiscovery signature is missing or does not match. The code protocol
is never enabled: the sensor never passes -code, -file, -esc or
-dast, passes -headless only when configured in code, and refuses all of
them (and -dut=false) in extra args.
Non-intrusive only (api RFC-036, tier T1). Every nuclei run, of the
sensor's own templates or of custom templates, gets
-etags intrusive,default-login,dos,fuzz,fuzzing,bruteforce,brute-force,local,txt-service
on the command line (with or without managed templates), plus any tags the
operator or the scan excludes. A scan whose tags setting names one of
these classes fails with the reason instead of running; a scan's
exclude_tags only adds to the list; and -itags / -include-tags, the
only nuclei flags that re-admit an excluded template, are refused in extra
args (nuclei v3.11.1 drops an -etags template however it was selected:
directory, explicit -t file, -id or -tags). Re-verifications exclude
default-login as well. There is no intrusive mode. Planned: an intrusive
mode that needs an approved grant on the command, within a ceiling the
network owner sets (RFC-036 T2).
Custom templates (uploaded by a tenant admin on the platform) are not signed by ProjectDiscovery, so they are trusted another way.
With signed jobs (the sensor verifies the platform's
signed jobs), the job statement lists the SHA-256 of every
custom template in the payload. The platform's job signer lists a template only
when its digest was approved in the signer's scope ledger (api RFC-040 P2).
The sensor compares the decoded templates with the verified list before writing
them, and needs no SENSOR_TEMPLATE_SIGNING_KEYS. Steps 1, 3 and 4 below, and
the local allow_custom_templates gate, still apply.
Without signed jobs, the per-tenant template manifest is the fallback, planned for removal:
- The platform refuses, at upload, templates that use the
code,javascript,headlessorfileprotocol or are self-contained. - When it hands a command to a sensor, the platform validates every
template again and signs one manifest of the set (tenant, this sensor,
this command, issue and expiry time, and the id, name, type and SHA-256
of each template) in a DSSE envelope with an Ed25519 key derived for the
tenant. The sensor (sdk-go) verifies the envelope against
SENSOR_TEMPLATE_SIGNING_KEYSbefore parsing it, then refuses a manifest for another command (or another sensor, whenSENSOR_IDis set), an expired one, and any template changed, added, held back or reordered. Without a pinned key, scans with custom templates fail. - The sensor checks the templates itself again (
CheckCustomTemplates): the same protocols and self-contained templates are refused. - Custom templates run in their own nuclei run, with
-exclude-type code,file,headless,javascriptand never-headless; the sensor's own templates run before them, still with-disable-unsigned-templates. The scan's results are both runs'.
Pin the key once per sensor:
# On the platform, as a tenant admin:
curl -H "Authorization: Bearer $TOKEN" https://openctem.example.com/api/v1/scanner-templates/signing-key
# -> {"algorithm":"ed25519","key_id":"…","public_key":"<base64>"}
docker run … -e SENSOR_TEMPLATE_SIGNING_KEYS=<base64> ghcr.io/openctemio/sensor:<tag>To roll the platform key, pin the new key next to the old one (comma-separated), rotate on the platform, then drop the old one.
Rate limits. nuclei always gets -rate-limit, -c and -bs. A scan
command may ask for lower values (config rate_limit, concurrency,
bulk_size); the sensor uses them up to the ceilings
SENSOR_NUCLEI_MAX_RATE_LIMIT / _CONCURRENCY / _BULK_SIZE (default
150 / 25 / 25, nuclei's own defaults) and never above. A value outside
1..1000000 stops the sensor at start. Rate-limit flags in extra args
(-rate-limit, -bs, -c, -per-host-rate-limit, ...) are refused.
Re-verification (validate:nuclei) runs at 20 requests per second, or the
ceiling when it is lower.
The binary, images and settings were renamed from agent to sensor (RFC-023 §9.5). A sensor upgraded in place keeps working with its existing configuration:
| Before | After | On upgrade |
|---|---|---|
binary agent |
openctemio-sensor |
— |
image ghcr.io/openctemio/agent:<tag> |
ghcr.io/openctemio/sensor:<tag> |
old tags stay pullable and frozen (never updated, never deleted) |
AGENT_ID, AGENT_NAME, AGENT_ALLOW_PRIVATE_TARGETS |
SENSOR_ID, SENSOR_NAME, SENSOR_ALLOW_PRIVATE_TARGETS |
old name applied, startup warning naming both |
-agent-id |
-sensor-id |
old flag applied, startup warning |
config agent: block, server.agent_id |
sensor:, server.sensor_id |
old keys applied, startup warning |
~/.openctem/agent-credentials.json |
~/.openctem/sensor-credentials.json |
moved on first start (written 0600 and read back before the old file is removed); same identity and key, no re-registration. If the file cannot be moved (read-only mount) it is used in place. -credentials <path> is used as is. |
API_URL, API_KEY, BOOTSTRAP_TOKEN |
unchanged | — |
The sensor refuses to start only when an old and a new name are both set to different values; the error names both (never the values). The sensor speaks protocol v2 only, so it needs an OpenCTEM API from 2026-10-02 on (see Results delivery and the outbox).
Betterleaks replaces gitleaks as the secret scanner. It is gitleaks' successor by its original author (MIT): v1 keeps gitleaks' CLI flags, config format and JSON report, and adds BPE-token filtering, Expr rule filters and validation, recursive decoding and scanning inside archives (on by default).
- The image is
ghcr.io/openctemio/sensor:<version>-betterleaks. No-gitleaksimage is published from this release on; existing-gitleakstags stay pullable and frozen. - The scanner is
betterleaks(-tool betterleaks,SENSOR_TOOLS,scanners: - name: betterleaks).gitleaksin an existing command line, config or CI template still works: it runs betterleaks and prints a note. A platform that has not migrated its scan configs and still dispatchesgitleaksscans is handled the same way. .gitleaks.tomlcustom rules keep working (betterleaks reads them;.betterleaks.tomlis the new name).- Findings keep their identity: a secret both tools report has the same fingerprint, so existing findings are updated, not duplicated. Rule sets differ, for example betterleaks reports an AWS access key ID only together with its secret key, so a few gitleaks-only findings are auto-resolved by the first full betterleaks scan, and archives produce new ones.
- Upgrade every sensor that scans a repository together: a gitleaks sensor and a betterleaks sensor on the same repository resolve and reopen each other's rule-set differences.
- The platform maps reports from older sensors (
tool: gitleaks) tobetterleaksat ingest and migrates scan configs and existing findings (API migration 000241).
| Message | Cause | Fix |
|---|---|---|
x509: certificate signed by unknown authority |
The API uses a private CA | Trust the CA |
http 421 ... WRONG_ENDPOINT or API key required |
API_URL points at the web UI or at a proxy that strips Authorization |
Point API_URL at the API |
the platform does not serve sensor protocol v2 |
The OpenCTEM API predates protocol v2 | Upgrade the platform |
-platform mode has been removed |
A command line that still passes -platform (images up to v0.3.0 used it as their default command) |
Use -daemon -enable-commands |
SENSOR_ALLOW_PRIVATE_TARGETS="true" is not recognized |
Only 1 or 0 is accepted |
Set 1 |
[connection] the platform rejected the API key |
The key is wrong, revoked, expired or regenerated, or the sensor was deleted | See Rejected key and connection failures |
paused by platform |
The sensor is deactivated: it keeps heartbeating and takes no jobs | Reactivate it under Settings > Sensors |
refused by local policy: ... |
The sensor-local policy refuses the job | See docs/LOCAL_POLICY.md |
- Connection refused: check
API_URL(curl $API_URL/health) and the firewall. From a container on Docker Desktop (macOS, Windows), reach a platform on the host ashttp://host.docker.internal:8080. - Tool not found:
openctemio-sensor -check-toolsnames the missing binaries and how to install them;-install-toolsinstalls them. - No findings: run the scanner with
-verboseand check that it is installed (-list-tools). One-shot runs push a report only when it has findings. - Every start prints the preflight problems and the platform shows them under the sensor's Setup & health (see below).
# Build for current platform
make build
# Build for all platforms
make build-all
# Run tests
make testSee CONTRIBUTING.md.
Report vulnerabilities privately; see SECURITY.md.
- openctemio/openctem - the platform: API (
api/) and web console (web/), formerly openctemio/api and openctemio/ui - openctemio/sdk-go - Go SDK the sensor is built on
- openctemio/ci - CI scanning (
openctem-ci, GitHub Action, GitLab templates) - openctemio/ctis - the CTIS report format and importers
- openctemio/helm-charts - Helm chart for the platform
Apache License 2.0 - see LICENSE.