From 94637d5fe4a85825cce9dbf3882c71921678ee7a Mon Sep 17 00:00:00 2001 From: Ben Luca Behring Date: Sat, 5 Sep 2026 09:40:26 +0200 Subject: [PATCH] refactor: deepen scan architecture --- CHANGELOG.md | 2 + README.md | 2 +- crates/surface-cli/src/main.rs | 161 ++-- crates/surface-core/src/engine.rs | 695 +++++++----------- crates/surface-core/src/findings.rs | 237 +++--- crates/surface-core/src/http.rs | 33 + crates/surface-core/src/intelligence.rs | 565 +++++++++++--- .../{passive_http.rs => intelligence/http.rs} | 29 +- crates/surface-core/src/lib.rs | 17 +- crates/surface-core/src/lifecycle.rs | 290 ++++++++ crates/surface-core/src/service.rs | 131 +++- crates/surface-core/src/ssh.rs | 346 ++++----- crates/surface-core/src/tls.rs | 1 + crates/surface-report/src/diff.rs | 195 +++-- crates/surface-report/src/exports.rs | 167 ++++- crates/surface-report/src/lib.rs | 398 +++++----- crates/surface-report/src/projection.rs | 144 ++++ docs/report-schema.md | 10 +- 18 files changed, 2300 insertions(+), 1123 deletions(-) rename crates/surface-core/src/{passive_http.rs => intelligence/http.rs} (96%) create mode 100644 crates/surface-core/src/lifecycle.rs create mode 100644 crates/surface-report/src/projection.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d08db46..21f9380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes are documented here. ### Changed +- Changed report schema to `0.4.0` with typed complete, partial, and indeterminate SSH posture; legacy SSH maps remain readable but are not interpreted or diffed against typed reports. +- Unified scan finalization, preserved partial stage and passive-intelligence evidence, and made terminal, HTML, SARIF, CycloneDX, and semantic-diff lifecycle semantics consistent. - Removed the hosted server/control plane in favor of direct CLI scans and complete self-contained HTML reports. - Removed the `--acknowledge-authorization` gate; operators remain responsible for scanning only authorized targets. - Added `indicatif` stage progress, resolver-returned CNAME-chain evidence, default bounded CertSpotter Certificate Transparency discovery, optional passive exact-pair reverse-NS correlation, and truthful HTTP-to-HTTPS redirect rendering. diff --git a/README.md b/README.md index bef83f8..795192c 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ A scan runs every applicable built-in check by default. `--only dns,http,tls` re ## Reports - **terminal** — concise observations, open ports, findings, and counts -- **json** — schema `0.3.0`, complete transport-aware observations/findings/errors/score +- **json** — schema `0.4.0`, complete transport-aware observations/findings/errors/score - **html** — responsive, print-friendly, self-contained, no remote assets or scripts - **sarif** — SARIF 2.1.0 findings for code-scanning integrations - **cyclonedx-json** — CycloneDX 1.6 observed-service inventory and vulnerabilities diff --git a/crates/surface-cli/src/main.rs b/crates/surface-cli/src/main.rs index 9ba9a6d..1643e6b 100644 --- a/crates/surface-cli/src/main.rs +++ b/crates/surface-cli/src/main.rs @@ -10,9 +10,9 @@ use indicatif::{ProgressBar, ProgressDrawTarget}; use surface_core::{ IntelligenceObservation, ScanConfiguration, ScanProgress, ScanReport, ScanSelection, ScanStage, ScanStatus, Severity, SkippedCheck, analyze_bgp_routes, analyze_certificate_transparency, - analyze_intelligence, analyze_network_registrations, analyze_related_domains, - calculate_exposure, normalize_target, parse_bundle, parse_ports, parse_udp_ports, - run_scan_selected_until_with_progress, + analyze_intelligence, analyze_network_registrations, analyze_related_domains, finalize_report, + normalize_target, parse_bundle, parse_ports, parse_udp_ports, + run_scan_selected_until_with_progress_deferred, }; use surface_report::{ decode_key, diff_reports, render_cyclonedx, render_diff_html, render_diff_json, @@ -448,7 +448,7 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { }); let progress = progress_bar(arguments.quiet); let scan_progress = progress.clone(); - let mut report = run_scan_selected_until_with_progress( + let mut report = run_scan_selected_until_with_progress_deferred( target, configuration, cancellation.clone(), @@ -487,6 +487,8 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { &arguments.dkim_selectors, bundle.as_ref(), arguments.request_timeout, + scan_deadline, + &cancellation, ) .await .map_err(|error| AppError::new(error, EXIT_INVALID_INPUT)) @@ -548,42 +550,40 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { let passive_results = if ct_applicable || related_configured { let passive_progress = progress_bar(arguments.quiet); passive_progress.set_message("Passive intelligence"); - let results = tokio::select! { - biased; - () = cancellation.cancelled() => None, - result = tokio::time::timeout_at(scan_deadline, async { - tokio::join!( - async { - if ct_applicable { - Some( - analyze_certificate_transparency( - &report, - certspotter_api_key.as_deref(), - arguments.request_timeout, - ) - .await, - ) - } else { - None - } - }, - async { - if let Some(api_key) = reverse_ns_api_key.as_deref() { - Some( - analyze_related_domains( - &report, - api_key, - arguments.request_timeout, - ) - .await, - ) - } else { - None - } - } - ) - }) => result.ok(), - }; + let results = Some(tokio::join!( + async { + if ct_applicable { + Some( + analyze_certificate_transparency( + &report, + certspotter_api_key.as_deref(), + arguments.request_timeout, + scan_deadline, + &cancellation, + ) + .await, + ) + } else { + None + } + }, + async { + if let Some(api_key) = reverse_ns_api_key.as_deref() { + Some( + analyze_related_domains( + &report, + api_key, + arguments.request_timeout, + scan_deadline, + &cancellation, + ) + .await, + ) + } else { + None + } + } + )); passive_progress.finish_and_clear(); results } else { @@ -608,11 +608,11 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { } } Err(reason) => { - report.skipped_checks.push(SkippedCheck { - check: "certificate_transparency".to_owned(), - reason, - }); - mark_intelligence_partial(&mut report); + mark_intelligence_source_error( + &mut report, + "certificate_transparency", + &reason, + ); } } } @@ -634,11 +634,7 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { } } Err(reason) => { - report.skipped_checks.push(SkippedCheck { - check: "related_domains".to_owned(), - reason, - }); - mark_intelligence_partial(&mut report); + mark_intelligence_source_error(&mut report, "related_domains", &reason); } } } @@ -652,6 +648,10 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { ], ); } + if cancellation.is_cancelled() { + report.status = ScanStatus::Interrupted; + "scan interrupted".clone_into(&mut report.message); + } if intelligence_selected && !ct_applicable { report.skipped_checks.push(SkippedCheck { check: "certificate_transparency".to_owned(), @@ -664,8 +664,7 @@ async fn run_scan_command(arguments: ScanArgs) -> Result<(), AppError> { intelligence_selected, related_configured, ); - refresh_exposure_score(&mut report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); + finalize_report(&mut report, &cancellation, scan_deadline, |_| {}); signal_task.abort(); if arguments.persist { let database = arguments.database.as_ref().ok_or_else(|| { @@ -1052,8 +1051,9 @@ fn report_exit_error(report: &ScanReport) -> Option { }) } +#[cfg(test)] fn refresh_exposure_score(report: &mut ScanReport) { - report.exposure_score = Some(calculate_exposure(report)); + report.exposure_score = Some(surface_core::calculate_exposure(report)); } fn mark_intelligence_partial(report: &mut ScanReport) { @@ -1063,6 +1063,31 @@ fn mark_intelligence_partial(report: &mut ScanReport) { } } +fn mark_intelligence_source_error(report: &mut ScanReport, check: &str, reason: &str) { + let intelligence = report + .intelligence + .get_or_insert_with(|| IntelligenceObservation { + complete: true, + ..IntelligenceObservation::default() + }); + intelligence.complete = false; + let error = format!("{check}: {reason}"); + if !intelligence.errors.contains(&error) { + intelligence.errors.push(error); + } + if !report + .skipped_checks + .iter() + .any(|skipped| skipped.check == check) + { + report.skipped_checks.push(SkippedCheck { + check: check.to_owned(), + reason: reason.to_owned(), + }); + } + mark_intelligence_partial(report); +} + fn mark_intelligence_stopped(report: &mut ScanReport, cancelled: bool, checks: &[(&str, bool)]) { let reason = if cancelled { report.status = ScanStatus::Interrupted; @@ -1201,8 +1226,8 @@ mod tests { use super::{ Cli, Command, EXIT_INVALID_INPUT, EXIT_SCAN_FAILED, ScanPart, mark_intelligence_partial, - mark_intelligence_stopped, parse_duration, parse_retention_duration, - refresh_exposure_score, report_exit_error, run, + mark_intelligence_source_error, mark_intelligence_stopped, parse_duration, + parse_retention_duration, refresh_exposure_score, report_exit_error, run, }; #[test] @@ -1289,6 +1314,32 @@ mod tests { assert_eq!(report.skipped_checks[1].reason, "scan interrupted"); } + #[test] + fn intelligence_source_error_marks_root_observation_incomplete() { + let mut report = surface_core::ScanReport::not_started( + surface_core::normalize_target("example.com").unwrap_or_else(|error| panic!("{error}")), + surface_core::ScanConfiguration { + ports: vec![80], + udp_ports: Vec::new(), + concurrency: 1, + connect_timeout_ms: 100, + request_timeout_ms: 100, + global_timeout_ms: 1_000, + ipv4_only: false, + ipv6_only: false, + authorization_acknowledged: true, + }, + ); + report.status = surface_core::ScanStatus::Completed; + + mark_intelligence_source_error(&mut report, "certificate_transparency", "HTTP 503"); + + let intelligence = report.intelligence.expect("intelligence observation"); + assert!(!intelligence.complete); + assert_eq!(intelligence.errors, ["certificate_transparency: HTTP 503"]); + assert_eq!(report.status, surface_core::ScanStatus::Partial); + } + #[tokio::test] async fn invalid_configuration_is_rejected_before_scanning() { let invalid = Cli::try_parse_from(["surface", "scan", "example.com", "--ports", "nope"]) diff --git a/crates/surface-core/src/engine.rs b/crates/surface-core/src/engine.rs index 0ffceb1..0f553b7 100644 --- a/crates/surface-core/src/engine.rs +++ b/crates/surface-core/src/engine.rs @@ -4,16 +4,17 @@ use std::collections::BTreeSet; use std::net::IpAddr; use std::time::Duration; -use tokio::time::{Instant, timeout_at}; +use tokio::time::{Instant, sleep_until, timeout_at}; use tokio_util::sync::CancellationToken; +use crate::lifecycle; +use crate::lifecycle::add_skipped_check; use crate::ssh::{SshAnalysisState, analyze_ssh}; use crate::{ - AxfrOutcome, DanglingCnameStatus, DnssecObservation, DnssecStatus, HostObservation, - NormalizedTarget, ScanConfiguration, ScanError, ScanErrorKind, ScanProgress, ScanReport, - ScanSelection, ScanStage, ScanStatus, SkippedCheck, WildcardDnsStatus, analyze_http, - analyze_tls, calculate_exposure, detect_services, generate_findings, scan_ports, - scan_udp_ports, + AxfrOutcome, DanglingCnameStatus, DnssecStatus, HostObservation, NormalizedTarget, + ScanConfiguration, ScanError, ScanErrorKind, ScanProgress, ScanReport, ScanSelection, + ScanStage, ScanStatus, WildcardDnsStatus, analyze_http, analyze_tls, detect_services, + scan_ports, scan_udp_ports, }; // Rust guideline compliant 2026-02-21 @@ -41,7 +42,7 @@ pub async fn run_scan_with_progress( run_scan_selected_with_progress( target, configuration, - cancellation, + cancellation.clone(), ScanSelection::all(), progress, ) @@ -61,7 +62,7 @@ pub async fn run_scan_selected_with_progress( run_scan_selected_until_with_progress( target, configuration, - cancellation, + cancellation.clone(), selection, deadline, progress, @@ -78,6 +79,29 @@ pub async fn run_scan_selected_until_with_progress( selection: ScanSelection, deadline: Instant, progress: impl Fn(ScanProgress), +) -> ScanReport { + let mut report = run_scan_inner( + target, + configuration, + cancellation.clone(), + selection, + deadline, + &progress, + ) + .await; + finalize_report(&mut report, &cancellation, deadline, progress); + report +} + +/// Runs selected core stages while deferring report finalization to the caller. +#[must_use] +pub async fn run_scan_selected_until_with_progress_deferred( + target: NormalizedTarget, + configuration: ScanConfiguration, + cancellation: CancellationToken, + selection: ScanSelection, + deadline: Instant, + progress: impl Fn(ScanProgress), ) -> ScanReport { run_scan_inner( target, @@ -90,6 +114,16 @@ pub async fn run_scan_selected_until_with_progress( .await } +/// Commits findings, exposure, and the final completion timestamp. +pub fn finalize_report( + report: &mut ScanReport, + cancellation: &CancellationToken, + deadline: Instant, + progress: impl Fn(ScanProgress), +) { + lifecycle::finalize(report, cancellation, deadline, &progress); +} + #[expect( clippy::too_many_lines, reason = "linear stages preserve partial observations and deadlines" @@ -105,11 +139,13 @@ async fn run_scan_inner( let mut configuration = configuration; configuration.authorization_acknowledged = true; let mut report = ScanReport::not_started(target, configuration); - record_selection_skips(&mut report, selection); + lifecycle::record_selection_skips(&mut report, selection); if incompatible_address_family(&report) { - reject_before_scan( + lifecycle::terminate( &mut report, - ScanErrorKind::Other, + ScanStage::Preflight, + selection, + ScanErrorKind::Configuration, "explicit IP conflicts with the selected address family", ); return report; @@ -117,10 +153,11 @@ async fn run_scan_inner( "Scan started.".clone_into(&mut report.message); if Instant::now() >= deadline { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Dns, selection, + ScanErrorKind::Timeout, "global timeout expired before DNS analysis", ); return report; @@ -137,7 +174,7 @@ async fn run_scan_inner( let dns = tokio::select! { biased; () = cancellation.cancelled() => { - interrupt(&mut report, ScanStage::Dns, selection, "scan interrupted during DNS analysis"); + lifecycle::terminate(&mut report, ScanStage::Dns, selection, ScanErrorKind::Cancelled, "scan interrupted during DNS analysis"); return report; } result = timeout_at(deadline, dns_future) => result, @@ -159,10 +196,11 @@ async fn run_scan_inner( None } Err(_) => { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Dns, selection, + ScanErrorKind::Timeout, "global timeout expired during DNS analysis", ); return report; @@ -185,23 +223,27 @@ async fn run_scan_inner( let dnssec = tokio::select! { biased; () = cancellation.cancelled() => { - stop_after_dnssec( - &mut report, - selection, - ScanErrorKind::Cancelled, - ScanStatus::Interrupted, - "scan interrupted during DNSSEC validation", - ); + if let Some(dns) = report.dns.as_mut() { + dns.dnssec = Some(crate::DnssecObservation { status: DnssecStatus::Indeterminate, limitations: vec!["scan interrupted during DNSSEC validation".to_owned()], ..crate::DnssecObservation::default() }); + } + lifecycle::terminate(&mut report, ScanStage::Dns, selection, ScanErrorKind::Cancelled, "scan interrupted during DNSSEC validation"); return report; } result = timeout_at(deadline, dnssec_future) => result, }; let Ok(observation) = dnssec else { - stop_after_dnssec( + if let Some(dns) = report.dns.as_mut() { + dns.dnssec = Some(crate::DnssecObservation { + status: DnssecStatus::Indeterminate, + limitations: vec!["global timeout expired during DNSSEC validation".to_owned()], + ..crate::DnssecObservation::default() + }); + } + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Timeout, - ScanStatus::Partial, "global timeout expired during DNSSEC validation", ); return report; @@ -273,21 +315,21 @@ async fn run_scan_inner( add_skipped_check(&mut report, "authoritative_axfr", reason); } crate::dns::AxfrCheckState::TimedOut => { - stop_after_axfr( + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Timeout, - ScanStatus::Partial, "global timeout expired during authoritative AXFR checking", ); return report; } crate::dns::AxfrCheckState::Cancelled => { - stop_after_axfr( + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Cancelled, - ScanStatus::Interrupted, "scan interrupted during authoritative AXFR checking", ); return report; @@ -338,21 +380,21 @@ async fn run_scan_inner( } } crate::dns::WildcardDnsCheckState::TimedOut => { - stop_after_wildcard_dns( + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Timeout, - ScanStatus::Partial, "global timeout expired during wildcard DNS detection", ); return report; } crate::dns::WildcardDnsCheckState::Cancelled => { - stop_after_wildcard_dns( + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Cancelled, - ScanStatus::Interrupted, "scan interrupted during wildcard DNS detection", ); return report; @@ -406,21 +448,21 @@ async fn run_scan_inner( } } crate::dns::DanglingCnameCheckState::TimedOut => { - stop_after_dangling_cname( + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Timeout, - ScanStatus::Partial, "global timeout expired during CNAME destination checking", ); return report; } crate::dns::DanglingCnameCheckState::Cancelled => { - stop_after_dangling_cname( + lifecycle::terminate( &mut report, + ScanStage::Dns, selection, ScanErrorKind::Cancelled, - ScanStatus::Interrupted, "scan interrupted during CNAME destination checking", ); return report; @@ -451,10 +493,11 @@ async fn run_scan_inner( return finish_selected_report(report, &cancellation, selection, None, progress); } if Instant::now() >= deadline { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Ports, selection, + ScanErrorKind::Timeout, "global timeout expired before port scanning", ); return report; @@ -462,6 +505,12 @@ async fn run_scan_inner( progress(ScanProgress::Started(ScanStage::Ports)); let probe_concurrency = report.configuration.concurrency.div_ceil(2).max(1); + let stage_cancellation = cancellation.child_token(); + let deadline_cancellation = stage_cancellation.clone(); + let deadline_task = tokio::spawn(async move { + sleep_until(deadline).await; + deadline_cancellation.cancel(); + }); let ports_future = async { let (tcp, udp) = tokio::join!( scan_ports( @@ -469,37 +518,40 @@ async fn run_scan_inner( &report.configuration.ports, probe_concurrency, Duration::from_millis(report.configuration.connect_timeout_ms), - &cancellation, + &stage_cancellation, ), scan_udp_ports( &addresses, &report.configuration.udp_ports, probe_concurrency, Duration::from_millis(report.configuration.connect_timeout_ms), - &cancellation, + &stage_cancellation, ) ); merge_hosts(tcp, udp) }; - let hosts = tokio::select! { - biased; - () = cancellation.cancelled() => { - interrupt(&mut report, ScanStage::Ports, selection, "scan interrupted during port scanning"); - return report; - } - result = timeout_at(deadline, ports_future) => result, - }; - if let Ok(hosts) = hosts { - report.hosts = hosts; - progress(ScanProgress::Completed { - stage: ScanStage::Ports, - observations: report.hosts.len(), - }); - } else { - fail_timeout( + report.hosts = ports_future.await; + deadline_task.abort(); + progress(ScanProgress::Completed { + stage: ScanStage::Ports, + observations: report.hosts.len(), + }); + if cancellation.is_cancelled() { + lifecycle::terminate( + &mut report, + ScanStage::Ports, + selection, + ScanErrorKind::Cancelled, + "scan interrupted during port scanning", + ); + return report; + } + if Instant::now() >= deadline { + lifecycle::terminate( &mut report, ScanStage::Ports, selection, + ScanErrorKind::Timeout, "global timeout expired during port scanning", ); return report; @@ -509,10 +561,11 @@ async fn run_scan_inner( return finish_selected_report(report, &cancellation, selection, None, progress); } if Instant::now() >= deadline { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Services, selection, + ScanErrorKind::Timeout, "global timeout expired before service detection", ); return report; @@ -534,28 +587,32 @@ async fn run_scan_inner( )); } progress(ScanProgress::Started(ScanStage::Services)); - let services_future = detect_services( + let (stage_cancellation, deadline_task) = stage_deadline_cancellation(&cancellation, deadline); + report.services = detect_services( &report.hosts, report.target.hostname.as_deref(), report.configuration.concurrency.min(32), Duration::from_millis(report.configuration.request_timeout_ms), - &cancellation, - ); - let services = tokio::select! { - biased; - () = cancellation.cancelled() => { - interrupt(&mut report, ScanStage::Services, selection, "scan interrupted during service detection"); - return report; - } - result = timeout_at(deadline, services_future) => result, - }; - if let Ok(services) = services { - report.services = services; - } else { - fail_timeout( + &stage_cancellation, + ) + .await; + deadline_task.abort(); + if cancellation.is_cancelled() { + lifecycle::terminate( + &mut report, + ScanStage::Services, + selection, + ScanErrorKind::Cancelled, + "scan interrupted during service detection", + ); + return report; + } + if Instant::now() >= deadline { + lifecycle::terminate( &mut report, ScanStage::Services, selection, + ScanErrorKind::Timeout, "global timeout expired during service detection", ); return report; @@ -577,19 +634,21 @@ async fn run_scan_inner( }); } SshAnalysisState::TimedOut => { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Services, selection, + ScanErrorKind::Timeout, "global timeout expired during SSH analysis", ); return report; } SshAnalysisState::Cancelled => { - interrupt( + lifecycle::terminate( &mut report, ScanStage::Services, selection, + ScanErrorKind::Cancelled, "scan interrupted during SSH analysis", ); return report; @@ -598,41 +657,47 @@ async fn run_scan_inner( if selection.http { if Instant::now() >= deadline { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Http, selection, + ScanErrorKind::Timeout, "global timeout expired before HTTP analysis", ); return report; } progress(ScanProgress::Started(ScanStage::Http)); - let http_future = analyze_http( + let (stage_cancellation, deadline_task) = + stage_deadline_cancellation(&cancellation, deadline); + report.http = analyze_http( &report.target, &report.services, report.configuration.concurrency.min(16), Duration::from_millis(report.configuration.request_timeout_ms), - &cancellation, - ); - let http = tokio::select! { - biased; - () = cancellation.cancelled() => { - interrupt(&mut report, ScanStage::Http, selection, "scan interrupted during HTTP analysis"); - return report; - } - result = timeout_at(deadline, http_future) => result, - }; - if let Ok(http) = http { - report.http = http; - progress(ScanProgress::Completed { - stage: ScanStage::Http, - observations: report.http.len(), - }); - } else { - fail_timeout( + &stage_cancellation, + ) + .await; + deadline_task.abort(); + progress(ScanProgress::Completed { + stage: ScanStage::Http, + observations: report.http.len(), + }); + if cancellation.is_cancelled() { + lifecycle::terminate( + &mut report, + ScanStage::Http, + selection, + ScanErrorKind::Cancelled, + "scan interrupted during HTTP analysis", + ); + return report; + } + if Instant::now() >= deadline { + lifecycle::terminate( &mut report, ScanStage::Http, selection, + ScanErrorKind::Timeout, "global timeout expired during HTTP analysis", ); return report; @@ -640,80 +705,66 @@ async fn run_scan_inner( } if selection.tls + && report.target.explicit_ip.is_none() && let Some(server_name) = report.target.hostname.as_deref() { if Instant::now() >= deadline { - fail_timeout( + lifecycle::terminate( &mut report, ScanStage::Tls, selection, + ScanErrorKind::Timeout, "global timeout expired before TLS analysis", ); return report; } progress(ScanProgress::Started(ScanStage::Tls)); - let tls_future = analyze_tls( + let (stage_cancellation, deadline_task) = + stage_deadline_cancellation(&cancellation, deadline); + report.tls = analyze_tls( &report.services, server_name, report.configuration.concurrency.min(16), Duration::from_millis(report.configuration.request_timeout_ms), - &cancellation, - ); - let tls = tokio::select! { - biased; - () = cancellation.cancelled() => { - interrupt(&mut report, ScanStage::Tls, selection, "scan interrupted during TLS analysis"); - return report; - } - result = timeout_at(deadline, tls_future) => result, - }; - if let Ok(tls) = tls { - report.tls = tls; - progress(ScanProgress::Completed { - stage: ScanStage::Tls, - observations: report.tls.len(), - }); - } else { - fail_timeout( + &stage_cancellation, + ) + .await; + deadline_task.abort(); + progress(ScanProgress::Completed { + stage: ScanStage::Tls, + observations: report.tls.len(), + }); + if cancellation.is_cancelled() { + lifecycle::terminate( + &mut report, + ScanStage::Tls, + selection, + ScanErrorKind::Cancelled, + "scan interrupted during TLS analysis", + ); + return report; + } + if Instant::now() >= deadline { + lifecycle::terminate( &mut report, ScanStage::Tls, selection, + ScanErrorKind::Timeout, "global timeout expired during TLS analysis", ); return report; } } - finish_selected_report(report, &cancellation, selection, None, progress) -} - -fn add_skipped_check(report: &mut ScanReport, check: &str, reason: &str) { - if !report - .skipped_checks - .iter() - .any(|skipped| skipped.check == check) - { - report.skipped_checks.push(SkippedCheck { - check: check.to_owned(), - reason: reason.to_owned(), - }); + if selection.tls && (report.target.explicit_ip.is_some() || report.target.hostname.is_none()) { + add_skipped_check( + &mut report, + "tls", + "not applicable: TLS validation requires a hostname for SNI and certificate matching", + ); } -} -fn record_selection_skips(report: &mut ScanReport, selection: ScanSelection) { - for (check, selected) in [ - ("ports", selection.ports), - ("services", selection.services), - ("http", selection.http), - ("tls", selection.tls), - ] { - if !selected { - report.skipped_checks.push(SkippedCheck { - check: check.to_owned(), - reason: "excluded by --only".to_owned(), - }); - } - } + finish_selected_report(report, &cancellation, selection, None, progress) } fn finish_selected_report( @@ -721,57 +772,20 @@ fn finish_selected_report( cancellation: &CancellationToken, selection: ScanSelection, unfinished_stage: Option, - progress: &impl Fn(ScanProgress), + _progress: &impl Fn(ScanProgress), ) -> ScanReport { if cancellation.is_cancelled() { let message = "scan interrupted before findings finalization"; - report.status = ScanStatus::Interrupted; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - ScanStage::Findings, - report.target.hostname.clone(), + lifecycle::terminate( + &mut report, + unfinished_stage.unwrap_or(ScanStage::Findings), + selection, ScanErrorKind::Cancelled, message, - true, - )); - if let Some(stage) = unfinished_stage { - record_unfinished_checks(&mut report, stage, selection, message); - } - } else if report.status != ScanStatus::Partial { - let dnssec_indeterminate = report - .dns - .as_ref() - .and_then(|dns| dns.dnssec.as_ref()) - .is_some_and(|dnssec| dnssec.status == DnssecStatus::Indeterminate); - let wildcard_indeterminate = report - .dns - .as_ref() - .and_then(|dns| dns.wildcard_dns.as_ref()) - .is_some_and(|wildcard| wildcard.status == WildcardDnsStatus::Indeterminate); - let dangling_indeterminate = report.dns.as_ref().is_some_and(|dns| { - dns.dangling_cnames - .iter() - .any(|observation| observation.status == DanglingCnameStatus::Indeterminate) - }); - report.status = if report.errors.is_empty() - && !dnssec_indeterminate - && !wildcard_indeterminate - && !dangling_indeterminate - { - ScanStatus::Completed - } else { - ScanStatus::Partial - }; - "Surface analyzed externally observable services and security-related configuration." - .clone_into(&mut report.message); + ); + } else { + lifecycle::settle(&mut report); } - progress(ScanProgress::Started(ScanStage::Findings)); - complete_findings(&mut report); - progress(ScanProgress::Completed { - stage: ScanStage::Findings, - observations: report.findings.len(), - }); - report.completed_at = Some(time::OffsetDateTime::now_utc()); report } @@ -789,6 +803,19 @@ fn merge_hosts(mut tcp: Vec, udp: Vec) -> Vec< tcp } +fn stage_deadline_cancellation( + cancellation: &CancellationToken, + deadline: Instant, +) -> (CancellationToken, tokio::task::JoinHandle<()>) { + let stage_cancellation = cancellation.child_token(); + let deadline_cancellation = stage_cancellation.clone(); + let deadline_task = tokio::spawn(async move { + sleep_until(deadline).await; + deadline_cancellation.cancel(); + }); + (stage_cancellation, deadline_task) +} + fn incompatible_address_family(report: &ScanReport) -> bool { report.target.explicit_ip.is_some_and(|ip| { (report.configuration.ipv4_only && ip.is_ipv6()) @@ -796,36 +823,6 @@ fn incompatible_address_family(report: &ScanReport) -> bool { }) } -fn reject_before_scan(report: &mut ScanReport, kind: ScanErrorKind, message: &str) { - report.status = ScanStatus::Failed; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - ScanStage::Ports, - report.target.hostname.clone(), - kind, - message, - false, - )); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn complete_findings(report: &mut ScanReport) { - report.findings = generate_findings( - report - .target - .hostname - .as_deref() - .unwrap_or(&report.target.original), - report.dns.as_ref(), - &report.hosts, - &report.services, - &report.http, - &report.tls, - time::OffsetDateTime::now_utc(), - ); - report.exposure_score = Some(calculate_exposure(report)); -} - fn scan_addresses(report: &ScanReport) -> Vec { report .dns @@ -841,186 +838,6 @@ fn scan_addresses(report: &ScanReport) -> Vec { .unwrap_or_default() } -fn stop_after_dnssec( - report: &mut ScanReport, - selection: ScanSelection, - kind: ScanErrorKind, - status: ScanStatus, - message: &str, -) { - report.status = status; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - ScanStage::Dns, - report.target.hostname.clone(), - kind, - message, - true, - )); - if let Some(dns) = report.dns.as_mut() { - dns.dnssec = Some(DnssecObservation { - status: DnssecStatus::Indeterminate, - limitations: vec![message.to_owned()], - ..DnssecObservation::default() - }); - } - add_skipped_check(report, "dnssec_validation", message); - add_skipped_check(report, "authoritative_axfr", message); - add_skipped_check(report, "wildcard_dns", message); - add_skipped_check(report, "dangling_cname", message); - record_unfinished_checks(report, ScanStage::Ports, selection, message); - complete_findings(report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn stop_after_axfr( - report: &mut ScanReport, - selection: ScanSelection, - kind: ScanErrorKind, - status: ScanStatus, - message: &str, -) { - report.status = status; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - ScanStage::Dns, - report.target.hostname.clone(), - kind, - message, - true, - )); - add_skipped_check(report, "authoritative_axfr", message); - add_skipped_check(report, "wildcard_dns", message); - add_skipped_check(report, "dangling_cname", message); - record_unfinished_checks(report, ScanStage::Ports, selection, message); - complete_findings(report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn stop_after_wildcard_dns( - report: &mut ScanReport, - selection: ScanSelection, - kind: ScanErrorKind, - status: ScanStatus, - message: &str, -) { - report.status = status; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - ScanStage::Dns, - report.target.hostname.clone(), - kind, - message, - true, - )); - add_skipped_check(report, "wildcard_dns", message); - add_skipped_check(report, "dangling_cname", message); - record_unfinished_checks(report, ScanStage::Ports, selection, message); - complete_findings(report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn stop_after_dangling_cname( - report: &mut ScanReport, - selection: ScanSelection, - kind: ScanErrorKind, - status: ScanStatus, - message: &str, -) { - report.status = status; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - ScanStage::Dns, - report.target.hostname.clone(), - kind, - message, - true, - )); - add_skipped_check(report, "dangling_cname", message); - record_unfinished_checks(report, ScanStage::Ports, selection, message); - complete_findings(report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn interrupt(report: &mut ScanReport, stage: ScanStage, selection: ScanSelection, message: &str) { - report.status = ScanStatus::Interrupted; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - stage, - report.target.hostname.clone(), - ScanErrorKind::Cancelled, - message, - true, - )); - record_unfinished_checks(report, stage, selection, message); - complete_findings(report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn fail_timeout( - report: &mut ScanReport, - stage: ScanStage, - selection: ScanSelection, - message: &str, -) { - report.status = ScanStatus::Partial; - message.clone_into(&mut report.message); - report.errors.push(ScanError::new( - stage, - report.target.hostname.clone(), - ScanErrorKind::Timeout, - message, - true, - )); - record_unfinished_checks(report, stage, selection, message); - complete_findings(report); - report.completed_at = Some(time::OffsetDateTime::now_utc()); -} - -fn record_unfinished_checks( - report: &mut ScanReport, - failed_stage: ScanStage, - selection: ScanSelection, - reason: &str, -) { - let failed_rank = stage_rank(failed_stage); - for (check, stage, selected) in [ - ("dns", ScanStage::Dns, true), - ("dnssec_validation", ScanStage::Dns, true), - ("authoritative_axfr", ScanStage::Dns, true), - ("wildcard_dns", ScanStage::Dns, true), - ("dangling_cname", ScanStage::Dns, true), - ("ports", ScanStage::Ports, selection.ports), - ("services", ScanStage::Services, selection.services), - ("http", ScanStage::Http, selection.http), - ("tls", ScanStage::Tls, selection.tls), - ] { - if selected - && stage_rank(stage) >= failed_rank - && !report - .skipped_checks - .iter() - .any(|skipped| skipped.check == check) - { - report.skipped_checks.push(SkippedCheck { - check: check.to_owned(), - reason: reason.to_owned(), - }); - } - } -} - -const fn stage_rank(stage: ScanStage) -> u8 { - match stage { - ScanStage::Dns => 0, - ScanStage::Ports => 1, - ScanStage::Services => 2, - ScanStage::Http => 3, - ScanStage::Tls => 4, - ScanStage::Findings => 5, - } -} - #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -1174,6 +991,14 @@ mod tests { )); assert_eq!(report.hosts[0].ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); assert!(report.configuration.authorization_acknowledged); + assert_eq!( + report + .skipped_checks + .iter() + .filter(|skipped| skipped.check == "tls") + .count(), + 1 + ); } #[tokio::test] @@ -1259,7 +1084,7 @@ mod tests { ) .await; - assert_eq!(report.status, ScanStatus::Partial); + assert_eq!(report.status, ScanStatus::Failed); assert_eq!(report.errors[0].stage, ScanStage::Dns); assert!( report @@ -1382,13 +1207,13 @@ mod tests { assert_eq!(report.services.len(), 1); assert_eq!(report.services[0].address, address); assert_eq!(report.services[0].service, crate::ServiceKind::Ssh); - assert_eq!( + assert!(matches!( report.services[0] - .protocol_details - .get("ssh_analysis_status") - .map(String::as_str), - Some("complete_inferred") - ); + .ssh + .as_ref() + .map(|posture| &posture.outcome), + Some(crate::SshPostureOutcome::Complete { .. }) + )); assert!(report.http.is_empty()); assert!(report.tls.is_empty()); } @@ -1485,13 +1310,11 @@ mod tests { .await .unwrap_or_else(|error| panic!("{error}")); assert_eq!(report.status, ScanStatus::Partial); - assert_eq!( - report.services[0] - .protocol_details - .get("ssh_skip_reason") - .map(String::as_str), - Some("caller deadline exceeded") - ); + assert!(matches!( + report.services[0].ssh.as_ref().map(|posture| &posture.outcome), + Some(crate::SshPostureOutcome::Indeterminate { reason }) + if reason == "caller deadline exceeded" + )); assert_eq!(report.services[0].address, address); let listener = TcpListener::bind("127.0.0.1:0") @@ -1535,13 +1358,10 @@ mod tests { .await .unwrap_or_else(|error| panic!("{error}")); assert_eq!(report.status, ScanStatus::Interrupted); - assert_eq!( - report.services[0] - .protocol_details - .get("ssh_skip_reason") - .map(String::as_str), - Some("cancelled") - ); + assert!(matches!( + report.services[0].ssh.as_ref().map(|posture| &posture.outcome), + Some(crate::SshPostureOutcome::Indeterminate { reason }) if reason == "cancelled" + )); assert_eq!(report.services[0].address, address); } @@ -1618,6 +1438,10 @@ mod tests { } #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "the end-to-end fixture keeps lifecycle assertions in one behavior test" + )] async fn no_address_cancellation_preserves_completed_dns_observations() { let target = normalize_target("127.0.0.1").unwrap_or_else(|error| panic!("{error}")); let mut report = crate::ScanReport::not_started( @@ -1671,13 +1495,19 @@ mod tests { let cancellation = CancellationToken::new(); cancellation.cancel(); - let report = super::finish_selected_report( + let mut report = super::finish_selected_report( report, &cancellation, ScanSelection::all(), Some(ScanStage::Ports), &|_| {}, ); + super::finalize_report( + &mut report, + &cancellation, + Instant::now() + Duration::from_secs(1), + |_| {}, + ); let dns = report.dns.as_ref().expect("DNS observation"); assert_eq!(report.status, ScanStatus::Interrupted); @@ -1751,8 +1581,11 @@ mod tests { ) .await; - assert_eq!(report.status, ScanStatus::Completed); - assert!(report.errors.is_empty()); + assert_eq!(report.status, ScanStatus::Interrupted); + assert_eq!( + report.errors.last().map(|error| error.kind), + Some(crate::ScanErrorKind::Cancelled) + ); assert!(report.completed_at.is_some()); } @@ -1784,11 +1617,11 @@ mod tests { .observation, ); - super::stop_after_dnssec( + crate::lifecycle::terminate( &mut report, + ScanStage::Dns, ScanSelection::all(), crate::ScanErrorKind::Cancelled, - ScanStatus::Interrupted, "scan interrupted during DNSSEC validation", ); @@ -1796,7 +1629,7 @@ mod tests { assert_eq!(dns.resolved_hosts.len(), 1); assert_eq!( dns.dnssec.as_ref().map(|dnssec| dnssec.status), - Some(crate::DnssecStatus::Indeterminate) + Some(crate::DnssecStatus::NotApplicable) ); assert_eq!( report @@ -1860,11 +1693,11 @@ mod tests { }); report.dns = Some(dns); - super::stop_after_axfr( + crate::lifecycle::terminate( &mut report, + ScanStage::Dns, ScanSelection::all(), crate::ScanErrorKind::Timeout, - ScanStatus::Partial, "global timeout expired during authoritative AXFR checking", ); @@ -1892,7 +1725,7 @@ mod tests { report .skipped_checks .iter() - .all(|skipped| skipped.check != "dnssec_validation") + .any(|skipped| skipped.check == "dnssec_validation") ); assert_eq!( report @@ -1904,7 +1737,7 @@ mod tests { ); assert_eq!(report.errors.len(), 1); assert_eq!(report.status, ScanStatus::Partial); - assert!(report.completed_at.is_some()); + assert!(report.completed_at.is_none()); } #[tokio::test] @@ -1948,11 +1781,11 @@ mod tests { }); report.dns = Some(dns); - super::stop_after_wildcard_dns( + crate::lifecycle::terminate( &mut report, + ScanStage::Dns, ScanSelection::all(), crate::ScanErrorKind::Cancelled, - ScanStatus::Interrupted, "scan interrupted during wildcard DNS detection", ); @@ -2044,11 +1877,11 @@ mod tests { ] { let mut report = crate::ScanReport::not_started(target.clone(), configuration.clone()); report.dns = Some(dns.clone()); - super::stop_after_dangling_cname( + crate::lifecycle::terminate( &mut report, + ScanStage::Dns, ScanSelection::all(), kind, - status, message, ); @@ -2177,5 +2010,7 @@ mod tests { .await; assert_eq!(report.status, ScanStatus::Failed); assert!(report.hosts.is_empty()); + assert_eq!(report.errors[0].stage, ScanStage::Preflight); + assert_eq!(report.errors[0].kind, crate::ScanErrorKind::Configuration); } } diff --git a/crates/surface-core/src/findings.rs b/crates/surface-core/src/findings.rs index f000aab..28f32ab 100644 --- a/crates/surface-core/src/findings.rs +++ b/crates/surface-core/src/findings.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::{ - DnsObservation, HostObservation, HttpObservation, PortState, ServiceKind, ServiceObservation, - TlsObservation, + DnsObservation, HostObservation, HstsState, HttpObservation, PortState, ServiceKind, + ServiceObservation, TlsObservation, }; // Rust guideline compliant 2026-02-21 @@ -398,41 +398,68 @@ fn exposure_findings( } fn ssh_legacy_finding(service: &ServiceObservation) -> Option { - if service - .protocol_details - .get("ssh_analysis_status") - .is_none_or(|status| status != "complete_inferred") - { + let Some(crate::SshPosture { + outcome: crate::SshPostureOutcome::Complete { selections }, + .. + }) = service.ssh.as_ref() + else { return None; - } + }; let legacy = [ - ("ssh_kex", "diffie-hellman-group14-sha1"), - ("ssh_kex", "diffie-hellman-group1-sha1"), - ("ssh_host_key_algorithm", "ssh-rsa"), - ("ssh_host_key_algorithm", "ssh-dss"), - ("ssh_cipher_c2s", "3des-cbc"), - ("ssh_cipher_c2s", "arcfour"), - ("ssh_cipher_c2s", "arcfour128"), - ("ssh_cipher_c2s", "arcfour256"), - ("ssh_cipher_s2c", "3des-cbc"), - ("ssh_cipher_s2c", "arcfour"), - ("ssh_cipher_s2c", "arcfour128"), - ("ssh_cipher_s2c", "arcfour256"), - ("ssh_mac_c2s", "hmac-sha1"), - ("ssh_mac_c2s", "hmac-md5"), - ("ssh_mac_c2s", "hmac-md5-96"), - ("ssh_mac_s2c", "hmac-sha1"), - ("ssh_mac_s2c", "hmac-md5"), - ("ssh_mac_s2c", "hmac-md5-96"), + ( + "ssh_kex", + selections.kex.as_str(), + "diffie-hellman-group14-sha1", + ), + ( + "ssh_kex", + selections.kex.as_str(), + "diffie-hellman-group1-sha1", + ), + ( + "ssh_host_key_algorithm", + selections.host_key.as_str(), + "ssh-rsa", + ), + ( + "ssh_host_key_algorithm", + selections.host_key.as_str(), + "ssh-dss", + ), + ("ssh_cipher_c2s", selections.cipher_c2s.as_str(), "3des-cbc"), + ("ssh_cipher_c2s", selections.cipher_c2s.as_str(), "arcfour"), + ( + "ssh_cipher_c2s", + selections.cipher_c2s.as_str(), + "arcfour128", + ), + ( + "ssh_cipher_c2s", + selections.cipher_c2s.as_str(), + "arcfour256", + ), + ("ssh_cipher_s2c", selections.cipher_s2c.as_str(), "3des-cbc"), + ("ssh_cipher_s2c", selections.cipher_s2c.as_str(), "arcfour"), + ( + "ssh_cipher_s2c", + selections.cipher_s2c.as_str(), + "arcfour128", + ), + ( + "ssh_cipher_s2c", + selections.cipher_s2c.as_str(), + "arcfour256", + ), + ("ssh_mac_c2s", selections.mac_c2s.as_str(), "hmac-sha1"), + ("ssh_mac_c2s", selections.mac_c2s.as_str(), "hmac-md5"), + ("ssh_mac_c2s", selections.mac_c2s.as_str(), "hmac-md5-96"), + ("ssh_mac_s2c", selections.mac_s2c.as_str(), "hmac-sha1"), + ("ssh_mac_s2c", selections.mac_s2c.as_str(), "hmac-md5"), + ("ssh_mac_s2c", selections.mac_s2c.as_str(), "hmac-md5-96"), ] .into_iter() - .filter(|(key, algorithm)| { - service - .protocol_details - .get(*key) - .is_some_and(|selected| selected == algorithm) - }) - .map(|(key, algorithm)| format!("{key}={algorithm}")) + .filter(|(_, selected, legacy)| selected == legacy) + .map(|(key, _, algorithm)| format!("{key}={algorithm}")) .collect::>(); (!legacy.is_empty()).then(|| { with_references( @@ -465,7 +492,7 @@ fn http_findings(target: &str, observations: &[HttpObservation], findings: &mut .filter(|observation| observation.status.is_some()) { let https = observation.url.starts_with("https://"); - let location = observation.final_url.as_deref().unwrap_or(&observation.url); + let location = observation.effective_url(); let redirects_to_https = observation .redirects .iter() @@ -484,11 +511,7 @@ fn http_findings(target: &str, observations: &[HttpObservation], findings: &mut FindingConfidence::High, )); } - if https - && !observation - .headers - .contains_key("strict-transport-security") - { + if observation.hsts_state() == HstsState::Missing { findings.push(finding( "HTTP-HSTS-MISSING", "HSTS header missing", @@ -735,14 +758,13 @@ fn finding( #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use super::{FindingConfidence, Severity, generate_findings}; use crate::{ AddressSource, AuthoritativeAxfrObservation, AxfrAttempt, AxfrOutcome, CnameHop, DanglingCnameObservation, DanglingCnameStatus, DetectionConfidence, DnsObservation, DnsRecord, DnssecObservation, DnssecRecordType, DnssecRrsetObservation, DnssecStatus, - MailObservation, ResolvedHost, ServiceKind, ServiceObservation, SpfObservation, + HttpObservation, MailObservation, PartialSshAlgorithmSelections, ResolvedHost, ServiceKind, + ServiceObservation, SpfObservation, SshAlgorithmSelections, SshPosture, SshPostureOutcome, TlsObservation, TransportProtocol, WildcardDnsObservation, WildcardDnsRecordType, WildcardDnsStatus, }; @@ -775,7 +797,7 @@ mod tests { } } - fn ssh_service(protocol_details: BTreeMap) -> ServiceObservation { + fn ssh_service(ssh: Option) -> ServiceObservation { ServiceObservation { transport: TransportProtocol::Tcp, address: "127.0.0.1:22" @@ -784,7 +806,38 @@ mod tests { service: ServiceKind::Ssh, confidence: DetectionConfidence::High, banner: Some("SSH-2.0-OpenSSH_7.2 CVE-2099-0001".to_owned()), - protocol_details, + protocol_details: std::collections::BTreeMap::new(), + ssh, + } + } + + fn ssh_selections(field: &str, algorithm: &str) -> SshAlgorithmSelections { + let mut selections = SshAlgorithmSelections { + kex: "curve25519-sha256".to_owned(), + host_key: "ssh-ed25519".to_owned(), + cipher_c2s: "aes256-ctr".to_owned(), + cipher_s2c: "aes256-ctr".to_owned(), + mac_c2s: "hmac-sha2-512".to_owned(), + mac_s2c: "hmac-sha2-512".to_owned(), + }; + match field { + "ssh_kex" => selections.kex = algorithm.to_owned(), + "ssh_host_key_algorithm" => selections.host_key = algorithm.to_owned(), + "ssh_cipher_c2s" => selections.cipher_c2s = algorithm.to_owned(), + "ssh_cipher_s2c" => selections.cipher_s2c = algorithm.to_owned(), + "ssh_mac_c2s" => selections.mac_c2s = algorithm.to_owned(), + "ssh_mac_s2c" => selections.mac_s2c = algorithm.to_owned(), + _ => panic!("unknown SSH selection field"), + } + selections + } + + fn complete_ssh(field: &str, algorithm: &str) -> SshPosture { + SshPosture { + identification: None, + outcome: SshPostureOutcome::Complete { + selections: ssh_selections(field, algorithm), + }, } } @@ -831,6 +884,46 @@ mod tests { assert!(!findings[0].evidence.is_empty()); } + #[test] + fn final_https_response_without_hsts_generates_finding() { + let observation = HttpObservation { + address: "127.0.0.1:80" + .parse() + .unwrap_or_else(|error| panic!("{error}")), + url: "http://example.com/".to_owned(), + final_url: Some("https://example.com/".to_owned()), + status: Some(200), + version: Some("HTTP/1.1".to_owned()), + latency_ms: Some(1), + redirects: Vec::new(), + headers: std::collections::BTreeMap::new(), + cookies: Vec::new(), + title: None, + body_bytes: 0, + body_truncated: false, + robots_txt: None, + security_txt: None, + sitemap_xml: None, + error: None, + }; + + let findings = generate_findings( + "example.com", + None, + &[], + &[], + &[observation], + &[], + time::OffsetDateTime::UNIX_EPOCH, + ); + + assert!( + findings + .iter() + .any(|finding| finding.id == "HTTP-HSTS-MISSING") + ); + } + #[test] #[expect( clippy::too_many_lines, @@ -1110,13 +1203,7 @@ mod tests { ("ssh_mac_s2c", "hmac-md5-96"), ]; for (field, algorithm) in legacy_cases { - let service = ssh_service(BTreeMap::from([ - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - (field.to_owned(), algorithm.to_owned()), - ])); + let service = ssh_service(Some(complete_ssh(field, algorithm))); let findings = generate_findings( "localhost", None, @@ -1162,13 +1249,7 @@ mod tests { ("ssh_mac_s2c", "hmac-md5-etm@openssh.com"), ]; for (field, algorithm) in near_matches { - let service = ssh_service(BTreeMap::from([ - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - (field.to_owned(), algorithm.to_owned()), - ])); + let service = ssh_service(Some(complete_ssh(field, algorithm))); let findings = generate_findings( "localhost", None, @@ -1186,32 +1267,21 @@ mod tests { } let negatives = [ - BTreeMap::from([ - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - ("ssh_kex".to_owned(), "curve25519-sha256".to_owned()), - ( - "ssh_server_kex_algorithms".to_owned(), - "diffie-hellman-group1-sha1".to_owned(), - ), - ]), - BTreeMap::from([ - ("ssh_analysis_status".to_owned(), "indeterminate".to_owned()), - ( - "ssh_skip_reason".to_owned(), - "no common required algorithm: ssh_kex".to_owned(), - ), - ( - "ssh_kex".to_owned(), - "diffie-hellman-group1-sha1".to_owned(), - ), - ]), - BTreeMap::from([("ssh_host_key_algorithm".to_owned(), "ssh-dss".to_owned())]), + Some(complete_ssh("ssh_kex", "curve25519-sha256")), + Some(SshPosture { + identification: None, + outcome: SshPostureOutcome::Partial { + selections: PartialSshAlgorithmSelections { + kex: Some("diffie-hellman-group1-sha1".to_owned()), + ..PartialSshAlgorithmSelections::default() + }, + reason: "no common required algorithm: host_key".to_owned(), + }, + }), + None, ]; - for details in negatives { - let service = ssh_service(details); + for posture in negatives { + let service = ssh_service(posture); let findings = generate_findings( "localhost", None, @@ -1373,6 +1443,7 @@ mod tests { confidence: DetectionConfidence::High, banner: None, protocol_details: std::collections::BTreeMap::new(), + ssh: None, }; let generic = TlsObservation { address, diff --git a/crates/surface-core/src/http.rs b/crates/surface-core/src/http.rs index 64dd387..d4b776e 100644 --- a/crates/surface-core/src/http.rs +++ b/crates/surface-core/src/http.rs @@ -99,6 +99,37 @@ pub struct HttpObservation { pub error: Option, } +/// Security state of the final observed HTTP response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HstsState { + /// The final response was HTTPS and included HSTS. + Present, + /// The final response was HTTPS but omitted HSTS. + Missing, + /// The final response was not HTTPS. + NotApplicable, +} + +impl HttpObservation { + /// Returns the final observed URL, falling back to the initial URL. + #[must_use] + pub fn effective_url(&self) -> &str { + self.final_url.as_deref().unwrap_or(&self.url) + } + + /// Interprets HSTS against the final observed response. + #[must_use] + pub fn hsts_state(&self) -> HstsState { + if !self.effective_url().starts_with("https://") { + HstsState::NotApplicable + } else if self.headers.contains_key("strict-transport-security") { + HstsState::Present + } else { + HstsState::Missing + } + } +} + /// Inspects discovered HTTP candidates with bounded concurrency. #[must_use] pub async fn analyze_http( @@ -480,6 +511,7 @@ mod tests { confidence: DetectionConfidence::High, banner: None, protocol_details: BTreeMap::new(), + ssh: None, }; let observations = analyze_http( &target, @@ -526,6 +558,7 @@ mod tests { confidence: DetectionConfidence::High, banner: None, protocol_details: BTreeMap::new(), + ssh: None, }; let observations = analyze_http( &normalize_target("localhost").unwrap_or_else(|error| panic!("{error}")), diff --git a/crates/surface-core/src/intelligence.rs b/crates/surface-core/src/intelligence.rs index cc417ca..98df3ca 100644 --- a/crates/surface-core/src/intelligence.rs +++ b/crates/surface-core/src/intelligence.rs @@ -13,9 +13,13 @@ use tokio::time::Instant; use tokio_util::sync::CancellationToken; use crate::{ - DnsObservation, ScanReport, ScanStatus, SkippedCheck, analyze_dns, lookup_txt, - normalize_target, - passive_http::{FetchError, PinnedClients, get_bounded, is_public_destination}, + DnsObservation, ScanReport, ScanStatus, SkippedCheck, analyze_dns, lookup_txt, normalize_target, +}; + +mod http; + +use self::http::{ + FetchError, PinnedClients, get_bounded, get_bounded_with_bearer, is_public_destination, }; const MAX_SUBDOMAINS: usize = 32; @@ -390,6 +394,8 @@ pub async fn analyze_intelligence( selectors: &[String], bundle: Option<&IntelligenceBundle>, timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, ) -> Result { let primary = report .target @@ -406,6 +412,9 @@ pub async fn analyze_intelligence( }; let mut names = BTreeSet::new(); for input in subdomains { + if stop_supplied_intelligence(&mut observation, deadline, cancellation) { + break; + } let target = normalize_target(input).map_err(|_| "invalid supplied subdomain".to_owned())?; let name = target @@ -417,14 +426,22 @@ pub async fn analyze_intelligence( "supplied subdomain is outside the primary domain or duplicated".to_owned(), ); } - match analyze_dns( - &normalize_target(&name).map_err(|_| "invalid supplied subdomain".to_owned())?, - timeout, - false, - false, + let dns_target = + normalize_target(&name).map_err(|_| "invalid supplied subdomain".to_owned())?; + let result = match await_supplied( + analyze_dns(&dns_target, timeout, false, false), + deadline, + cancellation, ) .await { + Ok(result) => result, + Err(message) => { + record_intelligence_stop(&mut observation, message); + break; + } + }; + match result { Ok(dns) => observation.subdomains.push(SubdomainObservation { name, dns: Some(dns), @@ -441,13 +458,24 @@ pub async fn analyze_intelligence( } } for selector in selectors { + if stop_supplied_intelligence(&mut observation, deadline, cancellation) { + break; + } validate_selector(selector)?; - let records = lookup_txt(&format!("{selector}._domainkey.{primary}"), timeout) - .await - .unwrap_or_default(); - observation.dkim.push(parse_dkim(selector, &records)); + let query_name = format!("{selector}._domainkey.{primary}"); + let result = + match await_supplied(lookup_txt(&query_name, timeout), deadline, cancellation).await { + Ok(result) => result, + Err(message) => { + record_intelligence_stop(&mut observation, message); + break; + } + }; + record_dkim_result(&mut observation, selector, result); } - if let Some(bundle) = bundle { + if observation.complete + && let Some(bundle) = bundle + { observation.networks = correlate_networks(report, bundle); observation.cve_candidates = correlate_cves(report, bundle); } @@ -460,6 +488,59 @@ pub async fn analyze_intelligence( Ok(observation) } +async fn await_supplied( + future: impl Future>, + deadline: Instant, + cancellation: &CancellationToken, +) -> Result, &'static str> { + tokio::select! { + biased; + () = cancellation.cancelled() => Err("intelligence collection was cancelled"), + result = tokio::time::timeout_at(deadline, future) => result + .map_err(|_| "intelligence collection deadline elapsed"), + } +} + +fn stop_supplied_intelligence( + observation: &mut IntelligenceObservation, + deadline: Instant, + cancellation: &CancellationToken, +) -> bool { + if cancellation.is_cancelled() { + record_intelligence_stop(observation, "intelligence collection was cancelled"); + true + } else if Instant::now() >= deadline { + record_intelligence_stop(observation, "intelligence collection deadline elapsed"); + true + } else { + false + } +} + +fn record_intelligence_stop(observation: &mut IntelligenceObservation, reason: &str) { + observation.complete = false; + if !observation.errors.iter().any(|error| error == reason) { + observation.errors.push(reason.to_owned()); + } +} + +fn record_dkim_result( + observation: &mut IntelligenceObservation, + selector: &str, + result: Result, String>, +) { + match result { + Ok(records) => observation.dkim.push(parse_dkim(selector, &records)), + Err(error) => { + observation.complete = false; + observation + .errors + .push(format!("DKIM selector {selector}: {error}")); + observation.dkim.push(parse_dkim(selector, &[])); + } + } +} + #[derive(Debug, Deserialize)] struct RdapBootstrap { services: Vec<(Vec, Vec)>, @@ -1408,48 +1489,90 @@ pub async fn analyze_certificate_transparency( report: &ScanReport, api_key: Option<&str>, request_timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, ) -> Result { - analyze_certificate_transparency_at(report, api_key, request_timeout, CERTSPOTTER_ENDPOINT) - .await + let mut clients = PinnedClients::new(request_timeout); + analyze_certificate_transparency_at( + report, + api_key, + request_timeout, + deadline, + cancellation, + CERTSPOTTER_ENDPOINT, + &mut clients, + ) + .await +} + +#[derive(Clone, Copy)] +struct RequestBounds<'a> { + timeout: Duration, + deadline: Instant, + cancellation: &'a CancellationToken, } async fn analyze_certificate_transparency_at( report: &ScanReport, api_key: Option<&str>, request_timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, endpoint: &str, + clients: &mut PinnedClients, ) -> Result { let primary = report.target.hostname.as_deref().ok_or_else(|| { "certificate transparency discovery requires a hostname target".to_owned() })?; - let client = Client::builder() - .timeout(request_timeout) - .redirect(reqwest::redirect::Policy::none()) - .user_agent(concat!("surface/", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(|_| "could not initialize certificate transparency client".to_owned())?; let mut issuances = Vec::new(); let mut after = None; let mut complete = false; let mut pages_fetched = 0; + let mut errors = Vec::new(); + let bounds = RequestBounds { + timeout: request_timeout, + deadline, + cancellation, + }; for _ in 0..MAX_CT_PAGES { - let page = - fetch_certspotter_page(&client, endpoint, primary, api_key, after.as_deref()).await?; - pages_fetched += 1; + let page = match fetch_certspotter_page( + clients, + endpoint, + primary, + api_key, + after.as_deref(), + bounds, + ) + .await + { + Ok(page) => page, + Err(error) if !issuances.is_empty() => { + errors.push(error); + break; + } + Err(error) => return Err(error), + }; if page.is_empty() { + pages_fetched += 1; complete = true; break; } - after = page.last().map(|issuance| issuance.id.clone()); - if after.as_deref().is_none_or(str::is_empty) { - return Err("certificate transparency response lacked a pagination ID".to_owned()); + let next_after = page.last().map(|issuance| issuance.id.clone()); + if next_after.as_deref().is_none_or(str::is_empty) { + let error = "certificate transparency response lacked a pagination ID".to_owned(); + if issuances.is_empty() { + return Err(error); + } + errors.push(error); + break; } + pages_fetched += 1; + after = next_after; issuances.extend(page); } let issuance_count = issuances.len(); let (candidates, candidates_truncated) = ct_candidates(primary, &issuances); - let mut errors = Vec::new(); - if !complete { + if !complete && errors.is_empty() { errors.push(format!( "certificate transparency pagination stopped after {MAX_CT_PAGES} pages" )); @@ -1471,11 +1594,12 @@ async fn analyze_certificate_transparency_at( } async fn fetch_certspotter_page( - client: &Client, + clients: &mut PinnedClients, endpoint: &str, domain: &str, api_key: Option<&str>, after: Option<&str>, + bounds: RequestBounds<'_>, ) -> Result, String> { let mut url = Url::parse(endpoint).map_err(|_| "invalid CertSpotter endpoint".to_owned())?; { @@ -1489,33 +1613,42 @@ async fn fetch_certspotter_page( query.append_pair("after", after); } } - let mut request = client.get(url); - if let Some(api_key) = api_key.filter(|value| !value.trim().is_empty()) { - request = request.bearer_auth(api_key); - } - let response = request - .send() + let client = clients + .client_for(&url, bounds.deadline, bounds.cancellation) .await - .map_err(|_| "certificate transparency request failed".to_owned())?; - if !response.status().is_success() { - return Err(format!( - "certificate transparency service returned HTTP {}", - response.status().as_u16() - )); - } - let mut body = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| "certificate transparency response failed".to_owned())?; - if body.len().saturating_add(chunk.len()) > MAX_CT_RESPONSE_BYTES { - return Err("certificate transparency response exceeded 1 MiB".to_owned()); - } - body.extend_from_slice(&chunk); - } + .map_err(certificate_transparency_fetch_error)?; + let body = get_bounded_with_bearer( + &client, + url, + MAX_CT_RESPONSE_BYTES, + bounds.timeout, + bounds.deadline, + bounds.cancellation, + api_key, + ) + .await + .map_err(certificate_transparency_fetch_error)?; serde_json::from_slice(&body) .map_err(|_| "certificate transparency response was invalid".to_owned()) } +fn certificate_transparency_fetch_error(error: FetchError) -> String { + match error { + FetchError::Cancelled => "certificate transparency request was cancelled".to_owned(), + FetchError::Deadline => "certificate transparency deadline elapsed".to_owned(), + FetchError::Timeout => "certificate transparency request timed out".to_owned(), + FetchError::Resolution => "certificate transparency host resolution failed".to_owned(), + FetchError::Destination => { + "certificate transparency resolved to a non-public destination".to_owned() + } + FetchError::Request => "certificate transparency request failed".to_owned(), + FetchError::Http(status) => { + format!("certificate transparency service returned HTTP {status}") + } + FetchError::TooLarge => "certificate transparency response exceeded 1 MiB".to_owned(), + } +} + fn ct_candidates( primary: &str, issuances: &[CertSpotterIssuance], @@ -1565,15 +1698,30 @@ pub async fn analyze_related_domains( report: &ScanReport, api_key: &str, request_timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, ) -> Result { - analyze_related_domains_at(report, api_key, request_timeout, REVERSE_NS_ENDPOINT).await + let mut clients = PinnedClients::new(request_timeout); + analyze_related_domains_at( + report, + api_key, + request_timeout, + deadline, + cancellation, + REVERSE_NS_ENDPOINT, + &mut clients, + ) + .await } async fn analyze_related_domains_at( report: &ScanReport, api_key: &str, request_timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, endpoint: &str, + clients: &mut PinnedClients, ) -> Result { if api_key.trim().is_empty() { return Err("SURFACE_WHOISXML_API_KEY is empty".to_owned()); @@ -1601,14 +1749,15 @@ async fn analyze_related_domains_at( ); } - let client = Client::builder() - .timeout(request_timeout) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|_| "could not initialize reverse-NS client".to_owned())?; + let left_url = reverse_ns_url(endpoint, api_key, &nameservers[0])?; + let right_url = reverse_ns_url(endpoint, api_key, &nameservers[1])?; + let client = clients + .client_for(&left_url, deadline, cancellation) + .await + .map_err(reverse_ns_fetch_error)?; let (left, right) = tokio::join!( - fetch_reverse_ns(&client, endpoint, api_key, &nameservers[0]), - fetch_reverse_ns(&client, endpoint, api_key, &nameservers[1]) + fetch_reverse_ns(&client, left_url, request_timeout, deadline, cancellation), + fetch_reverse_ns(&client, right_url, request_timeout, deadline, cancellation) ); let mut observation = RelatedDomainsObservation { nameservers: nameservers.clone(), @@ -1640,34 +1789,32 @@ async fn analyze_related_domains_at( Ok(observation) } -async fn fetch_reverse_ns( - client: &Client, - endpoint: &str, - api_key: &str, - nameserver: &str, -) -> Result<(BTreeSet, bool), String> { +fn reverse_ns_url(endpoint: &str, api_key: &str, nameserver: &str) -> Result { let mut url = Url::parse(endpoint).map_err(|_| "invalid reverse-NS endpoint".to_owned())?; url.query_pairs_mut() .append_pair("apiKey", api_key) .append_pair("ns", nameserver) .append_pair("outputFormat", "JSON"); - let response = client - .get(url) - .send() - .await - .map_err(|_| "reverse-NS request failed".to_owned())?; - if !response.status().is_success() { - return Err("reverse-NS service returned an error".to_owned()); - } - let mut body = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| "reverse-NS response failed".to_owned())?; - if body.len().saturating_add(chunk.len()) > MAX_REVERSE_NS_RESPONSE_BYTES { - return Err("reverse-NS response exceeded 1 MiB".to_owned()); - } - body.extend_from_slice(&chunk); - } + Ok(url) +} + +async fn fetch_reverse_ns( + client: &Client, + url: Url, + request_timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, +) -> Result<(BTreeSet, bool), String> { + let body = get_bounded( + client, + url, + MAX_REVERSE_NS_RESPONSE_BYTES, + request_timeout, + deadline, + cancellation, + ) + .await + .map_err(reverse_ns_fetch_error)?; let response: ReverseNsResponse = serde_json::from_slice(&body).map_err(|_| "reverse-NS response was invalid".to_owned())?; let truncated = response.result.len() >= MAX_RELATED_DOMAINS; @@ -1684,6 +1831,19 @@ async fn fetch_reverse_ns( Ok((names, truncated)) } +fn reverse_ns_fetch_error(error: FetchError) -> String { + match error { + FetchError::Cancelled => "reverse-NS request was cancelled".to_owned(), + FetchError::Deadline => "reverse-NS deadline elapsed".to_owned(), + FetchError::Timeout => "reverse-NS request timed out".to_owned(), + FetchError::Resolution => "reverse-NS host resolution failed".to_owned(), + FetchError::Destination => "reverse-NS resolved to a non-public destination".to_owned(), + FetchError::Request => "reverse-NS request failed".to_owned(), + FetchError::Http(status) => format!("reverse-NS service returned HTTP {status}"), + FetchError::TooLarge => "reverse-NS response exceeded 1 MiB".to_owned(), + } +} + fn related_candidates( primary: &str, nameservers: &[String], @@ -1928,12 +2088,14 @@ mod tests { time::Duration, }; - use reqwest::Client; + use reqwest::Url; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener, task::JoinHandle, + time::Instant, }; + use tokio_util::sync::CancellationToken; use crate::{ AddressSource, DetectionConfidence, DnsObservation, HostObservation, MailObservation, @@ -1944,15 +2106,15 @@ mod tests { use super::{ AllowedRdapBase, BgpConfig, BgpStop, CertSpotterIssuance, MAX_BGP_RESPONSE_BYTES, MAX_BOOTSTRAP_BYTES, MAX_CT_RESPONSE_BYTES, MAX_RDAP_RESPONSE_BYTES, OFFICIAL_RDAP_BASES, - PinnedClients, RdapConfig, RdapStop, analyze_bgp_routes_with, + PinnedClients, RdapConfig, RdapStop, RequestBounds, analyze_bgp_routes_with, analyze_certificate_transparency_at, analyze_intelligence, analyze_network_registrations_with, approved_rdap_base, canonical_external_address, coherent_range, construct_bgp_url, construct_rdap_url, correlate_cves, correlate_networks, ct_candidates, eligible_bgp_addresses, eligible_rdap_addresses, fetch_certspotter_page, finish_bgp_routes, finish_network_registrations, identify_nameserver_provider, longest_bootstrap_endpoint, parse_bgp_response, parse_bootstrap, parse_bundle, - parse_rdap_response, production_bgp_request_configuration, related_candidates, - validate_json_depth, + parse_rdap_response, production_bgp_request_configuration, record_dkim_result, + related_candidates, validate_json_depth, }; async fn mock_http_response( @@ -1999,6 +2161,27 @@ mod tests { (format!("http://{address}/v1/issuances"), task) } + fn ct_fixture_clients(endpoint: &str) -> PinnedClients { + let url = Url::parse(endpoint).unwrap_or_else(|error| panic!("{error}")); + let host = url.host_str().unwrap_or_else(|| panic!("missing host")); + let address = SocketAddr::new( + host.parse().unwrap_or_else(|error| panic!("{error}")), + url.port_or_known_default().unwrap_or(80), + ); + PinnedClients::fixture( + Duration::from_secs(1), + BTreeMap::from([(host.to_owned(), vec![address])]), + ) + } + + fn request_bounds(cancellation: &CancellationToken) -> RequestBounds<'_> { + RequestBounds { + timeout: Duration::from_secs(1), + deadline: Instant::now() + Duration::from_secs(2), + cancellation, + } + } + #[derive(Clone)] struct MockRoute { status: &'static str, @@ -2271,6 +2454,7 @@ mod tests { confidence: DetectionConfidence::High, banner: Some("SSH-2.0-OpenSSH_9.3p1".to_owned()), protocol_details: BTreeMap::default(), + ssh: None, }); report } @@ -2429,7 +2613,7 @@ mod tests { assert_eq!(config.path, "/data/network-info/data.json"); assert_eq!( clients.policy(), - crate::passive_http::ClientPolicy::PublicHttpsPinnedDnsNoProxyNoRedirect + super::http::ClientPolicy::PublicHttpsPinnedDnsNoProxyNoRedirect ); let ipv4 = construct_bgp_url( @@ -3563,6 +3747,27 @@ mod tests { assert_eq!(correlate_cves(&report, &bundle).len(), 1); } + #[test] + fn dkim_resolver_error_marks_supplied_evidence_incomplete() { + let mut observation = super::IntelligenceObservation { + complete: true, + ..super::IntelligenceObservation::default() + }; + + record_dkim_result( + &mut observation, + "selector", + Err("DNS lookup failed".to_owned()), + ); + + assert!(!observation.complete); + assert_eq!(observation.dkim.len(), 1); + assert_eq!( + observation.errors, + ["DKIM selector selector: DNS lookup failed"] + ); + } + #[tokio::test] async fn certspotter_request_uses_bearer_and_pagination_cursor() { let (endpoint, request) = mock_http_response( @@ -3571,17 +3776,16 @@ mod tests { br#"[{"id":"next","dns_names":["api.example.com"]}]"#.to_vec(), ) .await; - let client = Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap_or_else(|error| panic!("{error}")); + let mut clients = ct_fixture_clients(&endpoint); + let cancellation = CancellationToken::new(); let page = fetch_certspotter_page( - &client, + &mut clients, &endpoint, "example.com", Some("secret"), Some("cursor-1"), + request_bounds(&cancellation), ) .await .unwrap_or_else(|error| panic!("{error}")); @@ -3611,11 +3815,16 @@ mod tests { Vec::new(), ) .await; + let mut clients = ct_fixture_clients(&redirect); + let cancellation = CancellationToken::new(); let error = analyze_certificate_transparency_at( &report(), Some("secret"), std::time::Duration::from_secs(1), + Instant::now() + Duration::from_secs(2), + &cancellation, &redirect, + &mut clients, ) .await .expect_err("redirect must not be followed"); @@ -3629,34 +3838,155 @@ mod tests { vec![b' '; MAX_CT_RESPONSE_BYTES + 1], ) .await; - let client = Client::new(); - let error = fetch_certspotter_page(&client, &oversized, "example.com", None, None) - .await - .expect_err("oversized response must fail"); + let mut clients = ct_fixture_clients(&oversized); + let error = fetch_certspotter_page( + &mut clients, + &oversized, + "example.com", + None, + None, + request_bounds(&cancellation), + ) + .await + .expect_err("oversized response must fail"); assert!(error.contains("exceeded 1 MiB")); let _ = oversized_task.await; } #[tokio::test] async fn certspotter_reports_http_and_malformed_response_errors() { - let client = Client::new(); + let cancellation = CancellationToken::new(); let (rate_limited, rate_task) = mock_http_response("429 Too Many Requests", "", Vec::new()).await; - let error = fetch_certspotter_page(&client, &rate_limited, "example.com", None, None) - .await - .expect_err("HTTP error must fail"); + let mut clients = ct_fixture_clients(&rate_limited); + let error = fetch_certspotter_page( + &mut clients, + &rate_limited, + "example.com", + None, + None, + request_bounds(&cancellation), + ) + .await + .expect_err("HTTP error must fail"); assert!(error.contains("HTTP 429")); let _ = rate_task.await; let (malformed, malformed_task) = mock_http_response("200 OK", "", b"not-json".to_vec()).await; - let error = fetch_certspotter_page(&client, &malformed, "example.com", None, None) - .await - .expect_err("malformed JSON must fail"); + let mut clients = ct_fixture_clients(&malformed); + let error = fetch_certspotter_page( + &mut clients, + &malformed, + "example.com", + None, + None, + request_bounds(&cancellation), + ) + .await + .expect_err("malformed JSON must fail"); assert!(error.contains("response was invalid")); let _ = malformed_task.await; } + #[tokio::test] + async fn certspotter_retains_earlier_pages_when_a_later_page_fails() { + let (address, server) = mock_http_routes(2, |_| { + BTreeMap::from([ + ( + "/v1/issuances?domain=example.com&include_subdomains=true&match_wildcards=true&expand=dns_names".to_owned(), + MockRoute { + status: "200 OK", + headers: "Content-Type: application/json\r\n".to_owned(), + body: br#"[{"id":"next","dns_names":["api.example.com"]}]"#.to_vec(), + delay: Duration::ZERO, + }, + ), + ( + "/v1/issuances?domain=example.com&include_subdomains=true&match_wildcards=true&expand=dns_names&after=next".to_owned(), + MockRoute { + status: "503 Service Unavailable", + headers: String::new(), + body: Vec::new(), + delay: Duration::ZERO, + }, + ), + ]) + }) + .await; + let endpoint = format!("http://{address}/v1/issuances"); + let mut clients = ct_fixture_clients(&endpoint); + let cancellation = CancellationToken::new(); + + let observation = analyze_certificate_transparency_at( + &report(), + None, + Duration::from_secs(1), + Instant::now() + Duration::from_secs(2), + &cancellation, + &endpoint, + &mut clients, + ) + .await + .expect("earlier CT evidence must survive a later page error"); + let requests = server.finish().await; + + assert_eq!(requests.len(), 2); + assert_eq!(observation.candidates[0].name, "api.example.com"); + assert_eq!(observation.pages_fetched, 1); + assert!(!observation.complete); + assert!(observation.errors[0].contains("HTTP 503")); + } + + #[tokio::test] + async fn certspotter_retains_earlier_pages_when_a_later_page_lacks_an_id() { + let (address, server) = mock_http_routes(2, |_| { + BTreeMap::from([ + ( + "/v1/issuances?domain=example.com&include_subdomains=true&match_wildcards=true&expand=dns_names".to_owned(), + MockRoute { + status: "200 OK", + headers: "Content-Type: application/json\r\n".to_owned(), + body: br#"[{"id":"next","dns_names":["api.example.com"]}]"#.to_vec(), + delay: Duration::ZERO, + }, + ), + ( + "/v1/issuances?domain=example.com&include_subdomains=true&match_wildcards=true&expand=dns_names&after=next".to_owned(), + MockRoute { + status: "200 OK", + headers: "Content-Type: application/json\r\n".to_owned(), + body: br#"[{"id":"","dns_names":["www.example.com"]}]"#.to_vec(), + delay: Duration::ZERO, + }, + ), + ]) + }) + .await; + let endpoint = format!("http://{address}/v1/issuances"); + let mut clients = ct_fixture_clients(&endpoint); + let cancellation = CancellationToken::new(); + + let observation = analyze_certificate_transparency_at( + &report(), + None, + Duration::from_secs(1), + Instant::now() + Duration::from_secs(2), + &cancellation, + &endpoint, + &mut clients, + ) + .await + .expect("earlier CT evidence must survive a malformed later page"); + let requests = server.finish().await; + + assert_eq!(requests.len(), 2); + assert_eq!(observation.candidates[0].name, "api.example.com"); + assert_eq!(observation.pages_fetched, 1); + assert!(!observation.complete); + assert!(observation.errors[0].contains("pagination ID")); + } + #[test] fn certificate_transparency_candidates_are_scoped_and_deduplicated() { let issuances = vec![ @@ -3716,6 +4046,31 @@ mod tests { ); } + #[tokio::test] + async fn supplied_intelligence_returns_partial_evidence_when_cancelled() { + let cancellation = CancellationToken::new(); + cancellation.cancel(); + + let observation = analyze_intelligence( + &report(), + &["api.example.com".to_owned()], + &[], + None, + Duration::from_secs(1), + Instant::now() + Duration::from_secs(2), + &cancellation, + ) + .await + .expect("cancellation must return retained intelligence evidence"); + + assert!(!observation.complete); + assert!(observation.subdomains.is_empty()); + assert_eq!( + observation.errors, + ["intelligence collection was cancelled"] + ); + } + #[tokio::test] async fn supplied_subdomain_scope_fails_before_dns() { let error = analyze_intelligence( @@ -3724,6 +4079,8 @@ mod tests { &[], None, std::time::Duration::from_millis(10), + Instant::now() + Duration::from_secs(1), + &CancellationToken::new(), ) .await .expect_err("sibling must fail"); diff --git a/crates/surface-core/src/passive_http.rs b/crates/surface-core/src/intelligence/http.rs similarity index 96% rename from crates/surface-core/src/passive_http.rs rename to crates/surface-core/src/intelligence/http.rs index 49f307d..c99ee8a 100644 --- a/crates/surface-core/src/passive_http.rs +++ b/crates/surface-core/src/intelligence/http.rs @@ -1,4 +1,4 @@ -//! Bounded, destination-pinned HTTP reads for passive intelligence. +//! Bounded, destination-pinned HTTP reads for intelligence sources. use std::{ collections::{BTreeMap, BTreeSet}, @@ -287,9 +287,34 @@ pub(crate) async fn get_bounded( request_timeout: Duration, deadline: Instant, cancellation: &CancellationToken, +) -> Result, FetchError> { + get_bounded_with_bearer( + client, + url, + maximum_bytes, + request_timeout, + deadline, + cancellation, + None, + ) + .await +} + +pub(crate) async fn get_bounded_with_bearer( + client: &Client, + url: Url, + maximum_bytes: usize, + request_timeout: Duration, + deadline: Instant, + cancellation: &CancellationToken, + bearer: Option<&str>, ) -> Result, FetchError> { let fetch = async { - let response = client.get(url).send().await.map_err(|error| { + let mut request = client.get(url); + if let Some(bearer) = bearer.filter(|value| !value.trim().is_empty()) { + request = request.bearer_auth(bearer); + } + let response = request.send().await.map_err(|error| { if error.is_timeout() { FetchError::Timeout } else { diff --git a/crates/surface-core/src/lib.rs b/crates/surface-core/src/lib.rs index 10be798..73b0505 100644 --- a/crates/surface-core/src/lib.rs +++ b/crates/surface-core/src/lib.rs @@ -6,7 +6,7 @@ mod exposure; mod findings; mod http; mod intelligence; -mod passive_http; +mod lifecycle; mod ports; mod scanner; mod service; @@ -28,7 +28,8 @@ pub use dns::{ }; #[doc(inline)] pub use engine::{ - run_scan, run_scan_selected_until_with_progress, run_scan_selected_with_progress, + finalize_report, run_scan, run_scan_selected_until_with_progress, + run_scan_selected_until_with_progress_deferred, run_scan_selected_with_progress, run_scan_with_progress, }; #[doc(inline)] @@ -40,7 +41,7 @@ pub use findings::{ Evidence, Finding, FindingCategory, FindingConfidence, Severity, generate_findings, }; #[doc(inline)] -pub use http::{CookieObservation, HttpObservation, RedirectObservation, analyze_http}; +pub use http::{CookieObservation, HstsState, HttpObservation, RedirectObservation, analyze_http}; #[doc(inline)] pub use intelligence::{ BgpRouteObservation, CertificateTransparencyCandidate, CertificateTransparencyObservation, @@ -58,7 +59,9 @@ pub use scanner::{ }; #[doc(inline)] pub use service::{ - DetectionConfidence, ServiceKind, ServiceObservation, detect_services, sanitize_banner, + DetectionConfidence, PartialSshAlgorithmSelections, ServiceKind, ServiceObservation, + SshAlgorithmSelections, SshIdentification, SshPosture, SshPostureOutcome, detect_services, + sanitize_banner, }; #[doc(inline)] pub use target::{NormalizedTarget, TargetError, normalize_target}; @@ -87,6 +90,8 @@ pub enum ScanStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ScanStage { + /// Validation performed before scan work starts. + Preflight, /// Passive DNS collection. Dns, /// TCP connect and UDP response scanning. @@ -119,6 +124,8 @@ pub enum ScanProgress { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ScanErrorKind { + /// Invalid or conflicting scan configuration. + Configuration, /// DNS query failure. Dns, /// Operation deadline exceeded. @@ -303,7 +310,7 @@ impl ScanReport { #[must_use] pub fn not_started(target: NormalizedTarget, configuration: ScanConfiguration) -> Self { Self { - schema_version: "0.3.0".to_owned(), + schema_version: "0.4.0".to_owned(), scanner_version: env!("CARGO_PKG_VERSION").to_owned(), scan_id: Uuid::new_v4(), started_at: OffsetDateTime::now_utc(), diff --git a/crates/surface-core/src/lifecycle.rs b/crates/surface-core/src/lifecycle.rs new file mode 100644 index 0000000..77d4c1e --- /dev/null +++ b/crates/surface-core/src/lifecycle.rs @@ -0,0 +1,290 @@ +//! Terminal scan-report lifecycle operations. + +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +use crate::{ + DnssecStatus, ScanError, ScanErrorKind, ScanProgress, ScanReport, ScanSelection, ScanStage, + ScanStatus, SkippedCheck, WildcardDnsStatus, calculate_exposure, generate_findings, +}; + +pub(crate) fn add_skipped_check(report: &mut ScanReport, check: &str, reason: &str) { + if report + .skipped_checks + .iter() + .all(|skipped| skipped.check != check) + { + report.skipped_checks.push(SkippedCheck { + check: check.to_owned(), + reason: reason.to_owned(), + }); + } +} + +pub(crate) fn record_selection_skips(report: &mut ScanReport, selection: ScanSelection) { + for (check, selected) in [ + ("ports", selection.ports), + ("services", selection.services), + ("http", selection.http), + ("tls", selection.tls), + ] { + if !selected { + add_skipped_check(report, check, "excluded by --only"); + } + } +} + +pub(crate) fn terminate( + report: &mut ScanReport, + stage: ScanStage, + selection: ScanSelection, + kind: ScanErrorKind, + message: &str, +) { + if report.status == ScanStatus::Interrupted && kind == ScanErrorKind::Cancelled { + return; + } + if report.status == ScanStatus::Failed && kind == ScanErrorKind::Timeout { + return; + } + report.status = match kind { + ScanErrorKind::Configuration => ScanStatus::Failed, + ScanErrorKind::Cancelled => ScanStatus::Interrupted, + ScanErrorKind::Timeout if !has_observations(report) => ScanStatus::Failed, + _ => ScanStatus::Partial, + }; + message.clone_into(&mut report.message); + report.errors.push(ScanError::new( + stage, + report.target.hostname.clone(), + kind, + message, + report.status != ScanStatus::Failed, + )); + record_unfinished_checks(report, stage, selection, message); +} + +pub(crate) fn settle(report: &mut ScanReport) { + if matches!( + report.status, + ScanStatus::Interrupted | ScanStatus::Failed | ScanStatus::Partial + ) { + return; + } + let dnssec_indeterminate = report + .dns + .as_ref() + .and_then(|dns| dns.dnssec.as_ref()) + .is_some_and(|dnssec| dnssec.status == DnssecStatus::Indeterminate); + let wildcard_indeterminate = report + .dns + .as_ref() + .and_then(|dns| dns.wildcard_dns.as_ref()) + .is_some_and(|wildcard| wildcard.status == WildcardDnsStatus::Indeterminate); + let dangling_indeterminate = report.dns.as_ref().is_some_and(|dns| { + dns.dangling_cnames + .iter() + .any(|observation| observation.status == crate::DanglingCnameStatus::Indeterminate) + }); + report.status = if report.errors.is_empty() + && !dnssec_indeterminate + && !wildcard_indeterminate + && !dangling_indeterminate + { + ScanStatus::Completed + } else { + ScanStatus::Partial + }; + "Surface analyzed externally observable services and security-related configuration." + .clone_into(&mut report.message); +} + +pub(crate) fn finalize( + report: &mut ScanReport, + cancellation: &CancellationToken, + deadline: Instant, + progress: &impl Fn(ScanProgress), +) { + if report.completed_at.is_some() { + return; + } + if report + .errors + .iter() + .any(|error| error.stage == ScanStage::Preflight) + { + report.completed_at = Some(time::OffsetDateTime::now_utc()); + return; + } + if cancellation.is_cancelled() { + terminate( + report, + ScanStage::Findings, + ScanSelection::all(), + ScanErrorKind::Cancelled, + "scan interrupted during findings finalization", + ); + } else if Instant::now() >= deadline { + terminate( + report, + ScanStage::Findings, + ScanSelection::all(), + ScanErrorKind::Timeout, + "global timeout expired during findings finalization", + ); + } else { + settle(report); + } + progress(ScanProgress::Started(ScanStage::Findings)); + report.findings = generate_findings( + report + .target + .hostname + .as_deref() + .unwrap_or(&report.target.original), + report.dns.as_ref(), + &report.hosts, + &report.services, + &report.http, + &report.tls, + time::OffsetDateTime::now_utc(), + ); + report.exposure_score = Some(calculate_exposure(report)); + progress(ScanProgress::Completed { + stage: ScanStage::Findings, + observations: report.findings.len(), + }); + if cancellation.is_cancelled() { + terminate( + report, + ScanStage::Findings, + ScanSelection::all(), + ScanErrorKind::Cancelled, + "scan interrupted after findings finalization", + ); + } else if Instant::now() >= deadline { + terminate( + report, + ScanStage::Findings, + ScanSelection::all(), + ScanErrorKind::Timeout, + "global timeout expired after findings finalization", + ); + } + report.completed_at = Some(time::OffsetDateTime::now_utc()); +} + +fn has_observations(report: &ScanReport) -> bool { + report.dns.is_some() + || !report.hosts.is_empty() + || !report.services.is_empty() + || !report.http.is_empty() + || !report.tls.is_empty() +} + +fn record_unfinished_checks( + report: &mut ScanReport, + failed_stage: ScanStage, + selection: ScanSelection, + reason: &str, +) { + let failed_rank = stage_rank(failed_stage); + for (check, stage, selected) in [ + ("dns", ScanStage::Dns, true), + ("dnssec_validation", ScanStage::Dns, true), + ("authoritative_axfr", ScanStage::Dns, true), + ("wildcard_dns", ScanStage::Dns, true), + ("dangling_cname", ScanStage::Dns, true), + ("ports", ScanStage::Ports, selection.ports), + ("services", ScanStage::Services, selection.services), + ("http", ScanStage::Http, selection.http), + ("tls", ScanStage::Tls, selection.tls), + ] { + if selected && stage_rank(stage) >= failed_rank { + add_skipped_check(report, check, reason); + } + } +} + +const fn stage_rank(stage: ScanStage) -> u8 { + match stage { + ScanStage::Preflight => 0, + ScanStage::Dns => 1, + ScanStage::Ports => 2, + ScanStage::Services => 3, + ScanStage::Http => 4, + ScanStage::Tls => 5, + ScanStage::Findings => 6, + } +} + +#[cfg(test)] +mod tests { + use super::{add_skipped_check, record_selection_skips, terminate}; + use crate::{ + ScanConfiguration, ScanErrorKind, ScanReport, ScanSelection, ScanStage, ScanStatus, + normalize_target, + }; + + fn report() -> ScanReport { + ScanReport::not_started( + normalize_target("127.0.0.1").unwrap_or_else(|error| panic!("{error}")), + ScanConfiguration { + ports: vec![9], + udp_ports: Vec::new(), + concurrency: 1, + connect_timeout_ms: 1, + request_timeout_ms: 1, + global_timeout_ms: 1, + ipv4_only: false, + ipv6_only: false, + authorization_acknowledged: true, + }, + ) + } + + #[test] + fn unfinished_checks_are_deduplicated_in_stage_order() { + let mut report = report(); + record_selection_skips(&mut report, ScanSelection::only(&[])); + add_skipped_check(&mut report, "ports", "duplicate"); + terminate( + &mut report, + ScanStage::Dns, + ScanSelection::all(), + ScanErrorKind::Cancelled, + "cancelled", + ); + assert_eq!( + report + .skipped_checks + .iter() + .map(|check| check.check.as_str()) + .collect::>(), + vec![ + "ports", + "services", + "http", + "tls", + "dns", + "dnssec_validation", + "authoritative_axfr", + "wildcard_dns", + "dangling_cname" + ], + ); + } + + #[test] + fn timeout_without_observations_fails() { + let mut report = report(); + terminate( + &mut report, + ScanStage::Dns, + ScanSelection::all(), + ScanErrorKind::Timeout, + "expired", + ); + assert_eq!(report.status, ScanStatus::Failed); + } +} diff --git a/crates/surface-core/src/service.rs b/crates/surface-core/src/service.rs index 499ee9d..2bc7cd8 100644 --- a/crates/surface-core/src/service.rs +++ b/crates/surface-core/src/service.rs @@ -145,6 +145,81 @@ pub enum DetectionConfidence { High, } +/// SSH identification observed before key exchange. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshIdentification { + /// SSH protocol version from the server identification line. + pub protocol: String, + /// Sanitized server software identification. + pub software: String, +} + +/// Complete client-first SSH algorithm selections. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshAlgorithmSelections { + /// Key-exchange algorithm. + pub kex: String, + /// Server host-key algorithm. + pub host_key: String, + /// Client-to-server cipher. + pub cipher_c2s: String, + /// Server-to-client cipher. + pub cipher_s2c: String, + /// Client-to-server message authentication code. + pub mac_c2s: String, + /// Server-to-client message authentication code. + pub mac_s2c: String, +} + +/// Available client-first selections from an incomplete SSH analysis. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PartialSshAlgorithmSelections { + /// Key-exchange algorithm, when selected. + pub kex: Option, + /// Server host-key algorithm, when selected. + pub host_key: Option, + /// Client-to-server cipher, when selected. + pub cipher_c2s: Option, + /// Server-to-client cipher, when selected. + pub cipher_s2c: Option, + /// Client-to-server message authentication code, when selected. + pub mac_c2s: Option, + /// Server-to-client message authentication code, when selected. + pub mac_s2c: Option, +} + +/// Outcome of bounded SSH posture analysis. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SshPostureOutcome { + /// Every required algorithm selection was inferred. + Complete { + /// Complete inferred selections. + selections: SshAlgorithmSelections, + }, + /// Some selections were inferred before analysis became incomplete. + Partial { + /// Selections available before analysis stopped. + selections: PartialSshAlgorithmSelections, + /// Stable explanation of the incomplete analysis. + reason: String, + }, + /// No algorithm selection could be inferred. + Indeterminate { + /// Stable explanation of the unavailable analysis. + reason: String, + }, +} + +/// Typed SSH protocol evidence for one endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshPosture { + /// Server identification, when available. + pub identification: Option, + /// Analysis outcome and inferred selections. + pub outcome: SshPostureOutcome, +} + /// Bounded service evidence for an open socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ServiceObservation { @@ -161,6 +236,9 @@ pub struct ServiceObservation { pub banner: Option, /// Small deterministic protocol metadata. pub protocol_details: BTreeMap, + /// Typed SSH posture for SSH services. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh: Option, } /// Probes open ports with bounded concurrency and safe payloads. @@ -223,6 +301,7 @@ pub async fn detect_services( }, banner: None, protocol_details: BTreeMap::new(), + ssh: None, }, ) }) @@ -285,6 +364,7 @@ async fn probe_inner(address: SocketAddr, hostname: &str) -> Option ServiceObservation { confidence: DetectionConfidence::Low, banner: None, protocol_details: BTreeMap::new(), + ssh: None, } } @@ -434,10 +515,56 @@ pub fn sanitize_banner(bytes: &[u8]) -> String { #[cfg(test)] mod tests { use super::{ - DetectionConfidence, ProbeBehavior, ServiceKind, classify_banner, hinted_service, - probe_behavior, sanitize_banner, smtp_details, + DetectionConfidence, PartialSshAlgorithmSelections, ProbeBehavior, ServiceKind, + SshAlgorithmSelections, SshIdentification, SshPosture, SshPostureOutcome, classify_banner, + hinted_service, probe_behavior, sanitize_banner, smtp_details, }; + #[test] + fn ssh_posture_round_trips_complete_partial_and_indeterminate_outcomes() { + let identification = SshIdentification { + protocol: "2.0".to_owned(), + software: "OpenSSH_9.9".to_owned(), + }; + let postures = [ + SshPosture { + identification: Some(identification.clone()), + outcome: SshPostureOutcome::Complete { + selections: SshAlgorithmSelections { + kex: "curve25519-sha256".to_owned(), + host_key: "ssh-ed25519".to_owned(), + cipher_c2s: "aes256-ctr".to_owned(), + cipher_s2c: "aes256-ctr".to_owned(), + mac_c2s: "hmac-sha2-512".to_owned(), + mac_s2c: "hmac-sha2-512".to_owned(), + }, + }, + }, + SshPosture { + identification: Some(identification), + outcome: SshPostureOutcome::Partial { + selections: PartialSshAlgorithmSelections { + kex: Some("curve25519-sha256".to_owned()), + ..PartialSshAlgorithmSelections::default() + }, + reason: "no common required algorithm: host_key".to_owned(), + }, + }, + SshPosture { + identification: None, + outcome: SshPostureOutcome::Indeterminate { + reason: "identification timed out".to_owned(), + }, + }, + ]; + + for expected in postures { + let encoded = serde_json::to_string(&expected).expect("posture serializes"); + let actual: SshPosture = serde_json::from_str(&encoded).expect("posture deserializes"); + assert_eq!(actual, expected); + } + } + #[test] fn classifies_protocol_evidence_not_only_ports() { assert_eq!( diff --git a/crates/surface-core/src/ssh.rs b/crates/surface-core/src/ssh.rs index d9752e5..541da65 100644 --- a/crates/surface-core/src/ssh.rs +++ b/crates/surface-core/src/ssh.rs @@ -1,6 +1,6 @@ //! Bounded SSH identification and KEXINIT posture analysis. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::net::SocketAddr; use std::time::Duration; @@ -11,7 +11,10 @@ use tokio::time::{Instant, timeout, timeout_at}; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::{ServiceKind, ServiceObservation, TransportProtocol}; +use crate::{ + PartialSshAlgorithmSelections, ServiceKind, ServiceObservation, SshAlgorithmSelections, + SshIdentification, SshPosture, SshPostureOutcome, TransportProtocol, +}; // Rust guideline compliant 2026-02-21 @@ -68,19 +71,6 @@ const MAC_ALGORITHMS: &[&str] = &[ "hmac-md5-96", ]; const COMPRESSION_ALGORITHMS: &[&str] = &["none"]; -const SSH_DETAIL_KEYS: &[&str] = &[ - "ssh_protocol", - "ssh_software", - "ssh_kex", - "ssh_host_key_algorithm", - "ssh_cipher_c2s", - "ssh_cipher_s2c", - "ssh_mac_c2s", - "ssh_mac_s2c", - "ssh_analysis_status", - "ssh_skip_reason", -]; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SshAnalysisState { Completed, @@ -91,7 +81,7 @@ pub(crate) enum SshAnalysisState { #[derive(Debug)] struct EndpointAnalysis { address: SocketAddr, - details: BTreeMap, + posture: SshPosture, state: SshAnalysisState, } @@ -101,6 +91,15 @@ struct Identification { software: String, } +impl From<&Identification> for SshIdentification { + fn from(value: &Identification) -> Self { + Self { + protocol: value.protocol.to_owned(), + software: value.software.clone(), + } + } +} + #[derive(Debug)] struct KexInit { lists: Vec>, @@ -112,21 +111,21 @@ struct ProtocolError(&'static str); #[derive(Debug)] struct ExchangeError { reason: &'static str, - details: BTreeMap, + identification: Option, } impl ExchangeError { fn new(reason: &'static str) -> Self { Self { reason, - details: BTreeMap::new(), + identification: None, } } fn identified(reason: &'static str, identification: &Identification) -> Self { Self { reason, - details: identification_details(identification), + identification: Some(identification.into()), } } } @@ -178,10 +177,7 @@ pub(crate) async fn analyze_ssh( .iter() .find(|analysis| analysis.address == service.address) { - for key in SSH_DETAIL_KEYS { - service.protocol_details.remove(*key); - } - service.protocol_details.extend(analysis.details.clone()); + service.ssh = Some(analysis.posture.clone()); } } state @@ -201,16 +197,16 @@ async fn inspect( result = timeout_at(deadline, timeout(request_timeout, inspect_inner(address))) => result, }; match result { - Ok(Ok(Ok(details))) => EndpointAnalysis { + Ok(Ok(Ok(posture))) => EndpointAnalysis { address, - details, + posture, state: SshAnalysisState::Completed, }, Ok(Ok(Err(error))) => indeterminate_with_details( address, error.reason, SshAnalysisState::Completed, - error.details, + error.identification, ), Ok(Err(_)) => indeterminate(address, "request timeout", SshAnalysisState::Completed), Err(_) => indeterminate( @@ -221,7 +217,7 @@ async fn inspect( } } -async fn inspect_inner(address: SocketAddr) -> Result, ExchangeError> { +async fn inspect_inner(address: SocketAddr) -> Result { let mut socket = TcpStream::connect(address) .await .map_err(|_| ExchangeError::new("TCP connection failed"))?; @@ -240,7 +236,7 @@ async fn inspect_inner(address: SocketAddr) -> Result, let kexinit = read_server_kexinit(&mut socket) .await .map_err(|error| ExchangeError::identified(error.0, &identification))?; - Ok(inferred_details(&identification, &kexinit)) + Ok(inferred_posture(&identification, &kexinit)) } fn indeterminate( @@ -248,20 +244,23 @@ fn indeterminate( reason: &'static str, state: SshAnalysisState, ) -> EndpointAnalysis { - indeterminate_with_details(address, reason, state, BTreeMap::new()) + indeterminate_with_details(address, reason, state, None) } fn indeterminate_with_details( address: SocketAddr, reason: &'static str, state: SshAnalysisState, - mut details: BTreeMap, + identification: Option, ) -> EndpointAnalysis { - details.insert("ssh_analysis_status".to_owned(), "indeterminate".to_owned()); - details.insert("ssh_skip_reason".to_owned(), reason.to_owned()); EndpointAnalysis { address, - details, + posture: SshPosture { + identification, + outcome: SshPostureOutcome::Indeterminate { + reason: reason.to_owned(), + }, + }, state, } } @@ -585,55 +584,85 @@ fn valid_domain_name(domain: &str) -> bool { }) } -fn identification_details(identification: &Identification) -> BTreeMap { - BTreeMap::from([ - ( - "ssh_protocol".to_owned(), - identification.protocol.to_owned(), - ), - ("ssh_software".to_owned(), identification.software.clone()), - ]) -} - -fn inferred_details( - identification: &Identification, - kexinit: &KexInit, -) -> BTreeMap { - let mut details = identification_details(identification); +fn inferred_posture(identification: &Identification, kexinit: &KexInit) -> SshPosture { let required = [ - ("ssh_kex", KEX_ALGORITHMS, 0_usize), - ("ssh_host_key_algorithm", HOST_KEY_ALGORITHMS, 1), - ("ssh_cipher_c2s", CIPHER_ALGORITHMS, 2), - ("ssh_cipher_s2c", CIPHER_ALGORITHMS, 3), - ("ssh_mac_c2s", MAC_ALGORITHMS, 4), - ("ssh_mac_s2c", MAC_ALGORITHMS, 5), + ("kex", KEX_ALGORITHMS, 0_usize), + ("host_key", HOST_KEY_ALGORITHMS, 1), + ("cipher_c2s", CIPHER_ALGORITHMS, 2), + ("cipher_s2c", CIPHER_ALGORITHMS, 3), + ("mac_c2s", MAC_ALGORITHMS, 4), + ("mac_s2c", MAC_ALGORITHMS, 5), ("compression_c2s", COMPRESSION_ALGORITHMS, 6), ("compression_s2c", COMPRESSION_ALGORITHMS, 7), ]; let mut missing = None; + let mut selections = PartialSshAlgorithmSelections::default(); for (key, client, index) in required { let selected = select_algorithm(client, &kexinit.lists[index]); if let Some(selected) = selected { - if key.starts_with("ssh_") { - details.insert(key.to_owned(), selected.to_owned()); + match key { + "kex" => selections.kex = Some(selected.to_owned()), + "host_key" => selections.host_key = Some(selected.to_owned()), + "cipher_c2s" => selections.cipher_c2s = Some(selected.to_owned()), + "cipher_s2c" => selections.cipher_s2c = Some(selected.to_owned()), + "mac_c2s" => selections.mac_c2s = Some(selected.to_owned()), + "mac_s2c" => selections.mac_s2c = Some(selected.to_owned()), + _ => {} } } else if missing.is_none() { missing = Some(key); } } - if let Some(missing) = missing { - details.insert("ssh_analysis_status".to_owned(), "indeterminate".to_owned()); - details.insert( - "ssh_skip_reason".to_owned(), - format!("no common required algorithm: {missing}"), - ); - } else { - details.insert( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ); + let PartialSshAlgorithmSelections { + kex, + host_key, + cipher_c2s, + cipher_s2c, + mac_c2s, + mac_s2c, + } = selections; + let outcome = match ( + missing, kex, host_key, cipher_c2s, cipher_s2c, mac_c2s, mac_s2c, + ) { + ( + None, + Some(kex), + Some(host_key), + Some(cipher_c2s), + Some(cipher_s2c), + Some(mac_c2s), + Some(mac_s2c), + ) => SshPostureOutcome::Complete { + selections: SshAlgorithmSelections { + kex, + host_key, + cipher_c2s, + cipher_s2c, + mac_c2s, + mac_s2c, + }, + }, + (missing, kex, host_key, cipher_c2s, cipher_s2c, mac_c2s, mac_s2c) => { + SshPostureOutcome::Partial { + selections: PartialSshAlgorithmSelections { + kex, + host_key, + cipher_c2s, + cipher_s2c, + mac_c2s, + mac_s2c, + }, + reason: missing.map_or_else( + || "incomplete required algorithm selection".to_owned(), + |missing| format!("no common required algorithm: {missing}"), + ), + } + } + }; + SshPosture { + identification: Some(identification.into()), + outcome, } - details } fn select_algorithm<'a>(client: &[&'a str], server: &[String]) -> Option<&'a str> { @@ -659,10 +688,13 @@ mod tests { use super::{ CIPHER_ALGORITHMS, CLIENT_IDENTIFICATION, HOST_KEY_ALGORITHMS, Identification, KEX_ALGORITHMS, MAC_ALGORITHMS, MAX_IDENTIFICATION_BYTES, MAX_PACKET_LENGTH_FIELD_VALUE, - MAX_PRE_BANNER_BYTES, SshAnalysisState, analyze_ssh, inferred_details, + MAX_PRE_BANNER_BYTES, SshAnalysisState, analyze_ssh, inferred_posture, parse_identification, parse_kexinit, read_identification, read_server_kexinit, }; - use crate::{DetectionConfidence, ServiceKind, ServiceObservation, TransportProtocol}; + use crate::{ + DetectionConfidence, ServiceKind, ServiceObservation, SshAlgorithmSelections, SshPosture, + SshPostureOutcome, TransportProtocol, + }; const VALID_LISTS: [&str; 10] = [ "diffie-hellman-group14-sha1,curve25519-sha256", @@ -685,6 +717,27 @@ mod tests { confidence: DetectionConfidence::High, banner: Some("SSH-2.0-fixture".to_owned()), protocol_details: BTreeMap::new(), + ssh: None, + } + } + + fn selection<'a>(selections: &'a SshAlgorithmSelections, key: &str) -> &'a str { + match key { + "ssh_kex" => &selections.kex, + "ssh_host_key_algorithm" => &selections.host_key, + "ssh_cipher_c2s" => &selections.cipher_c2s, + "ssh_cipher_s2c" => &selections.cipher_s2c, + "ssh_mac_c2s" => &selections.mac_c2s, + "ssh_mac_s2c" => &selections.mac_s2c, + _ => panic!("unknown SSH selection field"), + } + } + + fn indeterminate_reason(posture: &SshPosture) -> Option<&str> { + match &posture.outcome { + SshPostureOutcome::Indeterminate { reason } + | SshPostureOutcome::Partial { reason, .. } => Some(reason), + SshPostureOutcome::Complete { .. } => None, } } @@ -825,44 +878,33 @@ mod tests { .await; assert_eq!(state, SshAnalysisState::Completed); fixture.await.unwrap_or_else(|error| panic!("{error}")); - for details in services + for posture in services .iter() - .map(|observation| &observation.protocol_details) + .filter_map(|observation| observation.ssh.as_ref()) { - assert_eq!(details.get("ssh_protocol").map(String::as_str), Some("2.0")); - assert_eq!( - details.get("ssh_software").map(String::as_str), - Some("OpenSSH_9.9 fixture") - ); - assert_eq!( - details.get("ssh_kex").map(String::as_str), - Some("curve25519-sha256") - ); - assert_eq!( - details.get("ssh_host_key_algorithm").map(String::as_str), - Some("rsa-sha2-256") - ); - assert_eq!( - details.get("ssh_cipher_c2s").map(String::as_str), - Some("aes128-ctr") - ); assert_eq!( - details.get("ssh_cipher_s2c").map(String::as_str), - Some("aes256-ctr") + posture + .identification + .as_ref() + .map(|value| value.protocol.as_str()), + Some("2.0") ); assert_eq!( - details.get("ssh_mac_c2s").map(String::as_str), - Some("hmac-sha2-256") - ); - assert_eq!( - details.get("ssh_mac_s2c").map(String::as_str), - Some("hmac-sha2-512") - ); - assert_eq!( - details.get("ssh_analysis_status").map(String::as_str), - Some("complete_inferred") + posture + .identification + .as_ref() + .map(|value| value.software.as_str()), + Some("OpenSSH_9.9 fixture") ); - assert!(!details.contains_key("ssh_skip_reason")); + let SshPostureOutcome::Complete { selections } = &posture.outcome else { + panic!("expected complete SSH posture"); + }; + assert_eq!(selections.kex, "curve25519-sha256"); + assert_eq!(selections.host_key, "rsa-sha2-256"); + assert_eq!(selections.cipher_c2s, "aes128-ctr"); + assert_eq!(selections.cipher_s2c, "aes256-ctr"); + assert_eq!(selections.mac_c2s, "hmac-sha2-256"); + assert_eq!(selections.mac_s2c, "hmac-sha2-512"); } } @@ -905,26 +947,19 @@ mod tests { .await; fixture.await.unwrap_or_else(|error| panic!("{error}")); assert_eq!(state, SshAnalysisState::Completed); + let posture = services[0].ssh.as_ref().expect("SSH posture"); assert_eq!( - services[0] - .protocol_details - .get("ssh_software") - .map(String::as_str), + posture + .identification + .as_ref() + .map(|value| value.software.as_str()), Some("server\"\\fixture") ); assert_eq!( - services[0] - .protocol_details - .get("ssh_analysis_status") - .map(String::as_str), - Some("indeterminate") - ); - assert_eq!( - services[0] - .protocol_details - .get("ssh_skip_reason") - .map(String::as_str), - Some("server packet is not KEXINIT") + posture.outcome, + SshPostureOutcome::Indeterminate { + reason: "server packet is not KEXINIT".to_owned() + } ); let json = serde_json::to_string(&services[0]).unwrap_or_else(|error| panic!("{error}")); let round_trip: ServiceObservation = @@ -1142,43 +1177,39 @@ mod tests { lists[index] = algorithm; let kexinit = parse_kexinit(&kex_payload(lists, 20, 0, 0, &[])) .unwrap_or_else(|error| panic!("{}", error.0)); - let details = inferred_details( + let posture = inferred_posture( &Identification { protocol: "2.0", software: "legacy-fixture".to_owned(), }, &kexinit, ); - assert_eq!( - details.get("ssh_analysis_status").map(String::as_str), - Some("complete_inferred") - ); - assert_eq!(details.get(key).map(String::as_str), Some(algorithm)); + let SshPostureOutcome::Complete { selections } = posture.outcome else { + panic!("expected complete SSH posture"); + }; + assert_eq!(selection(&selections, key), algorithm); } } #[test] - fn no_common_required_algorithm_is_indeterminate() { + fn no_common_required_algorithm_retains_partial_selections() { let mut lists = VALID_LISTS; lists[0] = "unsupported-kex"; let payload = kex_payload(lists, 20, 1, 0, &[]); let kexinit = parse_kexinit(&payload).unwrap_or_else(|error| panic!("{}", error.0)); - let details = inferred_details( + let posture = inferred_posture( &Identification { protocol: "2.0", software: "fixture".to_owned(), }, &kexinit, ); - assert_eq!( - details.get("ssh_analysis_status").map(String::as_str), - Some("indeterminate") - ); - assert_eq!( - details.get("ssh_skip_reason").map(String::as_str), - Some("no common required algorithm: ssh_kex") - ); - assert!(!details.contains_key("ssh_kex")); + let SshPostureOutcome::Partial { selections, reason } = posture.outcome else { + panic!("expected partial SSH posture"); + }; + assert_eq!(reason, "no common required algorithm: kex"); + assert_eq!(selections.kex, None); + assert_eq!(selections.host_key.as_deref(), Some("rsa-sha2-256")); assert_eq!(KEX_ALGORITHMS.last(), Some(&"diffie-hellman-group1-sha1")); let legacy_lists = [ @@ -1195,33 +1226,20 @@ mod tests { ]; let payload = kex_payload(legacy_lists, 20, 0, 0, &[]); let kexinit = parse_kexinit(&payload).unwrap_or_else(|error| panic!("{}", error.0)); - let details = inferred_details( + let posture = inferred_posture( &Identification { protocol: "2.0", software: "legacy-fixture".to_owned(), }, &kexinit, ); - assert_eq!( - details.get("ssh_analysis_status").map(String::as_str), - Some("complete_inferred") - ); - assert_eq!( - details.get("ssh_kex").map(String::as_str), - Some("diffie-hellman-group1-sha1") - ); - assert_eq!( - details.get("ssh_host_key_algorithm").map(String::as_str), - Some("ssh-dss") - ); - assert_eq!( - details.get("ssh_cipher_c2s").map(String::as_str), - Some("arcfour") - ); - assert_eq!( - details.get("ssh_mac_s2c").map(String::as_str), - Some("hmac-md5-96") - ); + let SshPostureOutcome::Complete { selections } = posture.outcome else { + panic!("expected complete SSH posture"); + }; + assert_eq!(selections.kex, "diffie-hellman-group1-sha1"); + assert_eq!(selections.host_key, "ssh-dss"); + assert_eq!(selections.cipher_c2s, "arcfour"); + assert_eq!(selections.mac_s2c, "hmac-md5-96"); } #[tokio::test] @@ -1238,10 +1256,7 @@ mod tests { .await; assert_eq!(state, SshAnalysisState::Completed); assert_eq!( - services[0] - .protocol_details - .get("ssh_skip_reason") - .map(String::as_str), + services[0].ssh.as_ref().and_then(indeterminate_reason), Some("request timeout") ); fixture.await.unwrap_or_else(|error| panic!("{error}")); @@ -1264,10 +1279,7 @@ mod tests { .await; assert_eq!(state, SshAnalysisState::Cancelled); assert_eq!( - services[0] - .protocol_details - .get("ssh_skip_reason") - .map(String::as_str), + services[0].ssh.as_ref().and_then(indeterminate_reason), Some("cancelled") ); fixture.await.unwrap_or_else(|error| panic!("{error}")); @@ -1284,10 +1296,7 @@ mod tests { .await; assert_eq!(state, SshAnalysisState::TimedOut); assert_eq!( - services[0] - .protocol_details - .get("ssh_skip_reason") - .map(String::as_str), + services[0].ssh.as_ref().and_then(indeterminate_reason), Some("caller deadline exceeded") ); fixture.await.unwrap_or_else(|error| panic!("{error}")); @@ -1312,6 +1321,7 @@ mod tests { .await; assert_eq!(state, SshAnalysisState::Completed); assert!(services[0].protocol_details.is_empty()); + assert!(services[0].ssh.is_none()); assert!( timeout(Duration::from_millis(50), listener.accept()) .await diff --git a/crates/surface-core/src/tls.rs b/crates/surface-core/src/tls.rs index 3809426..699c8c3 100644 --- a/crates/surface-core/src/tls.rs +++ b/crates/surface-core/src/tls.rs @@ -673,6 +673,7 @@ mod tests { confidence: DetectionConfidence::High, banner: None, protocol_details: BTreeMap::new(), + ssh: None, } } diff --git a/crates/surface-report/src/diff.rs b/crates/surface-report/src/diff.rs index b93398a..1118a23 100644 --- a/crates/surface-report/src/diff.rs +++ b/crates/surface-report/src/diff.rs @@ -115,8 +115,14 @@ pub fn diff_reports(old: &ScanReport, new: &ScanReport) -> Result String { ) } -fn ensure_schema_supported(version: &str) -> Result<(), DiffError> { - if matches!(version, "0.1.0" | "0.1.1" | "0.1.2" | "0.2.0" | "0.3.0") { - Ok(()) - } else { - Err(DiffError(format!( +fn schema_family(version: &str) -> Result<&'static str, DiffError> { + match version { + "0.1.0" | "0.1.1" | "0.1.2" | "0.2.0" | "0.3.0" => Ok("legacy-ssh-map"), + "0.4.0" => Ok("typed-ssh"), + _ => Err(DiffError(format!( "unsupported report schema version '{version}'" - ))) + ))), } } @@ -328,6 +334,7 @@ fn service_changes(old: &ScanReport, new: &ScanReport, target: &str) -> Vec Vec "high", )); } - let old_intelligence = old - .intelligence - .as_ref() - .and_then(|value| serde_json::to_value(value).ok()); - let new_intelligence = new - .intelligence - .as_ref() - .and_then(|value| serde_json::to_value(value).ok()); + let old_intelligence = old.intelligence.as_ref().and_then(intelligence_value); + let new_intelligence = new.intelligence.as_ref().and_then(intelligence_value); if old_intelligence != new_intelligence { changes.push(change( "passive_intelligence", @@ -410,6 +411,25 @@ fn sort_changes(changes: &mut [Change]) { changes.sort_by(|left, right| (&left.category, &left.key).cmp(&(&right.category, &right.key))); } +fn intelligence_value(observation: &surface_core::IntelligenceObservation) -> Option { + let mut value = serde_json::to_value(observation).ok()?; + let object = value.as_object_mut()?; + object.remove("errors"); + if let Some(subdomains) = object.get_mut("subdomains").and_then(Value::as_array_mut) { + for subdomain in subdomains { + if let Some(subdomain) = subdomain.as_object_mut() { + subdomain.remove("error"); + } + } + } + for source in ["related_domains", "certificate_transparency"] { + if let Some(source) = object.get_mut(source).and_then(Value::as_object_mut) { + source.remove("errors"); + } + } + Some(value) +} + fn completeness_changes(old: &ScanReport, new: &ScanReport, target: &str) -> Vec { let old_value = serde_json::json!({ "status": old.status, @@ -517,7 +537,6 @@ fn http_map(report: &ScanReport) -> BTreeMap { "robots_txt": http.robots_txt, "security_txt": http.security_txt, "sitemap_xml": http.sitemap_xml, - "error": http.error, }); (http.url.clone(), value) }) @@ -672,14 +691,17 @@ fn json_key(value: &impl Serialize) -> Option { #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::{ + collections::BTreeMap, + net::{IpAddr, Ipv4Addr, SocketAddr}, + }; use surface_core::{ DetectionConfidence, Evidence, Finding, FindingCategory, FindingConfidence, - HostObservation, PortObservation, PortState, ScanConfiguration, ScanError, ScanErrorKind, - ScanReport, ScanStage, ScanStatus, ServiceKind, ServiceObservation, Severity, - TlsObservation, TransportProtocol, normalize_target, + HostObservation, HttpObservation, IntelligenceObservation, PortObservation, PortState, + ScanConfiguration, ScanError, ScanErrorKind, ScanReport, ScanStage, ScanStatus, + ServiceKind, ServiceObservation, Severity, SshAlgorithmSelections, SshPosture, + SshPostureOutcome, TlsObservation, TransportProtocol, normalize_target, }; use super::{Change, diff_reports, render_diff_json, render_diff_terminal}; @@ -703,7 +725,7 @@ mod tests { report } - fn ssh_service(protocol_details: BTreeMap) -> ServiceObservation { + fn ssh_service(outcome: SshPostureOutcome) -> ServiceObservation { ServiceObservation { transport: TransportProtocol::Tcp, address: "192.0.2.1:22" @@ -712,7 +734,45 @@ mod tests { service: ServiceKind::Ssh, confidence: DetectionConfidence::High, banner: Some("SSH-2.0-OpenSSH_9.9".to_owned()), - protocol_details, + protocol_details: BTreeMap::default(), + ssh: Some(SshPosture { + identification: None, + outcome, + }), + } + } + + fn ssh_selections(kex: &str) -> SshAlgorithmSelections { + SshAlgorithmSelections { + kex: kex.to_owned(), + host_key: "ssh-ed25519".to_owned(), + cipher_c2s: "aes256-ctr".to_owned(), + cipher_s2c: "aes256-ctr".to_owned(), + mac_c2s: "hmac-sha2-512".to_owned(), + mac_s2c: "hmac-sha2-512".to_owned(), + } + } + + fn http_observation(error: &str) -> HttpObservation { + HttpObservation { + address: "192.0.2.1:80" + .parse() + .unwrap_or_else(|parse_error| panic!("{parse_error}")), + url: "http://example.com/".to_owned(), + final_url: Some("http://example.com/".to_owned()), + status: None, + version: None, + latency_ms: None, + redirects: Vec::new(), + headers: BTreeMap::default(), + cookies: Vec::new(), + title: None, + body_bytes: 0, + body_truncated: false, + robots_txt: None, + security_txt: None, + sitemap_xml: None, + error: Some(error.to_owned()), } } @@ -771,6 +831,40 @@ mod tests { assert!(rendered.contains("warning 31m雪")); } + #[test] + fn diff_rejects_reports_across_the_typed_ssh_schema_change() { + let mut old = report(); + let new = report(); + old.schema_version = "0.3.0".to_owned(); + + let error = diff_reports(&old, &new).expect_err("schema families must not be mixed"); + + assert!(error.to_string().contains("schema families")); + } + + #[test] + fn semantic_diff_ignores_transient_http_and_intelligence_error_text() { + let mut old = report(); + let mut new = report(); + old.http.push(http_observation("connection reset")); + new.http.push(http_observation("request timeout")); + old.intelligence = Some(IntelligenceObservation { + complete: false, + errors: vec!["first provider message".to_owned()], + ..IntelligenceObservation::default() + }); + new.intelligence = Some(IntelligenceObservation { + complete: false, + errors: vec!["second provider message".to_owned()], + ..IntelligenceObservation::default() + }); + + let diff = diff_reports(&old, &new).unwrap_or_else(|error| panic!("{error}")); + + assert!(diff.service_changes.is_empty()); + assert!(diff.dns_changes.is_empty()); + } + #[test] fn ignores_volatile_fields_and_detects_port_state_changes() { let mut old = report(); @@ -850,48 +944,37 @@ mod tests { fn ssh_completion_and_status_changes_are_visible() { let mut old = report(); let mut new = report(); - old.services.push(ssh_service(BTreeMap::from([ - ("ssh_analysis_status".to_owned(), "indeterminate".to_owned()), - ("ssh_skip_reason".to_owned(), "request timeout".to_owned()), - ]))); - new.services.push(ssh_service(BTreeMap::from([ - ("ssh_protocol".to_owned(), "2.0".to_owned()), - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - ("ssh_kex".to_owned(), "curve25519-sha256".to_owned()), - ]))); + old.services + .push(ssh_service(SshPostureOutcome::Indeterminate { + reason: "request timeout".to_owned(), + })); + new.services.push(ssh_service(SshPostureOutcome::Complete { + selections: ssh_selections("curve25519-sha256"), + })); let diff = diff_reports(&old, &new).unwrap_or_else(|error| panic!("{error}")); assert_eq!(diff.service_changes.len(), 1); let new_details = &diff.service_changes[0] .new_value .as_ref() - .unwrap_or_else(|| panic!("new SSH service evidence missing"))["protocol_details"]; - assert_eq!(new_details["ssh_analysis_status"], "complete_inferred"); - assert_eq!(new_details["ssh_kex"], "curve25519-sha256"); - assert!(new_details.get("ssh_skip_reason").is_none()); + .unwrap_or_else(|| panic!("new SSH service evidence missing"))["ssh"]; + assert_eq!(new_details["outcome"]["status"], "complete"); + assert_eq!( + new_details["outcome"]["selections"]["kex"], + "curve25519-sha256" + ); } #[test] fn ssh_algorithm_changes_are_visible() { let mut old = report(); let mut new = report(); - let details = BTreeMap::from([ - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - ("ssh_kex".to_owned(), "curve25519-sha256".to_owned()), - ]); - old.services.push(ssh_service(details.clone())); - let mut changed = details; - changed.insert( - "ssh_kex".to_owned(), - "diffie-hellman-group14-sha256".to_owned(), - ); - new.services.push(ssh_service(changed)); + old.services.push(ssh_service(SshPostureOutcome::Complete { + selections: ssh_selections("curve25519-sha256"), + })); + new.services.push(ssh_service(SshPostureOutcome::Complete { + selections: ssh_selections("diffie-hellman-group14-sha256"), + })); let diff = diff_reports(&old, &new).unwrap_or_else(|error| panic!("{error}")); assert_eq!(diff.service_changes.len(), 1); @@ -899,8 +982,8 @@ mod tests { diff.service_changes[0] .new_value .as_ref() - .unwrap_or_else(|| panic!("new SSH algorithm evidence missing"))["protocol_details"] - ["ssh_kex"], + .unwrap_or_else(|| panic!("new SSH algorithm evidence missing"))["ssh"]["outcome"] + ["selections"]["kex"], "diffie-hellman-group14-sha256" ); } diff --git a/crates/surface-report/src/exports.rs b/crates/surface-report/src/exports.rs index 84675e7..43ebd4d 100644 --- a/crates/surface-report/src/exports.rs +++ b/crates/surface-report/src/exports.rs @@ -3,7 +3,9 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; -use surface_core::{Finding, ScanReport, Severity}; +use surface_core::{Finding, ScanReport, Severity, SshPosture, SshPostureOutcome}; + +use crate::projection; // Rust guideline compliant 2026-02-21 @@ -66,7 +68,7 @@ pub fn render_sarif(report: &ScanReport) -> Result { }) }) .collect::>(); - let successful = matches!(report.status, surface_core::ScanStatus::Completed); + let lifecycle = projection::lifecycle(report); let document = json!({ "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0", @@ -80,12 +82,12 @@ pub fn render_sarif(report: &ScanReport) -> Result { } }, "invocations": [{ - "executionSuccessful": successful, + "executionSuccessful": lifecycle.successful, "properties": { "scanId": report.scan_id, "reportSchemaVersion": report.schema_version, "scanStatus": report.status, - "partial": !successful, + "partial": lifecycle.partial, } }], "results": results, @@ -100,8 +102,18 @@ pub fn render_sarif(report: &ScanReport) -> Result { /// /// Returns an error if JSON serialization fails. pub fn render_cyclonedx(report: &ScanReport) -> Result { + let lifecycle = projection::lifecycle(report); let mut services = report.services.iter().collect::>(); - services.sort_by_key(|service| (service.address, service.transport)); + services.sort_by(|left, right| { + left.address + .cmp(&right.address) + .then(left.transport.cmp(&right.transport)) + .then_with(|| left.service.to_string().cmp(&right.service.to_string())) + .then_with(|| format!("{:?}", left.confidence).cmp(&format!("{:?}", right.confidence))) + .then(left.banner.cmp(&right.banner)) + .then(left.protocol_details.cmp(&right.protocol_details)) + .then_with(|| format!("{:?}", left.ssh).cmp(&format!("{:?}", right.ssh))) + }); let services = services .into_iter() .map(|service| { @@ -121,6 +133,9 @@ pub fn render_cyclonedx(report: &ScanReport) -> Result Result Result Vec { + let mut properties = Vec::new(); + if let Some(identification) = &posture.identification { + properties.push(property("surface:ssh:protocol", &identification.protocol)); + properties.push(property("surface:ssh:software", &identification.software)); + } + let (outcome, selections, reason) = match &posture.outcome { + SshPostureOutcome::Complete { selections } => ( + "complete", + [ + ("kex", Some(selections.kex.as_str())), + ("host-key", Some(selections.host_key.as_str())), + ("cipher-c2s", Some(selections.cipher_c2s.as_str())), + ("cipher-s2c", Some(selections.cipher_s2c.as_str())), + ("mac-c2s", Some(selections.mac_c2s.as_str())), + ("mac-s2c", Some(selections.mac_s2c.as_str())), + ], + None, + ), + SshPostureOutcome::Partial { selections, reason } => ( + "partial", + [ + ("kex", selections.kex.as_deref()), + ("host-key", selections.host_key.as_deref()), + ("cipher-c2s", selections.cipher_c2s.as_deref()), + ("cipher-s2c", selections.cipher_s2c.as_deref()), + ("mac-c2s", selections.mac_c2s.as_deref()), + ("mac-s2c", selections.mac_s2c.as_deref()), + ], + Some(reason.as_str()), + ), + SshPostureOutcome::Indeterminate { reason } => { + ("indeterminate", [("", None); 6], Some(reason.as_str())) + } + }; + properties.push(property("surface:ssh:outcome", outcome)); + properties.extend(selections.into_iter().filter_map(|(name, value)| { + value.map(|value| property(&format!("surface:ssh:{name}"), value)) + })); + if let Some(reason) = reason { + properties.push(property("surface:ssh:reason", reason)); + } + properties +} + fn canonical_finding(finding: &Finding) -> String { serde_json::to_string(finding).unwrap_or_default() } @@ -247,12 +308,11 @@ const fn security_severity(severity: Severity) -> &'static str { #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use serde_json::Value; use surface_core::{ DetectionConfidence, Finding, FindingCategory, FindingConfidence, ScanConfiguration, - ScanReport, ServiceKind, ServiceObservation, Severity, TransportProtocol, normalize_target, + ScanReport, ScanStatus, ServiceKind, ServiceObservation, Severity, SshAlgorithmSelections, + SshPosture, SshPostureOutcome, TransportProtocol, normalize_target, }; use super::{render_cyclonedx, render_sarif}; @@ -308,7 +368,42 @@ mod tests { } #[test] - fn ssh_details_remain_generic_cyclonedx_service_properties() { + fn exports_preserve_exact_scan_lifecycle() { + for (status, partial) in [ + (ScanStatus::NotStarted, false), + (ScanStatus::Completed, false), + (ScanStatus::Partial, true), + (ScanStatus::Interrupted, false), + (ScanStatus::Failed, false), + ] { + let mut report = report(); + report.status = status; + let sarif: Value = serde_json::from_str( + &render_sarif(&report).unwrap_or_else(|error| panic!("{error}")), + ) + .unwrap_or_else(|error| panic!("{error}")); + let cyclonedx: Value = serde_json::from_str( + &render_cyclonedx(&report).unwrap_or_else(|error| panic!("{error}")), + ) + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!( + sarif["runs"][0]["invocations"][0]["properties"]["partial"], + partial + ); + assert_eq!( + cyclonedx["metadata"]["component"]["properties"][2]["name"], + "surface:scan-status" + ); + assert_eq!( + cyclonedx["metadata"]["component"]["properties"][2]["value"], + serde_json::to_value(status).unwrap_or_else(|error| panic!("{error}")) + ); + } + } + + #[test] + fn typed_ssh_uses_stable_cyclonedx_properties() { let mut report = report(); report.services.push(ServiceObservation { transport: TransportProtocol::Tcp, @@ -318,21 +413,53 @@ mod tests { service: ServiceKind::Ssh, confidence: DetectionConfidence::High, banner: Some("SSH-2.0-OpenSSH_9.9".to_owned()), - protocol_details: BTreeMap::from([ - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - ("ssh_kex".to_owned(), "curve25519-sha256".to_owned()), - ]), + protocol_details: std::collections::BTreeMap::new(), + ssh: Some(SshPosture { + identification: None, + outcome: SshPostureOutcome::Complete { + selections: SshAlgorithmSelections { + kex: "curve25519-sha256".to_owned(), + host_key: "ssh-ed25519".to_owned(), + cipher_c2s: "aes256-ctr".to_owned(), + cipher_s2c: "aes256-ctr".to_owned(), + mac_c2s: "hmac-sha2-512".to_owned(), + mac_s2c: "hmac-sha2-512".to_owned(), + }, + }, + }), }); let cyclonedx = render_cyclonedx(&report).unwrap_or_else(|error| panic!("{error}")); - assert!(cyclonedx.contains("surface:protocol:ssh_analysis_status")); - assert!(cyclonedx.contains("surface:protocol:ssh_kex")); + assert!(cyclonedx.contains("surface:ssh:outcome")); + assert!(cyclonedx.contains("surface:ssh:kex")); let sarif = render_sarif(&report).unwrap_or_else(|error| panic!("{error}")); - assert!(!sarif.contains("ssh_analysis_status")); - assert!(!sarif.contains("ssh_kex")); + assert!(!sarif.contains("surface:ssh:outcome")); + assert!(!sarif.contains("surface:ssh:kex")); + } + + #[test] + fn cyclonedx_ignores_equal_endpoint_service_input_order() { + let mut forward = report(); + for service in [ServiceKind::Https, ServiceKind::Http] { + forward.services.push(ServiceObservation { + transport: TransportProtocol::Tcp, + address: "192.0.2.1:443" + .parse() + .unwrap_or_else(|error| panic!("{error}")), + service, + confidence: DetectionConfidence::High, + banner: None, + protocol_details: std::collections::BTreeMap::new(), + ssh: None, + }); + } + let mut reversed = forward.clone(); + reversed.services.reverse(); + + assert_eq!( + render_cyclonedx(&forward).unwrap_or_default(), + render_cyclonedx(&reversed).unwrap_or_default() + ); } #[test] diff --git a/crates/surface-report/src/lib.rs b/crates/surface-report/src/lib.rs index 6467da9..1cbfdb6 100644 --- a/crates/surface-report/src/lib.rs +++ b/crates/surface-report/src/lib.rs @@ -2,11 +2,12 @@ mod diff; mod exports; +mod projection; mod signing; use std::fmt::Write; -use surface_core::{PortState, ScanReport, ServiceKind, ServiceObservation, Severity}; +use surface_core::{HstsState, ScanReport, Severity}; #[doc(inline)] pub use diff::{ @@ -21,15 +22,6 @@ pub use signing::{SignatureEnvelope, VerificationError, decode_key, sign_bytes, /// Maximum characters rendered for one externally supplied SSH text value. const SSH_DISPLAY_CHARS: usize = 255; -const SSH_SELECTION_KEYS: [&str; 6] = [ - "ssh_kex", - "ssh_host_key_algorithm", - "ssh_cipher_c2s", - "ssh_cipher_s2c", - "ssh_mac_c2s", - "ssh_mac_s2c", -]; - /// Renders a report as human-readable plain text. #[must_use] #[expect( @@ -38,6 +30,7 @@ const SSH_SELECTION_KEYS: [&str; 6] = [ )] pub fn render_terminal(report: &ScanReport) -> String { let mut output = String::new(); + let lifecycle = projection::lifecycle(report); let _ = writeln!( output, "Surface {}", @@ -198,55 +191,34 @@ pub fn render_terminal(report: &ScanReport) -> String { let _ = writeln!(output, "Hosts"); for host in &report.hosts { let _ = writeln!(output, " {}", host.ip); - for port in host.ports.iter().filter(|port| { - port.state == PortState::Open - || (port.state == PortState::OpenFiltered - && report.services.iter().any(|service| { - service.address == port.address && service.transport == port.transport - })) - }) { - let service = report - .services - .iter() - .find(|service| { - service.address == port.address && service.transport == port.transport - }) - .map_or_else( - || "unknown".to_owned(), - |service| service.service.to_string(), - ); - let state = if port.state == PortState::Open { - "open" - } else { - "open|filtered" - }; + for port in projection::ports(report, host) { let _ = writeln!( output, - " {}/{} {state} {service}", - port.address.port(), - port.transport.as_str() + " {}/{} {} {}", + port.number, port.transport, port.state, port.service ); } } output.push('\n'); } - if report.services.iter().any(has_ssh_posture) { + if report.services.iter().any(|service| service.ssh.is_some()) { let _ = writeln!(output, "SSH posture"); for service in report .services .iter() - .filter(|service| has_ssh_posture(service)) + .filter(|service| service.ssh.is_some()) { - let detail = - |key| bounded_ssh_value(service.protocol_details.get(key).map(String::as_str)); + let Some(posture) = projection::ssh(service) else { + continue; + }; let _ = writeln!( output, " {} protocol={} software={} status={}", service.address, - clean_terminal(&detail("ssh_protocol")), - clean_terminal(&detail("ssh_software")), - clean_terminal(&detail("ssh_analysis_status")) + clean_terminal(&bounded_ssh_value(posture.protocol)), + clean_terminal(&bounded_ssh_value(posture.software)), + posture.status ); if let Some(banner) = service.banner.as_deref() { let _ = writeln!( @@ -255,25 +227,26 @@ pub fn render_terminal(report: &ScanReport) -> String { clean_terminal(&bounded_ssh_value(Some(banner))) ); } - if has_complete_ssh_inference(service) { + if posture.selections.kex.is_some() { let _ = writeln!( output, - " KEXINIT-inferred selections (no completed key exchange): KEX={} host-key={}", - clean_terminal(&detail("ssh_kex")), - clean_terminal(&detail("ssh_host_key_algorithm")) + " KEXINIT-inferred selections ({}; no completed key exchange): KEX={} host-key={}", + posture.status, + clean_terminal(&bounded_ssh_value(posture.selections.kex)), + clean_terminal(&bounded_ssh_value(posture.selections.host_key)) ); let _ = writeln!( output, " cipher c2s={} s2c={} MAC c2s={} s2c={}", - clean_terminal(&detail("ssh_cipher_c2s")), - clean_terminal(&detail("ssh_cipher_s2c")), - clean_terminal(&detail("ssh_mac_c2s")), - clean_terminal(&detail("ssh_mac_s2c")) + clean_terminal(&bounded_ssh_value(posture.selections.cipher_c2s)), + clean_terminal(&bounded_ssh_value(posture.selections.cipher_s2c)), + clean_terminal(&bounded_ssh_value(posture.selections.mac_c2s)), + clean_terminal(&bounded_ssh_value(posture.selections.mac_s2c)) ); } else { let _ = writeln!(output, " No inferred selections available."); } - if let Some(reason) = service.protocol_details.get("ssh_skip_reason") { + if let Some(reason) = posture.reason { let _ = writeln!( output, " Skip reason (bounded): {}", @@ -287,7 +260,6 @@ pub fn render_terminal(report: &ScanReport) -> String { if !report.http.is_empty() { let _ = writeln!(output, "HTTP"); for http in &report.http { - let effective_url = http.final_url.as_deref().unwrap_or(&http.url); let _ = write!( output, " {} status={}", @@ -295,16 +267,8 @@ pub fn render_terminal(report: &ScanReport) -> String { http.status .map_or_else(|| "error".to_owned(), |status| status.to_string()), ); - if effective_url.starts_with("https://") { - let _ = write!( - output, - " HSTS={}", - if http.headers.contains_key("strict-transport-security") { - "present" - } else { - "missing" - } - ); + if http.hsts_state() != HstsState::NotApplicable { + let _ = write!(output, " HSTS={}", hsts_label(http.hsts_state())); } let _ = writeln!(output, " security.txt={}", option_bool(http.security_txt)); for redirect in &http.redirects { @@ -517,8 +481,8 @@ pub fn render_terminal(report: &ScanReport) -> String { .count(); let _ = writeln!(output, " {severity:?}: {count}"); } - if !report.errors.is_empty() { - let _ = writeln!(output, " Partial errors: {}", report.errors.len()); + if lifecycle.errors > 0 { + let _ = writeln!(output, " {:?} errors: {}", report.status, lifecycle.errors); } output } @@ -540,6 +504,7 @@ pub fn render_json(report: &ScanReport) -> Result { reason = "small bounded report fragments favor one auditable escaped template" )] pub fn render_html(report: &ScanReport) -> String { + let lifecycle = projection::lifecycle(report); let mut findings = String::new(); for finding in &report.findings { let evidence = finding @@ -787,27 +752,15 @@ pub fn render_html(report: &ScanReport) -> String { .hosts .iter() .map(|host| { - let ports = host - .ports - .iter() - .filter(|port| { - port.state == PortState::Open - || (port.state == PortState::OpenFiltered - && report.services.iter().any(|service| { - service.address == port.address - && service.transport == port.transport - })) - }) + let ports = projection::ports(report, host) + .into_iter() .map(|port| { - let state = if port.state == PortState::Open { - "open" - } else { - "open|filtered" - }; format!( - "
  • {}/{} {state}
  • ", - port.address.port(), - port.transport.as_str() + "
  • {}/{} {} {}
  • ", + port.number, + port.transport, + port.state, + escape_html(&port.service) ) }) .collect::(); @@ -824,9 +777,6 @@ pub fn render_html(report: &ScanReport) -> String { let details = service .protocol_details .iter() - .filter(|(key, _)| { - has_complete_ssh_inference(service) || !is_ssh_selection_key(key) - }) .map(|(key, value)| { format!( "{}={}", @@ -849,30 +799,24 @@ pub fn render_html(report: &ScanReport) -> String { let ssh_entries = report .services .iter() - .filter(|service| has_ssh_posture(service)) - .map(|service| { - let detail = |key| { - escape_html(&bounded_ssh_value( - service.protocol_details.get(key).map(String::as_str), - )) - }; - let selections = if has_complete_ssh_inference(service) { + .filter_map(|service| projection::ssh(service).map(|posture| (service, posture))) + .map(|(service, posture)| { + let detail = |value| escape_html(&bounded_ssh_value(value)); + let selections = if posture.selections.kex.is_some() { format!( - "

    KEXINIT-inferred selections (no completed key exchange): KEX {} · host key {} · cipher c2s/s2c {}/{} · MAC c2s/s2c {}/{}

    ", - detail("ssh_kex"), - detail("ssh_host_key_algorithm"), - detail("ssh_cipher_c2s"), - detail("ssh_cipher_s2c"), - detail("ssh_mac_c2s"), - detail("ssh_mac_s2c") + "

    KEXINIT-inferred selections ({}; no completed key exchange): KEX {} · host key {} · cipher c2s/s2c {}/{} · MAC c2s/s2c {}/{}

    ", + posture.status, + detail(posture.selections.kex), + detail(posture.selections.host_key), + detail(posture.selections.cipher_c2s), + detail(posture.selections.cipher_s2c), + detail(posture.selections.mac_c2s), + detail(posture.selections.mac_s2c) ) } else { "

    No inferred selections available.

    ".to_owned() }; - let skip_reason = service - .protocol_details - .get("ssh_skip_reason") - .map_or_else(String::new, |reason| { + let skip_reason = posture.reason.map_or_else(String::new, |reason| { format!( "

    Skip reason (bounded): {}

    ", escape_html(&bounded_ssh_value(Some(reason))) @@ -881,9 +825,9 @@ pub fn render_html(report: &ScanReport) -> String { format!( "
    {} · status {}

    Protocol: {} · Software: {} · Banner: {}

    {selections}{skip_reason}
    ", escape_html(&service.address.to_string()), - detail("ssh_analysis_status"), - detail("ssh_protocol"), - detail("ssh_software"), + posture.status, + detail(posture.protocol), + detail(posture.software), escape_html(&bounded_ssh_value(service.banner.as_deref())), ) }) @@ -897,16 +841,8 @@ pub fn render_html(report: &ScanReport) -> String { .http .iter() .map(|http| { - let effective_url = http.final_url.as_deref().unwrap_or(&http.url); - let hsts_state = if effective_url.starts_with("https://") { - if http.headers.contains_key("strict-transport-security") { - "present" - } else { - "missing" - } - } else { - "not applicable" - }; + let effective_url = http.effective_url(); + let hsts_state = hsts_label(http.hsts_state()); let redirects = http .redirects .iter() @@ -1123,8 +1059,13 @@ pub fn render_html(report: &ScanReport) -> String { ) }) .collect::(); + let incomplete = if lifecycle.score_incomplete { + "

    Incomplete scan: score interpretation is limited.

    " + } else { + "" + }; format!( - "

    {}/100 ({:?}, model {})

      {deductions}
    ", + "

    {}/100 ({:?}, model {})

    {incomplete}
      {deductions}
    ", score.value, score.classification, escape_html(&score.model_version) @@ -1190,21 +1131,6 @@ pub fn render_html(report: &ScanReport) -> String { ) } -fn has_ssh_posture(service: &ServiceObservation) -> bool { - service.service == ServiceKind::Ssh -} - -fn has_complete_ssh_inference(service: &ServiceObservation) -> bool { - service - .protocol_details - .get("ssh_analysis_status") - .is_some_and(|status| status == "complete_inferred") -} - -fn is_ssh_selection_key(key: &str) -> bool { - SSH_SELECTION_KEYS.contains(&key) -} - fn bounded_ssh_value(value: Option<&str>) -> String { value .unwrap_or("unknown") @@ -1248,6 +1174,14 @@ fn option_bool(value: Option) -> &'static str { } } +const fn hsts_label(state: HstsState) -> &'static str { + match state { + HstsState::Present => "present", + HstsState::Missing => "missing", + HstsState::NotApplicable => "not applicable", + } +} + fn option_yes_no(value: Option) -> &'static str { match value { Some(true) => "yes", @@ -1274,11 +1208,14 @@ mod tests { AuthoritativeAxfrObservation, AxfrAttempt, AxfrOutcome, CertificateTransparencyCandidate, CertificateTransparencyObservation, CnameHop, DanglingCnameObservation, DanglingCnameStatus, DetectionConfidence, DnsObservation, DnssecObservation, - DnssecRecordType, DnssecRrsetObservation, DnssecStatus, HttpObservation, - IntelligenceObservation, MailObservation, RedirectObservation, RelatedDomainCandidate, - RelatedDomainsObservation, ScanConfiguration, ScanReport, ServiceKind, ServiceObservation, - SkippedCheck, SpfObservation, TlsObservation, TransportProtocol, WildcardDnsObservation, - WildcardDnsRecordType, WildcardDnsStatus, calculate_exposure, normalize_target, + DnssecRecordType, DnssecRrsetObservation, DnssecStatus, HostObservation, HttpObservation, + IntelligenceObservation, MailObservation, PartialSshAlgorithmSelections, PortObservation, + PortState, RedirectObservation, RelatedDomainCandidate, RelatedDomainsObservation, + ScanConfiguration, ScanError, ScanErrorKind, ScanReport, ScanStage, ScanStatus, + ServiceKind, ServiceObservation, SkippedCheck, SpfObservation, SshAlgorithmSelections, + SshIdentification, SshPosture, SshPostureOutcome, TlsObservation, TransportProtocol, + WildcardDnsObservation, WildcardDnsRecordType, WildcardDnsStatus, calculate_exposure, + normalize_target, }; use super::{render_html, render_json, render_terminal}; @@ -1300,11 +1237,7 @@ mod tests { ) } - fn ssh_service( - port: u16, - banner: Option<&str>, - protocol_details: BTreeMap, - ) -> ServiceObservation { + fn ssh_service(port: u16, banner: Option<&str>, ssh: Option) -> ServiceObservation { ServiceObservation { transport: TransportProtocol::Tcp, address: format!("127.0.0.1:{port}") @@ -1313,7 +1246,19 @@ mod tests { service: ServiceKind::Ssh, confidence: DetectionConfidence::High, banner: banner.map(str::to_owned), - protocol_details, + protocol_details: BTreeMap::new(), + ssh, + } + } + + fn ssh_selections() -> SshAlgorithmSelections { + SshAlgorithmSelections { + kex: "".to_owned(), + host_key: "".to_owned(), + cipher_c2s: "".to_owned(), + cipher_s2c: "".to_owned(), + mac_c2s: "".to_owned(), + mac_s2c: "".to_owned(), } } @@ -1323,11 +1268,79 @@ mod tests { assert!(render_terminal(&report).contains("Status: NotStarted")); let json = render_json(&report).unwrap_or_default(); assert!(json.contains("\"status\": \"not_started\"")); - assert!(json.contains("\"schema_version\": \"0.3.0\"")); + assert!(json.contains("\"schema_version\": \"0.4.0\"")); assert!(!render_terminal(&report).contains("\nSSH posture\n")); assert!(!render_html(&report).contains("

    SSH posture

    ")); } + #[test] + fn human_renderers_expose_exact_failure_and_incomplete_score() { + let mut report = report("example.com"); + report.status = ScanStatus::Failed; + report.errors.push(ScanError::new( + ScanStage::Preflight, + None, + ScanErrorKind::Configuration, + "invalid configuration", + false, + )); + let mut score = calculate_exposure(&report); + score.incomplete = true; + report.exposure_score = Some(score); + + let terminal = render_terminal(&report); + let html = render_html(&report); + + assert!(terminal.contains("Failed errors: 1")); + assert!(!terminal.contains("Partial errors")); + assert!(html.contains("Incomplete scan: score interpretation is limited.")); + } + + #[test] + fn human_renderers_share_visible_port_semantics() { + let mut report = report("example.com"); + let ip = "192.0.2.1" + .parse() + .unwrap_or_else(|error| panic!("{error}")); + let port = |number, transport, state| PortObservation { + transport, + address: (ip, number).into(), + state, + latency_ms: None, + error: None, + }; + report.hosts.push(HostObservation { + ip, + ports: vec![ + port(80, TransportProtocol::Tcp, PortState::Open), + port(53, TransportProtocol::Udp, PortState::OpenFiltered), + port(54, TransportProtocol::Udp, PortState::OpenFiltered), + port(81, TransportProtocol::Tcp, PortState::Closed), + ], + }); + report.services.push(ServiceObservation { + transport: TransportProtocol::Udp, + address: (ip, 53).into(), + service: ServiceKind::Dns, + confidence: DetectionConfidence::High, + banner: None, + protocol_details: BTreeMap::new(), + ssh: None, + }); + + let terminal = render_terminal(&report); + let html = render_html(&report); + + assert!(terminal.contains("80/tcp open unknown")); + assert!(terminal.contains("53/udp open|filtered DNS")); + assert!(!terminal.contains("54/udp")); + assert!(!terminal.contains("81/tcp")); + assert!(html.contains("
  • 80/tcp open unknown
  • ")); + assert!(html.contains("
  • 53/udp open|filtered DNS
  • ")); + assert!(!html.contains("54/udp")); + assert!(!html.contains("81/tcp")); + } + #[test] fn terminal_sanitizes_imported_dynamic_metadata() { let mut report = report("example.com"); @@ -1387,31 +1400,28 @@ mod tests { report.services.push(ssh_service( 22, Some("SSH-2.0-\u{1b}[31m"), - BTreeMap::from([ - ("ssh_protocol".to_owned(), "2.0".to_owned()), - ("ssh_software".to_owned(), "&\u{7}".to_owned()), - ("ssh_kex".to_owned(), "".to_owned()), - ("ssh_host_key_algorithm".to_owned(), "".to_owned()), - ("ssh_cipher_c2s".to_owned(), "".to_owned()), - ("ssh_cipher_s2c".to_owned(), "".to_owned()), - ("ssh_mac_c2s".to_owned(), "".to_owned()), - ("ssh_mac_s2c".to_owned(), "".to_owned()), - ( - "ssh_analysis_status".to_owned(), - "complete_inferred".to_owned(), - ), - ]), + Some(SshPosture { + identification: Some(SshIdentification { + protocol: "2.0".to_owned(), + software: "&\u{7}".to_owned(), + }), + outcome: SshPostureOutcome::Complete { + selections: ssh_selections(), + }, + }), )); let terminal = render_terminal(&report); assert!(terminal.contains("127.0.0.1:22 protocol=2.0")); - assert!(terminal.contains("status=complete_inferred")); - assert!(terminal.contains("KEXINIT-inferred selections (no completed key exchange)")); + assert!(terminal.contains("status=complete")); + assert!( + terminal.contains("KEXINIT-inferred selections (complete; no completed key exchange)") + ); assert!(!terminal.contains('\u{1b}')); assert!(!terminal.contains('\u{7}')); let html = render_html(&report); - assert!(html.contains("KEXINIT-inferred selections (no completed key exchange)")); + assert!(html.contains("KEXINIT-inferred selections (complete; no completed key exchange)")); for escaped in [ "<banner>", "<software>&", @@ -1435,28 +1445,30 @@ mod tests { report.services.push(ssh_service( 2222, None, - BTreeMap::from([ - ("ssh_protocol".to_owned(), "2.0".to_owned()), - ("ssh_software".to_owned(), "OpenSSH_<9>&".to_owned()), - ("ssh_analysis_status".to_owned(), "indeterminate".to_owned()), - ( - "ssh_skip_reason".to_owned(), - format!("\u{1b}[31m{}END", "x".repeat(300)), - ), - ]), + Some(SshPosture { + identification: Some(SshIdentification { + protocol: "2.0".to_owned(), + software: "OpenSSH_<9>&".to_owned(), + }), + outcome: SshPostureOutcome::Indeterminate { + reason: format!("\u{1b}[31m{}END", "x".repeat(300)), + }, + }), )); report.services.push(ssh_service( 2200, None, - BTreeMap::from([( - "ssh_skip_reason".to_owned(), - "identification timeout".to_owned(), - )]), + Some(SshPosture { + identification: None, + outcome: SshPostureOutcome::Indeterminate { + reason: "identification timeout".to_owned(), + }, + }), )); let terminal = render_terminal(&report); assert!(terminal.contains("protocol=2.0 software=OpenSSH_<9>& status=indeterminate")); - assert!(terminal.contains("status=unknown")); + assert_eq!(terminal.matches("status=indeterminate").count(), 2); assert_eq!( terminal .matches("No inferred selections available.") @@ -1470,7 +1482,7 @@ mod tests { let html = render_html(&report); assert!(html.contains("status indeterminate")); - assert!(html.contains("status unknown")); + assert_eq!(html.matches("status indeterminate").count(), 2); assert_eq!(html.matches("No inferred selections available.").count(), 2); assert!(html.contains("OpenSSH_<9>&")); assert!(html.contains("<request timeout>")); @@ -1480,27 +1492,28 @@ mod tests { } #[test] - fn stale_ssh_selections_are_suppressed_when_status_is_not_complete() { + fn partial_ssh_selections_are_rendered_as_incomplete() { let mut report = report("example.com"); report.services.push(ssh_service( 22, None, - BTreeMap::from([ - ("ssh_analysis_status".to_owned(), "indeterminate".to_owned()), - ("ssh_kex".to_owned(), "stale-kex".to_owned()), - ( - "ssh_host_key_algorithm".to_owned(), - "stale-host-key".to_owned(), - ), - ("ssh_cipher_c2s".to_owned(), "stale-cipher".to_owned()), - ("ssh_mac_c2s".to_owned(), "stale-mac".to_owned()), - ]), + Some(SshPosture { + identification: None, + outcome: SshPostureOutcome::Partial { + selections: PartialSshAlgorithmSelections { + kex: Some("partial-kex".to_owned()), + host_key: Some("partial-host-key".to_owned()), + ..PartialSshAlgorithmSelections::default() + }, + reason: "no common cipher".to_owned(), + }, + }), )); for rendered in [render_terminal(&report), render_html(&report)] { - assert!(rendered.contains("No inferred selections available.")); - assert!(!rendered.contains("stale-")); - assert!(!rendered.contains("KEXINIT")); + assert!(rendered.contains("partial-kex")); + assert!(rendered.contains("partial-host-key")); + assert!(rendered.contains("no common cipher")); } } @@ -1516,6 +1529,7 @@ mod tests { confidence: DetectionConfidence::Medium, banner: None, protocol_details: BTreeMap::new(), + ssh: None, }); let mut value = serde_json::to_value(report).unwrap_or_else(|error| panic!("{error}")); value["schema_version"] = serde_json::json!("0.2.0"); diff --git a/crates/surface-report/src/projection.rs b/crates/surface-report/src/projection.rs new file mode 100644 index 0000000..e41144a --- /dev/null +++ b/crates/surface-report/src/projection.rs @@ -0,0 +1,144 @@ +//! Shared semantic projections for report renderers. + +use surface_core::{ + HostObservation, PartialSshAlgorithmSelections, PortState, ScanReport, ScanStatus, + ServiceObservation, SshAlgorithmSelections, SshPostureOutcome, +}; + +// Rust guideline compliant 2026-02-21 + +#[derive(Debug)] +pub(crate) struct SshProjection<'a> { + pub(crate) protocol: Option<&'a str>, + pub(crate) software: Option<&'a str>, + pub(crate) status: &'static str, + pub(crate) selections: PartialSshAlgorithmSelectionsRef<'a>, + pub(crate) reason: Option<&'a str>, +} + +#[derive(Debug, Default)] +pub(crate) struct PartialSshAlgorithmSelectionsRef<'a> { + pub(crate) kex: Option<&'a str>, + pub(crate) host_key: Option<&'a str>, + pub(crate) cipher_c2s: Option<&'a str>, + pub(crate) cipher_s2c: Option<&'a str>, + pub(crate) mac_c2s: Option<&'a str>, + pub(crate) mac_s2c: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct LifecycleProjection { + pub(crate) status: &'static str, + pub(crate) successful: bool, + pub(crate) partial: bool, + pub(crate) errors: usize, + pub(crate) score_incomplete: bool, +} + +#[derive(Debug)] +pub(crate) struct PortProjection { + pub(crate) number: u16, + pub(crate) transport: &'static str, + pub(crate) state: &'static str, + pub(crate) service: String, +} + +pub(crate) fn ports(report: &ScanReport, host: &HostObservation) -> Vec { + host.ports + .iter() + .filter_map(|port| { + let service = report.services.iter().find(|service| { + service.address == port.address && service.transport == port.transport + }); + if port.state != PortState::Open + && !(port.state == PortState::OpenFiltered && service.is_some()) + { + return None; + } + Some(PortProjection { + number: port.address.port(), + transport: port.transport.as_str(), + state: if port.state == PortState::Open { + "open" + } else { + "open|filtered" + }, + service: service + .map_or_else(|| "unknown".to_owned(), |value| value.service.to_string()), + }) + }) + .collect() +} + +pub(crate) fn lifecycle(report: &ScanReport) -> LifecycleProjection { + LifecycleProjection { + status: match report.status { + ScanStatus::NotStarted => "not_started", + ScanStatus::Completed => "completed", + ScanStatus::Partial => "partial", + ScanStatus::Interrupted => "interrupted", + ScanStatus::Failed => "failed", + }, + successful: report.status == ScanStatus::Completed, + partial: report.status == ScanStatus::Partial, + errors: report.errors.len(), + score_incomplete: report + .exposure_score + .as_ref() + .is_some_and(|score| score.incomplete), + } +} + +impl<'a> From<&'a SshAlgorithmSelections> for PartialSshAlgorithmSelectionsRef<'a> { + fn from(value: &'a SshAlgorithmSelections) -> Self { + Self { + kex: Some(&value.kex), + host_key: Some(&value.host_key), + cipher_c2s: Some(&value.cipher_c2s), + cipher_s2c: Some(&value.cipher_s2c), + mac_c2s: Some(&value.mac_c2s), + mac_s2c: Some(&value.mac_s2c), + } + } +} + +impl<'a> From<&'a PartialSshAlgorithmSelections> for PartialSshAlgorithmSelectionsRef<'a> { + fn from(value: &'a PartialSshAlgorithmSelections) -> Self { + Self { + kex: value.kex.as_deref(), + host_key: value.host_key.as_deref(), + cipher_c2s: value.cipher_c2s.as_deref(), + cipher_s2c: value.cipher_s2c.as_deref(), + mac_c2s: value.mac_c2s.as_deref(), + mac_s2c: value.mac_s2c.as_deref(), + } + } +} + +pub(crate) fn ssh(service: &ServiceObservation) -> Option> { + let posture = service.ssh.as_ref()?; + let (status, selections, reason) = match &posture.outcome { + SshPostureOutcome::Complete { selections } => ("complete", selections.into(), None), + SshPostureOutcome::Partial { selections, reason } => { + ("partial", selections.into(), Some(reason.as_str())) + } + SshPostureOutcome::Indeterminate { reason } => ( + "indeterminate", + PartialSshAlgorithmSelectionsRef::default(), + Some(reason.as_str()), + ), + }; + Some(SshProjection { + protocol: posture + .identification + .as_ref() + .map(|identification| identification.protocol.as_str()), + software: posture + .identification + .as_ref() + .map(|identification| identification.software.as_str()), + status, + selections, + reason, + }) +} diff --git a/docs/report-schema.md b/docs/report-schema.md index 33b7a4e..73629d2 100644 --- a/docs/report-schema.md +++ b/docs/report-schema.md @@ -1,6 +1,6 @@ # Report schema -Surface JSON reports use schema version `0.3.0`. Reports from `0.1.x` and `0.2.0` remain readable. Version `0.3.0` adds UDP selections and transport-aware port and service observations. +Surface JSON reports use schema version `0.4.0`. Older reports remain readable, but semantic diffs reject comparisons across the `0.3.0` to `0.4.0` SSH schema change. Version `0.3.0` added UDP selections and transport-aware port and service observations; version `0.4.0` adds typed SSH posture and dedicated preflight/configuration lifecycle values. Top-level fields: @@ -32,9 +32,9 @@ Top-level fields: `dns.wildcard_dns` is additive and optional for older reports. New scans report `detected`, `not_detected`, `indeterminate`, or `not_applicable`, the number of probes attempted, selected answer types, SHA-256 answer fingerprints, bounded diagnostics, and `probe_answers_scanned: false`. Hostname scans use exactly two UUID-v4 child names and query only selected A/AAAA plus CNAME records under the request timeout, cancellation, and caller's absolute deadline. Probe names and raw answers are never retained or used as downstream targets. Only identical non-empty answer sets are `detected`; this produces the high-confidence informational finding `DNS-WILDCARD-DETECTED`, which is contextual routing evidence and not automatically a vulnerability. -SSH posture data uses the existing deterministic `services[].protocol_details` map; no endpoint or parallel top-level model is added. Stable keys are `ssh_protocol`, `ssh_software`, `ssh_kex`, `ssh_host_key_algorithm`, `ssh_cipher_c2s`, `ssh_cipher_s2c`, `ssh_mac_c2s`, `ssh_mac_s2c`, `ssh_analysis_status`, and `ssh_skip_reason`. `ssh_analysis_status=complete_inferred` means all required RFC 4253 client-first intersections, including both compression directions, existed; the named KEX, host-key, cipher, and MAC values are inferred selections before completed key exchange, not completed cryptographic negotiation or evidence of server preference. `indeterminate` always carries a bounded stable reason. Surface attempts one reconnect per deduplicated TCP `SocketAddr` already classified SSH, never adds an address or service, and runs this work only with the Services selection. An exact selected `diffie-hellman-group14-sha1`, `diffie-hellman-group1-sha1`, `ssh-rsa`, `ssh-dss`, `3des-cbc`, `arcfour`, `arcfour128`, `arcfour256`, `hmac-sha1`, `hmac-md5`, or `hmac-md5-96` value produces the Medium/medium-confidence `NET-SSH-LEGACY-ALGORITHM` only when status is `complete_inferred`; advertisement-only, banner/version, no-common, and indeterminate evidence cannot produce it or a CVE claim. Caller-deadline expiry makes the scan partial and cancellation makes it interrupted when an applicable SSH attempt is in progress; ordinary no-SSH scans add no skipped check. +SSH posture data uses `services[].ssh`; legacy SSH keys in `services[].protocol_details` are not interpreted. Optional `identification` contains sanitized `protocol` and `software`. The internally tagged `outcome.status` is `complete`, `partial`, or `indeterminate`. A complete outcome contains all six inferred selections: `kex`, `host_key`, `cipher_c2s`, `cipher_s2c`, `mac_c2s`, and `mac_s2c`. A partial outcome contains optional versions of those selections plus a bounded stable `reason`; indeterminate contains only a reason. Complete means all required RFC 4253 client-first intersections, including both compression directions, existed. Selections are inferred before completed key exchange, not completed cryptographic negotiation or evidence of server preference. Surface attempts one reconnect per deduplicated TCP `SocketAddr` already classified SSH, never adds an address or service, and runs this work only with the Services selection. An exact selected `diffie-hellman-group14-sha1`, `diffie-hellman-group1-sha1`, `ssh-rsa`, `ssh-dss`, `3des-cbc`, `arcfour`, `arcfour128`, `arcfour256`, `hmac-sha1`, `hmac-md5`, or `hmac-md5-96` value produces the Medium/medium-confidence `NET-SSH-LEGACY-ALGORITHM` only for a complete outcome; advertisement-only, banner/version, partial, and indeterminate evidence cannot produce it or a CVE claim. Caller-deadline expiry makes the scan partial and cancellation makes it interrupted when an applicable SSH attempt is in progress; ordinary no-SSH scans add no skipped check. -Every pre-identification line and the SSH-2.0 or SSH-1.99 identification line must end in CRLF and contain no embedded control characters. The identification limit is 255 bytes including CRLF. At most 50 pre-identification lines and 4,096 cumulative pre-identification bytes are accepted; those byte totals include each line's CRLF and exclude the identification line. The one parsed server packet has an RFC 4253 total-size limit of 35,000 bytes: its four-byte length field is followed by at most 34,996 bytes. It requires padding of at least four bytes and an exact KEXINIT payload. Each of its ten name-lists is limited to 8 KiB and 128 nonempty names; each algorithm name is at most 64 printable ASCII bytes excluding commas and permits at most one syntactically valid `@domain` suffix. Malformed, oversized, truncated, incompatible, per-request timed-out, or cancelled exchanges retain no raw binary and remain indeterminate. Software/comments are sanitized. Terminal and HTML presentation caps every imported SSH detail key/value and banner at 255 Unicode scalar values; terminal controls are replaced or removed and every HTML value is escaped. The terminal/HTML SSH section is omitted unless `ssh_protocol` or `ssh_analysis_status` exists. Surface sends one fixed SSH-2.0 identification and one unencrypted KEXINIT with a UUID-v4 cookie, then closes immediately after parsing and intersection; it performs no key exchange, NEWKEYS, host-key retrieval, authentication, agent, channel, subsystem, or command operation. +Every pre-identification line and the SSH-2.0 or SSH-1.99 identification line must end in CRLF and contain no embedded control characters. The identification limit is 255 bytes including CRLF. At most 50 pre-identification lines and 4,096 cumulative pre-identification bytes are accepted; those byte totals include each line's CRLF and exclude the identification line. The one parsed server packet has an RFC 4253 total-size limit of 35,000 bytes: its four-byte length field is followed by at most 34,996 bytes. It requires padding of at least four bytes and an exact KEXINIT payload. Each of its ten name-lists is limited to 8 KiB and 128 nonempty names; each algorithm name is at most 64 printable ASCII bytes excluding commas and permits at most one syntactically valid `@domain` suffix. Malformed, oversized, truncated, incompatible, per-request timed-out, or cancelled exchanges retain no raw binary and remain indeterminate. Software/comments are sanitized. Terminal and HTML presentation caps every imported SSH value and banner at 255 Unicode scalar values; terminal controls are replaced or removed and every HTML value is escaped. The SSH section is omitted unless typed posture exists. Surface sends one fixed SSH-2.0 identification and one unencrypted KEXINIT with a UUID-v4 cookie, then closes immediately after parsing and intersection; it performs no key exchange, NEWKEYS, host-key retrieval, authentication, agent, channel, subsystem, or command operation. `tls` observations add `cipher_suite`, `certificate_chain_length`, `leaf_certificate_sha256`, `public_key_bits`, and `subject_alt_names_truncated`. These fields default to `null`, `null`, `null`, `null`, and `false` when older JSON is read. Successful handshakes populate negotiated fields and validated certificate evidence. When the normal WebPKI verifier rejects a presented certificate, `handshake_succeeded` remains `false`, `certificate_trusted` and `hostname_matches` remain `null`, and the same bounded parsed certificate fields may still be populated; the validation error remains generic and sanitized. `leaf_certificate_sha256` is exactly 64 lowercase hexadecimal characters over the leaf DER; `public_key_bits` is present only when x509-parser reports a nonzero size. Surface records the peer-supplied chain length but inspects at most the first 16 certificates and at most 1,048,576 cumulative DER bytes. It retains at most 128 unique DNS SANs normalized to lowercase without a trailing root dot and canonical textual IPv4/IPv6 SANs. `subject_alt_names_truncated` is true when another unique supported SAN is omitted. Bound limitations appear in the observation's `errors`; certificate DER is dropped after parsing and never serialized, retained, or logged. Existing subject, issuer, serial, validity, public-key OID, and signature OID meanings are unchanged. Exact direct fields can additionally produce `TLS-CERT-WEAK-RSA-KEY`, `TLS-CERT-SHA1-SIGNATURE`, or `TLS-OBSOLETE-PROTOCOL`; all three are Medium/high-confidence and follow the existing severity-based exposure deduction. Unknown, zero, non-RSA, near-match OID, and nonexact protocol values produce none of these findings. The obsolete-protocol rule recognizes only `TLSv1_0` and `TLSv1_1`; current rustls negotiation remains TLS 1.2/1.3. No broad cipher finding is generated. @@ -42,9 +42,9 @@ No field contains ANSI escape sequences. Target-controlled banners, headers, bod Optional SQLite history stores this complete JSON unchanged alongside normalized query metadata. Retrieval returns the original report schema version; persistence does not reinterpret or update historical content. -`surface diff` accepts compatible `0.1.x` reports. It compares typed network, service, certificate, DNS/mail, finding, score, and completeness data while ignoring execution IDs, timestamps, latency, and transient error text. Reports with incompatible schema or score-model versions produce explicit warnings rather than fabricated comparisons. +`surface diff` accepts reports within either the legacy SSH-map schema family (`0.1.0` through `0.3.0`) or the typed-SSH family (`0.4.0`). It compares typed network, service, certificate, DNS/mail, finding, score, and completeness data while ignoring execution IDs, timestamps, latency, and transient error text. Reports from different families or with incompatible score-model versions produce explicit warnings rather than fabricated comparisons. -SARIF 2.1.0 and CycloneDX 1.6 are integration projections, not replacements for the complete Surface JSON report. CycloneDX includes SSH only through the existing generic `surface:protocol:*` service properties. SARIF remains finding-only; neither export invents banner-mapped CVEs or unsupported SSH vulnerabilities. Unknown observations and partial status remain represented conservatively. +SARIF 2.1.0 and CycloneDX 1.6 are integration projections, not replacements for the complete Surface JSON report. Both expose the exact scan lifecycle status. CycloneDX exports typed SSH evidence through stable `surface:ssh:*` properties; generic non-SSH protocol metadata remains under `surface:protocol:*`. SARIF remains finding-only; neither export invents banner-mapped CVEs or unsupported SSH vulnerabilities. Unknown observations and incomplete outcomes remain represented conservatively. `intelligence.network_registrations` is additive and defaults to an empty list in older reports. Each entry records one canonical address already present in `dns.resolved_hosts`, stable source `RDAP`, the IANA-bootstrap-selected regional registry, bounded handle/name/network type/status values, optional coherent start/end addresses, and `registration_country`. The country field is registration metadata, not geolocation. The returned range is retained only when both bounds share the queried address family, are ordered, and contain the queried address. Entities, contacts, remarks, notices, links, events, and raw bodies are never retained. RDAP evidence is administrative allocation data; it does not establish current BGP origin, ownership, operation, routing, or location.