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:

ComponentWhat 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 registryThe 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

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

PathPurpose
~/.ruso/scripts/<ns>/<name>/<version>.rbcLocal install cache for registry checks. Override the root with $RUSO_HOME.
$XDG_CONFIG_HOME/ruso/credentials.jsonRegistry 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

VerdictMeaning
detectedThe check matched — a finding was emitted (with evidence).
not detectedThe target didn't meet the check's conditions.
skippedA required port was already seen closed in this run.
errorA precondition (assert) or probe failed.

Add -v / -vv for the detail behind any verdict.

Where to go next

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:

ProbeTransportTypical use
httpHTTP/HTTPSWeb apps, APIs, headers, status codes
tcpRaw TCP (optionally TLS)Banners, line protocols (Redis, SMTP…)
udpRaw UDPNTP, DNS wire, echo
dnsOS resolver or DNS wireName 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 http for tcp/udp/dns. See Socket probes.
  • Multiple steps — send several probes, use if, for, extract/save.
  • Prove it worksTesting 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.

StatementExample (inside metadata { })
namename "Open Redis"
descriptiondescription "…"
impactimpact "…"
severityseverity low | medium | high | critical | info
authorauthor "ruso-lab"
reportreport "Report title override"
cvecve ["CVE-2024-1234", "CVE-2024-5678"]
cwecwe ["CWE-79"]
referencesreferences ["https://…", "https://…"]
cvsscvss "CVSS:3.1/…" full vector string (repeat to list multiple)
cvss_scorecvss_score 9.8 numeric score literal (repeat to list multiple)
mitigationmitigation "…" single free-text remediation note (declaring it more than once is a compile error)
tagstags ["auth", "rce", "log4j"] free-form discovery labels
familyfamily "web" single curated category (see below)
versionversion "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):

VariableExample value
scan_hostexample.com
scan_port443
scan_urlhttps://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

ConfigurationBehaviorMatch on
host onlyOS DNS resolver.answer
host + port and/or payloadUDP 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 literalpayload "010203ff" decodes to raw bytes (DNS queries, NTP, …).

Send

send <probe_name>
send <probe_name> payload "next message"
send <probe_name> payload "deadbeef"
  • First send on a session true probe opens the connection.
  • Later send reuses the socket; with session true, response data is appended to the stored socket response (matchers see the full dialog).
  • Without session, each send replaces the stored response for that probe name.
  • payload on send overrides 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

FieldExample
statusmatch home.status == 200
bodymatch home.body contains "admin"
header("Name")match home.header("Server") contains "nginx"
response_timematch home.response_time < 500ms
response_sizematch home.response_size > 100

DNS resolver fields

FieldExample
answermatch lookup.answer contains "1.1.1.1"

Socket fields (tcp / udp / wire dns)

FieldExample
responsematch redis.response contains "PONG"
banneralias for response

Predicates

FormExample
Compare==, !=, <, >, <=, >= with number, string, or duration
Containscontains "text"
Not containsnot_contains "text"
Regexregex '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

matchassert
On failureSets match chain to false; run continuesAborts the run with an error
When chain already falseSkipped (no-op)Skipped (no-op)
Use forPositive finding logicHard 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 by set hosts ["a", "b"].
  • break — jump to instruction after the loop.
  • continue — skip to the next iteration of the current for.

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'
FormMeaning
evidence <probe>.bodyHTTP only — response body (max 500 chars)
evidence <probe>.responseFull 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

StatementEffect
stopStop script; no finding emitted (even if matchers passed)
exitStop script; emit finding if matchers passed and name/report set
failAbort with error
continueSkip 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:

