Skip to content
openctemioPublic

About

OpenCTEM Sensor: lightweight scanner runtime that runs Nuclei, Trivy, Semgrep, Betterleaks and recon tools close to your assets and reports results to the OpenCTEM platform.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

352 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenCTEM Sensor

Open-source security scanning sensor for the OpenCTEM Continuous Threat Exposure Management (CTEM) platform. Formerly the OpenCTEM Agent: see Upgrading from the agent release.

License Go

Product documentation: docs.openctem.io.

Overview

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, the ghcr.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).

Supported tools

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.

Install

Container images

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.

Release archives

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 -version

The archive holds only the sensor; the scanners it runs must be installed on the host (openctemio-sensor -check-tools).

From source

git clone https://github.com/openctemio/sensor.git
cd sensor
make build        # or: go build -o openctemio-sensor .

Verifying images and releases

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.0

Release 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-missing

Images and archives published before signing was added carry no signature.

Run a sensor for the platform

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:latest

The 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-content caches scanner content (trivy DB, nuclei templates, semgrep rules). It can be deleted and is downloaded again.
  • Without API_URL the daemon exits with code 2 and names the missing setting. Without API_KEY it 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, /scan in the default image). Mount repositories there.
  • A dispatched scan starts within one heartbeat (see Heartbeat doorbell).

Pair the sensor

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 pair pairs 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 -repair replaces 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 exact chmod/chown to 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 set SENSOR_CA_FINGERPRINT, after a deliberate CA change. A sensor paired before this release is not pinned until it is paired again or sets SENSOR_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) and SENSOR_PLATFORM_KEY (the thumbprint of the platform's pairing key). Both are public values that stop a fake platform at first contact. With SENSOR_CA_FINGERPRINT, API_URL must name the platform by the host name in its certificate, not by an IP address (pair refuses an IP URL; the daemon warns in its config report).
  • Pairing also pins the platform job signer's keys the hello lists (job_signing_keys in identity.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.

Modes

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.yaml

In 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 -push

A one-shot run whose key is rejected exits with code 78 (EX_CONFIG); see Rejected key and connection failures.

Connecting to the platform

Which URL

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.

HTTPS with a private CA

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.

Through an HTTP proxy

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:// or socks5h://, with user:password@ when the proxy needs authentication. NO_PROXY is the bypass list. An https:// platform is reached through an HTTP proxy with a CONNECT tunnel, 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 same HTTP(S)_PROXY and NO_PROXY, and the sensor prints a warning at start: their traffic to targets then goes through the proxy unless NO_PROXY lists the target, which is rarely wanted for internal targets. Set SENSOR_SCAN_PROXY=direct so scanners connect directly (content downloads keep using the proxy), or SENSOR_SCAN_PROXY=inherit to 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 read SSL_CERT_FILE.

Configuration

Environment Variables

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 File (sensor.yaml)

-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/project

Heartbeat doorbell

With 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

API key renewal

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.

Tool sandbox

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, -config file 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.

Results delivery and the outbox

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-status

Upgrading: 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.

Rejected key and connection failures

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.

Setup & health (config report)

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.

Scanner content updates

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 default and nuclei images bake one nuclei-templates release, pinned in the Dockerfiles with its archive SHA-256 (NUCLEI_TEMPLATES_VERSION / NUCLEI_TEMPLATES_SHA256) and gated at build by scripts/nuclei-templates-bake.sh: any template that fails nuclei -validate with the pinned nuclei and is not in docker/nuclei-templates-allowlist.txt fails the build, and so does a scan run that logs an error or a release whose .nuclei-ignore stops excluding dos, local, fuzz, bruteforce or txt-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-ignore is the release's own exclusion list plus the baseline tags above.
  • Template classes the sensor does not enable for its own set (code, headless unless configured, file, self-contained) are skipped by nuclei, not errors.
  • Every finding carries template_digest (sha256: of the template file that matched) and template_path; every result carries the release (tool.properties.content: version and archive digest). A nuclei re-verification reports template_digest, templates_version and templates_digest in 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 set SENSOR_CONTENT_TRIVY_DB_REPOSITORY=registry.example.com/aquasec/trivy-db:2.
  • nuclei templates: put nuclei-templates-vX.Y.Z.tar.gz and the release's nuclei-templates-X.Y.Z_checksums.txt on an internal web server or a mounted directory, set SENSOR_CONTENT_NUCLEI_TEMPLATES_URL=file:///mirror/nuclei-templates-{version}.tar.gz, SENSOR_CONTENT_NUCLEI_TEMPLATES_CHECKSUMS_URL=file:///mirror/nuclei-templates-{bare_version}_checksums.txt and pin the version (policy or SENSOR_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.

Validation (CTEM Stage-4)

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 for validate commands, regardless of which scanners are enabled (runDaemon in main.go).
  • validate:nuclei — advertised when the vuln-scan (nuclei) image is present It re-runs a finding's own detection template non-destructively and returns detected / not_detected / inconclusive / error (internal/executor/validation.go RunNucleiValidate). If the template is not installed, the result is inconclusive — never a false downgrade.
  • retest:nuclei: advertised with validate:nuclei. The platform's retest command lists nuclei findings (the template id of each, and the address it is on). The nuclei-validate tool 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 is still_present. One whose template ran against the reachable address and did not match is fixed. Anything else is unverifiable, 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.allow must list retest when the policy lists check types), and every address passes the validate guard (no loopback, link-local or cloud-metadata target).

One job, then exit (Kubernetes Job)

-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:

  1. sets up as a daemon does: identity, manifest, tools, local policy, outbox, and heartbeats, which keep the job's lease and carry cancels;
  2. claims the command by id;
  3. runs it with every check a polled command gets (kill switch, served command types, expiry, local policy, the platform's tool policy);
  4. waits up to 5 minutes for the outbox to deliver the results;
  5. 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}

Tenable.sc connector

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).

