packages feed

osv-0.2.0.0: src/Security/OSV.hs

-- | This module contains the OSV datatype and its ToJSON instance.
-- The module was initialized with http://json-to-haskell.chrispenner.ca/
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE OverloadedStrings #-}

module Security.OSV
  (
  -- * Top-level data type
    Model(..)
  , newModel
  , newModel'
  , defaultSchemaVersion

  -- * Subsidiary data types
  , Affected(..)
  , Credit(..)
  , CreditType(..)
  , creditTypes
  , Event(..)
  , Package(..)
  , Range(..)
  , Reference(..)
  , ReferenceType(..)
  , referenceTypes
  , Severity(..)
  )
  where

import Control.Applicative ((<|>))
import Control.Monad (when)
import Data.Maybe (catMaybes, fromMaybe)
import Data.Aeson
  ( ToJSON(..), FromJSON(..), Value(..)
  , (.:), (.:?), (.=), object, withObject, withText
  )
import Data.Aeson.Types
  ( Key, Object, Parser
  , explicitParseField, explicitParseFieldMaybe, prependFailure, typeMismatch
  )
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time (UTCTime)
import Data.Time.Format.ISO8601 (iso8601ParseM)
import Data.Tuple (swap)

import Data.Purl (Purl)
import qualified Security.CVSS as CVSS

data Affected dbSpecific ecosystemSpecific rangeDbSpecific = Affected
  { affectedRanges :: [Range rangeDbSpecific]
  , affectedPackage :: Package
  , affectedSeverity :: [Severity]
  , affectedEcosystemSpecific :: Maybe ecosystemSpecific
  , affectedDatabaseSpecific :: Maybe dbSpecific
  } deriving (Show, Eq)

data Event a
  = EventIntroduced a
  | EventFixed a
  | EventLastAffected a
  | EventLimit a
  deriving (Eq, Ord, Show)

instance (FromJSON a) => FromJSON (Event a) where
  parseJSON = withObject "events[]" $ \o -> do
    -- there must exactly one key
    when (length o /= 1) $ typeMismatch "events[]" (Object o)
    prependFailure "unknown event type" $
      EventIntroduced <$> o .: "introduced"
      <|> EventFixed <$> o .: "fixed"
      <|> EventLastAffected <$> o .: "last_affected"
      <|> EventLimit <$> o .: "limit"

instance (ToJSON a) => ToJSON (Event a) where
  toJSON ev = object . pure $ case ev of
    EventIntroduced a   -> "introduced"    .= a
    EventFixed a        -> "fixed"         .= a
    EventLastAffected a -> "last_affected" .= a
    EventLimit a        -> "limit"         .= a

-- | OSV model parameterised over database-specific and
-- ecosystem-specific fields.
--
-- A naïve consumer can parse @'Model' 'Value' Value Value Value@
-- for no loss of information.
--
-- A producer can instantiate unused database/ecosystem-specific
-- fields at @Data.Void.Void@.  '()' is not recommended, because
-- @'Just' ()@ will serialise as an empty JSON array.
--
data Model dbSpecific affectedEcosystemSpecific affectedDbSpecific rangeDbSpecific = Model
  { modelSchemaVersion :: Text  -- TODO make it a proper semver version type
  , modelId :: Text             -- TODO we should newtype it
  , modelModified :: UTCTime
  , modelPublished :: Maybe UTCTime
  , modelWithdrawn :: Maybe UTCTime
  , modelAliases :: [Text]
  , modelRelated :: [Text]
  , modelSummary :: Maybe Text
    -- ^ A one-line, English textual summary of the vulnerability. It is
    -- recommended that this field be kept short, on the order of no more than
    -- 120 characters.
  , modelDetails :: Maybe Text
    -- ^ CommonMark markdown giving additional English textual details about
    -- the vulnerability.
  , modelSeverity :: [Severity]
  , modelAffected :: [Affected affectedEcosystemSpecific affectedDbSpecific rangeDbSpecific]
  , modelReferences :: [Reference]
  , modelCredits :: [Credit]
  , modelDatabaseSpecific :: Maybe dbSpecific
  } deriving (Show, Eq)

