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 pub default_timeout: Duration,
36 pub read_timeout: Duration,
39 pub max_response_bytes: usize,
43 pub follow_redirect: bool,
44 pub verify_ssl: bool,
49 pub proxy: Option<String>,
50 pub max_script_duration: Option<Duration>,
54 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, 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: Arc<BytecodeProgram>,
83 client: Client,
84 compiled_matcher_regex: Arc<[CompiledMatcherRegex]>,
88 compiled_evidence_regex: Arc<[Option<Regex>]>,
91 compiled_extract_regex: Arc<[Option<Regex>]>,
94}
95
96#[derive(Debug, Clone)]
97pub struct ExecutionResult {
98 pub success: bool,
99 pub detected: bool,
101 pub skipped: bool,
103 pub skip_reason: Option<String>,
104 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 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 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 fn step_loop_back(&self, context: &mut Context) -> Result<usize, RuntimeError> {
426 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 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 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 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 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 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 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 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
975fn 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 #[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 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 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 fn foreach_program(body: Vec<Instr>) -> BytecodeProgram {
1162 let loop_back_pc = 1 + body.len();
1164 let end_pc = loop_back_pc + 1; 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 #[tokio::test]
1207 async fn foreach_iterates_every_value_and_exits() {
1208 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 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 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 let cfg = ExecutorConfig::default();
1242 assert!(cfg.verify_ssl);
1243 assert!(cfg.max_script_duration.is_some());
1244 }
1245}