Skip to content

Commit 00ec5b5

Browse files
committed
[ANE-2484] Call ficus
1 parent ebe802c commit 00ec5b5

5 files changed

Lines changed: 389 additions & 9 deletions

File tree

spectrometer.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ library
246246
App.Fossa.DumpBinaries
247247
App.Fossa.EmbeddedBinary
248248
App.Fossa.Ficus.Analyze
249+
App.Fossa.Ficus.Types
249250
App.Fossa.FirstPartyScan
250251
App.Fossa.Init
251252
App.Fossa.Lernie.Analyze

src/App/Fossa/Analyze.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ analyze cfg = Diag.context "fossa-analyze" $ do
353353
logInfo "Running in VSI only mode, skipping keyword search and custom-license search"
354354
pure Nothing
355355
else
356-
Diag.context "snippet scan" . runStickyLogger SevInfo $ analyzeWithFicus basedir maybeApiOpts revision $ Config.licenseScanPathFilters vendoredDepsOptions
356+
Diag.context "snippet scan" . runStickyLogger SevInfo $ analyzeWithFicus snippetScan basedir maybeApiOpts revision $ Config.licenseScanPathFilters vendoredDepsOptions
357357
let ficusResults = join . resultToMaybe $ maybeFicusResults
358358

359359

src/App/Fossa/EmbeddedBinary.hs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
module App.Fossa.EmbeddedBinary (
66
BinaryPaths,
7+
Ficus,
78
Lernie,
89
ThemisIndex,
910
ThemisBins (..),
1011
toPath,
1112
withThemisAndIndex,
1213
withBerkeleyBinary,
14+
withFicusBinary,
1315
withLernieBinary,
1416
withMillhoneBinary,
1517
withCirceBinary,
@@ -57,6 +59,7 @@ data PackagedBinary
5759
= Themis
5860
| ThemisIndex
5961
| BerkeleyDB
62+
| Ficus
6063
| Lernie
6164
| Millhone
6265
| Circe
@@ -77,6 +80,8 @@ data ThemisIndex
7780

7881
data Lernie
7982

83+
data Ficus
84+
8085
data Circe
8186

8287
data ThemisBins = ThemisBins
@@ -116,6 +121,9 @@ withBerkeleyBinary = withEmbeddedBinary BerkeleyDB
116121
withLernieBinary :: (Has (Lift IO) sig m) => (BinaryPaths -> m c) -> m c
117122
withLernieBinary = withEmbeddedBinary Lernie
118123

124+
withFicusBinary :: (Has (Lift IO) sig m) => (BinaryPaths -> m c) -> m c
125+
withFicusBinary = withEmbeddedBinary Ficus
126+
119127
withMillhoneBinary :: (Has (Lift IO) sig m) => (BinaryPaths -> m c) -> m c
120128
withMillhoneBinary = withEmbeddedBinary Millhone
121129

@@ -131,7 +139,6 @@ cleanupExtractedBinaries (BinaryPaths binPath _) = sendIO $ removeDirRecur binPa
131139
extractEmbeddedBinary :: (Has (Lift IO) sig m) => PackagedBinary -> m BinaryPaths
132140
extractEmbeddedBinary bin = do
133141
container <- sendIO extractDir
134-
-- Determine paths to which we should write the binaries
135142
let binPath = extractedPath bin
136143
-- Write the binary
137144
sendIO $ writeBinary (container </> binPath) bin
@@ -144,13 +151,15 @@ dumpEmbeddedBinary dir bin = writeBinary path bin
144151
path = dir </> extractedPath bin
145152

146153
writeBinary :: (Has (Lift IO) sig m) => Path Abs File -> PackagedBinary -> m ()
147-
writeBinary dest bin = sendIO . writeExecutable dest $ case bin of
148-
Themis -> embeddedBinaryThemis
149-
ThemisIndex -> embeddedBinaryThemisIndex
150-
BerkeleyDB -> embeddedBinaryBerkeleyDB
151-
Lernie -> embeddedBinaryLernie
152-
Millhone -> embeddedBinaryMillhone
153-
Circe -> embeddedBinaryCirce
154+
writeBinary dest bin = do
155+
sendIO . writeExecutable dest $ case bin of
156+
Themis -> embeddedBinaryThemis
157+
ThemisIndex -> embeddedBinaryThemisIndex
158+
BerkeleyDB -> embeddedBinaryBerkeleyDB
159+
Ficus -> embeddedBinaryFicus
160+
Lernie -> embeddedBinaryLernie
161+
Millhone -> embeddedBinaryMillhone
162+
Circe -> embeddedBinaryCirce
154163

155164
writeExecutable :: Path Abs File -> ByteString -> IO ()
156165
writeExecutable path content = do
@@ -163,6 +172,7 @@ extractedPath bin = case bin of
163172
Themis -> $(mkRelFile "themis-cli")
164173
ThemisIndex -> $(mkRelFile "index.gob.xz")
165174
BerkeleyDB -> $(mkRelFile "berkeleydb-plugin")
175+
Ficus -> $(mkRelFile "ficus")
166176
Lernie -> $(mkRelFile "lernie")
167177
Millhone -> $(mkRelFile "millhone")
168178
Circe -> $(mkRelFile "circe")
@@ -211,6 +221,9 @@ embeddedBinaryLernie = $(embedFileIfExists "vendor-bins/lernie")
211221
embeddedBinaryCirce :: ByteString
212222
embeddedBinaryCirce = $(embedFileIfExists "vendor-bins/circe")
213223

224+
embeddedBinaryFicus :: ByteString
225+
embeddedBinaryFicus = $(embedFileIfExists "vendor-bins/ficus")
226+
214227
-- To build this, run `make build` or `cargo build --release`.
215228
#ifdef mingw32_HOST_OS
216229
embeddedBinaryMillhone :: ByteString

src/App/Fossa/Ficus/Analyze.hs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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+
import Data.Flag (Flag, fromFlag)
54+
import App.Fossa.Config.Analyze (SnippetScan)
55+
import qualified App.Fossa.Config.Analyze as Config
56+
-- | scan rootDir with Ficus, using the given GrepOptions. This is the main entry point to this module
57+
analyzeWithFicus ::
58+
( Has Diagnostics sig m
59+
, Has (Lift IO) sig m
60+
, Has Exec sig m
61+
, Has ReadFS sig m
62+
, Has Logger sig m
63+
) =>
64+
Flag SnippetScan ->
65+
Path Abs Dir ->
66+
Maybe ApiOpts ->
67+
ProjectRevision ->
68+
Maybe LicenseScanPathFilters ->
69+
m (Maybe FicusSnippetScanResults)
70+
analyzeWithFicus snippetScan rootDir apiOpts revision filters = do
71+
if fromFlag Config.SnippetScan snippetScan
72+
then do
73+
logInfo $ "Running Ficus analysis on " <> pretty (toFilePath rootDir)
74+
messages <- runFicus ficusConfig
75+
let ficusResults = ficusMessagesToFicusSnippetScanResults messages
76+
case ficusResults of
77+
Just results -> do
78+
logInfo $ "Ficus analysis completed successfully with analysis ID: " <> pretty (ficusSnippetScanResultsAnalysisId results)
79+
Nothing -> logError "Ficus analysis completed but no fingerprint findings were found"
80+
pure ficusResults
81+
else do
82+
logInfo $ "Skipping Ficus analysis on " <> pretty (toFilePath rootDir)
83+
pure Nothing
84+
where
85+
ficusConfig =
86+
FicusConfig
87+
{ ficusConfigRootDir = rootDir
88+
, ficusConfigExclude = maybe [] licenseScanPathFiltersExclude filters
89+
, ficusConfigEndpoint = apiOptsUri =<< apiOpts
90+
, ficusConfigSecret = apiOptsApiKey <$> apiOpts
91+
, ficusConfigRevision = revision
92+
, ficusConfigFlags = [All $ FicusAllFlag SkipHiddenFiles, All $ FicusAllFlag Gitignore]
93+
}
94+
95+
runFicus ::
96+
( Has Diagnostics sig m
97+
, Has (Lift IO) sig m
98+
, Has ReadFS sig m
99+
, Has Exec sig m
100+
, Has Logger sig m
101+
) =>
102+
FicusConfig ->
103+
m FicusMessages
104+
runFicus ficusConfig = do
105+
withFicusBinary $ \bin -> do
106+
cmd <- ficusCommand ficusConfig bin
107+
logDebug $ "Executing Ficus command: " <> pretty (show cmd)
108+
result <- execThrow' cmd
109+
let messages = parseFicusJson result
110+
111+
-- Log findings at debug level for visibility
112+
traverse_ (logDebug . pretty . displayFicusFinding) $ ficusMessageFindings messages
113+
traverse_ (logError . pretty . displayFicusError) $ ficusMessageErrors messages
114+
traverse_ (logDebug . pretty . displayFicusDebug) $ ficusMessageDebugs messages
115+
116+
pure messages
117+
where
118+
displayFicusDebug :: FicusDebug -> Text
119+
displayFicusDebug (FicusDebug FicusMessageData{..}) = "DEBUG " <> ficusMessageDataStrategy <> ": " <> ficusMessageDataPayload
120+
displayFicusError :: FicusError -> Text
121+
displayFicusError (FicusError FicusMessageData{..}) = "ERROR " <> ficusMessageDataStrategy <> ": " <> ficusMessageDataPayload
122+
displayFicusFinding :: FicusFinding -> Text
123+
displayFicusFinding (FicusFinding FicusMessageData{..}) = "FINDING " <> ficusMessageDataStrategy <> ": " <> ficusMessageDataPayload
124+
125+
126+
ficusMessagesToFicusSnippetScanResults :: FicusMessages -> Maybe FicusSnippetScanResults
127+
ficusMessagesToFicusSnippetScanResults messages =
128+
let isFingerprintStrategy :: FicusFinding -> Bool
129+
isFingerprintStrategy (FicusFinding (FicusMessageData strategy _)) =
130+
Text.toLower strategy == "fingerprint"
131+
132+
extractAnalysisId :: FicusFinding -> Maybe Int
133+
extractAnalysisId (FicusFinding (FicusMessageData _ payload)) =
134+
case decode (BL.fromStrict $ Text.Encoding.encodeUtf8 payload) :: Maybe Object of
135+
Just obj -> parseMaybe (.: "analysis_id") obj
136+
Nothing -> Nothing
137+
138+
matchingFinding = filter isFingerprintStrategy (ficusMessageFindings messages)
139+
analysisId = mapMaybe extractAnalysisId matchingFinding
140+
in case analysisId of
141+
(aid : _) -> Just $ FicusSnippetScanResults{ficusSnippetScanResultsAnalysisId = aid}
142+
[] -> Nothing
143+
144+
-- Run Ficus, passing config-based args as configuration.
145+
-- Caveat! This hard-codes some flags currently which may later need to be set on a strategy-by-strategy basis.
146+
ficusCommand :: Has Diagnostics sig m => FicusConfig -> BinaryPaths -> m Command
147+
ficusCommand ficusConfig bin = do
148+
endpoint <- case ficusConfigEndpoint ficusConfig of
149+
Just baseUri -> do
150+
proxyUri <- setPath [PathComponent "api", PathComponent "proxy"] (TrailingSlash False) baseUri
151+
pure $ render proxyUri
152+
Nothing -> pure ""
153+
pure $ Command
154+
{ cmdName = toText $ toPath bin
155+
, cmdArgs = configArgs endpoint
156+
, cmdAllowErr = Never
157+
}
158+
where
159+
configArgs endpoint = ["analyze", "--secret", secret, "--endpoint", endpoint, "--locator", locator, "--set", "all:skip-hidden-files", "--set", "all:gitignore"] ++ configExcludes ++ [targetDir]
160+
targetDir = toText $ toFilePath $ ficusConfigRootDir ficusConfig
161+
secret = maybe "" (toText . unApiKey) $ ficusConfigSecret ficusConfig
162+
locator = renderLocator $ Locator "custom" (projectName $ ficusConfigRevision ficusConfig) (Just $ projectRevision $ ficusConfigRevision ficusConfig)
163+
configExcludes = unGlobFilter <$> ficusConfigExclude ficusConfig
164+
165+
-- Parse Ficus's NDJson output by splitting on newlines (character 10) and
166+
-- then decoding each line
167+
parseFicusJson :: BL.ByteString -> FicusMessages
168+
parseFicusJson out =
169+
fold messages
170+
where
171+
messageLines = BL.splitWith (== 10) out
172+
-- Once Ficus supports file filtering we'll do this by passing the config file path into Ficus
173+
-- and Ficus will skip scanning the config file. But for now let's just filter post-scan
174+
parsedLines = mapMaybe decode messageLines
175+
messages = map singletonFicusMessage parsedLines
176+
177+
-- add a FicusMessage to the corresponding entry of an empty FicusMessages
178+
singletonFicusMessage :: FicusMessage -> FicusMessages
179+
singletonFicusMessage message = case message of
180+
FicusMessageFinding msg -> mempty{ficusMessageFindings = [msg]}
181+
FicusMessageDebug msg -> mempty{ficusMessageDebugs = [msg]}
182+
FicusMessageError msg -> mempty{ficusMessageErrors = [msg]}

0 commit comments

Comments
 (0)