LiteralMeaning
200ms200 milliseconds
30s30 seconds
5m5 minutes
1h1 hour
1d1 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

  1. --target vs socket host — HTTP uses --target as base URL; TCP/UDP/DNS wire use host in the script (prefer host "{{scan_host}}").
  2. DNS resolver vs wire — different match fields (.answer vs .response).
  3. evidence home.body on a TCP probe — use .response or evidence home regex.
  4. detected in CLI — requires a finding (name/report + matchers passed + not stop).
  5. Port cache (30s)skipped is per script run when a required port was already seen closed in this ruso process.
  6. session true — socket responses accumulate across send in a loop.
  7. 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 PINGPONG. 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

PatternExample
Web availability / contenthttp_status_ok.rsl
Header / version disclosurehttp_server_version_disclosure.rsl
DNS recon (wire)dns_wire_a.rsl, dns_wire_txt.rsl
Cleartext protocol testtcp_redis_unauth.rsl
Service fingerprint / bannertcp_http_banner.rsl
UDP service probeudp_ntp.rsl, udp_echo.rsl

Writing your own

  1. Copy the closest example.
  2. Change metadata for your finding (name, severity, cve [...], cwe [...], references [...], cvss, cvss_score, a single mitigation, …).
  3. Adjust host/port/payload (use payload bytes "<hex>" for control/binary bytes) or the HTTP path.
  4. Tighten matchers to reduce false positives.
  5. Add evidence for 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:

  1. Detects the vulnerable / exposed condition it targets.
  2. 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 one match failed, or the run hit stop.
  • skipped — a required port was already seen closed earlier in this ruso process (a 30-second per-run port cache).
  • error — an assert failed, a fail ran, or a probe errored (e.g. an evidence regex 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):

CommandPurpose
scanParse, compile, and run .rsl against targets
validateCheck .rsl syntax
compileWrite hex-encoded bytecode to <script>.rbc (silent on success)
execRun .rbc bytecode against targets

Registry (talks to the Ruso registry):

CommandPurpose
loginSave a PAT or session token for the active registry
logoutDelete the stored credential
whoamiShow the user the stored credential belongs to
publishUpload a .rsl script (under your own namespace)
installDownload <ns>/<name>[@<range>] into the local cache
searchSearch published scripts
infoShow registry metadata for a script (versions, install, tags, family)
yank / unyankPull / restore a published version (owner, idempotent)
editUpdate description / visibility of a script you own
pat list/create/revokeManage 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

FlagEffect
-q / --quietLess logging
-v / --verboseMore logging; live per-run status lines ([SEVERITY] …, [OK], [SKIP], [ERROR]) during scan/exec
RUST_LOGOverrides default filter

Registry URL resolution

Every command that talks to a backend resolves the registry base URL in this order:

  1. --registry <URL> flag on the command
  2. RUSO_REGISTRY_URL environment variable
  3. Built-in default https://ruso.hopeless-labs.com (the hosted registry; use http://127.0.0.1:8080 to 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:

  1. If the argument exists on the filesystem → treat as a path. (Local files always win, so a directory named myorg/check still works.)
  2. Else if it parses as a registry ref → resolve through the install cache ($RUSO_HOME or $HOME/.ruso/scripts/<ns>/<name>/<version>.rbc), downloading from the registry on cache miss.
  3. Else → error.

validate

ruso validate --script check.rsl
ruso validate --script ./checks/
  • File must be .rsl; directory collects *.rsl recursively.
  • Exit 0 if 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.rbc beside check.rsl (ASCII text, not raw binary).
  • No stdout on success.
  • exec decodes hex from .rbc before running (legacy raw-binary .rbc with RUSO header still works).
  • While the runtime is 0.1.0-dev the v1 wire format may change between commits without a version bump — recompile your .rbc files 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
FlagDescription
--bytecode.rbc file, directory of .rbc files, or registry ref <ns>/<name>[@<range>]
--targetURL (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)
--timeoutDefault 30s
--read-timeoutPer-read I/O timeout for socket probes (default 10s)
--max-response-bytesHTTP body cap (default 10 MiB)
--no-follow-redirectsHTTP
--insecureDisable 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
--proxyHTTP proxy
--retriesAuto-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-timeoutPer-script wall-clock budget (default 5m)
--concurrencyParallel (target × script) runs (default 16)
--max-per-hostCap 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.
--rpsCap 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.
--outputhuman, json, csv
--reportRequired for json/csv