-- | Schema version implemented by this library.  Currently @1.5.0@.
defaultSchemaVersion :: Text
defaultSchemaVersion = "1.5.0"

-- | Construct a new model with only the required fields
newModel
  :: Text -- ^ schema version
  -> Text -- ^ id
  -> UTCTime -- ^ modified
  -> Model dbs aes adbs rdbs
newModel ver ident modified = Model
  ver
  ident
  modified
  Nothing
  Nothing
  []
  []
  Nothing
  Nothing
  []
  []
  []
  []
  Nothing

-- | Construct a new model given @id@ and @modified@ values,
-- using 'defaultSchemaVersion'.
newModel'
  :: Text -- ^ id
  -> UTCTime -- ^ modified
  -> Model dbs aes adbs rdbs
newModel' = newModel defaultSchemaVersion

-- | Severity.  There is no 'Ord' instance.  Severity scores should be
-- calculated and compared in a more nuanced way than 'Ord' can provide
-- for.
--
newtype Severity = Severity CVSS.CVSS
  deriving (Show)

instance Eq Severity where
  Severity s1 == Severity s2 = CVSS.cvssVectorString s1 == CVSS.cvssVectorString s2

instance FromJSON Severity where
  parseJSON = withObject "severity" $ \o -> do
    typ <- o .: "type" :: Parser Text
    score <- o .: "score" :: Parser Text
    cvss <- case CVSS.parseCVSS score of
      Right cvss -> pure cvss
      Left err ->
        prependFailure ("unregognised severity score: " <> show err)
          $ typeMismatch "severity" (Object o)
    case typ of
      "CVSS_V2" | CVSS.cvssVersion cvss == CVSS.CVSS20 -> pure $ Severity cvss
      "CVSS_V3" | CVSS.cvssVersion cvss `elem` [CVSS.CVSS30, CVSS.CVSS31] -> pure $ Severity cvss
      s ->
        prependFailure ("unregognised severity type: " <> show s)
          $ typeMismatch "severity" (Object o)

instance ToJSON Severity where
  toJSON (Severity cvss) = object ["score" .= CVSS.cvssVectorString cvss, "type" .= typ]
    where
      typ :: Text
      typ = case CVSS.cvssVersion cvss of
        CVSS.CVSS31 -> "CVSS_V3"
        CVSS.CVSS30 -> "CVSS_V3"
        CVSS.CVSS20 -> "CVSS_V2"

data Package = Package
  { packageName :: Text
  , packageEcosystem :: Text
  , packagePurl :: Maybe Purl
  } deriving (Show, Eq)

data Range dbSpecific
  = RangeSemVer [Event Text {- TODO refine -}] (Maybe dbSpecific)
  | RangeEcosystem [Event Text] (Maybe dbSpecific)
  | RangeGit
      [Event Text {- TODO refine -}]
      Text -- ^ Git repo URL
      (Maybe dbSpecific)
  deriving (Eq, Show)

instance (FromJSON dbSpecific) => FromJSON (Range dbSpecific) where
  parseJSON = withObject "ranges[]" $ \o -> do
    typ <- o .: "type" :: Parser Text
    case typ of
      "SEMVER" -> RangeSemVer <$> o .: "events" <*> o .:? "database_specific"
      "ECOSYSTEM" -> RangeEcosystem <$> o .: "events" <*> o .:? "database_specific"
      "GIT" -> RangeGit <$> o .: "events" <*> o .: "repo" <*> o .:? "database_specific"
      s ->
        prependFailure ("unregognised range type: " <> show s)
          $ typeMismatch "ranges[]" (Object o)

