|
| 1 | +{-# LANGUAGE RecordWildCards #-} |
| 2 | + |
| 3 | +module App.Fossa.Ficus.Analyze ( |
| 4 | + analyzeWithFicus |
| 5 | +) where |
| 6 | + |
| 7 | +import App.Fossa.EmbeddedBinary (BinaryPaths, toPath, withFicusBinary) |
| 8 | +import App.Fossa.Ficus.Types (FicusSnippetScanResults (..), FicusMessages (..), FicusMessage (..), FicusConfig (..), FicusDebug (..), FicusMessageData (..), FicusError (..), FicusFinding (..), FicusAnalysisFlag (..), FicusAllFlag (..), FicusPerStrategyFlag (..)) |
| 9 | +import App.Types (FileUpload (..), ProjectRevision (..)) |
| 10 | +import Control.Carrier.Debug (Debug) |
| 11 | +import Control.Carrier.Diagnostics (Diagnostics, fatal, warn) |
| 12 | +import Control.Carrier.FossaApiClient (runFossaApiClient) |
| 13 | +import Control.Carrier.Telemetry.Types |
| 14 | +import Control.Effect.FossaApiClient (FossaApiClient, getOrganization) |
| 15 | +import Control.Effect.Lift (Has, Lift) |
| 16 | +import Control.Effect.Telemetry (Telemetry, trackUsage) |
| 17 | +import Control.Monad (join, unless) |
| 18 | +import Data.Aeson (decode, Object, (.:)) |
| 19 | +import Data.Aeson.Types (parseMaybe) |
| 20 | +import Data.ByteString.Lazy qualified as BL |
| 21 | +import Data.Foldable (fold, traverse_) |
| 22 | +import Data.HashMap.Internal.Strict (HashMap) |
| 23 | +import Data.HashMap.Strict qualified as H |
| 24 | +import Data.HashMap.Strict qualified as HashMap |
| 25 | +import Data.Hashable (Hashable) |
| 26 | +import Data.List (nub) |
| 27 | +import Data.List.NonEmpty qualified as NE |
| 28 | +import Data.Maybe (mapMaybe) |
| 29 | +import Data.String.Conversion (ToText (toText), decodeUtf8) |
| 30 | +import Data.Text (Text) |
| 31 | +import Data.Text qualified as Text |
| 32 | +import Data.Text.Encoding qualified as Text.Encoding |
| 33 | +import Effect.Exec (AllowErr (Never), Command (..), Exec, execCurrentDirStdinThrow, execThrow') |
| 34 | +import Effect.ReadFS (ReadFS) |
| 35 | +import Fossa.API.Types (ApiKey (..), ApiOpts (..), Organization (..), orgFileUpload) |
| 36 | +import Path (Abs, Dir, File, Path, toFilePath) |
| 37 | +import Prettyprinter (pretty) |
| 38 | +import Srclib.Types |
| 39 | + ( LicenseScanType (..) |
| 40 | + , LicenseSourceUnit (..) |
| 41 | + , LicenseUnit (..) |
| 42 | + , LicenseUnitData (..) |
| 43 | + , LicenseUnitInfo (..) |
| 44 | + , LicenseUnitMatchData (..) |
| 45 | + , Locator (..) |
| 46 | + , renderLocator |
| 47 | + ) |
| 48 | +import Effect.Logger (Logger, logDebug, logInfo, logError) |
| 49 | +import Text.URI (render) |
| 50 | +import Text.URI.Builder (PathComponent (..), TrailingSlash (..), setPath) |
| 51 | +import Types (GlobFilter (..), LicenseScanPathFilters (..), LicenseScanPathFilters) |
| 52 | +import Prelude hiding (unwords) |
| 53 | +-- | scan rootDir with Ficus, using the given GrepOptions. This is the main entry point to this module |
| 54 | +analyzeWithFicus :: |
| 55 | + ( Has Diagnostics sig m |
| 56 | + , Has (Lift IO) sig m |
| 57 | + , Has Exec sig m |
| 58 | + , Has ReadFS sig m |
| 59 | + , Has Logger sig m |
| 60 | + ) => |
| 61 | + Path Abs Dir -> |
| 62 | + Maybe ApiOpts -> |
| 63 | + ProjectRevision -> |
| 64 | + Maybe LicenseScanPathFilters -> |
| 65 | + m (Maybe FicusSnippetScanResults) |
| 66 | +analyzeWithFicus rootDir apiOpts revision filters = do |
| 67 | + logInfo $ "Running Ficus analysis on " <> pretty (toFilePath rootDir) |
| 68 | + messages <- runFicus ficusConfig |
| 69 | + let ficusResults = ficusMessagesToFicusSnippetScanResults messages |
| 70 | + case ficusResults of |
| 71 | + Just results -> do |
| 72 | + logInfo $ "Ficus analysis completed successfully with analysis ID: " <> pretty (ficusSnippetScanResultsAnalysisId results) |
| 73 | + Nothing -> logError "Ficus analysis completed but no fingerprint findings were found" |
| 74 | + pure ficusResults |
| 75 | + where |
| 76 | + ficusConfig = |
| 77 | + FicusConfig |
| 78 | + { ficusConfigRootDir = rootDir |
| 79 | + , ficusConfigExclude = maybe [] licenseScanPathFiltersExclude filters |
| 80 | + , ficusConfigEndpoint = apiOptsUri =<< apiOpts |
| 81 | + , ficusConfigSecret = apiOptsApiKey <$> apiOpts |
| 82 | + , ficusConfigRevision = revision |
| 83 | + , ficusConfigFlags = [All $ FicusAllFlag SkipHiddenFiles, All $ FicusAllFlag Gitignore] |
| 84 | + } |
| 85 | + |
| 86 | +runFicus :: |
| 87 | + ( Has Diagnostics sig m |
| 88 | + , Has (Lift IO) sig m |
| 89 | + , Has ReadFS sig m |
| 90 | + , Has Exec sig m |
| 91 | + , Has Logger sig m |
| 92 | + ) => |
| 93 | + FicusConfig -> |
| 94 | + m FicusMessages |
| 95 | +runFicus ficusConfig = do |
| 96 | + withFicusBinary $ \bin -> do |
| 97 | + cmd <- ficusCommand ficusConfig bin |
| 98 | + logDebug $ "Executing Ficus command: " <> pretty (show cmd) |
| 99 | + result <- execThrow' cmd |
| 100 | + let messages = parseFicusJson result |
| 101 | + |
| 102 | + -- Log findings at debug level for visibility |
| 103 | + traverse_ (logDebug . pretty . displayFicusFinding) $ ficusMessageFindings messages |
| 104 | + traverse_ (logError . pretty . displayFicusError) $ ficusMessageErrors messages |
| 105 | + traverse_ (logDebug . pretty . displayFicusDebug) $ ficusMessageDebugs messages |
| 106 | + |
| 107 | + pure messages |
| 108 | + where |
| 109 | + displayFicusDebug :: FicusDebug -> Text |
| 110 | + displayFicusDebug (FicusDebug FicusMessageData{..}) = "DEBUG " <> ficusMessageDataStrategy <> ": " <> ficusMessageDataPayload |
| 111 | + displayFicusError :: FicusError -> Text |
| 112 | + displayFicusError (FicusError FicusMessageData{..}) = "ERROR " <> ficusMessageDataStrategy <> ": " <> ficusMessageDataPayload |
| 113 | + displayFicusFinding :: FicusFinding -> Text |
| 114 | + displayFicusFinding (FicusFinding FicusMessageData{..}) = "FINDING " <> ficusMessageDataStrategy <> ": " <> ficusMessageDataPayload |
| 115 | + |
| 116 | + |
| 117 | +ficusMessagesToFicusSnippetScanResults :: FicusMessages -> Maybe FicusSnippetScanResults |
| 118 | +ficusMessagesToFicusSnippetScanResults messages = |
| 119 | + let isFingerprintStrategy :: FicusFinding -> Bool |
| 120 | + isFingerprintStrategy (FicusFinding (FicusMessageData strategy _)) = |
| 121 | + Text.toLower strategy == "fingerprint" |
| 122 | + |
| 123 | + extractAnalysisId :: FicusFinding -> Maybe Int |
| 124 | + extractAnalysisId (FicusFinding (FicusMessageData _ payload)) = |
| 125 | + case decode (BL.fromStrict $ Text.Encoding.encodeUtf8 payload) :: Maybe Object of |
| 126 | + Just obj -> parseMaybe (.: "analysis_id") obj |
| 127 | + Nothing -> Nothing |
| 128 | + |
| 129 | + matchingFinding = filter isFingerprintStrategy (ficusMessageFindings messages) |
| 130 | + analysisId = mapMaybe extractAnalysisId matchingFinding |
| 131 | + in case analysisId of |
| 132 | + (aid : _) -> Just $ FicusSnippetScanResults{ficusSnippetScanResultsAnalysisId = aid} |
| 133 | + [] -> Nothing |
| 134 | + |
| 135 | +-- Run Ficus, passing config-based args as configuration. |
| 136 | +-- Caveat! This hard-codes some flags currently which may later need to be set on a strategy-by-strategy basis. |
| 137 | +ficusCommand :: Has Diagnostics sig m => FicusConfig -> BinaryPaths -> m Command |
| 138 | +ficusCommand ficusConfig bin = do |
| 139 | + endpoint <- case ficusConfigEndpoint ficusConfig of |
| 140 | + Just baseUri -> do |
| 141 | + proxyUri <- setPath [PathComponent "api", PathComponent "proxy"] (TrailingSlash False) baseUri |
| 142 | + pure $ render proxyUri |
| 143 | + Nothing -> pure "" |
| 144 | + pure $ Command |
| 145 | + { cmdName = toText $ toPath bin |
| 146 | + , cmdArgs = configArgs endpoint |
| 147 | + , cmdAllowErr = Never |
| 148 | + } |
| 149 | + where |
| 150 | + configArgs endpoint = ["analyze", "--secret", secret, "--endpoint", endpoint, "--locator", locator, "--set", "all:skip-hidden-files", "--set", "all:gitignore"] ++ configExcludes ++ [targetDir] |
| 151 | + targetDir = toText $ toFilePath $ ficusConfigRootDir ficusConfig |
| 152 | + secret = maybe "" (toText . unApiKey) $ ficusConfigSecret ficusConfig |
| 153 | + locator = renderLocator $ Locator "custom" (projectName $ ficusConfigRevision ficusConfig) (Just $ projectRevision $ ficusConfigRevision ficusConfig) |
| 154 | + configExcludes = unGlobFilter <$> ficusConfigExclude ficusConfig |
| 155 | + |
| 156 | +-- Parse Ficus's NDJson output by splitting on newlines (character 10) and |
| 157 | +-- then decoding each line |
| 158 | +parseFicusJson :: BL.ByteString -> FicusMessages |
| 159 | +parseFicusJson out = |
| 160 | + fold messages |
| 161 | + where |
| 162 | + messageLines = BL.splitWith (== 10) out |
| 163 | + -- Once Ficus supports file filtering we'll do this by passing the config file path into Ficus |
| 164 | + -- and Ficus will skip scanning the config file. But for now let's just filter post-scan |
| 165 | + parsedLines = mapMaybe decode messageLines |
| 166 | + messages = map singletonFicusMessage parsedLines |
| 167 | + |
| 168 | +-- add a FicusMessage to the corresponding entry of an empty FicusMessages |
| 169 | +singletonFicusMessage :: FicusMessage -> FicusMessages |
| 170 | +singletonFicusMessage message = case message of |
| 171 | + FicusMessageFinding msg -> mempty{ficusMessageFindings = [msg]} |
| 172 | + FicusMessageDebug msg -> mempty{ficusMessageDebugs = [msg]} |
| 173 | + FicusMessageError msg -> mempty{ficusMessageErrors = [msg]} |
0 commit comments