Migration note: the previous --verify-tls flag (a positive opt-in for verification, disabled by default) is gone. Verification is now the default; pass --insecure to 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 + port from tcp / udp / wire-mode dns
  • HTTP checks: host + port from --target (e.g. https://example.comexample.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):

FlagDescription
--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-probeSkip 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.

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

FlagEffect
--forceRe-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-versionsInstall 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.
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
FlagEffect
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].
--jsonEmit 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.

FlagEffect
Positional <REF><ns>/<name> or <ns>/<name>@<semver-range>.
--jsonEmit 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.

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

FlagEffect
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

FlagEffect
--active-onlyHide revoked tokens. By default they're shown with a revoked status marker so you can audit what's been cleaned up.
--jsonMachine-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 create calls — a leaked PAT shouldn't be able to mint sibling PATs. Sign in via the web UI's Tokens page, grab the ruso_sess_… cookie, and ruso login with that to use this command. pat list and pat revoke work with either token type.

FlagEffect
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

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

FieldSource in .rsl
cvecve ["…", "…"] list (JSON array; CSV joined with |)
cwecwe ["…"] list
referencesreferences ["…", "…"] list (URLs, advisories, etc.)
cvssRepeatable cvss "…" lines (CVSS vector, e.g. CVSS:3.1/…)
cvss_scoreRepeatable cvss_score 9.8 lines (numeric literal, stored as string in reports)
mitigationSingle mitigation "…" line (remediation guidance; declaring it twice is a compile error)
versionversion "X.Y.Z" (script SemVer)
familyfamily "web" (curated category)
tagstags ["…", "…"] 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_reason is set when a run did not execute because a required port was closed (port 80 closed, etc.). skipped is true in the same row.
  • error is set only for genuine failures (parse failure, IO error, runtime fail opcode, 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

  • --target accepts 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, scan resolves the scheme https-first: it probes https:// and uses it on any HTTP response; it falls back to http:// 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 --insecure was 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; default https) and --no-scheme-probe (skip the probe, apply --default-scheme directly). A non-HTTP scan (TCP/UDP/DNS only — Redis, NTP, …) skips the probe and keeps an http:// carrier, since the scheme never reaches the wire (--target 127.0.0.1).
  • HTTP checks use --target as the request base URL.
  • TCP/UDP/DNS wire checks use host in the .rsl script. Prefer host "{{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: UnknownIssuer rather than a bare error sending request.
  • ruso validate / ruso compile fail if the script has match or evidence but no name or report metadata.

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 author field 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). Keep name short and use report for 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

CommandEffect
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/revokeManage 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
  1. Parsegrammar.pestProgram { statements }.
  2. Build spec — metadata + probe definitions → ProgramSpec (probe table only; no control flow).
  3. Compile — executable statements → Vec<Instr> + string/matcher/payload pools.
  4. ExecuteExecutor walks instructions, calls send_probe, evaluates matchers, emits findings.

Why probes are not opcodes

Enterprise scanners often embed protocol logic in plugins. Ruso instead uses:

LayerResponsibility
Probe tableWhat to send (HTTP path, TCP payload, DNS wire bytes, …)
InstructionsWhen 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 .rsl source.
  • Owns all network I/O (reqwest, tokio sockets, tokio-rustls). build_client is 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:

ModulePurpose
contract.rsMatchers, severity, HTTP method, evidence
runtime/spec.rsHttpRequestSpec, SocketProbeSpec, ProbeKind
runtime/bytecode.rsInstr enum
runtime/binary.rsWire encode/decode v1
runtime/executor.rsVM main loop
runtime/http.rsHTTP client requests
runtime/session.rsTCP/TLS connect, multi-read, UDP
runtime/socket.rsOne-shot and session exchanges
runtime/dns.rsOS resolver vs wire UDP DNS
runtime/matcher.rsField predicates
runtime/context.rsVariables, responses, sessions, loop stack

