packages feed

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

-- | The deterministic secret scanner, Gitleaks, run on exactly the captured
-- bytes.
--
-- The captured post-change content is written to a private temporary tree
-- (removed on every exit path) and scanned with @gitleaks dir@. The scan uses
-- a Seihou-owned configuration that extends only Gitleaks' built-in rules,
-- ignores @gitleaks:allow@ comments, points the ignore-file lookup at an
-- empty directory and clears @GITLEAKS_*@ environment overrides, so nothing in
-- the repository or environment can weaken the baseline. Only the rule ID,
-- path and line are read from the report; the matched text never leaves this
-- module.
--
-- The scanner serves two roles that callers keep distinct: a transmission
-- safeguard ('flaggedPaths', 'flaggedTexts') and a deterministic check whose
-- findings are reported only for definitions that declare it.
module Seihou.CLI.Judgement.Scanner
  ( ScanResult (..),
    findScanner,
    scanCaptured,
    flaggedPaths,
  )
where

import Control.Exception (IOException, try)
import Control.Monad (forM_)
import Data.Aeson ((.:), (.:?))
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Generics.Labels ()
import Data.List (isPrefixOf, nub)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Seihou.CLI.Judgement.Evidence (EvidenceFile)
import Seihou.Core.Judgement
import Seihou.Prelude
import System.Directory (createDirectoryIfMissing, findExecutable)
import System.Environment (getEnvironment, lookupEnv)
import System.Exit (ExitCode (..))
import System.FilePath (makeRelative, splitDirectories, takeDirectory)
import System.IO.Temp (withSystemTempDirectory)
import System.Process qualified as P

data ScanResult = ScanResult
  { -- | Hits in captured file content, as deterministic findings.
    findings :: ![DeterministicFinding],
    -- | Names of outgoing instruction texts the scanner flagged.
    flaggedTexts :: ![Text]
  }
  deriving stock (Eq, Show, Generic)

-- | Locate the scanner: @SEIHOU_GITLEAKS@ when set, otherwise @gitleaks@ on
-- PATH.
findScanner :: IO (Maybe FilePath)
findScanner = do
  override <- lookupEnv "SEIHOU_GITLEAKS"
  case override of
    Just p | not (null p) -> pure (Just p)
    _ -> findExecutable "gitleaks"

trustedConfig :: Text
trustedConfig =
  T.unlines
    [ "# Seihou-owned Gitleaks configuration: the built-in rules only.",
      "title = \"seihou judgement baseline\"",
      "[extend]",
      "useDefault = true"
    ]

-- | Scan captured content and named outgoing texts.
scanCaptured :: FilePath -> [EvidenceFile] -> [(Text, Text)] -> IO (Either Text ScanResult)
scanCaptured exe files texts = withSystemTempDirectory "seihou-scan" $ \tmp -> do
  let contentRoot = tmp </> "content"
      textRoot = tmp </> "texts"
      ignoreDir = tmp </> "ignore"
      configPath = tmp </> "gitleaks.toml"
      reportPath = tmp </> "report.json"
  forM_ [contentRoot, textRoot, ignoreDir] (createDirectoryIfMissing True)
  BS.writeFile configPath (TE.encodeUtf8 trustedConfig)
  forM_ files $ \f -> forM_ (f ^. #content) $ \bytes -> do
    let dest = contentRoot </> (f ^. #path)
    createDirectoryIfMissing True (takeDirectory dest)
    BS.writeFile dest bytes
  forM_ (zip [0 :: Int ..] texts) $ \(i, (_, body)) ->
    BS.writeFile (textRoot </> ("text-" <> show i <> ".txt")) (TE.encodeUtf8 body)
  env <- getEnvironment
  let cleanEnv = [(k, v) | (k, v) <- env, not ("GITLEAKS_" `isPrefixOf` k)]
      args =
        [ "dir",
          tmp,
          "--no-banner",
          "--no-color",
          "--log-level",
          "error",
          "--redact",
          "--ignore-gitleaks-allow",
          "--gitleaks-ignore-path",
          ignoreDir,
          "--config",
          configPath,
          "--report-format",
          "json",
          "--report-path",
          reportPath,
          "--exit-code",
          "0"
        ]
  attempted <- try (P.readCreateProcessWithExitCode ((P.proc exe args) {P.env = Just cleanEnv, P.cwd = Just tmp}) "")
  case attempted of
    Left (e :: IOException) -> pure (Left ("could not run the secret scanner (" <> T.pack exe <> "): " <> T.pack (show e)))
    Right (ExitFailure n, _, _) -> pure (Left ("the secret scanner exited " <> T.pack (show n) <> "; no scan result is available"))
    Right (ExitSuccess, _, _) -> do
      report <- try (BL.readFile reportPath)
      pure $ case report of
        Left (_ :: IOException) -> Left "the secret scanner wrote no report"
        Right bytes -> case Aeson.eitherDecode bytes >>= Aeson.parseEither (mapM parseHit) of
          Left _ -> Left "the secret scanner's report did not have the expected structure"
          Right hits -> Right (classify tmp hits)
  where
    parseHit = Aeson.withObject "finding" $ \o -> do
      rule <- o .: "RuleID"
      file <- o .: "File"
      line <- o .:? "StartLine"
      pure (rule :: Text, file :: FilePath, line :: Maybe Int)
    classify tmp hits =
      let rel f = makeRelative tmp f
          contentHits =
            [ DeterministicFinding SecretScan rule (Location p line)
            | (rule, file, line) <- hits,
              ("content" : rest) <- [splitDirectories (rel file)],
              let p = joinPosix rest
            ]
          textHits =
            nub
              [ name
              | (_, file, _) <- hits,
                ["texts", leaf] <- [splitDirectories (rel file)],
                (i, (name, _)) <- zip [0 :: Int ..] texts,
                leaf == "text-" <> show i <> ".txt"
              ]
       in ScanResult contentHits textHits
    joinPosix = T.unpack . T.intercalate "/" . map T.pack

-- | Paths whose captured content the scanner flagged.
flaggedPaths :: ScanResult -> [FilePath]
flaggedPaths r = nub [f ^. #location . #path | f <- r ^. #findings]