Skip to main content

ruso_runtime/runtime/
executor.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use regex::Regex;
6use reqwest::Client;
7
8use crate::contract::{EvidenceKind, ExtractSource};
9use crate::runtime::binary;
10use crate::runtime::bytecode::{BytecodeProgram, Instr};
11use crate::runtime::context::{Context, LoopFrame, LoopState, VariableValue};
12use crate::runtime::dns::{resolve_host, run_dns_probe};
13use crate::runtime::duration::parse_duration;
14use crate::runtime::error::RuntimeError;
15use crate::runtime::http::{build_client, execute_http};
16use crate::runtime::interpolate::interpolate;
17use crate::runtime::matcher::{CompiledMatcherRegex, evaluate, evaluate_all, evaluate_any};
18use crate::runtime::port_cache::{PortCache, PortCheck, scan_target_host_port};
19use crate::runtime::report::Report;
20use crate::runtime::response::ProbeResponse;
21use crate::runtime::response::SocketResponse;
22use crate::runtime::session::{
23    ProbeSession, open_tcp_session, open_udp_session, read_opts_from_spec,
24};
25use crate::runtime::socket::{
26    exchange_tcp, exchange_udp, tcp_session_exchange, udp_session_exchange,
27};
28use crate::runtime::spec::ProbeKind;
29use crate::runtime::spec::SocketProbeSpec;
30
31/// Runtime configuration for an [`Executor`]: the target base URL plus the
32/// network, TLS, and safety knobs. Use [`ExecutorConfig::default`] and override
33/// the fields you care about.
34#[derive(Debug, Clone)]
35pub struct ExecutorConfig {
36    /// Base URL HTTP probe `path`s are joined onto, and the source of the
37    /// `scan_host` / `scan_port` / `scan_url` variables (the CLI `--target`).
38    pub base_url: String,
39    /// Connect timeout for HTTP requests and TCP/UDP/DNS probes.
40    pub default_timeout: Duration,
41    /// Per-read I/O timeout for socket probes (TCP/UDP/DNS). Falls back to
42    /// `default_timeout` if not explicitly tuned.
43    pub read_timeout: Duration,
44    /// Maximum HTTP response body size in bytes. Larger responses are
45    /// truncated at this boundary to bound memory use against malicious
46    /// or misconfigured targets.
47    pub max_response_bytes: usize,
48    /// Follow HTTP redirects (per-probe `follow_redirect` overrides this).
49    pub follow_redirect: bool,
50    /// Verify TLS server certificates. Default is `true`; set to `false`
51    /// (CLI `--insecure`) only for explicitly trusted scan environments —
52    /// otherwise the scanner is exposed to MITM that can plant findings
53    /// or read in-flight credentials.
54    pub verify_ssl: bool,
55    /// Optional proxy URL for HTTP probes (e.g. `http://127.0.0.1:8080`).
56    pub proxy: Option<String>,
57    /// Wall-clock budget for a single script execution. `None` disables.
58    /// Defaults to 5 minutes so a hostile/buggy bytecode (deep loops, long
59    /// `sleep`s, etc.) cannot pin a tokio worker.
60    pub max_script_duration: Option<Duration>,
61    /// How many times to retry an HTTP probe that fails with a *transient*
62    /// transport error (connection reset, connect/read timeout) before giving
63    /// up. `0` disables. A probe driven by the script's own `retry` directive
64    /// is exempt — the author controls retries there. Defaults to `2`.
65    pub http_retries: u32,
66}
67
68impl Default for ExecutorConfig {
69    fn default() -> Self {
70        Self {
71            base_url: String::new(),
72            default_timeout: Duration::from_secs(30),
73            read_timeout: Duration::from_secs(10),
74            max_response_bytes: 10 * 1024 * 1024, // 10 MiB
75            follow_redirect: true,
76            verify_ssl: true,
77            proxy: None,
78            max_script_duration: Some(Duration::from_secs(300)),
79            http_retries: 2,
80        }
81    }
82}
83
84/// Executes a compiled program against a target. Construct one with
85/// [`from_bytes`](Executor::from_bytes), [`from_bytecode`](Executor::from_bytecode),
86/// or [`from_program`](Executor::from_program), then call
87/// [`run`](Executor::run).
88pub struct Executor {
89    config: ExecutorConfig,
90    /// Program shared via `Arc` so the same compiled script can run against
91    /// many targets without cloning the bytecode (string pool, payload pool,
92    /// matcher pool, etc.) for each.
93    program: Arc<BytecodeProgram>,
94    client: Client,
95    /// Pre-compiled regexes aligned by index with `program.matchers`. Built
96    /// once at executor construction so per-run / per-loop-iteration matcher
97    /// dispatch never pays the regex compile cost again.
98    compiled_matcher_regex: Arc<[CompiledMatcherRegex]>,
99    /// Pre-compiled regexes for evidence rules that carry a regex, aligned with
100    /// `program.evidence`. `None` for non-regex evidence kinds.
101    compiled_evidence_regex: Arc<[Option<Regex>]>,
102    /// Pre-compiled regexes for `ExtractSource::Body { regex: Some(...) }`,
103    /// aligned with `program.extracts`.
104    compiled_extract_regex: Arc<[Option<Regex>]>,
105}
106
107/// The outcome of one [`Executor::run`]: whether a finding was produced, plus
108/// the report, port-check details, and final variable state.
109#[derive(Debug, Clone)]
110pub struct ExecutionResult {
111    /// The script ran to completion without an aborting error.
112    pub success: bool,
113    /// True when `finalize_finding()` produced a finding (metadata + matchers passed).
114    pub detected: bool,
115    /// Check did not run because a required socket port was closed (see `skip_reason`).
116    pub skipped: bool,
117    /// Human-readable reason the check was skipped, if `skipped`.
118    pub skip_reason: Option<String>,
119    /// Port probes performed before execution (empty for HTTP-only checks).
120    pub port_checks: Vec<PortCheck>,
121    /// The findings report (empty unless `detected`).
122    pub report: Report,
123    /// Final value of every variable set during the run.
124    pub variables: HashMap<String, VariableValue>,
125    /// The check's metadata, echoed for convenience.
126    pub metadata: crate::runtime::spec::CheckMetadata,
127}
128
129impl Executor {
130    /// Decode a raw `.rbc` byte buffer and build an executor from it.
131    /// Fails with [`RuntimeError::Bytecode`] if the bytes are not a valid
132    /// program for this runtime version.
133    pub fn from_bytes(config: ExecutorConfig, bytes: &[u8]) -> Result<Self, RuntimeError> {
134        let program = binary::decode(bytes).map_err(RuntimeError::Bytecode)?;
135        Self::from_bytecode(config, program)
136    }
137
138    /// Build an executor from an already-decoded [`BytecodeProgram`]. Prefer
139    /// [`from_program`](Executor::from_program) when running one program
140    /// against many targets.
141    pub fn from_bytecode(
142        config: ExecutorConfig,
143        program: BytecodeProgram,
144    ) -> Result<Self, RuntimeError> {
145        Self::from_program(config, Arc::new(program))
146    }
147
148    /// Construct an executor sharing a pre-built `Arc<BytecodeProgram>`.
149    ///
150    /// Prefer this over [`Executor::from_bytecode`] when a single compiled
151    /// script is run against many targets: the program (and the regex caches
152    /// derived from it) are cloned via `Arc` rather than deep-copied.
153    pub fn from_program(
154        config: ExecutorConfig,
155        program: Arc<BytecodeProgram>,
156    ) -> Result<Self, RuntimeError> {
157        let client = build_client(
158            Some(config.default_timeout),
159            config.follow_redirect,
160            config.verify_ssl,
161            config.proxy.as_deref(),
162        )?;
163        let compiled_matcher_regex: Arc<[CompiledMatcherRegex]> = program
164            .matchers
165            .iter()
166            .map(CompiledMatcherRegex::compile)
167            .collect::<Result<Vec<_>, _>>()?
168            .into();
169        let compiled_evidence_regex: Arc<[Option<Regex>]> = program
170            .evidence
171            .iter()
172            .map(|kind| match kind {
173                EvidenceKind::Body { pattern, .. }
174                | EvidenceKind::Response { pattern, .. }
175                | EvidenceKind::Header { pattern, .. } => match pattern {
176                    Some(p) => Regex::new(p).map(Some),
177                    None => Ok(None),
178                },
179            })
180            .collect::<Result<Vec<_>, regex::Error>>()?
181            .into();
182        let compiled_extract_regex: Arc<[Option<Regex>]> = program
183            .extracts
184            .iter()
185            .map(|src| match src {
186                ExtractSource::Body {
187                    regex: Some(pattern),
188                    ..
189                } => Regex::new(pattern).map(Some),
190                _ => Ok(None),
191            })
192            .collect::<Result<Vec<_>, regex::Error>>()?
193            .into();
194        Ok(Self {
195            config,
196            program,
197            client,
198            compiled_matcher_regex,
199            compiled_evidence_regex,
200            compiled_extract_regex,
201        })
202    }
203
204    pub fn bytecode(&self) -> &BytecodeProgram {
205        &self.program
206    }
207
208    /// Execute the program against the configured target and return the
209    /// [`ExecutionResult`]. Probes that must reach a closed port short-circuit
210    /// to a `skipped` result; an `assert`/`fail` or transport error returns
211    /// `Err`.
212    pub async fn run(&self) -> Result<ExecutionResult, RuntimeError> {
213        self.run_bytecode().await
214    }
215
216    fn client_for_http(
217        &self,
218        spec: &crate::runtime::spec::HttpRequestSpec,
219    ) -> Result<Client, RuntimeError> {
220        let verify_ssl = spec.verify_ssl.unwrap_or(self.config.verify_ssl);
221        let follow_redirect = spec.follow_redirect.unwrap_or(self.config.follow_redirect);
222        if verify_ssl == self.config.verify_ssl && follow_redirect == self.config.follow_redirect {
223            return Ok(self.client.clone());
224        }
225        build_client(
226            Some(self.config.default_timeout),
227            follow_redirect,
228            verify_ssl,
229            self.config.proxy.as_deref(),
230        )
231    }
232
233    async fn run_bytecode(&self) -> Result<ExecutionResult, RuntimeError> {
234        let cache = PortCache::global();
235        let (port_checks, closed) = cache
236            .check_for_run(&self.program.spec, &self.config.base_url)
237            .await;
238        if let Some((host, port)) = closed {
239            return Ok(ExecutionResult {
240                success: true,
241                detected: false,
242                skipped: true,
243                skip_reason: Some(format!("port {host}:{port} closed")),
244                port_checks,
245                report: Report::default(),
246                variables: HashMap::new(),
247                metadata: self.program.spec.metadata.clone(),
248            });
249        }
250
251        let mut context = Context::from_spec(&self.program.spec);
252        inject_scan_target_variables(&mut context, &self.config.base_url);
253        let mut pc: usize = 0;
254        let started_at = Instant::now();
255        let budget = self.config.max_script_duration;
256
257        while pc < self.program.code.len() {
258            if let Some(limit) = budget
259                && started_at.elapsed() > limit
260            {
261                return Err(RuntimeError::Other(format!(
262                    "script execution exceeded budget of {:?}",
263                    limit
264                )));
265            }
266            match &self.program.code[pc] {
267                Instr::Set { name, value } => {
268                    let name = &self.program.strings[*name as usize];
269                    let value = &self.program.strings[*value as usize];
270                    context.set_variable(name.clone(), interpolate(value, &context.variables)?);
271                    pc += 1;
272                }
273                Instr::SetList { name, start, len } => {
274                    let name = &self.program.strings[*name as usize];
275                    let values = self.program.strings
276                        [*start as usize..(*start + *len as u32) as usize]
277                        .iter()
278                        .map(|value| interpolate(value, &context.variables))
279                        .collect::<Result<Vec<_>, _>>()?;
280                    context.set_list_variable(name.clone(), values);
281                    pc += 1;
282                }
283                Instr::Send { probe, payload } => {
284                    let name = &self.program.strings[*probe as usize];
285                    let payload_override =
286                        payload.map(|id| self.program.payloads[id as usize].clone());
287                    tracing::trace!(probe = %name, "send");
288                    self.send_probe(
289                        name,
290                        payload_override,
291                        self.config.http_retries,
292                        &mut context,
293                    )
294                    .await?;
295                    pc += 1;
296                }
297                Instr::Match(matcher) => {
298                    self.apply_match(*matcher as usize, &mut context)?;
299                    pc += 1;
300                }
301                Instr::MatchAll { start, len } => {
302                    self.apply_match_all(*start as usize, *len as usize, &mut context)?;
303                    pc += 1;
304                }
305                Instr::MatchAny { start, len } => {
306                    self.apply_match_any(*start as usize, *len as usize, &mut context)?;
307                    pc += 1;
308                }
309                Instr::Assert(matcher) => {
310                    self.require_assert(*matcher as usize, &context)?;
311                    pc += 1;
312                }
313                Instr::Extract { name, source } => {
314                    if context.matched {
315                        let name = &self.program.strings[*name as usize];
316                        let source_idx = *source as usize;
317                        let source = &self.program.extracts[source_idx];
318                        self.extract(name, source, source_idx, &mut context)?;
319                    }
320                    pc += 1;
321                }
322                Instr::IfMatch { matcher, else_pc } => {
323                    if !context.matched || !self.matches_idx(*matcher as usize, &context)? {
324                        pc = *else_pc as usize;
325                    } else {
326                        pc += 1;
327                    }
328                }
329                Instr::ForList {
330                    item,
331                    start,
332                    len,
333                    end_pc,
334                } => {
335                    let item = self.program.strings[*item as usize].clone();
336                    let values = self.program.strings
337                        [*start as usize..(*start + *len as u32) as usize]
338                        .iter()
339                        .map(|value| interpolate(value, &context.variables))
340                        .collect::<Result<Vec<_>, _>>()?;
341                    pc = self.enter_foreach(&mut context, item, values, pc, *end_pc as usize);
342                }
343                Instr::ForVar { item, list, end_pc } => {
344                    let item = self.program.strings[*item as usize].clone();
345                    let list_name = &self.program.strings[*list as usize];
346                    let values = match context.variables.get(list_name) {
347                        Some(VariableValue::List(values)) => values.clone(),
348                        Some(VariableValue::String(_)) => {
349                            return Err(RuntimeError::Other(format!(
350                                "variable {list_name} is not a list"
351                            )));
352                        }
353                        None => Vec::new(),
354                    };
355                    pc = self.enter_foreach(&mut context, item, values, pc, *end_pc as usize);
356                }
357                Instr::LoopBack => pc = self.step_loop_back(&mut context)?,
358                Instr::Break => pc = self.step_break(&mut context)?,
359                Instr::Save { from, to } => {
360                    let from = &self.program.strings[*from as usize];
361                    let to = &self.program.strings[*to as usize];
362                    context.alias_response(from, to);
363                    pc += 1;
364                }
365                Instr::Evidence(kind) => {
366                    if !context.matched {
367                        pc += 1;
368                        continue;
369                    }
370                    let kind_idx = *kind as usize;
371                    let kind = &self.program.evidence[kind_idx];
372                    let text = self.collect_evidence(kind, kind_idx, &context)?;
373                    context.evidence.push(text);
374                    pc += 1;
375                }
376                Instr::Retry { probe, count } => {
377                    let name = &self.program.strings[*probe as usize];
378                    self.retry_send(name, *count, &mut context).await?;
379                    pc += 1;
380                }
381                Instr::RetryDelay(value) => {
382                    let value = &self.program.strings[*value as usize];
383                    context.retry_delay = Some(parse_duration(value)?);
384                    pc += 1;
385                }
386                Instr::Sleep(value) => {
387                    let value = &self.program.strings[*value as usize];
388                    tokio::time::sleep(parse_duration(value)?).await;
389                    pc += 1;
390                }
391                Instr::Stop => {
392                    tracing::warn!("stop");
393                    context.emit_finding = false;
394                    break;
395                }
396                Instr::Exit => {
397                    tracing::info!("exit");
398                    break;
399                }
400                Instr::Fail => {
401                    tracing::error!("fail");
402                    context.failed = true;
403                    return Err(RuntimeError::Flow("fail".into()));
404                }
405                Instr::Continue => pc = self.step_continue(&context)?,
406            }
407        }
408
409        context.close_sessions();
410        context.finalize_finding();
411
412        Ok(ExecutionResult {
413            success: !context.failed,
414            detected: !context.report.findings.is_empty(),
415            skipped: false,
416            skip_reason: None,
417            port_checks,
418            report: context.report,
419            variables: context.variables,
420            metadata: context.metadata,
421        })
422    }
423
424    /// Enter a `for` loop: bind `item` to the first value and push a loop
425    /// frame, or skip the body entirely when the list is empty. Returns the
426    /// next program counter — the loop body (`pc + 1`) or `end_pc`.
427    fn enter_foreach(
428        &self,
429        context: &mut Context,
430        item: String,
431        values: Vec<String>,
432        pc: usize,
433        end_pc: usize,
434    ) -> usize {
435        if values.is_empty() {
436            return end_pc;
437        }
438        let previous = context.variables.get(&item).cloned();
439        context.set_variable(item.clone(), values[0].clone());
440        context.loop_stack.push(LoopFrame {
441            state: LoopState::ForEach {
442                item,
443                values,
444                index: 0,
445                previous,
446            },
447            head_pc: pc + 1,
448            continue_pc: end_pc.saturating_sub(1),
449            end_pc,
450        });
451        pc + 1
452    }
453
454    /// Advance the innermost loop on `loop_back`: bind the next item and jump
455    /// to the loop head, or — when exhausted — pop the frame, restore the
456    /// shadowed variable, and continue past the loop. Returns the next pc.
457    fn step_loop_back(&self, context: &mut Context) -> Result<usize, RuntimeError> {
458        // Decide the action while borrowing the frame, then mutate `context`
459        // after that borrow ends (the two can't overlap).
460        enum Next {
461            Jump {
462                item: String,
463                value: String,
464                head_pc: usize,
465            },
466            End {
467                item: String,
468                previous: Option<VariableValue>,
469                end_pc: usize,
470            },
471        }
472        let next = {
473            let frame = context
474                .loop_stack
475                .last_mut()
476                .ok_or_else(|| RuntimeError::Other("loop_back outside loop".into()))?;
477            let LoopState::ForEach {
478                item,
479                values,
480                index,
481                previous,
482            } = &mut frame.state;
483            if *index + 1 < values.len() {
484                *index += 1;
485                Next::Jump {
486                    item: item.clone(),
487                    value: values[*index].clone(),
488                    head_pc: frame.head_pc,
489                }
490            } else {
491                Next::End {
492                    item: item.clone(),
493                    previous: previous.clone(),
494                    end_pc: frame.end_pc,
495                }
496            }
497        };
498        Ok(match next {
499            Next::Jump {
500                item,
501                value,
502                head_pc,
503            } => {
504                context.set_variable(item, value);
505                head_pc
506            }
507            Next::End {
508                item,
509                previous,
510                end_pc,
511            } => {
512                context.loop_stack.pop();
513                context.restore_or_remove_variable(item, previous);
514                end_pc
515            }
516        })
517    }
518
519    /// `break`: pop the innermost loop frame, restore its shadowed variable,
520    /// and return the program counter just past the loop (`end_pc`).
521    fn step_break(&self, context: &mut Context) -> Result<usize, RuntimeError> {
522        let frame = context
523            .loop_stack
524            .pop()
525            .ok_or_else(|| RuntimeError::Other("break outside loop".into()))?;
526        let LoopState::ForEach { item, previous, .. } = frame.state;
527        context.restore_or_remove_variable(item, previous);
528        Ok(frame.end_pc)
529    }
530
531    /// `continue`: jump to the innermost loop's `loop_back` (its `continue_pc`).
532    fn step_continue(&self, context: &Context) -> Result<usize, RuntimeError> {
533        Ok(context
534            .loop_stack
535            .last()
536            .ok_or_else(|| RuntimeError::Other("continue outside loop".into()))?
537            .continue_pc)
538    }
539
540    fn interpolate_socket_spec(
541        &self,
542        spec: &SocketProbeSpec,
543        context: &Context,
544    ) -> Result<SocketProbeSpec, RuntimeError> {
545        let payload = spec
546            .payload
547            .as_ref()
548            .map(|bytes| {
549                if let Ok(text) = std::str::from_utf8(bytes) {
550                    interpolate(text, &context.variables).map(|value| value.into_bytes())
551                } else {
552                    Ok(bytes.clone())
553                }
554            })
555            .transpose()?;
556
557        Ok(SocketProbeSpec {
558            host: interpolate(&spec.host, &context.variables)?,
559            port: spec.port,
560            payload,
561            tls: spec.tls,
562            session: spec.session,
563            read_max: spec.read_max,
564            read_idle_ms: spec.read_idle_ms,
565        })
566    }
567
568    #[tracing::instrument(level = "trace", skip(self, context), fields(probe = name))]
569    async fn send_probe(
570        &self,
571        name: &str,
572        payload_override: Option<Vec<u8>>,
573        retries: u32,
574        context: &mut Context,
575    ) -> Result<(), RuntimeError> {
576        let probe = self
577            .program
578            .spec
579            .probes
580            .get(name)
581            .ok_or_else(|| RuntimeError::UnknownTarget(name.to_string()))?
582            .clone();
583
584        // `retry_delay` is the wait *between* retry attempts (used only in
585        // `retry_send` below). Earlier revisions piped it in here as the
586        // connect timeout, which made a `retry_delay 1s` directive silently
587        // shrink every subsequent probe's connect timeout to 1s.
588        let timeout = self.config.default_timeout;
589
590        let response = match probe {
591            ProbeKind::Http(spec) => {
592                let client = self.client_for_http(&spec)?;
593                let http = execute_http(
594                    &client,
595                    &self.config.base_url,
596                    &spec,
597                    &context.variables,
598                    self.config.max_response_bytes,
599                    retries,
600                )
601                .await?;
602                ProbeResponse::Http(http)
603            }
604            ProbeKind::Dns(spec) => {
605                let mut spec = self.interpolate_socket_spec(&spec, context)?;
606                if let Some(p) = payload_override {
607                    spec.payload = Some(p);
608                }
609                if spec.is_dns_resolver_mode() {
610                    ProbeResponse::DnsResolve(resolve_host(&spec.host).await?)
611                } else {
612                    ProbeResponse::Socket(
613                        run_dns_probe(&spec, timeout, self.config.read_timeout).await?,
614                    )
615                }
616            }
617            ProbeKind::Tcp(spec) => {
618                let spec = self.interpolate_socket_spec(&spec, context)?;
619                let port = spec
620                    .port
621                    .ok_or_else(|| RuntimeError::Other("tcp probe requires port".to_string()))?;
622                let payload = payload_override.or_else(|| spec.payload.clone());
623                ProbeResponse::Socket(
624                    self.exchange_tcp_probe(
625                        name,
626                        &spec,
627                        port,
628                        payload.as_deref(),
629                        timeout,
630                        context,
631                    )
632                    .await?,
633                )
634            }
635            ProbeKind::Udp(spec) => {
636                let spec = self.interpolate_socket_spec(&spec, context)?;
637                let port = spec
638                    .port
639                    .ok_or_else(|| RuntimeError::Other("udp probe requires port".to_string()))?;
640                let payload = payload_override.or_else(|| spec.payload.clone());
641                ProbeResponse::Socket(
642                    self.exchange_udp_probe(
643                        name,
644                        &spec,
645                        port,
646                        payload.as_deref(),
647                        timeout,
648                        context,
649                    )
650                    .await?,
651                )
652            }
653        };
654
655        log_probe_response(name, &response);
656
657        let append_session = probe_session_enabled(name, &self.program.spec);
658        if append_session
659            && let (ProbeResponse::Socket(chunk), Some(ProbeResponse::Socket(existing))) =
660                (&response, context.responses.get(name))
661        {
662            let mut merged = existing.clone();
663            merged.data.extend_from_slice(&chunk.data);
664            context.store_response(name, ProbeResponse::Socket(merged));
665            return Ok(());
666        }
667        context.store_response(name, response);
668        Ok(())
669    }
670
671    async fn exchange_tcp_probe(
672        &self,
673        name: &str,
674        spec: &SocketProbeSpec,
675        port: u16,
676        payload: Option<&[u8]>,
677        timeout: Duration,
678        context: &mut Context,
679    ) -> Result<SocketResponse, RuntimeError> {
680        let read = read_opts_from_spec(spec);
681        let io_timeout = self.config.read_timeout;
682
683        if spec.session {
684            if let Some(ProbeSession::Tcp(session)) = context.sessions.get_mut(name) {
685                let data = tcp_session_exchange(session, payload, &read, io_timeout).await?;
686                return Ok(SocketResponse {
687                    host: spec.host.clone(),
688                    port,
689                    data,
690                });
691            }
692            let mut session =
693                open_tcp_session(&spec.host, port, spec.tls, self.config.verify_ssl, timeout)
694                    .await?;
695            let data = tcp_session_exchange(&mut session, payload, &read, io_timeout).await?;
696            context
697                .sessions
698                .insert(name.to_string(), ProbeSession::Tcp(session));
699            return Ok(SocketResponse {
700                host: spec.host.clone(),
701                port,
702                data,
703            });
704        }
705
706        let mut send_spec = spec.clone();
707        send_spec.payload = payload.map(|p| p.to_vec());
708        exchange_tcp(
709            &send_spec.host,
710            port,
711            &send_spec,
712            self.config.verify_ssl,
713            timeout,
714            io_timeout,
715        )
716        .await
717    }
718
719    async fn exchange_udp_probe(
720        &self,
721        name: &str,
722        spec: &SocketProbeSpec,
723        port: u16,
724        payload: Option<&[u8]>,
725        timeout: Duration,
726        context: &mut Context,
727    ) -> Result<SocketResponse, RuntimeError> {
728        let read = read_opts_from_spec(spec);
729        let io_timeout = self.config.read_timeout;
730
731        if spec.tls {
732            return Err(RuntimeError::Other("tls is not supported for udp".into()));
733        }
734
735        if spec.session {
736            if let Some(ProbeSession::Udp(socket)) = context.sessions.get(name) {
737                let data = udp_session_exchange(socket, payload, &read, io_timeout).await?;
738                return Ok(SocketResponse {
739                    host: spec.host.clone(),
740                    port,
741                    data,
742                });
743            }
744            let socket = open_udp_session(&spec.host, port, timeout).await?;
745            let data = udp_session_exchange(&socket, payload, &read, io_timeout).await?;
746            context
747                .sessions
748                .insert(name.to_string(), ProbeSession::Udp(socket));
749            return Ok(SocketResponse {
750                host: spec.host.clone(),
751                port,
752                data,
753            });
754        }
755
756        exchange_udp(&spec.host, port, payload, spec, timeout, io_timeout).await
757    }
758
759    async fn retry_send(
760        &self,
761        name: &str,
762        count: u32,
763        context: &mut Context,
764    ) -> Result<(), RuntimeError> {
765        let delay = context.retry_delay.unwrap_or(Duration::from_secs(1));
766        let mut last_error = None;
767
768        for attempt in 0..count {
769            if attempt > 0 {
770                tokio::time::sleep(delay).await;
771            }
772            // 0 auto-retries: the script's own `retry` directive controls
773            // re-sends here, so the transport layer must not multiply attempts
774            // underneath it.
775            match self.send_probe(name, None, 0, context).await {
776                Ok(()) => return Ok(()),
777                Err(err) => last_error = Some(err),
778            }
779        }
780
781        Err(last_error
782            .unwrap_or_else(|| RuntimeError::Other(format!("retry send failed for {name}"))))
783    }
784
785    fn apply_match(&self, matcher_idx: usize, context: &mut Context) -> Result<(), RuntimeError> {
786        if !context.matched {
787            return Ok(());
788        }
789        let matcher = &self.program.matchers[matcher_idx];
790        if !self.matches_idx(matcher_idx, context)? {
791            tracing::trace!(target = %matcher.field.target, ?matcher.predicate, "match failed");
792            context.matched = false;
793        } else {
794            tracing::trace!(target = %matcher.field.target, ?matcher.predicate, "match ok");
795        }
796        Ok(())
797    }
798
799    fn apply_match_all(
800        &self,
801        start: usize,
802        len: usize,
803        context: &mut Context,
804    ) -> Result<(), RuntimeError> {
805        if !context.matched {
806            return Ok(());
807        }
808        let matchers = &self.program.matchers[start..start + len];
809        let compiled = &self.compiled_matcher_regex[start..start + len];
810        if !evaluate_all(matchers, compiled, &context.responses)? {
811            tracing::trace!(count = len, "match all failed");
812            context.matched = false;
813        } else {
814            tracing::trace!(count = len, "match all ok");
815        }
816        Ok(())
817    }
818
819    fn apply_match_any(
820        &self,
821        start: usize,
822        len: usize,
823        context: &mut Context,
824    ) -> Result<(), RuntimeError> {
825        if !context.matched {
826            return Ok(());
827        }
828        let matchers = &self.program.matchers[start..start + len];
829        let compiled = &self.compiled_matcher_regex[start..start + len];
830        if !evaluate_any(matchers, compiled, &context.responses)? {
831            tracing::trace!(count = len, "match any failed");
832            context.matched = false;
833        } else {
834            tracing::trace!(count = len, "match any ok");
835        }
836        Ok(())
837    }
838
839    fn require_assert(&self, matcher_idx: usize, context: &Context) -> Result<(), RuntimeError> {
840        if !context.matched {
841            return Ok(());
842        }
843        if self.matches_idx(matcher_idx, context)? {
844            return Ok(());
845        }
846        let matcher = &self.program.matchers[matcher_idx];
847        let detail = format!("target {}", matcher.field.target);
848        Err(RuntimeError::assert_failed(matcher, detail))
849    }
850
851    fn matches_idx(&self, matcher_idx: usize, context: &Context) -> Result<bool, RuntimeError> {
852        let matcher = &self.program.matchers[matcher_idx];
853        let response = context
854            .response(&matcher.field.target)
855            .ok_or_else(|| RuntimeError::UnknownTarget(matcher.field.target.clone()))?;
856        evaluate(matcher, response, &self.compiled_matcher_regex[matcher_idx])
857    }
858
859    fn extract(
860        &self,
861        name: &str,
862        source: &ExtractSource,
863        source_idx: usize,
864        context: &mut Context,
865    ) -> Result<(), RuntimeError> {
866        let value = match source {
867            ExtractSource::Body { target, regex } => {
868                let response = context
869                    .response(target)
870                    .ok_or_else(|| RuntimeError::UnknownTarget(target.clone()))?;
871                let http = response
872                    .as_http()
873                    .map_err(|_| RuntimeError::WrongProbeKind {
874                        name: target.clone(),
875                    })?;
876                match regex {
877                    Some(_) => {
878                        let compiled = self.compiled_extract_regex[source_idx]
879                            .as_ref()
880                            .ok_or_else(|| {
881                                RuntimeError::Other(
882                                    "extract regex missing pre-compiled entry".into(),
883                                )
884                            })?;
885                        extract_with_compiled(&http.body, compiled)?
886                    }
887                    None => http.body.clone(),
888                }
889            }
890            ExtractSource::Header {
891                target,
892                name: header,
893            } => {
894                let response = context
895                    .response(target)
896                    .ok_or_else(|| RuntimeError::UnknownTarget(target.clone()))?;
897                let http = response
898                    .as_http()
899                    .map_err(|_| RuntimeError::WrongProbeKind {
900                        name: target.clone(),
901                    })?;
902                // Empty-string and missing-header used to collapse into the
903                // same "empty result" error. Distinguish them: a present but
904                // empty header is a legitimate value to capture; only an
905                // absent header is an extraction failure.
906                match http
907                    .headers
908                    .iter()
909                    .find(|(key, _)| key.eq_ignore_ascii_case(header))
910                {
911                    Some((_, value)) => value.clone(),
912                    None => {
913                        return Err(RuntimeError::ExtractFailed {
914                            name: name.to_string(),
915                            reason: format!("header `{header}` not present"),
916                        });
917                    }
918                }
919            }
920        };
921
922        // Body extracts: an empty extract is still a failure (the regex
923        // matched zero characters). Header extracts that resolved to an
924        // empty value have already returned above; that path is preserved.
925        if matches!(source, ExtractSource::Body { .. }) && value.is_empty() {
926            return Err(RuntimeError::ExtractFailed {
927                name: name.to_string(),
928                reason: "empty result".into(),
929            });
930        }
931
932        tracing::trace!(variable = %name, "extracted");
933        context.set_variable(name, value);
934        Ok(())
935    }
936
937    fn collect_evidence(
938        &self,
939        kind: &EvidenceKind,
940        kind_idx: usize,
941        context: &Context,
942    ) -> Result<String, RuntimeError> {
943        // Resolve the explicit source to its raw string, plus the optional
944        // regex and a label for error messages.
945        let (raw, pattern, label, target): (String, &Option<String>, String, &str) = match kind {
946            EvidenceKind::Body { target, pattern } => {
947                let response = context
948                    .response(target)
949                    .ok_or_else(|| RuntimeError::UnknownTarget(target.clone()))?;
950                let http = response.as_http().map_err(|_| {
951                    RuntimeError::Other(format!(
952                        "evidence {target}.body requires an http probe; use {target}.response for socket/dns"
953                    ))
954                })?;
955                (http.body.clone(), pattern, format!("{target}.body"), target)
956            }
957            EvidenceKind::Response { target, pattern } => {
958                let response = context
959                    .response(target)
960                    .ok_or_else(|| RuntimeError::UnknownTarget(target.clone()))?;
961                (
962                    evidence_haystack(response),
963                    pattern,
964                    format!("{target}.response"),
965                    target,
966                )
967            }
968            EvidenceKind::Header {
969                target,
970                name,
971                pattern,
972            } => {
973                let response = context
974                    .response(target)
975                    .ok_or_else(|| RuntimeError::UnknownTarget(target.clone()))?;
976                let http = response.as_http().map_err(|_| {
977                    RuntimeError::Other(format!("evidence {target}.header requires an http probe"))
978                })?;
979                let value = http
980                    .headers
981                    .iter()
982                    .find(|(key, _)| key.eq_ignore_ascii_case(name))
983                    .map(|(_, value)| value.clone())
984                    .ok_or_else(|| {
985                        RuntimeError::Other(format!(
986                            "evidence header `{name}` on {target} not present"
987                        ))
988                    })?;
989                (
990                    value,
991                    pattern,
992                    format!("{target}.header \"{name}\""),
993                    target,
994                )
995            }
996        };
997        let _ = target;
998        match pattern {
999            None => Ok(crate::util::truncate_str(&raw, 500)),
1000            Some(p) => {
1001                let compiled =
1002                    self.compiled_evidence_regex[kind_idx]
1003                        .as_ref()
1004                        .ok_or_else(|| {
1005                            RuntimeError::Other("evidence regex missing pre-compiled entry".into())
1006                        })?;
1007                extract_with_compiled(&raw, compiled).map_err(|_| {
1008                    RuntimeError::Other(format!("evidence regex on {label} did not match: {p}"))
1009                })
1010            }
1011        }
1012    }
1013}
1014
1015fn inject_scan_target_variables(context: &mut Context, base_url: &str) {
1016    if let Some((host, port)) = scan_target_host_port(base_url) {
1017        context.set_variable("scan_host", host);
1018        context.set_variable("scan_port", port.to_string());
1019    }
1020    if !base_url.is_empty() {
1021        context.set_variable("scan_url", base_url.to_string());
1022    }
1023}
1024
1025fn evidence_haystack(response: &ProbeResponse) -> String {
1026    match response {
1027        ProbeResponse::Http(http) => http.body.clone(),
1028        ProbeResponse::DnsResolve(dns) => dns.answers.join(" "),
1029        // Socket data is bytes; evidence is human-facing, so a lossy decode is
1030        // intentional here. Matching elsewhere still operates on raw bytes.
1031        ProbeResponse::Socket(sock) => sock.data_lossy().into_owned(),
1032    }
1033}
1034
1035fn probe_session_enabled(name: &str, spec: &crate::runtime::spec::ProgramSpec) -> bool {
1036    spec.probes.get(name).is_some_and(|kind| match kind {
1037        ProbeKind::Tcp(s) | ProbeKind::Udp(s) | ProbeKind::Dns(s) => s.session,
1038        ProbeKind::Http(_) => false,
1039    })
1040}
1041
1042/// Run a pre-compiled regex against `body`, returning the first capture group
1043/// (or the full match if there is no capture). Compiled once at executor init
1044/// rather than on every call site.
1045fn extract_with_compiled(body: &str, regex: &Regex) -> Result<String, RuntimeError> {
1046    let captures = regex
1047        .captures(body)
1048        .ok_or_else(|| RuntimeError::ExtractFailed {
1049            name: "regex".into(),
1050            reason: format!("pattern not found: {}", regex.as_str()),
1051        })?;
1052    let matched = captures
1053        .get(1)
1054        .or_else(|| captures.get(0))
1055        .map(|value| value.as_str().to_string())
1056        .unwrap_or_default();
1057    if matched.is_empty() {
1058        return Err(RuntimeError::ExtractFailed {
1059            name: "regex".into(),
1060            reason: format!("empty capture for: {}", regex.as_str()),
1061        });
1062    }
1063    Ok(matched)
1064}
1065
1066fn log_probe_response(name: &str, response: &ProbeResponse) {
1067    match response {
1068        ProbeResponse::Http(http) => {
1069            tracing::trace!(
1070                probe = name,
1071                status = http.status,
1072                elapsed_ms = http.elapsed.as_millis() as u64,
1073                body_bytes = http.body.len(),
1074                "http response"
1075            );
1076        }
1077        ProbeResponse::DnsResolve(dns) => {
1078            tracing::trace!(
1079                probe = name,
1080                host = %dns.host,
1081                answers = dns.answers.len(),
1082                "dns resolve"
1083            );
1084        }
1085        ProbeResponse::Socket(sock) => {
1086            tracing::trace!(
1087                probe = name,
1088                host = %sock.host,
1089                port = sock.port,
1090                data_bytes = sock.data.len(),
1091                "socket response"
1092            );
1093        }
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100    use crate::contract::{
1101        CmpOp, CmpValue, FieldKind, MatchPredicate, QualifiedField, QualifiedMatch, Severity,
1102    };
1103    use crate::runtime::bytecode::BytecodeProgram;
1104    use crate::runtime::spec::{CheckMetadata, ProgramSpec};
1105
1106    fn metadata_only_bytecode() -> BytecodeProgram {
1107        BytecodeProgram {
1108            spec: ProgramSpec {
1109                probes: Default::default(),
1110                metadata: CheckMetadata {
1111                    name: Some("Metadata only".into()),
1112                    severity: Some(Severity::Low),
1113                    ..CheckMetadata::default()
1114                },
1115            },
1116            code: vec![],
1117            strings: vec![],
1118            payloads: vec![],
1119            matchers: vec![],
1120            extracts: vec![],
1121            evidence: vec![],
1122        }
1123    }
1124
1125    #[tokio::test]
1126    async fn run_fail_opcode_returns_error() {
1127        let bytecode = BytecodeProgram {
1128            spec: ProgramSpec {
1129                probes: Default::default(),
1130                metadata: CheckMetadata::default(),
1131            },
1132            code: vec![Instr::Fail],
1133            strings: vec![],
1134            payloads: vec![],
1135            matchers: vec![],
1136            extracts: vec![],
1137            evidence: vec![],
1138        };
1139        let config = ExecutorConfig {
1140            base_url: "http://127.0.0.1".into(),
1141            ..ExecutorConfig::default()
1142        };
1143        let executor = Executor::from_bytecode(config, bytecode).unwrap();
1144        assert!(executor.run().await.is_err());
1145    }
1146
1147    #[tokio::test]
1148    async fn match_without_send_returns_unknown_target() {
1149        let bytecode = BytecodeProgram {
1150            spec: ProgramSpec {
1151                probes: Default::default(),
1152                metadata: CheckMetadata::default(),
1153            },
1154            code: vec![Instr::Match(0)],
1155            strings: vec![],
1156            payloads: vec![],
1157            matchers: vec![QualifiedMatch {
1158                field: QualifiedField {
1159                    target: "home".into(),
1160                    kind: FieldKind::Status,
1161                },
1162                predicate: MatchPredicate::Compare {
1163                    op: CmpOp::Eq,
1164                    value: CmpValue::Number(200),
1165                },
1166            }],
1167            extracts: vec![],
1168            evidence: vec![],
1169        };
1170        let config = ExecutorConfig {
1171            base_url: "http://127.0.0.1".into(),
1172            ..ExecutorConfig::default()
1173        };
1174        let executor = Executor::from_bytecode(config, bytecode).unwrap();
1175        let err = executor.run().await.expect_err("match without prior send");
1176        assert!(matches!(err, RuntimeError::UnknownTarget(_)));
1177    }
1178
1179    #[tokio::test]
1180    async fn metadata_only_can_emit_finding_without_network() {
1181        let config = ExecutorConfig {
1182            base_url: "http://127.0.0.1".into(),
1183            ..ExecutorConfig::default()
1184        };
1185        let executor = Executor::from_bytecode(config, metadata_only_bytecode()).unwrap();
1186        assert!(executor.bytecode().code.is_empty());
1187        let result = executor.run().await.expect("metadata run");
1188        assert!(result.detected);
1189        assert_eq!(result.report.findings[0].name, "Metadata only");
1190    }
1191
1192    /// The wall-clock budget is checked at instruction boundaries, so a script
1193    /// with many steps must abort once it runs past the budget rather than
1194    /// executing them all. (A long sequence of short sleeps stands in for any
1195    /// long-running script now that the unbounded `repeat` loop is gone.)
1196    #[tokio::test]
1197    async fn script_budget_aborts_a_long_run() {
1198        let bytecode = BytecodeProgram {
1199            spec: ProgramSpec {
1200                probes: Default::default(),
1201                metadata: CheckMetadata::default(),
1202            },
1203            // strings[0] = "10ms" per sleep; 200 of them (~2s) far exceed the
1204            // 50ms budget, so the boundary check fires after a handful.
1205            code: vec![Instr::Sleep(0); 200],
1206            strings: vec!["10ms".into()],
1207            payloads: vec![],
1208            matchers: vec![],
1209            extracts: vec![],
1210            evidence: vec![],
1211        };
1212        let config = ExecutorConfig {
1213            base_url: "http://127.0.0.1".into(),
1214            // Tight budget so the test finishes quickly.
1215            max_script_duration: Some(Duration::from_millis(50)),
1216            ..ExecutorConfig::default()
1217        };
1218        let executor = Executor::from_bytecode(config, bytecode).unwrap();
1219        let err = executor.run().await.expect_err("budget should fire");
1220        let msg = err.to_string();
1221        assert!(msg.contains("budget"), "expected budget error, got: {msg}");
1222    }
1223
1224    /// Build a `for x in ["a", "b", "c"] { <body> }` program. `body` is the
1225    /// instructions between the `ForList` header and the trailing `LoopBack`;
1226    /// a `Stop` is appended just past the loop so control flow has a clean
1227    /// landing point. `end_pc` (one past `LoopBack`) is wired automatically.
1228    fn foreach_program(body: Vec<Instr>) -> BytecodeProgram {
1229        // Layout: [ForList][..body..][LoopBack][Stop]
1230        let loop_back_pc = 1 + body.len();
1231        let end_pc = loop_back_pc + 1; // one past LoopBack — where the loop exits to
1232        let mut code = vec![Instr::ForList {
1233            item: 0,
1234            start: 1,
1235            len: 3,
1236            end_pc: end_pc as u32,
1237        }];
1238        code.extend(body);
1239        code.push(Instr::LoopBack);
1240        code.push(Instr::Stop);
1241        BytecodeProgram {
1242            spec: ProgramSpec {
1243                probes: Default::default(),
1244                metadata: CheckMetadata {
1245                    name: Some("loop test".into()),
1246                    ..CheckMetadata::default()
1247                },
1248            },
1249            code,
1250            strings: vec!["x".into(), "a".into(), "b".into(), "c".into()],
1251            payloads: vec![],
1252            matchers: vec![],
1253            extracts: vec![],
1254            evidence: vec![],
1255        }
1256    }
1257
1258    async fn run_offline(bytecode: BytecodeProgram) -> Result<(), RuntimeError> {
1259        let config = ExecutorConfig {
1260            base_url: "http://127.0.0.1".into(),
1261            ..ExecutorConfig::default()
1262        };
1263        let executor = Executor::from_bytecode(config, bytecode).unwrap();
1264        executor.run().await.map(|_| ())
1265    }
1266
1267    // The next three tests pin down loop *execution* (ForList/LoopBack/Break/
1268    // Continue) — the opcodes extracted into `enter_foreach`/`step_loop_back`/
1269    // `step_break`/`step_continue`. They use `Instr::Fail` as a tripwire: any
1270    // instruction the control flow should have skipped turns a pass into an
1271    // error, so a regression can't slip through as a silent no-op.
1272
1273    #[tokio::test]
1274    async fn foreach_iterates_every_value_and_exits() {
1275        // Empty body: enter, three loop-backs, exhaust, fall through to Stop.
1276        // A broken index/jump would panic (out-of-bounds) or hang (budget),
1277        // so reaching a clean `Ok` proves all three iterations ran and the
1278        // frame was popped exactly once.
1279        run_offline(foreach_program(vec![]))
1280            .await
1281            .expect("foreach should iterate and exit cleanly");
1282    }
1283
1284    #[tokio::test]
1285    async fn foreach_break_skips_rest_of_body_and_loop() {
1286        // `break` must jump past the `Fail` and out of the loop to `Stop`.
1287        let body = vec![Instr::Break, Instr::Fail];
1288        run_offline(foreach_program(body))
1289            .await
1290            .expect("break should exit before reaching Fail");
1291    }
1292
1293    #[tokio::test]
1294    async fn foreach_continue_skips_to_loop_back() {
1295        // `continue` must jump to `LoopBack` (continue_pc), skipping the `Fail`,
1296        // on every iteration — then the loop exhausts and exits to `Stop`.
1297        let body = vec![Instr::Continue, Instr::Fail];
1298        run_offline(foreach_program(body))
1299            .await
1300            .expect("continue should skip Fail on every iteration");
1301    }
1302
1303    #[tokio::test]
1304    async fn default_config_verifies_tls() {
1305        // C3 regression: ExecutorConfig::default() must verify TLS so a
1306        // freshly-constructed runtime cannot silently fall back to
1307        // accept-invalid-certs.
1308        let cfg = ExecutorConfig::default();
1309        assert!(cfg.verify_ssl);
1310        assert!(cfg.max_script_duration.is_some());
1311    }
1312}