From c5b79610ff66aaaf2dfcc5cf80f9bfdd1ecabb0d Mon Sep 17 00:00:00 2001 From: attiasas Date: Tue, 3 Nov 2026 16:52:32 +0200 Subject: [PATCH] XRAY-164011 - Nested-targets-JAS-scans Co-authored-by: Cursor --- commands/audit/audit.go | 21 ++- commands/audit/audit_test.go | 25 +++ jas/runner/jasrunner.go | 199 ++++++++++++++++++++++++ jas/runner/jasrunner_test.go | 287 +++++++++++++++++++++++++++++++++++ 4 files changed, 531 insertions(+), 1 deletion(-) diff --git a/commands/audit/audit.go b/commands/audit/audit.go index 67357cb3f..20be566e1 100644 --- a/commands/audit/audit.go +++ b/commands/audit/audit.go @@ -1020,12 +1020,15 @@ func createJasScansTask(auditParallelRunner *utils.SecurityParallelRunner, scanR log.Debug(clientutils.GetLogMsgPrefix(threadId, false) + "using custom analyzer manager binary path") } log.Debug(clientutils.GetLogMsgPrefix(threadId, false) + fmt.Sprintf("Using analyzer manager executable at: %s", scanner.AnalyzerManager.AnalyzerManagerFullPath)) + // A target the old flow cannot scan must not cover a nested target, or that nested directory would be scanned by nobody. + runnableTargets := jasTargetsToRun(scanResults.Targets, isNewFlow) // Run JAS scanners for each scan target - for _, targetResult := range scanResults.Targets { + for targetIndex, targetResult := range scanResults.Targets { if !isNewFlow && targetResult.DeprecatedAppsConfigModule == nil { _ = targetResult.AddTargetError(fmt.Errorf("can't find module for path %s", targetResult.Target), scanResults.AllowPartialResults) continue } + coveredDirectoryScans, coveredByDirectory := nestedJasDirectoryScans(scanResults, targetIndex, auditParams, runnableTargets) params := runner.JasRunnerParams{ Runner: auditParallelRunner, ServerDetails: serverDetails, @@ -1057,6 +1060,8 @@ func createJasScansTask(auditParallelRunner *utils.SecurityParallelRunner, scanR TargetCount: len(scanResults.Targets), TargetOutputDir: auditParams.scanResultsOutputDir, AllowPartialResults: scanResults.AllowPartialResults, + CoveredDirectoryScans: coveredDirectoryScans, + CoveredBy: coveredByDirectory, } if generalError = runner.AddJasScannersTasks(params); generalError != nil { _ = targetResult.AddTargetError(fmt.Errorf("failed to add JAS scan tasks: %s", generalError.Error()), scanResults.AllowPartialResults) @@ -1068,6 +1073,20 @@ func createJasScansTask(auditParallelRunner *utils.SecurityParallelRunner, scanR } } +// nestedJasDirectoryScans reports which directory scans this target skips because an outer target already walks them, and which target walks each one. +func nestedJasDirectoryScans(scanResults *results.SecurityCommandResults, targetIndex int, auditParams *AuditParams, runnable []bool) ([]utils.SubScanType, map[utils.SubScanType]string) { + return runner.CoveredDirectoryScans(scanResults.Targets, targetIndex, auditParams.ScansToPerform(), auditParams.GetConfigProfile(), runnable) +} + +// jasTargetsToRun reports which targets will actually run JAS. The old flow skips a target with no module. +func jasTargetsToRun(targets []*results.TargetResults, isNewFlow bool) []bool { + runnable := make([]bool, len(targets)) + for i, target := range targets { + runnable[i] = target != nil && (isNewFlow || target.DeprecatedAppsConfigModule != nil) + } + return runnable +} + func getRootDir(rootDir string, scanResults *results.SecurityCommandResults) string { if rootDir != "" { return rootDir diff --git a/commands/audit/audit_test.go b/commands/audit/audit_test.go index 4557acb74..cc50da171 100644 --- a/commands/audit/audit_test.go +++ b/commands/audit/audit_test.go @@ -2007,3 +2007,28 @@ func TestInitAuditCmdResults_TransitiveContextualAnalysis_ErrorDoesNotFailAuditO require.NoError(t, err) assert.NotContains(t, string(entitlementsJson), "transitive_contextual_analysis") } + +func TestNestedJasDirectoryScanAssignment(t *testing.T) { + root := t.TempDir() + parentDir := root + childDir := filepath.Join(root, "subproject") + cmdResults := results.NewCommandResults(utils.SourceCode) + cmdResults.NewScanResults(results.ScanTarget{Target: parentDir}) + child := cmdResults.NewScanResults(results.ScanTarget{Target: childDir}) + params := NewAuditParams() + runnable := []bool{true, true} + directoryScans := []utils.SubScanType{utils.SecretsScan, utils.IacScan, utils.SastScan, utils.ServicesScan} + + parentCovered, _ := nestedJasDirectoryScans(cmdResults, 0, params, runnable) + assert.Empty(t, parentCovered) + + childCovered, coveredBy := nestedJasDirectoryScans(cmdResults, 1, params, runnable) + assert.ElementsMatch(t, directoryScans, childCovered) + for _, scan := range directoryScans { + assert.Equal(t, parentDir, coveredBy[scan]) + } + + child.Exclude = []string{"*.py"} + childCovered, _ = nestedJasDirectoryScans(cmdResults, 1, params, runnable) + assert.ElementsMatch(t, directoryScans, childCovered) +} diff --git a/jas/runner/jasrunner.go b/jas/runner/jasrunner.go index e4f43d675..ee20d08ae 100644 --- a/jas/runner/jasrunner.go +++ b/jas/runner/jasrunner.go @@ -3,8 +3,11 @@ package runner import ( "errors" "fmt" + "path/filepath" + "strings" "github.com/jfrog/gofrog/parallel" + jfrogappsconfig "github.com/jfrog/jfrog-apps-config/go" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-security/jas" "github.com/jfrog/jfrog-cli-security/jas/applicability" @@ -60,6 +63,10 @@ type JasRunnerParams struct { SastRules string // Outputs TargetOutputDir string + // CoveredDirectoryScans are directory scans an outer target already walks. Applicability is never skipped by this list. + CoveredDirectoryScans []utils.SubScanType + // CoveredBy names the target path that already walks each covered scan. + CoveredBy map[utils.SubScanType]string } func (params JasRunnerParams) runAllScanners() bool { @@ -108,11 +115,203 @@ func AddJasScannersTasks(params JasRunnerParams) error { return errorsCollection } +// directoryScanTypes are the scans that walk a target directory. Applicability is absent: it uses that target's own vulnerabilities. +var directoryScanTypes = []utils.SubScanType{utils.SecretsScan, utils.IacScan, utils.SastScan, utils.ServicesScan} + +// CoveredDirectoryScans reports directory scans an outer target already walks for targets[index]. +// The inner target skips those scans. The outer scan keeps the nested files. A target at the same directory is not covered. +// An empty result means this target keeps its directory scans. +func CoveredDirectoryScans(targets []*results.TargetResults, index int, scansToPerform []utils.SubScanType, configProfile *services.ConfigProfile, runnable []bool) ([]utils.SubScanType, map[utils.SubScanType]string) { + if index < 0 || index >= len(targets) || targets[index] == nil { + return nil, nil + } + var covered []utils.SubScanType + coveredBy := map[utils.SubScanType]string{} + for _, scan := range directoryScanTypes { + if by, ok := directoryScanCovered(targets, index, scan, scansToPerform, configProfile, runnable); ok { + covered = append(covered, scan) + coveredBy[scan] = by + } + } + if len(covered) == 0 { + return nil, nil + } + return covered, coveredBy +} + +// directoryScanCovered reports whether every directory this target scans is already walked by another target that will run scan. +// One directory that cannot be compared, or that no other scan walks, keeps the scan. +// The string is the path of a target that walks those directories. +func directoryScanCovered(targets []*results.TargetResults, index int, scan utils.SubScanType, scansToPerform []utils.SubScanType, configProfile *services.ConfigProfile, runnable []bool) (string, bool) { + roots := comparableScanRoots(rootsForScan(targets[index].ScanTarget, scan)) + if len(roots) == 0 { + return "", false + } + coveredBy := "" + for _, root := range roots { + by, ok := scanRootCovered(targets, index, root, scan, scansToPerform, configProfile, runnable) + if !ok { + return "", false + } + if coveredBy == "" { + coveredBy = by + } + } + return coveredBy, true +} + +func comparableScanRoots(raw []string) []string { + if len(raw) == 0 { + return nil + } + roots := make([]string, 0, len(raw)) + for _, root := range raw { + abs, ok := absoluteScanDir(root) + if !ok { + return nil + } + roots = append(roots, abs) + } + return roots +} + +// rootsForScan reports the directories the scanner will walk. A module working directory replaces the target path for that scan. +func rootsForScan(target results.ScanTarget, scan utils.SubScanType) []string { + module := target.DeprecatedAppsConfigModule + if module == nil { + return jas.GetRootsFromTarget(target) + } + scanner := moduleScanner(module, scan) + if (scanner != nil && len(scanner.WorkingDirs) > 0) || strings.TrimSpace(module.SourceRoot) != "" { + roots, err := jas.GetSourceRoots(*module, scanner) + if err != nil { + return nil + } + return roots + } + return jas.GetRootsFromTarget(target) +} + +func moduleScanner(module *jfrogappsconfig.Module, scan utils.SubScanType) *jfrogappsconfig.Scanner { + if module == nil { + return nil + } + switch scan { + case utils.SecretsScan: + return module.Scanners.Secrets + case utils.IacScan: + return module.Scanners.Iac + case utils.SastScan: + if module.Scanners.Sast != nil { + return &module.Scanners.Sast.Scanner + } + } + return nil +} + +func excludesForScan(target results.ScanTarget, scan utils.SubScanType) []string { + if module := target.DeprecatedAppsConfigModule; module != nil { + return jas.GetJasExcludePatterns(*module, moduleScanner(module, scan), target.GetCentralConfigExclusions(scan)) + } + return jas.GetJasExcludePatternsForTarget(target, target.GetCentralConfigExclusions(scan)) +} + +func absoluteScanDir(dir string) (string, bool) { + if strings.TrimSpace(dir) == "" { + return "", false + } + abs, err := filepath.Abs(dir) + if err != nil { + return "", false + } + return filepath.Clean(abs), true +} + +func scanRootCovered(targets []*results.TargetResults, index int, root string, scan utils.SubScanType, scansToPerform []utils.SubScanType, configProfile *services.ConfigProfile, runnable []bool) (string, bool) { + for otherIndex, other := range targets { + if otherIndex == index || other == nil || !targetWillRun(runnable, otherIndex) || !targetRunsDirectoryScan(other.ScanTarget, scansToPerform, configProfile, scan) { + continue + } + excludes := excludesForScan(other.ScanTarget, scan) + for _, otherRoot := range comparableScanRoots(rootsForScan(other.ScanTarget, scan)) { + _, inside := scanDirRelation(otherRoot, root) + if !inside || scanRootExcluded(otherRoot, root, excludes) { + continue + } + return other.Target, true + } + } + return "", false +} + +// scanRootExcluded reports whether the covering scan's excludes skip this directory. +// The check uses the path from the covering directory, so a repository path that happens to match a default exclude still covers its children. +// Patterns such as **/subproject/** need a segment before the directory and a file under it before they match. +func scanRootExcluded(parent, child string, excludes []string) bool { + if len(excludes) == 0 { + return false + } + rel, err := filepath.Rel(parent, child) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + for _, candidate := range []string{ + rel, + filepath.Join(rel, "file"), + filepath.Join("dir", rel, "nested", "file"), + } { + if utils.IsPathExcludedFromRoot(parent, filepath.Join(parent, candidate), excludes) { + return true + } + } + return false +} + +func scanDirRelation(parent, child string) (same, inside bool) { + if parent == child { + return true, false + } + rel, err := filepath.Rel(parent, child) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false, false + } + if rel == "." { + return true, false + } + return false, true +} + +func targetWillRun(runnable []bool, index int) bool { + return runnable == nil || (index < len(runnable) && runnable[index]) +} + +func targetRunsDirectoryScan(target results.ScanTarget, scansToPerform []utils.SubScanType, configProfile *services.ConfigProfile, subScan utils.SubScanType) bool { + jasType := jasutils.SubScanTypeToJasScanType(subScan) + if jasType == "" { + return false + } + if len(scansToPerform) > 0 && !slices.Contains(scansToPerform, subScan) { + return false + } + if configProfile != nil { + return !jas.ShouldSkipScannerByConfigProfile(target, configProfile, subScan, jasType) + } + return !jas.ShouldSkipScannerByModule(target, jasType) +} + func addJasScanTaskIfNeeded(params JasRunnerParams, subScan utils.SubScanType, task parallel.TaskFunc) (generalError error) { jasType := jasutils.SubScanTypeToJasScanType(subScan) if jasType == "" { return fmt.Errorf("failed to determine Jas scan type for %s", subScan) } + if subScan != utils.ContextualAnalysisScan && slices.Contains(params.CoveredDirectoryScans, subScan) { + coveredBy := "another target" + if params.CoveredBy != nil && params.CoveredBy[subScan] != "" { + coveredBy = params.CoveredBy[subScan] + } + log.Debug(fmt.Sprintf("Skipping %s scan because %s already scans this directory...", subScan, coveredBy)) + return + } if len(params.ScansToPerform) > 0 && !slices.Contains(params.ScansToPerform, subScan) { log.Debug(fmt.Sprintf("Skipping %s scan as requested by input...", subScan)) return diff --git a/jas/runner/jasrunner_test.go b/jas/runner/jasrunner_test.go index 55edfffd2..f05a3def0 100644 --- a/jas/runner/jasrunner_test.go +++ b/jas/runner/jasrunner_test.go @@ -2,11 +2,14 @@ package runner import ( "os" + "path/filepath" "testing" + "time" "github.com/CycloneDX/cyclonedx-go" "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + jfrogappsconfig "github.com/jfrog/jfrog-apps-config/go" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-security/jas" "github.com/jfrog/jfrog-cli-security/jas/applicability" @@ -182,6 +185,290 @@ func TestJasRunner_Target_AnalyzerManagerReturnsError(t *testing.T) { assert.ErrorContains(t, jas.ParseAnalyzerManagerError(jasutils.Applicability, err), "failed to run Applicability scan") } +func TestAddJasScanTaskIfNeededSkipsCoveredDirectoryScan(t *testing.T) { + runner := utils.CreateSecurityParallelRunner(1) + target := newDirTarget(t.TempDir(), nil, nil) + var secretsRan, applicabilityRan bool + params := JasRunnerParams{ + Runner: runner, + ScanResults: target, + CoveredDirectoryScans: []utils.SubScanType{ + utils.SecretsScan, utils.IacScan, utils.SastScan, utils.ServicesScan, utils.ContextualAnalysisScan, + }, + } + assert.NoError(t, addJasScanTaskIfNeeded(params, utils.SecretsScan, func(int) error { + defer runner.JasScannersWg.Done() + secretsRan = true + return nil + })) + assert.NoError(t, addJasScanTaskIfNeeded(params, utils.ContextualAnalysisScan, func(int) error { + defer runner.JasScannersWg.Done() + applicabilityRan = true + return nil + })) + done := make(chan struct{}) + runner.OnScanEnd(func() { close(done) }) + go runner.Start() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("runner did not finish") + } + assert.False(t, secretsRan) + assert.True(t, applicabilityRan) +} + +func TestComputedCoverageSkipsNestedDirectoryScans(t *testing.T) { + root := t.TempDir() + parent := newDirTarget(root, nil, nil) + child := newDirTarget(filepath.Join(root, "subproject"), nil, nil) + targets := []*results.TargetResults{parent, child} + covered, coveredBy := CoveredDirectoryScans(targets, 1, nil, nil, nil) + assert.NotEmpty(t, covered) + assert.Equal(t, root, coveredBy[utils.SecretsScan]) + + parallelRunner := utils.CreateSecurityParallelRunner(1) + var ran bool + params := JasRunnerParams{Runner: parallelRunner, ScanResults: child, CoveredDirectoryScans: covered, CoveredBy: coveredBy} + for _, scan := range covered { + assert.NoError(t, addJasScanTaskIfNeeded(params, scan, func(int) error { + defer parallelRunner.JasScannersWg.Done() + ran = true + return nil + })) + } + done := make(chan struct{}) + parallelRunner.OnScanEnd(func() { close(done) }) + go parallelRunner.Start() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("runner did not finish") + } + assert.False(t, ran) +} + +func newDirTarget(dir string, exclude []string, excludeScanners []string, include ...string) *results.TargetResults { + target := results.ScanTarget{Target: dir, Exclude: exclude, Include: include} + if len(excludeScanners) > 0 { + target.DeprecatedAppsConfigModule = &jfrogappsconfig.Module{ExcludeScanners: excludeScanners} + } + return results.NewCommandResults(utils.SourceCode).NewScanResults(target) +} + +func TestCoveredDirectoryScans(t *testing.T) { + root := t.TempDir() + parent := root + child := filepath.Join(root, "subproject") + inner := filepath.Join(child, "inner") + sibling := filepath.Join(root, "other") + insideModules := filepath.Join(root, "node_modules", "leftpad") + frontend := filepath.Join(root, "frontend") + frontendApp := filepath.Join(frontend, "app") + directoryScans := []utils.SubScanType{utils.SecretsScan, utils.IacScan, utils.SastScan, utils.ServicesScan} + withoutSecrets := []utils.SubScanType{utils.IacScan, utils.SastScan, utils.ServicesScan} + + pipAtRoot := newDirTarget(parent, nil, nil) + pipAtRoot.Technologies = []techutils.Technology{techutils.Pip} + npmAtRoot := newDirTarget(parent, nil, nil) + npmAtRoot.Technologies = []techutils.Technology{techutils.Npm} + unnamedAtRoot := newDirTarget(parent, nil, nil) + sastOutsideChild := newDirTarget(parent, nil, nil) + sastOutsideChild.DeprecatedAppsConfigModule = &jfrogappsconfig.Module{ + SourceRoot: parent, + Scanners: jfrogappsconfig.Scanners{Sast: &jfrogappsconfig.SastScanner{ + Scanner: jfrogappsconfig.Scanner{WorkingDirs: []string{"src"}}, + }}, + } + childDifferentRules := newDirTarget(child, nil, nil) + childDifferentRules.CentralConfigModules = []services.Module{{ + ScanConfig: services.ScanConfig{SastScannerConfig: services.SastScannerConfig{ExcludeRules: []string{"python-hardcoded-credentials"}}}, + }} + parentValidatesSecrets := newDirTarget(parent, nil, nil) + parentValidatesSecrets.CentralConfigModules = []services.Module{{ + ScanConfig: services.ScanConfig{SecretsScannerConfig: services.SecretsScannerConfig{EnableSecretsScan: true, ValidateSecrets: true}}, + }} + parentSkipsSecretsInProfile := newDirTarget(parent, nil, nil) + parentSkipsSecretsInProfile.CentralConfigModules = []services.Module{{ + ScanConfig: services.ScanConfig{ + IacScannerConfig: services.IacScannerConfig{EnableIacScan: true}, + SastScannerConfig: services.SastScannerConfig{EnableSastScan: true}, + ServicesScannerConfig: services.ServicesScannerConfig{EnableServicesScan: true}, + }, + }} + profile := &services.ConfigProfile{ProfileName: "review"} + + tests := []struct { + name string + targets []*results.TargetResults + index int + scans []utils.SubScanType + profile *services.ConfigProfile + runnable []bool + secretsEnabled bool + secretsRequested bool + want []utils.SubScanType + }{ + { + name: "nested target skips directory scans the parent will run", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(child, nil, nil)}, + index: 1, + want: directoryScans, + }, + { + name: "parent is not covered by its nested target", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(child, nil, nil)}, + index: 0, + }, + { + name: "nested target listed before the parent still skips", + targets: []*results.TargetResults{newDirTarget(child, nil, nil), newDirTarget(parent, nil, nil)}, + index: 0, + want: directoryScans, + }, + { + name: "single target keeps every scan", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil)}, + index: 0, + }, + { + name: "sibling directories both keep their scans", + targets: []*results.TargetResults{newDirTarget(child, nil, nil), newDirTarget(sibling, nil, nil)}, + index: 1, + }, + { + name: "targets sharing a directory both keep their scans", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(parent, nil, nil)}, + index: 1, + }, + { + name: "first target sharing a directory keeps the scan", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(parent, nil, nil)}, + index: 0, + }, + { + name: "deeper nested target is covered by the outermost", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(child, nil, nil), newDirTarget(inner, nil, nil)}, + index: 2, + want: directoryScans, + }, + { + name: "parent exclude leaves the nested target to scan itself", + targets: []*results.TargetResults{newDirTarget(parent, []string{"subproject"}, nil), newDirTarget(child, nil, nil)}, + index: 1, + }, + { + name: "default excludes leave a node_modules target to scan itself", + targets: []*results.TargetResults{newDirTarget(parent, utils.DefaultScaExcludePatterns, nil), newDirTarget(insideModules, nil, nil)}, + index: 1, + }, + { + name: "a repository path containing test still covers a nested project", + targets: []*results.TargetResults{ + newDirTarget(filepath.Join(root, "my-test-repo"), utils.DefaultScaExcludePatterns, nil), + newDirTarget(filepath.Join(root, "my-test-repo", "subproject"), utils.DefaultScaExcludePatterns, nil), + }, + index: 1, + want: directoryScans, + }, + { + name: "parent that skips secrets does not cover secrets", + targets: []*results.TargetResults{newDirTarget(parent, utils.DefaultScaExcludePatterns, []string{"secrets"}), newDirTarget(child, utils.DefaultScaExcludePatterns, nil)}, + index: 1, + want: withoutSecrets, + }, + { + name: "targets sharing a directory both scan when only one runs secrets", + targets: []*results.TargetResults{newDirTarget(parent, utils.DefaultScaExcludePatterns, []string{"secrets"}), newDirTarget(parent, utils.DefaultScaExcludePatterns, nil)}, + index: 1, + }, + { + name: "only a requested scan can cover a nested target", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(child, nil, nil)}, + index: 1, + scans: []utils.SubScanType{utils.SecretsScan}, + want: []utils.SubScanType{utils.SecretsScan}, + }, + { + name: "include roots cover only directories they contain", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil, frontend), newDirTarget(child, nil, nil)}, + index: 1, + }, + { + name: "include root covers a target inside it", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil, frontend), newDirTarget(frontendApp, nil, nil)}, + index: 1, + want: directoryScans, + }, + { + name: "target with no directory keeps its scans", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget("", nil, nil)}, + index: 1, + }, + { + name: "a target with no directory does not cover another", + targets: []*results.TargetResults{newDirTarget("", nil, nil), newDirTarget(child, nil, nil)}, + index: 1, + }, + { + name: "a target that will not run does not cover a nested target", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(child, nil, nil)}, + index: 1, + runnable: []bool{false, true}, + }, + { + name: "two technologies at the same directory both keep their scans", + targets: []*results.TargetResults{pipAtRoot, npmAtRoot}, + index: 0, + }, + { + name: "a named technology and a target with no technology both keep a shared directory", + targets: []*results.TargetResults{unnamedAtRoot, npmAtRoot}, + index: 0, + }, + { + name: "a sast working directory does not cover a nested project outside it", + targets: []*results.TargetResults{sastOutsideChild, newDirTarget(child, utils.DefaultScaExcludePatterns, nil)}, + index: 1, + want: []utils.SubScanType{utils.SecretsScan, utils.IacScan, utils.ServicesScan}, + }, + { + name: "different sast excluded rules still leave the nested scans to the outer target", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), childDifferentRules}, + index: 1, + want: directoryScans, + }, + { + name: "different file excludes still leave the nested scans to the outer target", + targets: []*results.TargetResults{newDirTarget(parent, nil, nil), newDirTarget(child, []string{"*.py"}, nil)}, + index: 1, + want: directoryScans, + }, + { + name: "a config profile that disables secrets does not cover secrets", + targets: []*results.TargetResults{parentSkipsSecretsInProfile, newDirTarget(child, nil, nil)}, + index: 1, + profile: profile, + want: withoutSecrets, + }, + { + name: "different secret validation still leaves the nested scans to the outer target", + targets: []*results.TargetResults{parentValidatesSecrets, newDirTarget(child, nil, nil)}, + index: 1, + secretsEnabled: true, + want: directoryScans, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, _ := CoveredDirectoryScans(test.targets, test.index, test.scans, test.profile, test.runnable) + assert.ElementsMatch(t, test.want, got) + assert.NotContains(t, got, utils.ContextualAnalysisScan) + }) + } +} + func TestSastChangedFilesMode(t *testing.T) { profileWithDiffMode := &services.ConfigProfile{Modules: []services.Module{{ ScanConfig: services.ScanConfig{SastScannerConfig: services.SastScannerConfig{EnableFastDiffMode: true}},