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.