packages feed

nixpkgs-update-0.3.0: src/CVE.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module CVE
  ( parseFeed,
    CPE (..),
    CPEMatch (..),
    CPEMatchRow (..),
    cpeMatches,
    CVE (..),
    CVEID,
    cveLI,
  )
where

import Data.Aeson
  ( FromJSON,
    Object,
    eitherDecode,
    parseJSON,
    withObject,
    (.!=),
    (.:),
    (.:!),
  )
import Data.Aeson.Types (Parser, prependFailure)
import qualified Data.ByteString.Lazy.Char8 as BSL
import Data.List (intercalate)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime)
import Database.SQLite.Simple (FromRow, ToRow, field, fromRow, toRow)
import Database.SQLite.Simple.ToField (toField)
import OurPrelude
import Utils (Boundary (..), VersionMatcher (..))

type CVEID = Text

data CVE = CVE
  { cveID :: CVEID,
    cveCPEMatches :: [CPEMatch],
    cveDescription :: Text,
    cvePublished :: UTCTime,
    cveLastModified :: UTCTime
  }
  deriving (Show, Eq, Ord)

-- | cve list item
cveLI :: CVE -> Bool -> Text
cveLI c patched =
  "- ["
    <> cveID c
    <> "](https://nvd.nist.gov/vuln/detail/"
    <> cveID c
    <> ")"
    <> p
  where
    p =
      if patched
        then " (patched)"
        else ""

data CPEMatch = CPEMatch
  { cpeMatchCPE :: CPE,
    cpeMatchVulnerable :: Bool,
    cpeMatchVersionMatcher :: VersionMatcher
  }
  deriving (Show, Eq, Ord)

instance FromRow CPEMatch where
  fromRow = do
    cpeMatchCPE <- fromRow
    let cpeMatchVulnerable = True
    cpeMatchVersionMatcher <- field
    pure CPEMatch {..}

-- This decodes an entire CPE string and related attributes, but we only use
-- cpeVulnerable, cpeProduct, cpeVersion and cpeMatcher.
data CPE = CPE
  { cpePart :: (Maybe Text),
    cpeVendor :: (Maybe Text),
    cpeProduct :: (Maybe Text),
    cpeVersion :: (Maybe Text),
    cpeUpdate :: (Maybe Text),
    cpeEdition :: (Maybe Text),
    cpeLanguage :: (Maybe Text),
    cpeSoftwareEdition :: (Maybe Text),
    cpeTargetSoftware :: (Maybe Text),
    cpeTargetHardware :: (Maybe Text),
    cpeOther :: (Maybe Text)
  }
  deriving (Eq, Ord)

instance Show CPE where
  show
    CPE
      { cpePart,
        cpeVendor,
        cpeProduct,
        cpeVersion,
        cpeUpdate,
        cpeEdition,
        cpeLanguage,
        cpeSoftwareEdition,
        cpeTargetSoftware,
        cpeTargetHardware,
        cpeOther
      } =
      "CPE {"
        <> (intercalate ", " . concat)
          [ cpeField "part" cpePart,
            cpeField "vendor" cpeVendor,
            cpeField "product" cpeProduct,
            cpeField "version" cpeVersion,
            cpeField "update" cpeUpdate,
            cpeField "edition" cpeEdition,
            cpeField "language" cpeLanguage,
            cpeField "softwareEdition" cpeSoftwareEdition,
            cpeField "targetSoftware" cpeTargetSoftware,
            cpeField "targetHardware" cpeTargetHardware,
            cpeField "other" cpeOther
          ]
        <> "}"
      where
        cpeField :: Show a => String -> Maybe a -> [String]
        cpeField _ Nothing = []
        cpeField name (Just value) = [name <> " = " <> show value]

instance ToRow CPE where
  toRow
    CPE
      { cpePart,
        cpeVendor,
        cpeProduct,
        cpeVersion,
        cpeUpdate,
        cpeEdition,
        cpeLanguage,
        cpeSoftwareEdition,
        cpeTargetSoftware,
        cpeTargetHardware,
        cpeOther
      } =
      fmap -- There is no toRow instance for a tuple this large
        toField
        [ cpePart,
          cpeVendor,
          cpeProduct,
          cpeVersion,
          cpeUpdate,
          cpeEdition,
          cpeLanguage,
          cpeSoftwareEdition,
          cpeTargetSoftware,
          cpeTargetHardware,
          cpeOther
        ]

instance FromRow CPE where
  fromRow = do
    cpePart <- field
    cpeVendor <- field
    cpeProduct <- field
    cpeVersion <- field
    cpeUpdate <- field
    cpeEdition <- field
    cpeLanguage <- field
    cpeSoftwareEdition <- field
    cpeTargetSoftware <- field
    cpeTargetHardware <- field
    cpeOther <- field
    pure CPE {..}

-- | Parse a @description_data@ subtree and return the concatenation of the
-- english descriptions.
parseDescription :: Object -> Parser Text
parseDescription o = do
  dData <- o .: "description_data"
  descriptions <-
    fmap concat $
      sequence $
        flip map dData $
          \dDatum -> do
            value <- dDatum .: "value"
            lang :: Text <- dDatum .: "lang"
            pure $
              case lang of
                "en" -> [value]
                _ -> []
  pure $ T.intercalate "\n\n" descriptions

