pan-os-syslog-0.2.0.0: src/Panos/Syslog/Internal/Threat.hs
{-# language BangPatterns #-}
{-# language DerivingStrategies #-}
{-# language DuplicateRecordFields #-}
{-# language GeneralizedNewtypeDeriving #-}
{-# language LambdaCase #-}
{-# language MagicHash #-}
{-# language MultiWayIf #-}
{-# language NamedFieldPuns #-}
{-# language NumericUnderscores #-}
{-# language ScopedTypeVariables #-}
module Panos.Syslog.Internal.Threat
( Threat(..)
, parserThreat
) where
import Panos.Syslog.Internal.Common
import Chronos (Datetime)
import Control.Monad (when)
import Data.Bytes.Parser (Parser)
import Data.Bytes.Types (Bytes(..))
import Data.Char (isAsciiUpper,isAsciiLower)
import Data.Primitive (ByteArray)
import Data.Word (Word64,Word32,Word16)
import GHC.Exts (Int(I#),Ptr(Ptr))
import Net.Types (IP(IP),IPv6(IPv6))
import Data.WideWord (Word128)
import qualified Data.Bytes as Bytes
import qualified Data.Bytes.Parser as P
import qualified Data.Bytes.Parser.Latin as Latin
import qualified Data.Bytes.Parser.Unsafe as Unsafe
import qualified Data.Primitive as PM
import qualified GHC.Exts as Exts
import qualified Net.IP as IP
import qualified UUID
-- | A PAN-OS threat log. Read-only accessors are found in
-- @Panos.Syslog.Threat@.
data Threat = Threat
{ message :: {-# UNPACK #-} !ByteArray
-- The original log
, syslogHost :: {-# UNPACK #-} !Bounds
-- The host as presented in the syslog preamble that
-- prefixes the message.
, receiveTime :: {-# UNPACK #-} !Datetime
-- In log, presented as: 2019/06/18 15:10:20
, serialNumber :: {-# UNPACK #-} !Bounds
-- In log, presented as: 002610378847
, subtype :: {-# UNPACK #-} !Bounds
-- Presented as: data, file, flood, packet, scan, spyware, url,
-- virus, vulnerability, wildfire, or wildfire-virus.
, timeGenerated :: {-# UNPACK #-} !Datetime
-- Presented as: 2018/04/11 23:19:22
, sourceAddress :: {-# UNPACK #-} !IP
, destinationAddress :: {-# UNPACK #-} !IP
, natSourceIp :: {-# UNPACK #-} !IP
, natDestinationIp :: {-# UNPACK #-} !IP
, ruleName :: {-# UNPACK #-} !Bounds
, sourceUser :: {-# UNPACK #-} !Bounds
, destinationUser :: {-# UNPACK #-} !Bounds
, application :: {-# UNPACK #-} !Bounds
, virtualSystem :: {-# UNPACK #-} !Bounds
, sourceZone :: {-# UNPACK #-} !Bounds
, destinationZone :: {-# UNPACK #-} !Bounds
, inboundInterface :: {-# UNPACK #-} !Bounds
, outboundInterface :: {-# UNPACK #-} !Bounds
, logAction :: {-# UNPACK #-} !Bounds
, sessionId :: {-# UNPACK #-} !Word64
, repeatCount :: {-# UNPACK #-} !Word64
, sourcePort :: {-# UNPACK #-} !Word16
, destinationPort :: {-# UNPACK #-} !Word16
, natSourcePort :: {-# UNPACK #-} !Word16
, natDestinationPort :: {-# UNPACK #-} !Word16
, action :: {-# UNPACK #-} !Bounds
, ipProtocol :: {-# UNPACK #-} !Bounds
, flags :: {-# UNPACK #-} !Word32
, miscellaneousBounds :: {-# UNPACK #-} !Bounds
, miscellaneousByteArray :: {-# UNPACK #-} !ByteArray
, threatName :: {-# UNPACK #-} !Bounds
, threatId :: {-# UNPACK #-} !Word64
, category :: {-# UNPACK #-} !Bounds
, severity :: {-# UNPACK #-} !Bounds
, direction :: {-# UNPACK #-} !Bounds
, sequenceNumber :: {-# UNPACK #-} !Word64
, actionFlags :: {-# UNPACK #-} !Word64
-- Presented as: 0x8000000000000000
, sourceCountry :: {-# UNPACK #-} !Bounds
, destinationCountry :: {-# UNPACK #-} !Bounds
, contentType :: {-# UNPACK #-} !Bounds
, pcapId :: {-# UNPACK #-} !Word64
, fileDigest :: {-# UNPACK #-} !Bounds
-- Only used by wildfire subtype
-- TODO: make the file digest a 128-bit or 256-bit word
, cloud :: {-# UNPACK #-} !Bounds
-- Only used by wildfire subtype
, urlIndex :: {-# UNPACK #-} !Word64
-- Only used by wildfire subtype
, userAgentBounds :: {-# UNPACK #-} !Bounds
, userAgentByteArray :: {-# UNPACK #-} !ByteArray
-- Only used by url filtering subtype. This field may have
-- escaped characters, so we include the possibility of
-- using a byte array distinct from the original log.
, fileType :: {-# UNPACK #-} !Bounds
-- Only used by wildfire subtype
, forwardedFor :: {-# UNPACK #-} !Bounds
-- Only used by url filtering subtype
, referer :: {-# UNPACK #-} !Bytes
-- Only used by url filtering subtype
, sender :: {-# UNPACK #-} !Bytes
-- Only used by wildfire subtype
, subject :: {-# UNPACK #-} !Bytes
-- Only used by wildfire subtype
, recipient :: {-# UNPACK #-} !Bytes
-- Only used by wildfire subtype
, reportId :: {-# UNPACK #-} !Bounds
-- Only used by wildfire subtype
, deviceGroupHierarchyLevel1 :: {-# UNPACK #-} !Word64
, deviceGroupHierarchyLevel2 :: {-# UNPACK #-} !Word64
, deviceGroupHierarchyLevel3 :: {-# UNPACK #-} !Word64
, deviceGroupHierarchyLevel4 :: {-# UNPACK #-} !Word64
, virtualSystemName :: {-# UNPACK #-} !Bounds
, deviceName :: {-# UNPACK #-} !Bounds
-- TODO: skipping over uuid fields for now
, httpMethod :: {-# UNPACK #-} !Bounds
, tunnelId :: {-# UNPACK #-} !Word64
, parentSessionId :: {-# UNPACK #-} !Word64
-- Only used by url subtype
, threatCategory :: {-# UNPACK #-} !Bounds
, contentVersion :: {-# UNPACK #-} !Bounds
-- TODO: skipping some fields here
, sctpAssociationId :: {-# UNPACK #-} !Word64
, payloadProtocolId :: {-# UNPACK #-} !Word64
-- TODO: skipping over other fields here
, httpHeaders :: {-# UNPACK #-} !Bytes
, urlCategoryList :: {-# UNPACK #-} !Bytes
, ruleUuid :: {-# UNPACK #-} !Word128
}
parserThreat :: Bounds -> Datetime -> Bounds -> Parser Field s Threat
parserThreat !syslogHost receiveTime !serialNumber = do
!message <- Unsafe.expose
subtype@(Bounds subtypeOff subtypeLen) <- untilComma subtypeField
let !subtypeB = Bytes message subtypeOff subtypeLen
let !isWildfire =
if | Bytes.equalsCString (Ptr "wildfire"# ) subtypeB -> 1 :: Int
| otherwise -> 0 :: Int
let !isUrl =
if | Bytes.equalsCString (Ptr "url"# ) subtypeB -> 1 :: Int
| otherwise -> 0 :: Int
let !isSpyware =
if | Bytes.equalsCString (Ptr "spyware"# ) subtypeB -> 1 :: Int
| otherwise -> 0 :: Int
Bounds futureUseAOff futureUseALen <- untilComma futureUseAField
let !futureUseA = Bytes message futureUseAOff futureUseALen
let !version =
if | Bytes.equalsCString (Ptr "10.0"# ) futureUseA -> 100 :: Int
| Bytes.equalsCString (Ptr "10.1"# ) futureUseA -> 101 :: Int
| otherwise -> 0 :: Int
-- the datetime parser also grabs the trailing comma
timeGenerated <- parserDatetime timeGeneratedDateField timeGeneratedTimeField
sourceAddress <- IP.parserUtf8Bytes sourceAddressField
Latin.char sourceAddressField ','
destinationAddress <- IP.parserUtf8Bytes destinationAddressField
Latin.char destinationAddressField ','
-- Use the ip address zero when no NAT address is present.
natSourceIp <- Latin.trySatisfy (==',') >>= \case
True -> pure (IP (IPv6 0))
False -> do
natSourceIp <- IP.parserUtf8Bytes natSourceIpField
Latin.char natSourceIpField ','
pure natSourceIp
natDestinationIp <- Latin.trySatisfy (==',') >>= \case
True -> pure (IP (IPv6 0))
False -> do
natDestinationIp <- IP.parserUtf8Bytes natDestinationIpField
Latin.char natDestinationIpField ','
pure natDestinationIp
ruleName <- untilComma ruleNameField
sourceUser <- untilComma sourceUserField
destinationUser <- untilComma destinationUserField
application <- untilComma applicationField
virtualSystem <- untilComma virtualSystemField
sourceZone <- untilComma sourceZoneField
destinationZone <- untilComma destinationZoneField
inboundInterface <- untilComma inboundInterfaceField
outboundInterface <- untilComma outboundInterfaceField
logAction <- untilComma logActionField
-- According to Palo Alto's documentation, the field after log_action
-- is a future use field. However, in some (possibly all) PAN-OS 10
-- logs, this field is missing. In all other logs, it is a
-- YYYY/MM/DD HH:mm:ss timestamp. If the field is exactly 19 bytes long,
-- we assume that it is the unused field. Otherwise, we assume that the
-- unused field is missing, and we try to interpret these bytes as the
-- session_id.
futureCursorB <- Unsafe.cursor
Bounds _ futureLenB <- untilComma futureUseBField
sessionId <- case futureLenB of
19 -> w64Comma sessionIdField
_ -> do
Unsafe.jump futureCursorB
w64Comma sessionIdField
repeatCount <- w64Comma repeatCountField
sourcePort <- w16Comma sourcePortField
destinationPort <- w16Comma destinationPortField
natSourcePort <- w16Comma natSourcePortField
natDestinationPort <- w16Comma natDestinationPortField
-- Note: Flags are ignored. Also, in either PAN-OS 10 or Prisma
-- (not sure which one causes this), flags are missing.
Latin.trySatisfy (=='0') >>= \case
True -> do
Latin.char flagsField 'x'
_ <- untilComma flagsField
pure ()
False -> pure ()
let flags = 0
ipProtocol <- untilComma ipProtocolField
action <- untilComma actionField
Bytes{array=miscellaneousByteArray,offset=miscOff,length=miscLen} <-
parserOptionallyQuoted miscellaneousField
let miscellaneousBounds = Bounds miscOff miscLen
-- Detect presence of threat name and id by looking for trailing
-- close paren on field.
threatIdCursor <- Unsafe.cursor
Bounds threatIdOff threatIdLen <- untilComma threatIdField
Unsafe.jump threatIdCursor
(threatName,threatId) <- case Bytes.isByteSuffixOf 0x29 (Bytes message threatIdOff threatIdLen) of
True -> parserThreatId
False -> pure (Bounds threatIdOff 0, 0)
category <-
if | version < 100 || isUrl == 1 -> untilComma categoryField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
severity <- untilComma severityField
direction <- untilComma directionField
sequenceNumber <- w64Comma sequenceNumberField
-- Note: Action flags are ignored. See note on Flags as well.
Latin.trySatisfy (=='0') >>= \case
True -> do
Latin.char actionFlagsField 'x'
_ <- untilComma actionFlagsField
pure ()
False -> pure ()
let actionFlags = 0
sourceCountry <- untilComma sourceCountryField
destinationCountry <- untilComma destinationCountryField
-- This future use is always either zero or empty before PAN-OS 10.x,
-- and in newer versions it is suppressed entirely. However, the
-- following field is content_type, which never starts with zero.
if | version >= 100, isUrl == 1 -> pure ()
| version >= 100, isSpyware == 1 -> do
-- I cannot figure out why, in PAN-OS 10, there is an extra field
-- in spyware logs.
Latin.skipDigits1 futureUseEField
Latin.char2 futureUseEField ',' ','
| otherwise -> do
Latin.skipDigits
Latin.char futureUseEField ','
-- In PAN-OS 10.x, content_type only shows up in URL logs.
-- I've added spyware here as well, but I'm not sure if this
-- is correct. Content type definitely does not show up in
-- wildfire logs.
contentType <-
if | version < 100 || isUrl == 1 || isSpyware == 1 -> untilComma contentTypeField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
-- Wildfire logs in PAN-OS 10.x do not have a pcap_id field.
pcapId <-
if | version < 100 || isUrl == 1 || isSpyware == 1 -> w64Comma pcapIdField
| otherwise -> pure 0
-- In PAN-OS 10.x, file_digest and cloud only show up in wildfire logs.
fileDigest <-
if | version < 100 || isWildfire == 1 || isSpyware == 1 -> untilComma fileDigestField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
cloud <-
if | version < 100 || isWildfire == 1 || isSpyware == 1 -> untilComma cloudField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
urlIndex <-
if | version < 100 || isUrl == 1 || isWildfire == 1 -> w64Comma urlIndexField
| otherwise -> pure 0
Bytes{array=userAgentByteArray,offset=uaOff,length=uaLen} <-
if | version < 100 || isUrl == 1 -> parserOptionallyQuoted userAgentField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
let userAgentBounds = Bounds uaOff uaLen
fileType <-
if | version < 100 || isWildfire == 1 || isSpyware == 1 -> untilComma fileTypeField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
forwardedFor <-
if | version < 100 || isUrl == 1 -> untilComma forwardedForField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
referer <-
if | version < 100 || isUrl == 1 -> parserOptionallyQuoted refererField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
sender <-
if | version < 100 || isWildfire == 1 -> parserOptionallyQuoted senderField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
subject <-
if | version < 100 || isWildfire == 1 -> parserOptionallyQuoted subjectField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
recipient <-
if | version < 100 || isWildfire == 1 -> parserOptionallyQuoted recipientField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
reportId <-
if | version < 100 || isWildfire == 1 || isSpyware == 1 -> untilComma reportIdField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
deviceGroupHierarchyLevel1 <- w64Comma deviceGroupHierarchyLevel1Field
deviceGroupHierarchyLevel2 <- w64Comma deviceGroupHierarchyLevel2Field
deviceGroupHierarchyLevel3 <- w64Comma deviceGroupHierarchyLevel3Field
deviceGroupHierarchyLevel4 <- w64Comma deviceGroupHierarchyLevel4Field
virtualSystemName <- untilComma virtualSystemNameField
deviceName <- untilComma deviceNameField
-- On PAN-OS 10+, this future use field is omitted.
when (version < 100) (parserOptionallyQuoted_ futureUseFField)
skipThroughComma sourceVmUuidField
skipThroughComma destinationVmUuidField
httpMethod <-
if | version < 100 || isUrl == 1 || isSpyware == 1 -> untilComma httpMethodField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
tunnelId <- w64Comma tunnelIdField
skipThroughComma monitorTagField
parentSessionId <- w64Comma parentSessionIdField
skipThroughComma parentStartTimeField
skipThroughComma tunnelTypeField
threatCategory <- untilComma threatCategoryField
contentVersion <-
if | version < 100 -> untilComma contentVersionField
| otherwise -> do
off <- Unsafe.cursor
pure (Bounds off 0)
when (version < 100) (skipThroughComma futureUseGField)
sctpAssociationId <- w64Comma sctpAssociationIdField
payloadProtocolId <-
if | version < 100 -> do
w64Comma payloadProtocolField
| isUrl == 1 -> do
w64Comma payloadProtocolField
| isSpyware == 1 || isWildfire == 1 -> do
-- In PAN-OS 10.x, this field looks like it is always zero,
-- represented in hexadecimal, in spyware logs.
Latin.char4 payloadProtocolField '0' 'x' '0' ','
pure 0
| otherwise -> pure 0
-- TODO: Escape or parse HTTP Headers correctly
httpHeaders <-
if | version < 100 || isUrl == 1 -> do
finalOptionallyQuoted httpHeadersField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
-- In PAN-OS 8.1, threat logs end after http headers.
-- PAN-OS 9.0 adds three more fields.
P.isEndOfInput >>= \case
False -> do
!urlCategoryList <-
if | version < 100 || isUrl == 1 -> do
Latin.char urlCategoryListField ','
parserOptionallyQuoted urlCategoryListField
| otherwise -> do
off <- Unsafe.cursor
pure (Bytes message off 0)
ruleUuid <- UUID.parserHyphenated ruleUuidField
Latin.char http2ConnectionField ','
Latin.skipDigits1 http2ConnectionField
pure Threat
{ subtype , timeGenerated , sourceAddress , destinationAddress
, natSourceIp , natDestinationIp , ruleName , sourceUser
, destinationUser , application , virtualSystem , sourceZone
, destinationZone , inboundInterface , outboundInterface , logAction
, sessionId , repeatCount , sourcePort , destinationPort
, natSourcePort , natDestinationPort , ipProtocol
, action , category
, sequenceNumber , sourceCountry , destinationCountry
, deviceGroupHierarchyLevel1 , deviceGroupHierarchyLevel2
, deviceGroupHierarchyLevel3 , deviceGroupHierarchyLevel4
, virtualSystemName , deviceName , receiveTime
, serialNumber, actionFlags, flags, message
, syslogHost, threatId, severity, direction, threatName
, contentType, pcapId
, fileDigest, cloud, urlIndex
, userAgentBounds, sctpAssociationId
, userAgentByteArray, fileType
, forwardedFor, referer
, sender, subject, recipient
, reportId, httpMethod, contentVersion
, threatCategory, miscellaneousBounds, miscellaneousByteArray
, payloadProtocolId, parentSessionId, tunnelId
, httpHeaders, ruleUuid, urlCategoryList
}
True -> pure Threat
{ subtype , timeGenerated , sourceAddress , destinationAddress
, natSourceIp , natDestinationIp , ruleName , sourceUser
, destinationUser , application , virtualSystem , sourceZone
, destinationZone , inboundInterface , outboundInterface , logAction
, sessionId , repeatCount , sourcePort , destinationPort
, natSourcePort , natDestinationPort , ipProtocol
, action , category
, sequenceNumber , sourceCountry , destinationCountry
, deviceGroupHierarchyLevel1 , deviceGroupHierarchyLevel2
, deviceGroupHierarchyLevel3 , deviceGroupHierarchyLevel4
, virtualSystemName , deviceName , receiveTime
, serialNumber, actionFlags, flags, message
, syslogHost, threatId, severity, direction, threatName
, contentType, pcapId
, fileDigest, cloud, urlIndex
, userAgentBounds, sctpAssociationId
, userAgentByteArray, fileType
, forwardedFor, referer
, sender, subject, recipient
, reportId, httpMethod, contentVersion
, threatCategory, miscellaneousBounds, miscellaneousByteArray
, payloadProtocolId, parentSessionId, tunnelId
, httpHeaders
, ruleUuid = 0
, urlCategoryList = Bytes message 0 0
}
-- Threat IDs are weird. There are three different kinds of
-- strings that can show up here:
--
-- * (9999)
-- * Microsoft RPC Endpoint Mapper Detection(30845)
-- * Windows Executable (EXE)(52020)
--
-- URL logs have a threat id of 9999, and there is no description.
-- Everything else has a human-readable description. Sometimes,
-- this description is suffixed by a space and a parenthesized
-- acronym (EXE, DLL, etc.).
parserThreatId :: Parser Field s (Bounds,Word64)
parserThreatId = Latin.any threatIdField >>= \case
'(' -> do
theId <- Latin.decWord64 threatIdField
Latin.char threatIdField ')'
Latin.char threatIdField ','
pure (Bounds 0 0, theId)
_ -> do
startSucc <- Unsafe.cursor
Latin.skipTrailedBy threatIdField '('
end <- Latin.trySatisfy (\c -> isAsciiUpper c || isAsciiLower c) >>= \case
True -> do
endSuccSucc <- Unsafe.cursor
Latin.skipTrailedBy threatIdField '('
arr <- Unsafe.expose
-- We go back an extra character to remove the trailing
-- space. I do not believe this can lead to negative-length
-- slices, but the line of reasoning is muddy.
case indexCharArray arr (endSuccSucc - 3) of
' ' -> pure (endSuccSucc - 3)
_ -> P.fail threatIdField
False -> do
endSucc <- Unsafe.cursor
pure (endSucc - 1)
theId <- Latin.decWord64 threatIdField
Latin.char threatIdField ')'
Latin.char threatIdField ','
let start = startSucc - 1
pure (Bounds start (end - start), theId)
indexCharArray :: ByteArray -> Int -> Char
indexCharArray (PM.ByteArray x) (I# i) =
Exts.C# (Exts.indexCharArray# x i)