nessus-parser 0.3.0

A parser for `.nessus` (v2) XML reports
Documentation
//! Parse and normalize `plugin_output` text from ping-related Nessus finding.

use crate::{MacAddress, error::FormatError};

/// Structured interpretation of the `plugin_output` text for
/// Nessus plugin `10180` ("Ping the remote host").
#[derive(Debug, Clone, Copy)]
pub enum PingOutcome {
    /// The host was marked dead after no response to one or more ping methods.
    HostDidntRespondToPingMethods { tcp: bool, icmp: bool },
    /// The host replied to an ICMP echo packet.
    IcmpEchoPacket,
    /// The host is local-network reachable via ARP checks but did not answer ARP who-is.
    HostLocalNetworkDidntRespondArpWhoIsQuery,
    /// The host replied to an ARP who-is query with the given MAC address.
    ArpWhoIsQuery(MacAddress),
    /// The host replied with ICMP unreachable to a TCP SYN probe sent to `to`.
    IcmpUnreachPacketInResponseToTcpSynPacket { to: u16 },
    /// The host replied to a TCP SYN probe with `RST,ACK`.
    RepliedTcpSynPacketWithRstAck { to: u16 },
    /// The host replied to a TCP SYN probe with `SYN,ACK`.
    RepliedTcpSynPacketWithSynAck { to: u16 },
    /// The host replied to a TCP SYN probe with `FIN,ACK`.
    RepliedTcpSynPacketWithFinAck { to: u16 },
    /// The host emitted a TCP SYN packet.
    EmittedTcpSynPacket { from: u16, to: u16 },
    /// The host emitted a TCP ACK packet.
    EmittedTcpAckPacket { from: u16, to: u16 },
    /// The host emitted a TCP PUSH,ACK packet.
    EmittedTcpPushAckPacket { from: u16, to: u16 },
    /// The host replied with a generic ICMP unreachable packet.
    IcmpUnreachPacket,
    /// The host emitted a UDP packet.
    EmittedUdpPacket { from: u16, to: u16 },
    /// The target host is the same machine as the Nessus scanner.
    LocalScanner,
}

