packages feed

hsec-tools-0.2.0.2: src/Security/Advisories/Parse.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-orphans #-}

 module Security.Advisories.Parse
 ( parseAdvisory
 , OOB
 , OOBError (..)
 , OutOfBandAttributes(..)
 , displayOOBError
 , AttributeOverridePolicy(..)
 , ParseAdvisoryError(..)
 , validateComponentIdentifier
 )
where

import Control.Exception (Exception(displayException))
import Data.Bifunctor (first)
import Data.Foldable (toList)
import Data.Maybe (fromMaybe)
import Data.Monoid (First(..))

import Data.Tuple (swap)
import Control.Applicative ((<|>))

import GHC.Generics (Generic)

import Data.Sequence (Seq((:<|)))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as T (toStrict)
import Data.Time (UTCTime(..))

import Commonmark.Html (Html, renderHtml)
import qualified Commonmark.Parser as Commonmark
import Commonmark.Types (HasAttributes(..), IsBlock(..), IsInline(..), Rangeable(..), SourceRange(..))
import Commonmark.Pandoc (Cm(unCm))
import qualified Toml
import qualified Toml.Syntax as Toml (startPos)
import qualified Toml.Schema as Toml
import Text.Pandoc.Builder (Blocks, Many(..))
import Text.Pandoc.Definition (Block(..), Inline(..), Pandoc(..))
import Text.Pandoc.Walk (query)
import Text.Parsec.Pos (sourceLine)

import Security.Advisories.Core.Advisory
import Security.Advisories.Format (FrontMatter(..), AdvisoryMetadata(..))
import Security.Advisories.Git (GitError, explainGitError)

-- | if there are no out of band attributes, attach a reason why that's the case
--
-- @since 0.2.0.0
type OOB = Either OOBError OutOfBandAttributes

-- | A source of attributes supplied out of band from the advisory
-- content.  Values provided out of band are treated according to
-- the 'AttributeOverridePolicy'.
data OutOfBandAttributes = OutOfBandAttributes
  { oobModified :: UTCTime
  , oobPublished :: UTCTime
  , oobComponentIdentifier :: Maybe ComponentIdentifier
  }
  deriving (Show)

data AttributeOverridePolicy
  = PreferInBand
  | PreferOutOfBand
  | NoOverrides -- ^ Parse error if attribute occurs both in-band and out-of-band
  deriving (Show, Eq)

data ParseAdvisoryError
    = MarkdownError Commonmark.ParseError Text
    | MarkdownFormatError Text
    | TomlError String Text
    | AdvisoryError [Toml.MatchMessage Toml.Position] T.Text
    deriving stock (Eq, Show, Generic)

-- | @since 0.2.0.0
instance Exception ParseAdvisoryError where
  displayException = T.unpack . \case
    MarkdownError _ explanation -> "Markdown parsing error:\n" <> explanation
    MarkdownFormatError explanation -> "Markdown structure error:\n" <> explanation
    TomlError _ explanation -> "Couldn't parse front matter as TOML:\n" <> explanation
    AdvisoryError _ explanation -> "Advisory structure error:\n" <> explanation

-- | errors that may occur while ingesting oob data
--
-- @since 0.2.0.0
data OOBError
  = StdInHasNoOOB -- ^ we obtain the advisory via stdin and can hence not parse git history
  | PathHasNoComponentIdentifier -- ^ the path is missing 'hackage' or 'ghc' directory
  | GitHasNoOOB GitError -- ^ processing oob info via git failed
  deriving stock (Eq, Show, Generic)

displayOOBError :: OOBError -> String
displayOOBError = \case
  StdInHasNoOOB -> "stdin doesn't provide out of band information"
  PathHasNoComponentIdentifier -> "the path is missing 'hackage' or 'ghc' directory"
  GitHasNoOOB gitErr -> "no out of band information obtained with git error:\n"
    <> explainGitError gitErr

parseAdvisory
  :: AttributeOverridePolicy
  -> OOB
  -> T.Text -- ^ input (CommonMark with TOML header)
  -> Either ParseAdvisoryError Advisory
