packages feed

seihou-cli-0.16.0.0: src/Seihou/CLI/Judgement/Validate.hs

-- | @seihou validate-judgement@: every problem with a judgement artifact,
-- named, before any provider call.
--
-- A target is a directory holding @judgement.dhall@ (and optionally
-- @examples.json@), a path to that file, or a judgement name found through
-- the ordinary search paths. Validation decodes the definition, checks it
-- structurally, checks the examples against it, and, when the scanner is
-- available, scans every example's contents so a real credential is caught
-- before evaluation could send it anywhere.
module Seihou.CLI.Judgement.Validate
  ( JudgementValidation (..),
    resolveJudgementTarget,
    discoverJudgementNames,
    activeJudgementNames,
    validateJudgementDir,
    loadExamples,
    renderValidation,
    exampleEvidence,
  )
where

import Control.Monad (filterM, forM)
import Data.ByteString.Lazy qualified as LBS
import Data.Containers.ListUtils (nubOrd)
import Data.Generics.Labels ()
import Data.List (isPrefixOf)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Seihou.CLI.Judgement.Evidence (EvidenceFile (..), FileChange (..))
import Seihou.CLI.Judgement.Scanner (findScanner, flaggedPaths, scanCaptured)
import Seihou.Core.Judgement
import Seihou.Core.JudgementExamples
import Seihou.Core.Module (defaultSearchPaths)
import Seihou.Core.Types (ModuleLoadError (..))
import Seihou.Dhall.Eval (evalJudgementFromFile)
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Effect.ManifestStore (readManifest)
import Seihou.Effect.ManifestStoreInterp (runManifestStore)
import Seihou.Prelude
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (takeDirectory, takeFileName)

data JudgementValidation = JudgementValidation
  { dir :: !FilePath,
    definition :: !(Maybe JudgementDefinition),
    examples :: !(Maybe JudgementExamples),
    errors :: ![JudgementError],
    -- | Advisory notes that do not fail validation.
    notes :: ![Text]
  }
  deriving stock (Eq, Show, Generic)

-- | A path to a directory or @judgement.dhall@, or a name to discover.
resolveJudgementTarget :: Maybe Text -> IO (Either Text FilePath)
resolveJudgementTarget = \case
  Nothing -> pure (Right ".")
  Just t -> do
    let p = T.unpack t
    isDir <- doesDirectoryExist p
    isFile <- doesFileExist p
    case () of
      _
        | isDir -> pure (Right p)
        | isFile && takeFileName p == "judgement.dhall" -> pure (Right (takeDirectory p))
        | otherwise -> do
            roots <- defaultSearchPaths
            found <- filterM (\r -> doesFileExist (r </> p </> "judgement.dhall")) roots
            pure $ case found of
              (r : _) -> Right (r </> p)
              [] -> Left ("No judgement '" <> t <> "' was found as a path or in " <> T.intercalate ", " (map T.pack roots))
  where
    filterM f = foldr (\x acc -> f x >>= \keep -> (if keep then (x :) else id) <$> acc) (pure [])