instance FromJSON CVE where
  parseJSON =
    withObject "CVE" $ \o -> do
      cve <- o .: "cve"
      meta <- cve .: "CVE_data_meta"
      cveID <- meta .: "ID"
      prependFailure (T.unpack cveID <> ": ") $ do
        cfgs <- o .: "configurations"
        cveCPEMatches <- parseConfigurations cfgs
        cvePublished <- o .: "publishedDate"
        cveLastModified <- o .: "lastModifiedDate"
        description <- cve .: "description"
        cveDescription <- parseDescription description
        pure CVE {..}

instance ToRow CVE where
  toRow CVE {cveID, cveDescription, cvePublished, cveLastModified} =
    toRow (cveID, cveDescription, cvePublished, cveLastModified)

instance FromRow CVE where
  fromRow = do
    let cveCPEMatches = []
    cveID <- field
    cveDescription <- field
    cvePublished <- field
    cveLastModified <- field
    pure CVE {..}

splitCPE :: Text -> [Maybe Text]
splitCPE =
  map (toMaybe . T.replace "\a" ":") . T.splitOn ":" . T.replace "\\:" "\a"
  where
    toMaybe "*" = Nothing
    toMaybe x = Just x

instance FromJSON CPEMatch where
  parseJSON =
    withObject "CPEMatch" $ \o -> do
      t <- o .: "cpe23Uri"
      cpeMatchCPE <-
        case splitCPE t of
          [Just "cpe", Just "2.3", cpePart, cpeVendor, cpeProduct, cpeVersion, cpeUpdate, cpeEdition, cpeLanguage, cpeSoftwareEdition, cpeTargetSoftware, cpeTargetHardware, cpeOther] ->
            pure CPE {..}
          _ -> fail $ "unparsable cpe23Uri: " <> T.unpack t
      cpeMatchVulnerable <- o .: "vulnerable"
      vStartIncluding <- o .:! "versionStartIncluding"
      vEndIncluding <- o .:! "versionEndIncluding"
      vStartExcluding <- o .:! "versionStartExcluding"
      vEndExcluding <- o .:! "versionEndExcluding"
      startBoundary <-
        case (vStartIncluding, vStartExcluding) of
          (Nothing, Nothing) -> pure Unbounded
          (Just start, Nothing) -> pure (Including start)
          (Nothing, Just start) -> pure (Excluding start)
          (Just _, Just _) -> fail "multiple version starts"
      endBoundary <-
        case (vEndIncluding, vEndExcluding) of
          (Nothing, Nothing) -> pure Unbounded
          (Just end, Nothing) -> pure (Including end)
          (Nothing, Just end) -> pure (Excluding end)
          (Just _, Just _) -> fail "multiple version ends"
      cpeMatchVersionMatcher <-
        case (cpeVersion cpeMatchCPE, startBoundary, endBoundary) of
          (Just v, Unbounded, Unbounded) -> pure $ SingleMatcher v
          (Nothing, start, end) -> pure $ RangeMatcher start end
          _ ->
            fail
              ( "cpe_match has both version "
                  <> show (cpeVersion cpeMatchCPE)
                  <> " in cpe, and boundaries from "
                  <> show startBoundary
                  <> " to "
                  <> show endBoundary
              )
      pure (CPEMatch {..})

data CPEMatchRow
  = CPEMatchRow CVE CPEMatch

instance ToRow CPEMatchRow where
  toRow (CPEMatchRow CVE {cveID} CPEMatch {cpeMatchCPE, cpeMatchVersionMatcher}) =
    [toField $ Just cveID]
      ++ toRow cpeMatchCPE
      ++ [toField cpeMatchVersionMatcher]

instance FromRow CPEMatchRow where
  fromRow = do
    let cveCPEMatches = []
    let cveDescription = undefined
    let cvePublished = undefined
    let cveLastModified = undefined
    cveID <- field
    cpeM <- fromRow
    pure $ CPEMatchRow (CVE {..}) cpeM

cpeMatches :: [CVE] -> [CPEMatchRow]
cpeMatches = concatMap rows
  where
    rows cve = fmap (CPEMatchRow cve) (cveCPEMatches cve)

guardAttr :: (Eq a, FromJSON a, Show a) => Object -> Text -> a -> Parser ()
guardAttr object attribute expected = do
  actual <- object .: attribute
  unless (actual == expected) $
    fail $
      "unexpected "
        <> T.unpack attribute
        <> ", expected "
        <> show expected
        <> ", got "
        <> show actual

boundedMatcher :: VersionMatcher -> Bool
boundedMatcher (RangeMatcher Unbounded Unbounded) = False
boundedMatcher _ = True

-- Because complex boolean formulas can't be used to determine if a single
-- product/version is vulnerable, we simply use all leaves marked vulnerable.
parseNode :: Object -> Parser [CPEMatch]
parseNode node = do
  maybeChildren <- node .:! "children"
  case maybeChildren of
    Nothing -> do
      matches <- node .:! "cpe_match" .!= []
      pure $
        filter (cpeMatchVersionMatcher >>> boundedMatcher) $
          filter cpeMatchVulnerable matches
    Just children -> do
      fmap concat $ sequence $ map parseNode children

parseConfigurations :: Object -> Parser [CPEMatch]
parseConfigurations o = do
  guardAttr o "CVE_data_version" ("4.0" :: Text)
  nodes <- o .: "nodes"
  fmap concat $ sequence $ map parseNode nodes

parseFeed :: BSL.ByteString -> Either Text [CVE]
parseFeed = bimap T.pack cvefItems . eitherDecode

data CVEFeed = CVEFeed
  { cvefItems :: [CVE]
  }

instance FromJSON CVEFeed where
  parseJSON = withObject "CVEFeed" $ \o -> CVEFeed <$> o .: "CVE_Items"