The Ruso Book
Ruso is a vulnerability scanner driven by a library of small, shareable checks. Point it at a target and scan against community checks straight from the registry — no setup, no plugins to compile:
ruso scan --family web --target https://target.example.com
Need something bespoke? Write your own check in the Ruso Scripting Language (RSL) — a few readable lines describing a probe and what a positive result looks like. Either way, scanning for a known issue is usually a one-liner.
Development status: Ruso is under active development. The language, bytecode format, CLI, and APIs may change without notice. Not recommended for production use yet.
How it fits together
Ruso is intentionally not a monorepo. Each piece does one job and has a stable contract with its neighbours:
| Component | What it does |
|---|---|
RSL (ruso-script) | Parses .rsl source and compiles it to bytecode |
Runtime (ruso-runtime) | A small VM that executes the bytecode, runs probes, and emits findings |
CLI (ruso-cli) | The ruso binary — the driver you actually run |
| Ruso registry | The service you publish, install, and search shared checks against (hosted at ruso.hopeless-labs.com) |
Shared checks live in the registry — a growing library of ready-made .rsl
checks you can install and scan with, no authoring required.
The flow is a short pipeline:
check.rsl ──[ruso-script: parse + compile]──▶ bytecode (.rbc)
│
[ruso-runtime: VM executes]
│
▼
probes ⇄ target → finding
A source check (.rsl) compiles to bytecode (.rbc) — a compact, validated
binary that the runtime executes. You can run a check in one step (scan
compiles and runs), or split the steps (compile then exec) and ship the
.rbc without the source.
Who this book is for
- Want to run or write checks? Start with the User Guide and Writing Checks.
- Want to share checks? See The Registry.
- Want to hack on Ruso itself? Jump to Internals & Contributing.
A taste of RSL
metadata {
name "Exposed Redis (no auth)"
severity high
family "database"
version "1.0.0"
}
tcp redis {
host "{{scan_host}}"
port 6379
payload "PING\r\n"
}
send redis
match redis.response contains "PONG"
evidence redis regex 'redis_version:[0-9.]+'
Eight lines: declare what you're looking for, send a probe, decide what a hit looks like, and capture proof. The next chapters take you from install to your first real check.
Installation
Ruso is distributed as a single binary, ruso. While the crates are not yet on
crates.io, you install from source with Cargo.
Prerequisites
- Rust (stable) and Cargo — install via rustup.
- A C toolchain (for building native TLS/crypto dependencies). On Debian/Ubuntu:
sudo apt install build-essential pkg-config libssl-dev.
Install with Cargo (recommended)
Install the ruso binary straight from the repository:
cargo install --git https://github.com/Hopeless-Labs/ruso-cli.git
Cargo resolves the ruso-runtime and ruso-script dependencies automatically.
The binary lands in ~/.cargo/bin/ruso (make sure that's on your PATH).
Build from a checkout
If you want to hack on the CLI, clone it and build:
git clone https://github.com/Hopeless-Labs/ruso-cli.git
cd ruso-cli
cargo build --release
# binary at ./target/release/ruso
To work against local checkouts of the runtime and language crates, clone them
as siblings — the paths override in .cargo/config.toml picks them up
automatically:
parent/
├── ruso-cli/
├── ruso-runtime/
└── ruso-script/
When the siblings are absent, Cargo falls back to the git dependencies.
Verify
ruso --help
ruso --version
You should see the command list (scan, validate, compile, exec, plus the
registry commands). Next: Quick Start.
Configuration locations
Ruso keeps two things on disk:
| Path | Purpose |
|---|---|
~/.ruso/scripts/<ns>/<name>/<version>.rbc | Local install cache for registry checks. Override the root with $RUSO_HOME. |
$XDG_CONFIG_HOME/ruso/credentials.json | Registry credentials, per registry URL (mode 0600 on Unix). |
Neither is created until you install a check or log in to a registry.
Quick Start
Ruso scans a target against a library of ready-made checks. No checks to write, no config to fill in — install it, point it at something you're allowed to test, and go. This page gets you from install to your first finding in about a minute.
Scan only what you're authorized to test. Run Ruso against your own systems or targets you have explicit permission to assess. Unauthorised scanning may be illegal.
1. Install
cargo install --git https://github.com/Hopeless-Labs/ruso-cli.git
That's the whole setup. (Prerequisites and other methods: Installation.)
2. Run your first scan
Point Ruso at a target and scan it against an entire family of community checks — in one command:
ruso scan --family web --target https://target.example.com
Ruso pulls every published web check from the registry, runs them against your
target, and prints a finding for each hit — with severity and the evidence that
proves it. The default registry is the hosted one, so there's nothing to
configure.
Other families to try: auth, database, dns, network, tls, cloud,
mail.
3. Search for a specific check
Looking for a known issue? Search the registry:
ruso search log4j --tag rce
ruso search --family database --severity high
Each result shows a <namespace>/<name> reference you can run directly.
4. Run one check by name
ruso scan --script someuser/log4shell --target https://target.example.com -v
A registry reference is fetched and cached on first use, then reused. -v shows
the per-probe detail and the evidence behind each finding.
5. Read the result
| Verdict | Meaning |
|---|---|
| detected | The check matched — a finding was emitted (with evidence). |
| not detected | The target didn't meet the check's conditions. |
| skipped | A required port was already seen closed in this run. |
| error | A precondition (assert) or probe failed. |
Add -v / -vv for the detail behind any verdict.
Where to go next
- Want to write your own check? Write Your Own Script builds one from scratch in a few lines.
- Curious how it works? Core Concepts gives you the mental model in five minutes.
- Want to share checks? See Publishing & Installing.
Core Concepts
A handful of ideas explain almost everything in Ruso. Once these click, the Language Reference reads like a lookup table.
Checks
A check is one .rsl file. It describes a single security question — "is
this Redis exposed?", "does this server leak its version?" — and what a yes
looks like. Checks are deliberately small: declare metadata, define probes, send
them, and decide whether the responses constitute a finding.
Probes
A probe is a network request you define but don't send yet. Ruso has four kinds:
| Probe | Transport | Typical use |
|---|---|---|
http | HTTP/HTTPS | Web apps, APIs, headers, status codes |
tcp | Raw TCP (optionally TLS) | Banners, line protocols (Redis, SMTP…) |
udp | Raw UDP | NTP, DNS wire, echo |
dns | OS resolver or DNS wire | Name resolution, record lookups |
Defining a probe (http home { … }) only describes it. Nothing hits the network
until you send it.
Send, then match
Execution is a short script of statements:
send home # now the request goes out
match home.status == 200 # inspect the response
match home.body contains "admin"
send performs the request and stores the response under the probe's name.
match inspects a field of that response (status, body, header(...),
response, answer, …) with a predicate (==, contains, regex, …).
The match chain
All the match statements in a run form a single chain of AND-ed
conditions. The moment one fails, the chain latches false and later
match/evidence statements short-circuit — so a check "detects" only when
every match held.
assert is the strict cousin: instead of latching the chain false, a failed
assert aborts the run with an error. Use it for hard preconditions ("must
be 200 before we go on"); use match for the actual finding logic. See
match vs assert.
Findings and detected
When a check finishes with the chain still true — and it has a name or
report — the runtime emits a finding: the metadata plus any captured
evidence. The CLI reports this as detected. stop ends a run with no
finding; exit ends it and emits the finding if the chain held.
Source vs bytecode
You write source (.rsl); the runtime executes bytecode (.rbc).
Compiling is explicit (compile) or implicit (scan does it for you). Bytecode
is a compact binary with a validated structure — it's what the registry stores
and ships, and what exec runs directly. See
Bytecode Format for the wire details.
Targets and --target
The CLI's --target sets the base a check runs against. For HTTP probes it
becomes the base URL (path is appended). For socket probes it populates the
scan_host / scan_port / scan_url variables you interpolate into the probe:
tcp redis { host "{{scan_host}}" port 6379 }
This is the single most common footgun — HTTP reads the base URL, sockets read
host from the script. See the footguns list.
Families and tags
Metadata carries two kinds of labels:
tags— many per check, free-form discovery labels ("rce","log4j").family— a single structural category (web,database,tls, …), the unit for "scan everything in this group" (ruso scan --family web).
The language accepts any string; the registry enforces a curated family set at publish time.
The registry
The Ruso registry is where checks are published, searched,
and installed. A check is addressed as <namespace>/<name>[@<version-range>].
Namespaces are your username; versions are SemVer. Installed checks land in the
local cache and are reused across runs. See The Registry.
Write Your Own Script
This chapter builds a real HTTP check from scratch, one piece at a time. By the end you'll understand every line — and be ready to use the Language Reference as a lookup table.
Our goal: detect a web server that leaks its software version in the Server
header (an information-disclosure finding).
1. Start with metadata
Every check opens with a metadata { } block. It describes the finding — not
the logic. Only name (or report) is strictly required for a check that emits
findings; the rest makes the result useful and the check publishable.
metadata {
name "HTTP server version disclosure"
description "Flags servers that reveal their exact version in the Server header."
impact "Version strings let attackers target known CVEs for that exact build."
severity low
author "you"
cwe ["CWE-200"]
tags ["disclosure", "http", "headers"]
family "web"
version "1.0.0"
}
2. Define a probe
A probe describes a request without sending it. For HTTP, the host comes from
--target; you supply the path and options:
http home {
method GET
path "/"
timeout 10s
follow_redirect false
user_agent "ruso/1.0"
}
Keywords are case-insensitive — GET and get are the same.
3. Send it
send performs the request and stores the response under the probe's name
(home):
send home
4. Decide what a finding looks like
Now inspect the response. We want a 200 OK and a Server header that
contains a version number. Two match statements, AND-ed together by the
match chain:
match home.status == 200
match home.header("Server") regex '[0-9]+\.[0-9]+'
If either fails, the chain latches false and the check won't detect.
5. Capture proof
A finding is far more useful with evidence — the exact string that proves it.
evidence only attaches while the chain is still true:
evidence home regex 'Server:[^\r\n]+'
The complete check
metadata {
name "HTTP server version disclosure"
description "Flags servers that reveal their exact version in the Server header."
impact "Version strings let attackers target known CVEs for that exact build."
severity low
author "you"
cwe ["CWE-200"]
tags ["disclosure", "http", "headers"]
family "web"
version "1.0.0"
}
http home {
method GET
path "/"
timeout 10s
follow_redirect false
user_agent "ruso/1.0"
}
send home
match home.status == 200
match home.header("Server") regex '[0-9]+\.[0-9]+'
evidence home regex 'Server:[^\r\n]+'
Run it
ruso validate --script version-disclosure.rsl # compiles?
ruso scan --script version-disclosure.rsl --target https://example.com -v
A server that returns Server: nginx/1.25.3 detects; one that sends a bare
Server: nginx (or strips the header) does not.
What to learn next
- Other transports — swap
httpfortcp/udp/dns. See Socket probes. - Multiple steps — send several probes, use
if,for,extract/save. - Prove it works — Testing Your Checks shows how to verify a check against both vulnerable and safe targets before you trust it.
Ruso Scripting Language (RSL) reference
Scripts use the .rsl extension. Syntax is line-oriented statements; blocks use keyword name { … } with end closing if, match all, match any, and for.
Keywords are case-insensitive (HTTP, http, Send are equivalent).
File structure
Typical check layout:
metadata {
name "Check title"
description "What this check does"
impact "Risk if positive"
severity high
author "team"
cve ["CVE-2024-1234"]
cwe ["CWE-79"]
references ["https://example.com/advisory"]
cvss_score 9.8
cvss "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
mitigation "Apply security patch"
tags ["auth", "rce", "log4j"]
family "web"
version "1.2.3"
}
# Probes (definitions only — no network yet)
http home { … }
tcp svc { … }
# Logic
send home
match home.status == 200
evidence home regex 'secret'
Comments start with #.
Metadata
All finding metadata lives in a single metadata { … } block at the top of the script (before probe definitions). cve, cwe, references, and tags are list literals; the other metadata fields keep their existing scalar / repeatable forms.
| Statement | Example (inside metadata { }) |
|---|---|
name | name "Open Redis" |
description | description "…" |
impact | impact "…" |
severity | severity low | medium | high | critical | info |
author | author "ruso-lab" |
report | report "Report title override" |
cve | cve ["CVE-2024-1234", "CVE-2024-5678"] |
cwe | cwe ["CWE-79"] |
references | references ["https://…", "https://…"] |
cvss | cvss "CVSS:3.1/…" full vector string (repeat to list multiple) |
cvss_score | cvss_score 9.8 numeric score literal (repeat to list multiple) |
mitigation | mitigation "…" single free-text remediation note (declaring it more than once is a compile error) |
tags | tags ["auth", "rce", "log4j"] free-form discovery labels |
family | family "web" single curated category (see below) |
version | version "1.2.3" SemVer string; required at publish time, optional for local validate/compile |
cve, cwe, references, and tags stay stored as Vec<String> in metadata, findings, and reports. Use cvss for vectors and cvss_score for scores (e.g. base + temporal). Tags are unconstrained at the RSL level — downstream registries are free to enforce their own slug rules and per-script caps at publish time. version is a single optional string; repeated declarations take the last value. The registry rejects publishes without it.
family vs tags: tags are many-per-script, free-form discovery labels; family is a single structural category for "scan everything in this group" selection (à la Nessus/OpenVAS plugin families). The RSL accepts any string and stores the last-declared value; the registry enforces a curated set at publish time (currently auth, cloud, database, dns, mail, misc, network, tls, web) and rejects anything outside it. family is optional — omit it for uncategorised scripts.
Variables
set token "abc123"
set hosts ["a.example", "b.example"]
set accepts either a string or a string list. String values support "{{ variable }}" interpolation in places where the grammar allows quoted strings.
Scan target variables (from CLI --target)
Before your script runs, the executor sets (when --target is a valid URL):
| Variable | Example value |
|---|---|
scan_host | example.com |
scan_port | 443 |
scan_url | https://example.com |
Use in socket probes: host "{{scan_host}}". HTTP probes still use base_url from --target; they do not read host from the probe block.
HTTP probe
http <name> {
method get | post | put | patch | delete | head | options
path "/api/health"
timeout 30s
follow_redirect true
verify_ssl true # optional; overrides the runtime default (`true`).
# Set `false` only to scan targets with self-signed
# certs you explicitly trust.
proxy "http://127.0.0.1:8080"
user_agent "ruso/1.0"
header "X-Custom" "value"
cookie "session" "id"
query "q" "search"
data { key "value" }
json { key "value" }
raw 'body.*pattern'
body_bytes "504b0304"
multipart { … }
}
HTTP requests use ExecutorConfig.base_url from the CLI --target (scheme + host + optional port). Probe path is appended to that base.
path may contain {{ var }} placeholders. An interpolation that expands
the relative path into an absolute URL (http://… / https://…) is
rejected at runtime as an SSRF guard — extracted values cannot redirect
later probes to internal services. Scripts that intentionally hit a
separate origin should write the absolute URL directly in path; that
literal form is honoured.
cookie lines in one HTTP block are emitted as a single Cookie: request
header joined by "; " (RFC 6265 §5.4). Multiple header lines remain
distinct request headers.
Socket probes (dns / tcp / udp)
Same fields for all three keywords:
tcp | udp | dns <name> {
host "127.0.0.1" # required
port 6379 # optional (required at runtime for tcp/udp)
payload "text" # optional UTF-8 string
payload "aabbccdd" # optional hex (quoted hex digits)
tls true # TCP only: TLS before app data
session true # keep connection for repeated send
read_max 65536 # max bytes per read phase (default 65536)
read_idle 200ms # multi-read until idle (0 = single read)
}
DNS modes
| Configuration | Behavior | Match on |
|---|---|---|
host only | OS DNS resolver | .answer |
host + port and/or payload | UDP wire format (default port 53) | .response / .banner |
Do not use .answer on wire-mode probes or .response on resolver-only probes.
Payload encoding
- String — sent as UTF-8 bytes (Redis RESP, SMTP text, …).
- Hex literal —
payload "010203ff"decodes to raw bytes (DNS queries, NTP, …).
Send
send <probe_name>
send <probe_name> payload "next message"
send <probe_name> payload "deadbeef"
- First
sendon asession trueprobe opens the connection. - Later
sendreuses the socket; withsession true, response data is appended to the stored socket response (matchers see the full dialog). - Without
session, eachsendreplaces the stored response for that probe name. payloadonsendoverrides the probe definition for that step only.
Matching
Single matcher:
match <probe>.<field> <predicate>
assert <probe>.<field> <predicate>
Groups:
match all
home.status == 200
home.body contains "ok"
end
match any
home.status == 403
home.status == 401
end
HTTP fields
| Field | Example |
|---|---|
status | match home.status == 200 |
body | match home.body contains "admin" |
header("Name") | match home.header("Server") contains "nginx" |
response_time | match home.response_time < 500ms |
response_size | match home.response_size > 100 |
DNS resolver fields
| Field | Example |
|---|---|
answer | match lookup.answer contains "1.1.1.1" |
Socket fields (tcp / udp / wire dns)
| Field | Example |
|---|---|
response | match redis.response contains "PONG" |
banner | alias for response |
Predicates
| Form | Example |
|---|---|
| Compare | ==, !=, <, >, <=, >= with number, string, or duration |
| Contains | contains "text" |
| Not contains | not_contains "text" |
| Regex | regex 'pattern' (Rust regex syntax) |
If any match / match all fails, the match chain latches false (later match / match all / assert / evidence short-circuit until if runs its own branch).
match vs assert
match | assert | |
|---|---|---|
| On failure | Sets match chain to false; run continues | Aborts the run with an error |
| When chain already false | Skipped (no-op) | Skipped (no-op) |
| Use for | Positive finding logic | Hard precondition (“must be 200 before we continue”) |
Conditionals
if home.status == 200
match home.body contains "secret"
end
Compiled to IfMatch — skips body when chain already failed or condition false.
Loops
for host in ["a.example", "b.example"]
set current_host "{{ host }}"
send dialog
match dialog.response contains "PONG"
break
end
for item in ["a", "b"]— iterate a literal string list.for item in hosts— iterate a list variable created byset hosts ["a", "b"].break— jump to instruction after the loop.continue— skip to the next iteration of the currentfor.
There is no while; looping is for item in <list>. The old fixed-count
repeat N … end was removed from the grammar and the VM — use for to
iterate, or retry <probe> <n> to re-send a probe. repeat is no longer valid
syntax and fails to parse.
Extract and save
extract token from home.header("Set-Cookie") regex 'session=([^;]+)'
save home as cached
extract is HTTP-only (body or header). save copies an existing probe response to another name — it does not send again; match cached.body is a snapshot from when save ran.
Evidence
Attach proof strings to the finding (only when the match chain is still true). Requires name or report metadata if the script uses match / evidence (compile-time check).
evidence home.body
evidence home.response
evidence home regex 'PASSWORD='
evidence redis_ping regex 'PONG'
| Form | Meaning |
|---|---|
evidence <probe>.body | HTTP only — response body (max 500 chars) |
evidence <probe>.response | Full response text: HTTP body, joined DNS answers, or socket data (max 500 chars) |
evidence <probe> regex '…' | Regex on that probe only; capture group 1 or full match |
<probe> must already have been send in this run. Regex uses Rust syntax; mismatch fails the run.
Evidence is attached when the script finishes with a finding (name or report set, match chain true, and not stopped — see flow control).
Retry and sleep
retry_delay 2s
retry home 3
sleep 1s
retry home 3 re-sends a probe up to N times, stopping on the first success,
waiting retry_delay between attempts — author-controlled re-send logic.
This is distinct from the runtime's automatic transport retry (the CLI
--retries, default 2), which transparently re-tries a probe that fails with a
transient connection error (reset, connect/read timeout). The two do not
stack: a probe driven by a retry directive opts out of the automatic transport
retry, so the script's count is the sole authority for that probe.
Flow control
| Statement | Effect |
|---|---|
stop | Stop script; no finding emitted (even if matchers passed) |
exit | Stop script; emit finding if matchers passed and name/report set |
fail | Abort with error |
continue | Skip to the next iteration of the current loop |
Scripts with match / evidence must include name "…" or report "…" or compilation fails.
Duration literals
Supported suffixes — ms, s, m, h, d:
| Literal | Meaning |
|---|---|
200ms | 200 milliseconds |
30s | 30 seconds |
5m | 5 minutes |
1h | 1 hour |
1d | 1 day |
Used in timeout, read_idle, sleep, retry_delay, and comparisons.
Earlier revisions accepted only ms and s; m / h / d were added
so long-running auth flows and scheduled retries no longer need to be
written as awkward second counts.
Common footguns
--targetvs sockethost— HTTP uses--targetas base URL; TCP/UDP/DNS wire usehostin the script (preferhost "{{scan_host}}").- DNS resolver vs wire — different match fields (
.answervs.response). evidence home.bodyon a TCP probe — use.responseorevidence home regex.detectedin CLI — requires a finding (name/report+ matchers passed + notstop).- Port cache (30s) —
skippedis per script run when a required port was already seen closed in thisrusoprocess. session true— socket responses accumulate acrosssendin a loop.- Nesting depth — blocks/objects may nest at most 64 levels deep; deeper scripts are rejected at parse time (a guard against parser stack overflow). Real checks never approach this.
Grammar source
Authoritative syntax: ruso-script/src/script/grammar.pest.
After grammar changes, regenerate is not required (Pest compiles at build time).
Example scripts
Scripts live in examples/ in the ruso-script repository:
two runnable checks per protocol (HTTP, DNS, TCP, UDP). Every example has been
verified against a local Docker target.
Install or build ruso-cli, then from a clone of ruso-script:
ruso validate --script examples/http_status_ok.rsl # syntax + compile, no network
ruso scan --script examples/http_status_ok.rsl --target http://127.0.0.1:8080
Socket examples (dns/tcp/udp) take the host from --target via
{{scan_host}}; the port is the literal in the probe block. HTTP examples use
--target as the base URL.
HTTP
http_status_ok.rsl
Purpose: Endpoint availability + content check.
Concepts: http probe, send, match on status / body / header, evidence.
Run: ruso scan --script examples/http_status_ok.rsl --target http://127.0.0.1:8080
http_server_version_disclosure.rsl
Purpose: Flag a Server header that leaks the product version (info disclosure).
Concepts: HEAD request, match … header "Server" regex 'nginx/[0-9]+\.[0-9]+'.
Note: Detects nginx/1.31.1; stays quiet when server_tokens off yields a bare nginx.
DNS (wire mode over UDP)
dns_wire_a.rsl
Purpose: Raw A query, confirm the server answers.
Concepts: dns with host / port 53 / hex payload bytes; match wire_a.response contains "ruso" (the queried labels echo back in the response).
dns_wire_txt.rsl
Purpose: Read a TXT record's plaintext value (TXT often carries tokens/secrets).
Concepts: Same shape as dns_wire_a with QTYPE TXT; match … contains "ruso-dns-ok" (TXT rdata is ASCII).
TCP
tcp_redis_unauth.rsl
Purpose: Detect unauthenticated Redis via RESP PING → PONG.
Concepts: payload bytes "<hex>" for the RESP *1\r\n$4\r\nPING\r\n frame (text payloads are sent verbatim, so control bytes must be hex), read_idle, match … contains "PONG" + not_contains "NOAUTH", evidence.
tcp_http_banner.rsl
Purpose: Banner-grab a text protocol over a raw TCP socket.
Concepts: tcp probe sending a hex-encoded HEAD / HTTP/1.0 request, match … contains "HTTP/1." and "Server:".
UDP
udp_ntp.rsl
Purpose: Confirm an NTP daemon replies (reflection/amplification exposure class).
Concepts: udp + port 123 + a 48-byte client packet (payload bytes "1b00…"), match ntp.response regex '^\x1c' (server-mode reply byte).
udp_echo.rsl
Purpose: Generic UDP request/response.
Concepts: Text payload, match echo.response contains "RUSO-PING".
Mapping examples to scanner patterns
| Pattern | Example |
|---|---|
| Web availability / content | http_status_ok.rsl |
| Header / version disclosure | http_server_version_disclosure.rsl |
| DNS recon (wire) | dns_wire_a.rsl, dns_wire_txt.rsl |
| Cleartext protocol test | tcp_redis_unauth.rsl |
| Service fingerprint / banner | tcp_http_banner.rsl |
| UDP service probe | udp_ntp.rsl, udp_echo.rsl |
Writing your own
- Copy the closest example.
- Change metadata for your finding (
name,severity,cve [...],cwe [...],references [...],cvss,cvss_score, a singlemitigation, …). - Adjust
host/port/payload(usepayload bytes "<hex>"for control/binary bytes) or the HTTPpath. - Tighten matchers to reduce false positives.
- Add
evidencefor the report body.
See RSL reference for full syntax.
Testing Your Checks
A check is only worth publishing if you've proven it does two things:
- Detects the vulnerable / exposed condition it targets.
- Stays quiet against a safe, patched, or hardened target.
A check that only ever fires (or never fires) is worse than no check — it erodes trust in every result. Prove both directions before you ship.
Use disposable Docker targets
The cleanest way to get a known-vulnerable and a known-safe target is a
throwaway container. Don't test against a host python -m http.server or random
internet hosts — you want a target whose state you fully control.
Example: the unauthenticated-Redis check
Vulnerable target — Redis with no password:
docker run --rm -d -p 6379:6379 --name redis-vuln redis:7-alpine
ruso scan --script redis.rsl --target tcp://127.0.0.1:6379 -v
# expect: detected
docker rm -f redis-vuln
Safe target — same image, password required:
docker run --rm -d -p 6379:6379 --name redis-safe \
redis:7-alpine redis-server --requirepass secret
ruso scan --script redis.rsl --target tcp://127.0.0.1:6379 -v
# expect: not detected (PING is refused without AUTH)
docker rm -f redis-safe
If the check detects in the first case and stays quiet in the second, it's doing real work — not just pattern-matching the presence of the service.
Tips
- Prefer small, official images (
*-alpine, official upstream tags) so pulls are fast. - Clean up containers (and pulled images, if you won't reuse them) after testing.
- For HTTP checks, many products ship a vulnerable demo image; otherwise toggle the relevant setting (auth on/off, header present/absent) to create the "safe" variant from the same image.
The fast inner loop
While iterating, lean on the cheap commands first:
ruso validate --script check.rsl # syntax + compile, no network
ruso scan --script check.rsl --target <vuln> -v # should detect
ruso scan --script check.rsl --target <safe> -v # should NOT detect
validate catches every parse/compile error without touching the network, so
run it on every edit. Only move to scan once it compiles.
Reading the result
detected— the match chain held to the end and a finding was emitted (with your evidence).not detected— at least onematchfailed, or the run hitstop.skipped— a required port was already seen closed earlier in thisrusoprocess (a 30-second per-run port cache).- error — an
assertfailed, afailran, or a probe errored (e.g. anevidenceregex that didn't match).
Run with -v (or -vv) to see the per-probe detail behind the verdict.
Publishing checked work
Checks shared through the registry are expected to be proven this way. Once your check passes both the vulnerable and safe cases, see Publishing & Installing.
CLI (ruso)
Binary name: ruso. Fifteen commands across two groups:
Local (no network):
| Command | Purpose |
|---|---|
scan | Parse, compile, and run .rsl against targets |
validate | Check .rsl syntax |
compile | Write hex-encoded bytecode to <script>.rbc (silent on success) |
exec | Run .rbc bytecode against targets |
Registry (talks to the Ruso registry):
| Command | Purpose |
|---|---|
login | Save a PAT or session token for the active registry |
logout | Delete the stored credential |
whoami | Show the user the stored credential belongs to |
publish | Upload a .rsl script (under your own namespace) |
install | Download <ns>/<name>[@<range>] into the local cache |
search | Search published scripts |
info | Show registry metadata for a script (versions, install, tags, family) |
yank / unyank | Pull / restore a published version (owner, idempotent) |
edit | Update description / visibility of a script you own |
pat list/create/revoke | Manage personal access tokens |
Plus: scan accepts --script <ns>/<name>[@<range>] (single registry ref) or --family <name> (every installed/published script in a curated family); exec accepts the same ref form in --bytecode. Refs resolve through the local cache, auto-installing on miss.
Build
cargo build --release
./target/release/ruso --help
Global flags
| Flag | Effect |
|---|---|
-q / --quiet | Less logging |
-v / --verbose | More logging; live per-run status lines ([SEVERITY] …, [OK], [SKIP], [ERROR]) during scan/exec |
RUST_LOG | Overrides default filter |
Registry URL resolution
Every command that talks to a backend resolves the registry base URL in this order:
--registry <URL>flag on the commandRUSO_REGISTRY_URLenvironment variable- Built-in default
https://ruso.hopeless-labs.com(the hosted registry; usehttp://127.0.0.1:8080to point at a local registry instance)
Credentials are stored per registry base URL in
$XDG_CONFIG_HOME/ruso/credentials.json (Linux/macOS) or
%APPDATA%\ruso\credentials.json (Windows), mode 0600 on Unix. The
same machine can be logged into multiple registries at once.
Registry refs
A registry ref is a string of the form <namespace>/<name>[@<range>]:
<namespace>and<name>follow the slug rule^[a-z0-9][a-z0-9-]{0,38}$.<range>is an optional SemVer range like^1.2,>=0.3,<0.5, or=1.0.0.
Refs are accepted wherever the CLI takes a script/bytecode path:
ruso install <ref>…ruso scan --script <ref>/ruso exec --bytecode <ref>
Resolution rule for scan / exec:
- If the argument exists on the filesystem → treat as a path.
(Local files always win, so a directory named
myorg/checkstill works.) - Else if it parses as a registry ref → resolve through the install
cache (
$RUSO_HOMEor$HOME/.ruso/scripts/<ns>/<name>/<version>.rbc), downloading from the registry on cache miss. - Else → error.
validate
ruso validate --script check.rsl
ruso validate --script ./checks/
- File must be
.rsl; directory collects*.rslrecursively. - Exit
0if all valid; errors on stderr only. - No stdout on success.
compile
ruso compile --script check.rsl
ruso compile --script ./checks/
- Writes lowercase hex of the RUSO v1 bytecode to
check.rbcbesidecheck.rsl(ASCII text, not raw binary). - No stdout on success.
execdecodes hex from.rbcbefore running (legacy raw-binary.rbcwithRUSOheader still works).- While the runtime is
0.1.0-devthe v1 wire format may change between commits without a version bump — recompile your.rbcfiles after each upgrade.
exec
ruso exec --bytecode check.rbc --target https://example.com
ruso exec --bytecode ./built/ --target targets.txt -v
# Registry ref — auto-fetches if not cached.
ruso exec --bytecode myorg/log4shell@^0.2 --target https://lab.local
| Flag | Description |
|---|---|
--bytecode | .rbc file, directory of .rbc files, or registry ref <ns>/<name>[@<range>] |
--target | URL (http(s)://…), bare host/IP/domain (127.0.0.1, db.internal:5432, [::1]:9000), or a file with one target per line |
--registry <URL> | Override the registry base URL (only consulted for ref inputs) |
--timeout | Default 30s |
--read-timeout | Per-read I/O timeout for socket probes (default 10s) |
--max-response-bytes | HTTP body cap (default 10 MiB) |
--no-follow-redirects | HTTP |
--insecure | Disable TLS certificate verification. Defaults to off (TLS is verified); opt-in only for environments where you accept MITM and finding-injection risk. Emits a runtime warning when active. If a scan run fails because a target's certificate did not verify, a one-shot hint suggests --insecure (covers bare hosts and explicit https:// alike). HTTP verify_ssl in the script still overrides per probe |
--proxy | HTTP proxy |
--retries | Auto-retry an HTTP probe that fails with a transient transport error — connection reset, connect/read timeout — up to N times (default 2; 0 disables). A received HTTP response (any status) and a TLS-certificate rejection are never retried. A probe with its own retry directive opts out — the script's count wins. Helpful against CDN/edge resets under bursty scans. |
--script-timeout | Per-script wall-clock budget (default 5m) |
--concurrency | Parallel (target × script) runs (default 16) |
--max-per-host | Cap concurrent in-flight scans against a single host (default 0 = disabled; only --concurrency applies). Prevents a high -c from piling many connections onto one sensitive target while still allowing wide parallelism across distinct hosts. |
--rps | Cap on how often a new script run may start, scripts per second (default 0 = disabled). Coarse safety cap at the orchestrator: a running script can still send many probes. |
--output | human, json, csv |
--report | Required for json/csv |
Migration note: the previous
--verify-tlsflag (a positive opt-in for verification, disabled by default) is gone. Verification is now the default; pass--insecureto restore the old behaviour.
Port checks (ruso-runtime)
Before each script run, ruso-runtime TCP-probes required ports and caches host:port → open/closed for 30 seconds (one ruso process, shared cache).
Endpoints:
- Socket probes:
host+portfromtcp/udp/ wire-modedns - HTTP checks:
host+ port from--target(e.g.https://example.com→example.com:443)
skipped means this script run did not execute because a required port was closed (often from cache after an earlier script hit the same port). The scan continues with other scripts that use different ports. Example: three scripts on port 443 — first run finds 443 closed and caches it; the other two are skipped for that target; a script on port 22 still runs.
scan
ruso scan --script check.rsl --target https://example.com
ruso scan --script ./checks/ --target targets.txt --output json --report out.json
# Registry ref — auto-fetches if not cached.
ruso scan --script myorg/log4shell@^0.2 --target https://lab.local -v
Same target/timeout/TLS/report/port-cache flags as exec, but runs .rsl source directly (no .rbc file). Each local script is parsed and compiled once, then bytecode is reused for every target. Registry-ref inputs skip the compile step — they are served as already-compiled bytecode from the cache and go through the same decode-and-run path as exec.
--script also accepts a --registry <URL> override for ref resolution.
Scan-only flags (in addition to the shared ones above):
| Flag | Description |
|---|---|
--family <name> | Scan every published script in a registry family (mutually exclusive with --script) |
--default-scheme <https|http> | Scheme for a bare-host --target when the probe is disabled or nothing answers (default https). See Scan target and socket checks. |
--no-scheme-probe | Skip the https-first connectivity probe; apply --default-scheme directly (deterministic/offline runs) |
Scan a whole family
ruso scan --family web --target https://lab.local -v
--family <name> is mutually exclusive with --script (exactly one is required). It queries the registry for every published script in that curated family (web, network, database, …), installs each into the local cache, and runs them all against the target(s). One script failing to install is warned and skipped, not fatal. A family with no scripts errors out rather than silently doing nothing.
login
ruso login --token ruso_pat_xxxxxxxxxxxx
# Or read from stdin:
echo "ruso_pat_xxxxxxxxxxxx" | ruso login
# Interactive prompt if stdin is a tty:
ruso login
Verifies the token against the registry's /v1/me endpoint before
saving — better to fail loudly here than silently store a bad token.
| Flag | Effect |
|---|---|
--token <TOKEN> | PAT (ruso_pat_…) or session token (ruso_sess_…). |
--registry <URL> | Override the registry base URL. |
logout
ruso logout
ruso logout --registry https://other.example.com
Removes the stored credential for the active registry. Idempotent.
whoami
ruso whoami
Prints the user the stored credential resolves to, plus the registry URL the credential is bound to. Exits non-zero if no credential is stored.
publish
ruso publish ./mycheck.rsl
ruso publish ./mycheck.rsl --visibility private
| Flag | Effect |
|---|---|
--visibility <public|private> | First-publish-only. Subsequent publishes inherit the existing visibility (change via PATCH — not yet exposed in the CLI). |
--registry <URL> | Override the registry base URL. |
A script is always published under your own username as the namespace — the registry has no organizations, so there's no flag to target a different one. (The backend rejects a mismatched namespace with 404.)
The script's name "…" metadata is slugified to form the URL path
component. version "X.Y.Z", optional tags [...], and optional
family "…" metadata are extracted from the .rsl source by the
backend at publish time — all immutable per version.
Success output:
published myuser/log4shell@0.2.0 (4321 bytes, public)
tags: log4j, rce, jndi
install
ruso install someuser/log4shell
ruso install someuser/log4shell@^0.2
ruso install --all-versions someuser/log4shell
ruso install --force someuser/log4shell # re-download
ruso install a/x b/y c/z@~1.4 # multiple refs
Resolves the best non-yanked version matching the range (newest wins;
no range = newest overall) and writes it to
$RUSO_HOME/scripts/<ns>/<name>/<version>.rbc (default
~/.ruso/scripts/...). Subsequent runs reuse the cache. A cached entry is
reused only if it still decodes with the current runtime; one that no longer
does (e.g. compiled by an older toolchain) is re-fetched automatically, so
--force is needed only to refresh an entry that is still valid.
| Flag | Effect |
|---|---|
--force | Re-download even if a matching version is already cached. The cached entry is replaced only once the new download succeeds — a failed --force (registry down, network error) leaves the existing cache intact. |
--all-versions | Install every non-yanked version of the ref (honouring @<range> if given). Newest-first so Ctrl-C mid-install leaves the most-useful versions on disk. |
--registry <URL> | Override the registry base URL. |
search
ruso search "log4j"
ruso search --tag rce --tag auth # AND on tags
ruso search --severity critical --cve CVE-2021-44228
ruso search --namespace someuser
ruso search "log4j" --json --per-page 50 # machine-readable
| Flag | Effect |
|---|---|
Positional <QUERY> | Free-text query (matches name + description + tags via tsvector). |
--tag <T> | Filter by tag. Repeat for AND. |
--severity <S> | Exact match on the latest version's severity. |
--cve <ID> | Exact match on a CVE in the cached list. |
--namespace <NS> | Filter by owner username. |
--page <N> / --per-page <N> | Defaults 1 / 20; per-page clamped to [1, 100]. |
--json | Emit a JSON array of hits to stdout (table view by default). |
--registry <URL> | Override the registry base URL. |
Anonymous searches see only public scripts; authenticated searches also include private scripts owned by the caller. Scripts whose only versions are yanked are excluded from results.
info
ruso info someuser/log4shell
ruso info someuser/log4shell@^1 # filter versions by range
ruso info someuser/log4shell --json # machine-readable
Read-only — works anonymously for public scripts, requires login for private scripts you own.
| Flag | Effect |
|---|---|
Positional <REF> | <ns>/<name> or <ns>/<name>@<semver-range>. |
--json | Emit the raw ScriptResponse shape to stdout. |
--registry <URL> | Override the registry base URL. |
Human output shows: namespace/name, visibility, description, tags, the latest non-yanked version + its download count, copy-paste install commands, and the full version list with per-version size + download count + yank flag.
yank / unyank
ruso yank someuser/check@1.4.2 --reason "false-positive rate too high"
ruso unyank someuser/check@1.4.2
Owner-only. Idempotent — yanking an already-yanked version (or unyanking an already-active one) is a no-op success.
| Flag | Effect |
|---|---|
Positional <REF@VERSION> | Exact SemVer, not a range. |
--reason <TEXT> (yank only) | Surfaced in version metadata as yank_reason; helps installers understand why a previously-shipping version disappeared. |
--registry <URL> | Override the registry base URL. |
Yank only requires the yank scope on PATs. Sessions carry full scope.
Yanked versions still serve their bytecode if explicitly requested by
version — the registry just stops recommending them in search +
install without @<range> matching.
edit
ruso edit someuser/check --description "Now detects CVE-2024-XYZ too"
ruso edit someuser/check --visibility private
ruso edit someuser/check --description "" --visibility public # combo
Owner-only. Updates fields on the script (not on a version — version data is immutable once published).
| Flag | Effect |
|---|---|
Positional <REF> | <ns>/<name> — no version part. |
--description <TEXT> | New description. Pass "" to clear. |
--visibility <public|private> | Toggle visibility. |
--registry <URL> | Override the registry base URL. |
Refuses to call with neither flag set (would be a no-op round-trip).
pat
Personal access tokens lifecycle from the terminal — the full set of operations the web UI's Tokens page exposes.
ruso pat list # table view
ruso pat list --active-only --json # filter + JSON
ruso pat create laptop # default scope: read
ruso pat create ci --scope read --scope publish
ruso pat create release --scope yank \
--expires-at 2026-12-31T00:00:00Z
ruso pat revoke <PAT_UUID>
All three subcommands need a stored credential (ruso login first).
Backend re-checks ownership — you can only see / mutate your own
PATs.
pat list
| Flag | Effect |
|---|---|
--active-only | Hide revoked tokens. By default they're shown with a revoked status marker so you can audit what's been cleaned up. |
--json | Machine-readable output (array of token records). |
--registry <URL> | Override the registry base URL. |
Sorted newest-first by created_at. The plaintext token is never
shown — only pat create returns that, and only once.
pat create
Requires a session token, not a PAT. Backend deliberately rejects PAT-authed
createcalls — a leaked PAT shouldn't be able to mint sibling PATs. Sign in via the web UI's Tokens page, grab theruso_sess_…cookie, andruso loginwith that to use this command.pat listandpat revokework with either token type.
| Flag | Effect |
|---|---|
Positional <NAME> | Human label so you remember what the token is for. Stored verbatim. |
--scope <SCOPE> | Repeatable. Allowed: read, publish, yank. Defaults to read if not specified. Pick the minimum needed for the job. |
--expires-at <RFC3339> | Optional. Omit for a never-expiring token (still revocable via pat revoke). |
--registry <URL> | Override the registry base URL. |
Output:
created PAT `laptop` (id 0a35…, scopes: read)
Store this token now — it won't be shown again:
ruso_pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
pat revoke
| Flag | Effect |
|---|---|
Positional <ID> | PAT UUID, copy from pat list. |
--registry <URL> | Override the registry base URL. |
Idempotent — already-revoked tokens still return success.
Report output (--output json / csv / human)
Human output is a one line per finding: [SEVERITY] <target> <title>
(e.g. [CRITICAL] 127.0.0.1 Redis exposed without authentication). The
[SEVERITY] tag is colour-coded by level (critical = magenta, high = red,
medium = yellow, low = cyan, info = grey), the target is bold, and tags are
padded to one column so targets line up. Findings stream as they are found
during the scan (a progress spinner sits below them on a TTY), not in a single
dump at the end. The full metadata is intentionally kept out of the console —
use --output json / csv with --report <path> for the complete record. In
-v mode each run instead logs a status line as it completes: [OK] (green),
[SKIP] … (reason) (yellow), or [ERROR] … (msg) (red).
Scanning is pipelined: a bare-host --target's scheme (http/https) is
resolved lazily, once per target, as part of scanning — so a large target file
starts producing results immediately instead of waiting for every target to be
probed up front.
For a multi-run scan the human output ends with a per-target summary table and a duration footer:
┌─────────────┬──────────┬────────┬─────────┬───────┐
│ target │ detected │ failed │ skipped │ clean │
├─────────────┼──────────┼────────┼─────────┼───────┤
│ protergo.id │ 0 │ 48 │ 0 │ 0 │
└─────────────┴──────────┴────────┴─────────┴───────┘
scan duration 1.4s · 48 runs across 1 target
Each count is coloured by bucket when non-zero (detected/failed red, skipped yellow, clean green) and dimmed at zero.
Colour is applied only when stdout is a terminal; piped or redirected output
(and any run with the NO_COLOR environment variable set) stays plain, so
escape codes never pollute logs or grep.
Every interactive invocation also prints a startup banner (the "ruso"
wordmark plus version / GitHub / registry links) to stderr — shown only when
stderr is a terminal, so piped/CI output and the report on stdout are
unaffected. NO_COLOR drops its colour like everywhere else.
The json/csv report carries every metadata field from the script
metadata { … } block. Besides name, description, impact, severity,
author, and evidence:
| Field | Source in .rsl |
|---|---|
cve | cve ["…", "…"] list (JSON array; CSV joined with |) |
cwe | cwe ["…"] list |
references | references ["…", "…"] list (URLs, advisories, etc.) |
cvss | Repeatable cvss "…" lines (CVSS vector, e.g. CVSS:3.1/…) |
cvss_score | Repeatable cvss_score 9.8 lines (numeric literal, stored as string in reports) |
mitigation | Single mitigation "…" line (remediation guidance; declaring it twice is a compile error) |
version | version "X.Y.Z" (script SemVer) |
family | family "web" (curated category) |
tags | tags ["…", "…"] free-form labels |
Empty lists / absent fields are omitted from JSON (skip_serializing_if).
skip_reason vs error
JSON and CSV reports carry two separate channels for non-finding outcomes:
skip_reasonis set when a run did not execute because a required port was closed (port 80 closed, etc.).skippedistruein the same row.erroris set only for genuine failures (parse failure, IO error, runtimefailopcode, SSRF guard, budget exceeded).
Earlier revisions wrote the skip reason into error, which made
"intentional skip" indistinguishable from "the run blew up" in
downstream tooling. The CSV header now includes skipped and
skip_reason columns.
Scan target and socket checks
--targetaccepts a full URL or a bare host/IP/domain. A target that already carries a scheme is used as-is.- Bare-host scheme resolution (
scan). For a bare host,scanresolves the scheme https-first: it probeshttps://and uses it on any HTTP response; it falls back tohttp://only when 443 is unreachable at the connection level (refused/reset/timeout) — it never downgrades to cleartext because of a certificate or HTTP-status error. If 443 is reachable but the certificate does not verify and--insecurewas not given, it stays on https and warns you to pass--insecure. Control it with--default-scheme <https|http>(the fallback when probing is off or nothing answers; defaulthttps) and--no-scheme-probe(skip the probe, apply--default-schemedirectly). A non-HTTP scan (TCP/UDP/DNS only — Redis, NTP, …) skips the probe and keeps anhttp://carrier, since the scheme never reaches the wire (--target 127.0.0.1). - HTTP checks use
--targetas the request base URL. - TCP/UDP/DNS wire checks use
hostin the.rslscript. Preferhost "{{scan_host}}"so the host comes from--target. - Failure reasons are reported in full. A failed run shows the underlying
cause, not just a generic line — e.g.
http error: error sending request for url (…): client error (Connect): invalid peer certificate: UnknownIssuerrather than a bareerror sending request. ruso validate/ruso compilefail if the script hasmatchorevidencebut nonameorreportmetadata.
Workflow
# Local development loop.
ruso validate --script mycheck.rsl
ruso compile --script mycheck.rsl # → mycheck.rbc
ruso exec --bytecode mycheck.rbc --target https://lab.local -v
# Or one step from source:
ruso scan --script mycheck.rsl --target https://lab.local -v
# Publish a finished check and run someone else's:
echo "$RUSO_PAT" | ruso login --registry https://registry.example.com
ruso publish ./mycheck.rsl --visibility public
ruso install someone/another-check@^1
ruso scan --script someone/another-check --target https://lab.local -v
Exit codes
Non-zero on validation/compile failure, missing paths, runtime errors, or report I/O. A successful run with no finding is exit 0 ([OK] line in verbose human output).
Publishing & Installing
The registry is how checks are shared. It stores compiled checks under a
<namespace>/<name> slug, versioned with SemVer, and lets anyone search,
install, and run them. The hosted registry lives at
https://ruso.hopeless-labs.com.
This chapter is the workflow. For every flag, see the CLI Reference.
Addressing a check
Everything in the registry is addressed the same way:
<namespace>/<name>[@<semver-range>]
alice /log4shell @ ^1.2
- namespace — your username (the registry has no separate orgs; put an
organisation name in the check's
authorfield instead). - name — a slug derived from the check's metadata
name. - range — an optional SemVer range; omit it to mean "newest non-yanked".
Logging in
Authenticate once per registry. Use a Personal Access Token (PAT) or a session token from the backend's web flow:
echo "ruso_pat_…" | ruso login
The credential is stored per registry URL in
$XDG_CONFIG_HOME/ruso/credentials.json (mode 0600), so the same machine can
be logged into a local backend and the hosted one simultaneously. Check who
you are with ruso whoami; clear it with ruso logout.
Publishing
Publishing requires a version in the check's metadata. The namespace defaults
to your username:
ruso publish ./mycheck.rsl --visibility public
The CLI uploads the source; the registry compiles and stores it. To publish
a new version, bump version in the metadata and run publish again. Before
publishing, make sure the check passes both the vulnerable and safe cases — see
Testing Your Checks.
Tip: the slug comes from the metadata
name(lowercased, hyphenated, max 39 chars). Keepnameshort and usereportfor a longer human title.
Finding checks
Free-text search with optional filters (tag, severity, CVE, namespace, family):
ruso search "log4j" --tag rce
ruso search --family database --severity high
ruso info <ns>/<name> shows a check's versions, tags, and a ready-to-paste
install snippet.
Installing and running
install downloads a version into the local cache
(~/.ruso/scripts/<ns>/<name>/<version>.rbc):
ruso install someuser/log4shell@^0.2
But you rarely need to install explicitly — scan and exec accept a registry
reference directly and fetch on a cache miss:
ruso scan --script someuser/log4shell --target https://target.example.com -v
Filesystem paths always win over reference matching, so a local file or directory named like a slug still works.
Scanning a whole family
Run every published check in a category against a target:
ruso scan --family web --target https://target.example.com
Managing your published checks
| Command | Effect |
|---|---|
ruso yank <ns>/<name>@<version> | Hide a version from new installs (idempotent, owner-only). Existing installs keep working. |
ruso unyank <ns>/<name>@<version> | Restore a yanked version. |
ruso edit <ns>/<name> | Update description / visibility of a check you own. |
ruso pat list/create/revoke | Manage Personal Access Tokens from the terminal. |
Pointing at a different registry
Registry URL precedence: --registry <url> > $RUSO_REGISTRY_URL > the built-in
default (https://ruso.hopeless-labs.com). Use http://127.0.0.1:8080 for a
local or private registry instance.
Architecture
Ruso splits language (script + compiler) from execution (runtime VM). The CLI is a thin driver. This keeps the bytecode contract stable while RSL evolves.
End-to-end flow
flowchart LR
subgraph script_crate [ruso-script]
SRC[".rsl source"]
PEST[Pest parser]
AST[AST]
SPEC[ProgramSpec]
BC[BytecodeProgram]
SRC --> PEST --> AST
AST --> SPEC
AST --> BC
end
subgraph runtime_crate [ruso-runtime]
ENC[encode binary]
EXEC[Executor]
NET[HTTP / DNS / TCP / UDP]
BC --> EXEC
EXEC --> NET
BC --> ENC
end
CLI[ruso-cli] --> SRC
CLI --> EXEC
- Parse —
grammar.pest→Program { statements }. - Build spec — metadata + probe definitions →
ProgramSpec(probe table only; no control flow). - Compile — executable statements →
Vec<Instr>+ string/matcher/payload pools. - Execute —
Executorwalks instructions, callssend_probe, evaluates matchers, emits findings.
Why probes are not opcodes
Enterprise scanners often embed protocol logic in plugins. Ruso instead uses:
| Layer | Responsibility |
|---|---|
| Probe table | What to send (HTTP path, TCP payload, DNS wire bytes, …) |
| Instructions | When to send and how to branch (Send, Match, ForList, …) |
There is a single network opcode:
Send { probe_id, optional_payload_override }
ProbeKind in the probe table distinguishes HTTP vs DNS vs TCP vs UDP. Adding a Redis check does not add OP_REDIS; it adds a tcp probe with a RESP payload in a .rsl file.
Benefits:
- Scripts ship without recompiling the VM.
- Bytecode stays small and stable.
- Same executor path for all TCP-based services.
Trade-off: very complex protocol state machines still need either richer generic options (session, read_idle, loop) or future opcodes—not per-service hardcoding.
Three crates
ruso-runtime
- Public API:
Executor,ExecutorConfig,BytecodeProgram,encode_bytecode/decode_bytecode,ProgramSpec,SocketProbeSpec,RuntimeError,build_client, contract types. - Does not parse
.rslsource. - Owns all network I/O (reqwest, tokio sockets, tokio-rustls).
build_clientis exported so a frontend can build a preflight/probe client with the same TLS/proxy/redirect behaviour as the executor instead of hand-rolling one.
Key modules:
| Module | Purpose |
|---|---|
contract.rs | Matchers, severity, HTTP method, evidence |
runtime/spec.rs | HttpRequestSpec, SocketProbeSpec, ProbeKind |
runtime/bytecode.rs | Instr enum |
runtime/binary.rs | Wire encode/decode v1 |
runtime/executor.rs | VM main loop |
runtime/http.rs | HTTP client requests |
runtime/session.rs | TCP/TLS connect, multi-read, UDP |
runtime/socket.rs | One-shot and session exchanges |
runtime/dns.rs | OS resolver vs wire UDP DNS |
runtime/matcher.rs | Field predicates |
runtime/context.rs | Variables, responses, sessions, loop stack |
ruso-script
- Public API:
parse(),compile(),Program, AST types. - Depends on
ruso-runtimeforProgramSpecand contract types. - Pest grammar is the source of truth for syntax.
Pipeline: parse → build_program_spec → compile.
ruso-cli
- Clap CLI:
scan,parse,compile,exec. - Wires
ExecutorConfig(base URL, timeout, TLS verify, proxy) from flags. - Discovers
.rslfiles and target lists for batch scans.
Repositories (not a monorepo)
Ruso ships as three independent Git repositories under Hopeless-Labs:
| Repository | Role |
|---|---|
| ruso-runtime | VM, bytecode wire format, network I/O (this repo) |
| ruso-script | Pest grammar, compiler, examples/*.rsl |
| ruso-cli | ruso binary |
A local parent folder (e.g. Hopeless-Labs/ with sibling clones) is only for convenience on your machine. It is not a git repository and does not contain canonical documentation.
Dependency direction:
ruso-cli → ruso-script → ruso-runtime
Published Cargo.toml files use git dependencies on main (see Extending).
Layout in this repo (ruso-runtime)
ruso-runtime/
├── docs/ ← developer documentation
├── src/
│ ├── contract.rs # matchers, severity, …
│ ├── opcode.rs # opcode IDs + module docs
│ └── runtime/ # executor, http, socket, session, binary, …
└── Cargo.toml
Related documentation in other repos:
- RSL syntax — ruso-script
docs/RSL_REFERENCE.md - Example scripts — ruso-script
docs/EXAMPLES.md - CLI — ruso-cli
docs/CLI.md
Generic socket model
dns, tcp, and udp blocks all parse into the same SocketProbe / SocketProbeSpec:
pub struct SocketProbeSpec {
pub host: String,
pub port: Option<u16>,
pub payload: Option<Vec<u8>>,
pub tls: bool,
pub session: bool,
pub read_max: u32,
pub read_idle_ms: u32,
}
Runtime behavior is selected by probe kind + field values:
| Kind | Condition | Behavior |
|---|---|---|
Dns | no port, no payload | OS resolver (tokio::net::lookup_host) → ProbeResponse::DnsResolve |
Dns | port and/or payload | UDP to host:port (default 53) → ProbeResponse::Socket |
Tcp | port required | TCP (+ optional TLS) exchange → Socket |
Udp | port required | UDP exchange → Socket |
HTTP uses a separate HttpRequestSpec because the model is request/response document-oriented, not raw socket bytes.
Execution state
Context holds per-run state:
variables—set/extractresponses— map probe name →ProbeResponsesessions— open TCP/TLS or UDP sockets whensession trueloop_stack—ForList/LoopBack/Breakmatched— AND-chain for matchers until one failsevidence— strings for the final finding
Findings
A finding is emitted when:
- Match chain stayed true (
context.matched), and finalize_finding()runs at end of bytecode (metadata name/severity, optional advisory fields includingcve/cwe/references/cvss/cvss_score/mitigation, plus evidence).
Flow instructions: stop (halt, no finding), exit (halt, finalize finding), fail (error), continue (no-op), break (exit innermost for).
What Ruso is not (yet)
- Not a scan orchestration platform (scheduling, workers, asset DB).
- Not a plugin marketplace (checks are
.rslfiles you ship). - Not a full web crawler / Burp replacement.
It is a solid check execution engine with a small, generic bytecode ISA.
Compiler (ruso-script)
The compiler turns .rsl source into a BytecodeProgram consumed by ruso-runtime.
Public API
use ruso_script::{parse, compile};
let program = parse(source)?;
let bytecode = compile(&program)?;
| Function | Output |
|---|---|
parse | Program { statements: Vec<Stmt> } |
compile | BytecodeProgram |
Parse errors: ParseError (Pest or Invalid message).
Compile does not fail on well-formed AST today—errors are parse-time.
Pipeline
source text
→ Pest (Rule::program)
→ build_statement per item
→ Program
→ build_program_spec (metadata + probes only)
→ Compiler::emit_program (executable stmts)
→ BytecodeProgram
build_program_spec
File: src/spec_build.rs.
Walks statements; collects:
- Metadata into
CheckMetadata Stmt::Http/Dns/Tcp/UdpintoProgramSpec.probes: HashMap<String, ProbeKind>
Executable statements are ignored here.
compile
File: src/compile.rs.
Compiler maintains:
code: Vec<Instr>strings+string_idsdedup mappayloads+payload_idsdedup map (byte equality)matchers,extracts,evidenceappend-only pools
Probe definitions (Http, Dns, …) emit nothing—only send triggers network ops at runtime.
Control-flow compilation
if
let if_pc = emit(IfMatch { matcher, else_pc: 0 });
emit_program(body);
patch else_pc = code.len();
Parser layout
| Module | Responsibility |
|---|---|
grammar.pest | Syntax |
parser/mod.rs | build_statement dispatch |
parser/metadata.rs | name, description, impact, severity, author, report, cve, cwe, references, cvss, cvss_score, mitigation, tags, version, family |
parser/probes.rs | http block items |
parser/socket.rs | dns / tcp / udp shared builder |
parser/match_expr.rs | qualified matchers, groups |
parser/statements.rs | send, if, for, flow, … |
parser/body.rs | HTTP body objects |
Nesting-depth guard
pest is a recursive-descent parser, so each nested block (if / for
… end) or object ({ … }) costs one parser stack frame. A few
thousand levels — well under the backend's 256 KiB source cap — overflow
the stack and abort the process (a stack overflow can't be caught by
catch_unwind and isn't bounded by the executor's wall-clock budget).
parse() therefore runs check_nesting_depth before handing the source
to pest: a single linear, string-/comment-aware scan that rejects input
nesting deeper than MAX_NESTING_DEPTH (64) with a graceful
ParseError::Invalid. The counter tracks simultaneously-open constructs,
which equals pest's recursion depth, so it can't be evaded by interleaving
brace and keyword nesting.
Adding a socket option
- Add keyword to
grammar.pest(socket_itemarm). - Parse in
parser/socket.rs→ field onSocketProbe. - Copy in
spec_build::socket_spec. - Extend
SocketProbeSpecin runtime +write_socket_probe/read_socket_probe. - Implement behavior in
session.rs/executor.rs. - Document in
RSL_REFERENCE.mdand bumpVERSIONif wire format changes.
Adding a statement
grammar.pest— newstatementalternative.ast.rs—Stmtvariant.parser— builder.compile.rs—emit_stmt+ newInstrif needed.executor.rs+binary.rs+opcode.rsfor new instructions.
AST highlights
pub struct SocketProbe {
pub name: String,
pub host: String,
pub port: Option<u16>,
pub payload: Option<Vec<u8>>,
pub tls: bool,
pub session: bool,
pub read_max: u32,
pub read_idle_ms: u32,
}
pub enum Stmt {
Send { probe: String, payload: Option<Vec<u8>> },
Break,
// …
}
Syntax tests
src/script/syntax_tests.rs — parse-only tests for grammar regressions. Run when changing parser:
cargo test -p ruso-script
Examples
Bundled under examples/*.rsl in this repository — living documentation (see EXAMPLES.md).
Bytecode and opcodes (v1)
Compiled output is a BytecodeProgram defined in ruso-runtime/src/runtime/bytecode.rs. The on-disk / on-wire format is implemented in runtime/binary.rs.
Constants:
pub const MAGIC: &[u8; 4] = b"RUSO";
pub const VERSION: u8 = 1;
Versioning policy
The header carries a one-byte VERSION (currently 1). The decoder accepts
only that exact version; anything else is rejected up front with
BytecodeError::BadVersion { found, supported } ("unsupported bytecode version
N (this build reads version M)") — never a cryptic mid-decode Corrupt error.
Any change to the wire format must bump VERSION. Early-development
revisions evolved the v1 layout in place without bumping (folding changes back
into v1), which is why a stale .rbc could fail to decode with an opaque "string
length exceeds buffer" instead of a clean version error. That era is over: now
that bytecode is cached locally and distributed via the registry, a format
change is a version bump.
A
VERSIONbump is a coordinated change: the registry must deploy the new runtime and serve (re-compile)VERSION-N bytecode, otherwise clients on the new version reject everything the registry still serves as old. The local install cache self-heals (an undecodable entry is re-fetched), so once the registry serves the new version, clients converge automatically.
Removing an opcode is not a format change as long as the remaining opcode
numbers are stable and no valid program used the removed one — those byte
streams are identical. (That is why dropping repeat/OP_REPEAT left VERSION
at 1; opcode 18 is reserved.)
The current v1 layout:
- Encodes
CmpValue::Numberasu64(earlier development revisions truncated tou32). - Assigns HTTP method tags 5 and 6 to
HeadandOptions. - Bounds every untrusted list/count against the remaining buffer in the
decoder, so a malicious or corrupt
.rbcfile cannot trigger OOM allocations from au32::MAXcount. - Bounds-checks every instruction operand index against its pool after
decoding (see Operand validation),
so an out-of-range index surfaces as a
Corrupterror instead of panicking the executor.
File layout
Sections are written in order:
| # | Section | Content |
|---|---|---|
| 1 | Header | MAGIC + VERSION |
| 2 | Metadata | See Metadata section |
| 3 | Probe table | count + (name, ProbeKind)* |
| 4 | String pool | UTF-8 strings (identifiers, durations as text, …) |
| 5 | Payload pool | raw byte blobs for Send overrides |
| 6 | Matcher pool | QualifiedMatch entries |
| 7 | Extract pool | ExtractSource entries |
| 8 | Evidence pool | EvidenceKind entries |
| 9 | Code | instruction stream |
CLI compile emits hex; exec accepts hex files. The runtime
load_bytecode_input helper used to accept an @path prefix to read a file
directly; that overload has been removed to keep file IO inside the CLI and
prevent any caller from passing less-trusted hex text through a
path-traversal sink.
Bounded counts (decoder hardening)
Every u32 count field that drives a Vec::with_capacity(count) is now
validated against the remaining buffer in the same step:
let raw = r.u32()?;
let count = r.bounded_count(raw)?; // errors if count > remaining bytes
let mut out = Vec::with_capacity(count);
Without this guard a corrupt or hostile bytecode could set
count = u32::MAX, triggering a multi-GB allocation and killing the
scanner before the rest of the buffer was inspected.
The bound also applies to the length-prefixed str and opt_bytes readers,
so an inner len field that overruns the buffer is rejected before the
allocation, not after.
Operand validation (decoder hardening)
Bounded counts stop OOM allocations, but they do not check that an
instruction's operand indices land inside the decoded pools — those
indices are plain u32s in the code stream, and the executor indexes
strings, payloads, matchers, extracts, and evidence directly.
An unchecked out-of-range index (e.g. Set { name: u32::MAX }) would
panic the worker thread.
After the whole program is decoded, validate_program walks the code
once and rejects any operand index >= pool.len() (and any
start + len slice that overruns, computed in usize so it can't wrap)
with a Corrupt error. Jump targets (else_pc, end_pc) are exempt:
the executor's main loop halts once pc >= code.len(), so an
out-of-range jump simply ends execution without reading out of bounds.
HTTP methods (wire tag)
| Tag | Method |
|---|---|
| 0 | GET |
| 1 | POST |
| 2 | PUT |
| 3 | PATCH |
| 4 | DELETE |
| 5 | HEAD |
| 6 | OPTIONS |
Probe kinds (wire tag)
| Tag | Variant | Body |
|---|---|---|
0 | Http | HttpRequestSpec (method, path, options, bodies, …) |
1 | Dns | SocketProbeSpec |
2 | Tcp | SocketProbeSpec |
3 | Udp | SocketProbeSpec |
SocketProbeSpec
Binary order:
host— length-prefixed UTF-8 stringport— optionalu16(u8flag + value)payload— optional byte blob (u8flag +u32len + bytes)tls—u8(0/1)session—u8(0/1)read_max—u32read_idle_ms—u32
Instruction set
Wire opcode byte → Instr variant:
| Op | Name | Operands |
|---|---|---|
| 1 | Set | name_id: u32, value_id: u32 |
| 2 | Send | probe_id: u32, has_payload: u8, optional payload_id: u32 |
| 3 | Match | matcher_id: u32 |
| 4 | MatchAll | start: u32, len: u16 |
| 5 | MatchAny | start: u32, len: u16 |
| 6 | Assert | matcher_id: u32 |
| 7 | Extract | name_id: u32, source_id: u32 |
| 8 | IfMatch | matcher_id: u32, else_pc: u32 |
| 9 | Save | from_id: u32, to_id: u32 |
| 10 | Evidence | kind_id: u32 |
| 11 | Retry | probe_id: u32, count: u32 |
| 12 | RetryDelay | duration_id: u32 (string pool) |
| 13 | Sleep | duration_id: u32 |
| 14 | Stop | — |
| 15 | Fail | — |
| 16 | Continue | — |
| 17 | Exit | — |
| 18 | (reserved) | was Repeat, removed |
| 19 | LoopBack | — |
| 20 | Break | — |
| 21 | SetList | name_id: u32, start: u32, len: u16 |
| 22 | ForList | item_id: u32, start: u32, len: u16, end_pc: u32 |
| 23 | ForVar | item_id: u32, list_id: u32, end_pc: u32 |
Public constants: ruso_runtime::opcode::{OP_*}.
CmpValue encoding
| Tag | Variant | Wire |
|---|---|---|
| 0 | Number(u64) | u64 little-endian |
| 1 | String(String) | length-prefixed UTF-8 |
| 2 | Duration(String) | length-prefixed UTF-8 |
The Number payload is encoded as u64. Earlier in-development revisions
truncated to u32; scripts that compare against values above ~4.3 billion
(e.g. response_size > 5_000_000_000) now round-trip without silent loss.
Control-flow patching
The compiler emits placeholders and patches PCs:
IfMatch—else_pcset after body is emitted.ForList—end_pcset afterLoopBackis emitted.
Executor semantics:
ForList— pushes aLoopFrameover the literal list, binding the item variable each iteration.LoopBack— advances theforiterator; if more items remain, jump tohead_pc, else pop frame and continue after loop.Break— pop innermost frame, jump toend_pc.
The executor also enforces a wall-clock budget
(ExecutorConfig::max_script_duration, default 5 minutes), checked at
instruction boundaries, so a long-running script (e.g. a for over a large
list of slow probes) cannot keep a tokio worker busy beyond that budget.
Metadata section
Written in order after the header (MAGIC + VERSION):
| Field | Encoding |
|---|---|
name | optional UTF-8 string |
description | optional string |
impact | optional string |
severity | u8 tag (0=absent, else 1–5 for low…critical) |
author | optional string |
report_title | optional string (report in RSL) |
cve | u32 count + strings |
cwe | u32 count + strings |
references | u32 count + strings |
cvss | u32 count + strings (vector) |
cvss_score | u32 count + strings (numeric score) |
mitigation | u32 count + strings |
tags | u32 count + strings (discovery labels) |
version | optional UTF-8 string (SemVer, required at publish) |
family | optional UTF-8 string (single curated category) |
Each string list uses the same write_strings / read_strings helper as the string pool (count, then length-prefixed UTF-8 per entry). Repeatable metadata lines in .rsl append to these lists at compile time.
version and family are written at the tail of the metadata block via opt_str (a 0/1 presence byte then the string). They were appended in place during 0.1.0-dev without bumping the version byte — older .rbc that predate them simply won't have the trailing bytes, so always recompile after pulling.
Pools and IDs
All u32 IDs index into compile-time pools in BytecodeProgram:
- Strings — probe names, variable names, duration text for sleep/retry
- Payloads — binary overrides for
Send - Matchers — full
QualifiedMatchstructs - Extracts / Evidence — parallel structures
Evidence pool entries (EvidenceKind):
| Tag | Form | Wire |
|---|---|---|
| 0 | body <probe> | probe name string |
| 1 | regex <probe> <pattern> | probe name + pattern string |
| 2 | response <probe> | probe name string |
The executor resolves IDs at runtime via program.strings[id], etc.
Disassembly
use ruso_runtime::format_human;
let text = format_human(&bytecode);
Human listing is in runtime/disasm.rs (metadata, probes, pools, annotated instructions). String spans referenced by ForList/SetList are looked up via .get() rather than indexed, so corrupt-but-decodable bytecode that points past the string pool no longer panics the disassembler.
Embedding bytecode
use ruso_runtime::{Executor, ExecutorConfig, decode_bytecode};
let program = decode_bytecode(&bytes)?;
let executor = Executor::from_bytecode(config, program)?;
let result = executor.run().await?;
Compilers must target VERSION 1. While 0.1.0-dev the v1 wire format
may change between commits without a version bump — recompile stored .rbc
files after pulling.
Design note: why not more opcodes?
Protocol-specific opcodes (OP_SMTP, OP_REDIS, …) would couple the VM to services. Ruso keeps:
- Data in the probe table (payload bytes, ports, TLS flag).
- Control in a small ISA (
Send,Match,ForList, …).
New network behavior should prefer new socket options or send overrides before new opcodes.
Runtime (ruso-runtime)
The runtime executes BytecodeProgram without parsing .rsl source. Integrators embed Executor directly; the CLI is optional.
Entry points
use ruso_runtime::{Executor, ExecutorConfig, BytecodeProgram};
// From compiler
let executor = Executor::from_bytecode(config, program)?;
// From bytes (RUSO v1)
let executor = Executor::from_bytes(config, &bytes)?;
let result = executor.run().await?;
ExecutionResult:
| Field | Meaning |
|---|---|
success | No fail instruction |
detected | A finding was produced |
report | Findings + metadata |
variables | Final variable map |
metadata | Check metadata from spec (CheckMetadata) |
Finding / CheckMetadata
When a check matches, finalize_finding() builds one Finding from metadata plus collected evidence:
| Field | Source |
|---|---|
name | name, or report title if name omitted |
description, impact, author | Optional metadata strings |
severity | Metadata severity, default info |
cve, cwe, references, cvss, cvss_score | Repeatable RSL lines → Vec<String> |
mitigation | Single free-text line → Option<String> (declaring it twice is a compile error) |
evidence | evidence <probe>.body or evidence <probe> regex '…' on that probe only |
Port reachability cache
Before the VM runs, Executor::run TCP-connect-probes the ports required by tcp probes in the program spec (plus the --target host:port for HTTP checks). Results live in a process-wide cache for 30 seconds (PortCache::global()). udp and wire-mode dns probes are connectionless — a TCP connect to their port proves nothing about the UDP service, so they are not pre-checked and always run, bounded only by their own read timeout.
If any required port is closed (live probe or cache hit), only that script run is skipped:
ExecutionResult.skipped = trueExecutionResult.skip_reason— e.g.port example.com:443 closedExecutionResult.port_checks— per-endpoint open/closed snapshot
Other scripts in the same ruso scan continue. Scripts that share the same closed host:port within 30s are skipped without reconnecting. Endpoints come from socket probes and from --target (https://host → port 443) when the check uses HTTP.
ExecutorConfig
| Field | Default | Role |
|---|---|---|
base_url | "" | HTTP probe base (from CLI --target) |
default_timeout | 30s | Connect/read fallback |
read_timeout | 10s | Per-read I/O timeout for socket probes |
max_response_bytes | 10 MiB | HTTP body cap |
follow_redirect | true | HTTP client |
verify_ssl | true | HTTP and TCP TLS (tls true) |
proxy | none | HTTP proxy URL |
max_script_duration | 5 minutes | Wall-clock budget per script (None disables) |
http_retries | 2 | Transient-transport retries per HTTP probe (0 disables) |
verify_ssl defaults to true. Earlier revisions defaulted to false
("scanner mode"), which made a freshly-constructed runtime open to MITM —
an on-path attacker could plant findings on the scanner or read in-flight
request data. Disable per-call by setting verify_ssl = false; the CLI
exposes this as --insecure and emits a runtime warning when used. Per
HTTP probe, verify_ssl true|false in the script overrides the global
setting for that request only.
max_script_duration puts a wall-clock cap on a single script run, so a
long-running script (a for over a large list of slow probes, long sleeps)
cannot pin a tokio worker. The executor checks the budget at the top of
every VM instruction; on exceedance run() returns RuntimeError::Other
with a message containing "budget".
SSRF guard on interpolated paths
HttpRequestSpec.path is templated and may contain {{ var }}
placeholders. When the literal path is relative, the resolved value is
also required to be relative — an interpolation that expands into a full
URL (http://169.254.169.254/latest/meta-data, http://localhost:6379/…)
is rejected with RuntimeError::Other("interpolated path switched to absolute URL; refusing as SSRF guard: …"). Scripts that intentionally
probe a separate origin should write the absolute URL into the script
itself; that path is honoured verbatim.
send_probe flow
- Resolve probe name in
program.spec.probes. interpolate_socket_spec— substitute{{ var }}in host/payload when payload is valid UTF-8.- Dispatch by
ProbeKind:
HTTP
execute_http builds a reqwest request from HttpRequestSpec + base_url, returns ProbeResponse::Http. TLS verify follows HttpRequestSpec.verify_ssl when set, otherwise ExecutorConfig.verify_ssl.
It retries a transient transport failure (connection reset, connect/read timeout — never a received response or a TLS-certificate rejection) up to http_retries times with a short backoff. A probe re-sent by the script's own retry directive passes 0 here, so author-controlled re-sends and the automatic transport retry never multiply.
DNS
- Resolver mode (
portandpayloadboth absent):resolve_host→ProbeResponse::DnsResolve. - Wire mode:
run_dns_probe→ UDP exchange →ProbeResponse::Socket.
TCP
Requires port. Uses exchange_tcp_probe:
session | Behavior |
|---|---|
false | Connect, optional TLS, one exchange, close |
true | Reuse ProbeSession::Tcp in context.sessions; append response data |
UDP
Requires port. tls is rejected. Session reuse mirrors TCP with ProbeSession::Udp.
Send payload override
Instr::Send { payload: Some(id) } uses program.payloads[id] instead of spec payload for that invocation only.
Session and TLS (runtime/session.rs)
TCP plain
TcpStream::connect → optional write → read loop.
TCP TLS
tokio-rustls with WebPKI roots when verify_ssl is true (the default).
A custom NoVerifier is installed only when explicitly requested via
verify_ssl = false.
Multi-read (read_idle_ms > 0)
After each read chunk, wait up to read_idle_ms for more data; stop on idle timeout or read_max bytes.
Single read (read_idle_ms == 0)
One read up to read_max (buffer chunk 4096), subject to I/O timeout (3s per operation by default).
Response types
pub enum ProbeResponse {
Http(HttpResponse),
DnsResolve(DnsResolveResponse),
Socket(SocketResponse),
}
Socket data is String from UTF-8 lossy conversion of bytes—fine for text protocols; binary matching uses regex on lossy string or future byte matchers.
Matcher evaluation
runtime/matcher.rs evaluates QualifiedMatch against stored responses:
- HTTP → status, body, headers, timing, size
- DnsResolve →
answer(joined A/AAAA strings) - Socket →
response/bannerondata
Failed match sets context.matched = false (AND chain).
Context lifecycle
pub struct Context {
pub variables: HashMap<String, String>,
pub responses: HashMap<String, ProbeResponse>,
pub sessions: HashMap<String, ProbeSession>,
pub loop_stack: Vec<LoopFrame>,
pub matched: bool,
pub evidence: Vec<String>,
// …
}
At end of run_bytecode: close_sessions() drops open sockets, then finalize_finding().
Errors
RuntimeError includes unknown probe, wrong probe kind for field, bytecode decode errors, flow fail, I/O timeouts, etc. The CLI maps these to exit codes and stderr.
RuntimeError::full_message() returns the error joined with its full source chain. The wrapped HTTP/I/O variants' Display shows only the top layer (http error: error sending request for url (…)); full_message() appends the real cause (: client error (Connect): invalid peer certificate: UnknownIssuer) so callers log it instead of an opaque line. The CLI uses it for every scan/exec failure.
Dependencies
| Crate | Use |
|---|---|
tokio | Async runtime, sockets |
reqwest | HTTP |
tokio-rustls / rustls | TCP TLS |
regex | Matchers and extract |
tracing | Instrumentation (RUST_LOG) |
Testing runtime changes
- Unit tests in
runtime/*modules (#[cfg(test)]). - Compile a
.rslscript andExecutor::from_bytecode+ manual run. format_human/ round-tripencode→decodefor bytecode changes.
Any change to the wire layout bumps VERSION, so a mismatched .rbc is rejected with a clear BadVersion error rather than a cryptic decode failure (see BYTECODE.md for the policy). Recompile stored .rbc files after a bump.
IPv6 sockets
Direct socket connect addresses are formatted via
port_cache::format_socket_addr, which brackets literal IPv6 addresses so
::1 becomes [::1]:443 (the form TcpStream::connect requires).
port_cache also normalises IPv6 hosts before keying the reachability
cache, so ::1 and 0:0:0:0:0:0:0:1 share one entry. The UDP bind
address tracks the remote family ([::]:0 for IPv6 targets).
Header-duplicate handling
reqwest exposes multi-valued response headers (most notably Set-Cookie)
as separate entries. The runtime flattens them into one string per header
by joining with ", " so substring matchers (header "set-cookie" contains "HttpOnly", etc.) see all values. RFC 7230 §3.2.2 documents this
combining rule; Set-Cookie is the standard exception, but substring
matching against the joined string still works for the cookie attributes
scanners typically check.
Cookie request header
Outbound cookie name "value" directives in a single HTTP block are now
emitted as one Cookie: request header joined by "; " per RFC 6265 §5.4.
The earlier per-call header("cookie", …) pattern produced multiple
Cookie: headers, which several servers reject outright.
JSON body encoding
object_to_json builds JSON via serde_json, so every interpolated value
is escaped as a string literal rather than concatenated. The previous
hand-rolled escape_json only handled \ " \n \r \t and left a
JSON-injection vector open against control characters and any value
containing literal ","key":". The serde-based path closes that hole.
Extending Ruso
Guide for maintainers adding features without breaking the generic probe model.
Decision tree
New vulnerability check for existing transport?
└─ Yes → add .rsl under examples/ or your checks repo (no Rust)
New socket behavior (TLS client cert, SCTP, …)?
└─ Extend SocketProbeSpec + session layer + grammar
New control flow (while, goto)?
└─ New Instr + compiler + executor + opcode doc + VERSION bump if needed
New protocol family unrelated to HTTP/socket bytes?
└─ New ProbeKind variant + executor arm (rare; prefer socket first)
Adding a check (script only)
- Create
my_check.rslwith metadata + probe +send+match. ruso validate --script my_check.rsl- Test with
ruso scanagainst a lab target. - Ship the
.rslfile; no runtime release required.
Use hex payload for binary protocols; session (and multiple sends) for multi-step dialogs.
Optional cve, cwe, references, cvss, and cvss_score lines (repeatable) plus a single mitigation line (free text; declaring it more than once is a compile error) attach advisory IDs, CVSS vectors/scores, remediation text, and URLs to findings and CLI/JSON reports.
Adding a metadata field
Example: a new tags list on checks.
| Step | File |
|---|---|
| 1 | grammar.pest — keyword + metadata_stmt arm |
| 2 | ast.rs — Stmt variant |
| 3 | parser/metadata.rs + spec_build.rs |
| 4 | spec.rs — CheckMetadata field |
| 5 | binary.rs — write_metadata / read_metadata (append at end of metadata block) |
| 6 | report.rs / disasm.rs — Finding + listing |
| 7 | docs/RSL_REFERENCE.md, BYTECODE.md, RUNTIME.md |
| 8 | ruso-cli report.rs if exposed in scan output |
| 9 | Recompile all .rbc files; bump VERSION only if probe or instruction layout changes |
Adding a socket probe option
Example: connect_timeout 5s.
| Step | File |
|---|---|
| 1 | grammar.pest — kw_connect_timeout ~ duration in socket_item |
| 2 | ast.rs — field on SocketProbe |
| 3 | parser/socket.rs — parse duration → ms or store string |
| 4 | spec.rs — SocketProbeSpec field + Default |
| 5 | spec_build.rs — copy field |
| 6 | binary.rs — encode/decode after existing fields |
| 7 | session.rs / executor.rs — use value on connect |
| 8 | disasm.rs — show in probe dump |
| 9 | docs/RSL_REFERENCE.md — document |
| 10 | Bump VERSION if layout changes |
Backward compatibility: old bytecode cannot read new fields—bump version and recompile all stored .rbc files.
Adding an instruction
Example: close <probe> to drop session.
| Step | File |
|---|---|
| 1 | grammar.pest + ast::Stmt |
| 2 | compile.rs — emit Instr::Close(probe_id) |
| 3 | bytecode.rs — enum variant |
| 4 | binary.rs — OP_CLOSE, write/read |
| 5 | opcode.rs — constant + table row |
| 6 | executor.rs — remove from context.sessions |
| 7 | disasm.rs — format line |
| 8 | Syntax test in syntax_tests.rs |
Prefer reusing Send + session false + new probe if semantics allow—fewer opcodes stay easier to reason about.
Adding a matcher field
Example: cert_cn on TLS socket responses (hypothetical).
contract::FieldKindvariant.matcher.rs— read from response (may need to store TLS info onSocketResponse).grammar.pest—kw_cert_cninqualified_field.match_expr.rs— map toFieldKind.- Binary matcher encode in
binary.rsif already generic—field kinds are tagged on wire.
HTTP extensions
Extend HttpItem in AST, http_item in grammar, probes.rs, write_http_spec / read_http_spec, and execute_http.
HTTP stays separate from SocketProbeSpec intentionally.
Contract sharing
Types in ruso-runtime/src/contract.rs are shared with the compiler via ruso_script::ast re-exports. Changing matchers affects both crates—publish runtime first, then bump script dependency.
Testing checklist
-
cargo buildall three crates -
cargo test -p ruso-script(parser) -
cargo test -p ruso-runtime(unit tests) -
ruso validateon affected examples -
Round-trip
compile→decode→encodefor bytecode changes -
One live
scanagainst lab service
Anti-patterns
| Avoid | Prefer |
|---|---|
OP_REDIS, OP_SMTP | tcp + payload |
| Forking executor per CVE | Parameterized .rsl |
| Raw SQL in scripts | Not applicable—keep I/O in runtime |
Breaking VERSION without bump | Explicit migration notes |
Multi-repo dependency graph
ruso-cli → ruso-script → ruso-runtime
Each crate is its own Git repository. Published Cargo.toml files use git dependencies on main:
ruso-runtime = { git = "https://github.com/Hopeless-Labs/ruso-runtime.git", branch = "main" }
ruso-script = { git = "https://github.com/Hopeless-Labs/ruso-script.git", branch = "main" }
For local work on all three at once, clone them as siblings and temporarily use path = "../ruso-*" in Cargo.toml, or add a [patch] table in the crate you are building.
When publishing to crates.io later, semver the runtime contract (VERSION, public types) independently from RSL.
Future directions (not implemented)
Documented for planning only:
--targetoverrides sockethost/portwhile/forwith variables- Byte-level matchers (
match hex) - ICMP probe kind
- Check signing / attestation of bytecode
These should follow the same probe-table + small-ISA pattern.
API Reference
Generated rustdoc for the two library crates at the core of Ruso. Reach for
these when embedding Ruso or building directly on the runtime or compiler.
ruso_runtime— the bytecode VM:Executor,ExecutorConfig,decode_bytecode,Finding,SocketProbeSpec, and the instruction set (Instr,opcode).ruso_script— the RSL parser and compiler:parse,compile, the AST (ast::Program,Stmt), and bytecode helpers.
These docs are regenerated from crate source on every documentation build. For the narrative explanation behind the types, read Architecture, The Compiler, Bytecode Format, and The Runtime VM.
Glossary
Check — one .rsl file describing a single security question and what a
positive result looks like.
RSL (Ruso Scripting Language) — the language checks are written in. Source
files use the .rsl extension.
Bytecode (.rbc) — the compact, validated binary a check compiles to. The
runtime executes bytecode; the registry stores and ships it.
Probe — a network request defined in a check (http, tcp, udp, dns)
that is not performed until send.
Send — the statement that performs a probe's request and stores the response under the probe's name.
Match — a statement testing a response field against a predicate. All matches in a run AND together into the match chain.
Match chain — the running AND of every match. Once one fails it latches
false and later match/evidence short-circuit.
Assert — like match, but a failure aborts the run with an error instead
of latching the chain false. Used for hard preconditions.
Finding — the result emitted when a check finishes with the match chain true
and a name/report set: metadata plus captured evidence.
Detected — the CLI verdict that a finding was emitted.
Evidence — proof strings attached to a finding (a body excerpt or a regex capture), recorded only while the match chain is true.
Metadata — the metadata { } block describing the finding (name, severity,
CWE/CVE, CVSS, family, version, …).
Family — a single curated structural category (web, database, tls, …);
the unit for scan --family.
Tags — many free-form discovery labels per check.
Target (--target) — what a check runs against. HTTP probes use it as the
base URL; socket probes read it via the scan_host/scan_port/scan_url
variables.
Session probe — a TCP/UDP/DNS probe with session true, which keeps the
connection open across multiple sends and appends responses.
Registry — the Ruso registry service where checks are published, searched, and installed.
Namespace — the owner segment of a check reference (<namespace>/<name>);
your username.
Reference (registry ref) — <namespace>/<name>[@<semver-range>], how a
published check is addressed.
PAT (Personal Access Token) — a longer-lived, scoped credential for authenticating to the registry without the web flow.
Yank — hide a published version from new installs without deleting it; existing installs keep working.
Install cache — ~/.ruso/scripts/<ns>/<name>/<version>.rbc, where fetched
checks are stored and reused (override the root with $RUSO_HOME).
Troubleshooting
Common errors and the footguns behind them. For the authoritative field/keyword detail, see the Language Reference.
"not a .rsl script file"
--script points at a file without the .rsl extension. Source checks must be
.rsl; compiled bytecode must be .rbc. Rename the file or use the right flag
(--bytecode for .rbc).
The check never detects (or always detects)
This is the single most important thing to rule out — test against both a
vulnerable and a safe target (Testing Your Checks). A check
that fires unconditionally usually has a match that's too loose (e.g. matching
the mere presence of a service rather than the vulnerable behaviour).
HTTP probe ignores the host I set
HTTP probes take their host from --target (the base URL); the host field in a
probe block is for socket probes (tcp/udp/dns). For sockets, interpolate
the scan target:
tcp redis { host "{{scan_host}}" port 6379 }
DNS matches never work
DNS has two modes with different match fields:
hostonly → OS resolver → match on.answer.host+port/payload→ DNS wire → match on.response/.banner.
Using .answer on a wire probe (or .response on a resolver probe) never
matches. See DNS modes.
evidence home.body errors on a TCP probe
.body is HTTP-only. For sockets use .response, or a probe-scoped regex:
evidence home.response
evidence home regex 'PONG'
Certificate verification failed
The target presents a certificate Ruso won't trust. If you intentionally trust
it (a self-signed lab box), pass --insecure on the CLI, or set
verify_ssl false on that specific HTTP probe. Never disable verification for
real targets.
A target shows up as skipped
A required port was already seen closed earlier in the same ruso process —
results are cached for ~30 seconds per run to avoid hammering a dead port. It's a
performance optimisation, not an error.
repeat fails to parse
repeat N … end was removed from the language. Use for item in <list> to
iterate, or retry <probe> <n> to re-send a probe.
Publish rejected: missing version / bad family
- Publishing requires a
version(SemVer) in metadata — localvalidate/compiledon't. familymust be one of the registry's curated set (auth,cloud,database,dns,mail,misc,network,tls,web). The language accepts any string; the registry enforces the set at publish time.
Authentication failures against the registry
Confirm you're logged in to the right registry — credentials are stored per
registry URL. Check with ruso whoami, and remember --registry /
$RUSO_REGISTRY_URL override the default. Re-login if a token expired.
Still stuck?
Run with -v / -vv for per-probe detail, and consult the
CLI Reference for exact flag behaviour. Bugs go to the
relevant repository's issue tracker.