Sensor-local policy

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 removed

Every 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.

Signed jobs

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_KEYS to the signer's key id and SENSOR_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.json job_signing_root), or the network owner sets SENSOR_JOB_SIGNING_ROOT. The sensor then accepts job signatures only from keys in the current key set (plus SENSOR_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 rule job_keyset. The sensor warns 7 days before the key set expires, and its posture reports jobs.root, jobs.keyset_version and jobs.keyset_expires_at.
  • Without a root, a new signer key is pinned by updating SENSOR_JOB_SIGNING_KEYS or by pairing again.
  • A sensor that requires signed jobs refuses every command of a platform that does not sign them.

Scanner safety model

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. IMDS 169.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). Set SENSOR_ALLOW_PRIVATE_TARGETS=1 to 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-force and default-login template 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).

Nuclei template trust and rate limits

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:

  1. The platform refuses, at upload, templates that use the code, javascript, headless or file protocol or are self-contained.
  2. 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_KEYS before parsing it, then refuses a manifest for another command (or another sensor, when SENSOR_ID is set), an expired one, and any template changed, added, held back or reordered. Without a pinned key, scans with custom templates fail.
  3. The sensor checks the templates itself again (CheckCustomTemplates): the same protocols and self-contained templates are refused.
  4. Custom templates run in their own nuclei run, with -exclude-type code,file,headless,javascript and 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.

Upgrading from the agent release

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).

Upgrading: gitleaks → Betterleaks

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 -gitleaks image is published from this release on; existing -gitleaks tags stay pullable and frozen.
  • The scanner is betterleaks (-tool betterleaks, SENSOR_TOOLS, scanners: - name: betterleaks). gitleaks in 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 dispatches gitleaks scans is handled the same way.
  • .gitleaks.toml custom rules keep working (betterleaks reads them; .betterleaks.toml is 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) to betterleaks at ingest and migrates scan configs and existing findings (API migration 000241).

Troubleshooting

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 as http://host.docker.internal:8080.
  • Tool not found: openctemio-sensor -check-tools names the missing binaries and how to install them; -install-tools installs them.
  • No findings: run the scanner with -verbose and 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).

Building

# Build for current platform
make build

# Build for all platforms
make build-all

# Run tests
make test

Contributing

See CONTRIBUTING.md.

Security

Report vulnerabilities privately; see SECURITY.md.

Related Projects

License

Apache License 2.0 - see LICENSE.

About

OpenCTEM Sensor: lightweight scanner runtime that runs Nuclei, Trivy, Semgrep, Betterleaks and recon tools close to your assets and reports results to the OpenCTEM platform.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages