packages feed

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

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

-- | Utilities for parsing a DMARC aggregated report, following RFC 7489. These functions also handle decompression (gzip or pkzip)

module Data.Mail.DMARC.Reports (
  -- * Parsers
  dmarcFeedbackFromStrict,
  dmarcFeedbackFromLazy,
  dmarcFeedbackFromStream,
  dmarcFeedbackFromXml,
  -- * Data types
  Feedback(..),
  ReportMetadata(..),
  Record(..),
  Row(..),
  PolicyPublished(..),
  PolicyEvaluated(..),
  DkimAuth(..),
  SpfAuth(..),
  AuthResult(..),
  IpAddress,
  AlignmentMode(..),
  PolicyAction(..),
  -- * Utility
  uncompressStream
  ) where

import Streaming
import Streaming.Zip
import qualified Streaming.Prelude as S
import qualified Streaming.ByteString as Q
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString as B
import qualified Codec.Archive.Zip as Z
import Control.Monad.IO.Class
import Control.Monad.Trans
import Control.Monad.Trans.Resource
import Text.XML.Light
import Text.Read (readMaybe)
import Data.Time.Clock
import Data.Time.Format
import Data.Maybe
import qualified Data.Text as T
import System.IO.Unsafe

-- | Uncompress gzip or pkzip stream. If it is neither gzipped or pkzipped, return the stream unmodified.
uncompressStream :: (MonadFail m, MonadIO m) => Q.ByteStream m r -> Q.ByteStream m ()
uncompressStream stream = do
  magic <- lift $ Q.toStrict_ $ Q.take 2 stream
  if magic == "PK"
    then pkunzip stream
    else if magic == "\x1F\x8b"
         then void $ gunzip stream
         else void stream
  where pkunzip stream = do
          lbs <- lift $ Q.toLazy_ stream
          let a = Z.toArchive lbs
          case Z.zEntries a of
            [] -> lift $ fail "Empty PKZip archive :("
            (e:_) -> Q.fromLazy $ Z.fromEntry e

data ReportMetadata = ReportMetadata {
  rmOrgName :: Maybe T.Text,
  rmEmail :: Maybe T.Text,
  rmExtraContactInfo :: Maybe T.Text,
  rmReportId :: Maybe Integer,
  rmDateRange :: Maybe (UTCTime, UTCTime)
  } deriving (Eq,Show,Read)

data AlignmentMode = Strict | Relaxed deriving (Eq)

instance Show AlignmentMode where
  show Strict = "s"
  show Relaxed = "r"

instance Read AlignmentMode where
  readsPrec _ r =
    [(Strict,s) | ("s",s) <- lex r]
    ++[(Relaxed,s) | ("r",s) <- lex r]

data PolicyAction = NoPolicy
                  | Quarantine
                  | Reject deriving (Eq)

instance Show PolicyAction where
  show NoPolicy = "none"
  show Quarantine = "quarantine"
  show Reject = "reject"

instance Read PolicyAction where
  readsPrec _ r =
    [(NoPolicy,s) | ("none",s) <- lex r]
    ++[(Quarantine,s) | ("quarantine",s) <- lex r]
    ++[(Reject,s) | ("reject",s) <- lex r]
  
data PolicyPublished = PolicyPublished {
  ppDomain :: T.Text,
  ppDkimAlignment :: AlignmentMode,
  ppSpfAlignment :: AlignmentMode,
  ppPolicy :: PolicyAction,
  ppSubdomainPolicy :: PolicyAction,
  ppPercent :: Int
  } deriving (Eq,Show,Read)

data AuthResult = NoAuth    -- ^ no policy published
                | AuthPass  -- ^ successful authentication
                | AuthFail  -- ^ authentication failed
                | TempError -- ^ temporary error in resolving the policy
                | PermError -- ^ permanent error in resolving the policy
                | AuthNeutral -- ^ ??? (DKIM only)
                | AuthPolicy  -- ^ ??? (DKIM only)
                | SoftFail  -- ^ auth failed, but won't reject (SPF only)
                deriving (Eq)

instance Show AuthResult where
  show NoAuth = "none"
  show AuthPass = "pass"
  show AuthFail = "fail"
  show TempError = "temperror"
  show PermError = "permerror"
  show AuthNeutral = "neutral"
  show AuthPolicy = "policy"
  show SoftFail = "softfail"

instance Read AuthResult where
  readsPrec _ r =
    [(NoAuth,s) | ("none",s) <- lex r]
    ++[(AuthPass,s) | ("pass",s) <- lex r]
    ++[(AuthFail,s) | ("fail",s) <- lex r]
    ++[(TempError,s) | ("temperror",s) <- lex r]
    ++[(PermError,s) | ("permerror",s) <- lex r]
    ++[(AuthNeutral,s) | ("neutral",s) <- lex r]
    ++[(AuthPolicy,s) | ("policy",s) <- lex r]
    ++[(SoftFail,s) | ("softfail",s) <- lex r]

type IpAddress = String

data PolicyEvaluated = PolicyEvaluated {
  peDisposition :: PolicyAction,
  peDkimResult :: AuthResult,
  peSpfResult :: AuthResult
  } deriving (Eq, Show, Read)

data Row = Row {
  rwSourceIp :: IpAddress,
  rwCount :: Int,
  rwPolicyEvaluated :: PolicyEvaluated
  } deriving (Eq, Show, Read)

data Record = Record {
  rcRow :: Row,
  rcAuthDkim :: Maybe DkimAuth,
  rcAuthSpf :: Maybe SpfAuth
  } deriving (Eq, Show, Read)

data DkimAuth = DkimAuth {
  daResult :: AuthResult,
  daDomain :: T.Text,
  daSelector :: T.Text
  } deriving (Eq, Show, Read)

