1use crate::objects::enterpriseca::{WebEnrollmentEndpoint, WebEnrollmentResult};
41use crate::utils::b64::{b64_decode, b64_encode};
42use log::{debug, warn};
43use reqwest::blocking::Client;
44use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
45use std::net::{TcpStream, ToSocketAddrs};
46use std::time::Duration;
47
48const MV_AV_EOL: u16 = 0x0000;
52
53const MV_AV_CHANNEL_BINDINGS: u16 = 0x000A;
55
56const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
60const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
62const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
64const HTTPS_TIMEOUT: Duration = Duration::from_secs(8);
66
67const NTLM_NEGOTIATE: &[u8] = &[
90 0x4e, 0x54, 0x4c, 0x4d, 0x53, 0x53, 0x50, 0x00,
92 0x01, 0x00, 0x00, 0x00,
94 0x07, 0x82, 0x08, 0xa0,
97 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
99 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
101];
102
103pub const STATUS_VULNERABLE_HTTP: &str = "Vulnerable_NtlmHttpEndpoint";
105pub const STATUS_VULNERABLE_HTTPS: &str = "Vulnerable_NtlmHttpsEndpointWithoutEpa";
106pub const STATUS_NOT_VULN_EPA: &str = "NotVulnerable_EpaEnabled";
107pub const STATUS_NOT_VULN_PORT: &str = "NotVulnerable_PortInaccessible";
108
109const TYPE_WEB_ENROLLMENT: &str = "WebEnrollmentApplication";
111
112const TYPE_CES: &str = "CertificateEnrollmentWebService";
116
117const CES_AUTH_TYPES: &[&str] = &["Kerberos", "NTLM"];
122
123fn display_url(scheme: &str, host: &str) -> String {
126 format!("{}://{}/certsrv/", scheme, host)
127}
128
129fn probe_url(scheme: &str, host: &str) -> String {
131 format!("{}://{}/certsrv/certfnsh.asp", scheme, host)
132}
133
134fn ces_url(scheme: &str, host: &str, ca: &str, auth: &str) -> String {
142 format!("{}://{}/{}_CES_{}/service.svc/CES", scheme, host, ca, auth)
143}
144
145#[derive(Debug, Clone, PartialEq)]
149pub enum WebEnrollmentStatus {
150 NotFound,
152 Vulnerable,
154 Protected,
156}
157
158#[derive(Debug, Clone, PartialEq)]
160pub enum ProbeOutcome {
161 Reached(WebEnrollmentStatus),
163 PortClosed,
165 Failed(String),
167}
168
169fn outcome_status(outcome: &ProbeOutcome) -> WebEnrollmentStatus {
171 match outcome {
172 ProbeOutcome::Reached(status) => status.clone(),
173 _ => WebEnrollmentStatus::NotFound,
174 }
175}
176
177fn build_non_result(
182 url: String,
183 enrollment_type: &str,
184 outcome: &ProbeOutcome,
185) -> Option<WebEnrollmentEndpoint> {
186 match outcome {
187 ProbeOutcome::PortClosed => Some(WebEnrollmentEndpoint {
188 result: Some(WebEnrollmentResult {
189 url,
190 enrollment_type: enrollment_type.to_string(),
191 status: STATUS_NOT_VULN_PORT.to_string(),
192 adcs_web_enrollment_http: false,
193 adcs_web_enrollment_https: false,
194 adcs_web_enrollment_epa: false,
195 }),
196 collected: true,
197 failure_reason: None,
198 }),
199 ProbeOutcome::Failed(reason) => Some(WebEnrollmentEndpoint {
200 result: None,
201 collected: false,
202 failure_reason: Some(reason.clone()),
203 }),
204 ProbeOutcome::Reached(_) => None,
205 }
206}
207
208fn build_http_endpoint(
211 url: String,
212 enrollment_type: &str,
213 outcome: &ProbeOutcome,
214) -> WebEnrollmentEndpoint {
215 if let Some(ep) = build_non_result(url.clone(), enrollment_type, outcome) {
216 return ep;
217 }
218
219 let vulnerable = outcome_status(outcome) == WebEnrollmentStatus::Vulnerable;
220
221 WebEnrollmentEndpoint {
222 result: Some(WebEnrollmentResult {
223 url,
224 enrollment_type: enrollment_type.to_string(),
225 status: if vulnerable {
226 STATUS_VULNERABLE_HTTP.to_string()
227 } else {
228 STATUS_NOT_VULN_PORT.to_string()
229 },
230 adcs_web_enrollment_http: vulnerable,
231 adcs_web_enrollment_https: false,
232 adcs_web_enrollment_epa: false,
233 }),
234 collected: true,
235 failure_reason: None,
236 }
237}
238
239fn build_https_endpoint(
242 url: String,
243 enrollment_type: &str,
244 outcome: &ProbeOutcome,
245) -> WebEnrollmentEndpoint {
246 if let Some(ep) = build_non_result(url.clone(), enrollment_type, outcome) {
247 return ep;
248 }
249
250 let (status, https, epa) = match outcome_status(outcome) {
251 WebEnrollmentStatus::Vulnerable => (STATUS_VULNERABLE_HTTPS.to_string(), true, false),
252 WebEnrollmentStatus::Protected => (STATUS_NOT_VULN_EPA.to_string(), true, true),
253 WebEnrollmentStatus::NotFound => (STATUS_NOT_VULN_PORT.to_string(), false, false),
254 };
255
256 WebEnrollmentEndpoint {
257 result: Some(WebEnrollmentResult {
258 url,
259 enrollment_type: enrollment_type.to_string(),
260 status,
261 adcs_web_enrollment_http: false,
262 adcs_web_enrollment_https: https,
263 adcs_web_enrollment_epa: epa,
264 }),
265 collected: true,
266 failure_reason: None,
267 }
268}
269
270#[derive(Debug, Clone)]
272pub struct Esc8Result {
273 pub host: String,
274 pub http: WebEnrollmentStatus,
276 pub https: WebEnrollmentStatus,
278 pub vulnerable: bool,
280 pub endpoints: Vec<WebEnrollmentEndpoint>,
283}
284
285pub fn check_esc8(host: &str, ca_name: &str) -> Esc8Result {
296 let http_outcome = probe_http(host, &probe_url("http", host));
297 let https_outcome = probe_https(host, &probe_url("https", host));
298
299 let http = outcome_status(&http_outcome);
300 let https = outcome_status(&https_outcome);
301
302 let mut vulnerable = http == WebEnrollmentStatus::Vulnerable
303 || https == WebEnrollmentStatus::Vulnerable;
304
305 if http == WebEnrollmentStatus::Vulnerable {
306 warn!(
307 "ESC8 detected on {}, Web Enrollment exposed over HTTP without EPA \
308 (NTLM relay possible on {})",
309 host,
310 probe_url("http", host)
311 );
312 }
313 if https == WebEnrollmentStatus::Vulnerable {
314 warn!(
315 "ESC8 detected on {}, Web Enrollment over HTTPS without Channel Binding \
316 (NTLM relay possible on {})",
317 host,
318 probe_url("https", host)
319 );
320 }
321 if https == WebEnrollmentStatus::Protected {
322 debug!("ESC8 HTTPS {}: EPA/Channel Binding enforced, protected", host);
323 }
324 if let ProbeOutcome::Failed(ref reason) = http_outcome {
325 debug!("ESC8 HTTP {} not collected: {}", host, reason);
326 }
327 if let ProbeOutcome::Failed(ref reason) = https_outcome {
328 debug!("ESC8 HTTPS {} not collected: {}", host, reason);
329 }
330
331 let mut endpoints = vec![
332 build_http_endpoint(display_url("http", host), TYPE_WEB_ENROLLMENT, &http_outcome),
333 build_https_endpoint(display_url("https", host), TYPE_WEB_ENROLLMENT, &https_outcome),
334 ];
335
336 if ca_name.is_empty() {
343 debug!("ESC8 CES probe skipped on {}: empty CA name", host);
344 } else {
345 for auth in CES_AUTH_TYPES {
346 let http_url = ces_url("http", host, ca_name, auth);
348 let http_outcome = probe_http(host, &http_url);
349 if outcome_status(&http_outcome) == WebEnrollmentStatus::Vulnerable {
350 vulnerable = true;
351 warn!(
352 "ESC8 detected on {}, CES ({}) exposed over HTTP without EPA \
353 (NTLM relay possible on {})",
354 host, auth, http_url
355 );
356 }
357 if let ProbeOutcome::Failed(ref reason) = http_outcome {
358 debug!("ESC8 CES HTTP {} ({}) not collected: {}", host, auth, reason);
359 }
360 endpoints.push(build_http_endpoint(http_url, TYPE_CES, &http_outcome));
361
362 let https_url = ces_url("https", host, ca_name, auth);
364 let https_outcome = probe_https(host, &https_url);
365 if outcome_status(&https_outcome) == WebEnrollmentStatus::Vulnerable {
366 vulnerable = true;
367 warn!(
368 "ESC8 detected on {}, CES ({}) over HTTPS without Channel Binding \
369 (NTLM relay possible on {})",
370 host, auth, https_url
371 );
372 }
373 if let ProbeOutcome::Failed(ref reason) = https_outcome {
374 debug!("ESC8 CES HTTPS {} ({}) not collected: {}", host, auth, reason);
375 }
376 endpoints.push(build_https_endpoint(https_url, TYPE_CES, &https_outcome));
377 }
378 }
379
380 Esc8Result {
381 host: host.to_string(),
382 http,
383 https,
384 vulnerable,
385 endpoints,
386 }
387}
388
389enum PortState {
393 Open,
395 Closed,
397 Unresolved(String),
399}
400
401fn check_port(host: &str, port: u16) -> PortState {
406 let addrs = match (host, port).to_socket_addrs() {
407 Ok(a) => a.collect::<Vec<_>>(),
408 Err(e) => {
409 return PortState::Unresolved(format!(
410 "DNS resolution failed for {}:{}: {}",
411 host, port, e
412 ));
413 }
414 };
415
416 if addrs.is_empty() {
417 return PortState::Unresolved(format!("no address resolved for {}:{}", host, port));
418 }
419
420 for addr in &addrs {
421 match TcpStream::connect_timeout(addr, TCP_CONNECT_TIMEOUT) {
422 Ok(_) => {
423 debug!("ESC8 port check {}:{} open ({})", host, port, addr);
424 return PortState::Open;
425 }
426 Err(e) => debug!("ESC8 port check {} unreachable: {}", addr, e),
427 }
428 }
429
430 PortState::Closed
431}
432
433fn probe_http(host: &str, url: &str) -> ProbeOutcome {
443 debug!("ESC8 HTTP probe: {}", url);
444
445 match check_port(host, 80) {
446 PortState::Open => {}
447 PortState::Closed => return ProbeOutcome::PortClosed,
448 PortState::Unresolved(reason) => return ProbeOutcome::Failed(reason),
449 }
450
451 let client = match Client::builder()
452 .timeout(HTTP_TIMEOUT)
453 .connect_timeout(HTTP_CONNECT_TIMEOUT)
454 .redirect(reqwest::redirect::Policy::limited(3))
455 .build()
456 {
457 Ok(c) => c,
458 Err(e) => {
459 return ProbeOutcome::Failed(format!("failed to build HTTP client for {}: {}", url, e));
460 }
461 };
462
463 let response = match client.head(url).send() {
464 Ok(r) => r,
465 Err(e) => {
466 return ProbeOutcome::Failed(format!("HTTP request to {} failed: {}", url, e));
467 }
468 };
469
470 let status = response.status();
471 let code = status.as_u16();
472 let has_ntlm = response
473 .headers()
474 .get_all(WWW_AUTHENTICATE)
475 .iter()
476 .any(|v| {
477 let s = v.to_str().unwrap_or("").to_lowercase();
478 s.starts_with("ntlm") || s.starts_with("negotiate")
479 });
480
481 debug!("ESC8 HTTP probe {}: status={} ntlm={}", host, code, has_ntlm);
482
483 if code == 401 {
484 return if has_ntlm {
485 ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable)
486 } else {
487 ProbeOutcome::Reached(WebEnrollmentStatus::NotFound)
489 };
490 }
491
492 if status.is_success() || status.is_redirection() {
493 return ProbeOutcome::Reached(WebEnrollmentStatus::NotFound);
495 }
496
497 ProbeOutcome::Failed(format!(
498 "Response status code does not indicate success: {} ({}) for {}",
499 code,
500 status.canonical_reason().unwrap_or("Unknown"),
501 url
502 ))
503}
504
505fn probe_https(host: &str, url: &str) -> ProbeOutcome {
511 debug!("ESC8 HTTPS probe: {}", url);
512
513 match check_port(host, 443) {
514 PortState::Open => {}
515 PortState::Closed => return ProbeOutcome::PortClosed,
516 PortState::Unresolved(reason) => return ProbeOutcome::Failed(reason),
517 }
518
519 let neg_b64 = b64_encode(NTLM_NEGOTIATE);
520 let auth_value = format!("NTLM {}", neg_b64);
521
522 let client = match Client::builder()
523 .timeout(HTTPS_TIMEOUT)
524 .connect_timeout(HTTP_CONNECT_TIMEOUT)
525 .danger_accept_invalid_certs(true)
526 .build()
527 {
528 Ok(c) => c,
529 Err(e) => {
530 return ProbeOutcome::Failed(format!("failed to build HTTPS client for {}: {}", url, e));
531 }
532 };
533
534 let response = match client.get(url).header(AUTHORIZATION, &auth_value).send() {
535 Ok(r) => r,
536 Err(e) => {
537 return ProbeOutcome::Failed(format!("HTTPS request to {} failed: {}", url, e));
538 }
539 };
540
541 let status = response.status();
542 let code = status.as_u16();
543 debug!("ESC8 HTTPS probe {}: status={}", host, code);
544
545 if code != 401 {
546 if status.is_success() || status.is_redirection() {
547 return ProbeOutcome::Reached(WebEnrollmentStatus::NotFound);
548 }
549 return ProbeOutcome::Failed(format!(
550 "Response status code does not indicate success: {} ({}) for {}",
551 code,
552 status.canonical_reason().unwrap_or("Unknown"),
553 url
554 ));
555 }
556
557 let challenge_token = response
559 .headers()
560 .get_all(WWW_AUTHENTICATE)
561 .iter()
562 .find_map(|v| {
563 let s = v.to_str().unwrap_or("");
564 let lower = s.to_ascii_lowercase();
565 if let Some(rest) = lower.strip_prefix("ntlm ") {
566 let token_b64 = rest.trim();
567 if token_b64.len() > 16 {
568 let orig = s["ntlm ".len()..].trim();
569 return b64_decode(orig);
570 }
571 }
572 None
573 });
574
575 match challenge_token {
576 None => {
577 debug!(
578 "ESC8 HTTPS {}: no NTLM challenge received (Kerberos-only or not installed)",
579 host
580 );
581 ProbeOutcome::Reached(WebEnrollmentStatus::NotFound)
582 }
583 Some(token) => {
584 if parse_epa_channel_bindings(&token) {
585 debug!("ESC8 HTTPS {}: MsvAvChannelBindings present: EPA enforced", host);
586 ProbeOutcome::Reached(WebEnrollmentStatus::Protected)
587 } else {
588 debug!("ESC8 HTTPS {}: MsvAvChannelBindings absent: EPA disabled", host);
589 ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable)
590 }
591 }
592 }
593}
594
595pub fn parse_epa_channel_bindings(token: &[u8]) -> bool {
617 if token.len() < 48 {
618 debug!("NTLM token too short ({} bytes), cannot parse as Type 2", token.len());
619 return false;
620 }
621
622 if &token[0..8] != b"NTLMSSP\0" {
623 debug!("NTLM signature mismatch");
624 return false;
625 }
626
627 let msg_type = u32::from_le_bytes([token[8], token[9], token[10], token[11]]);
628 if msg_type != 2 {
629 debug!("Not a Type 2 message (MessageType={})", msg_type);
630 return false;
631 }
632
633 let ti_len = u16::from_le_bytes([token[40], token[41]]) as usize;
634 let ti_off = u32::from_le_bytes([token[44], token[45], token[46], token[47]]) as usize;
635
636 if ti_len == 0 {
637 debug!("TargetInfo is empty, no AvPairs to inspect");
638 return false;
639 }
640 if token.len() < ti_off.saturating_add(ti_len) {
641 debug!(
642 "TargetInfo out of bounds (off={}, len={}, token_len={})",
643 ti_off, ti_len, token.len()
644 );
645 return false;
646 }
647
648 let avpairs = &token[ti_off..ti_off + ti_len];
649 debug!("Parsing {} bytes of AvPairs", avpairs.len());
650
651 let mut i = 0;
652 while i + 4 <= avpairs.len() {
653 let av_id = u16::from_le_bytes([avpairs[i], avpairs[i + 1]]);
654 let av_len = u16::from_le_bytes([avpairs[i + 2], avpairs[i + 3]]) as usize;
655
656 match av_id {
657 MV_AV_EOL => {
658 debug!("MsvAvEOL reached");
659 break;
660 }
661 MV_AV_CHANNEL_BINDINGS => {
662 debug!("MsvAvChannelBindings found (av_len={})", av_len);
663 return av_len > 0;
664 }
665 other => {
666 debug!("AvPair id=0x{:04x} len={}, skipping", other, av_len);
667 i += 4 + av_len;
668 }
669 }
670 }
671
672 false
673}
674
675#[cfg(test)]
678mod tests {
679 use super::*;
680
681 fn build_type2(avpairs: &[u8]) -> Vec<u8> {
684 let mut t = Vec::new();
685 t.extend_from_slice(b"NTLMSSP\0");
686 t.extend_from_slice(&2u32.to_le_bytes());
687 t.extend_from_slice(&0u16.to_le_bytes());
688 t.extend_from_slice(&0u16.to_le_bytes());
689 t.extend_from_slice(&56u32.to_le_bytes());
690 t.extend_from_slice(&0u32.to_le_bytes());
691 t.extend_from_slice(&[0x01u8; 8]);
692 t.extend_from_slice(&[0u8; 8]);
693 let ti_len = avpairs.len() as u16;
694 t.extend_from_slice(&ti_len.to_le_bytes());
695 t.extend_from_slice(&ti_len.to_le_bytes());
696 t.extend_from_slice(&56u32.to_le_bytes());
697 t.extend_from_slice(&[0u8; 8]);
698 t.extend_from_slice(avpairs);
699 t
700 }
701
702 fn avpairs_with_channel_bindings(value: &[u8]) -> Vec<u8> {
703 let mut p = Vec::new();
704 p.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
705 p.extend_from_slice(&(value.len() as u16).to_le_bytes());
706 p.extend_from_slice(value);
707 p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
708 p.extend_from_slice(&0u16.to_le_bytes());
709 p
710 }
711
712 fn avpairs_without_channel_bindings() -> Vec<u8> {
713 let name: Vec<u8> = "SERVER"
714 .encode_utf16()
715 .flat_map(|u| u.to_le_bytes())
716 .collect();
717 let mut p = Vec::new();
718 p.extend_from_slice(&0x0001u16.to_le_bytes());
719 p.extend_from_slice(&(name.len() as u16).to_le_bytes());
720 p.extend_from_slice(&name);
721 p.extend_from_slice(&MV_AV_EOL.to_le_bytes());
722 p.extend_from_slice(&0u16.to_le_bytes());
723 p
724 }
725
726 #[test]
729 fn epa_present_with_non_zero_value() {
730 let cbt = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE,
731 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
732 let token = build_type2(&avpairs_with_channel_bindings(&cbt));
733 assert!(parse_epa_channel_bindings(&token));
734 }
735
736 #[test]
737 fn epa_present_but_zero_length() {
738 let token = build_type2(&avpairs_with_channel_bindings(&[]));
739 assert!(!parse_epa_channel_bindings(&token));
740 }
741
742 #[test]
743 fn epa_absent_from_avpairs() {
744 let token = build_type2(&avpairs_without_channel_bindings());
745 assert!(!parse_epa_channel_bindings(&token));
746 }
747
748 #[test]
749 fn epa_multiple_avpairs_with_channel_bindings_last() {
750 let name: Vec<u8> = "DC01"
751 .encode_utf16()
752 .flat_map(|u| u.to_le_bytes())
753 .collect();
754 let cbt = [0xAA, 0xBB, 0xCC, 0xDD];
755 let mut avpairs = Vec::new();
756 avpairs.extend_from_slice(&0x0001u16.to_le_bytes());
757 avpairs.extend_from_slice(&(name.len() as u16).to_le_bytes());
758 avpairs.extend_from_slice(&name);
759 avpairs.extend_from_slice(&MV_AV_CHANNEL_BINDINGS.to_le_bytes());
760 avpairs.extend_from_slice(&(cbt.len() as u16).to_le_bytes());
761 avpairs.extend_from_slice(&cbt);
762 avpairs.extend_from_slice(&MV_AV_EOL.to_le_bytes());
763 avpairs.extend_from_slice(&0u16.to_le_bytes());
764 let token = build_type2(&avpairs);
765 assert!(parse_epa_channel_bindings(&token));
766 }
767
768 #[test]
769 fn epa_empty_avpairs() {
770 let token = build_type2(&[]);
771 assert!(!parse_epa_channel_bindings(&token));
772 }
773
774 #[test]
777 fn token_too_short_returns_false() {
778 assert!(!parse_epa_channel_bindings(&[0u8; 10]));
779 assert!(!parse_epa_channel_bindings(&[]));
780 }
781
782 #[test]
783 fn invalid_signature_returns_false() {
784 let mut token = build_type2(&avpairs_without_channel_bindings());
785 token[0] = 0xFF;
786 assert!(!parse_epa_channel_bindings(&token));
787 }
788
789 #[test]
790 fn wrong_message_type_returns_false() {
791 let mut token = build_type2(&avpairs_without_channel_bindings());
792 token[8] = 0x01;
793 token[9] = 0x00;
794 token[10] = 0x00;
795 token[11] = 0x00;
796 assert!(!parse_epa_channel_bindings(&token));
797 }
798
799 #[test]
800 fn target_info_offset_out_of_bounds_returns_false() {
801 let avpairs = avpairs_without_channel_bindings();
802 let mut token = build_type2(&avpairs);
803 let bad_offset = (token.len() + 1024) as u32;
804 token[44..48].copy_from_slice(&bad_offset.to_le_bytes());
805 assert!(!parse_epa_channel_bindings(&token));
806 }
807
808 #[test]
811 fn base64_roundtrip_ntlm_negotiate() {
812 let encoded = b64_encode(NTLM_NEGOTIATE);
813 let decoded = b64_decode(&encoded).expect("base64_decode should succeed");
814 assert_eq!(NTLM_NEGOTIATE, decoded.as_slice());
815 }
816
817 #[test]
818 fn base64_known_vector() {
819 assert_eq!(b64_encode(b"Man"), "TWFu");
820 assert_eq!(b64_decode("TWFu"), Some(b"Man".to_vec()));
821 }
822
823 #[test]
824 fn base64_with_padding() {
825 assert_eq!(b64_encode(b"Ma"), "TWE=");
826 assert_eq!(b64_decode("TWE="), Some(b"Ma".to_vec()));
827 assert_eq!(b64_encode(b"M"), "TQ==");
828 assert_eq!(b64_decode("TQ=="), Some(b"M".to_vec()));
829 }
830
831 #[test]
832 fn base64_decode_invalid_char_returns_none() {
833 assert_eq!(b64_decode("TQ!Q"), None);
834 }
835
836 #[test]
837 fn base64_decode_empty_input() {
838 assert_eq!(b64_decode(""), Some(vec![]));
839 }
840
841 #[test]
844 fn urls_match_sharphound_shape() {
845 assert_eq!(display_url("http", "ca.corp.local"), "http://ca.corp.local/certsrv/");
846 assert_eq!(
847 probe_url("https", "ca.corp.local"),
848 "https://ca.corp.local/certsrv/certfnsh.asp"
849 );
850 }
851
852 #[test]
853 fn ces_url_shape() {
854 assert_eq!(
855 ces_url("https", "ca.corp.local", "CORP-CA", "Kerberos"),
856 "https://ca.corp.local/CORP-CA_CES_Kerberos/service.svc/CES"
857 );
858 assert_eq!(
859 ces_url("https", "ca.corp.local", "CORP-CA", "NTLM"),
860 "https://ca.corp.local/CORP-CA_CES_NTLM/service.svc/CES"
861 );
862 }
863
864 #[test]
870 fn unreachable_host_reports_all_inaccessible_endpoints() {
871 let expected = 2 + 2 * CES_AUTH_TYPES.len();
873 let result = check_esc8("192.0.2.1", "CORP-CA");
874
875 assert_eq!(result.endpoints.len(), expected, "every endpoint must be reported");
876 assert!(!result.vulnerable, "non-routable host must not be flagged");
877 assert_eq!(result.http, WebEnrollmentStatus::NotFound);
878 assert_eq!(result.https, WebEnrollmentStatus::NotFound);
879
880 for ep in &result.endpoints {
881 assert!(ep.collected, "a closed port is collected data");
882 assert!(ep.failure_reason.is_none());
883 assert_eq!(ep.result.as_ref().unwrap().status, STATUS_NOT_VULN_PORT);
884 }
885
886 let ces: Vec<_> = result
888 .endpoints
889 .iter()
890 .filter_map(|e| e.result.as_ref())
891 .filter(|r| r.enrollment_type == TYPE_CES)
892 .collect();
893 assert_eq!(ces.len(), 2 * CES_AUTH_TYPES.len());
894 assert!(ces.iter().all(|r| r.url.contains("_CES_") && r.url.ends_with("/service.svc/CES")));
895 }
896
897 #[test]
900 fn empty_ca_name_skips_ces() {
901 let result = check_esc8("192.0.2.1", "");
902 assert_eq!(result.endpoints.len(), 2);
903 assert!(result
904 .endpoints
905 .iter()
906 .filter_map(|e| e.result.as_ref())
907 .all(|r| r.enrollment_type == TYPE_WEB_ENROLLMENT));
908 }
909
910 #[test]
913 fn from_http_vulnerable() {
914 let ep = build_http_endpoint(
915 display_url("http", "ca.corp.local"),
916 TYPE_WEB_ENROLLMENT,
917 &ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable),
918 );
919 let r = ep.result.as_ref().unwrap();
920 assert_eq!(r.url, "http://ca.corp.local/certsrv/");
921 assert_eq!(r.enrollment_type, TYPE_WEB_ENROLLMENT);
922 assert_eq!(r.status, STATUS_VULNERABLE_HTTP);
923 assert!(r.adcs_web_enrollment_http);
924 assert!(!r.adcs_web_enrollment_https);
925 assert!(!r.adcs_web_enrollment_epa);
926 assert!(ep.collected);
927 assert!(ep.failure_reason.is_none());
928 }
929
930 #[test]
931 fn from_http_reached_but_not_exposed() {
932 let ep = build_http_endpoint(
933 display_url("http", "ca.corp.local"),
934 TYPE_WEB_ENROLLMENT,
935 &ProbeOutcome::Reached(WebEnrollmentStatus::NotFound),
936 );
937 let r = ep.result.as_ref().unwrap();
938 assert_eq!(r.status, STATUS_NOT_VULN_PORT);
939 assert!(!r.adcs_web_enrollment_http);
940 assert!(ep.collected);
941 }
942
943 #[test]
945 fn from_http_port_closed() {
946 let ep = build_http_endpoint(
947 display_url("http", "ca.corp.local"),
948 TYPE_WEB_ENROLLMENT,
949 &ProbeOutcome::PortClosed,
950 );
951 let r = ep.result.as_ref().unwrap();
952 assert_eq!(r.status, STATUS_NOT_VULN_PORT);
953 assert!(ep.collected);
954 assert!(ep.failure_reason.is_none());
955 }
956
957 #[test]
960 fn from_http_request_failed() {
961 let ep = build_http_endpoint(
962 display_url("http", "ca.corp.local"),
963 TYPE_WEB_ENROLLMENT,
964 &ProbeOutcome::Failed("Response status code does not indicate success: 404".into()),
965 );
966 assert!(ep.result.is_none());
967 assert!(!ep.collected);
968 assert!(ep.failure_reason.as_ref().unwrap().contains("404"));
969 }
970
971 #[test]
972 fn from_https_vulnerable() {
973 let ep = build_https_endpoint(
974 display_url("https", "ca.corp.local"),
975 TYPE_WEB_ENROLLMENT,
976 &ProbeOutcome::Reached(WebEnrollmentStatus::Vulnerable),
977 );
978 let r = ep.result.as_ref().unwrap();
979 assert_eq!(r.url, "https://ca.corp.local/certsrv/");
980 assert_eq!(r.status, STATUS_VULNERABLE_HTTPS);
981 assert!(!r.adcs_web_enrollment_http);
982 assert!(r.adcs_web_enrollment_https);
983 assert!(!r.adcs_web_enrollment_epa);
984 }
985
986 #[test]
987 fn from_https_protected() {
988 let ep = build_https_endpoint(
989 display_url("https", "ca.corp.local"),
990 TYPE_WEB_ENROLLMENT,
991 &ProbeOutcome::Reached(WebEnrollmentStatus::Protected),
992 );
993 let r = ep.result.as_ref().unwrap();
994 assert_eq!(r.status, STATUS_NOT_VULN_EPA);
995 assert!(r.adcs_web_enrollment_https);
996 assert!(r.adcs_web_enrollment_epa);
997 }
998
999 #[test]
1000 fn from_https_port_closed() {
1001 let ep = build_https_endpoint(
1002 display_url("https", "ca.corp.local"),
1003 TYPE_WEB_ENROLLMENT,
1004 &ProbeOutcome::PortClosed,
1005 );
1006 let r = ep.result.as_ref().unwrap();
1007 assert_eq!(r.status, STATUS_NOT_VULN_PORT);
1008 assert!(!r.adcs_web_enrollment_https);
1009 assert!(!r.adcs_web_enrollment_epa);
1010 assert!(ep.collected);
1011 }
1012
1013 #[test]
1014 fn from_https_request_failed() {
1015 let ep = build_https_endpoint(
1016 display_url("https", "ca.corp.local"),
1017 TYPE_WEB_ENROLLMENT,
1018 &ProbeOutcome::Failed("TLS handshake failed".into()),
1019 );
1020 assert!(ep.result.is_none());
1021 assert!(!ep.collected);
1022 assert!(ep.failure_reason.as_ref().unwrap().contains("TLS handshake failed"));
1023 }
1024}