1use std::ops::Range;
5
6use bathy_types::ProbeCapture;
7use bathy_types::event::Observation;
8
9use crate::rules::rules_for;
10
11#[derive(Clone, Debug, PartialEq)]
18pub struct Interpretation {
19 pub observation: Observation,
20 pub rule_id: &'static str,
21 pub matched_span: Range<usize>,
25 pub rationale: String,
26}
27
28pub fn interpret(capture: &ProbeCapture) -> Vec<Interpretation> {
40 let mut out = Vec::new();
41 for rule in rules_for(capture.probe_id) {
42 if let Some(hit) = (rule.matcher)(&capture.response) {
43 out.push(Interpretation {
44 observation: Observation {
45 service: rule.doc.service.to_owned(),
46 product: hit.product,
47 version: hit.version,
48 confidence: hit.specificity.confidence(),
49 },
50 rule_id: rule.doc.id,
51 matched_span: hit.span,
52 rationale: rule.doc.rationale.to_owned(),
53 });
54 }
55 }
56 sort_stable(out)
57}
58
59pub(crate) fn sort_stable(mut out: Vec<Interpretation>) -> Vec<Interpretation> {
65 out.sort_by(|a, b| {
66 b.observation
67 .confidence
68 .partial_cmp(&a.observation.confidence)
69 .unwrap_or(std::cmp::Ordering::Equal)
70 .then_with(|| a.rule_id.cmp(b.rule_id))
71 });
72 out
73}
74
75#[cfg(test)]
76mod tests {
77 use bathy_types::Transport;
78 use bathy_types::confidence::Confidence;
79 use proptest::prelude::*;
80
81 use super::*;
82
83 fn cap(id: &'static str, port: u16, response: &[u8]) -> ProbeCapture {
84 ProbeCapture {
85 probe_id: id,
86 transport: Transport::Tcp,
87 port,
88 request: None,
89 response: response.to_vec(),
90 elapsed_micros: 0,
91 truncated: false,
92 }
93 }
94
95 fn observation(confidence: f64) -> Observation {
99 Observation {
100 service: "test".to_string(),
101 product: None,
102 version: None,
103 confidence: Confidence::new(confidence).unwrap(),
104 }
105 }
106
107 fn interp(rule_id: &'static str, confidence: f64) -> Interpretation {
108 Interpretation {
109 observation: observation(confidence),
110 rule_id,
111 matched_span: 0..0,
112 rationale: String::new(),
113 }
114 }
115
116 #[test]
117 fn sort_stable_orders_by_confidence_descending() {
118 let out = sort_stable(vec![interp("a", 0.5), interp("b", 0.9), interp("c", 0.7)]);
119 let ids: Vec<&str> = out.iter().map(|i| i.rule_id).collect();
120 assert_eq!(ids, vec!["b", "c", "a"]);
121 }
122
123 #[test]
124 fn tie_break_is_by_rule_id_not_registration_order() {
125 let out = sort_stable(vec![
130 interp("zebra", 0.8),
131 interp("apple", 0.8),
132 interp("mango", 0.8),
133 ]);
134 let ids: Vec<&str> = out.iter().map(|i| i.rule_id).collect();
135 assert_eq!(ids, vec!["apple", "mango", "zebra"]);
136 }
137
138 proptest! {
139 #[test]
162 fn sort_stable_is_deterministic_and_produces_a_total_order(
163 picks in proptest::collection::vec((0u8..5, 0usize..8), 0..8),
164 ) {
165 let ids = ["h", "g", "f", "e", "d", "c", "b", "a"];
166 let items: Vec<Interpretation> = picks
167 .iter()
168 .map(|&(rung, id_ix)| interp(ids[id_ix], f64::from(rung) * 0.2))
169 .collect();
170 let sorted_once = sort_stable(items.clone());
171 let sorted_twice = sort_stable(items);
172 prop_assert_eq!(&sorted_once, &sorted_twice);
173 for w in sorted_once.windows(2) {
174 let a = w[0].observation.confidence.get();
175 let b = w[1].observation.confidence.get();
176 prop_assert!(
177 a > b || (a == b && w[0].rule_id <= w[1].rule_id),
178 "not totally ordered: {a} ({}) then {b} ({})",
179 w[0].rule_id,
180 w[1].rule_id
181 );
182 }
183 }
184 }
185
186 #[test]
189 fn identifies_nginx_with_a_version_at_high_confidence() {
190 let out = interpret(&cap(
191 "http-get-v1",
192 80,
193 b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
194 ));
195 let top = &out[0];
196 assert_eq!(top.observation.service, "http");
197 assert_eq!(top.observation.product.as_deref(), Some("nginx"));
198 assert_eq!(top.observation.version.as_deref(), Some("1.26.0"));
199 assert!(top.observation.confidence.get() >= 0.90);
200 }
201
202 #[test]
203 fn a_product_without_a_version_scores_lower_than_one_with() {
204 let with = interpret(&cap(
205 "http-get-v1",
206 80,
207 b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
208 ));
209 let without = interpret(&cap(
210 "http-get-v1",
211 80,
212 b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n",
213 ));
214 assert!(without[0].observation.confidence.get() < with[0].observation.confidence.get());
215 assert!(without[0].observation.version.is_none());
216 }
217
218 #[test]
219 fn a_bare_protocol_match_still_reports_the_service_at_low_confidence() {
220 let out = interpret(&cap("http-get-v1", 8080, b"HTTP/1.0 404 Not Found\r\n\r\n"));
221 assert_eq!(out[0].observation.service, "http");
222 assert!(out[0].observation.product.is_none());
223 assert!(out[0].observation.confidence.get() <= 0.75);
224 }
225
226 #[test]
227 fn identifies_openssh_from_its_banner() {
228 let out = interpret(&cap(
229 "ssh-banner-v1",
230 22,
231 b"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n",
232 ));
233 assert_eq!(out[0].observation.service, "ssh");
234 assert_eq!(out[0].observation.product.as_deref(), Some("OpenSSH"));
235 assert_eq!(out[0].observation.version.as_deref(), Some("9.6p1"));
236 }
237
238 #[test]
239 fn identifies_postgres_from_its_single_byte_ssl_reply() {
240 let out = interpret(&cap("postgres-startup-v1", 5432, b"S"));
241 assert_eq!(out[0].observation.service, "postgresql");
242 }
243
244 #[test]
245 fn every_interpretation_cites_the_rule_and_the_matched_bytes() {
246 let c = cap(
247 "http-get-v1",
248 80,
249 b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
250 );
251 let out = interpret(&c);
252 let i = &out[0];
253 assert!(!i.rule_id.is_empty());
254 let matched = &c.response[i.matched_span.clone()];
255 assert!(
256 String::from_utf8_lossy(matched).contains("nginx"),
257 "matched_span must point at the bytes that justified the claim"
258 );
259 assert!(
260 crate::explain(i.rule_id).is_some(),
261 "every rule must be explainable"
262 );
263 }
264
265 #[test]
266 fn unrecognized_bytes_yield_no_observation_rather_than_a_guess() {
267 let out = interpret(&cap("http-get-v1", 80, b"\x00\x01\x02\x03garbage"));
268 assert!(out.is_empty(), "interpretation must not invent a service");
269 }
270
271 #[test]
272 fn interpretation_is_deterministic() {
273 let c = cap("ssh-banner-v1", 22, b"SSH-2.0-OpenSSH_9.6p1\r\n");
274 assert_eq!(interpret(&c), interpret(&c));
275 }
276
277 #[test]
278 fn interpretation_never_panics_on_arbitrary_bytes() {
279 for len in [0usize, 1, 2, 3, 7, 64, 8192] {
280 for fill in [0x00u8, 0xff, 0x0a, 0x1b] {
281 let _ = interpret(&cap("http-get-v1", 80, &vec![fill; len]));
282 let _ = interpret(&cap("tls-v1", 443, &vec![fill; len]));
283 }
284 }
285 }
286
287 #[test]
290 fn interpretation_never_panics_on_lone_surrogate_shaped_byte_sequences() {
291 let surrogate: [u8; 3] = [0xED, 0xA0, 0x80];
297 for probe_id in [
298 "http-get-v1",
299 "tls-v1",
300 "ssh-banner-v1",
301 "smtp-banner-v1",
302 "dns-version-bind-v1",
303 "postgres-startup-v1",
304 "mysql-greeting-v1",
305 "redis-ping-v1",
306 ] {
307 for reps in [1usize, 5, 500] {
308 let bytes: Vec<u8> = surrogate.iter().cycle().take(reps * 3).copied().collect();
309 let _ = interpret(&cap(probe_id, 1, &bytes));
310 }
311 }
312 }
313
314 #[test]
315 fn interpretation_never_panics_across_every_known_probe_id_and_many_byte_shapes() {
316 let probe_ids = [
317 "http-get-v1",
318 "tls-v1",
319 "ssh-banner-v1",
320 "smtp-banner-v1",
321 "dns-version-bind-v1",
322 "postgres-startup-v1",
323 "mysql-greeting-v1",
324 "redis-ping-v1",
325 "totally-unknown-probe-id",
326 ];
327 for probe_id in probe_ids {
328 for len in [0usize, 1, 2, 4, 5, 6, 8, 9, 10, 11, 45, 66, 300] {
329 for fill in [0x00u8, 0xff, 0x0a, 0x16, 0x02, b'S', b'N'] {
330 let _ = interpret(&cap(probe_id, 1, &vec![fill; len]));
331 }
332 }
333 }
334 }
335
336 fn with_corruption(valid: impl Strategy<Value = Vec<u8>>) -> impl Strategy<Value = Vec<u8>> {
367 (valid, 0u8..3, any::<usize>(), any::<u8>()).prop_map(|(bytes, mode, at, extra)| match mode
368 {
369 0 => bytes,
370 1 => {
371 if bytes.is_empty() {
372 bytes
373 } else {
374 let cut = at % (bytes.len() + 1);
375 bytes[..cut].to_vec()
376 }
377 }
378 _ => {
379 let mut b = bytes;
380 let pos = at % (b.len() + 1);
381 b.insert(pos, extra);
382 b
383 }
384 })
385 }
386
387 fn http_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
388 (1u16..500, 0u16..500, 0u16..500, any::<bool>()).prop_map(
389 |(major, minor, patch, with_version)| {
390 let server = if with_version {
391 format!("nginx/{major}.{minor}.{patch}")
392 } else {
393 "nginx".to_string()
394 };
395 format!("HTTP/1.1 200 OK\r\nServer: {server}\r\n\r\n").into_bytes()
396 },
397 )
398 }
399
400 fn ssh_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
401 (1u16..50, 0u16..50, any::<bool>()).prop_map(|(major, minor, with_patch)| {
402 let patch = if with_patch {
403 format!("p{minor}")
404 } else {
405 String::new()
406 };
407 format!("SSH-2.0-OpenSSH_{major}.{minor}{patch}\r\n").into_bytes()
408 })
409 }
410
411 fn smtp_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
412 (0u32..1000, any::<bool>()).prop_map(|(host_n, is_postfix)| {
413 let software = if is_postfix { "Postfix" } else { "Sendmail" };
414 format!("220 host{host_n}.example.com ESMTP {software}\r\n").into_bytes()
415 })
416 }
417
418 fn mysql_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
419 (
420 0u8..30,
421 0u8..30,
422 0u8..30,
423 proptest::collection::vec(any::<u8>(), 0..20),
424 )
425 .prop_map(|(major, minor, patch, trailing)| {
426 let mut bytes = vec![0u8, 0, 0, 0, 0x0a]; bytes.extend_from_slice(format!("{major}.{minor}.{patch}").as_bytes());
428 bytes.push(0); bytes.extend_from_slice(&trailing);
430 bytes
431 })
432 }
433
434 fn build_synthetic_dns_reply(id: u16, version: &str) -> Vec<u8> {
439 let mut msg = Vec::new();
440 msg.extend_from_slice(&id.to_be_bytes());
441 msg.extend_from_slice(&0x8400u16.to_be_bytes()); msg.extend_from_slice(&1u16.to_be_bytes()); msg.extend_from_slice(&1u16.to_be_bytes()); msg.extend_from_slice(&0u16.to_be_bytes()); msg.extend_from_slice(&0u16.to_be_bytes()); for label in ["version", "bind"] {
447 msg.push(label.len() as u8);
448 msg.extend_from_slice(label.as_bytes());
449 }
450 msg.push(0); msg.extend_from_slice(&16u16.to_be_bytes()); msg.extend_from_slice(&3u16.to_be_bytes()); msg.extend_from_slice(&[0xC0, 0x0C]); msg.extend_from_slice(&16u16.to_be_bytes()); msg.extend_from_slice(&3u16.to_be_bytes()); msg.extend_from_slice(&0u32.to_be_bytes()); let rdata_len = 1 + version.len();
458 msg.extend_from_slice(&(rdata_len as u16).to_be_bytes());
459 msg.push(version.len() as u8);
460 msg.extend_from_slice(version.as_bytes());
461
462 let mut framed = Vec::with_capacity(2 + msg.len());
463 framed.extend_from_slice(&(msg.len() as u16).to_be_bytes());
464 framed.extend_from_slice(&msg);
465 framed
466 }
467
468 fn dns_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
469 (any::<u16>(), 1usize..15).prop_map(|(id, version_len)| {
470 let version: String = (0..version_len)
471 .map(|i| (b'0' + (i % 10) as u8) as char)
472 .collect();
473 build_synthetic_dns_reply(id, &version)
474 })
475 }
476
477 fn tls_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
478 proptest::collection::vec(any::<u8>(), 0..50).prop_map(|trailing| {
479 let mut bytes = vec![0x16, 0x03, 0x03, 0x00, 0x02, 0x02];
480 bytes.extend_from_slice(&trailing);
481 bytes
482 })
483 }
484
485 fn redis_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
486 prop_oneof![
487 Just(b"+PONG\r\n".to_vec()),
488 Just(b"-ERR unknown command\r\n".to_vec()),
489 Just(b":1000\r\n".to_vec()),
490 Just(b"$-1\r\n".to_vec()),
491 ]
492 }
493
494 fn postgres_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
495 prop_oneof![Just(b"S".to_vec()), Just(b"N".to_vec())]
496 }
497
498 fn probe_and_response_strategy() -> impl Strategy<Value = (&'static str, Vec<u8>)> {
499 prop_oneof![
500 3 => with_corruption(http_valid_bytes()).prop_map(|b| ("http-get-v1", b)),
501 3 => with_corruption(ssh_valid_bytes()).prop_map(|b| ("ssh-banner-v1", b)),
502 3 => with_corruption(smtp_valid_bytes()).prop_map(|b| ("smtp-banner-v1", b)),
503 3 => with_corruption(mysql_valid_bytes()).prop_map(|b| ("mysql-greeting-v1", b)),
504 3 => with_corruption(dns_valid_bytes()).prop_map(|b| ("dns-version-bind-v1", b)),
505 3 => with_corruption(tls_valid_bytes()).prop_map(|b| ("tls-v1", b)),
506 3 => with_corruption(redis_valid_bytes()).prop_map(|b| ("redis-ping-v1", b)),
507 3 => with_corruption(postgres_valid_bytes()).prop_map(|b| ("postgres-startup-v1", b)),
508 6 => (
509 prop_oneof![
510 Just("http-get-v1"),
511 Just("tls-v1"),
512 Just("ssh-banner-v1"),
513 Just("smtp-banner-v1"),
514 Just("dns-version-bind-v1"),
515 Just("postgres-startup-v1"),
516 Just("mysql-greeting-v1"),
517 Just("redis-ping-v1"),
518 Just("totally-unknown-probe-id"),
519 ],
520 proptest::collection::vec(any::<u8>(), 0..300),
521 ),
522 ]
523 }
524
525 proptest! {
526 #![proptest_config(ProptestConfig::with_cases(2048))]
527
528 #[test]
536 fn matched_span_is_always_a_valid_range_into_the_response(
537 (probe_id, response) in probe_and_response_strategy(),
538 ) {
539 let c = cap(probe_id, 1, &response);
540 let interpretations = interpret(&c);
541 for i in &interpretations {
542 prop_assert!(i.matched_span.start <= i.matched_span.end);
543 prop_assert!(i.matched_span.end <= c.response.len());
544 }
545 }
546
547 #[test]
551 fn interpret_is_deterministic_over_arbitrary_input(
552 (probe_id, response) in probe_and_response_strategy(),
553 ) {
554 let c = cap(probe_id, 1, &response);
555 prop_assert_eq!(interpret(&c), interpret(&c));
556 }
557
558 }
559
560 #[test]
570 fn structured_strategy_reaches_real_matches_and_deep_spans_most_of_the_time() {
571 use proptest::strategy::ValueTree;
572 use proptest::test_runner::TestRunner;
573 let mut runner = TestRunner::default();
574 let strategy = probe_and_response_strategy();
575 const TOTAL: usize = 2000;
576 let mut non_empty = 0usize;
577 let mut deep_span = 0usize;
578 for _ in 0..TOTAL {
579 let (probe_id, response) = strategy.new_tree(&mut runner).unwrap().current();
580 let interpretations = interpret(&cap(probe_id, 1, &response));
581 if !interpretations.is_empty() {
582 non_empty += 1;
583 }
584 if interpretations.iter().any(|i| i.matched_span.end > 6) {
585 deep_span += 1;
586 }
587 }
588 assert!(
589 non_empty * 100 >= TOTAL * 30,
590 "expected at least 30% of {TOTAL} structured cases to produce a match, got {non_empty}"
591 );
592 assert!(
593 deep_span * 100 >= TOTAL * 20,
594 "expected at least 20% of {TOTAL} structured cases to produce a span past byte 6 \
595 (i.e. actually reach a rule's own offset arithmetic), got {deep_span}"
596 );
597 }
598}