packages feed

salmon-apps-0.2.0.0: src/Report/Nmap.hs

{-# LANGUAGE OverloadedStrings #-}

{- | The pure half of @salmon-report@'s @nmap@ probe: a declared target read
into a host and a port list, the argument vector handed to @nmap@, and what
its grepable output (@-oG -@) says about each port. No IO here; "Report"
holds the shell-out.

What is scanned is exactly what the operator declared with @--nmap
HOST:PORTS@: one host (a name or one address, never a range, a mask or a
list) and an explicit, bounded port list. There is no default target and no
default port list, and nothing a declaration says reaches @nmap@ as an
option: the host is checked against a strict alphabet and the ports are
re-rendered from the parsed numbers.
-}
module Report.Nmap (
    -- * Declared targets
    NmapTarget (..),
    parseNmapTarget,
    checkHost,
    parsePorts,
    renderPorts,
    maxPorts,
    nmapArgs,

    -- * Grepable output
    PortState (..),
    NmapScan (..),
    parseNmapGrepable,
    portState,
    stateText,
) where

import Data.Char (isAlpha, isAlphaNum, isDigit, isHexDigit)
import Data.List (nub, sort)
import qualified Data.Map.Strict as Map
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import Text.Read (readMaybe)

-------------------------------------------------------------------------------
-- Declared targets

-- | One host and the TCP ports to look at, sorted and without duplicates.
data NmapTarget = NmapTarget
    { targetHost :: Text
    , targetPorts :: [Int]
    }
    deriving (Eq, Ord, Show)

-- | The most ports one target may declare: a report looks at named ports, it does not sweep.
maxPorts :: Int
maxPorts = 128

{- | Reads @HOST:PORTS@ (the last colon splits, so an IPv6 address goes in
brackets). The host is a DNS name, an IPv4 address or an IPv6 address; the
forms @nmap@ would expand into several hosts (@10.0.0.0\/24@, @10.0.0.1-9@,
@10.0.0.*@, a list) and anything starting with a dash are refused with a
reason.
-}
parseNmapTarget :: Text -> Either Text NmapTarget
parseNmapTarget raw = do
    let (h, p) = Text.breakOnEnd ":" (Text.strip raw)
    bracketed <- maybe (Left "not of the form HOST:PORTS") Right (Text.stripSuffix ":" h)
    host <- checkHost (Text.dropAround (`elem` ("[]" :: String)) bracketed)
    ports <- parsePorts p
    pure (NmapTarget host ports)

-- | One DNS name, IPv4 address or IPv6 address, from a strict alphabet; else the reason.
checkHost :: Text -> Either Text Text
checkHost h
    | Text.null h = Left "no host before the colon"
    | "-" `Text.isPrefixOf` h = Left "a host cannot start with a dash"
    | Text.any (== ':') h =
        if Text.all (\c -> isHexDigit c || c == ':' || c == '.') h
            then Right h
            else Left "not an IPv6 address"
    | not (Text.all (\c -> (isAlphaNum c && c < '\x80') || c `elem` ("._-" :: String)) h) =
        Left "a host is one DNS name or one address: no mask, list or wildcard"
    | Text.any isAlpha h = Right h
    | isIpv4 h = Right h
    | otherwise = Left "a host is one DNS name or one address: no address range"
  where
    isIpv4 t = case Text.splitOn "." t of
        os@[_, _, _, _] -> all octet os
        _ -> False
    octet o = not (Text.null o) && Text.all isDigit o && Text.length o <= 3 && maybe False (<= (255 :: Int)) (readMaybe (Text.unpack o))

{- | Reads a port list such as @22,80,8000-8010@ into sorted, distinct port
numbers; at most 'maxPorts' of them.
-}
parsePorts :: Text -> Either Text [Int]
parsePorts t = do
    parts <- traverse part (Text.splitOn "," (Text.strip t))
    let ports = nub (sort (concat parts))
    if length ports > maxPorts
        then Left ("more than " <> Text.pack (show maxPorts) <> " ports declared for one target")
        else Right ports
  where
    part x = case Text.splitOn "-" (Text.strip x) of
        [a] -> (: []) <$> port a
        [a, b] -> do
            lo <- port a
            hi <- port b
            if lo > hi
                then Left ("the port range " <> x <> " is backwards")
                else -- bounded before it is built: the cap is checked again on the whole list
                    if hi - lo >= maxPorts
                        then Left ("more than " <> Text.pack (show maxPorts) <> " ports declared for one target")
                        else Right [lo .. hi]
        _ -> Left ("not a port or a port range: " <> x)
    port x
        | not (Text.null x) && Text.all isDigit x && Text.length x <= 5
        , Just n <- readMaybe (Text.unpack x)
        , n >= 1 && n <= 65535 =
            Right n
        | otherwise = Left ("not a port between 1 and 65535: " <> if Text.null x then "(empty)" else x)

-- | The port list as @nmap -p@ takes it, with runs folded back into ranges.
renderPorts :: [Int] -> Text
renderPorts = Text.intercalate "," . fmap one . runs
  where
    runs [] = []
    runs (x : xs) = go x x xs
    go lo hi (y : ys) | y == hi + 1 = go lo y ys
    go lo hi rest = (lo, hi) : runs rest
    one (lo, hi)
        | lo == hi = Text.pack (show lo)
        | otherwise = Text.pack (show lo) <> "-" <> Text.pack (show hi)

{- | The arguments of the one scan a target gets: a TCP connect scan (no raw
sockets, so no privilege), no host discovery (@-Pn@: nothing but the declared
ports is touched, and a host that is away shows as filtered ports), no
reverse lookup, grepable output on stdout.
-}
nmapArgs :: NmapTarget -> [String]
nmapArgs t =
    ["-6" | Text.any (== ':') (targetHost t)]
        <> ["-sT", "-Pn", "-n", "-oG", "-", "-p", Text.unpack (renderPorts (targetPorts t)), Text.unpack (targetHost t)]

-------------------------------------------------------------------------------
-- Grepable output

data PortState
    = PortOpen
    | PortClosed
    | -- | nothing answered: a firewall dropped the attempt, or the host is away
      PortFiltered
    | -- | a state a connect scan does not normally give, as printed
      PortOther Text
    deriving (Eq, Show)

stateText :: PortState -> Text
stateText PortOpen = "open"
stateText PortClosed = "closed"
stateText PortFiltered = "filtered"
stateText (PortOther t) = t

readState :: Text -> PortState
readState "open" = PortOpen
readState "closed" = PortClosed
readState "filtered" = PortFiltered
readState t = PortOther t

{- | What one host's @Host:@ lines said. @nmap@ leaves out of the list the
ports of a state shared by many of them and prints the count instead
(@Ignored State: closed (100)@): those are 'scanIgnored'.
-}
data NmapScan = NmapScan
    { scanAddress :: Text
    , scanPorts :: Map.Map Int PortState
    -- ^ the TCP ports listed
    , scanIgnored :: [(PortState, Int)]
    }
    deriving (Eq, Show)

{- | Reads @nmap -oG -@ output for a single host. 'Nothing' when no @Host:@
line was printed (the name did not resolve, or nmap refused to start).
-}
parseNmapGrepable :: Text -> Maybe NmapScan
parseNmapGrepable out = case mapMaybe hostLine (Text.lines out) of
    [] -> Nothing
    ls@((addr, _, _) : _) ->
        -- one target is one host; lines about another address would be a surprise and are left out
        let mine = [(ps, ig) | (a, ps, ig) <- ls, a == addr]
         in Just (NmapScan addr (Map.fromList (concatMap fst mine)) (concatMap snd mine))
  where
    hostLine l = do
        rest <- Text.stripPrefix "Host: " l
        let fields = Text.splitOn "\t" rest
        addr <- case fields of
            (f : _) | (a : _) <- Text.words f -> Just a
            _ -> Nothing
        let ports = concat [mapMaybe portEntry (Text.splitOn "," v) | f <- fields, Just v <- [Text.stripPrefix "Ports:" f]]
            ignored = concat [mapMaybe ignoredEntry (Text.splitOn "," v) | f <- fields, Just v <- [Text.stripPrefix "Ignored State:" f]]
        pure (addr, ports, ignored)
    -- 22/open/tcp//ssh///
    portEntry e = case Text.splitOn "/" (Text.strip e) of
        (p : st : "tcp" : _) | Just n <- readMaybe (Text.unpack p) -> Just (n, readState st)
        _ -> Nothing
    -- closed (100)
    ignoredEntry e = case Text.words (Text.strip e) of
        [st, n] | Just k <- readMaybe (Text.unpack (Text.dropAround (`elem` ("()" :: String)) n)) -> Just (readState st, k)
        _ -> Nothing

{- | The state of one scanned port: as listed, else the one state the
unlisted ports share. With no such single state the output does not say, and
the reason is given.
-}
portState :: NmapScan -> Int -> Either Text PortState
portState scan p = case Map.lookup p (scanPorts scan) of
    Just st -> Right st
    Nothing -> case scanIgnored scan of
        [(st, _)] -> Right st
        [] -> Left "nmap printed no state for this port"
        several -> Left ("nmap did not list this port; the unlisted ones are " <> Text.intercalate ", " [stateText st <> " (" <> Text.pack (show n) <> ")" | (st, n) <- several])