parseAdvisory policy attrs raw = do
  markdown <-
    unCm
    <$> firstPretty MarkdownError (T.pack . show)
          (Commonmark.commonmark "input" raw :: Either Commonmark.ParseError (Cm () Blocks))
  (frontMatter, rest) <- first MarkdownFormatError $ advisoryDoc markdown
  let doc = Pandoc mempty rest
  !summary <- first MarkdownFormatError $ parseAdvisorySummary doc
  table <- case Toml.parse frontMatter of
    Left e -> Left (TomlError e (T.pack e))
    Right t -> Right t

  -- Re-parse as FirstSourceRange to find the source range of
  -- the TOML header.
  FirstSourceRange (First mRange) <-
    firstPretty MarkdownError (T.pack . show) (Commonmark.commonmark "input" raw)
  let
    details = case mRange of
      Just (SourceRange ((_,end):_)) ->
        T.unlines
        . dropWhile T.null
        . fmap snd
        . dropWhile ((< sourceLine end) . fst)
        . zip [1..]
        $ T.lines raw
      _ ->
        -- no block elements?  empty range list?
        -- these shouldn't happen, but better be total
        raw
  -- Re-parse input as HTML.  This will probably go away; we now store the
  -- Pandoc doc and can render that instead, where needed.
  html <-
    T.toStrict . renderHtml
    <$> firstPretty MarkdownError (T.pack . show)
          (Commonmark.commonmark "input" raw :: Either Commonmark.ParseError (Html ()))

  case parseAdvisoryTable attrs policy doc summary details html table of
    Left es -> Left (AdvisoryError es (T.pack (unlines (map Toml.prettyMatchMessage es))))
    Right adv -> pure adv

  where
    firstPretty
      :: (e -> Text -> ParseAdvisoryError)
      -> (e -> Text)
      -> Either e a
      -> Either ParseAdvisoryError a
    firstPretty ctr pretty = first $ mkPretty ctr pretty

    mkPretty
      :: (e -> Text -> ParseAdvisoryError)
      -> (e -> Text)
      -> e
      -> ParseAdvisoryError
    mkPretty ctr pretty x = ctr x $ pretty x

parseAdvisoryTable
  :: OOB
  -> AttributeOverridePolicy
  -> Pandoc -- ^ parsed document (without frontmatter)
  -> Text -- ^ summary
  -> Text -- ^ details
  -> Text -- ^ rendered HTML
  -> Toml.Table' Toml.Position
  -> Either [Toml.MatchMessage Toml.Position] Advisory
