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