diff --git a/commands/audit/audit.go b/commands/audit/audit.go index f209497d1..dbf7b6cfe 100644 --- a/commands/audit/audit.go +++ b/commands/audit/audit.go @@ -15,6 +15,7 @@ import ( "github.com/jfrog/jfrog-cli-security/jas" "github.com/jfrog/jfrog-cli-security/jas/applicability" "github.com/jfrog/jfrog-cli-security/jas/runner" + "github.com/jfrog/jfrog-cli-security/jas/sast" "github.com/jfrog/jfrog-cli-security/jas/secrets" "github.com/jfrog/jfrog-cli-security/policy" "github.com/jfrog/jfrog-cli-security/policy/enforcer" @@ -266,7 +267,8 @@ func (auditCmd *AuditCommand) Run() (err error) { SetGitContext(auditCmd.GitContext()). SetThirdPartyApplicabilityScan(auditCmd.thirdPartyApplicabilityScan). SetThreads(auditCmd.Threads). - SetScansResultsOutputDir(auditCmd.scanResultsOutputDir).SetStartTime(startTime).SetMultiScanId(multiScanId) + SetScansResultsOutputDir(auditCmd.scanResultsOutputDir).SetStartTime(startTime).SetMultiScanId(multiScanId). + SetSastChangedFilesMode(auditCmd.sastChangedFilesMode).SetSastRules(auditCmd.sastRules) auditParams.SetIsRecursiveScan(isRecursiveScan).SetExclusions(auditCmd.Exclusions()) auditResults := RunAudit(auditParams) @@ -741,6 +743,8 @@ func createJasScansTask(auditParallelRunner *utils.SecurityParallelRunner, scanR ApplicableScanType: applicability.ApplicabilityScannerType, SignedDescriptions: getSignedDescriptions(auditParams.OutputFormat()), SastRules: auditParams.SastRules(), + SastChangedFilesMode: auditParams.SastChangedFilesMode(), + SastChangedFiles: sast.SastChangedFilesForTarget(auditParams.SastChangedFilesMode(), scanResults.GitContext, targetResult.Target, scanResults.GetCommonParentPath()), ScanResults: targetResult, TargetCount: len(scanResults.Targets), TargetOutputDir: auditParams.scanResultsOutputDir, diff --git a/commands/audit/auditparams.go b/commands/audit/auditparams.go index c75da1790..1c02bf504 100644 --- a/commands/audit/auditparams.go +++ b/commands/audit/auditparams.go @@ -43,9 +43,9 @@ type AuditParams struct { violationGenerator policy.PolicyHandler sastRules string // Diff mode, scan only the files affected by the diff. - diffMode bool - filesToScan []string - resultsToCompare *results.SecurityCommandResults + diffMode bool + sastChangedFilesMode bool + resultsToCompare *results.SecurityCommandResults } func NewAuditParams() *AuditParams { @@ -258,13 +258,13 @@ func (params *AuditParams) ToXrayScanGraphParams() (scanGraphParams scangraph.Sc return } -func (params *AuditParams) SetFilesToScan(filesToScan []string) *AuditParams { - params.filesToScan = filesToScan +func (params *AuditParams) SetSastChangedFilesMode(sastChangedFilesMode bool) *AuditParams { + params.sastChangedFilesMode = sastChangedFilesMode return params } -func (params *AuditParams) FilesToScan() []string { - return params.filesToScan +func (params *AuditParams) SastChangedFilesMode() bool { + return params.sastChangedFilesMode } func (params *AuditParams) SetResultsToCompare(resultsToCompare *results.SecurityCommandResults) *AuditParams { diff --git a/go.mod b/go.mod index c5c66d6b4..38d8adfb4 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,9 @@ require ( github.com/jfrog/froggit-go v1.21.1 github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-apps-config v1.0.1 - github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260416104146-471c3f71ce61 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260420010255-65b7a8d432af - github.com/jfrog/jfrog-client-go v1.55.1-0.20260416101832-c47c1246283b + github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260423195010-d7aa2c437305 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260427010241-873f53d940b3 + github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7 github.com/magiconair/properties v1.8.10 github.com/owenrumney/go-sarif/v3 v3.2.3 github.com/package-url/packageurl-go v0.1.3 diff --git a/go.sum b/go.sum index e0f24262b..35e80e618 100644 --- a/go.sum +++ b/go.sum @@ -169,12 +169,12 @@ github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260416104146-471c3f71ce61 h1:cES0xZ9ABOY/f3nq+3ETiVPgzjs7tEpHFzstc8h3W2U= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260416104146-471c3f71ce61/go.mod h1:6QJFQvde/CLnFeIIFOvm/6QuQr8OT1QWiTJAkQ+1Mnc= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260420010255-65b7a8d432af h1:TGYTFW5egYMD1MhLtuLbYwB2vn+KRlVZLk+9uLVjKyM= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260420010255-65b7a8d432af/go.mod h1:qpD7einonjqskDTEyqeG3NzAbZO6se0s0Pet0ObBQ3I= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260416101832-c47c1246283b h1:45UJJVG/jAA7/q2c/I6wOvKx8wu7EqTD2tEUtktgyug= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260416101832-c47c1246283b/go.mod h1:sCE06+GngPoyrGO0c+vmhgMoVSP83UMNiZnIuNPzU8U= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260423195010-d7aa2c437305 h1:wSoVNwbZ2Scm/q2MEfcf+vCUuq41wejk3+rlgnF56jE= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260423195010-d7aa2c437305/go.mod h1:6QJFQvde/CLnFeIIFOvm/6QuQr8OT1QWiTJAkQ+1Mnc= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260427010241-873f53d940b3 h1:LdLQQmhOMUfU+3x7wbtB7kY/Dd2LXKHz7CCUpHWn7uM= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260427010241-873f53d940b3/go.mod h1:qpD7einonjqskDTEyqeG3NzAbZO6se0s0Pet0ObBQ3I= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7 h1:MvHnFczVntYB/USj7/RRANvdWbTUcwEvXcIGr7lOyTc= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7/go.mod h1:sCE06+GngPoyrGO0c+vmhgMoVSP83UMNiZnIuNPzU8U= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= diff --git a/jas/runner/jasrunner.go b/jas/runner/jasrunner.go index c88692450..d88e29cb2 100644 --- a/jas/runner/jasrunner.go +++ b/jas/runner/jasrunner.go @@ -34,6 +34,7 @@ type JasRunnerParams struct { ScansToPerform []utils.SubScanType // Diff mode flags SourceResultsToCompare *results.TargetResults + SastChangedFiles []string DiffMode bool // Secret scan flags SecretsScanType secrets.SecretsScanType @@ -42,8 +43,9 @@ type JasRunnerParams struct { ApplicableScanType applicability.ApplicabilityScanType ThirdPartyApplicabilityScan bool // SAST scan flags - SignedDescriptions bool - SastRules string + SastChangedFilesMode bool + SignedDescriptions bool + SastRules string // Outputs TargetCount int ScanResults *results.TargetResults @@ -175,7 +177,19 @@ func runSastScan(params *JasRunnerParams) parallel.TaskFunc { defer func() { params.Runner.JasScannersWg.Done() }() - vulnerabilitiesResults, violationsResults, err := sast.RunSastScan(params.Scanner, params.Module, params.SignedDescriptions, params.SastRules, params.TargetCount, threadId, getSourceRunsToCompare(params, jasutils.Sast)...) + vulnerabilitiesResults, violationsResults, err := sast.RunSastScan( + sast.SastScanParams{ + Module: params.Module, + SignedDescriptions: params.SignedDescriptions, + SastRules: params.SastRules, + TargetCount: params.TargetCount, + ThreadId: threadId, + SastChangedFiles: params.SastChangedFiles, + ChangedFilesMode: params.SastChangedFilesMode, + ResultsToCompare: getSourceRunsToCompare(params, jasutils.Sast), + }, + params.Scanner, + ) params.Runner.ResultsMu.Lock() defer params.Runner.ResultsMu.Unlock() // We first add the scan results and only then check for errors, so we can store the exit code in order to report it in the end diff --git a/jas/sast/sastscanner.go b/jas/sast/sastscanner.go index 974c027a4..fc6179155 100644 --- a/jas/sast/sastscanner.go +++ b/jas/sast/sastscanner.go @@ -3,14 +3,20 @@ package sast import ( "fmt" "path/filepath" + "slices" + "strings" "time" + "github.com/jfrog/gofrog/datastructures" jfrogappsconfig "github.com/jfrog/jfrog-apps-config/go" "github.com/jfrog/jfrog-cli-security/jas" "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-cli-security/utils/formats/sarifutils" "github.com/jfrog/jfrog-cli-security/utils/jasutils" + clientutils "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "github.com/jfrog/jfrog-client-go/utils/log" + xscservices "github.com/jfrog/jfrog-client-go/xsc/services" "github.com/owenrumney/go-sarif/v3/pkg/report/v210/sarif" "golang.org/x/exp/maps" ) @@ -22,38 +28,59 @@ const ( ) type SastScanManager struct { - scanner *jas.JasScanner + scanner *jas.JasScanner + + sastChangedFiles []string signedDescriptions bool sastRules string + changedFilesMode bool + resultsToCompareFileName string configFileName string resultsFileName string } -func RunSastScan(scanner *jas.JasScanner, module jfrogappsconfig.Module, signedDescriptions bool, sastRules string, targetCount, threadId int, resultsToCompare ...*sarif.Run) (vulnerabilitiesResults []*sarif.Run, violationsResults []*sarif.Run, err error) { +type SastScanParams struct { + Module jfrogappsconfig.Module + SignedDescriptions bool + SastRules string + TargetCount int + ThreadId int + SastChangedFiles []string + ChangedFilesMode bool + ResultsToCompare []*sarif.Run +} + +func RunSastScan(params SastScanParams, scanner *jas.JasScanner) (vulnerabilitiesResults []*sarif.Run, violationsResults []*sarif.Run, err error) { + if params.ChangedFilesMode && len(params.SastChangedFiles) == 0 { + log.Info(clientutils.GetLogMsgPrefix(params.ThreadId, false) + "SAST changed files mode: no changed files in scope for this target, skipping SAST scan") + return + } var scannerTempDir string - if scannerTempDir, err = jas.CreateScannerTempDirectory(scanner, jasutils.Sast.String(), threadId); err != nil { + if scannerTempDir, err = jas.CreateScannerTempDirectory(scanner, jasutils.Sast.String(), params.ThreadId); err != nil { return } - sastScanManager, err := newSastScanManager(scanner, scannerTempDir, signedDescriptions, sastRules, resultsToCompare...) + sastScanManager, err := newSastScanManager(scanner, scannerTempDir, params.SignedDescriptions, params.ChangedFilesMode, params.SastRules, params.SastChangedFiles, params.ResultsToCompare...) if err != nil { return } startTime := time.Now() - log.Info(jas.GetStartJasScanLog(utils.SastScan, threadId, module, targetCount)) - if vulnerabilitiesResults, violationsResults, err = sastScanManager.scanner.Run(sastScanManager, module); err != nil { + log.Info(jas.GetStartJasScanLog(utils.SastScan, params.ThreadId, params.Module, params.TargetCount)) + if vulnerabilitiesResults, violationsResults, err = sastScanManager.scanner.Run(sastScanManager, params.Module); err != nil { return } - log.Info(utils.GetScanFindingsLog(utils.SastScan, sarifutils.GetResultsLocationCount(vulnerabilitiesResults...), startTime, threadId)) + log.Info(utils.GetScanFindingsLog(utils.SastScan, sarifutils.GetResultsLocationCount(vulnerabilitiesResults...), startTime, params.ThreadId)) return } -func newSastScanManager(scanner *jas.JasScanner, scannerTempDir string, signedDescriptions bool, sastRules string, resultsToCompare ...*sarif.Run) (manager *SastScanManager, err error) { +func newSastScanManager(scanner *jas.JasScanner, scannerTempDir string, signedDescriptions, changedFilesMode bool, sastRules string, sastChangedFiles []string, resultsToCompare ...*sarif.Run) (manager *SastScanManager, err error) { manager = &SastScanManager{ scanner: scanner, signedDescriptions: signedDescriptions, sastRules: sastRules, + changedFilesMode: changedFilesMode, + sastChangedFiles: sastChangedFiles, configFileName: filepath.Join(scannerTempDir, "config.yaml"), resultsFileName: filepath.Join(scannerTempDir, "results.sarif"), } @@ -69,7 +96,7 @@ func newSastScanManager(scanner *jas.JasScanner, scannerTempDir string, signedDe } func (ssm *SastScanManager) Run(module jfrogappsconfig.Module) (vulnerabilitiesSarifRuns []*sarif.Run, violationsSarifRuns []*sarif.Run, err error) { - if err = ssm.createConfigFile(module, ssm.signedDescriptions, ssm.scanner.ScannersExclusions.SastExcludePatterns, ssm.scanner.Exclusions...); err != nil { + if err = ssm.createConfigFile(module, ssm.signedDescriptions, ssm.sastChangedFiles, ssm.scanner.ScannersExclusions.SastExcludePatterns, ssm.scanner.Exclusions...); err != nil { return } if err = ssm.runAnalyzerManager(filepath.Dir(ssm.scanner.AnalyzerManager.AnalyzerManagerFullPath)); err != nil { @@ -104,7 +131,7 @@ type sastParameters struct { SignedDescriptions bool `yaml:"signed_descriptions,omitempty"` } -func (ssm *SastScanManager) createConfigFile(module jfrogappsconfig.Module, signedDescriptions bool, centralConfigExclusions []string, exclusions ...string) error { +func (ssm *SastScanManager) createConfigFile(module jfrogappsconfig.Module, signedDescriptions bool, sastChangedFiles []string, centralConfigExclusions []string, exclusions ...string) error { sastScanner := module.Scanners.Sast if sastScanner == nil { sastScanner = &jfrogappsconfig.SastScanner{} @@ -113,6 +140,10 @@ func (ssm *SastScanManager) createConfigFile(module jfrogappsconfig.Module, sign if err != nil { return err } + if ssm.changedFilesMode { + log.Debug(fmt.Sprintf("SAST changed files mode: using %d paths as scan roots", len(sastChangedFiles))) + roots = sastChangedFiles + } configFileContent := sastScanConfig{ Scans: []scanConfiguration{ { @@ -171,3 +202,115 @@ func getResultLocationStr(result *sarif.Result) string { func getResultId(result *sarif.Result) string { return sarifutils.GetResultRuleId(result) + result.Level + sarifutils.GetResultMsgText(result) + getResultLocationStr(result) } + +// sastChangedFileDropStats counts reasons entries from git were not used as SAST roots. +type sastChangedFileDropStats struct { + invalidPath int + outsideTarget int + absError int + duplicate int +} + +func (s sastChangedFileDropStats) anyDrops() bool { + return s.invalidPath+s.outsideTarget+s.absError+s.duplicate > 0 +} + +// collectSastChangedAbsPaths maps repo-relative (or absolute-under-repo) changed file paths to clean absolute +// paths under commonAbs that belong to targetRel, deduplicating by absolute path. +func collectSastChangedAbsPaths(commonAbs, targetRel string, changedFiles []string) (out []string, stats sastChangedFileDropStats) { + seen := datastructures.MakeSet[string]() + for _, cf := range changedFiles { + cfSlash, ok := normalizeRepoRelativeChangedPath(commonAbs, cf) + if !ok { + log.Verbose(fmt.Sprintf("SAST changed files: invalid path: %s", cf)) + stats.invalidPath++ + continue + } + if !changedFileBelongsToTarget(targetRel, cfSlash) { + log.Verbose(fmt.Sprintf("SAST changed files: outside target: %s", cf)) + stats.outsideTarget++ + continue + } + joined := filepath.Join(commonAbs, filepath.FromSlash(cfSlash)) + absPath, err := filepath.Abs(filepath.Clean(joined)) + if err != nil { + log.Verbose(fmt.Sprintf("SAST changed files: absolute path error: %s", err.Error())) + stats.absError++ + continue + } + if exists, err := fileutils.IsFileExists(absPath, false); err != nil || !exists { + log.Verbose(fmt.Sprintf("SAST changed files: file does not exist: %s", absPath)) + stats.invalidPath++ + continue + } + if seen.Exists(absPath) { + log.Verbose(fmt.Sprintf("SAST changed files: duplicate path: %s", absPath)) + stats.duplicate++ + continue + } + seen.Add(absPath) + out = append(out, absPath) + } + return out, stats +} + +// SastChangedFilesForTarget returns absolute paths of changed files under commonParent that belong to targetPath +// (paths from git are repo-relative or absolute under the repo). Only runs when changedFilesMode is true; only paths +// that exist on disk are returned. Returns nil if nothing matches or if gitCtx, commonParent, or targetPath are unusable. +func SastChangedFilesForTarget(changedFilesMode bool, gitCtx *xscservices.XscGitInfoContext, targetPath, commonParent string) []string { + if gitCtx == nil || !changedFilesMode { + return nil + } + if len(gitCtx.ChangedFiles) == 0 { + log.Debug("SAST changed files: git context has no changed files; skipping per-file roots") + return nil + } + if strings.TrimSpace(commonParent) == "" || strings.TrimSpace(targetPath) == "" { + log.Debug("SAST changed files: empty common parent or target path; skipping per-file roots") + return nil + } + commonAbs, err := filepath.Abs(filepath.Clean(commonParent)) + if err != nil { + log.Debug(fmt.Sprintf("SAST changed files: could not resolve common parent: %s", err.Error())) + return nil + } + targetRel := filepath.ToSlash(utils.GetRelativePath(targetPath, commonParent)) + inputCount := len(gitCtx.ChangedFiles) + out, stats := collectSastChangedAbsPaths(commonAbs, targetRel, gitCtx.ChangedFiles) + if stats.anyDrops() { + log.Debug(fmt.Sprintf("SAST changed files: kept %d of %d changed-file entries (dropped: %d invalid/unsafe path, %d outside target, %d path resolution error, %d duplicate after normalization)", + len(out), inputCount, stats.invalidPath, stats.outsideTarget, stats.absError, stats.duplicate)) + } + slices.Sort(out) + return out +} + +func normalizeRepoRelativeChangedPath(commonAbs, cf string) (slashPath string, ok bool) { + cf = strings.TrimSpace(cf) + if cf == "" { + return "", false + } + if filepath.IsAbs(cf) { + cleaned := filepath.Clean(cf) + r, err := filepath.Rel(commonAbs, cleaned) + if err != nil { + return "", false + } + r = filepath.ToSlash(filepath.Clean(r)) + if r == ".." || strings.HasPrefix(r, "../") { + return "", false + } + return r, true + } + return filepath.ToSlash(filepath.Clean(cf)), true +} + +func changedFileBelongsToTarget(targetRel, cfSlash string) bool { + if targetRel == "" { + return true + } + if cfSlash == targetRel { + return true + } + return strings.HasPrefix(cfSlash, targetRel+"/") +} diff --git a/jas/sast/sastscanner_test.go b/jas/sast/sastscanner_test.go index 7b51e9f02..5d16a2d48 100644 --- a/jas/sast/sastscanner_test.go +++ b/jas/sast/sastscanner_test.go @@ -1,9 +1,12 @@ package sast import ( + "gopkg.in/yaml.v3" + "os" "path/filepath" "testing" + jfrogappsconfig "github.com/jfrog/jfrog-apps-config/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -11,6 +14,7 @@ import ( coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + xscservices "github.com/jfrog/jfrog-client-go/xsc/services" "github.com/jfrog/jfrog-cli-security/jas" "github.com/jfrog/jfrog-cli-security/utils/formats/sarifutils" @@ -23,7 +27,7 @@ func TestNewSastScanManager(t *testing.T) { jfrogAppsConfigForTest, err := jas.CreateJFrogAppsConfig([]string{"currentDir"}) assert.NoError(t, err) // Act - sastScanManager, err := newSastScanManager(scanner, "temoDirPath", true, "") + sastScanManager, err := newSastScanManager(scanner, "tempDirPath", true, false, "", nil) assert.NoError(t, err) // Assert @@ -47,7 +51,7 @@ func TestNewSastScanManagerWithFilesToCompare(t *testing.T) { scannerTempDir, err := jas.CreateScannerTempDirectory(scanner, jasutils.Secrets.String(), 0) require.NoError(t, err) - sastScanManager, err := newSastScanManager(scanner, scannerTempDir, false, "", sarifutils.CreateRunWithDummyResults(sarifutils.CreateDummyResult("test-markdown", "test-msg", "test-rule-id", "note"))) + sastScanManager, err := newSastScanManager(scanner, scannerTempDir, false, false, "", nil, sarifutils.CreateRunWithDummyResults(sarifutils.CreateDummyResult("test-markdown", "test-msg", "test-rule-id", "note"))) require.NoError(t, err) // Check if path value exists and file is created @@ -62,7 +66,7 @@ func TestSastParseResults_EmptyResults(t *testing.T) { assert.NoError(t, err) // Arrange - sastScanManager, err := newSastScanManager(scanner, "temoDirPath", true, "") + sastScanManager, err := newSastScanManager(scanner, "tempDirPath", true, false, "", nil) assert.NoError(t, err) sastScanManager.resultsFileName = filepath.Join(jas.GetTestDataPath(), "sast-scan", "no-violations.sarif") @@ -85,7 +89,7 @@ func TestSastParseResults_ResultsContainIacViolations(t *testing.T) { jfrogAppsConfigForTest, err := jas.CreateJFrogAppsConfig([]string{}) assert.NoError(t, err) // Arrange - sastScanManager, err := newSastScanManager(scanner, "temoDirPath", false, "") + sastScanManager, err := newSastScanManager(scanner, "tempDirPath", false, false, "", nil) assert.NoError(t, err) sastScanManager.resultsFileName = filepath.Join(jas.GetTestDataPath(), "sast-scan", "contains-sast-violations.sarif") @@ -198,9 +202,186 @@ func TestSastRules(t *testing.T) { scannerTempDir, err := jas.CreateScannerTempDirectory(scanner, jasutils.Sast.String(), 0) require.NoError(t, err) - sastScanManager, err := newSastScanManager(scanner, scannerTempDir, false, "test-rules.json") + sastScanManager, err := newSastScanManager(scanner, scannerTempDir, false, false, "test-rules.json", nil) require.NoError(t, err) assert.Equal(t, "test-rules.json", sastScanManager.sastRules) assert.Equal(t, filepath.Join(scannerTempDir, "config.yaml"), sastScanManager.configFileName) assert.Equal(t, filepath.Join(scannerTempDir, "results.sarif"), sastScanManager.resultsFileName) } + +// xscGitInfoWithChanged builds an XscGitInfoContext the way the client defines it (GitDiffContext with changed_files). +// Must match the shape expected by SastChangedFilesForTarget in sastscanner.go. +func xscGitInfoWithChanged(t *testing.T, files ...string) *xscservices.XscGitInfoContext { + t.Helper() + return &xscservices.XscGitInfoContext{GitDiffContext: xscservices.GitDiffContext{ChangedFiles: files}} +} + +func TestSastChangedFilesForTarget(t *testing.T) { + base := t.TempDir() + modA := filepath.Join(base, "modA") + modB := filepath.Join(base, "modB") + require.NoError(t, os.MkdirAll(modA, 0o755)) + require.NoError(t, os.MkdirAll(modB, 0o755)) + // collectSastChangedAbsPaths only keeps paths that exist on disk + for _, rel := range []string{ + "modA/a.go", "modA/b.go", "modB/x.go", "modA/abs.go", "foo/x.go", "foobar/y.go", + } { + p := filepath.Join(base, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte("// test\n"), 0o644)) + } + + threeFiles := xscGitInfoWithChanged(t, "modA/a.go", "modA/b.go", "modB/x.go") + + tests := []struct { + name string + gitCtx *xscservices.XscGitInfoContext + targetPath string + commonParent string + changedFilesMode bool + // wantEmpty: expect no file roots (nil or empty slice) when mode is off or there is nothing to return. + wantEmpty bool + want []string + }{ + {name: "nil_context", gitCtx: nil, targetPath: base, commonParent: base, changedFilesMode: true, wantEmpty: true}, + {name: "changed_files_mode_off", gitCtx: threeFiles, targetPath: modA, commonParent: base, changedFilesMode: false, wantEmpty: true}, + {name: "empty_changed_files", gitCtx: xscGitInfoWithChanged(t), targetPath: modA, commonParent: base, changedFilesMode: true, wantEmpty: true}, + {name: "empty_common_parent", gitCtx: threeFiles, targetPath: modA, commonParent: "", changedFilesMode: true, wantEmpty: true}, + {name: "empty_target_path", gitCtx: threeFiles, targetPath: "", commonParent: base, changedFilesMode: true, wantEmpty: true}, + { + name: "target_is_common_parent_returns_all_as_abs", + gitCtx: threeFiles, + targetPath: base, + commonParent: base, + changedFilesMode: true, + want: []string{filepath.Join(base, "modA", "a.go"), filepath.Join(base, "modA", "b.go"), filepath.Join(base, "modB", "x.go")}, + }, + { + name: "filters_to_modA_only", + gitCtx: threeFiles, + targetPath: modA, + commonParent: base, + changedFilesMode: true, + want: []string{filepath.Join(base, "modA", "a.go"), filepath.Join(base, "modA", "b.go")}, + }, + { + name: "prefix_foo_does_not_match_foobar", + gitCtx: &xscservices.XscGitInfoContext{GitDiffContext: xscservices.GitDiffContext{ChangedFiles: []string{"foo/x.go", "foobar/y.go"}}}, + targetPath: filepath.Join(base, "foo"), + commonParent: base, + changedFilesMode: true, + want: []string{filepath.Join(base, "foo", "x.go")}, + }, + { + name: "absolute_changed_file_under_repo", + gitCtx: xscGitInfoWithChanged(t, filepath.Join(base, "modA", "abs.go")), + targetPath: modA, + commonParent: base, + changedFilesMode: true, + want: []string{filepath.Join(base, "modA", "abs.go")}, + }, + { + name: "deduplicates_same_paths", + gitCtx: &xscservices.XscGitInfoContext{GitDiffContext: xscservices.GitDiffContext{ChangedFiles: []string{"modA/a.go", "modA/a.go", "./modA/a.go"}}}, + targetPath: modA, + commonParent: base, + changedFilesMode: true, + want: []string{filepath.Join(base, "modA", "a.go")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SastChangedFilesForTarget(tt.changedFilesMode, tt.gitCtx, tt.targetPath, tt.commonParent) + if tt.wantEmpty { + assert.Empty(t, got, "SastChangedFilesForTarget should not return any paths in this case") + } else { + assert.ElementsMatch(t, tt.want, got, "SastChangedFilesForTarget per-target paths (order may be sorted in implementation)") + } + }) + } +} + +func TestCreateConfigFile_ChangedFilesModeRoots(t *testing.T) { + scanner, cleanUp := jas.InitJasTest(t) + defer cleanUp() + tempDir, cleanUpTempDir := coreTests.CreateTempDirWithCallbackAndAssert(t) + defer cleanUpTempDir() + scanner.TempDir = tempDir + scannerTempDir, err := jas.CreateScannerTempDirectory(scanner, jasutils.Sast.String(), 0) + require.NoError(t, err) + + jfrogAppsConfigForTest, err := jas.CreateJFrogAppsConfig([]string{}) + require.NoError(t, err) + module := jfrogAppsConfigForTest.Modules[0] + sastScanner := module.Scanners.Sast + if sastScanner == nil { + sastScanner = &jfrogappsconfig.SastScanner{} + } + expectedDefaultRoots, err := jas.GetSourceRoots(module, &sastScanner.Scanner) + require.NoError(t, err) + + changed := []string{"src/a.go", "src/b.go"} + ssm, err := newSastScanManager(scanner, scannerTempDir, false, false, "", nil) + require.NoError(t, err) + + type yamlCfg struct { + Scans []struct { + Roots []string `yaml:"roots,omitempty"` + } `yaml:"scans,omitempty"` + } + readConfigRoots := func(t *testing.T) []string { + t.Helper() + data, err := os.ReadFile(ssm.configFileName) + require.NoError(t, err) + var cfg yamlCfg + require.NoError(t, yaml.Unmarshal(data, &cfg)) + require.Len(t, cfg.Scans, 1) + return cfg.Scans[0].Roots + } + + for _, tc := range []struct { + name string + changedFilesMode bool + // sastForCall is the slice passed to createConfigFile; nil to pass nil. + sastForCall []string + want []string + emptyRoots bool + }{ + { + name: "env_true_uses_changed_files_as_roots", + changedFilesMode: true, + sastForCall: changed, + want: changed, + }, + { + name: "env_1_uses_changed_files_as_roots", + changedFilesMode: true, + sastForCall: changed, + want: changed, + }, + { + name: "env_false_ignores_changed_files", + changedFilesMode: false, + sastForCall: changed, + want: expectedDefaultRoots, + }, + { + // In changed-files mode, do not use full module roots; RunSastScan skips the analyzer with no diff baseline. + name: "env_true_no_changed_file_list_uses_no_module_roots", + changedFilesMode: true, + sastForCall: nil, + emptyRoots: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ssm.changedFilesMode = tc.changedFilesMode + require.NoError(t, ssm.createConfigFile(module, false, tc.sastForCall, nil)) + got := readConfigRoots(t) + if tc.emptyRoots { + assert.Empty(t, got, "with changed-files mode on and no per-target list, roots should be nil/empty in YAML, not the default module source roots") + } else { + assert.ElementsMatch(t, tc.want, got) + } + }) + } +} diff --git a/scans_test.go b/scans_test.go index f492efbff..f9fe653a7 100644 --- a/scans_test.go +++ b/scans_test.go @@ -209,12 +209,12 @@ func TestXrayBinaryScanSelectiveScan(t *testing.T) { subScans: []utils.SubScanType{utils.ScaScan, utils.ContextualAnalysisScan}, withoutCa: false, validate: func(t *testing.T, issueCount validations.ValidationCountActualValues) { - // Expect only SCA vulnerabilities + // Expect SCA vulnerabilities and contextual analysis statuses assert.GreaterOrEqual(t, issueCount.ScaVulnerabilities, 17, "SCA vulnerabilities count mismatch - should be 17 or more") + assert.Equal(t, 3, issueCount.ApplicableVulnerabilities, "Applicable vulnerabilities count mismatch - should be 3") + assert.Equal(t, 3, issueCount.UndeterminedVulnerabilities, "Undetermined vulnerabilities count mismatch - should be 3") // All other vulnerability types should be 0 assert.Equal(t, 0, issueCount.SecretsVulnerabilities, "Secrets vulnerabilities count mismatch - should be 0") - assert.Equal(t, 2, issueCount.ApplicableVulnerabilities, "Applicable vulnerabilities count mismatch - should be 2") - assert.Equal(t, 3, issueCount.UndeterminedVulnerabilities, "Undetermined vulnerabilities count mismatch - should be 3") }, }, {