Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add sarif output (GitHub Code Scan) #50

Merged
merged 1 commit into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cabal-audit.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ library
, optparse-applicative
, pretty
, process
, sarif
, temporary
, text
, transformers
Expand Down
2 changes: 2 additions & 0 deletions nix/cabal-audit.nix
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
optparse-applicative,
pretty,
process,
sarif,
temporary,
text,
transformers,
Expand Down Expand Up @@ -51,6 +52,7 @@ mkDerivation {
optparse-applicative
pretty
process
sarif
temporary
text
transformers
Expand Down
232 changes: 200 additions & 32 deletions src/Distribution/Audit.hs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{-# LANGUAGE CPP #-}

-- | provides the @cabal-audit@ plugin which works as follows:
--
-- 1. parse command line arguments to pass on to cabal to build
Expand All @@ -19,13 +21,17 @@ import Data.Aeson (KeyValue ((.=)), Value, object)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BSL
import Data.Coerce (coerce)
import Data.Foldable (for_)
import Data.Foldable (fold, for_)
import Data.Functor ((<&>))
import Data.Functor.Identity (Identity (runIdentity))
import Data.Map qualified as M
import Data.SARIF as Sarif
import Data.String (IsString (fromString))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector (Vector)
import Data.Vector qualified as V
import Distribution.Client.DistDirLayout (DistDirLayout (distProjectRootDirectory))
import Distribution.Client.NixStyleOptions (NixStyleFlags, defaultNixStyleFlags)
import Distribution.Client.ProjectConfig (ProjectConfig)
import Distribution.Client.ProjectOrchestration
Expand Down Expand Up @@ -86,6 +92,8 @@ data OutputFormat
HumanReadable
| -- | write as Osv format to the specified file
Osv
| -- | write as Sarif format to the specified file (for GitHub Code scanning)
Sarif

-- | configuration that is specific to the cabal audit command
data AuditConfig = MkAuditConfig
Expand Down Expand Up @@ -119,8 +127,8 @@ auditMain = do
runM $ interpPretty do
advisories <-
( do
advisories <- buildAdvisories auditConfig nixStyleFlags
handleBuiltAdvisories (outputHandle auditConfig) (outputFormat auditConfig) advisories
(advisories, projectBaseContext) <- buildAdvisories auditConfig nixStyleFlags
handleBuiltAdvisories advisories projectBaseContext (outputHandle auditConfig) (outputFormat auditConfig)
pure advisories
)
`catch` \(SomeException ex) -> do
Expand All @@ -138,11 +146,11 @@ buildAdvisories
:: (MonadUnliftIO m, Has (Pretty [Text]) sig m)
=> AuditConfig
-> NixStyleFlags ()
-> m (M.Map PackageName ElaboratedPackageInfoAdvised)
-> m (M.Map PackageName ElaboratedPackageInfoAdvised, ProjectBaseContext)
buildAdvisories MkAuditConfig {advisoriesPathOrURL, verbosity} flags = do
let cliConfig = projectConfigFromFlags flags

ProjectBaseContext {distDirLayout, cabalDirLayout, projectConfig, localPackages} <-
projectBaseContext@ProjectBaseContext {distDirLayout, cabalDirLayout, projectConfig, localPackages} <-
liftIO do
establishProjectBaseContext verbosity cliConfig OtherCommand
`catch` \ex ->
Expand Down Expand Up @@ -172,15 +180,22 @@ buildAdvisories MkAuditConfig {advisoriesPathOrURL, verbosity} flags = do
liftIO $ callProcess "git" ["clone", "--depth", "1", url, tmp]
k tmp

pure $ matchAdvisoriesForPlan plan advisories
pure (matchAdvisoriesForPlan plan advisories, projectBaseContext)

-- | provides the built advisories in some consumable form, e.g. as human readable form
--
-- FUTUREWORK(mangoiv): provide output as JSON
handleBuiltAdvisories :: (MonadUnliftIO m, Has (Pretty [Text]) sig m) => Codensity IO Handle -> OutputFormat -> M.Map PackageName ElaboratedPackageInfoAdvised -> m ()
handleBuiltAdvisories mkHandle = \case
HumanReadable -> humanReadableHandler mkHandle . M.toList
Osv -> osvHandler mkHandle
handleBuiltAdvisories
:: (MonadUnliftIO m, Has (Pretty [Text]) sig m)
=> M.Map PackageName ElaboratedPackageInfoAdvised
-> ProjectBaseContext
-> Codensity IO Handle
-> OutputFormat
-> m ()
handleBuiltAdvisories mp pbc mkHandle = \case
HumanReadable -> humanReadableHandler mkHandle $ M.toList mp
Osv -> osvHandler mkHandle mp
Sarif -> sarifHandler mkHandle pbc $ M.toList mp

osvHandler :: MonadUnliftIO m => Codensity IO Handle -> M.Map PackageName ElaboratedPackageInfoAdvised -> m ()
osvHandler mkHandle mp =
Expand All @@ -194,22 +209,162 @@ osvHandler mkHandle mp =
]
]

