diff --git a/README.md b/README.md index 81040af..2fa2fe5 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,13 @@ ignore_states = "not-fixed,unknown,wont-fix" transitive_libraries = false # Optional list of path patterns to exclude from Grype scanning. # exclude = ["**/vendor/**", "**/testdata/**"] +# Optional list of glob patterns passed to Syft via --exclude during SBOM generation. +# Use this to skip paths such as test sources from the SBOM (e.g. src/test/). +# Note: test-scoped pom.xml dependencies are not affected (Syft has no Maven scope filter). +# syft_exclude = ["**/src/test/**"] +# Depth to recursively resolve parent POMs (env: SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH). +# Default is 1; set to 0 for no limit. +# syft_depth = 1 # OpenGrep – static application security testing (SAST) scanner. [opengrep] @@ -182,6 +189,8 @@ transitive_libraries = false | `[grype].ignore_states` | string | no | Comma-separated Grype vulnerability states to suppress (e.g. `not-fixed,unknown,wont-fix`). | | `[grype].transitive_libraries` | bool | no | When `true`, Syft resolves transitive Java dependencies via Maven Central. Default: `false`. | | `[grype].exclude` | string array | no | Path glob patterns to exclude from Grype scanning (e.g. `["**/vendor/**"]`). | +| `[grype].syft_exclude` | string array | no | Path glob patterns passed to Syft via `--exclude` during SBOM generation (e.g. `["**/src/test/**"]`). Excludes filesystem paths only; test-scoped `pom.xml` dependencies are unaffected. | +| `[grype].syft_depth` | int | no | Maximum number of parent POM levels Syft will recursively resolve during Java/Maven analysis. Default is `1`; `0` means no limit. Forwarded as `SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH`. | | `[opengrep].exclude` | string array | no | Path glob patterns to exclude from OpenGrep scanning (e.g. `["**/vendor/**"]`). | | `[opengrep].exclude_rule` | string array | no | OpenGrep rule IDs to skip (e.g. `["python.lang.security.audit.formatted-sql-query.formatted-sql-query"]`). | | `[proxy].http_proxy` | string | no | HTTP proxy URL forwarded as `HTTP_PROXY` / `http_proxy` to all scanner sub-processes. | @@ -330,6 +339,8 @@ The Grype scanner uploads its JSON output file to DefectDojo as a `multipart/for | Do not reactivate | `true` | Previously suppressed findings are not reactivated on reimport | | Branch tag | `` | Associates the results with the scanned branch | +The **CWE/CVE** column in the CLI output and in file exports is populated with the CVE identifier (e.g. `CVE-2021-44228`) reported by Grype for each vulnerability match. KICS and OpenGrep findings use a CWE number in the same column. + ### OpenGrep Sync Behaviour The OpenGrep scanner uploads its JSON output file to DefectDojo as a `multipart/form-data` request. Before uploading, the file is enriched so that each result contains an `extra.severity` field required by DefectDojo's Semgrep JSON Report parser (the value is copied from `extra.metadata.impact`). The endpoint used is `/api/v2/import-scan/` on the first run and `/api/v2/reimport-scan/` on subsequent runs. The following options are set on every upload: diff --git a/config.toml b/config.toml index ed8b350..9020eaf 100644 --- a/config.toml +++ b/config.toml @@ -9,6 +9,7 @@ platform = "Dockerfile" [grype] ignore_states = "not-fixed,unknown,wont-fix" transitive_libraries = false +# syft_exclude = ["**/src/test/**"] # glob patterns passed to Syft --exclude to skip paths during SBOM generation [opengrep] exclude = ["tests/**"] diff --git a/connectors/defectdojo/client/client.go b/connectors/defectdojo/client/client.go index a4d4928..d74a5f3 100644 --- a/connectors/defectdojo/client/client.go +++ b/connectors/defectdojo/client/client.go @@ -1,11 +1,11 @@ package client import ( + "ScopeGuardian/logger" "bytes" "fmt" "io" "net/http" - "ScopeGuardian/logger" ) // Client is the interface for making HTTP requests to the DefectDojo API. diff --git a/connectors/defectdojo/const.go b/connectors/defectdojo/const.go index 4a92711..3c3a758 100644 --- a/connectors/defectdojo/const.go +++ b/connectors/defectdojo/const.go @@ -1,17 +1,17 @@ package defectdojo const ( - APIPrefix = "/api/v2" - GetProductsPath = "/products?name_exact=" - GetEngagementsPath = "/engagements?product=%d&offset=%d&limit=%d" - CreateEngagementPath = "/engagements/" - UpdateEngagementPath = "/engagements/%d/" - ImportScanPath = "/import-scan/" - ReimportScanPath = "/reimport-scan/" - GetTestsPath = "/tests/?engagement=%d&scan_type=%s" + APIPrefix = "/api/v2" + GetProductsPath = "/products?name_exact=" + GetEngagementsPath = "/engagements?product=%d&offset=%d&limit=%d" + CreateEngagementPath = "/engagements/" + UpdateEngagementPath = "/engagements/%d/" + ImportScanPath = "/import-scan/" + ReimportScanPath = "/reimport-scan/" + GetTestsPath = "/tests/?engagement=%d&scan_type=%s" // GetFindingsPath fetches only active (non-duplicate) findings and is used for // polling until the post-import count stabilises. - GetFindingsPath = "/findings/?test__engagement=%d&active=true&offset=%d&limit=%d" + GetFindingsPath = "/findings/?test__engagement=%d&active=true&offset=%d&limit=%d" // GetAllEngagementFindingsPath fetches all findings for an engagement regardless // of their active/duplicate status so that callers can read the "active" and // "duplicate" fields directly and derive the correct local Status. diff --git a/connectors/defectdojo/factory_test.go b/connectors/defectdojo/factory_test.go index c7abff7..9c0e5a9 100644 --- a/connectors/defectdojo/factory_test.go +++ b/connectors/defectdojo/factory_test.go @@ -1,8 +1,8 @@ package defectdojo import ( - "net/http" "ScopeGuardian/connectors/defectdojo/client" + "net/http" "testing" "github.com/stretchr/testify/assert" diff --git a/connectors/defectdojo/service.go b/connectors/defectdojo/service.go index cebddcf..9991026 100644 --- a/connectors/defectdojo/service.go +++ b/connectors/defectdojo/service.go @@ -1,6 +1,8 @@ package defectdojo import ( + "ScopeGuardian/connectors/defectdojo/client" + "ScopeGuardian/logger" "bytes" "encoding/json" "errors" @@ -9,8 +11,6 @@ import ( "net/http" "net/url" "reflect" - "ScopeGuardian/connectors/defectdojo/client" - "ScopeGuardian/logger" "strconv" "time" ) diff --git a/display/display.go b/display/display.go index ead953d..162096e 100644 --- a/display/display.go +++ b/display/display.go @@ -1,13 +1,13 @@ package display import ( + "ScopeGuardian/domains/models" + environment_variable "ScopeGuardian/environnement_variable" "encoding/csv" "encoding/json" "fmt" "io" "strconv" - "ScopeGuardian/domains/models" - environment_variable "ScopeGuardian/environnement_variable" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" @@ -18,7 +18,7 @@ const ( rowEngine = "Engine" rowSeverity = "Severity" rowName = "Name" - rowCwe = "CWE" + rowCwe = "CWE/CVE" rowDescription = "Description" rowSinkFile = "Sink File" rowSinkLine = "Sink Line" diff --git a/display/display_test.go b/display/display_test.go index d4966fc..a795332 100644 --- a/display/display_test.go +++ b/display/display_test.go @@ -1,10 +1,10 @@ package display import ( + "ScopeGuardian/domains/models" "bytes" "encoding/json" "strings" - "ScopeGuardian/domains/models" "testing" "github.com/stretchr/testify/assert" @@ -138,7 +138,7 @@ func TestDumpFindings_CSV_ContainsHeaders(t *testing.T) { assert.Nil(t, err) output := buf.String() - assert.True(t, strings.HasPrefix(output, "Engine,Severity,Name,CWE,Description,SinkFile,SinkLine,Recommendation,Status")) + assert.True(t, strings.HasPrefix(output, "Engine,Severity,Name,CWE/CVE,Description,SinkFile,SinkLine,Recommendation,Status")) } func TestDumpFindings_CSV_ContainsStatus(t *testing.T) { diff --git a/domains/models/finding.go b/domains/models/finding.go index 85e096a..c13541b 100644 --- a/domains/models/finding.go +++ b/domains/models/finding.go @@ -10,11 +10,11 @@ import ( const ( // FindingStatusActive indicates a finding that is active in DefectDojo // (not a duplicate, not suppressed). Without --sync all findings default to ACTIVE. - FindingStatusActive = "ACTIVE" + FindingStatusActive = "ACTIVE" // FindingStatusInactive indicates a finding that DefectDojo has suppressed, // marked as a false positive, or accepted as a risk. The local scanner still // reports it but it is excluded from security-gate evaluation. - FindingStatusInactive = "INACTIVE" + FindingStatusInactive = "INACTIVE" // FindingStatusDuplicate indicates a finding that DefectDojo's deduplication // engine has identified as a duplicate of another finding in the product. // Duplicate findings are excluded from security-gate evaluation. @@ -23,10 +23,14 @@ const ( // Finding represents a single security finding produced by a scanner. type Finding struct { - Engine string - Severity string - Name string - VulnId string + Engine string + Severity string + Name string + VulnId string + // Cwe holds the CWE identifier for most scanners (e.g. "CWE-79"). For Grype + // findings it holds the CVE identifier (extracted from vulnerability.epss.cve + // when present, falling back to vulnerability.id). The display layer renders + // this column as "CWE/CVE". Cwe string Description string SinkFile string @@ -77,9 +81,9 @@ func FilterFindingsByStatus(findings []Finding, statuses []string) []Finding { // Scanner-specific notes: // - Grype: recommendation is the "Upgrade to X" string derived from fix.versions. // - OpenGrep: recommendation is always "" because DefectDojo's Semgrep parser stores -// extra.message in description, not mitigation. The hash is additionally -// injected into extra.fingerprint before upload so that DefectDojo stores -// it as unique_id_from_tool, enabling a direct lookup without recomputation. +// extra.message in description, not mitigation. The hash is additionally +// injected into extra.fingerprint before upload so that DefectDojo stores +// it as unique_id_from_tool, enabling a direct lookup without recomputation. // - KICS: recommendation is the expected_value from each file entry. func ComputeFindingHash(severity, sinkFile string, sinkLine int, recommendation string) string { input := strings.ToLower(strings.TrimSpace(severity)) + "|" + diff --git a/engine/const.go b/engine/const.go index 9b5f091..8f38dca 100644 --- a/engine/const.go +++ b/engine/const.go @@ -24,8 +24,8 @@ const ( ) const ( - kicsScannerName = "Kics (IACST)" - syftScannerName = "Syft (SBOM)" - grypeScannerName = "Grype (SCA)" - opengrepScannerName = "OpenGrep (SAST)" + kicsScannerName = "Kics (IACST)" + syftScannerName = "Syft (SBOM)" + grypeScannerName = "Grype (SCA)" + opengrepScannerName = "OpenGrep (SAST)" ) diff --git a/engine/engine.go b/engine/engine.go index 46d7a48..1724fd5 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -1,22 +1,22 @@ package engine import ( - "fmt" - "net/http" - "os" - "path/filepath" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/connectors/defectdojo/client" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" environment_variable "ScopeGuardian/environnement_variable" - featuresync "ScopeGuardian/features/sync" "ScopeGuardian/features/scans/grype" "ScopeGuardian/features/scans/kics" "ScopeGuardian/features/scans/opengrep" "ScopeGuardian/features/scans/syft" + featuresync "ScopeGuardian/features/sync" "ScopeGuardian/loader" "ScopeGuardian/logger" + "fmt" + "net/http" + "os" + "path/filepath" "sync" ) diff --git a/engine/engine_test.go b/engine/engine_test.go index 4a81ae6..1e981a7 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -1,11 +1,11 @@ package engine import ( - "errors" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" "ScopeGuardian/loader" + "errors" "testing" "github.com/stretchr/testify/assert" diff --git a/features/scans/grype/const.go b/features/scans/grype/const.go index b8a8892..178baa5 100644 --- a/features/scans/grype/const.go +++ b/features/scans/grype/const.go @@ -1,16 +1,16 @@ package grype const ( - binaryPath = "/opt/grype/bin/grype" - dirPath = "/opt/grype" - configPath = "/opt/grype/config/grype.yaml" + binaryPath = "/opt/grype/bin/grype" + dirPath = "/opt/grype" + configPath = "/opt/grype/config/grype.yaml" outputFolder = "results" scannerType = "SCA" ) const ( severityThreshold = "Info" - groupByProperty = "finding_title" + groupByProperty = "component_name+component_version" findingGroupProperty = true findingTagProperty = true SCAEngineTag = "SCA" diff --git a/features/scans/grype/dto.go b/features/scans/grype/dto.go index 508fd6c..1c0e6ac 100644 --- a/features/scans/grype/dto.go +++ b/features/scans/grype/dto.go @@ -5,11 +5,16 @@ type GrypeFix struct { State string `json:"state"` } +type GrypeEpss struct { + Cve string `json:"cve"` +} + type GrypeVulnerability struct { - ID string `json:"id"` - Severity string `json:"severity"` - Description string `json:"description"` - Fix GrypeFix `json:"fix"` + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` + Fix GrypeFix `json:"fix"` + Epss []GrypeEpss `json:"epss"` } type GrypeArtifactLocation struct { diff --git a/features/scans/grype/mocks/working_results/results/grype-result.json b/features/scans/grype/mocks/working_results/results/grype-result.json index 6179c62..f0c7f9e 100644 --- a/features/scans/grype/mocks/working_results/results/grype-result.json +++ b/features/scans/grype/mocks/working_results/results/grype-result.json @@ -2,13 +2,18 @@ "matches": [ { "vulnerability": { - "id": "CVE-2021-1234", + "id": "GHSA-xxxx-1234", "severity": "High", "description": "A test high severity vulnerability in test-package", "fix": { "versions": ["1.2.0"], "state": "fixed" - } + }, + "epss": [ + { + "cve": "CVE-2021-1234" + } + ] }, "artifact": { "name": "test-package", diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index 9499675..03bd513 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -1,10 +1,6 @@ package grype import ( - "encoding/json" - "errors" - "fmt" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -12,6 +8,10 @@ import ( "ScopeGuardian/exec" "ScopeGuardian/loader" "ScopeGuardian/logger" + "encoding/json" + "errors" + "fmt" + "os" "strings" "time" ) @@ -101,12 +101,18 @@ func (s *GrypeServiceImpl) LoadFindings() ([]models.Finding, error) { recommendation = fmt.Sprintf(recommendationUpgradeMultiple, strings.Join(match.Vulnerability.Fix.Versions, ", ")) } + cveId := match.Vulnerability.ID + if len(match.Vulnerability.Epss) > 0 && match.Vulnerability.Epss[0].Cve != "" { + cveId = match.Vulnerability.Epss[0].Cve + } + severity := strings.ToUpper(match.Vulnerability.Severity) f := models.Finding{ Engine: scannerType, Severity: severity, Name: fmt.Sprintf("%s %s", match.Artifact.Name, match.Artifact.Version), - VulnId: match.Vulnerability.ID, + VulnId: cveId, + Cwe: cveId, Description: match.Vulnerability.Description, SinkFile: sinkFile, Recommendation: recommendation, diff --git a/features/scans/grype/service_test.go b/features/scans/grype/service_test.go index 74d276d..546dde7 100644 --- a/features/scans/grype/service_test.go +++ b/features/scans/grype/service_test.go @@ -1,12 +1,12 @@ package grype import ( - "fmt" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" environment_variable "ScopeGuardian/environnement_variable" "ScopeGuardian/loader" + "fmt" + "os" "testing" "github.com/stretchr/testify/assert" @@ -91,7 +91,7 @@ func TestNewGrypeService(t *testing.T) { } func TestLoadFindings(t *testing.T) { - t.Run("Should load findings", func(t *testing.T) { + t.Run("Should load findings and use epss.cve when present, fallback to vulnerability id otherwise", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -102,13 +102,17 @@ func TestLoadFindings(t *testing.T) { assert.Nil(t, err) assert.EqualValues(t, 2, len(findings)) + // finding[0]: vulnerability.id is a GHSA id; epss.cve holds the CVE assert.EqualValues(t, "test-package 1.0.0", findings[0].Name) assert.EqualValues(t, "CVE-2021-1234", findings[0].VulnId) + assert.EqualValues(t, "CVE-2021-1234", findings[0].Cwe) assert.EqualValues(t, "HIGH", findings[0].Severity) assert.EqualValues(t, "Upgrade to 1.2.0", findings[0].Recommendation) + // finding[1]: no epss object; falls back to vulnerability.id assert.EqualValues(t, "another-package 2.0.0", findings[1].Name) assert.EqualValues(t, "CVE-2021-5678", findings[1].VulnId) + assert.EqualValues(t, "CVE-2021-5678", findings[1].Cwe) assert.EqualValues(t, "MEDIUM", findings[1].Severity) assert.EqualValues(t, "", findings[1].Recommendation) }) diff --git a/features/scans/kics/const.go b/features/scans/kics/const.go index fad7aef..7ee83ce 100644 --- a/features/scans/kics/const.go +++ b/features/scans/kics/const.go @@ -18,16 +18,16 @@ const ( doNotReactivate = true ) const ( - scanArgument = "scan" - ciArgument = "--ci" - librariesPathArgument = "--libraries-path" - queriesPathArgument = "--queries-path" - outputPathArgument = "--output-path" - outputNameArgument = "--output-name" - pathArgument = "--path" - typeArgument = "--type" - ignoreOnExitArgument = "--ignore-on-exit" - excludeQueriesArgument = "--exclude-queries" + scanArgument = "scan" + ciArgument = "--ci" + librariesPathArgument = "--libraries-path" + queriesPathArgument = "--queries-path" + outputPathArgument = "--output-path" + outputNameArgument = "--output-name" + pathArgument = "--path" + typeArgument = "--type" + ignoreOnExitArgument = "--ignore-on-exit" + excludeQueriesArgument = "--exclude-queries" ) const ( diff --git a/features/scans/kics/dto.go b/features/scans/kics/dto.go index 250a22d..a500375 100644 --- a/features/scans/kics/dto.go +++ b/features/scans/kics/dto.go @@ -1,15 +1,15 @@ package kics type KicsFinding struct { - QueryName string `json:"query_name"` - QueryId string `json:"query_id"` - QueryUrl string `json:"query_url"` - Severity string `json:"severity"` - Platform string `json:"platforme"` - Cwe string `json:"cwe"` - RiskScore string `json:"risk_score"` - Description string `json:"description"` - Files []KicsFile `json:"files"` + QueryName string `json:"query_name"` + QueryId string `json:"query_id"` + QueryUrl string `json:"query_url"` + Severity string `json:"severity"` + Platform string `json:"platforme"` + Cwe string `json:"cwe"` + RiskScore string `json:"risk_score"` + Description string `json:"description"` + Files []KicsFile `json:"files"` } type KicsFile struct { diff --git a/features/scans/kics/service.go b/features/scans/kics/service.go index 71c86d9..461e7be 100644 --- a/features/scans/kics/service.go +++ b/features/scans/kics/service.go @@ -1,11 +1,6 @@ package kics import ( - "encoding/json" - "errors" - "fmt" - "io" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -13,6 +8,11 @@ import ( "ScopeGuardian/exec" "ScopeGuardian/loader" "ScopeGuardian/logger" + "encoding/json" + "errors" + "fmt" + "io" + "os" "strings" "time" ) diff --git a/features/scans/kics/service_test.go b/features/scans/kics/service_test.go index 48e207e..9bae999 100644 --- a/features/scans/kics/service_test.go +++ b/features/scans/kics/service_test.go @@ -1,12 +1,12 @@ package kics import ( - "fmt" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" environment_variable "ScopeGuardian/environnement_variable" "ScopeGuardian/loader" + "fmt" + "os" "testing" "github.com/stretchr/testify/assert" diff --git a/features/scans/opengrep/const.go b/features/scans/opengrep/const.go index 9590180..8bb1f1e 100644 --- a/features/scans/opengrep/const.go +++ b/features/scans/opengrep/const.go @@ -22,12 +22,12 @@ const ( ) const ( - jsonOutputArgument = "--json-output=" - ossOnlyArgument = "--oss-only" - quietArgument = "-q" - skipUnknownExtArgument = "--skip-unknown-extensions" - excludeArgument = "--exclude" - excludeRuleArgument = "--exclude-rule" + jsonOutputArgument = "--json-output=" + ossOnlyArgument = "--oss-only" + quietArgument = "-q" + skipUnknownExtArgument = "--skip-unknown-extensions" + excludeArgument = "--exclude" + excludeRuleArgument = "--exclude-rule" ) const ( diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index 46cd526..30b5c93 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -1,11 +1,6 @@ package opengrep import ( - "encoding/json" - "errors" - "fmt" - "io" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" @@ -13,6 +8,11 @@ import ( "ScopeGuardian/exec" "ScopeGuardian/loader" "ScopeGuardian/logger" + "encoding/json" + "errors" + "fmt" + "io" + "os" "strings" "time" ) @@ -109,12 +109,12 @@ func (s *OpenGrepServiceImpl) LoadFindings() ([]models.Finding, error) { description := strings.Join(item.Extra.Metadata.Owasp, ", ") f := models.Finding{ - Engine: scannerType, - Severity: severity, - Name: item.CheckId, - VulnId: item.CheckId, - Cwe: cwe, - Description: description, + Engine: scannerType, + Severity: severity, + Name: item.CheckId, + VulnId: item.CheckId, + Cwe: cwe, + Description: description, // Message is kept for display but intentionally excluded from the hash: // DefectDojo's Semgrep parser stores extra.message in description, not // mitigation, so f.Mitigation from the DD API will be empty for these findings. diff --git a/features/scans/opengrep/service_test.go b/features/scans/opengrep/service_test.go index 84e9dac..a06a650 100644 --- a/features/scans/opengrep/service_test.go +++ b/features/scans/opengrep/service_test.go @@ -1,14 +1,14 @@ package opengrep import ( - "encoding/json" - "fmt" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" environment_variable "ScopeGuardian/environnement_variable" "ScopeGuardian/loader" + "encoding/json" + "fmt" + "os" "testing" "github.com/stretchr/testify/assert" diff --git a/features/scans/syft/const.go b/features/scans/syft/const.go index de841b2..a87eb95 100644 --- a/features/scans/syft/const.go +++ b/features/scans/syft/const.go @@ -7,10 +7,11 @@ const ( ) const ( - scanArgument = "scan" - configArgument = "-c" - outputArgument = "--output" - quietArgument = "-q" + scanArgument = "scan" + configArgument = "-c" + outputArgument = "--output" + quietArgument = "-q" + excludeArgument = "--exclude" ) const ( @@ -20,9 +21,9 @@ const ( ) const ( - logInfoCommandLine = "Command line invoked [%s]" - logInfoTransitiveLibraries = "Transitive libraries resolution is enabled. This may significantly increase scan time." - logErrorDirectoryNotFound = "Cannot find directory [%s]" + logInfoCommandLine = "Command line invoked [%s]" + logInfoTransitiveLibraries = "Transitive libraries resolution is enabled. This may significantly increase scan time." + logErrorDirectoryNotFound = "Cannot find directory [%s]" ) const ( @@ -32,4 +33,5 @@ const ( const ( envJavaUseNetwork = "SYFT_JAVA_USE_NETWORK" envJavaResolveTransitiveDependencies = "SYFT_JAVA_RESOLVE_TRANSITIVE_DEPENDENCIES" + envJavaMaxParentRecursiveDepth = "SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH" ) diff --git a/features/scans/syft/factory.go b/features/scans/syft/factory.go index accbba2..4c32584 100644 --- a/features/scans/syft/factory.go +++ b/features/scans/syft/factory.go @@ -7,12 +7,20 @@ import ( // GetSyftService constructs and returns a ScanServiceImpl for the Syft SBOM generator // using the provided loader configuration. When a Grype configuration is present, -// its TransitiveLibraries flag is forwarded to the Syft service to control whether -// transitive Java dependencies are resolved from Maven Central during SBOM generation. +// its TransitiveLibraries flag, SyftExclude patterns, and SyftDepth are forwarded +// to the Syft service to control whether transitive Java dependencies are resolved +// from Maven Central, which filesystem paths are excluded during SBOM generation, +// and how many parent POM levels are recursively resolved. func GetSyftService(config loader.Config) interfaces.ScanServiceImpl { transitiveLibraries := false + var exclude []string + depth := 1 if config.Grype != nil { transitiveLibraries = config.Grype.TransitiveLibraries + exclude = config.Grype.SyftExclude + if config.Grype.SyftDepth != 0 { + depth = config.Grype.SyftDepth + } } - return newSyftService(config.Path, transitiveLibraries, config.Proxy.ToEnv()) + return newSyftService(config.Path, transitiveLibraries, exclude, depth, config.Proxy.ToEnv()) } diff --git a/features/scans/syft/factory_test.go b/features/scans/syft/factory_test.go index 767081a..f31ce17 100644 --- a/features/scans/syft/factory_test.go +++ b/features/scans/syft/factory_test.go @@ -32,4 +32,50 @@ func TestGetSyftService(t *testing.T) { assert.NotNil(t, service) assert.True(t, ok) }) + + t.Run("Should use syft_exclude from grype config when empty", func(t *testing.T) { + service := GetSyftService(loader.Config{Grype: &loader.Grype{SyftExclude: nil}}) + svc, ok := service.(*SyftServiceImpl) + + assert.NotNil(t, service) + assert.True(t, ok) + assert.Empty(t, svc.exclude) + }) + + t.Run("Should use syft_exclude from grype config when set", func(t *testing.T) { + patterns := []string{"**/src/test/**", "**/testdata/**"} + service := GetSyftService(loader.Config{Grype: &loader.Grype{SyftExclude: patterns}}) + svc, ok := service.(*SyftServiceImpl) + + assert.NotNil(t, service) + assert.True(t, ok) + assert.Equal(t, patterns, svc.exclude) + }) + + t.Run("Should use default syft_depth of 1 when grype config has depth zero (not configured)", func(t *testing.T) { + service := GetSyftService(loader.Config{Grype: &loader.Grype{SyftDepth: 0}}) + svc, ok := service.(*SyftServiceImpl) + + assert.NotNil(t, service) + assert.True(t, ok) + assert.Equal(t, 1, svc.depth) + }) + + t.Run("Should use syft_depth from grype config when set", func(t *testing.T) { + service := GetSyftService(loader.Config{Grype: &loader.Grype{SyftDepth: 5}}) + svc, ok := service.(*SyftServiceImpl) + + assert.NotNil(t, service) + assert.True(t, ok) + assert.Equal(t, 5, svc.depth) + }) + + t.Run("Should default syft_depth to 1 when grype config is nil", func(t *testing.T) { + service := GetSyftService(loader.Config{}) + svc, ok := service.(*SyftServiceImpl) + + assert.NotNil(t, service) + assert.True(t, ok) + assert.Equal(t, 1, svc.depth) + }) } diff --git a/features/scans/syft/service.go b/features/scans/syft/service.go index 37b027b..81ca170 100644 --- a/features/scans/syft/service.go +++ b/features/scans/syft/service.go @@ -1,16 +1,16 @@ package syft import ( - "errors" - "fmt" - "io" - "os" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/interfaces" "ScopeGuardian/domains/models" environment_variable "ScopeGuardian/environnement_variable" "ScopeGuardian/exec" "ScopeGuardian/logger" + "errors" + "fmt" + "io" + "os" "strings" ) @@ -22,6 +22,8 @@ type execRunner func(binaryPath string, dirPath string, args []string, stdout io type SyftServiceImpl struct { path string transitiveLibraries bool + exclude []string + depth int proxyEnv []string runner execRunner } @@ -29,12 +31,20 @@ type SyftServiceImpl struct { // newSyftService builds a SyftServiceImpl from the scan path, resolving it // relative to the SCAN_DIR environment variable. transitiveLibraries controls // whether Syft resolves transitive Java dependencies from Maven Central. +// exclude is a list of glob patterns passed to Syft via --exclude to skip +// matching paths during SBOM generation (e.g. ["**/src/test/**"]); each +// pattern is quoted in the CLI invocation. +// depth sets how many parent POM levels Syft will resolve; 0 means no limit, +// 1 is the default. It is forwarded as the +// SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH environment variable. // proxyEnv is an optional list of "KEY=VALUE" proxy environment variable entries // (see loader.Proxy.ToEnv) forwarded to the Syft process. -func newSyftService(path string, transitiveLibraries bool, proxyEnv []string) interfaces.ScanServiceImpl { +func newSyftService(path string, transitiveLibraries bool, exclude []string, depth int, proxyEnv []string) interfaces.ScanServiceImpl { return &SyftServiceImpl{ path: fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["SCAN_DIR"], path), transitiveLibraries: transitiveLibraries, + exclude: exclude, + depth: depth, proxyEnv: proxyEnv, runner: exec.Wrap, } @@ -61,12 +71,17 @@ func (s *SyftServiceImpl) Start() (bool, error) { logger.Info(logInfoTransitiveLibraries) } + for _, pattern := range s.exclude { + args = append(args, excludeArgument, pattern) + } + logger.Info(fmt.Sprintf(logInfoCommandLine, strings.Join(args, " "))) transitiveValue := fmt.Sprintf("%v", s.transitiveLibraries) extraEnv := []string{ fmt.Sprintf("%s=%s", envJavaUseNetwork, transitiveValue), fmt.Sprintf("%s=%s", envJavaResolveTransitiveDependencies, transitiveValue), + fmt.Sprintf("%s=%d", envJavaMaxParentRecursiveDepth, s.depth), } extraEnv = append(extraEnv, s.proxyEnv...) return s.runner(binaryPath, dirPath, args, os.Stdout, os.Stderr, extraEnv...) diff --git a/features/scans/syft/service_test.go b/features/scans/syft/service_test.go index 701a6d2..c6689ca 100644 --- a/features/scans/syft/service_test.go +++ b/features/scans/syft/service_test.go @@ -1,18 +1,18 @@ package syft import ( + "ScopeGuardian/domains/interfaces" + environment_variable "ScopeGuardian/environnement_variable" "fmt" "io" "os" - "ScopeGuardian/domains/interfaces" - environment_variable "ScopeGuardian/environnement_variable" "testing" "github.com/stretchr/testify/assert" ) func TestNewSyftService(t *testing.T) { - service := newSyftService("./test", false, nil) + service := newSyftService("./test", false, nil, 0, nil) _, ok := service.(interfaces.ScanServiceImpl) assert.NotNil(t, service) @@ -24,7 +24,7 @@ func TestSyftStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() - service := newSyftService("./doesnotexist", false, nil) + service := newSyftService("./doesnotexist", false, nil, 0, nil) ok, err := service.Start() @@ -37,7 +37,7 @@ func TestSyftStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", os.TempDir()) environment_variable.ReloadEnv() - svc := newSyftService(".", false, nil).(*SyftServiceImpl) + svc := newSyftService(".", false, nil, 0, nil).(*SyftServiceImpl) svc.runner = func(_ string, _ string, _ []string, _ io.Writer, _ io.Writer, _ ...string) (bool, error) { return false, fmt.Errorf("runner error") } @@ -52,7 +52,7 @@ func TestSyftStart(t *testing.T) { _ = os.Setenv("SCAN_DIR", os.TempDir()) environment_variable.ReloadEnv() - svc := newSyftService(".", true, nil).(*SyftServiceImpl) + svc := newSyftService(".", true, nil, 0, nil).(*SyftServiceImpl) svc.runner = func(_ string, _ string, _ []string, _ io.Writer, _ io.Writer, _ ...string) (bool, error) { return true, nil } @@ -62,11 +62,87 @@ func TestSyftStart(t *testing.T) { assert.Nil(t, err) assert.True(t, ok) }) + + t.Run("Should pass each exclude pattern as a separate --exclude arg (quoted)", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", os.TempDir()) + environment_variable.ReloadEnv() + + patterns := []string{"**/src/test/**", "**/testdata/**"} + var capturedArgs []string + svc := newSyftService(".", false, patterns, 1, nil).(*SyftServiceImpl) + svc.runner = func(_ string, _ string, args []string, _ io.Writer, _ io.Writer, _ ...string) (bool, error) { + capturedArgs = args + return true, nil + } + + ok, err := svc.Start() + + assert.Nil(t, err) + assert.True(t, ok) + assert.Contains(t, capturedArgs, excludeArgument) + for _, p := range patterns { + assert.Contains(t, capturedArgs, p) + } + }) + + t.Run("Should not pass --exclude arg when exclude list is empty", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", os.TempDir()) + environment_variable.ReloadEnv() + + var capturedArgs []string + svc := newSyftService(".", false, nil, 0, nil).(*SyftServiceImpl) + svc.runner = func(_ string, _ string, args []string, _ io.Writer, _ io.Writer, _ ...string) (bool, error) { + capturedArgs = args + return true, nil + } + + ok, err := svc.Start() + + assert.Nil(t, err) + assert.True(t, ok) + assert.NotContains(t, capturedArgs, excludeArgument) + }) + + t.Run("Should pass SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH env var with configured depth", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", os.TempDir()) + environment_variable.ReloadEnv() + + var capturedEnv []string + svc := newSyftService(".", false, nil, 3, nil).(*SyftServiceImpl) + svc.runner = func(_ string, _ string, _ []string, _ io.Writer, _ io.Writer, extraEnv ...string) (bool, error) { + capturedEnv = extraEnv + return true, nil + } + + ok, err := svc.Start() + + assert.Nil(t, err) + assert.True(t, ok) + assert.Contains(t, capturedEnv, "SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH=3") + }) + + t.Run("Should pass SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH=1 when default depth is used", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", os.TempDir()) + environment_variable.ReloadEnv() + + var capturedEnv []string + svc := newSyftService(".", false, nil, 1, nil).(*SyftServiceImpl) + svc.runner = func(_ string, _ string, _ []string, _ io.Writer, _ io.Writer, extraEnv ...string) (bool, error) { + capturedEnv = extraEnv + return true, nil + } + + ok, err := svc.Start() + + assert.Nil(t, err) + assert.True(t, ok) + assert.Contains(t, capturedEnv, "SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH=1") + }) } func TestSyftLoadFindings(t *testing.T) { t.Run("Should return nil findings and nil error", func(t *testing.T) { - service := newSyftService("./test", false, nil) + service := newSyftService("./test", false, nil, 0, nil) findings, err := service.LoadFindings() @@ -77,7 +153,7 @@ func TestSyftLoadFindings(t *testing.T) { func TestSyftSync(t *testing.T) { t.Run("Should return nil error", func(t *testing.T) { - service := newSyftService("./test", false, nil) + service := newSyftService("./test", false, nil, 0, nil) err := service.Sync(1, "main", nil) diff --git a/features/security-gate/security_gate.go b/features/security-gate/security_gate.go index 13a7fbf..508b9ce 100644 --- a/features/security-gate/security_gate.go +++ b/features/security-gate/security_gate.go @@ -5,10 +5,10 @@ package securitygate import ( - "fmt" "ScopeGuardian/domains/models" "ScopeGuardian/logger" "ScopeGuardian/parser" + "fmt" "strings" ) diff --git a/features/sync/sync.go b/features/sync/sync.go index 76e23e4..2641486 100644 --- a/features/sync/sync.go +++ b/features/sync/sync.go @@ -1,11 +1,11 @@ package sync import ( - "errors" - "fmt" "ScopeGuardian/connectors/defectdojo" "ScopeGuardian/domains/models" "ScopeGuardian/logger" + "errors" + "fmt" "time" ) @@ -243,4 +243,3 @@ func MarkFindingsByDDFindings(local []models.Finding, ddFindings []defectdojo.Fi } return result } - diff --git a/loader/dto.go b/loader/dto.go index fa0d3db..15c0acf 100644 --- a/loader/dto.go +++ b/loader/dto.go @@ -27,6 +27,20 @@ type ( // dependencies from Maven Central during SBOM generation. Disabled by // default because network resolution significantly increases scan time. TransitiveLibraries bool `toml:"transitive_libraries"` + // SyftExclude is a list of glob patterns passed to Syft via --exclude to + // skip matching paths during SBOM generation. Use this to reduce noise from + // test source directories or other paths that should not appear in the SBOM + // (e.g. ["**/src/test/**"]). Note: test-scoped Maven dependencies declared + // in pom.xml are not affected because Syft has no native Maven scope filter; + // this option only excludes paths from the filesystem scan. For JavaScript + // projects, dev dependencies are already excluded unconditionally via the + // include-dev-dependencies: false setting in syft.yaml. + SyftExclude []string `toml:"syft_exclude"` + // SyftDepth controls how many levels of parent POMs Syft will recursively + // resolve during Java/Maven analysis. Defaults to 1 when unset (0). + // Passed to Syft as the SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH environment + // variable. + SyftDepth int `toml:"syft_depth"` } // Opengrep contains the configuration for the OpenGrep SAST scanner. diff --git a/loader/loader.go b/loader/loader.go index a2bb901..bfa6a01 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -1,10 +1,10 @@ package loader import ( + "ScopeGuardian/logger" "errors" "fmt" "os" - "ScopeGuardian/logger" "github.com/pelletier/go-toml/v2" ) diff --git a/loader/loader_test.go b/loader/loader_test.go index 6c2d95d..85ca483 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -28,6 +28,7 @@ func TestLoad(t *testing.T) { assert.Nil(t, config.Kics) assert.EqualValues(t, "not-fixed,unknown,wont-fix", config.Grype.IgnoreStates) assert.EqualValues(t, []string{"/etc/**"}, config.Grype.Exclude) + assert.EqualValues(t, 5, config.Grype.SyftDepth) }) t.Run("Should load configuration file without engine", func(t *testing.T) { diff --git a/loader/mocks/config_with_grype.toml b/loader/mocks/config_with_grype.toml index 62f7dff..2437f2a 100644 --- a/loader/mocks/config_with_grype.toml +++ b/loader/mocks/config_with_grype.toml @@ -4,3 +4,4 @@ path = "./" [grype] ignore_states = "not-fixed,unknown,wont-fix" exclude = ["/etc/**"] +syft_depth = 5 diff --git a/main.go b/main.go index ba52ea9..1dfeac4 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "os" "ScopeGuardian/display" "ScopeGuardian/domains/models" "ScopeGuardian/engine" @@ -9,6 +8,7 @@ import ( "ScopeGuardian/loader" "ScopeGuardian/logger" "ScopeGuardian/parser" + "os" "golang.org/x/exp/slog" ) diff --git a/parser/parser.go b/parser/parser.go index 3696592..6b97634 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -45,14 +45,14 @@ func PrintUsage(w io.Writer) { func Parse(args []string) (Args, error) { fs := flag.NewFlagSet("ScopeGuardian", flag.ContinueOnError) - sync := fs.Bool("sync", false, "Enable sync result with DefectDojo") - threshold := fs.String("threshold", "", "Enable security gate (e.g., critical=1,high=2)") + sync := fs.Bool("sync", false, "Enable sync result with DefectDojo") + threshold := fs.String("threshold", "", "Enable security gate (e.g., critical=1,high=2)") projectName := fs.String("projectName", "", "Name of the project to scan") - branch := fs.String("branch", "", "Project branch to scan") - quiet := fs.Bool("q", false, "Quiet mode: suppress all log output") - output := fs.String("o", "", "Write findings to the specified file") - format := fs.String("format", FormatJSON, "Output format for -o: json, csv, or raw") - filter := fs.String("filter", FilterActive, "Comma-separated finding statuses to display: ACTIVE, INACTIVE, DUPLICATE (default: ACTIVE)") + branch := fs.String("branch", "", "Project branch to scan") + quiet := fs.Bool("q", false, "Quiet mode: suppress all log output") + output := fs.String("o", "", "Write findings to the specified file") + format := fs.String("format", FormatJSON, "Output format for -o: json, csv, or raw") + filter := fs.String("filter", FilterActive, "Comma-separated finding statuses to display: ACTIVE, INACTIVE, DUPLICATE (default: ACTIVE)") if err := fs.Parse(args); err != nil { return Args{}, err