impl PingOutcome {
    #[allow(clippy::too_many_lines)]
    pub(crate) fn from_plugin_output(plugin_output: &str) -> Result<Self, FormatError> {
        const LOCAL_SCANNER: &str = "The remote host is up\nThe host is the local scanner.";
        const ICMP_ECHO_PACKET: &str =
            "The remote host is up\nThe remote host replied to an ICMP echo packet";
        const ICMP_UNREACH_PACKET: &str =
            "The remote host is up\nThe remote host replied with an ICMP unreach packet.";
        const ARP_WHO_IS_QUERY_PREFIX: &str =
            "The remote host is up\nThe host replied to an ARP who-is query.\nHardware address : ";
        const REPLIED_TCP_SYN_PACKET_PREFIX: &str =
            "The remote host is up\nThe remote host replied to a TCP SYN packet sent to port ";
        const WITH_RST_ACK_SUFFIX: &str = " with a RST,ACK packet";
        const WITH_SYN_ACK_SUFFIX: &str = " with a SYN,ACK packet";
        const WITH_FIN_ACK_SUFFIX: &str = " with a FIN,ACK packet";
        const ICMP_UNREACH_PACKET_RESPONSE_TO_TCP_SYN_PACKET_PREFIX: &str = "The remote host is up\nThe remote host replied with an ICMP unreach packet sent in response to a TCP SYN packet sent to port ";
        const EMITTED_UDP_PACKET_PREFIX: &str =
            "The remote host is up\nThe remote host emited a UDP packet from port ";
        const EMITTED_PACKET_MIDDLE: &str = "going to port ";
        const EMITTED_TCP_SYN_PACKET_PREFIX: &str =
            "The remote host is up\nThe remote host emitted a TCP SYN packet from port ";
        const EMITTED_TCP_ACK_PACKET_PREFIX: &str =
            "The remote host is up\nThe remote host emitted a TCP ACK packet from port ";
        const EMITTED_TCP_PUSH_ACK_PACKET_PREFIX: &str =
            "The remote host is up\nThe remote host emitted a TCP PUSH,ACK packet from port ";
        const DEAD_HOST_NEEDLE: &str = ") is considered as dead - not scanning\nThe remote host (";
        const FAILED_TO_REPLY_ARP_SUFFIX: &str =
            ") is on the local network and failed to reply to an ARP who-is query.";
        const DIDNT_RESPOND_TO_PING_METHODS_NEEDLE: &str =
            ") did not respond to the following ping methods :\n";

        match plugin_output {
            ICMP_ECHO_PACKET => Ok(Self::IcmpEchoPacket),
            LOCAL_SCANNER => Ok(Self::LocalScanner),
            ICMP_UNREACH_PACKET => Ok(Self::IcmpUnreachPacket),
            text => {
                if text.contains(DEAD_HOST_NEEDLE) {
                    if text.ends_with(FAILED_TO_REPLY_ARP_SUFFIX) {
                        Ok(Self::HostLocalNetworkDidntRespondArpWhoIsQuery)
                    } else if let Some((_, ping_methods)) =
                        text.split_once(DIDNT_RESPOND_TO_PING_METHODS_NEEDLE)
                    {
                        let mut tcp = false;
                        let mut icmp = false;
                        for ping_method in ping_methods.trim_end().split('\n') {
                            match ping_method {
                                "- TCP ping" => tcp = true,
                                "- ICMP ping" => icmp = true,
                                _ => {
                                    return Err(FormatError::UnexpectedPingMethod(
                                        plugin_output.into(),
                                    ));
                                }
                            }
                        }
                        if !tcp && !icmp {
                            Err(FormatError::UnexpectedPingFormat(plugin_output.into()))
                        } else {
                            Ok(Self::HostDidntRespondToPingMethods { tcp, icmp })
                        }
                    } else {
                        Err(FormatError::UnexpectedDeadHostReason(plugin_output.into()))
                    }
                } else if let Some(mac) = text.strip_prefix(ARP_WHO_IS_QUERY_PREFIX) {
                    Ok(Self::ArpWhoIsQuery(mac.parse()?))
                } else if let Some(port) =
                    text.strip_prefix(ICMP_UNREACH_PACKET_RESPONSE_TO_TCP_SYN_PACKET_PREFIX)
                {
                    Ok(Self::IcmpUnreachPacketInResponseToTcpSynPacket { to: port.parse()? })
                } else if let Some(port_and_suffix) =
                    text.strip_prefix(REPLIED_TCP_SYN_PACKET_PREFIX)
                {
                    if let Some(port) = port_and_suffix.strip_suffix(WITH_RST_ACK_SUFFIX) {
                        Ok(Self::RepliedTcpSynPacketWithRstAck { to: port.parse()? })
                    } else if let Some(port) = port_and_suffix.strip_suffix(WITH_SYN_ACK_SUFFIX) {
                        Ok(Self::RepliedTcpSynPacketWithSynAck { to: port.parse()? })
                    } else if let Some(port) = port_and_suffix.strip_suffix(WITH_FIN_ACK_SUFFIX) {
                        Ok(Self::RepliedTcpSynPacketWithFinAck { to: port.parse()? })
                    } else {
                        Err(FormatError::UnexpectedPingTcpResponse(plugin_output.into()))
                    }
                } else if let Some(from_port_and_rest) =
                    text.strip_prefix(EMITTED_TCP_SYN_PACKET_PREFIX)
                    && let Some((from_port, rest)) = from_port_and_rest.split_once(' ')
                    && let Some(to_port) = rest.strip_prefix(EMITTED_PACKET_MIDDLE)
                {
                    Ok(Self::EmittedTcpSynPacket {
                        from: from_port.parse()?,
                        to: to_port.parse()?,
                    })
                // TODO: DRY
                } else if let Some(from_port_and_rest) =
                    text.strip_prefix(EMITTED_TCP_ACK_PACKET_PREFIX)
                    && let Some((from_port, rest)) = from_port_and_rest.split_once(' ')
                    && let Some(to_port) = rest.strip_prefix(EMITTED_PACKET_MIDDLE)
                {
                    Ok(Self::EmittedTcpAckPacket {
                        from: from_port.parse()?,
                        to: to_port.parse()?,
                    })
                } else if let Some(from_port_and_rest) =
                    text.strip_prefix(EMITTED_TCP_PUSH_ACK_PACKET_PREFIX)
                    && let Some((from_port, rest)) = from_port_and_rest.split_once(' ')
                    && let Some(to_port) = rest.strip_prefix(EMITTED_PACKET_MIDDLE)
                {
                    Ok(Self::EmittedTcpPushAckPacket {
                        from: from_port.parse()?,
                        to: to_port.parse()?,
                    })
                } else if let Some(from_port_and_rest) =
                    text.strip_prefix(EMITTED_UDP_PACKET_PREFIX)
                    && let Some((from_port, rest)) = from_port_and_rest.split_once(' ')
                    && let Some(to_port) = rest.strip_prefix(EMITTED_PACKET_MIDDLE)
                {
                    Ok(Self::EmittedUdpPacket {
                        from: from_port.parse()?,
                        to: to_port.parse()?,
                    })
                } else {
                    Err(FormatError::UnexpectedPingFormat(plugin_output.into()))
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::error::FormatError;

    use super::PingOutcome;

    #[test]
    fn parses_common_ping_outcomes() {
        let icmp_echo = "The remote host is up\nThe remote host replied to an ICMP echo packet";
        assert!(matches!(
            PingOutcome::from_plugin_output(icmp_echo).expect("must parse"),
            PingOutcome::IcmpEchoPacket
        ));

        let local = "The remote host is up\nThe host is the local scanner.";
        assert!(matches!(
            PingOutcome::from_plugin_output(local).expect("must parse"),
            PingOutcome::LocalScanner
        ));

        let arp = "The remote host is up\nThe host replied to an ARP who-is query.\nHardware address : 0a:1b:2c:3d:4e:5f";
        assert!(matches!(
            PingOutcome::from_plugin_output(arp).expect("must parse"),
            PingOutcome::ArpWhoIsQuery(_)
        ));
    }

    #[test]
    fn parses_tcp_and_udp_based_outcomes() {
        let syn_ack = "The remote host is up\nThe remote host replied to a TCP SYN packet sent to port 443 with a SYN,ACK packet";
        assert!(matches!(
            PingOutcome::from_plugin_output(syn_ack).expect("must parse"),
            PingOutcome::RepliedTcpSynPacketWithSynAck { to: 443 }
        ));

        let emitted_udp = "The remote host is up\nThe remote host emited a UDP packet from port 68 going to port 67";
        assert!(matches!(
            PingOutcome::from_plugin_output(emitted_udp).expect("must parse"),
            PingOutcome::EmittedUdpPacket { from: 68, to: 67 }
        ));
    }

    #[test]
    fn parses_dead_host_ping_methods() {
        let text = "The remote host (1.2.3.4) is considered as dead - not scanning\nThe remote host (1.2.3.4) did not respond to the following ping methods :\n- TCP ping\n- ICMP ping";
        assert!(matches!(
            PingOutcome::from_plugin_output(text).expect("must parse"),
            PingOutcome::HostDidntRespondToPingMethods {
                tcp: true,
                icmp: true
            }
        ));
    }

    #[test]
    fn rejects_unexpected_ping_method_lines() {
        let text = "The remote host (1.2.3.4) is considered as dead - not scanning\nThe remote host (1.2.3.4) did not respond to the following ping methods :\n- UDP ping";
        let err = PingOutcome::from_plugin_output(text).expect_err("must fail");
        assert!(matches!(err, FormatError::UnexpectedPingMethod(_)));
    }

    #[test]
    fn rejects_unknown_dead_host_reason() {
        let text = "The remote host (1.2.3.4) is considered as dead - not scanning\nThe remote host (1.2.3.4) failed for an unknown reason.";
        let err = PingOutcome::from_plugin_output(text).expect_err("must fail");
        assert!(matches!(err, FormatError::UnexpectedDeadHostReason(_)));
    }

    #[test]
    fn rejects_unexpected_tcp_reply_suffix() {
        let text = "The remote host is up\nThe remote host replied to a TCP SYN packet sent to port 443 with a MAYBE packet";
        let err = PingOutcome::from_plugin_output(text).expect_err("must fail");
        assert!(matches!(err, FormatError::UnexpectedPingTcpResponse(_)));
    }

    #[test]
    fn rejects_unrecognized_ping_format() {
        let text = "completely unexpected";
        let err = PingOutcome::from_plugin_output(text).expect_err("must fail");
        assert!(matches!(err, FormatError::UnexpectedPingFormat(_)));
    }
}