instance (ToJSON dbSpecific) => ToJSON (Range dbSpecific) where
  toJSON range = object $ case range of
    RangeSemVer evs dbs -> [typ "SEMVER", "events" .= evs] <> mkDbSpecific dbs
    RangeEcosystem evs dbs -> [typ "ECOSYSTEM", "events" .= evs] <> mkDbSpecific dbs
    RangeGit evs repo dbs -> [typ "GIT", "events" .= evs, "repo" .= repo] <> mkDbSpecific dbs
    where
      mkDbSpecific = maybe [] (\v -> ["database_specific" .= v])
      typ s = "type" .= (s :: Text)

data ReferenceType
  = ReferenceTypeAdvisory
  -- ^ A published security advisory for the vulnerability.
  | ReferenceTypeArticle
  -- ^ An article or blog post describing the vulnerability.
  | ReferenceTypeDetection
  -- ^ A tool, script, scanner, or other mechanism that allows for detection of
  -- the vulnerability in production environments. e.g. YARA rules, hashes,
  -- virus signature, or other scanners.
  | ReferenceTypeDiscussion
  -- ^ A social media discussion regarding the vulnerability, e.g. a Twitter,
  -- Mastodon, Hacker News, or Reddit thread.
  | ReferenceTypeReport
  -- ^ A report, typically on a bug or issue tracker, of the vulnerability.
  | ReferenceTypeFix
  -- ^ A source code browser link to the fix (e.g., a GitHub commit) Note that
  -- the @Fix@ type is meant for viewing by people using web browsers. Programs
  -- interested in analyzing the exact commit range would do better to use the
  -- GIT-typed affected 'Range' entries.
  | ReferenceTypeIntroduced
  -- ^ A source code browser link to the introduction of the vulnerability
  -- (e.g., a GitHub commit) Note that the introduced type is meant for viewing
  -- by people using web browsers. Programs interested in analyzing the exact
  -- commit range would do better to use the GIT-typed affected  'Range'
  -- entries.
  | ReferenceTypePackage
  -- ^ A home web page for the package.
  | ReferenceTypeEvidence
  -- ^ A demonstration of the validity of a vulnerability claim, e.g.
  -- @app.any.run@ replaying the exploitation of the vulnerability.
  | ReferenceTypeWeb
  -- ^ A web page of some unspecified kind.
  deriving (Show, Eq, Enum, Bounded)

-- | Bijection of reference types and their string representations
referenceTypes :: [(ReferenceType, Text)]
referenceTypes =
  [ (ReferenceTypeAdvisory    , "ADVISORY")
  , (ReferenceTypeArticle     , "ARTICLE")
  , (ReferenceTypeDetection   , "DETECTION")
  , (ReferenceTypeDiscussion  , "DISCUSSION")
  , (ReferenceTypeReport      , "REPORT")
  , (ReferenceTypeFix         , "FIX")
  , (ReferenceTypeIntroduced  , "INTRODUCED")
  , (ReferenceTypePackage     , "PACKAGE")
  , (ReferenceTypeEvidence    , "EVIDENCE")
  , (ReferenceTypeWeb         , "WEB")
  ]

instance FromJSON ReferenceType where
  parseJSON = withText "references.type" $ \s ->
    case lookup s (fmap swap referenceTypes) of
      Just v  -> pure v
      Nothing -> typeMismatch "references.type" (String s)

instance ToJSON ReferenceType where
  toJSON v = String $ fromMaybe "WEB" (lookup v referenceTypes)

data Reference = Reference
  { referencesType :: ReferenceType
  , referencesUrl :: Text
  } deriving (Show, Eq)