data SpfAuth = SpfAuth {
  saResult :: AuthResult,
  saDomain :: T.Text
  } deriving (Eq, Show, Read)
  
data Feedback = Feedback {
  fbReportMetadata :: ReportMetadata,
  fbPolicyPublished :: PolicyPublished,
  fbRecords :: [Record]
  } deriving (Eq,Show,Read)

-- | Gets an XML document root from a streaming bytestring
xmlFromStream :: (MonadFail m, MonadIO m) => Q.ByteStream m r -> m (Maybe Element)
xmlFromStream stream = do
  let stream' = uncompressStream stream
  lbs <- Q.toLazy_ stream'
  return $ parseXMLDoc lbs

test :: IO ()
test = do
  let filename = "../google.com!enumeration.eu!1607299200!1607385599.zip"
  runResourceT $ do
    mfb <- dmarcFeedbackFromStream $ Q.readFile filename
    case mfb of
      Nothing -> liftIO $ putStrLn "nothing :("
      Just x -> liftIO $ putStrLn $ show x

-- | Parse a DMARC report from a 'Q.ByteStream'
dmarcFeedbackFromStream :: (MonadFail m, MonadIO m) => Q.ByteStream m r -> m (Maybe Feedback)
dmarcFeedbackFromStream stream = do
  mxml <- xmlFromStream stream
  return $ mxml >>= processXml

-- | Parse a DMARC report from a strict 'B.ByteString'
dmarcFeedbackFromStrict :: B.ByteString -> Maybe Feedback
dmarcFeedbackFromStrict = unsafePerformIO . dmarcFeedbackFromStream . Q.fromStrict

-- | Parse a DMARC report from a lazy 'L.ByteString'
dmarcFeedbackFromLazy :: L.ByteString -> Maybe Feedback
dmarcFeedbackFromLazy = unsafePerformIO . dmarcFeedbackFromStream . Q.fromLazy

-- | Parse a DMARC report from an XML document
dmarcFeedbackFromXml = processXml

-- | Parse a DMARC report from an XML document (also: 'dmarcFeedbackFromXml')
processXml :: Element -> Maybe Feedback
processXml root =
  let strContent' = T.pack . strContent
      reportMetadata = findChild (unqual "report_metadata") root
      policyPublished = findChild (unqual "policy_published") root
      rmOrgName = strContent' <$> (findChild (unqual "org_name") =<< reportMetadata)
      rmEmail = strContent' <$> (findChild (unqual "email") =<< reportMetadata)
      rmExtraContactInfo = strContent' <$> (findChild (unqual "extra_contact_info") =<< reportMetadata)
      rmReportId = readMaybe =<< strContent <$> (findChild (unqual "report_id") =<< reportMetadata)
      rmDateRange = do
        rm <- reportMetadata
        rangee <- findChild (unqual "date_range") rm
        begine <- findChild (unqual "begin") rangee
        ende <- findChild (unqual "end") rangee
        let begins = strContent begine
            ends = strContent ende
        begint <- parseTimeM True defaultTimeLocale "%s" begins
        endt <- parseTimeM True defaultTimeLocale "%s" ends
        return (begint, endt)
      mpolicy = do
        pp <- policyPublished
        ppDomain <- strContent' <$> findChild (unqual "domain") pp
        ppDkimAlignment <- (do
          adkim <- findChild (unqual "adkim") pp
          readMaybe $ strContent adkim) <|> Just Relaxed
        ppSpfAlignment <- (do
          aspf <- findChild (unqual "spf") pp
          readMaybe $ strContent aspf) <|> Just Relaxed
        ppPolicy <- (do
          p <- findChild (unqual "p") pp
          readMaybe $ strContent p) <|> Just NoPolicy
        ppSubdomainPolicy <- (do
          sp <- findChild (unqual "sp") pp
          readMaybe $ strContent sp) <|> Just NoPolicy
        ppPercent <- (do
          pct <- findChild (unqual "pct") pp
          readMaybe $ strContent pct) <|> Just 100
        return PolicyPublished{..}
      mpe el = do
        dispo <- (readMaybe =<< strContent <$> findChild (unqual "disposition") el) <|> Just NoPolicy
        dkim <- (readMaybe =<< strContent <$> findChild (unqual "dkim") el) <|> Just NoAuth
        spf <- (readMaybe =<< strContent <$> findChild (unqual "spf") el) <|> Just NoAuth
        return $ PolicyEvaluated dispo dkim spf
      mrow el = do
        pe <- (mpe =<< findChild (unqual "policy_evaluated") el) <|> Just (PolicyEvaluated NoPolicy NoAuth NoAuth)
        sourceip <- strContent <$> findChild (unqual "source_ip") el
        count <- readMaybe =<< (strContent <$> findChild (unqual "count") el)
        return $ Row sourceip count pe
      mda el = do
        domain <- strContent' <$> findChild (unqual "domain") el
        result <- readMaybe =<< strContent <$> findChild (unqual "result") el
        selector <- strContent' <$> findChild (unqual "selector") el
        return $ DkimAuth result domain selector
      msa el = do
        domain <- strContent' <$> findChild (unqual "domain") el
        result <- readMaybe =<< strContent <$> findChild (unqual "result") el
        return $ SpfAuth result domain
      mrecord el = do
        row <- mrow =<< findChild (unqual "row") el
        let authresults = findChild (unqual "auth_results") el
        let da = mda =<< findChild (unqual "dkim") =<< authresults
        let sa = msa =<< findChild (unqual "spf") =<< authresults
        return $ Record row da sa
  in do
    policy <- mpolicy
    let records = mapMaybe mrecord $ findChildren (unqual "record") root
    return $ Feedback ReportMetadata{..} policy records