parseAdvisoryTable oob policy doc summary details html tab =
  Toml.runMatcherFatalWarn $
   do fm <- Toml.fromValue (Toml.Table' Toml.startPos tab)
      published <-
        mergeOobMandatory policy
          (oobPublished <$> oob)
          displayOOBError
          "advisory.date"
          (amdPublished (frontMatterAdvisory fm))
      modified <-
        fromMaybe published <$>
          mergeOobOptional policy
            (oobPublished <$> oob)
            "advisory.modified"
            (amdModified (frontMatterAdvisory fm))
      let affected = frontMatterAffected fm
      case oob of
        Right (OutOfBandAttributes _ _ (Just ecosystem)) -> validateComponentIdentifier ecosystem affected
        _ -> pure ()
      pure Advisory
        { advisoryId = amdId (frontMatterAdvisory fm)
        , advisoryPublished = published
        , advisoryModified = modified
        , advisoryCAPECs = amdCAPECs (frontMatterAdvisory fm)
        , advisoryCWEs = amdCWEs (frontMatterAdvisory fm)
        , advisoryKeywords = amdKeywords (frontMatterAdvisory fm)
        , advisoryAliases = amdAliases (frontMatterAdvisory fm)
        , advisoryRelated = amdRelated (frontMatterAdvisory fm)
        , advisoryAffected = affected
        , advisoryReferences = frontMatterReferences fm
        , advisoryPandoc = doc
        , advisoryHtml = html
        , advisorySummary = summary
        , advisoryDetails = details
        }

-- | Make sure one of the affected match the ecosystem
validateComponentIdentifier :: MonadFail m => ComponentIdentifier -> [Affected] -> m ()
validateComponentIdentifier ecosystem xs
  | any (\affected -> affectedComponentIdentifier affected == ecosystem) xs = pure ()
  | otherwise = fail $ "Expected an affected to match the ecosystem: " <> show ecosystem

advisoryDoc :: Blocks -> Either Text (Text, [Block])
advisoryDoc (Many blocks) = case blocks of
    CodeBlock (_, classes, _) frontMatter :<| t
        | "toml" `elem` classes ->
            pure (frontMatter, toList t)
    _ ->
        Left "Does not have toml code block as first element"

parseAdvisorySummary :: Pandoc -> Either Text Text
parseAdvisorySummary = fmap inlineText . firstHeading

firstHeading :: Pandoc -> Either Text [Inline]
firstHeading (Pandoc _ xs) = go xs
  where
    go [] = Left "Does not have summary heading"
    go (Header _ _ ys : _) = Right ys
    go (_ : t) = go t

-- yield "plain" terminal inline content; discard formatting
inlineText :: [Inline] -> Text
inlineText = query f
  where
    f inl = case inl of
        Str s -> s
        Code _ s -> s
        Space -> " "
        SoftBreak -> " "
        LineBreak -> "\n"
        Math _ s -> s
        RawInline _ s -> s
        _ -> ""

mergeOob
  :: MonadFail m
  => AttributeOverridePolicy
  -> Either e a  -- ^ out-of-band value
  -> String  -- ^ key
  -> Maybe a -- ^ in-band-value
  -> (e -> m b)  -- ^ when key and out-of-band value absent
  -> (a -> m b) -- ^ when value present
  -> m b
mergeOob policy oob k ib absent present = do
  case (oob, ib) of
    (Right l, Just r) -> case policy of
      NoOverrides -> fail ("illegal out of band override: " ++ k)
      PreferOutOfBand -> present l
      PreferInBand -> present r
    (Right a, Nothing) -> present a
    (Left _, Just a) -> present a
    (Left e, Nothing) -> absent e

mergeOobOptional
  :: MonadFail m
  => AttributeOverridePolicy
  -> Either e a  -- ^ out-of-band value
  -> String -- ^ key
  -> Maybe a -- ^ in-band-value
  -> m (Maybe a)
mergeOobOptional policy oob k ib =
  mergeOob policy oob k ib (const $ pure Nothing) (pure . Just)

mergeOobMandatory
  :: MonadFail m
  => AttributeOverridePolicy
  -> Either e a  -- ^ out-of-band value
  -> (e -> String) -- ^ how to display information about a missing out of band value
  -> String  -- ^ key
  -> Maybe a -- ^ in-band value
  -> m a
mergeOobMandatory policy eoob doob k ib =
  mergeOob policy eoob k ib everythingFailed pure
    where
      everythingFailed e = fail $ unlines
        [ "while trying to lookup mandatory key " <> show k <> ":"
        , doob e
        ]

{- | A solution to an awkward problem: how to delete the TOML
 block.  We parse into this type to get the source range of
 the first block element.  We can use it to delete the lines
 from the input.
-}
newtype FirstSourceRange = FirstSourceRange (First SourceRange)
    deriving (Show, Semigroup, Monoid)

instance Rangeable FirstSourceRange where
    ranged range = (FirstSourceRange (First (Just range)) <>)

instance HasAttributes FirstSourceRange where
    addAttributes _ = id

instance IsBlock FirstSourceRange FirstSourceRange where
    paragraph _ = mempty
    plain _ = mempty
    thematicBreak = mempty
    blockQuote _ = mempty
    codeBlock _ = mempty
    heading _ = mempty
    rawBlock _ = mempty
    referenceLinkDefinition _ = mempty
    list _ = mempty

instance IsInline FirstSourceRange where
    lineBreak = mempty
    softBreak = mempty
    str _ = mempty
    entity _ = mempty
    escapedChar _ = mempty
    emph = id
    strong = id
    link _ _ _ = mempty
    image _ _ _ = mempty
    code _ = mempty
    rawInline _ _ = mempty