Agentless threat detection and response for Linux, with an optional eBPF tier. The agentless scanner is an independent implementation. The eBPF sensor uses Tetragon for runtime telemetry.
See DESIGN.md for architecture and trust-boundary details.
- bladedr-probe — static, ephemeral collector run on the target. Carries a CEL
engine, evaluates rules against a
/procsnapshot, returns findings. Linux-only;--snapshot-filereplays a captured snapshot on any platform (tests, dev). - bladedr-server — inventory, scan orchestration, rule engine, REST API, web
console (auth + RBAC). In-memory store by default; Postgres + pg_search (BM25) for
production (
internal/store/migrations/). - bladedr-sensor — the eBPF tier (Phase 2). Thin Tetragon wrapper: loads the policies, maps hits to observations, posts them to the server. Deployed over SSH, runs as a systemd unit.
- bladectl — script-friendly API client:
login,hosts,scans,findings,responses. ReadsBLADEDR_SERVER_URLandBLADEDR_TOKEN.
Scanning runs over SSH: sealed credential store, SSHTransport in
internal/scan/ssh.go, host keys pinned on first use. Rules are YAML + CEL
(internal/rules/builtin/), overridable via BLADEDR_RULES_DIR or the API/UI.
Baseline/drift, fleet-rarity and a risk model sit on top of the raw observations;
ECS/JSON export feeds a SIEM.
make build # server, probe and CLI
make demo # scan against the bundled malicious snapshot
make build-probe-linux # cross-compile the static probe for LinuxManual startup:
BLADEDR_ADMIN_PASSWORD=dev-password \
BLADEDR_PROBE_BIN=./bin/bladedr-probe \
BLADEDR_PROBE_EXTRA="--snapshot-file testdata/malicious-snapshot.json" \
./bin/bladedr-serverIn another shell:
TOKEN=$(curl -fsS -X POST localhost:8080/api/v1/login \
-H 'content-type: application/json' \
-d '{"username":"admin","password":"dev-password"}' |
python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
AUTH="Authorization: Bearer $TOKEN"
HOST_ID=$(curl -fsS -H "$AUTH" -H 'content-type: application/json' \
-X POST localhost:8080/api/v1/hosts \
-d '{"hostname":"web-01","primary_ip":"10.0.0.5"}' |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -H "$AUTH" -X POST "localhost:8080/api/v1/hosts/$HOST_ID/scans"
curl -H "$AUTH" "localhost:8080/api/v1/observations?host=$HOST_ID"
curl -H "$AUTH" "localhost:8080/api/v1/observations?q=rootkit"In-memory by default. For Postgres, point BLADEDR_DATABASE_URL at a ParadeDB
instance — the server applies the embedded, versioned migrations on startup (tracked
in schema_migrations, so each runs once):
docker compose up -d
BLADEDR_DATABASE_URL=postgres://bladedr:bladedr@localhost:5432/bladedr ./bin/bladedr-serverBoth backends implement store.Store. Keyset pagination completeness, exclusive job
claims, lease reclamation, the response-action state machine and retention are
asserted once in internal/store/contract_test.go and run against both, so the
production backend is not the least-tested one. The Postgres pass needs a live
database; CI runs it and fails if it skips.
The suite TRUNCATEs every table between cases, so it needs a scratch database — not
the one above that you run the server against. It refuses any database whose name
doesn't contain test, because the two DSNs otherwise differ by nothing and wiping your
dev data is a silent, one-keystroke mistake:
docker compose up -d
docker compose exec db createdb -U bladedr bladedr_test # once
BLADEDR_TEST_DATABASE_URL=postgres://bladedr:bladedr@localhost:5432/bladedr_test \
go test ./internal/store/A case that fails on exactly one backend means that backend is wrong.
| var | default | meaning |
|---|---|---|
BLADEDR_ADDR |
:8080 |
listen address |
BLADEDR_PROBE_BIN |
bladedr-probe |
probe binary for LocalTransport |
BLADEDR_RULES_DIR |
(builtin) | load rules from a dir instead of embedded |
BLADEDR_PROBE_EXTRA |
– | extra probe args (dev/testing) |
BLADEDR_DATABASE_URL |
– | Postgres DSN; unset = in-memory |
BLADEDR_NODE_KEY |
(ephemeral) | base64 node key for credential sealing (-keygen mints one) |
BLADEDR_PROBE_LINUX_AMD64 |
– | linux/amd64 probe path (SSH scanning) |
BLADEDR_PROBE_LINUX_ARM64 |
– | linux/arm64 probe path (SSH scanning) |
BLADEDR_SENSOR_LINUX_AMD64 |
– | linux/amd64 sensor path for server-side deployment |
BLADEDR_SENSOR_LINUX_ARM64 |
– | linux/arm64 sensor path for server-side deployment |
BLADEDR_POLICY_DIR |
– | Tetragon policy directory used for sensor deployment |
BLADEDR_SERVER_URL |
– | control-plane URL reachable from deployed sensors |
BLADEDR_ADMIN_USER |
admin |
initial administrator username |
BLADEDR_ADMIN_PASSWORD |
(generated) | initial admin password |
BLADEDR_RESPONSE_ALLOW_SELF_APPROVAL |
false |
let one admin both request and approve a response action (see Response actions) |
BLADEDR_TLS_CERT |
– | PEM cert path; with _KEY serves HTTPS and auto-sets Secure cookies |
BLADEDR_TLS_KEY |
– | PEM private key path (pairs with BLADEDR_TLS_CERT) |
BLADEDR_SECURE_COOKIES |
false |
force the Secure flag on session cookies (implied by TLS) |
BLADEDR_TRUSTED_PROXY_CIDRS |
– | comma-separated proxy networks allowed to supply forwarding headers |
BLADEDR_LOG_FORMAT |
text |
json for structured logs (log aggregators) |
BLADEDR_LOG_LEVEL |
info |
debug to include health/metrics scrapes and detail |
BLADEDR_SCAN_WORKERS |
4 |
concurrent scan workers, range 1–128 |
BLADEDR_SCAN_TIMEOUT |
5m |
deadline for one queued scan |
BLADEDR_SCHEDULER_TICK |
30s |
schedule polling interval |
BLADEDR_EXPORT_WORKERS |
2 |
concurrent export workers, range 1–32 |
BLADEDR_RISK_DATASET |
poligon/dataset.jsonl |
labelled JSONL dataset; missing file disables lab samples |
BLADEDR_RISK_AUGMENT |
false |
enable deterministic class balancing for model training |
BLADEDR_RETENTION_OBSERVATIONS |
disabled | observation archive age; minimum 1h |
BLADEDR_RETENTION_SCANS |
disabled | scan archive age; minimum 1h |
BLADEDR_RETENTION_AUDIT |
disabled | audit archive age; minimum 1h |
BLADEDR_RETENTION_ARCHIVE |
disabled | archive deletion age; minimum 1h |
The server runs anywhere; scan targets are always Linux (over SSH).
docker run -p 127.0.0.1:8080:8080 \
-e BLADEDR_DATABASE_URL=postgres://… -e BLADEDR_NODE_KEY=… mar0ls/bladedr:0.9.0That is the whole agentless product: the image carries the probe for linux/amd64 and linux/arm64 and uploads it over SSH itself, and the ~85 builtin rules are compiled into the binary. Nothing needs to be cloned from here to scan a host. Two things do come from outside the image:
- A database. Without
BLADEDR_DATABASE_URLstate is in memory and gone on restart. ParadeDB, not stock Postgres — free-text search uses its BM25 index. - eBPF policies, if you want the sensor tier.
/etc/bladedr/policiesin the image is deliberately empty; mount your own bundle at it (see eBPF tier).
docs/compose.example.yml is a complete stack — control plane plus database — with the node key and database password required rather than defaulted, so it refuses to start half-configured instead of coming up with an example password guarding your sealed SSH credentials.
Pin the version. latest moves on the next release, and this is the component holding
SSH access to your fleet.
Running it for real (TLS, systemd, Postgres, backups, hardening): see docs/deployment.md.
Operational endpoints (unauthenticated, for probes/scrapers): GET /healthz
(liveness), GET /readyz (readiness — checks the store is reachable), GET /metrics
(Prometheus text: request counts by method/status + a latency summary).
The HTTP listener applies a 10-second header timeout, 30-second request-read timeout,
5-minute response-write timeout and 2-minute idle timeout.
Detection coverage vs the Linux ATT&CK / EDR-T matrix: COVERAGE.md.
Which parts are stable enough to build on — and which are Beta or experimental — is in docs/stability.md. Response actions and the eBPF tier are Beta; risk scoring is experimental.
Rules are data (YAML + CEL), not code — author detections without recompiling. The
active set merges three layers, later wins (a user rule can override or, with
enabled: false, disable a builtin of the same id):
- Builtin — embedded in the binary (
internal/rules/builtin/). - Filesystem —
BLADEDR_RULES_DIR=/pathfor versioned rule packs. - Database — POST a rule via API/UI; CEL-validated, active on the next scan.
curl -H "$AUTH" -X POST :8080/api/v1/rules --data-binary '
id: my-loader-watch
title: "Process named loader"
category: process
severity: medium
foreach: processes
when: '\''item.comm == "loader"'\''
evidence: { pid: item.pid, comm: item.comm }'
curl -H "$AUTH" :8080/api/v1/rules
curl -H "$AUTH" :8080/api/v1/rules/active
curl -H "$AUTH" -X PATCH :8080/api/v1/rules/my-loader-watch -d '{"enabled":false}'
curl -H "$AUTH" -X DELETE :8080/api/v1/rules/my-loader-watchA rule is an optional foreach collection (dotted paths like
persistence.systemd_units work), a CEL when, and evidence/dedup expressions.
Snapshot fields: processes, listening_sockets, persistence.* (cron,
systemd_units, authorized_keys, ld_preload), kernel_modules, suspicious_files,
accounts, kernel_log, pam_modules, pth_files, immutable_files,
hidden_modules, facts.*.
bladedr keeps a per-host baseline of stable state (listening ports, kernel modules,
accounts, authorized keys, cron, systemd units). The first scan sets it; later scans
diff against it and raise baseline-new-* for anything new — a new UID-0 account, a
new listener, a new key.
Fleet-rarity scoring complements it: an item present on a tiny fraction of the fleet
raises a low-severity fleet-rare-* lead. Both are deterministic, no training data.
curl -H "$AUTH" ":8080/api/v1/hosts/$HOST_ID/baseline"
curl -H "$AUTH" -X DELETE ":8080/api/v1/hosts/$HOST_ID/baseline" # reset; next scan re-establishes itA multinomial Naive Bayes model (internal/risk, pure Go) ranks open findings by how
likely you are to treat them as real. It trains on triage — acknowledged = real,
false_positive = noise (resolved is excluded, it's ambiguous) — using structural
features only (rule, category, severity, source, MITRE technique/tactic) plus coarse
evidence classes (path:tmp vs path:home, uid:root), never attacker-controlled
paths. So it learns techniques, not literal IOCs. It ranks; it does not detect.
/risk/stats cross-validates (stratified 5-fold) and reports ROC AUC, balanced accuracy,
precision, recall and Brier score — enough to tell you whether the labelled set is
big/balanced/separable enough to trust. Each class is loaded independently, up to 5k
recent observations, so a backlog of untriaged alerts can't crowd out the labels. Until
both classes exist, scoring falls back to the rule's static score.
It runs seven independent seeded passes and reports the mean and the spread, because a
single split is not a measurement. On the poligon dataset (157 positives, 10 negatives)
ROC AUC ranged 0.48 to 0.82 across seeds — below chance to respectable, from nothing but
how the folds fell. A single pass would have reported one of those and, at 0.81, called
the model trustworthy. So cv_roc_auc_stddev above 0.10 marks the figure untrustworthy
whatever the mean: that spread means the labelled set is too small or too uneven, not that
the model is good. Fold assignment used to follow the order rows came out of the database,
which made the metrics a property of insertion order.
BLADEDR_RISK_AUGMENT oversamples the minority class, but only when fitting the
scorer — the CV folds stay on real observations. Augmenting before the split would put
synthetic near-duplicates of a held-out row into training and quietly inflate every
number on that page.
Worth being honest about what the metrics mean: the labels are analyst decisions, and the analyst was looking at this model's ranking when they made them. So the numbers measure agreement with past triage, not ground truth, and a rule nobody ever triages contributes nothing either way. That's the reason ranking and detection stay separate — a badly calibrated model reorders your queue, it never makes a finding disappear.
A clean fleet yields few positives, so the model can also train on the attack-emulation
range (poligon/): make lab plants known techniques and writes
poligon/dataset.jsonl. Set BLADEDR_RISK_DATASET to mix them in; /risk/stats
reports the prod-vs-lab split.
curl -H "$AUTH" :8080/api/v1/risk/stats
curl -H "$AUTH" :8080/api/v1/risk/observationsContainment runs over the host's existing pinned SSH transport. There is no generic command endpoint: a request names one of four playbooks, and the server builds the command from validated parameters.
| Playbook | Effect | Parameters |
|---|---|---|
kill_process |
SIGTERM after confirming /proc/<pid>/exe still resolves to expected_exe |
pid, expected_exe (absolute) |
disable_systemd_unit |
systemctl disable --now <unit> |
unit (matching [A-Za-z0-9_.@-]{1,200}) |
isolate_host |
nftables table inet bladedr_response with a default-drop policy, keeping SSH and the control plane reachable |
control_plane_ip (literal), control_plane_port — either may be omitted and is taken from BLADEDR_SERVER_URL |
restore_network |
drops only the bladedr_response table |
– |
Actions are created pending, and dry_run unless dry_run:false is passed. Nothing
reaches the host until a second administrator approves: an approval whose approver
equals the requester is refused with 409 and recorded in the audit log. Rejection is
terminal and does not contact the host; the requester may withdraw their own request.
BLADEDR_RESPONSE_ALLOW_SELF_APPROVAL=true relaxes this for deployments with one
administrator, at the cost of making a single admin session sufficient to run
root-level commands fleet-wide. The server logs a warning at startup when it is set.
The expected_exe check is re-evaluated on the host at execution time, so a PID
recycled between triage and approval is not killed. isolate_host refuses a DNS name
for the control plane — from either the parameter or the server URL — because
resolution is the first thing to break under a default-drop policy, and a host that
can't reach the control plane is a host you've lost.
ACTION=$(curl -fsS -H "$AUTH" -H 'content-type: application/json' \
-X POST :8080/api/v1/responses \
-d "{\"host_id\":\"$HOST_ID\",\"playbook\":\"kill_process\",
\"params\":{\"pid\":\"4242\",\"expected_exe\":\"/usr/bin/xmrig\"},\"dry_run\":false}" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
# From a different admin account:
curl -H "$AUTH2" -X POST ":8080/api/v1/responses/$ACTION/approve"
curl -H "$AUTH2" -X POST ":8080/api/v1/responses/$ACTION/reject" \
-H 'content-type: application/json' -d '{"reason":"wrong host"}'
curl -H "$AUTH" ":8080/api/v1/responses/$ACTION"The same flow through bladectl:
bladectl responses request --host "$HOST_ID" --playbook kill_process \
--param pid=4242 --param expected_exe=/usr/bin/xmrig --execute
bladectl responses approve "$ACTION" # must be a different admin
bladectl responses reject --reason "wrong host" "$ACTION"Agentless scans see the artifact at rest; the sensor sees the act in real time (exec,
injection, container escape, fileless, C2). bladedr-sensor wraps
Tetragon: it loads TracingPolicies, consumes
Tetragon's JSON stream, maps each hit to an observation (severity/MITRE from the policy
annotations) and posts batches. These land in the same observations table
(source=ebpf_sensor), so they flow through triage, the risk model, the UI and export
unchanged. A host is scan_only or scan_plus_sensor.
You supply the policies. bladedr does not ship a bundle yet — one written and tested
against real kernels is planned for a later release. Until then, point
BLADEDR_POLICY_DIR at a directory of your own Tetragon TracingPolicy YAML:
# policies/exec-from-tmp.yaml — any *.yml or *.yaml in the directory is picked up
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: exec-from-tmp
spec:
kprobes:
- call: "security_bprm_check"
# …The sensor reads the policy's own metadata for severity and MITRE mapping, so what a hit looks like in bladedr is decided in the policy, not in bladedr. Without a policy directory the sensor has nothing to load and the tier is simply off; agentless scanning is unaffected.
make sensor
BLADEDR_TOKEN="$TOKEN" \
BLADEDR_SERVER=https://bladedr.example \
BLADEDR_POLICY_DIR=./policies \
scripts/deploy-sensor.sh user@host "$HOST_ID"
curl -H "$AUTH" ':8080/api/v1/observations?source=ebpf_sensor'Needs Docker and a BTF-capable kernel (Tetragon runs as a privileged container). All
detection logic lives in the policies; the sensor only forwards events. The
server-push path installs to /opt/bladedr and runs the sensor as a systemd unit
(Restart=always, token in a mode-0600 EnvironmentFile, never in argv).
Tetragon validates the whole bundle at load and aborts on any single failure, so a
policy referencing a kprobe symbol your kernel doesn't export takes the rest down with
it. deploy-sensor.sh works around this by checking each non-syscall symbol against
/proc/kallsyms on the target and setting aside the ones that won't load.
Console and API require auth. A fresh install creates an admin account (password from
BLADEDR_ADMIN_PASSWORD, or generated and printed once). Sign in at /ui/login; the
session works as a cookie (UI) or Authorization: Bearer <token> (API).
A password you didn't choose has to be replaced before the account can do anything else.
That covers a generated bootstrap password — it goes to the startup log, so it also lives
in scrollback and journald — plus any account an admin created or reset, where two people
know the password. Until it's changed, every route answers 403 except the change
itself, and the console redirects to /ui/password. A password you set through
BLADEDR_ADMIN_PASSWORD is already yours, so it isn't forced.
curl -H "$AUTH" -X POST :8080/api/v1/me/password \
-H 'content-type: application/json' \
-d '{"current_password":"…","new_password":"…"}' # 204, flag clearedRoles: admin (everything + users/credentials/response decisions), operator (read +
triage/scan/rules/sensor), viewer (read-only). Only admins create users and assign
roles. eBPF sensors authenticate machine-to-machine with a per-host token minted at
/api/v1/hosts/{id}/sensor-tokens — the server keeps only the digest, so the table is
useless to anyone who reads it. Security events (logins, user/role changes, sensor
deploys, RBAC denials, blocked self-approvals) go to an audit log — GET /api/v1/audit or the admin Audit page.
TOTP is optional per account, enrolled from the console (/api/v1/me/mfa/setup, then
/confirm with a code). The shared secret is sealed with the node key, so MFA only
works where BLADEDR_NODE_KEY is set. Turning MFA off needs the password and a
current code, so a hijacked session can't quietly strip it.
curl -X POST :8080/api/v1/login \
-H 'content-type: application/json' \
-d '{"username":"admin","password":"…"}'
curl -H "Authorization: Bearer <token>" :8080/api/v1/hostsCookies are HttpOnly + SameSite=Lax, and Secure once TLS is on. Failed logins
are rate-limited per source IP (a short lockout that backs off on repeated failures),
so online password guessing is bounded. An unknown username still costs a full bcrypt
verification, otherwise login timing would tell an attacker which accounts exist. Keep
.bladedr.env private (gitignored) — it holds the deployment's secrets.
Credentials are sealed with a Curve25519 key (NaCl sealed box); only the node key
(BLADEDR_NODE_KEY) decrypts them, so the DB never holds usable secrets. Secrets are
write-only via the API. SSH host keys are pinned on first use.
A scan opens an SSH session, drops a static probe in /tmp/.bladedr/ at mode 0700,
runs it, and leaves nothing behind. The probe reads a /proc snapshot, evaluates the
rules locally and prints JSON. Root widens what it can collect but isn't required.
Tetragon is the only thing that stays resident, and only on hosts you opt in.
The probe is cached under its sha256 so repeat scans skip the upload, which makes that
cache path worth guarding: /tmp is world-writable, so the staging dir is required to
be 0700 and owned by the scan account (checked without following symlinks), and a
cached binary is re-hashed before it runs. Anything that doesn't verify gets
re-uploaded instead of trusted.
The probe is cached under its content hash so scheduled scans skip the multi-MB
upload. On a host that may already be compromised that cache is a code-execution
path, and /tmp is world-writable, so two checks gate it:
- the staging directory must be mode
0700and owned by the scan account, checked with a non-dereferencingls -ldso a symlink at the path is rejected; - the cached binary is verified by SHA-256, not by size — a same-length substitute passes a size check. An entry that cannot be verified is re-uploaded, not trusted.
Without both, a local unprivileged user could pre-create /tmp/.bladedr, plant a
binary at the predictable path and have the next scan run it as the scan account.
The control plane holds SSH access to monitored hosts and must be treated as a
privileged system. Stored credentials are sealed with BLADEDR_NODE_KEY, which is
kept outside the database, and SSH host keys are pinned on first use. Use a
least-privilege SSH account when full /proc visibility is not required.
./bin/bladedr-server -keygen # mint BLADEDR_NODE_KEY
CREDENTIAL_ID=$(curl -fsS -H "$AUTH" -H 'content-type: application/json' \
-X POST :8080/api/v1/credentials \
-d '{"username":"root","auth_type":"ssh_key","secret":"<PEM>"}' |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
HOST_ID=$(curl -fsS -H "$AUTH" -H 'content-type: application/json' \
-X POST :8080/api/v1/hosts \
-d "{\"primary_ip\":\"10.0.0.5\",\"ssh_port\":22,\"credential_id\":\"$CREDENTIAL_ID\",\"arch\":\"amd64\"}" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -H "$AUTH" -X POST ":8080/api/v1/hosts/$HOST_ID/scans"


