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)]
35pub struct ExecutorConfig {
36 pub base_url: String,
39 pub default_timeout: Duration,
41 pub read_timeout: Duration,
44 pub max_response_bytes: usize,
48 pub follow_redirect: bool,
50 pub verify_ssl: bool,
55 pub proxy: Option<String>,
57 pub max_script_duration: Option<Duration>,
61 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, 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
84pub struct Executor {
89 config: ExecutorConfig,
90 program: Arc<BytecodeProgram>,
94 client: Client,
95 compiled_matcher_regex: Arc<[CompiledMatcherRegex]>,
99 compiled_evidence_regex: Arc<[Option<Regex>]>,
102 compiled_extract_regex: Arc<[Option<Regex>]>,
105}
106
107#[derive(Debug, Clone)]
110pub struct ExecutionResult {
111 pub success: bool,
113 pub detected: bool,
115 pub skipped: bool,
117 pub skip_reason: Option<String>,
119 pub port_checks: Vec<PortCheck>,
121 pub report: Report,
123 pub variables: HashMap<String, VariableValue>,
125 pub metadata: crate::runtime::spec::CheckMetadata,
127}
128
129impl Executor {
130 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 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 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 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 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 fn step_loop_back(&self, context: &mut Context) -> Result<usize, RuntimeError> {
458 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 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 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 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 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 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 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 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 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
1042fn 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 #[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 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 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 fn foreach_program(body: Vec<Instr>) -> BytecodeProgram {
1229 let loop_back_pc = 1 + body.len();
1231 let end_pc = loop_back_pc + 1; 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 #[tokio::test]
1274 async fn foreach_iterates_every_value_and_exits() {
1275 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 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 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 let cfg = ExecutorConfig::default();
1309 assert!(cfg.verify_ssl);
1310 assert!(cfg.max_script_duration.is_some());
1311 }
1312}