ruso-script

  • Public API: parse(), compile(), Program, AST types.
  • Depends on ruso-runtime for ProgramSpec and contract types.
  • Pest grammar is the source of truth for syntax.

Pipeline: parsebuild_program_speccompile.

ruso-cli

  • Clap CLI: scan, parse, compile, exec.
  • Wires ExecutorConfig (base URL, timeout, TLS verify, proxy) from flags.
  • Discovers .rsl files and target lists for batch scans.

Repositories (not a monorepo)

Ruso ships as three independent Git repositories under Hopeless-Labs:

RepositoryRole
ruso-runtimeVM, bytecode wire format, network I/O (this repo)
ruso-scriptPest grammar, compiler, examples/*.rsl
ruso-cliruso 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:

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:

KindConditionBehavior
Dnsno port, no payloadOS resolver (tokio::net::lookup_host) → ProbeResponse::DnsResolve
Dnsport and/or payloadUDP to host:port (default 53) → ProbeResponse::Socket
Tcpport requiredTCP (+ optional TLS) exchange → Socket
Udpport requiredUDP 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:

  • variablesset / extract
  • responses — map probe name → ProbeResponse
  • sessions — open TCP/TLS or UDP sockets when session true
  • loop_stackForList / LoopBack / Break
  • matched — AND-chain for matchers until one fails
  • evidence — strings for the final finding

Findings

A finding is emitted when:

  1. Match chain stayed true (context.matched), and
  2. finalize_finding() runs at end of bytecode (metadata name/severity, optional advisory fields including cve / 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 .rsl files 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)?;
FunctionOutput
parseProgram { statements: Vec<Stmt> }
compileBytecodeProgram

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 / Udp into ProgramSpec.probes: HashMap<String, ProbeKind>

Executable statements are ignored here.

compile

File: src/compile.rs.

Compiler maintains:

  • code: Vec<Instr>
  • strings + string_ids dedup map
  • payloads + payload_ids dedup map (byte equality)
  • matchers, extracts, evidence append-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

ModuleResponsibility
grammar.pestSyntax
parser/mod.rsbuild_statement dispatch
parser/metadata.rsname, description, impact, severity, author, report, cve, cwe, references, cvss, cvss_score, mitigation, tags, version, family
parser/probes.rshttp block items
parser/socket.rsdns / tcp / udp shared builder
parser/match_expr.rsqualified matchers, groups
parser/statements.rssend, if, for, flow, …
parser/body.rsHTTP body objects

Nesting-depth guard

pest is a recursive-descent parser, so each nested block (if / forend) 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

  1. Add keyword to grammar.pest (socket_item arm).
  2. Parse in parser/socket.rs → field on SocketProbe.
  3. Copy in spec_build::socket_spec.
  4. Extend SocketProbeSpec in runtime + write_socket_probe / read_socket_probe.
  5. Implement behavior in session.rs / executor.rs.
  6. Document in RSL_REFERENCE.md and bump VERSION if wire format changes.

Adding a statement

  1. grammar.pest — new statement alternative.
  2. ast.rsStmt variant.
  3. parser — builder.
  4. compile.rsemit_stmt + new Instr if needed.
  5. executor.rs + binary.rs + opcode.rs for 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 VERSION bump 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::Number as u64 (earlier development revisions truncated to u32).
  • Assigns HTTP method tags 5 and 6 to Head and Options.
  • Bounds every untrusted list/count against the remaining buffer in the decoder, so a malicious or corrupt .rbc file cannot trigger OOM allocations from a u32::MAX count.
  • Bounds-checks every instruction operand index against its pool after decoding (see Operand validation), so an out-of-range index surfaces as a Corrupt error instead of panicking the executor.

File layout

Sections are written in order:

#SectionContent
1HeaderMAGIC + VERSION
2MetadataSee Metadata section
3Probe tablecount + (name, ProbeKind)*
4String poolUTF-8 strings (identifiers, durations as text, …)
5Payload poolraw byte blobs for Send overrides
6Matcher poolQualifiedMatch entries
7Extract poolExtractSource entries
8Evidence poolEvidenceKind entries
9Codeinstruction 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)

TagMethod
0GET
1POST
2PUT
3PATCH
4DELETE
5HEAD
6OPTIONS

Probe kinds (wire tag)

TagVariantBody
0HttpHttpRequestSpec (method, path, options, bodies, …)
1DnsSocketProbeSpec
2TcpSocketProbeSpec
3UdpSocketProbeSpec

SocketProbeSpec

Binary order:

  1. host — length-prefixed UTF-8 string
  2. port — optional u16 (u8 flag + value)
  3. payload — optional byte blob (u8 flag + u32 len + bytes)
  4. tlsu8 (0/1)
  5. sessionu8 (0/1)
  6. read_maxu32
  7. read_idle_msu32

Instruction set

Wire opcode byte → Instr variant:

OpNameOperands
1Setname_id: u32, value_id: u32
2Sendprobe_id: u32, has_payload: u8, optional payload_id: u32
3Matchmatcher_id: u32
4MatchAllstart: u32, len: u16
5MatchAnystart: u32, len: u16
6Assertmatcher_id: u32
7Extractname_id: u32, source_id: u32
8IfMatchmatcher_id: u32, else_pc: u32
9Savefrom_id: u32, to_id: u32
10Evidencekind_id: u32
11Retryprobe_id: u32, count: u32
12RetryDelayduration_id: u32 (string pool)
13Sleepduration_id: u32
14Stop
15Fail
16Continue
17Exit
18(reserved)was Repeat, removed
19LoopBack
20Break
21SetListname_id: u32, start: u32, len: u16
22ForListitem_id: u32, start: u32, len: u16, end_pc: u32
23ForVaritem_id: u32, list_id: u32, end_pc: u32

Public constants: ruso_runtime::opcode::{OP_*}.

CmpValue encoding

TagVariantWire
0Number(u64)u64 little-endian
1String(String)length-prefixed UTF-8
2Duration(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:

  • IfMatchelse_pc set after body is emitted.
  • ForListend_pc set after LoopBack is emitted.

Executor semantics:

  • ForList — pushes a LoopFrame over the literal list, binding the item variable each iteration.
  • LoopBack — advances the for iterator; if more items remain, jump to head_pc, else pop frame and continue after loop.
  • Break — pop innermost frame, jump to end_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):

FieldEncoding
nameoptional UTF-8 string
descriptionoptional string
impactoptional string
severityu8 tag (0=absent, else 1–5 for low…critical)
authoroptional string
report_titleoptional string (report in RSL)
cveu32 count + strings
cweu32 count + strings
referencesu32 count + strings
cvssu32 count + strings (vector)
cvss_scoreu32 count + strings (numeric score)
mitigationu32 count + strings
tagsu32 count + strings (discovery labels)
versionoptional UTF-8 string (SemVer, required at publish)
familyoptional 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 QualifiedMatch structs
  • Extracts / Evidence — parallel structures

Evidence pool entries (EvidenceKind):

TagFormWire
0body <probe>probe name string
1regex <probe> <pattern>probe name + pattern string
2response <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:

FieldMeaning
successNo fail instruction
detectedA finding was produced
reportFindings + metadata
variablesFinal variable map
metadataCheck metadata from spec (CheckMetadata)

Finding / CheckMetadata

When a check matches, finalize_finding() builds one Finding from metadata plus collected evidence:

FieldSource
namename, or report title if name omitted
description, impact, authorOptional metadata strings
severityMetadata severity, default info
cve, cwe, references, cvss, cvss_scoreRepeatable RSL lines → Vec<String>
mitigationSingle free-text line → Option<String> (declaring it twice is a compile error)
evidenceevidence <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 = true
  • ExecutionResult.skip_reason — e.g. port example.com:443 closed
  • ExecutionResult.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

FieldDefaultRole
base_url""HTTP probe base (from CLI --target)
default_timeout30sConnect/read fallback
read_timeout10sPer-read I/O timeout for socket probes
max_response_bytes10 MiBHTTP body cap
follow_redirecttrueHTTP client
verify_ssltrueHTTP and TCP TLS (tls true)
proxynoneHTTP proxy URL
max_script_duration5 minutesWall-clock budget per script (None disables)
http_retries2Transient-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

  1. Resolve probe name in program.spec.probes.
  2. interpolate_socket_spec — substitute {{ var }} in host/payload when payload is valid UTF-8.
  3. 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 (port and payload both absent): resolve_hostProbeResponse::DnsResolve.
  • Wire mode: run_dns_probe → UDP exchange → ProbeResponse::Socket.

TCP

Requires port. Uses exchange_tcp_probe:

sessionBehavior
falseConnect, optional TLS, one exchange, close
trueReuse 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 / banner on data

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

CrateUse
tokioAsync runtime, sockets
reqwestHTTP
tokio-rustls / rustlsTCP TLS
regexMatchers and extract
tracingInstrumentation (RUST_LOG)

Testing runtime changes

  1. Unit tests in runtime/* modules (#[cfg(test)]).
  2. Compile a .rsl script and Executor::from_bytecode + manual run.
  3. format_human / round-trip encodedecode for 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.

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)

  1. Create my_check.rsl with metadata + probe + send + match.
  2. ruso validate --script my_check.rsl
  3. Test with ruso scan against a lab target.
  4. Ship the .rsl file; 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.

StepFile
1grammar.pest — keyword + metadata_stmt arm
2ast.rsStmt variant
3parser/metadata.rs + spec_build.rs
4spec.rsCheckMetadata field
5binary.rswrite_metadata / read_metadata (append at end of metadata block)
6report.rs / disasm.rsFinding + listing
7docs/RSL_REFERENCE.md, BYTECODE.md, RUNTIME.md
8ruso-cli report.rs if exposed in scan output
9Recompile all .rbc files; bump VERSION only if probe or instruction layout changes

Adding a socket probe option

Example: connect_timeout 5s.

StepFile
1grammar.pestkw_connect_timeout ~ duration in socket_item
2ast.rs — field on SocketProbe
3parser/socket.rs — parse duration → ms or store string
4spec.rsSocketProbeSpec field + Default
5spec_build.rs — copy field
6binary.rs — encode/decode after existing fields
7session.rs / executor.rs — use value on connect
8disasm.rs — show in probe dump
9docs/RSL_REFERENCE.md — document
10Bump 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.

StepFile
1grammar.pest + ast::Stmt
2compile.rs — emit Instr::Close(probe_id)
3bytecode.rs — enum variant
4binary.rsOP_CLOSE, write/read
5opcode.rs — constant + table row
6executor.rs — remove from context.sessions
7disasm.rs — format line
8Syntax 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).

  1. contract::FieldKind variant.
  2. matcher.rs — read from response (may need to store TLS info on SocketResponse).
  3. grammar.pestkw_cert_cn in qualified_field.
  4. match_expr.rs — map to FieldKind.
  5. Binary matcher encode in binary.rs if 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 build all three crates
  • cargo test -p ruso-script (parser)
  • cargo test -p ruso-runtime (unit tests)
  • ruso validate on affected examples
  • Round-trip compiledecodeencode for bytecode changes
  • One live scan against lab service

Anti-patterns

AvoidPrefer
OP_REDIS, OP_SMTPtcp + payload
Forking executor per CVEParameterized .rsl
Raw SQL in scriptsNot applicable—keep I/O in runtime
Breaking VERSION without bumpExplicit 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:

  • --target overrides socket host / port
  • while / for with 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:

  • host only → 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 — local validate/ compile don't.
  • family must 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.