packages feed

mail-reports-0.2.0.0: src/Data/Mail/TLSRPT/Reports.hs

{-# LANGUAGE OverloadedStrings, FlexibleContexts, RecordWildCards #-}

-- | Utilities for parsing an SMTP-TLRPT aggregated report, following RFC 8460. These functions also handle decompression (gzip or pkzip)

module Data.Mail.TLSRPT.Reports (
  -- * Parsers
  tlsReportFromStrict,
  tlsReportFromLazy,
  tlsReportFromStream,
  tlsReportFromJson,
  -- * Data types
  Report(..),
  Policy(..),
  PolicyDesc(..),
  FailureDetails(..),
  IpAddress,
  PolicySummary(..),
  PolicyType(..),
  ResultType(..)
  ) where

import Data.Mail.DMARC.Reports (uncompressStream)
import Streaming
import qualified Streaming.Prelude as S
import Data.Aeson
import Data.Aeson.Types
import Data.Time.Clock
import Data.Time.Format
import qualified Data.Text as T
import Text.Read (readMaybe)
import Data.Aeson.Encoding (string)
import qualified Streaming.ByteString as Q
import Control.Monad.Trans.Resource
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as L
import System.IO.Unsafe

type IpAddress = String

data PolicyType = NoPolicyFound -- ^ neither MTA-STS nor DANE
                | TlsaPolicy    -- ^ DANE policy (TLSA record)
                | StsPolicy     -- ^ MTA-STS policy
                deriving (Eq)

instance Show PolicyType where
  show NoPolicyFound = "no-policy-found"
  show TlsaPolicy = "tlsa"
  show StsPolicy = "sts"

instance Read PolicyType where
  readsPrec _ r =
    [(NoPolicyFound,s) | ("no-policy-found",s) <- lex' r]
    ++[(TlsaPolicy,s) | ("tlsa",s) <- lex' r]
    ++[(StsPolicy,s) | ("sts",s) <- lex' r]

instance ToJSON PolicyType where
  toJSON = toJSONShow
  toEncoding = toEncodingShow

toJSONShow :: Show a => a -> Value
toJSONShow = String . T.pack . show

toEncodingShow :: Show a => a -> Encoding
toEncodingShow = string . show

instance FromJSON PolicyType where
  parseJSON = parseJSONRead "PolicyType"

parseJSONRead :: Read a => String -> Value -> Parser a
parseJSONRead desc = withText desc $ \t ->
  case readMaybe (T.unpack t) of
    Nothing -> typeMismatch desc (String t)
    Just a -> return a

-- | Description of the evaluated policy
data PolicyDesc = PolicyDesc {
  -- | Policy type (DANE or MTA-STS)
  pdPolicyType :: PolicyType,
  -- | Applied policy as strings
  pdPolicyString :: [T.Text],
  -- | domain (if punycode: A-labels, not U-labels)
  pdPolicyDomain :: T.Text,
  -- | if MTA-STS, then this is a list of mx host patterns
  pdMxHost :: [T.Text]
  } deriving (Eq, Show, Read)

instance ToJSON PolicyDesc where
  toJSON PolicyDesc{..} =
    object ["policy-type" .= pdPolicyType,
            "policy-string" .= pdPolicyString,
            "policy-domain" .= pdPolicyDomain,
            "mx-host" .= pdMxHost]
  toEncoding PolicyDesc{..} =
    pairs ("policy-type" .= pdPolicyType <>
           "policy-string" .= pdPolicyString <>
           "policy-domain" .= pdPolicyDomain <>
           "mx-host" .= pdMxHost)

instance FromJSON PolicyDesc where
  parseJSON = withObject "PolicyDesc" $
    \o -> PolicyDesc
          <$> o .: "policy-type"
          <*> o .:? "policy-string" .!= []
          <*> o .: "policy-domain"
          <*> o .:? "mx-host" .!= []

-- | Summary of policy evaluation
data PolicySummary = PolicySummary {
  -- | Successful sessions
  psTotalSuccessfulSessionCount :: Int,
  -- | Failed sessions
  psTotalFailureSessionCount :: Int
  } deriving (Eq, Show, Read)

instance ToJSON PolicySummary where
  toJSON PolicySummary{..} =
    object ["total-successful-session-count" .= psTotalSuccessfulSessionCount,
            "total-failure-session-count" .= psTotalFailureSessionCount]
  toEncoding PolicySummary{..} =
    pairs ("total-successful-session-count" .= psTotalSuccessfulSessionCount <>
           "total-failure-session-count" .= psTotalFailureSessionCount)

instance FromJSON PolicySummary where
  parseJSON = withObject "PolicySummary" $
    \o -> PolicySummary
          <$> o .: "total-successful-session-count"
          <*> o .: "total-failure-session-count"

-- | Failure types
data ResultType = StarttlsNotSupported -- ^ recipient MX did not support STARTTLS
                | CertificateHostMismatch -- ^ MX hostname does not match any of the subject alternative names in the certificate
                | CertificateExpired -- ^ certificate has expired
                | CertificateNotTrusted -- ^ certificate is not signed by any trusted CA
                | ValidationFailure -- ^ general negotiation failure (other)
                | TlsaInvalid -- ^ validation error in the TLSA record
                | DnssecInvalid -- ^ no valid records were returned from the recursive resolver
                | DaneRequired -- ^ sending system is configured to require DANE TLSA records for all the MX hosts of the destination domain, but no DNSSEC-validated TLSA records were present for the MX host
                | StsPolicyFetchError -- ^ failure to retrieve an MTA-STS policy (e.g. host unreachable)
                | StsPolicyInvalid -- ^ validation error for the overall MTA-STS policy
                | StsWebpkiInvalid -- ^ MTA-STS policy could not be authenticated using PKIX validation
                deriving (Eq)

instance Show ResultType where
  show StarttlsNotSupported = "starttls-not-supported"
  show CertificateHostMismatch = "certificate-host-mismatch"
  show CertificateExpired = "certificate-expired"
  show CertificateNotTrusted = "certificate-not-trusted"
  show ValidationFailure = "validation-failure"
  show TlsaInvalid = "tlsa-invalid"
  show DnssecInvalid = "dnssec-invalid"
  show DaneRequired = "dane-required"
  show StsPolicyFetchError = "sts-policy-fetch-error"
  show StsPolicyInvalid = "sts-policy-invalid"
  show StsWebpkiInvalid = "sts-webpki-invalid"

instance Read ResultType where
  readsPrec _ r =
    [(rt,s)
    | (w,s) <- lex' r,
      let rt = case w of
            "starttls-not-supported" -> StarttlsNotSupported
            "certificate-host-mismatch" -> CertificateHostMismatch
            "certificate-expired" -> CertificateExpired
            "certificate-not-trusted" -> CertificateNotTrusted
            "validation-failure" -> ValidationFailure
            "tlsa-invalid" -> TlsaInvalid
            "dnssec-invalid" -> DnssecInvalid
            "dane-required" -> DaneRequired
            "sts-policy-fetch-error" -> StsPolicyFetchError
            "sts-policy-invalid" -> StsPolicyInvalid
            "sts-webpki-invalid" -> StsWebpkiInvalid]

lex' r = [span (\c -> c `elem` ('-':['a'..'z'])) r]
      
instance FromJSON ResultType where
  parseJSON = parseJSONRead "ResultType"

instance ToJSON ResultType where
  toJSON = toJSONShow
  toEncoding = toEncodingShow

data FailureDetails = FailureDetails {
  fdResultType :: ResultType,             -- ^ failure type
  fdSendingMtaIp :: IpAddress,            -- ^ address of the sending MTA
  fdReceivingMxHostname :: T.Text,        -- ^ hostname of the receiving MTA MX record
  fdReceivingMxHelo :: Maybe T.Text,      -- ^ the HELO or EHLO string from thte banner announced during the reported session
  fdReceivingIp :: IpAddress,             -- ^ destination IP address
  fdFailedSessionCount :: Int,            -- ^ number of attempted sessions that match the result type
  fdAdditionalInformation :: Maybe T.Text,-- ^ URI that points to additional information
  fdFailureReasonCode :: Maybe T.Text     -- ^ failure code, esp. useful if result type is validation-failure
  } deriving (Eq, Show, Read)

instance ToJSON FailureDetails where
  toJSON FailureDetails{..} =
    object (["result-type" .= fdResultType,
             "sending-mta-ip" .= fdSendingMtaIp,
             "receiving-mx-hostname" .= fdReceivingMxHostname,
             "receiving-ip" .= fdReceivingIp,
             "failed-session-count" .= fdFailedSessionCount]
             ++maybe [] (\h -> ["receiving-mx-helo" .= h]) fdReceivingMxHelo
             ++maybe [] (\h -> ["additional-information" .= h]) fdAdditionalInformation
             ++maybe [] (\h -> ["failure-reason-code" .= h]) fdFailureReasonCode)

instance FromJSON FailureDetails where
  parseJSON = withObject "FailureDetails" $
    \o -> FailureDetails
          <$> o .: "result-type"
          <*> o .: "sending-mta-ip"
          <*> o .: "receiving-mx-hostname"
          <*> o .:? "receiving-mx-helo"
          <*> o .: "receiving-ip"
          <*> o .: "failed-session-count"
          <*> o .:? "additional-information"
          <*> o .:? "failure-reason-code"

data Policy = Policy {
  -- | description of the evaluated policy
  pcPolicy :: PolicyDesc,
  -- | failure/success summary
  pcSummary :: PolicySummary,
  -- | details about failure types
  pcFailureDetails :: [FailureDetails]
  } deriving (Eq, Show, Read)

instance ToJSON Policy where
  toJSON Policy{..} =
    object ["policy" .= pcPolicy,
            "summary" .= pcSummary,
            "failure-details" .= pcFailureDetails]

instance FromJSON Policy where
  parseJSON = withObject "Policy" $
    \o -> Policy
          <$> o .: "policy"
          <*> o .: "summary"
          <*> o .:? "failure-details" .!= []

-- | The entire SMTP-TLSRPT report
data Report = Report {
  -- | organization responsible for the report
  rpOrganizationName :: T.Text,
  -- | start and end times for the report range
  rpDateRange :: (UTCTime, UTCTime),
  -- | e-mail address of the party responsible for the report
  rpContactInfo :: T.Text,
  -- | unique identifier
  rpReportId :: T.Text,
  -- | results for each policy
  rpPolicies :: [Policy]
  } deriving (Eq, Show, Read)

instance ToJSON Report where
  toJSON Report{..} =
    object ["organization-name" .= rpOrganizationName,
            "date-range" .= object
             ["start-datetime" .= fmt (fst rpDateRange),
              "end-datetime" .= fmt (snd rpDateRange)],
            "contact-info" .= rpContactInfo,
            "report-id" .= rpReportId,
            "policies" .= rpPolicies]
    where fmt = formatTime defaultTimeLocale "%FT%X%Z"
  
instance FromJSON Report where
  parseJSON = withObject "Report" $
    \o -> Report
          <$> o .: "organization-name"
          <*> mtime o
          <*> o .: "contact-info"
          <*> o .: "report-id"
          <*> o .: "policies"
    where mtime o = do
            range <- o .: "date-range"
            start <- range .: "start-datetime"
            end <- range .: "end-datetime"
            start' <- parseTimeM True defaultTimeLocale "%FT%X%Z" start
            end' <- parseTimeM True defaultTimeLocale "%FT%X%Z" end
            return (start', end')

-- | Parse a TLSRPT report from a JSON 'Value'
tlsReportFromJson :: Value -> Either String Report
tlsReportFromJson v =
  case fromJSON v of
    Error s -> Left s
    Success r -> Right r

-- | Parse a TLSRPT report from a strict 'B.ByteString'
tlsReportFromStrict :: B.ByteString -> Either String Report
tlsReportFromStrict = unsafePerformIO . tlsReportFromStream . Q.fromStrict

-- | Parse a TLSRPT report from a lazy 'L.ByteString'
tlsReportFromLazy :: L.ByteString -> Either String Report
tlsReportFromLazy = unsafePerformIO . tlsReportFromStream . Q.fromLazy

-- | Parse a TLSRPT report from a 'Q.ByteStream'
tlsReportFromStream :: (MonadFail m, MonadIO m) => Q.ByteStream m r -> m (Either String Report)
tlsReportFromStream stream = do
  let stream' = uncompressStream stream
  lbs <- Q.toLazy_ stream'
  return $ eitherDecode lbs

test :: IO ()
test = do
  let filename = "../google.com!enumeration.eu!1607990400!1608076799!001.json.gz"
  runResourceT $ do
    erpt <- tlsReportFromStream $ Q.readFile filename
    case erpt of
      Left e -> liftIO $ putStrLn e
      Right x -> liftIO $ putStrLn $ show x