-- | The evidence an example stands for, one file per example file, keyed
-- @<example id>/<path>@ so cases never collide.
exampleEvidence :: JudgementExample -> [EvidenceFile]
exampleEvidence e =
  [ EvidenceFile (T.unpack (e ^. #id) </> f ^. #path) Added "100644" Nothing Nothing (Just (TE.encodeUtf8 (f ^. #content))) Nothing Nothing
  | f <- e ^. #files
  ]

loadExamples :: FilePath -> JudgementDefinition -> IO (Either [JudgementError] (Maybe JudgementExamples))
loadExamples dir def = do
  let file = dir </> "examples.json"
  present <- doesFileExist file
  if not present
    then pure (Right Nothing)
    else do
      bytes <- LBS.readFile file
      pure $ case parseExamples "examples.json" bytes of
        Left err -> Left [err]
        Right ex -> case validateExamples def ex of
          [] -> Right (Just ex)
          errs -> Left errs

validateJudgementDir :: FilePath -> IO JudgementValidation
validateJudgementDir dir = do
  let file = dir </> "judgement.dhall"
  present <- doesFileExist file
  if not present
    then pure (JudgementValidation dir Nothing Nothing [judgementError "judgement_missing" (T.pack file <> " does not exist")] [])
    else
      evalJudgementFromFile file >>= \case
        Left err -> pure (JudgementValidation dir Nothing Nothing [judgementError "judgement_decode" (loadErrorText err)] [])
        Right def -> case validateDefinition def of
          Left err -> pure (JudgementValidation dir (Just def) Nothing [err] [])
          Right () ->
            loadExamples dir def >>= \case
              Left errs -> pure (JudgementValidation dir (Just def) Nothing errs [])
              Right Nothing -> pure (JudgementValidation dir (Just def) Nothing [] ["no examples.json; judgement eval needs labelled examples"])
              Right (Just ex) -> do
                (scanErrors, scanNotes) <- scanExamples def ex
                pure (JudgementValidation dir (Just def) (Just ex) scanErrors scanNotes)
  where
    loadErrorText = \case
      DhallEvalError _ reason -> T.take 2000 reason
      DhallDecodeError _ reason -> T.take 2000 reason
      _ -> "judgement.dhall could not be loaded"

-- | Scan the examples. A hit is an error unless the judgement declares the
-- secret scan and the example expects a block: that material is stopped by
-- the scanner before any request, so it is a valid deterministic case.
scanExamples :: JudgementDefinition -> JudgementExamples -> IO ([JudgementError], [Text])
scanExamples def ex =
  findScanner >>= \case
    Nothing -> pure ([], ["gitleaks was not found, so the examples were not scanned for credentials"])
    Just sc -> do
      let files = concatMap exampleEvidence (ex ^. #examples)
          expectsBlock e = ExpectBlocked `elem` Map.elems (e ^. #expect)
          allowed = [T.unpack (e ^. #id) | SecretScan `elem` (def ^. #deterministicChecks), e <- ex ^. #examples, expectsBlock e]
          exempt p = any (\i -> (i <> "/") `isPrefixOf` p) allowed
      scanCaptured sc files [] <&> \case
        Left err -> ([judgementError "judgement_examples_scan_failed" ("the examples could not be scanned: " <> err)], [])
        Right result ->
          ( [ judgementError "judgement_example_secret" ("'" <> T.pack p <> "' looks like a real credential to the secret scanner; examples must be synthetic, and only a block expected under the declared secret scan may contain one")
            | p <- flaggedPaths result,
              not (exempt p)
            ],
            []
          )

renderValidation :: JudgementValidation -> Text
renderValidation v =
  T.unlines $
    [ case v ^. #definition of
        Just d -> "Judgement '" <> d ^. #name <> "' " <> fromMaybe "(unversioned)" (d ^. #version) <> " at " <> T.pack (v ^. #dir)
        Nothing -> "Judgement at " <> T.pack (v ^. #dir)
    ]
      <> ["  error " <> e ^. #code <> ": " <> e ^. #message | e <- v ^. #errors]
      <> ["  note: " <> n | n <- v ^. #notes]
      <> [ if null (v ^. #errors)
             then "  valid: " <> maybe "" (\d -> T.pack (show (length (d ^. #checks))) <> " check(s)") (v ^. #definition) <> maybe "" (\e -> ", " <> T.pack (show (length (e ^. #examples))) <> " example(s)") (v ^. #examples)
             else "  invalid: " <> T.pack (show (length (v ^. #errors))) <> " problem(s)"
         ]

-- | The names of judgements in the standard search paths, for shell
-- completion: each directory holding a @judgement.dhall@. Nothing is
-- evaluated, so completion stays fast and never fails.
discoverJudgementNames :: IO [String]
discoverJudgementNames = do
  paths <- defaultSearchPaths
  found <- forM paths $ \dir -> do
    present <- doesDirectoryExist dir
    if not present
      then pure []
      else do
        entries <- listDirectory dir
        filterM (\e -> doesFileExist (dir </> e </> "judgement.dhall")) entries
  pure (nubOrd (concat found))

-- | The names of the judgements active in the project at this directory,
-- read from its manifest without changing anything.
activeJudgementNames :: FilePath -> IO [String]
activeJudgementNames root =
  runEff (runFilesystem (runManifestStore (root </> ".seihou" </> "manifest.json") readManifest)) <&> \case
    Right (Just m) -> [T.unpack (j ^. #definition . #name) | j <- m ^. #judgements]
    _ -> []