-- | Types of individuals or entities to be credited in relation to
-- an advisory.
data CreditType
  = CreditTypeFinder
  -- ^ Identified the vulnerability
  | CreditTypeReporter
  -- ^ Notified the vendor of the vulnerability to a CNA
  | CreditTypeAnalyst
  -- ^ Validated the vulnerability to ensure accuracy or severity
  | CreditTypeCoordinator
  -- ^ Facilitated the coordinated response process
  | CreditTypeRemediationDeveloper
  -- ^ prepared a code change or other remediation plans
  | CreditTypeRemediationReviewer
  -- ^ Reviewed vulnerability remediation plans or code changes for effectiveness and completeness
  | CreditTypeRemediationVerifier
  -- ^ Tested and verified the vulnerability or its remediation
  | CreditTypeTool
  -- ^ Names of tools used in vulnerability discovery or identification
  | CreditTypeSponsor
  -- ^ Supported the vulnerability identification or remediation activities
  | CreditTypeOther
  -- ^ Any other type or role that does not fall under the categories described above
  deriving (Show, Eq)

-- | Bijection of credit types and their string representations
creditTypes :: [(CreditType, Text)]
creditTypes =
  [ (CreditTypeFinder               , "FINDER")
  , (CreditTypeReporter             , "REPORTER")
  , (CreditTypeAnalyst              , "ANALYST")
  , (CreditTypeCoordinator          , "COORDINATOR")
  , (CreditTypeRemediationDeveloper , "REMEDIATION_DEVELOPER")
  , (CreditTypeRemediationReviewer  , "REMEDIATION_REVIEWER")
  , (CreditTypeRemediationVerifier  , "REMEDIATION_VERIFIER")
  , (CreditTypeTool                 , "TOOL")
  , (CreditTypeSponsor              , "SPONSOR")
  , (CreditTypeOther                , "OTHER")
  ]

instance FromJSON CreditType where
  parseJSON = withText "credits[].type" $ \s ->
    case lookup s (fmap swap creditTypes) of
      Just v  -> pure v
      Nothing -> typeMismatch "credits[].type" (String s)

instance ToJSON CreditType where
  toJSON v = String $ fromMaybe "OTHER" (lookup v creditTypes)

data Credit = Credit
  { creditType :: CreditType
  , creditName :: Text
    -- ^ The name, label, or other identifier of the individual or entity
    -- being credited, using whatever notation the creditor prefers.
  , creditContacts :: [Text] -- TODO refine tpye
    -- ^ Fully qualified, plain-text URLs at which the credited can be reached.
  }
  deriving (Show, Eq)

instance FromJSON Credit where
  parseJSON = withObject "credits[]" $ \o -> do
    creditType <- o .: "type"
    creditName <- o .: "name"
    creditContacts <- o .::? "contact"
    pure $ Credit{..}

instance ToJSON Credit where
  toJSON Credit{..} = object $
    [ "type" .= creditType
    , "name" .= creditName
    ]
    <> omitEmptyList "contact" creditContacts
    where
      omitEmptyList _ [] = []
      omitEmptyList k xs = [k .= xs]


instance
    (ToJSON ecosystemSpecific, ToJSON dbSpecific, ToJSON rangeDbSpecific)
    => ToJSON (Affected ecosystemSpecific dbSpecific rangeDbSpecific) where
  toJSON Affected{..} = object $
    [ "ranges" .= affectedRanges
    , "package" .= affectedPackage
    ]
    <> omitEmptyList "severity" affectedSeverity
    <> maybe [] (pure . ("ecosystem_specific" .=)) affectedEcosystemSpecific
    <> maybe [] (pure . ("database_specific" .=)) affectedDatabaseSpecific
    where
      omitEmptyList _ [] = []
      omitEmptyList k xs = [k .= xs]

instance
  ( ToJSON dbSpecific
  , ToJSON affectedEcosystemSpecific
  , ToJSON affectedDbSpecific
  , ToJSON rangeDbSpecific
  ) => ToJSON (Model dbSpecific affectedEcosystemSpecific affectedDbSpecific rangeDbSpecific)
  where
  toJSON Model{..} = object $
    [ "schema_version" .= modelSchemaVersion
    , "id" .= modelId
    , "modified" .= modelModified
    ]
    <> catMaybes
      [ ("published" .=) <$> modelPublished
      , ("withdrawn" .=) <$> modelWithdrawn
      , ("aliases" .=) <$> omitEmptyList modelAliases
      , ("related" .=) <$> omitEmptyList modelRelated
      , ("summary" .=) <$> modelSummary
      , ("details" .=) <$> modelDetails
      , ("severity" .=) <$> omitEmptyList modelSeverity
      , ("affected" .=) <$> omitEmptyList modelAffected
      , ("references" .=) <$> omitEmptyList modelReferences
      , ("credits" .=) <$> omitEmptyList modelCredits
      , ("database_specific" .=) <$> modelDatabaseSpecific
    ]
    where
      omitEmptyList [] = Nothing
      omitEmptyList xs = Just xs