prettyAdvisory :: Advisory -> Maybe Version -> Vector ([Text], Text)
prettyAdvisory Advisory {advisoryId, advisoryPublished, advisoryKeywords, advisorySummary} mfv =
let hsecId = T.pack (printHsecId advisoryId)
indentLine line = [([], " ")] <> line <> [([], "\n")]
in foldMap @[]
indentLine
[ [([bold, blue], hsecId <> " \"" <> advisorySummary <> "\"")]
, [([], "published: ") <> ([bold], T.pack $ show advisoryPublished)]
, [([], "https://haskell.github.io/security-advisories/advisory/" <> hsecId)]
, fixAvailable
, [([blue], T.intercalate ", " (coerce advisoryKeywords))]
]
sarifHandler
:: MonadUnliftIO m
=> Codensity IO Handle
-> ProjectBaseContext
-> [(PackageName, ElaboratedPackageInfoAdvised)]
-> m ()
sarifHandler mkHandle projectBaseContext packageAdvisories = do
let projectRoot = distProjectRootDirectory $ distDirLayout projectBaseContext -- TODO(blackheaven): resolve repository root (in GitHub Action, we get the cloned directory, instead of the directory from repository's root)
let advisories =
M.elems $
M.fromListWith (\(advisory, pkgsInfo) (_, pkgsInfo') -> (advisory, pkgsInfo <> pkgsInfo')) $
packageAdvisories >>= \(pkgName, pkgInfo) ->
runIdentity pkgInfo.packageAdvisories <&> \(advisory, fixedAt) ->
(advisory.advisoryId, (advisory, [(T.pack $ unPackageName pkgName, fixedAt)]))
run =
MkRun
{ runTool =
let tool name version =
defaultToolComponent
{ toolComponentName = Just name
, toolComponentVersion = Just version
}
in MkTool
{ toolExtensions =
[ tool "hsec-tools" VERSION_hsec_tools
, tool "ghc" $ T.pack __GLASGOW_HASKELL_FULL_VERSION__
]
, toolDriver = tool "cabal-audit" VERSION_cabal_audit
}
, runResults =
advisories <&> \(advisory, concernedInfo) ->
MkResult
{ resultRuleId = T.pack $ printHsecId advisory.advisoryId
, resultMessage =
MkMultiformatMessageString
{ mmsText = fold $ prettyAdvisory advisory $ prettyTextSummary $ fst <$> concernedInfo
, mmsMarkdown = Just $ fold $ prettyAdvisory advisory $ prettyMarkdown $ prettyMultiplePackages concernedInfo
}
, resultLocations =
[ -- TODO(blackheaven) cabal files/lock?
MkLocation $
Just $
MkPhysicalLocation
{ physicalLocationArtifactLocation = MkArtifactLocation $ T.pack ("file:///" <> projectRoot)
, physicalLocationRegion = MkRegion 1 1 2 2 -- TODO(blackheaven): inspect lock file to find exact position
}
]
, resultLevel = Just Sarif.Error
}
, runArtifacts =
[ -- TODO(blackheaven) cabal files/lock?
MkArtifact
{ artifactLocation = MkArtifactLocation $ T.pack ("file:///" <> projectRoot)
, artifactMimeType = Nothing
}
]
}
withRunCodensityInIO mkHandle \hdl ->
liftIO . BSL.hPutStr hdl . Aeson.encode $ defaultLog {logRuns = [run]}

data Segment = Segment
{ sConsoleColors :: [Text]
, sMarkdownPrefix :: Text
, sText :: Text
}

data Line = Line (Vector Segment) | BlockLine (Vector Segment) | EmptyLine

data PrettyArgs a = PrettyArgs
{ paFixed :: Vector Line
, paLine :: Line -> Vector a
, paSubtitle :: Vector Segment
}

prettyTextSummary :: [Text] -> PrettyArgs Text
prettyTextSummary packageNames =
PrettyArgs
{ paFixed = mempty
, paLine =
\case
Line xs | filledLine xs -> (sText <$> xs) <> ["\n"]
BlockLine xs | filledLine xs -> (sText <$> xs) <> ["\n"]
Line _ -> mempty
BlockLine _ -> mempty
EmptyLine -> ["\n"]
, paSubtitle = [Segment [] "" $ "(" <> T.intercalate ", " packageNames <> ")"]
}

prettyMultiline :: Vector Segment -> PrettyArgs ([Text], Text)
prettyMultiline fixedLine =
PrettyArgs
{ paFixed = [BlockLine fixedLine]
, paLine =
\case
Line xs | filledLine xs -> [([], " ")] <> (mkSegment <$> xs) <> nl
BlockLine xs | filledLine xs -> [([], " ")] <> (mkSegment <$> xs) <> nl
Line _ -> mempty
BlockLine _ -> mempty
EmptyLine -> nl
, paSubtitle = mempty
}
where
mkSegment s = (sConsoleColors s, sText s)
nl = [([], "\n")]

prettyMarkdown :: Vector Line -> PrettyArgs Text
prettyMarkdown pkgs =
PrettyArgs
{ paFixed = pkgs
, paLine =
\case
Line xs | filledLine xs -> (mkSegment <$> xs) <> ["\n"]
BlockLine xs | filledLine xs -> (mkSegment <$> xs) <> ["\n\n"]
Line _ -> mempty
BlockLine _ -> mempty
EmptyLine -> ["\n"]
, paSubtitle = mempty
}
where
mkSegment s = sMarkdownPrefix s <> sText s

filledLine :: Foldable t => t Segment -> Bool
filledLine = not . all (T.null . sText)

prettySinglePackage :: Maybe Version -> Vector Segment
prettySinglePackage =
\case
Nothing ->
[ Segment [bold, red] "" "No fix version available"
]
Just fv ->
[ Segment [bold, green] "" "Fix available since version "
, Segment [yellow] "" $ prettyVersion fv
]

prettyMultiplePackages :: [(Text, Maybe Version)] -> Vector Line
prettyMultiplePackages packages =
[Line [Segment [] "" "Concerned package:"]]
<> ( V.fromList packages <&> \(pkgName, mfv) ->
Line $ [Segment [] "" $ "* " <> pkgName <> ": "] <> prettySinglePackage mfv
)
<> [EmptyLine]

prettyAdvisory :: Advisory -> PrettyArgs a -> Vector a
prettyAdvisory Advisory {advisoryId, advisoryPublished, advisoryKeywords, advisorySummary} args =
foldMap @Vector
(foldMap $ paLine args)
[ [BlockLine [Segment [bold, blue] "# " $ hsecId <> " \"" <> advisorySummary <> "\""]]
, [BlockLine $ paSubtitle args]
, [BlockLine [Segment [] "" "published: ", Segment [bold] "" $ T.pack $ show advisoryPublished]]
, [BlockLine [Segment [] "" $ "https://haskell.github.io/security-advisories/advisory/" <> hsecId]]
, paFixed args
, [BlockLine [Segment [blue] "" $ T.intercalate ", " (coerce advisoryKeywords)]]
]
where
fixAvailable = case mfv of
Nothing -> [([bold, red], "No fix version available")]
Just fv -> [([bold, green], "Fix available since version "), ([yellow], prettyVersion fv)]
hsecId = T.pack (printHsecId advisoryId)

withRunCodensityInIO :: MonadUnliftIO m => Codensity IO a -> (a -> m b) -> m b
withRunCodensityInIO cod k = withRunInIO \inIO -> runCodensity cod (inIO . k)
Expand All @@ -229,7 +384,9 @@ humanReadableHandler mkHandle =
let verString = ([yellow], prettyVersion $ elaboratedPackageVersion i)
pkgName = ([yellow], T.pack $ show $ unPackageName pn)
pwetty hdl [([], "dependency "), pkgName, ([], " at version "), verString, ([], " is vulnerable for:")]
for_ (runIdentity (packageAdvisories i)) (pwetty hdl . uncurry prettyAdvisory)
for_
(runIdentity (packageAdvisories i))
(pwetty hdl . uncurry prettyAdvisory . fmap (prettyMultiline . prettySinglePackage))

projectConfigFromFlags :: NixStyleFlags a -> ProjectConfig
projectConfigFromFlags flags = commandLineFlagsToProjectConfig defaultGlobalFlags flags mempty
Expand Down Expand Up @@ -264,12 +421,23 @@ auditCommandParser =
"verbose" -> Right Verbosity.verbose
"deafening" -> Right Verbosity.deafening
_ -> Left "verbosity has to be one of \"silent\", \"normal\", \"verbose\" or \"deafening\""
<*> flag HumanReadable Osv do
mconcat
[ long "json"
, short 'm'
, help "whether to format as json mapping package names to osvs that apply"
]
<*> ( flag'
Osv
( mconcat
[ long "json"
, short 'm'
, help "whether to format as json mapping package names to osvs that apply"
]
)
<|> flag'
Sarif
( mconcat
[ long "sarif"
, help "produce a sarif file (GitHub Code Scanning)"
]
)
<|> pure HumanReadable
)
<*> do
let mkFileHandle fp = Codensity (withFile fp WriteMode)
mkFileHandle
Expand Down
Loading