instance ToJSON Package where
  toJSON Package{..} = object $
    [ "name" .= packageName
    , "ecosystem" .= packageEcosystem
    ]
    <> maybe [] (pure . ("purl" .=)) packagePurl

instance ToJSON Reference where
  toJSON Reference{..} = object
    [ "type" .= referencesType
    , "url" .= referencesUrl
    ]

instance
    (FromJSON ecosystemSpecific, FromJSON dbSpecific, FromJSON rangeDbSpecific)
    => FromJSON (Affected ecosystemSpecific dbSpecific rangeDbSpecific) where
  parseJSON (Object v) = do
    affectedRanges <- v .: "ranges"
    affectedPackage <- v .: "package"
    affectedSeverity <- v .::? "severity"
    affectedEcosystemSpecific <- v .:? "ecosystem_specific"
    affectedDatabaseSpecific <- v .:? "database_specific"
    pure $ Affected{..}
  parseJSON invalid = do
    prependFailure "parsing Affected failed, "
      (typeMismatch "Object" invalid)

-- | Explicit parser for 'UTCTime', stricter than the @FromJSON@
-- instance for that type.
--
parseUTCTime :: Value -> Parser UTCTime
parseUTCTime = withText "UTCTime" $ \s ->
  case iso8601ParseM (T.unpack s) of
    Nothing -> typeMismatch "UTCTime" (String s)
    Just t -> pure t

-- | Parse helper for optional lists.  If the key is absent,
-- it will be interpreted as an empty list.
--
(.::?) :: FromJSON a => Object -> Key -> Parser [a]
o .::? k = fromMaybe [] <$> o .:? k

instance
  ( FromJSON dbSpecific
  , FromJSON affectedEcosystemSpecific
  , FromJSON affectedDbSpecific
  , FromJSON rangeDbSpecific
  ) => FromJSON (Model dbSpecific affectedEcosystemSpecific affectedDbSpecific rangeDbSpecific) where
  parseJSON = withObject "osv-schema" $ \v -> do
    modelSchemaVersion <- v .: "schema_version"
    modelId <- v .: "id"
    modelModified <- explicitParseField parseUTCTime v "modified"
    modelPublished <- explicitParseFieldMaybe parseUTCTime v "published"
    modelWithdrawn <- explicitParseFieldMaybe parseUTCTime v "withdrawn"
    modelAliases <- v .::? "aliases"
    modelRelated <- v .::? "related"
    modelSummary <- v .:? "summary"
    modelDetails <- v .:? "details"
    modelSeverity <- v .::? "severity"
    modelAffected <- v .::? "affected"
    modelReferences <- v .::? "references"
    modelCredits <- v .::? "credits"
    modelDatabaseSpecific <- v .:? "database_specific"
    pure $ Model{..}

instance FromJSON Package where
  parseJSON (Object v) = do
    packageName <- v .: "name"
    packageEcosystem <- v .: "ecosystem"
    packagePurl <- v .:? "purl"
    pure $ Package{..}
  parseJSON invalid = do
    prependFailure "parsing Package failed, "
      (typeMismatch "Object" invalid)

instance FromJSON Reference where
  parseJSON (Object v) = do
    referencesType <- v .: "type"
    referencesUrl <- v .: "url"
    pure $ Reference{..}
  parseJSON invalid = do
    prependFailure "parsing References failed, "
      (typeMismatch "Object" invalid)