diff --git a/.binny.yaml b/.binny.yaml index 8a58ad2b233..d73cbb63c33 100644 --- a/.binny.yaml +++ b/.binny.yaml @@ -26,7 +26,7 @@ tools: # used for linting - name: golangci-lint version: - want: v2.1.2 + want: v2.1.6 method: github-release with: repo: golangci/golangci-lint @@ -58,7 +58,7 @@ tools: # used to release all artifacts - name: goreleaser version: - want: v2.8.2 + want: v2.9.0 method: github-release with: repo: goreleaser/goreleaser @@ -90,7 +90,7 @@ tools: # used for running all local and CI tasks - name: task version: - want: v3.43.2 + want: v3.43.3 method: github-release with: repo: go-task/task @@ -98,7 +98,7 @@ tools: # used for triggering a release - name: gh version: - want: v2.71.0 + want: v2.72.0 method: github-release with: repo: cli/cli diff --git a/.github/actions/bootstrap/action.yaml b/.github/actions/bootstrap/action.yaml index 06b3fb4fd16..3a6a906469e 100644 --- a/.github/actions/bootstrap/action.yaml +++ b/.github/actions/bootstrap/action.yaml @@ -32,7 +32,7 @@ runs: using: "composite" steps: # note: go mod and build is automatically cached on default with v4+ - - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 if: inputs.go-version != '' with: go-version: ${{ inputs.go-version }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4f5b659c241..89657d393f1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -56,14 +56,14 @@ jobs: ${{ runner.os }}-go- - name: Set correct version of Golang to use during CodeQL run - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.21' check-latest: true # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -85,4 +85,4 @@ jobs: run: make grype - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e2cd39ec005..1c17ee739cf 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -60,6 +60,17 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} # This check name is defined as the github action job name (in .github/workflows/testing.yaml) checkName: "Integration tests" + timeoutSeconds: 1200 # 20 minutes, it sometimes takes that long + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Check integration test results + uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 + id: quality_tests + with: + token: ${{ secrets.GITHUB_TOKEN }} + # This check name is defined as the github action job name (in .github/workflows/testing.yaml) + checkName: "Quality tests" + timeoutSeconds: 1200 # 20 minutes, it sometimes takes that long ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Check acceptance test results (linux) @@ -90,11 +101,12 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Quality gate - if: steps.static-analysis.outputs.conclusion != 'success' || steps.unit.outputs.conclusion != 'success' || steps.integration.outputs.conclusion != 'success' || steps.cli-linux.outputs.conclusion != 'success' || steps.acceptance-linux.outputs.conclusion != 'success' || steps.acceptance-mac.outputs.conclusion != 'success' + if: steps.static-analysis.outputs.conclusion != 'success' || steps.unit.outputs.conclusion != 'success' || steps.integration.outputs.conclusion != 'success' || steps.quality_tests.outputs.conclusion != 'success' || steps.cli-linux.outputs.conclusion != 'success' || steps.acceptance-linux.outputs.conclusion != 'success' || steps.acceptance-mac.outputs.conclusion != 'success' run: | echo "Static Analysis Status: ${{ steps.static-analysis.conclusion }}" echo "Unit Test Status: ${{ steps.unit.outputs.conclusion }}" echo "Integration Test Status: ${{ steps.integration.outputs.conclusion }}" + echo "Quality Test Status: ${{ steps.quality_tests.outputs.conclusion }}" echo "Acceptance Test (Linux) Status: ${{ steps.acceptance-linux.outputs.conclusion }}" echo "Acceptance Test (Mac) Status: ${{ steps.acceptance-mac.outputs.conclusion }}" echo "CLI Test (Linux) Status: ${{ steps.cli-linux.outputs.conclusion }}" @@ -158,7 +170,7 @@ jobs: # for updating brew formula in anchore/homebrew-syft GITHUB_BREW_TOKEN: ${{ secrets.ANCHOREOPS_GITHUB_OSS_WRITE_TOKEN }} - - uses: anchore/sbom-action@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0 + - uses: anchore/sbom-action@e11c554f704a0b820cbf8c51673f6945e0731532 # v0.20.0 continue-on-error: true with: artifact-name: sbom.spdx.json diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index b607ac7687b..8d90e6f855e 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -38,6 +38,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v1.0.26 + uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v1.0.26 with: sarif_file: results.sarif diff --git a/README.md b/README.md index 03c9093835e..4a6453e191b 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ docker run --rm \ $(ImageName):$(ImageTag) ``` -### Supported sources +## Supported sources Grype can scan a variety of sources beyond those found in Docker. @@ -210,6 +210,49 @@ use the `--distro :` flag. A full example is: grype --add-cpes-if-none --distro alpine:3.10 sbom:some-alpine-3.10.spdx.json ``` +## Threat & Risk Prioritization + +This section explains the columns and UI cues that help prioritize remediation efforts: + +- **Severity**: String severity based on CVSS scores and indicate the significance of a vulnerability in levels. + This balances concerns such as ease of exploitability, and the potential to affect + confidentiality, integrity, and availability of software and services. + +- **EPSS**: + [Exploit Prediction Scoring System](https://www.first.org/epss/model) is a metric expressing the likelihood + that a vulnerability will be + exploited in the wild over the next 30 days (on a 0–1 scale); higher values signal a greater likelihood of + exploitation. + The table output shows the EPSS percentile, a one-way transform of the EPSS score showing the + proportion of all scored vulnerabilities with an equal or lower probability. + Percentiles linearize a heavily skewed distribution, making threshold choice (e.g. “only CVEs above the + 90th percentile”) straightforward. + +- **KEV Indicator**: Flags entries from CISA’s [Known Exploited Vulnerabilities Catalog](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) + --an authoritative list of flaws observed being exploited in the wild. + +- **Risk Score**: A composite 0–100 metric calculated as: + ```markdown + risk = min(1, threat * average(severity)) * 100 + ``` + Where: + - `severity` is the average of all CVSS scores and string severity for a vulnerability (scaled between 0–1). + - `threat` is the EPSS score (between 0–1). If the vulnerability is on the KEV list then `threat` is + `1.05`, or `1.1` if the vulnerability is associated with a ransomware campaign. + This metric is one way to combine EPSS and CVSS suggested in the [EPSS user guide](https://www.first.org/epss/user-guide). + +- **Suggested Fixes**: All possible fixes for a package are listed, however, when multiple fixes are available, we de-emphasize all + upgrade paths except for the minimal upgrade path (which highlights the smallest, safest version bump). + +Results default to sorting by Risk Score and can be overridden with `--sort-by `: + +- `severity`: sort by severity +- `epss`: sort by EPSS percentile (aka, "threat") +- `risk`: sort by risk score +- `kev`: just like risk, except that KEV entries are always above non-KEV entries +- `package`: sort by package name, version, type +- `vulnerability`: sort by vulnerability ID + ### Supported versions Software updates are always applied to the latest version of Grype; fixes are not backported to any previous versions of Grype. @@ -699,192 +742,240 @@ GRYPE_CONFIG=/path/to/config.yaml grype Configuration options (example values are the default): ```yaml -# enable/disable checking for application updates on startup -# same as GRYPE_CHECK_FOR_APP_UPDATE env var -check-for-app-update: true - -# allows users to specify which image source should be used to generate the sbom -# valid values are: registry, docker, podman -# same as GRYPE_DEFAULT_IMAGE_PULL_SOURCE env var -default-image-pull-source: "" - -# same as --name; set the name of the target being analyzed -name: "" - -# upon scanning, if a severity is found at or above the given severity then the return code will be 2 -# default is unset which will skip this validation (options: negligible, low, medium, high, critical) -# same as --fail-on ; GRYPE_FAIL_ON_SEVERITY env var -fail-on-severity: "" - # the output format of the vulnerability report (options: table, template, json, cyclonedx) -# when using template as the output type, you must also provide a value for 'output-template-file' -# same as -o ; GRYPE_OUTPUT env var -output: "table" +# when using template as the output type, you must also provide a value for 'output-template-file' (env: GRYPE_OUTPUT) +output: 'table' # if using template output, you must provide a path to a Go template file # see https://github.com/anchore/grype#using-templates for more information on template output # the default path to the template file is the current working directory # output-template-file: .grype/html.tmpl +# +# write output report to a file (default is to write to stdout) (env: GRYPE_FILE) +file: '' -# write output report to a file (default is to write to stdout) -# same as --file; GRYPE_FILE env var -file: "" +# pretty-print JSON output (env: GRYPE_PRETTY) +pretty: false -# a list of globs to exclude from scanning, for example: -# exclude: -# - '/etc/**' -# - './out/**/*.json' -# same as --exclude ; GRYPE_EXCLUDE env var -exclude: [] +# distro to match against in the format: : (env: GRYPE_DISTRO) +distro: '' -# include matches on kernel-headers packages that are matched against upstream kernel package -# if 'false' any such matches are marked as ignored -match-upstream-kernel-headers: false +# generate CPEs for packages with no CPE data (env: GRYPE_ADD_CPES_IF_NONE) +add-cpes-if-none: false -# os and/or architecture to use when referencing container images (e.g. "windows/armv6" or "arm64") -# same as --platform; GRYPE_PLATFORM env var -platform: "" +# specify the path to a Go template file (requires 'template' output to be selected) (env: GRYPE_OUTPUT_TEMPLATE_FILE) +output-template-file: '' -# If using SBOM input, automatically generate CPEs when packages have none -add-cpes-if-none: false +# enable/disable checking for application updates on startup (env: GRYPE_CHECK_FOR_APP_UPDATE) +check-for-app-update: true -# Explicitly specify a linux distribution to use as : like alpine:3.10 -distro: +# ignore matches for vulnerabilities that are not fixed (env: GRYPE_ONLY_FIXED) +only-fixed: false -external-sources: - enable: false - maven: - search-upstream-by-sha1: true - base-url: https://search.maven.org/solrsearch/select - rate-limit: 300ms +# ignore matches for vulnerabilities that are fixed (env: GRYPE_ONLY_NOTFIXED) +only-notfixed: false -db: - # check for database updates on execution - # same as GRYPE_DB_AUTO_UPDATE env var - auto-update: true +# ignore matches for vulnerabilities with specified comma separated fix states, options=[fixed not-fixed unknown wont-fix] (env: GRYPE_IGNORE_WONTFIX) +ignore-wontfix: '' - # location to write the vulnerability database cache; defaults to $XDG_CACHE_HOME/grype/db - # same as GRYPE_DB_CACHE_DIR env var - cache-dir: "" +# an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux') (env: GRYPE_PLATFORM) +platform: '' - # URL of the vulnerability database - # same as GRYPE_DB_UPDATE_URL env var - update-url: "https://grype.anchore.io/databases" +# upon scanning, if a severity is found at or above the given severity then the return code will be 1 +# default is unset which will skip this validation (options: negligible, low, medium, high, critical) (env: GRYPE_FAIL_ON_SEVERITY) +fail-on-severity: '' - # it ensures db build is no older than the max-allowed-built-age - # set to false to disable check - validate-age: true +# show suppressed/ignored vulnerabilities in the output (only supported with table output format) (env: GRYPE_SHOW_SUPPRESSED) +show-suppressed: false - # Max allowed age for vulnerability database, - # age being the time since it was built - # Default max age is 120h (or five days) - max-allowed-built-age: "120h" +# orient results by CVE instead of the original vulnerability ID when possible (env: GRYPE_BY_CVE) +by-cve: false - # Timeout for downloading GRYPE_DB_UPDATE_URL to see if the database needs to be downloaded - # This file is ~156KiB as of 2024-04-17 so the download should be quick; adjust as needed - update-available-timeout: "30s" +# sort the match results with the given strategy, options=[package severity epss risk kev vulnerability] (env: GRYPE_SORT_BY) +sort-by: 'risk' - # Timeout for downloading actual vulnerability DB - # The DB is ~156MB as of 2024-04-17 so slower connections may exceed the default timeout; adjust as needed - update-download-timeout: "120s" +# same as --name; set the name of the target being analyzed (env: GRYPE_NAME) +name: '' -search: - # the search space to look for packages (options: all-layers, squashed) - # same as -s ; GRYPE_SEARCH_SCOPE env var - scope: "squashed" +# allows users to specify which image source should be used to generate the sbom +# valid values are: registry, docker, podman (env: GRYPE_DEFAULT_IMAGE_PULL_SOURCE) +default-image-pull-source: '' - # search within archives that do contain a file index to search against (zip) - # note: for now this only applies to the java package cataloger - # same as GRYPE_PACKAGE_SEARCH_INDEXED_ARCHIVES env var - indexed-archives: true +search: + # selection of layers to analyze, options=[squashed all-layers] (env: GRYPE_SEARCH_SCOPE) + scope: 'squashed' # search within archives that do not contain a file index to search against (tar, tar.gz, tar.bz2, etc) # note: enabling this may result in a performance impact since all discovered compressed tars will be decompressed - # note: for now this only applies to the java package cataloger - # same as GRYPE_PACKAGE_SEARCH_UNINDEXED_ARCHIVES env var + # note: for now this only applies to the java package cataloger (env: GRYPE_SEARCH_UNINDEXED_ARCHIVES) unindexed-archives: false -# options when pulling directly from a registry via the "registry:" scheme -registry: - # skip TLS verification when communicating with the registry - # same as GRYPE_REGISTRY_INSECURE_SKIP_TLS_VERIFY env var - insecure-skip-tls-verify: false - - # use http instead of https when connecting to the registry - # same as GRYPE_REGISTRY_INSECURE_USE_HTTP env var - insecure-use-http: false - - # filepath to a CA certificate (or directory containing *.crt, *.cert, *.pem) used to generate the client certificate - # GRYPE_REGISTRY_CA_CERT env var - ca-cert: "" + # search within archives that do contain a file index to search against (zip) + # note: for now this only applies to the java package cataloger (env: GRYPE_SEARCH_INDEXED_ARCHIVES) + indexed-archives: true - # credentials for specific registries - auth: - # the URL to the registry (e.g. "docker.io", "localhost:5000", etc.) - # GRYPE_REGISTRY_AUTH_AUTHORITY env var - - authority: "" +# A list of vulnerability ignore rules, one or more property may be specified and all matching vulnerabilities will be ignored. +# This is the full set of supported rule fields: +# - vulnerability: CVE-2008-4318 +# fix-state: unknown +# package: +# name: libcurl +# version: 1.5.1 +# type: npm +# location: "/usr/local/lib/node_modules/**" +# +# VEX fields apply when Grype reads vex data: +# - vex-status: not_affected +# vex-justification: vulnerable_code_not_present +ignore: [] - # GRYPE_REGISTRY_AUTH_USERNAME env var - username: "" +# a list of globs to exclude from scanning, for example: +# - '/etc/**' +# - './out/**/*.json' +# same as --exclude (env: GRYPE_EXCLUDE) +exclude: [] - # GRYPE_REGISTRY_AUTH_PASSWORD env var - password: "" +external-sources: + # enable Grype searching network source for additional information (env: GRYPE_EXTERNAL_SOURCES_ENABLE) + enable: false - # note: token and username/password are mutually exclusive - # GRYPE_REGISTRY_AUTH_TOKEN env var - token: "" + maven: + # search for Maven artifacts by SHA1 (env: GRYPE_EXTERNAL_SOURCES_MAVEN_SEARCH_MAVEN_UPSTREAM) + search-maven-upstream: true - # filepath to the client certificate used for TLS authentication to the registry - # GRYPE_REGISTRY_AUTH_TLS_CERT env var - tls-cert: "" + # base URL of the Maven repository to search (env: GRYPE_EXTERNAL_SOURCES_MAVEN_BASE_URL) + base-url: 'https://search.maven.org/solrsearch/select' - # filepath to the client key used for TLS authentication to the registry - # GRYPE_REGISTRY_AUTH_TLS_KEY env var - tls-key: "" + # (env: GRYPE_EXTERNAL_SOURCES_MAVEN_RATE_LIMIT) + rate-limit: 300ms - # - ... # note, more credentials can be provided via config file only (not env vars) +match: + java: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_JAVA_USING_CPES) + using-cpes: false + jvm: + # (env: GRYPE_MATCH_JVM_USING_CPES) + using-cpes: true -log: - # suppress all output (except for the vulnerability list) - # same as -q ; GRYPE_LOG_QUIET env var - quiet: false + dotnet: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_DOTNET_USING_CPES) + using-cpes: false - # increase verbosity - # same as GRYPE_LOG_VERBOSITY env var - verbosity: 0 + golang: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_GOLANG_USING_CPES) + using-cpes: false - # the log level; note: detailed logging suppress the ETUI - # same as GRYPE_LOG_LEVEL env var - # Uses logrus logging levels: https://github.com/sirupsen/logrus#level-logging - level: "error" + # use CPE matching to find vulnerabilities for the Go standard library (env: GRYPE_MATCH_GOLANG_ALWAYS_USE_CPE_FOR_STDLIB) + always-use-cpe-for-stdlib: true - # location to write the log file (default is not to have a log file) - # same as GRYPE_LOG_FILE env var - file: "" + # allow comparison between main module pseudo-versions (e.g. v0.0.0-20240413-2b432cf643...) (env: GRYPE_MATCH_GOLANG_ALLOW_MAIN_MODULE_PSEUDO_VERSION_COMPARISON) + allow-main-module-pseudo-version-comparison: false -match: - # sets the matchers below to use cpes when trying to find - # vulnerability matches. The stock matcher is the default - # when no primary matcher can be identified. - java: + javascript: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_JAVASCRIPT_USING_CPES) using-cpes: false + python: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_PYTHON_USING_CPES) using-cpes: false - javascript: - using-cpes: false + ruby: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_RUBY_USING_CPES) using-cpes: false - dotnet: - using-cpes: false - golang: + + rust: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_RUST_USING_CPES) using-cpes: false - # even if CPE matching is disabled, make an exception when scanning for "stdlib". - always-use-cpe-for-stdlib: true - # allow main module pseudo versions, which may have only been "guessed at" by Syft, to be used in vulnerability matching - allow-main-module-pseudo-version-comparison: false + stock: + # use CPE matching to find vulnerabilities (env: GRYPE_MATCH_STOCK_USING_CPES) using-cpes: true + + +registry: + # skip TLS verification when communicating with the registry (env: GRYPE_REGISTRY_INSECURE_SKIP_TLS_VERIFY) + insecure-skip-tls-verify: false + + # use http instead of https when connecting to the registry (env: GRYPE_REGISTRY_INSECURE_USE_HTTP) + insecure-use-http: false + + # Authentication credentials for specific registries. Each entry describes authentication for a specific authority: + # - authority: the registry authority URL the URL to the registry (e.g. "docker.io", "localhost:5000", etc.) (env: SYFT_REGISTRY_AUTH_AUTHORITY) + # username: a username if using basic credentials (env: SYFT_REGISTRY_AUTH_USERNAME) + # password: a corresponding password (env: SYFT_REGISTRY_AUTH_PASSWORD) + # token: a token if using token-based authentication, mutually exclusive with username/password (env: SYFT_REGISTRY_AUTH_TOKEN) + # tls-cert: filepath to the client certificate used for TLS authentication to the registry (env: SYFT_REGISTRY_AUTH_TLS_CERT) + # tls-key: filepath to the client key used for TLS authentication to the registry (env: SYFT_REGISTRY_AUTH_TLS_KEY) + auth: [] + + # filepath to a CA certificate (or directory containing *.crt, *.cert, *.pem) used to generate the client certificate (env: GRYPE_REGISTRY_CA_CERT) + ca-cert: '' + +# a list of VEX documents to consider when producing scanning results (env: GRYPE_VEX_DOCUMENTS) +vex-documents: [] + +# VEX statuses to consider as ignored rules (env: GRYPE_VEX_ADD) +vex-add: [] + +# match kernel-header packages with upstream kernel as kernel vulnerabilities (env: GRYPE_MATCH_UPSTREAM_KERNEL_HEADERS) +match-upstream-kernel-headers: false + +db: + # location to write the vulnerability database cache (env: GRYPE_DB_CACHE_DIR) + cache-dir: '~/Library/Caches/grype/db' + + # URL of the vulnerability database (env: GRYPE_DB_UPDATE_URL) + update-url: 'https://grype.anchore.io/databases' + + # certificate to trust download the database and listing file (env: GRYPE_DB_CA_CERT) + ca-cert: '' + + # check for database updates on execution (env: GRYPE_DB_AUTO_UPDATE) + auto-update: true + + # validate the database matches the known hash each execution (env: GRYPE_DB_VALIDATE_BY_HASH_ON_START) + validate-by-hash-on-start: true + + # ensure db build is no older than the max-allowed-built-age (env: GRYPE_DB_VALIDATE_AGE) + validate-age: true + + # Max allowed age for vulnerability database, + # age being the time since it was built + # Default max age is 120h (or five days) (env: GRYPE_DB_MAX_ALLOWED_BUILT_AGE) + max-allowed-built-age: 120h0m0s + + # fail the scan if unable to check for database updates (env: GRYPE_DB_REQUIRE_UPDATE_CHECK) + require-update-check: false + + # Timeout for downloading GRYPE_DB_UPDATE_URL to see if the database needs to be downloaded + # This file is ~156KiB as of 2024-04-17 so the download should be quick; adjust as needed (env: GRYPE_DB_UPDATE_AVAILABLE_TIMEOUT) + update-available-timeout: 30s + + # Timeout for downloading actual vulnerability DB + # The DB is ~156MB as of 2024-04-17 so slower connections may exceed the default timeout; adjust as needed (env: GRYPE_DB_UPDATE_DOWNLOAD_TIMEOUT) + update-download-timeout: 5m0s + + # Maximum frequency to check for vulnerability database updates (env: GRYPE_DB_MAX_UPDATE_CHECK_FREQUENCY) + max-update-check-frequency: 2h0m0s + +log: + # suppress all logging output (env: GRYPE_LOG_QUIET) + quiet: false + + # explicitly set the logging level (available: [error warn info debug trace]) (env: GRYPE_LOG_LEVEL) + level: 'warn' + + # file path to write logs to (env: GRYPE_LOG_FILE) + file: '' + +dev: + # capture resource profiling data (available: [cpu, mem]) (env: GRYPE_DEV_PROFILE) + profile: '' + + db: + # show sql queries in trace logging (requires -vv) (env: GRYPE_DEV_DB_DEBUG) + debug: false ``` ## Future plans diff --git a/cmd/grype/cli/cli.go b/cmd/grype/cli/cli.go index 164eb94107b..76ddf5a54e3 100644 --- a/cmd/grype/cli/cli.go +++ b/cmd/grype/cli/cli.go @@ -4,7 +4,10 @@ import ( "errors" "os" "runtime/debug" + "strings" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/spf13/cobra" "github.com/anchore/clio" @@ -39,6 +42,10 @@ func create(id clio.Identification) (clio.Application, *cobra.Command) { WithUIConstructor( // select a UI based on the logging configuration and state of stdin (if stdin is a tty) func(cfg clio.Config) (*clio.UICollection, error) { + // remove CI var from consideration when determining if we should use the UI + lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(os.Stdout, termenv.WithEnvironment(environWithoutCI{}))) + + // setup the UIs noUI := ui.None(cfg.Log.Quiet) if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet { return clio.NewUICollection(noUI), nil @@ -122,3 +129,24 @@ func syftVersion() (string, any) { func dbVersion() (string, any) { return "Supported DB Schema", v6.ModelVersion } + +type environWithoutCI struct { +} + +func (e environWithoutCI) Environ() []string { + var out []string + for _, s := range os.Environ() { + if strings.HasPrefix(s, "CI=") { + continue + } + out = append(out, s) + } + return out +} + +func (e environWithoutCI) Getenv(s string) string { + if s == "CI" { + return "" + } + return os.Getenv(s) +} diff --git a/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go b/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go index aa34e73a384..5c6442f9581 100644 --- a/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go +++ b/cmd/grype/cli/commands/internal/dbsearch/affected_packages_test.go @@ -356,7 +356,7 @@ func TestNewAffectedPackageRows(t *testing.T) { }, AffectedPackageInfo: AffectedPackageInfo{ CPE: &CPE{Part: "a", Vendor: "vendor1", Product: "product1"}, - Namespace: "nvd:cpe", + Namespace: "provider2:cpe", Detail: v6.AffectedPackageBlob{ CVEs: []string{"CVE-9876-5432"}, Ranges: []v6.AffectedRange{ @@ -588,7 +588,7 @@ func TestAffectedPackages(t *testing.T) { }, AffectedPackageInfo: AffectedPackageInfo{ CPE: &CPE{Part: "a", Vendor: "vendor1", Product: "product1"}, - Namespace: "nvd:cpe", + Namespace: "provider2:cpe", Detail: v6.AffectedPackageBlob{ CVEs: []string{"CVE-9876-5432"}, Ranges: []v6.AffectedRange{ diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index 67fb099ef7c..937797e04c5 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -12,6 +12,7 @@ import ( "github.com/anchore/clio" "github.com/anchore/grype/cmd/grype/cli/options" "github.com/anchore/grype/grype" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/event" "github.com/anchore/grype/grype/event/parsers" "github.com/anchore/grype/grype/grypeerr" @@ -35,7 +36,6 @@ import ( "github.com/anchore/grype/internal/stringutil" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/cataloging" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/sbom" ) @@ -65,6 +65,7 @@ You can also explicitly specify the scheme to use: {{.appName}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required) {{.appName}} purl:path/to/purl/file read a newline separated file of package URLs from a path on disk {{.appName}} PURL read a single package PURL directly (e.g. pkg:apk/openssl@3.2.1?distro=alpine-3.20.3) + {{.appName}} CPE read a single CPE directly (e.g. cpe:2.3:a:openssl:openssl:3.0.14:*:*:*:*:*) You can also pipe in Syft JSON directly: syft yourimage:tag -o json | {{.appName}} @@ -209,7 +210,7 @@ func runGrype(app clio.Application, opts *options.Grype, userInput string) (errs log.WithFields("time", time.Since(startTime)).Info("found vulnerability matches") startTime = time.Now() - model, err := models.NewDocument(app.ID(), packages, pkgContext, *remainingMatches, ignoredMatches, vp, opts, dbInfo(status, vp), models.SortByPackage) + model, err := models.NewDocument(app.ID(), packages, pkgContext, *remainingMatches, ignoredMatches, vp, opts, dbInfo(status, vp), models.SortStrategy(opts.SortBy.Criteria)) if err != nil { return fmt.Errorf("failed to create document: %w", err) } @@ -261,28 +262,25 @@ func applyDistroHint(pkgs []pkg.Package, context *pkg.Context, opts *options.Gry if len(split) > 1 { v = split[1] } - context.Distro = &linux.Release{ - PrettyName: d, - Name: d, - ID: d, - IDLike: []string{ - d, - }, - Version: v, - VersionID: v, + var err error + context.Distro, err = distro.NewFromNameVersion(d, v) + if err != nil { + log.WithFields("distro", opts.Distro, "error", err).Warn("unable to parse distro") } } - hasOSPackage := false + hasOSPackageWithoutDistro := false for _, p := range pkgs { switch p.Type { case syftPkg.AlpmPkg, syftPkg.DebPkg, syftPkg.RpmPkg, syftPkg.KbPkg: - hasOSPackage = true + if p.Distro == nil { + hasOSPackageWithoutDistro = true + } } } - if context.Distro == nil && hasOSPackage { - log.Warnf("Unable to determine the OS distribution. This may result in missing vulnerabilities. " + + if context.Distro == nil && hasOSPackageWithoutDistro { + log.Warnf("Unable to determine the OS distribution of some packages. This may result in missing vulnerabilities. " + "You may specify a distro using: --distro :") } } diff --git a/cmd/grype/cli/commands/root_test.go b/cmd/grype/cli/commands/root_test.go index bd1d27b80dc..e50d3408908 100644 --- a/cmd/grype/cli/commands/root_test.go +++ b/cmd/grype/cli/commands/root_test.go @@ -28,24 +28,24 @@ func Test_applyDistroHint(t *testing.T) { applyDistroHint([]pkg.Package{}, &ctx, &cfg) assert.NotNil(t, ctx.Distro) - assert.Equal(t, "alpine", ctx.Distro.Name) + assert.Equal(t, "alpine", ctx.Distro.Name()) assert.Equal(t, "3.10", ctx.Distro.Version) // does override an existing distro - cfg.Distro = "ubuntu:latest" + cfg.Distro = "ubuntu:24.04" applyDistroHint([]pkg.Package{}, &ctx, &cfg) assert.NotNil(t, ctx.Distro) - assert.Equal(t, "ubuntu", ctx.Distro.Name) - assert.Equal(t, "latest", ctx.Distro.Version) + assert.Equal(t, "ubuntu", ctx.Distro.Name()) + assert.Equal(t, "24.04", ctx.Distro.Version) // doesn't remove an existing distro when empty cfg.Distro = "" applyDistroHint([]pkg.Package{}, &ctx, &cfg) assert.NotNil(t, ctx.Distro) - assert.Equal(t, "ubuntu", ctx.Distro.Name) - assert.Equal(t, "latest", ctx.Distro.Version) + assert.Equal(t, "ubuntu", ctx.Distro.Name()) + assert.Equal(t, "24.04", ctx.Distro.Version) } func Test_getProviderConfig(t *testing.T) { diff --git a/cmd/grype/cli/options/grype.go b/cmd/grype/cli/options/grype.go index 4bd5b3f6e7d..0dab835425b 100644 --- a/cmd/grype/cli/options/grype.go +++ b/cmd/grype/cli/options/grype.go @@ -31,6 +31,7 @@ type Grype struct { Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"` ShowSuppressed bool `yaml:"show-suppressed" json:"show-suppressed" mapstructure:"show-suppressed"` ByCVE bool `yaml:"by-cve" json:"by-cve" mapstructure:"by-cve"` // --by-cve, indicates if the original match vulnerability IDs should be preserved or the CVE should be used instead + SortBy SortBy `yaml:",inline" json:",inline" mapstructure:",squash"` Name string `yaml:"name" json:"name" mapstructure:"name"` DefaultImagePullSource string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"` VexDocuments []string `yaml:"vex-documents" json:"vex-documents" mapstructure:"vex-documents"` @@ -64,6 +65,7 @@ func DefaultGrype(id clio.Identification) *Grype { CheckForAppUpdate: true, VexAdd: []string{}, MatchUpstreamKernelHeaders: false, + SortBy: defaultSortBy(), } } diff --git a/cmd/grype/cli/options/sort_by.go b/cmd/grype/cli/options/sort_by.go new file mode 100644 index 00000000000..8a7a6121880 --- /dev/null +++ b/cmd/grype/cli/options/sort_by.go @@ -0,0 +1,47 @@ +package options + +import ( + "fmt" + "strings" + + "github.com/scylladb/go-set/strset" + + "github.com/anchore/clio" + "github.com/anchore/fangs" + "github.com/anchore/grype/grype/presenter/models" +) + +var _ interface { + fangs.FlagAdder + fangs.PostLoader +} = (*SortBy)(nil) + +type SortBy struct { + Criteria string `yaml:"sort-by" json:"sort-by" mapstructure:"sort-by"` + AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"` +} + +func defaultSortBy() SortBy { + var strategies []string + for _, s := range models.SortStrategies() { + strategies = append(strategies, strings.ToLower(s.String())) + } + return SortBy{ + Criteria: models.DefaultSortStrategy.String(), + AllowableOptions: strategies, + } +} + +func (o *SortBy) AddFlags(flags clio.FlagSet) { + flags.StringVarP(&o.Criteria, + "sort-by", "", + fmt.Sprintf("sort the match results with the given strategy, options=%v", o.AllowableOptions), + ) +} + +func (o *SortBy) PostLoad() error { + if !strset.New(o.AllowableOptions...).Has(strings.ToLower(o.Criteria)) { + return fmt.Errorf("invalid sort-by criteria: %q (allowable: %s)", o.Criteria, strings.Join(o.AllowableOptions, ", ")) + } + return nil +} diff --git a/go.mod b/go.mod index e861e01a560..f6db9d072b5 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,8 @@ require ( github.com/adrg/xdg v0.5.3 github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51 github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 - github.com/anchore/clio v0.0.0-20250401141128-4c1d6bd1e872 + github.com/anchore/clio v0.0.0-20250408180537-ec8fa27f0d9f + github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 @@ -18,11 +19,11 @@ require ( github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 github.com/anchore/stereoscope v0.1.4 - github.com/anchore/syft v1.23.0 + github.com/anchore/syft v1.25.1 github.com/aquasecurity/go-pep440-version v0.0.1 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/bmatcuk/doublestar/v2 v2.0.4 - github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 github.com/dave/jennifer v1.7.1 github.com/docker/docker v28.1.1+incompatible @@ -66,9 +67,9 @@ require ( github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 golang.org/x/time v0.11.0 - golang.org/x/tools v0.32.0 + golang.org/x/tools v0.33.0 gopkg.in/yaml.v3 v3.0.1 - gorm.io/gorm v1.25.12 + gorm.io/gorm v1.26.1 ) require ( @@ -89,19 +90,18 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver v1.5.0 // indirect - github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.11.7 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.2.0 // indirect github.com/STARRY-S/zip v0.2.1 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect github.com/agext/levenshtein v1.2.1 // indirect - github.com/anchore/fangs v0.0.0-20250326231402-da263204d38e // indirect github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb // indirect + github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec // indirect github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect github.com/anchore/go-sync v0.0.0-20250326131806-4eda43a485b6 // indirect - github.com/andybalholm/brotli v1.1.1 // indirect + github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aws/aws-sdk-go v1.44.288 // indirect @@ -156,7 +156,7 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/github/go-spdx/v2 v2.3.2 // indirect + github.com/github/go-spdx/v2 v2.3.3 // indirect github.com/gkampitakis/ciinfo v0.3.1 // indirect github.com/gkampitakis/go-diff v1.3.2 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect @@ -190,7 +190,6 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect - github.com/knqyf263/go-rpmdb v0.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect @@ -202,7 +201,7 @@ require ( github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/mholt/archives v0.1.1 // indirect + github.com/mholt/archives v0.1.2 // indirect github.com/minio/minlz v1.0.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -220,6 +219,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/nwaples/rardecode/v2 v2.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -282,14 +282,14 @@ require ( go.opentelemetry.io/otel/trace v1.33.0 // indirect go.uber.org/multierr v1.11.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.37.0 // indirect + golang.org/x/crypto v0.38.0 // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect + golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.25.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/api v0.215.0 // indirect google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect diff --git a/go.sum b/go.sum index 3862a5cceae..2200baeef73 100644 --- a/go.sum +++ b/go.sum @@ -648,10 +648,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -662,8 +660,8 @@ github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= +github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= @@ -686,10 +684,10 @@ github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51 h1:yhk+P8lF3 github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51/go.mod h1:nwuGSd7aZp0rtYt79YggCGafz1RYsclE7pi3fhLwvuw= github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 h1:p0ZIe0htYOX284Y4axJaGBvXHU0VCCzLN5Wf5XbKStU= github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9/go.mod h1:3ZsFB9tzW3vl4gEiUeuSOMDnwroWxIxJelOOHUp8dSw= -github.com/anchore/clio v0.0.0-20250401141128-4c1d6bd1e872 h1:iEF0xhHUuh3J8FrlPsZAQVaMpTa2j4lvLRI5XrXzge4= -github.com/anchore/clio v0.0.0-20250401141128-4c1d6bd1e872/go.mod h1:Utb9i4kwiCWvqAIxZaJeMIXFO9uOgQXlvH2BfbfO/zI= -github.com/anchore/fangs v0.0.0-20250326231402-da263204d38e h1:9hXsNmfBqo2exA4a90Qw33Edb+OROVmeibe9RzgS1wA= -github.com/anchore/fangs v0.0.0-20250326231402-da263204d38e/go.mod h1:vrcYMDps9YXwwx2a9AsvipM6Fi5H9//9bymGb8G8BIQ= +github.com/anchore/clio v0.0.0-20250408180537-ec8fa27f0d9f h1:jTeN+fKTXz1VFo3Zj7Msnx//s5kD6Htd+SS0z9/o7Ss= +github.com/anchore/clio v0.0.0-20250408180537-ec8fa27f0d9f/go.mod h1:jQ+jv7v9RQnc5oA+Z0rAyXsQfaCAZHwY/CJZiLVggQ4= +github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe h1:qv/xxpjF5RdKPqZjx8RM0aBi3HUCAO0DhRBMs2xhY1I= +github.com/anchore/fangs v0.0.0-20250402135612-96e29e45f3fe/go.mod h1:vrcYMDps9YXwwx2a9AsvipM6Fi5H9//9bymGb8G8BIQ= github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 h1:GjNGuwK5jWjJMyVppBjYS54eOiiSNv4Ba869k4wh72Q= github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8= github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d h1:gT69osH9AsdpOfqxbRwtxcNnSZ1zg4aKy2BevO3ZBdc= @@ -698,6 +696,8 @@ github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4 github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw= github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb h1:iDMnx6LIjtjZ46C0akqveX83WFzhpTD3eqOthawb5vU= github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb/go.mod h1:DmTY2Mfcv38hsHbG78xMiTDdxFtkHpgYNVDPsF2TgHk= +github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec h1:SjjPMOXTzpuU1ZME4XeoHyek+dry3/C7I8gzaCo02eg= +github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec/go.mod h1:eQVa6QFGzKy0qMcnW2pez0XBczvgwSjw9vA23qifEyU= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/anchore/go-sync v0.0.0-20250326131806-4eda43a485b6 h1:Ha+LSCVuXYSYGi7wIkJK6G8g6jI3LH7y6LbyEVyp4Io= @@ -710,12 +710,12 @@ github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiE github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= github.com/anchore/stereoscope v0.1.4 h1:e+iT9UdUzLBabWGe84hn5sTHDRioY+4IHsVzJXuJlek= github.com/anchore/stereoscope v0.1.4/go.mod h1:omWgXDEp/XfqCJlZXIByEo1c3ArZg/qTJ5LBKVLAIdw= -github.com/anchore/syft v1.23.0 h1:rRF3ZjLi6s4TUbNSME4S1bKbAVX3tcdKRGjBS82iX60= -github.com/anchore/syft v1.23.0/go.mod h1:vDV0VBC601wHZ2nGuxqoDjfYsiu87WmE0w8HG3RDI6k= +github.com/anchore/syft v1.25.1 h1:HaG5/0r1UdZ7zyscEFeFz0pQsBLTXdCgEDXa5LqFjcg= +github.com/anchore/syft v1.25.1/go.mod h1:xa15pYmHrXKe7IlvaO+EAD/krawWYUtILTpMcL/S+Gw= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ= +github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -788,8 +788,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -946,8 +946,8 @@ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/github/go-spdx/v2 v2.3.2 h1:IfdyNHTqzs4zAJjXdVQfRnxt1XMfycXoHBE2Vsm1bjs= -github.com/github/go-spdx/v2 v2.3.2/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ= +github.com/github/go-spdx/v2 v2.3.3 h1:QI7evnHWEfWkT54eJwkoV/f3a0xD3gLlnVmT5wQG6LE= +github.com/github/go-spdx/v2 v2.3.3/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ= github.com/gkampitakis/ciinfo v0.3.1 h1:lzjbemlGI4Q+XimPg64ss89x8Mf3xihJqy/0Mgagapo= github.com/gkampitakis/ciinfo v0.3.1/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -1245,8 +1245,6 @@ github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GX github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8= github.com/knqyf263/go-deb-version v0.0.0-20190517075300-09fca494f03d h1:X4cedH4Kn3JPupAwwWuo4AzYp16P0OyLO9d7OnMZc/c= github.com/knqyf263/go-deb-version v0.0.0-20190517075300-09fca494f03d/go.mod h1:o8sgWoz3JADecfc/cTYD92/Et1yMqMy0utV1z+VaZao= -github.com/knqyf263/go-rpmdb v0.1.1 h1:oh68mTCvp1XzxdU7EfafcWzzfstUZAEa3MW0IJye584= -github.com/knqyf263/go-rpmdb v0.1.1/go.mod h1:9LQcoMCMQ9vrF7HcDtXfvqGO4+ddxFQ8+YF/0CVGDww= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -1306,8 +1304,8 @@ github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mholt/archives v0.1.1 h1:c7J3qXN1FB54y0qiUXiq9Bxk4eCUc8pdXWwOhZdRzeY= -github.com/mholt/archives v0.1.1/go.mod h1:FQVz01Q2uXKB/35CXeW/QFO23xT+hSCGZHVtha78U4I= +github.com/mholt/archives v0.1.2 h1:UBSe5NfYKHI1sy+S5dJsEsG9jsKKk8NJA4HCC+xTI4A= +github.com/mholt/archives v0.1.2/go.mod h1:D7QzTHgw3ctfS6wgOO9dN+MFgdZpbksGCxprUOwZWDs= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -1364,6 +1362,10 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 h1:kpt9ZfKcm+EDG4s40hMwE//d5SBgDjUOrITReV2u4aA= +github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1/go.mod h1:qgCw4bBKZX8qMgGeEZzGFVT3notl42dBjNqO2jut0M0= +github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 h1:NHrXEjTNQY7P0Zfx1aMrNhpgxHmow66XQtm0aQLY0AE= +github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= @@ -1673,8 +1675,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1808,8 +1810,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1862,8 +1864,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1967,8 +1969,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1984,8 +1986,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2007,8 +2009,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2083,8 +2085,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2400,8 +2402,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= -gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= +gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/grype/db/v5/distribution/curator.go b/grype/db/v5/distribution/curator.go index cff1c83d571..609d67a565b 100644 --- a/grype/db/v5/distribution/curator.go +++ b/grype/db/v5/distribution/curator.go @@ -477,6 +477,7 @@ func (c Curator) ListingFromURL() (Listing, error) { return Listing{}, fmt.Errorf("unable to create listing temp file: %w", err) } defer func() { + log.CloseAndLogError(tempFile, tempFile.Name()) err := c.fs.RemoveAll(tempFile.Name()) if err != nil { log.Errorf("failed to remove file (%s): %w", tempFile.Name(), err) diff --git a/grype/db/v6/data.go b/grype/db/v6/data.go index bbd1ac0979b..df4fbe34e2b 100644 --- a/grype/db/v6/data.go +++ b/grype/db/v6/data.go @@ -72,6 +72,9 @@ func KnownPackageSpecifierOverrides() []PackageSpecifierOverride { // jenkins plugins are a special case since they are always considered to be within the java ecosystem {Ecosystem: string(pkg.JenkinsPluginPkg), ReplacementEcosystem: ptr(string(pkg.JavaPkg))}, + + // legacy cases + {Ecosystem: "pecl", ReplacementEcosystem: ptr(string(pkg.PhpPeclPkg))}, } // remap package URL types to syft package types diff --git a/grype/db/v6/distribution/client.go b/grype/db/v6/distribution/client.go index 15ef6fc027f..92ec2e4afb7 100644 --- a/grype/db/v6/distribution/client.go +++ b/grype/db/v6/distribution/client.go @@ -177,6 +177,7 @@ func (c client) Latest() (*LatestDocument, error) { return nil, fmt.Errorf("unable to create listing temp file: %w", err) } defer func() { + log.CloseAndLogError(tempFile, tempFile.Name()) err := c.fs.RemoveAll(tempFile.Name()) if err != nil { log.WithFields("error", err, "file", tempFile.Name()).Errorf("failed to remove file") diff --git a/grype/db/v6/vulnerability.go b/grype/db/v6/vulnerability.go index e0c93772415..c17f528bea9 100644 --- a/grype/db/v6/vulnerability.go +++ b/grype/db/v6/vulnerability.go @@ -146,39 +146,10 @@ func getPackageQualifiers(affected *AffectedPackageBlob) []qualifier.Qualifier { // //nolint:funlen func MimicV5Namespace(vuln *VulnerabilityHandle, affected *AffectedPackageHandle) string { - if affected == nil { // for CPE matches - return v5NvdNamespace - } - switch vuln.Provider.ID { - case "nvd": - return v5NvdNamespace - case "github": - language := affected.Package.Ecosystem - // normalize from purl type, github ecosystem types, and vunnel mappings - switch strings.ToLower(language) { - case "golang", string(pkg.GoModulePkg): - language = "go" - case "composer", string(pkg.PhpComposerPkg): - language = "php" - case "cargo", string(pkg.RustPkg): - language = "rust" - case "pub", string(pkg.DartPubPkg): - language = "dart" - case "nuget", string(pkg.DotnetPkg): - language = "dotnet" - case "maven", string(pkg.JavaPkg), string(pkg.JenkinsPluginPkg): - language = "java" - case "swifturl", string(pkg.SwiplPackPkg), string(pkg.SwiftPkg): - language = "swift" - case "node", string(pkg.NpmPkg): - language = "javascript" - case "pypi", "pip", string(pkg.PythonPkg): - language = "python" - case "rubygems", string(pkg.GemPkg): - language = "ruby" - } - return fmt.Sprintf("github:language:%s", language) + if affected == nil || affected.Package == nil { // for CPE matches + return fmt.Sprintf("%s:cpe", vuln.Provider.ID) } + if affected.OperatingSystem != nil { // distro family fixes family := affected.OperatingSystem.Name @@ -229,6 +200,39 @@ func MimicV5Namespace(vuln *VulnerabilityHandle, affected *AffectedPackageHandle return fmt.Sprintf("%s:distro:%s:%s", pr, family, ver) } + + if affected.Package != nil { + language := affected.Package.Ecosystem + // normalize from purl type, github ecosystem types, and vunnel mappings + switch strings.ToLower(language) { + case "golang", string(pkg.GoModulePkg): + language = "go" + case "composer", string(pkg.PhpComposerPkg): + language = "php" + case "cargo", string(pkg.RustPkg): + language = "rust" + case "pub", string(pkg.DartPubPkg): + language = "dart" + case "nuget", string(pkg.DotnetPkg): + language = "dotnet" + case "maven", string(pkg.JavaPkg), string(pkg.JenkinsPluginPkg): + language = "java" + case "swifturl", string(pkg.SwiplPackPkg), string(pkg.SwiftPkg): + language = "swift" + case "node", string(pkg.NpmPkg): + language = "javascript" + case "pypi", "pip", string(pkg.PythonPkg): + language = "python" + case "rubygems", string(pkg.GemPkg): + language = "ruby" + case "msrc", string(pkg.KbPkg): // msrc packages were previously modelled as distro + return fmt.Sprintf("%s:distro:windows:%s", vuln.Provider.ID, affected.Package.Name) + case "": // CPE + return fmt.Sprintf("%s:cpe", vuln.Provider.ID) + } + return fmt.Sprintf("%s:language:%s", vuln.Provider.ID, language) + } + // this shouldn't happen and is not a valid v5 namespace, but some information is better than none return vuln.Provider.ID } diff --git a/grype/db/v6/vulnerability_provider.go b/grype/db/v6/vulnerability_provider.go index a655155d7bb..6e012daac38 100644 --- a/grype/db/v6/vulnerability_provider.go +++ b/grype/db/v6/vulnerability_provider.go @@ -222,11 +222,20 @@ func (vp vulnerabilityProvider) FindVulnerabilities(criteria ...vulnerability.Cr pkgSpec = &PackageSpecifier{} } // the v6 store normalizes ecosystems around the syft package type, so that field is preferred - if c.PackageType != "" && c.PackageType != syftPkg.UnknownPkg { - pkgSpec.Ecosystem = string(c.PackageType) + switch { + case c.PackageType != "" && c.PackageType != syftPkg.UnknownPkg: + // prefer to match by a non-blank, known package type pkgType = c.PackageType - } else { + pkgSpec.Ecosystem = string(c.PackageType) + case c.Language != "": + // if there's no known package type, but there is a non-blank language + // try that. pkgSpec.Ecosystem = string(c.Language) + case c.PackageType == syftPkg.UnknownPkg: + // if language is blank, and package type is explicitly "UnknownPkg" and not + // just blank, use that. + pkgType = c.PackageType + pkgSpec.Ecosystem = string(c.PackageType) } applied = true case *search.IDCriteria: diff --git a/grype/db/v6/vulnerability_provider_mocks_test.go b/grype/db/v6/vulnerability_provider_mocks_test.go index 6f01bba69c8..339fe650fc5 100644 --- a/grype/db/v6/vulnerability_provider_mocks_test.go +++ b/grype/db/v6/vulnerability_provider_mocks_test.go @@ -29,7 +29,7 @@ func testVulnerabilityProvider(t *testing.T) vulnerability.Provider { aWeekAgo := time.Now().Add(-7 * 24 * time.Hour) twoWeeksAgo := time.Now().Add(-14 * 24 * time.Hour) - prov := &Provider{ + debianProvider := &Provider{ ID: "debian", Version: "1", Processor: "debian-processor", @@ -37,6 +37,14 @@ func testVulnerabilityProvider(t *testing.T) vulnerability.Provider { InputDigest: hex.EncodeToString([]byte("debian")), } + nvdProvider := &Provider{ + ID: "nvd", + Version: "1", + Processor: "nvd-processor", + DateCaptured: &aDayAgo, + InputDigest: hex.EncodeToString([]byte("nvd")), + } + v5vulns := []v5.Vulnerability{ // neutron { @@ -125,10 +133,12 @@ func testVulnerabilityProvider(t *testing.T) vulnerability.Provider { for _, v := range v5vulns { var os *OperatingSystem + prov := nvdProvider switch v.Namespace { case "nvd:cpe": case "debian:distro:debian:8": + prov = debianProvider os = &OperatingSystem{ Name: "debian", MajorVersion: "8", diff --git a/grype/db/v6/vulnerability_provider_test.go b/grype/db/v6/vulnerability_provider_test.go index 3b5ebfbf313..73b451ced82 100644 --- a/grype/db/v6/vulnerability_provider_test.go +++ b/grype/db/v6/vulnerability_provider_test.go @@ -305,11 +305,56 @@ func Test_FindVulnerabilitiesByByID(t *testing.T) { } func Test_FindVulnerabilitiesByEcosystem_UnknownPackageType(t *testing.T) { + tests := []struct { + name string + packageName string + packageType syftPkg.Type + language syftPkg.Language + expectedIDs []string + }{ + { + name: "known package type", + packageName: "Newtonsoft.Json", + packageType: syftPkg.DotnetPkg, + language: syftPkg.Java, // deliberately wrong to prove we're using package type + expectedIDs: []string{"GHSA-5crp-9r3c-p9vr"}, + }, + { + name: "unknown package type, known language", + packageName: "Newtonsoft.Json", + packageType: syftPkg.UnknownPkg, + language: syftPkg.Dotnet, + expectedIDs: []string{"GHSA-5crp-9r3c-p9vr"}, + }, + { + name: "unknown package type, unknown language", + packageName: "Newtonsoft.Json", + packageType: syftPkg.UnknownPkg, + language: syftPkg.UnknownLanguage, + // The vuln GHSA-5crp-9r3c-p9vr is specifically associated + // with the dotnet ecosystem, so it should not be returned here. + // In a real search for UnknownPkg + UnknownLanguage, there should + // be a separate search.ByCPE run that _does_ return it. + expectedIDs: []string{}, + }, + } provider := testVulnerabilityProvider(t) - actual, err := provider.FindVulnerabilities(search.ByEcosystem(syftPkg.Dotnet, syftPkg.UnknownPkg)) - require.NoError(t, err) - require.NotEmpty(t, actual) - require.Equal(t, actual[0].Reference.ID, "GHSA-5crp-9r3c-p9vr") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := provider.FindVulnerabilities( + search.ByEcosystem(test.language, test.packageType), + search.ByPackageName(test.packageName), + ) + require.NoError(t, err) + actualIDs := make([]string, len(actual)) + for idx, vuln := range actual { + actualIDs[idx] = vuln.ID + } + if d := cmp.Diff(test.expectedIDs, actualIDs); d != "" { + t.Errorf("diff: %+v", d) + } + }) + } } func Test_DataSource(t *testing.T) { diff --git a/grype/db/v6/vulnerability_test.go b/grype/db/v6/vulnerability_test.go index 2feff56df19..5335a3db626 100644 --- a/grype/db/v6/vulnerability_test.go +++ b/grype/db/v6/vulnerability_test.go @@ -138,12 +138,13 @@ func TestV5Namespace(t *testing.T) { // +--------------------------------------+ type testCase struct { - name string - provider string // from Providers.id - ecosystem string // only used when provider is "github" - osName string // only used for OS-based providers - osVersion string // only used for OS-based providers - expected string + name string + provider string // from Providers.id + ecosystem string // only used when provider non-os provider + packageName string // only used for msrc + osName string // only used for OS-based providers + osVersion string // only used for OS-based providers + expected string // } tests := []testCase{ @@ -434,6 +435,39 @@ func TestV5Namespace(t *testing.T) { osVersion: "9.3.1", expected: "oracle:distro:oraclelinux:9", }, + // msrc is modeled as a distro for v5 but is just a package in v6 + { + name: "microsoft msrc-kb", + provider: "msrc", + ecosystem: "msrc-kb", + packageName: "10012", + expected: "msrc:distro:windows:10012", + }, + + // new provider existing ecosystem + { + name: "grizzly go-module", + provider: "grizzly", + ecosystem: "go-module", + expected: "grizzly:language:go", + }, + + // new provider new ecosystem + { + name: "armadillo pizza", + provider: "armadillo", + ecosystem: "pizza", + expected: "armadillo:language:pizza", + }, + + // new OS + { + name: "gothmog", + provider: "gothmog", + osName: "gothmoglinux", + osVersion: "zzzzzz11123", + expected: "gothmog:distro:gothmoglinux:zzzzzz11123", + }, } for _, tt := range tests { @@ -443,6 +477,7 @@ func TestV5Namespace(t *testing.T) { ID: tt.provider, }, } + pkg := &AffectedPackageHandle{} if tt.osName != "" { @@ -457,11 +492,14 @@ func TestV5Namespace(t *testing.T) { MinorVersion: minor, LabelVersion: label, } - } - - if tt.provider == "github" { + pkg.Package = &Package{ + Name: "os-package", + Ecosystem: "os-ecosystem", + } + } else if tt.ecosystem != "" { pkg.Package = &Package{ Ecosystem: tt.ecosystem, + Name: tt.packageName, } } diff --git a/grype/deprecated.go b/grype/deprecated.go index fc8ec72b29f..050974fef36 100644 --- a/grype/deprecated.go +++ b/grype/deprecated.go @@ -1,6 +1,7 @@ package grype import ( + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/matcher" "github.com/anchore/grype/grype/pkg" @@ -8,7 +9,6 @@ import ( "github.com/anchore/grype/internal/log" "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/source" ) @@ -33,7 +33,7 @@ func FindVulnerabilities(store vulnerability.Provider, userImageStr string, scop } // TODO: deprecated, will remove before v1.0.0 -func FindVulnerabilitiesForPackage(store vulnerability.Provider, d *linux.Release, matchers []match.Matcher, packages []pkg.Package) match.Matches { +func FindVulnerabilitiesForPackage(store vulnerability.Provider, d *distro.Distro, matchers []match.Matcher, packages []pkg.Package) match.Matches { exclusionProvider, _ := store.(match.ExclusionProvider) // TODO v5 is an exclusion provider, but v6 is not runner := VulnerabilityMatcher{ VulnerabilityProvider: store, diff --git a/grype/distro/distro.go b/grype/distro/distro.go index 13f95224366..50cf68855c8 100644 --- a/grype/distro/distro.go +++ b/grype/distro/distro.go @@ -6,6 +6,7 @@ import ( hashiVer "github.com/hashicorp/go-version" + "github.com/anchore/grype/internal/log" "github.com/anchore/syft/syft/linux" ) @@ -43,6 +44,13 @@ func New(t Type, version, label string, idLikes ...string) (*Distro, error) { } } + for i := range idLikes { + typ, ok := IDMapping[strings.TrimSpace(idLikes[i])] + if ok { + idLikes[i] = typ.String() + } + } + return &Distro{ Type: t, major: major, @@ -54,6 +62,35 @@ func New(t Type, version, label string, idLikes ...string) (*Distro, error) { }, nil } +// NewFromNameVersion creates a new Distro object derived from the provided name and version +func NewFromNameVersion(name, version string) (*Distro, error) { + var codename string + + // if there are no digits in the version, it is likely a codename + if !strings.ContainsAny(version, "0123456789") { + codename = version + version = "" + } + + typ := IDMapping[name] + if typ == "" { + typ = Type(name) + } + return New(typ, version, codename, string(typ)) +} + +// FromRelease attempts to get a distro from the linux release, only logging any errors +func FromRelease(linuxRelease *linux.Release) *Distro { + if linuxRelease == nil { + return nil + } + d, err := NewFromRelease(*linuxRelease) + if err != nil { + log.WithFields("error", err).Warn("unable to create distro from linux distribution") + } + return d +} + // NewFromRelease creates a new Distro object derived from a syft linux.Release object. func NewFromRelease(release linux.Release) (*Distro, error) { t := TypeFromRelease(release) @@ -105,6 +142,8 @@ func (d Distro) String() string { versionStr := "(version unknown)" if d.Version != "" { versionStr = d.Version + } else if d.Codename != "" { + versionStr = d.Codename } return fmt.Sprintf("%s %s", d.Type, versionStr) } diff --git a/grype/distro/distro_test.go b/grype/distro/distro_test.go index e399b7d2732..c9ca73e6972 100644 --- a/grype/distro/distro_test.go +++ b/grype/distro/distro_test.go @@ -29,10 +29,12 @@ func Test_NewDistroFromRelease(t *testing.T) { ID: "centos", VersionID: "8", Version: "7", + IDLike: []string{"rhel"}, }, expected: &Distro{ Type: CentOS, Version: "8", + IDLike: []string{"redhat"}, }, major: "8", minor: "", diff --git a/grype/internal/packagemetadata/discover_type_names.go b/grype/internal/packagemetadata/discover_type_names.go index 617e18d8585..a59748758c3 100644 --- a/grype/internal/packagemetadata/discover_type_names.go +++ b/grype/internal/packagemetadata/discover_type_names.go @@ -16,7 +16,7 @@ import ( var metadataExceptions = strset.New( "FileMetadata", - "PURLFileMetadata", + "SBOMFileMetadata", "PURLLiteralMetadata", "CPELiteralMetadata", ) diff --git a/grype/pkg/context.go b/grype/pkg/context.go index 5f46a6f9f9c..0279e24a401 100644 --- a/grype/pkg/context.go +++ b/grype/pkg/context.go @@ -1,11 +1,11 @@ package pkg import ( - "github.com/anchore/syft/syft/linux" + "github.com/anchore/grype/grype/distro" "github.com/anchore/syft/syft/source" ) type Context struct { Source *source.Description - Distro *linux.Release + Distro *distro.Distro } diff --git a/grype/pkg/package.go b/grype/pkg/package.go index 58ea7c8dd2d..abf6b244d30 100644 --- a/grype/pkg/package.go +++ b/grype/pkg/package.go @@ -3,15 +3,16 @@ package pkg import ( "fmt" "regexp" + "slices" "strings" "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/internal/log" "github.com/anchore/grype/internal/stringutil" + "github.com/anchore/packageurl-go" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" cpes "github.com/anchore/syft/syft/pkg/cataloger/common/cpe" ) @@ -44,7 +45,7 @@ type Package struct { Metadata interface{} // This is NOT 1-for-1 the syft metadata! Only the select data needed for vulnerability matching } -func New(p syftPkg.Package) Package { +func New(p syftPkg.Package, enhancers ...Enhancer) Package { metadata, upstreams := dataFromPkg(p) licenseObjs := p.Licenses.ToSlice() @@ -57,7 +58,7 @@ func New(p syftPkg.Package) Package { licenses = []string{} } - return Package{ + out := Package{ ID: ID(p.ID()), Name: p.Name, Version: p.Version, @@ -70,13 +71,25 @@ func New(p syftPkg.Package) Package { Upstreams: upstreams, Metadata: metadata, } + + if len(enhancers) > 0 { + purl, err := packageurl.FromString(p.PURL) + if err != nil { + log.WithFields("purl", purl, "error", err).Debug("unable to parse PURL") + } + for _, e := range enhancers { + e(&out, purl, p) + } + } + + return out } -func FromCollection(catalog *syftPkg.Collection, config SynthesisConfig) []Package { - return FromPackages(catalog.Sorted(), config) +func FromCollection(catalog *syftPkg.Collection, config SynthesisConfig, enhancers ...Enhancer) []Package { + return FromPackages(catalog.Sorted(), config, enhancers...) } -func FromPackages(syftpkgs []syftPkg.Package, config SynthesisConfig) []Package { +func FromPackages(syftpkgs []syftPkg.Package, config SynthesisConfig, enhancers ...Enhancer) []Package { var pkgs []Package for _, p := range syftpkgs { if len(p.CPEs) == 0 { @@ -87,7 +100,7 @@ func FromPackages(syftpkgs []syftPkg.Package, config SynthesisConfig) []Package log.Debugf("no CPEs for package: %s", p) } } - pkgs = append(pkgs, New(p)) + pkgs = append(pkgs, New(p, enhancers...)) } return pkgs @@ -98,7 +111,7 @@ func (p Package) String() string { return fmt.Sprintf("Pkg(type=%s, name=%s, version=%s, upstreams=%d)", p.Type, p.Name, p.Version, len(p.Upstreams)) } -func removePackagesByOverlap(catalog *syftPkg.Collection, relationships []artifact.Relationship, distro *linux.Release) *syftPkg.Collection { +func removePackagesByOverlap(catalog *syftPkg.Collection, relationships []artifact.Relationship, distro *distro.Distro) *syftPkg.Collection { byOverlap := map[artifact.ID]artifact.Relationship{} for _, r := range relationships { if r.Type == artifact.OwnershipByFileOverlapRelationship { @@ -127,8 +140,8 @@ func excludePackage(comprehensiveDistroFeed bool, p syftPkg.Package, parent syft // python 3.9.2 binary // python3.9 3.9.2-1 deb - // If the version is not effectively the same, keep both - if !strings.HasPrefix(parent.Version, p.Version) { + // If the version is not approximately the same, keep both + if !strings.HasPrefix(parent.Version, p.Version) && !strings.HasPrefix(p.Version, parent.Version) { return false } @@ -151,23 +164,23 @@ func excludePackage(comprehensiveDistroFeed bool, p syftPkg.Package, parent syft // distroFeedIsComprehensive returns true if the distro feed // is comprehensive enough that we can drop packages owned by distro packages // before matching. -func distroFeedIsComprehensive(distro *linux.Release) bool { +func distroFeedIsComprehensive(dst *distro.Distro) bool { // TODO: this mechanism should be re-examined once https://github.com/anchore/grype/issues/1426 // is addressed - if distro == nil { + if dst == nil { return false } - if distro.ID == "amzn" { + if dst.Type == distro.AmazonLinux { // AmazonLinux shows "like rhel" but is not an rhel clone // and does not have an exhaustive vulnerability feed. return false } for _, d := range comprehensiveDistros { - if strings.EqualFold(d, distro.ID) { + if strings.EqualFold(string(d), dst.Name()) { return true } - for _, n := range distro.IDLike { - if strings.EqualFold(d, n) { + for _, n := range dst.IDLike { + if strings.EqualFold(string(d), n) { return true } } @@ -177,13 +190,13 @@ func distroFeedIsComprehensive(distro *linux.Release) bool { // computed by: // sqlite3 vulnerability.db 'select distinct namespace from vulnerability where fix_state in ("wont-fix", "not-fixed") order by namespace;' | cut -d ':' -f 1 | sort | uniq -// then removing 'github' and replacing 'redhat' with 'rhel' -var comprehensiveDistros = []string{ - "azurelinux", - "debian", - "mariner", - "rhel", - "ubuntu", +// then removing 'github' +var comprehensiveDistros = []distro.Type{ + distro.Azure, + distro.Debian, + distro.Mariner, + distro.RedHat, + distro.Ubuntu, } func isOSPackage(p syftPkg.Package) bool { @@ -195,7 +208,7 @@ func isOSPackage(p syftPkg.Package) bool { } } -func dataFromPkg(p syftPkg.Package) (interface{}, []UpstreamPackage) { +func dataFromPkg(p syftPkg.Package) (any, []UpstreamPackage) { var metadata interface{} var upstreams []UpstreamPackage @@ -222,6 +235,7 @@ func dataFromPkg(p syftPkg.Package) (interface{}, []UpstreamPackage) { case syftPkg.JavaVMInstallation: metadata = javaVMDataFromPkg(p) } + return metadata, upstreams } @@ -409,3 +423,93 @@ func ByID(id ID, pkgs []Package) *Package { } return nil } + +func parseUpstream(pkgName string, value string, pkgType syftPkg.Type) []UpstreamPackage { + if pkgType == syftPkg.RpmPkg { + return handleSourceRPM(pkgName, value) + } + return handleDefaultUpstream(pkgName, value) +} + +func handleDefaultUpstream(pkgName string, value string) []UpstreamPackage { + fields := strings.Split(value, "@") + switch len(fields) { + case 2: + if fields[0] == pkgName { + return nil + } + return []UpstreamPackage{ + { + Name: fields[0], + Version: fields[1], + }, + } + case 1: + if fields[0] == pkgName { + return nil + } + return []UpstreamPackage{ + { + Name: fields[0], + }, + } + } + return nil +} + +func setUpstreamsFromPURL(out *Package, purl packageurl.PackageURL, syftPkg syftPkg.Package) { + if len(out.Upstreams) == 0 { + out.Upstreams = upstreamsFromPURL(purl, syftPkg.Type) + } +} + +// upstreamsFromPURL reads any additional data Grype can use, which is ignored by Syft's PURL conversion +func upstreamsFromPURL(purl packageurl.PackageURL, pkgType syftPkg.Type) (upstreams []UpstreamPackage) { + for _, qualifier := range purl.Qualifiers { + if qualifier.Key == syftPkg.PURLQualifierUpstream { + for _, newUpstream := range parseUpstream(purl.Name, qualifier.Value, pkgType) { + if slices.Contains(upstreams, newUpstream) { + continue + } + upstreams = append(upstreams, newUpstream) + } + } + } + return upstreams +} + +func setDistroFromPURL(out *Package, purl packageurl.PackageURL, _ syftPkg.Package) { + if out.Distro == nil { + out.Distro = distroFromPURL(purl) + } +} + +// distroFromPURL reads distro data for Grype can use, which is ignored by Syft's PURL conversion +func distroFromPURL(purl packageurl.PackageURL) (d *distro.Distro) { + var distroName, distroVersion string + + for _, qualifier := range purl.Qualifiers { + if qualifier.Key == syftPkg.PURLQualifierDistro { + fields := strings.SplitN(qualifier.Value, "-", 2) + distroName = fields[0] + if len(fields) > 1 { + distroVersion = fields[1] + } + } + } + + if distroName != "" { + var err error + d, err = distro.NewFromNameVersion(distroName, distroVersion) + if err != nil { + log.WithFields("purl", purl, "error", err).Debug("unable to create distro from a release") + d = nil + } + } + + return d +} + +type Enhancer func(out *Package, purl packageurl.PackageURL, pkg syftPkg.Package) + +var purlEnhancers = []Enhancer{setUpstreamsFromPURL, setDistroFromPURL} diff --git a/grype/pkg/package_test.go b/grype/pkg/package_test.go index f2950fac2f3..37ac0ed022e 100644 --- a/grype/pkg/package_test.go +++ b/grype/pkg/package_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/anchore/grype/grype/distro" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" @@ -376,7 +377,7 @@ func TestNew(t *testing.T) { }, }, { - name: "dart-pub-metadata", + name: "dart-publock-metadata", syftPkg: syftPkg.Package{ Metadata: syftPkg.DartPubspecLockEntry{ Name: "a", @@ -384,6 +385,33 @@ func TestNew(t *testing.T) { }, }, }, + { + name: "dart-pubspec-metadata", + syftPkg: syftPkg.Package{ + Metadata: syftPkg.DartPubspec{ + Homepage: "a", + Repository: "a", + Documentation: "a", + PublishTo: "a", + Environment: &syftPkg.DartPubspecEnvironment{ + SDK: "a", + Flutter: "a", + }, + Platforms: []string{"a"}, + IgnoredAdvisories: []string{"a"}, + }, + }, + }, + { + name: "homebrew-formula-metadata", + syftPkg: syftPkg.Package{ + Metadata: syftPkg.HomebrewFormula{ + Tap: "a", + Homepage: "a", + Description: "a", + }, + }, + }, { name: "dotnet-metadata", syftPkg: syftPkg.Package{ @@ -694,7 +722,7 @@ func TestNew(t *testing.T) { }, }, { - name: "Php-pecl-entry", + name: "php-pecl-entry", syftPkg: syftPkg.Package{ Metadata: syftPkg.PhpPeclEntry{ Name: "a", @@ -703,6 +731,15 @@ func TestNew(t *testing.T) { }, }, }, + { + name: "php-pear-entry", + syftPkg: syftPkg.Package{ + Metadata: syftPkg.PhpPearEntry{ + Name: "a", + Version: "a", + }, + }, + }, { name: "lua-rocks-entry", syftPkg: syftPkg.Package{ @@ -974,22 +1011,44 @@ func Test_RemovePackagesByOverlap(t *testing.T) { }, { name: "python bindings for system RPM install", - sbom: withDistro(catalogWithOverlaps( + sbom: withLinuxRelease(catalogWithOverlaps( []string{"rpm:python3-rpm@4.14.3-26.el8", "python:rpm@4.14.3"}, []string{"rpm:python3-rpm@4.14.3-26.el8 -> python:rpm@4.14.3"}), "rhel"), expectedPackages: []string{"rpm:python3-rpm@4.14.3-26.el8"}, }, { name: "amzn linux doesn't remove packages in this way", - sbom: withDistro(catalogWithOverlaps( + sbom: withLinuxRelease(catalogWithOverlaps( []string{"rpm:python3-rpm@4.14.3-26.el8", "python:rpm@4.14.3"}, []string{"rpm:python3-rpm@4.14.3-26.el8 -> python:rpm@4.14.3"}), "amzn"), expectedPackages: []string{"rpm:python3-rpm@4.14.3-26.el8", "python:rpm@4.14.3"}, }, + { + name: "remove overlapping package when parent version is prefix of child version", + sbom: withLinuxRelease(catalogWithOverlaps( + []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5", "linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5.x86_64+rt"}, + []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5 -> linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5.x86_64+rt"}), "rhel"), + expectedPackages: []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5"}, + }, + { + name: "remove overlapping package when child version is prefix of parent version", + sbom: withLinuxRelease(catalogWithOverlaps( + []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5+rt", "linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5"}, + []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5+rt -> linux-kernel:linux-kernel@5.14.0-503.40.1.el9_5"}), "rhel"), + expectedPackages: []string{"rpm:kernel-rt-core@5.14.0-503.40.1.el9_5+rt"}, + }, + { + name: "do not remove overlapping package when versions are not similar", + sbom: withLinuxRelease(catalogWithOverlaps( + []string{"rpm:kernel@5.14.0-503.40.1.el9_5", "linux-kernel:linux-kernel@6.17"}, + []string{"rpm:kernel@5.14.0-503.40.1.el9_5 -> linux-kernel:linux-kernel@6.17"}), "rhel"), + expectedPackages: []string{"rpm:kernel@5.14.0-503.40.1.el9_5", "linux-kernel:linux-kernel@6.17"}, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - catalog := removePackagesByOverlap(test.sbom.Artifacts.Packages, test.sbom.Relationships, test.sbom.Artifacts.LinuxDistribution) + d := distro.FromRelease(test.sbom.Artifacts.LinuxDistribution) + catalog := removePackagesByOverlap(test.sbom.Artifacts.Packages, test.sbom.Relationships, d) pkgs := FromCollection(catalog, SynthesisConfig{}) var pkgNames []string for _, p := range pkgs { @@ -1070,7 +1129,7 @@ func catalogWithOverlaps(packages []string, overlaps []string) *sbom.SBOM { } } -func withDistro(s *sbom.SBOM, id string) *sbom.SBOM { +func withLinuxRelease(s *sbom.SBOM, id string) *sbom.SBOM { s.Artifacts.LinuxDistribution = &linux.Release{ ID: id, } diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go index 165d490ad00..e06af83b23e 100644 --- a/grype/pkg/provider.go +++ b/grype/pkg/provider.go @@ -6,6 +6,7 @@ import ( "github.com/bmatcuk/doublestar/v2" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/internal/log" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/sbom" @@ -15,20 +16,17 @@ var errDoesNotProvide = fmt.Errorf("cannot provide packages from the given sourc // Provide a set of packages and context metadata describing where they were sourced from. func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) { - packages, ctx, s, err := syftSBOMProvider(userInput, config) - if !errors.Is(err, errDoesNotProvide) { - if len(config.Exclusions) > 0 { - var exclusionsErr error - packages, exclusionsErr = filterPackageExclusions(packages, config.Exclusions) - if exclusionsErr != nil { - return nil, ctx, s, exclusionsErr - } - } - log.WithFields("input", userInput).Trace("interpreting input as an SBOM document") - return packages, ctx, s, err + packages, ctx, s, err := provide(userInput, config) + if err != nil { + return nil, Context{}, nil, err } + setContextDistro(packages, &ctx) + return packages, ctx, s, nil +} - packages, ctx, s, err = purlProvider(userInput) +// Provide a set of packages and context metadata describing where they were sourced from. +func provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) { + packages, ctx, s, err := purlProvider(userInput, config) if !errors.Is(err, errDoesNotProvide) { log.WithFields("input", userInput).Trace("interpreting input as one or more PURLs") return packages, ctx, s, err @@ -40,6 +38,19 @@ func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom return packages, ctx, s, err } + packages, ctx, s, err = syftSBOMProvider(userInput, config) + if !errors.Is(err, errDoesNotProvide) { + if len(config.Exclusions) > 0 { + var exclusionsErr error + packages, exclusionsErr = filterPackageExclusions(packages, config.Exclusions) + if exclusionsErr != nil { + return nil, ctx, s, exclusionsErr + } + } + log.WithFields("input", userInput).Trace("interpreting input as an SBOM document") + return packages, ctx, s, err + } + log.WithFields("input", userInput).Trace("passing input to syft for interpretation") return syftProvider(userInput, config) } @@ -92,3 +103,29 @@ func locationMatches(location file.Location, exclusion string) (bool, error) { } return matchesRealPath || matchesVirtualPath, nil } + +func setContextDistro(packages []Package, ctx *Context) { + if ctx.Distro != nil { + return + } + var singleDistro *distro.Distro + for _, p := range packages { + if p.Distro == nil { + continue + } + if singleDistro == nil { + singleDistro = p.Distro + continue + } + if singleDistro.Type != p.Distro.Type || + singleDistro.Version != p.Distro.Version || + singleDistro.Codename != p.Distro.Codename { + return + } + } + + // if there is one distro (with one version) represented, use that + if singleDistro != nil { + ctx.Distro = singleDistro + } +} diff --git a/grype/pkg/purl_provider.go b/grype/pkg/purl_provider.go index 2b8f14f7496..740f34960fe 100644 --- a/grype/pkg/purl_provider.go +++ b/grype/pkg/purl_provider.go @@ -1,19 +1,11 @@ package pkg import ( - "bufio" "fmt" "io" - "os" "strings" - "github.com/scylladb/go-set/strset" - - "github.com/anchore/go-homedir" - "github.com/anchore/packageurl-go" - "github.com/anchore/syft/syft/cpe" - "github.com/anchore/syft/syft/linux" - "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) @@ -21,41 +13,28 @@ import ( const ( purlInputPrefix = "purl:" singlePurlInputPrefix = "pkg:" - cpesQualifierKey = "cpes" ) type PURLLiteralMetadata struct { PURL string } -type PURLFileMetadata struct { - Path string -} - -func purlProvider(userInput string) ([]Package, Context, *sbom.SBOM, error) { +func purlProvider(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) { reader, ctx, err := getPurlReader(userInput) if err != nil { return nil, Context{}, nil, err } - return decodePurlsFromReader(reader, ctx) + s, _, _, err := format.Decode(reader) + if s == nil { + return nil, Context{}, nil, fmt.Errorf("unable to decode purl: %w", err) + } + + return FromCollection(s.Artifacts.Packages, config.SynthesisConfig, purlEnhancers...), ctx, s, nil } func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) { - switch { - case strings.HasPrefix(userInput, purlInputPrefix): - path := strings.TrimPrefix(userInput, purlInputPrefix) - ctx.Source = &source.Description{ - Metadata: PURLFileMetadata{ - Path: path, - }, - } - file, err := openPurlFile(path) - if err != nil { - return nil, ctx, err - } - return file, ctx, nil - case strings.HasPrefix(userInput, singlePurlInputPrefix): + if strings.HasPrefix(userInput, singlePurlInputPrefix) { ctx.Source = &source.Description{ Metadata: PURLLiteralMetadata{ PURL: userInput, @@ -65,196 +44,3 @@ func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) { } return nil, ctx, errDoesNotProvide } - -func openPurlFile(path string) (*os.File, error) { - expandedPath, err := homedir.Expand(path) - if err != nil { - return nil, fmt.Errorf("unable to open purls: %w", err) - } - - f, err := os.Open(expandedPath) - if err != nil { - return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err) - } - - return f, nil -} - -func decodePurlsFromReader(reader io.Reader, ctx Context) ([]Package, Context, *sbom.SBOM, error) { - scanner := bufio.NewScanner(reader) - var packages []Package - var syftPkgs []pkg.Package - - distros := make(map[string]*strset.Set) - for scanner.Scan() { - rawLine := scanner.Text() - p, syftPkg, distroName, distroVersion, err := purlToPackage(rawLine) - if err != nil { - return nil, Context{}, nil, err - } - if distroName != "" { - if _, ok := distros[distroName]; !ok { - distros[distroName] = strset.New() - } - distros[distroName].Add(distroVersion) - } - if p != nil { - packages = append(packages, *p) - } - if syftPkg != nil { - syftPkgs = append(syftPkgs, *syftPkg) - } - } - - if err := scanner.Err(); err != nil { - return nil, Context{}, nil, err - } - - s := &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(syftPkgs...), - }, - } - // Do we have multiple purls - // purl litteral <-- - // purl file <-- FileMetadata - - // if there is one distro (with one version) represented, use that - if len(distros) == 1 { - for name, versions := range distros { - if versions.Size() == 1 { - version := versions.List()[0] - var codename string - // if there are no digits in the version, it is likely a codename - if !strings.ContainsAny(version, "0123456789") { - codename = version - version = "" - } - ctx.Distro = &linux.Release{ - Name: name, - ID: name, - IDLike: []string{name}, - Version: version, - VersionCodename: codename, - } - s.Artifacts.LinuxDistribution = &linux.Release{ - Name: name, - ID: name, - IDLike: []string{name}, - Version: version, - VersionCodename: codename, - } - } - } - } - - return packages, ctx, s, nil -} - -func purlToPackage(rawLine string) (*Package, *pkg.Package, string, string, error) { - purl, err := packageurl.FromString(rawLine) - if err != nil { - return nil, nil, "", "", fmt.Errorf("unable to decode purl %s: %w", rawLine, err) - } - - var cpes []cpe.CPE - var upstreams []UpstreamPackage - var distroName, distroVersion string - epoch := "0" - - pkgType := pkg.TypeByName(purl.Type) - - for _, qualifier := range purl.Qualifiers { - switch qualifier.Key { - case cpesQualifierKey: - rawCpes := strings.Split(qualifier.Value, ",") - for _, rawCpe := range rawCpes { - c, err := cpe.New(rawCpe, "") - if err != nil { - return nil, nil, "", "", fmt.Errorf("unable to decode cpe %s in purl %s: %w", rawCpe, rawLine, err) - } - cpes = append(cpes, c) - } - case pkg.PURLQualifierEpoch: - epoch = qualifier.Value - case pkg.PURLQualifierUpstream: - upstreams = append(upstreams, parseUpstream(purl.Name, qualifier.Value, pkgType)...) - case pkg.PURLQualifierDistro: - name, version := parseDistroQualifier(qualifier.Value) - if name != "" && version != "" { - distroName = name - distroVersion = version - } - } - } - - version := purl.Version - if purl.Type == packageurl.TypeRPM && !strings.HasPrefix(purl.Version, fmt.Sprintf("%s:", epoch)) { - version = fmt.Sprintf("%s:%s", epoch, purl.Version) - } - - syftPkg := pkg.Package{ - Name: purl.Name, - Version: version, - Type: pkgType, - CPEs: cpes, - PURL: purl.String(), - Language: pkg.LanguageByName(purl.Type), - } - - syftPkg.SetID() - return &Package{ - ID: ID(purl.String()), - CPEs: cpes, - Name: purl.Name, - Version: version, - Type: pkgType, - Language: pkg.LanguageByName(purl.Type), - PURL: purl.String(), - Upstreams: upstreams, - }, &syftPkg, distroName, distroVersion, nil -} - -func parseDistroQualifier(value string) (string, string) { - fields := strings.SplitN(value, "-", 2) - switch len(fields) { - case 2: - return fields[0], fields[1] - case 1: - return fields[0], "" - } - return "", "" -} - -func parseUpstream(pkgName string, value string, pkgType pkg.Type) []UpstreamPackage { - if pkgType == pkg.RpmPkg { - return handleSourceRPM(pkgName, value) - } - return handleDefaultUpstream(pkgName, value) -} - -func handleDefaultUpstream(pkgName string, value string) []UpstreamPackage { - fields := strings.Split(value, "@") - switch len(fields) { - case 2: - if fields[0] == pkgName { - return nil - } - return []UpstreamPackage{ - { - Name: fields[0], - Version: fields[1], - }, - } - case 1: - if fields[0] == pkgName { - return nil - } - return []UpstreamPackage{ - { - Name: fields[0], - }, - } - } - return nil -} diff --git a/grype/pkg/purl_provider_test.go b/grype/pkg/purl_provider_test.go index ca909402b8b..a63d55735ba 100644 --- a/grype/pkg/purl_provider_test.go +++ b/grype/pkg/purl_provider_test.go @@ -7,10 +7,8 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" - "github.com/anchore/syft/syft/file" - "github.com/anchore/syft/syft/linux" + "github.com/anchore/grype/grype/distro" "github.com/anchore/syft/syft/pkg" - "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) @@ -20,7 +18,6 @@ func Test_PurlProvider(t *testing.T) { userInput string context Context pkgs []Package - sbom *sbom.SBOM wantErr require.ErrorAssertionFunc }{ { @@ -41,26 +38,15 @@ func Test_PurlProvider(t *testing.T) { PURL: "pkg:apk/curl@7.61.1", }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "curl", - Version: "7.61.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/curl@7.61.1", - }), - }, - }, }, { name: "os with codename", userInput: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit", context: Context{ - Distro: &linux.Release{ - Name: "debian", - ID: "debian", - IDLike: []string{"debian"}, - VersionCodename: "jessie", // important! + Distro: &distro.Distro{ + Type: "debian", + IDLike: []string{"debian"}, + Codename: "jessie", // important! }, Source: &source.Description{ Metadata: PURLLiteralMetadata{ @@ -74,6 +60,7 @@ func Test_PurlProvider(t *testing.T) { Version: "2.88dsf-59", Type: pkg.DebPkg, PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit", + Distro: &distro.Distro{Type: distro.Debian, Version: "", Codename: "jessie", IDLike: []string{"debian"}}, Upstreams: []UpstreamPackage{ { Name: "sysvinit", @@ -81,22 +68,6 @@ func Test_PurlProvider(t *testing.T) { }, }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "sysv-rc", - Version: "2.88dsf-59", - Type: pkg.DebPkg, - PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-jessie&upstream=sysvinit", - }), - LinuxDistribution: &linux.Release{ - Name: "debian", - ID: "debian", - IDLike: []string{"debian"}, - VersionCodename: "jessie", - }, - }, - }, }, { name: "default upstream", @@ -121,16 +92,6 @@ func Test_PurlProvider(t *testing.T) { }, }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "libcrypto3", - Version: "3.3.2", - Type: pkg.ApkPkg, - PURL: "pkg:apk/libcrypto3@3.3.2?upstream=openssl", - }), - }, - }, }, { name: "upstream with version", @@ -156,25 +117,14 @@ func Test_PurlProvider(t *testing.T) { }, }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "libcrypto3", - Version: "3.3.2", - Type: pkg.ApkPkg, - PURL: "pkg:apk/libcrypto3@3.3.2?upstream=openssl%403.2.1", - }), - }, - }, }, { name: "upstream for source RPM", userInput: "pkg:rpm/redhat/systemd-x@239-82.el8_10.2?arch=aarch64&distro=rhel-8.10&upstream=systemd-239-82.el8_10.2.src.rpm", context: Context{ - Distro: &linux.Release{ - Name: "rhel", - ID: "rhel", - IDLike: []string{"rhel"}, + Distro: &distro.Distro{ + Type: "redhat", + IDLike: []string{"redhat"}, Version: "8.10", }, Source: &source.Description{ @@ -186,9 +136,10 @@ func Test_PurlProvider(t *testing.T) { pkgs: []Package{ { Name: "systemd-x", - Version: "0:239-82.el8_10.2", + Version: "239-82.el8_10.2", Type: pkg.RpmPkg, PURL: "pkg:rpm/redhat/systemd-x@239-82.el8_10.2?arch=aarch64&distro=rhel-8.10&upstream=systemd-239-82.el8_10.2.src.rpm", + Distro: &distro.Distro{Type: distro.RedHat, Version: "8.10", Codename: "", IDLike: []string{"redhat"}}, Upstreams: []UpstreamPackage{ { Name: "systemd", @@ -197,31 +148,14 @@ func Test_PurlProvider(t *testing.T) { }, }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "systemd-x", - Version: "0:239-82.el8_10.2", - Type: pkg.RpmPkg, - PURL: "pkg:rpm/redhat/systemd-x@239-82.el8_10.2?arch=aarch64&distro=rhel-8.10&upstream=systemd-239-82.el8_10.2.src.rpm", - }), - LinuxDistribution: &linux.Release{ - Name: "rhel", - ID: "rhel", - IDLike: []string{"rhel"}, - Version: "8.10", - }, - }, - }, }, { name: "RPM with epoch", userInput: "pkg:rpm/redhat/dbus-common@1.12.8-26.el8?arch=noarch&distro=rhel-8.10&epoch=1&upstream=dbus-1.12.8-26.el8.src.rpm", context: Context{ - Distro: &linux.Release{ - Name: "rhel", - ID: "rhel", - IDLike: []string{"rhel"}, + Distro: &distro.Distro{ + Type: "redhat", + IDLike: []string{"redhat"}, Version: "8.10", }, Source: &source.Description{ @@ -236,6 +170,7 @@ func Test_PurlProvider(t *testing.T) { Version: "1:1.12.8-26.el8", Type: pkg.RpmPkg, PURL: "pkg:rpm/redhat/dbus-common@1.12.8-26.el8?arch=noarch&distro=rhel-8.10&epoch=1&upstream=dbus-1.12.8-26.el8.src.rpm", + Distro: &distro.Distro{Type: distro.RedHat, Version: "8.10", Codename: "", IDLike: []string{"redhat"}}, Upstreams: []UpstreamPackage{ { Name: "dbus", @@ -244,103 +179,13 @@ func Test_PurlProvider(t *testing.T) { }, }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "dbus-common", - Version: "1:1.12.8-26.el8", - Type: pkg.RpmPkg, - PURL: "pkg:rpm/redhat/dbus-common@1.12.8-26.el8?arch=noarch&distro=rhel-8.10&epoch=1&upstream=dbus-1.12.8-26.el8.src.rpm", - }), - LinuxDistribution: &linux.Release{ - Name: "rhel", - ID: "rhel", - IDLike: []string{"rhel"}, - Version: "8.10", - }, - }, - }, - }, - { - name: "takes multiple purls", - userInput: "purl:test-fixtures/purl/valid-purl.txt", - context: Context{ - Distro: &linux.Release{ - Name: "debian", - ID: "debian", - IDLike: []string{"debian"}, - Version: "8", - }, - Source: &source.Description{ - Metadata: PURLFileMetadata{ - Path: "test-fixtures/purl/valid-purl.txt", - }, - }, - }, - pkgs: []Package{ - { - Name: "sysv-rc", - Version: "2.88dsf-59", - Type: pkg.DebPkg, - PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit", - Upstreams: []UpstreamPackage{ - { - Name: "sysvinit", - }, - }, - }, - { - Name: "ant", - Version: "1.10.8", - Type: pkg.JavaPkg, - PURL: "pkg:maven/org.apache.ant/ant@1.10.8", - }, - { - Name: "log4j-core", - Version: "2.14.1", - Type: pkg.JavaPkg, - PURL: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", - }, - }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection( - pkg.Package{ - Name: "sysv-rc", - Version: "2.88dsf-59", - Type: pkg.DebPkg, - PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit", - }, - pkg.Package{ - Name: "ant", - Version: "1.10.8", - Type: pkg.JavaPkg, - Language: pkg.Java, - PURL: "pkg:maven/org.apache.ant/ant@1.10.8", - }, - pkg.Package{ - Name: "log4j-core", - Version: "2.14.1", - Type: pkg.JavaPkg, - Language: pkg.Java, - PURL: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", - }), - LinuxDistribution: &linux.Release{ - Name: "debian", - ID: "debian", - IDLike: []string{"debian"}, - Version: "8", - }, - }, - }, }, { name: "infer context when distro is present for single purl", userInput: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", context: Context{ - Distro: &linux.Release{ - Name: "alpine", - ID: "alpine", + Distro: &distro.Distro{ + Type: "alpine", IDLike: []string{"alpine"}, Version: "3.20.3", }, @@ -356,152 +201,66 @@ func Test_PurlProvider(t *testing.T) { Version: "7.61.1", Type: pkg.ApkPkg, PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", - }, - }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "curl", - Version: "7.61.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", - }), - LinuxDistribution: &linux.Release{ - Name: "alpine", - ID: "alpine", - IDLike: []string{"alpine"}, - Version: "3.20.3", - }, + Distro: &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}}, }, }, }, { - name: "infer context when distro is present for multiple similar purls", - userInput: "purl:test-fixtures/purl/homogeneous-os.txt", + name: "include namespace in name when purl is type Golang", + userInput: "pkg:golang/k8s.io/ingress-nginx@v1.11.2", context: Context{ - Distro: &linux.Release{ - Name: "alpine", - ID: "alpine", - IDLike: []string{"alpine"}, - Version: "3.20.3", - }, Source: &source.Description{ - Metadata: PURLFileMetadata{ - Path: "test-fixtures/purl/homogeneous-os.txt", - }, + Metadata: PURLLiteralMetadata{PURL: "pkg:golang/k8s.io/ingress-nginx@v1.11.2"}, }, }, pkgs: []Package{ { - Name: "openssl", - Version: "3.2.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", - }, - { - Name: "curl", - Version: "7.61.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", - }, - }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "openssl", - Version: "3.2.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", - }, - pkg.Package{ - Name: "curl", - Version: "7.61.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", - }), - LinuxDistribution: &linux.Release{ - Name: "alpine", - ID: "alpine", - IDLike: []string{"alpine"}, - Version: "3.20.3", - }, + Name: "k8s.io/ingress-nginx", + Version: "v1.11.2", + Type: pkg.GoModulePkg, + PURL: "pkg:golang/k8s.io/ingress-nginx@v1.11.2", }, }, }, { - name: "different distro info in purls does not infer context", - userInput: "purl:test-fixtures/purl/different-os.txt", + name: "include complex namespace in name when purl is type Golang", + userInput: "pkg:golang/github.com/wazuh/wazuh@v4.5.0", context: Context{ - // important: no distro info inferred Source: &source.Description{ - Metadata: PURLFileMetadata{ - Path: "test-fixtures/purl/different-os.txt", - }, + Metadata: PURLLiteralMetadata{PURL: "pkg:golang/github.com/wazuh/wazuh@v4.5.0"}, }, }, pkgs: []Package{ { - Name: "openssl", - Version: "3.2.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", - }, - { - Name: "curl", - Version: "7.61.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2", + Name: "github.com/wazuh/wazuh", + Version: "v4.5.0", + Type: pkg.GoModulePkg, + PURL: "pkg:golang/github.com/wazuh/wazuh@v4.5.0", }, }, - sbom: &sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(pkg.Package{ - Name: "openssl", - Version: "3.2.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", - }, - pkg.Package{ - Name: "curl", - Version: "7.61.1", - Type: pkg.ApkPkg, - PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2", - }), - }, - }, - }, - { - name: "fails on path with nonexistant file", - userInput: "purl:tttt/empty.txt", - wantErr: require.Error, }, { - name: "fails on invalid path", - userInput: "purl:~&&", - wantErr: require.Error, - }, - { - name: "allow empty purl file", - userInput: "purl:test-fixtures/purl/empty.json", - sbom: &sbom.SBOM{}, + name: "do not include namespace when given blank input blank", + userInput: "pkg:golang/wazuh@v4.5.0", context: Context{ Source: &source.Description{ - Metadata: PURLFileMetadata{ - Path: "test-fixtures/purl/empty.json", - }, + Metadata: PURLLiteralMetadata{PURL: "pkg:golang/wazuh@v4.5.0"}, + }, + }, + pkgs: []Package{ + { + Name: "wazuh", + Version: "v4.5.0", + Type: pkg.GoModulePkg, + PURL: "pkg:golang/wazuh@v4.5.0", }, }, }, { - name: "fails on invalid purl in file", + name: "fails on purl list input", userInput: "purl:test-fixtures/purl/invalid-purl.txt", wantErr: require.Error, }, - { - name: "fails on invalid cpe in file", - userInput: "purl:test-fixtures/purl/invalid-cpe.txt", - wantErr: require.Error, - }, { name: "invalid prefix", userInput: "dir:test-fixtures/purl", @@ -509,22 +268,14 @@ func Test_PurlProvider(t *testing.T) { }, } - opts := []cmp.Option{ - cmpopts.IgnoreFields(Package{}, "ID", "Locations", "Licenses", "Metadata", "Language", "CPEs"), - } - - syftPkgOpts := []cmp.Option{ - cmpopts.IgnoreFields(pkg.Package{}, "id"), - cmpopts.IgnoreUnexported(pkg.Package{}, file.LocationSet{}, pkg.LicenseSet{}), - } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.wantErr == nil { tc.wantErr = require.NoError } - packages, ctx, gotSBOM, err := purlProvider(tc.userInput) + packages, ctx, _, err := purlProvider(tc.userInput, ProviderConfig{}) + setContextDistro(packages, &ctx) tc.wantErr(t, err) if err != nil { @@ -532,35 +283,20 @@ func Test_PurlProvider(t *testing.T) { return } - if d := cmp.Diff(tc.context, ctx, opts...); d != "" { + if d := cmp.Diff(tc.context, ctx, diffOpts...); d != "" { t.Errorf("unexpected context (-want +got):\n%s", d) } require.Len(t, packages, len(tc.pkgs)) for idx, expected := range tc.pkgs { - if d := cmp.Diff(expected, packages[idx], opts...); d != "" { + if d := cmp.Diff(expected, packages[idx], diffOpts...); d != "" { t.Errorf("unexpected context (-want +got):\n%s", d) } } - - gotSyftPkgs := gotSBOM.Artifacts.Packages.Sorted() - wantSyftPkgs := tc.sbom.Artifacts.Packages.Sorted() - require.Equal(t, len(gotSyftPkgs), len(wantSyftPkgs)) - for idx, wantPkg := range wantSyftPkgs { - if d := cmp.Diff(wantPkg, gotSyftPkgs[idx], syftPkgOpts...); d != "" { - t.Errorf("unexpected Syft Pkg (-want +got):\n%s", d) - } - } - - wantSyftDistro := tc.sbom.Artifacts.LinuxDistribution - gotDistro := gotSBOM.Artifacts.LinuxDistribution - if wantSyftDistro == nil { - require.Nil(t, gotDistro) - return - } - - if d := cmp.Diff(wantSyftDistro, gotDistro); d != "" { - t.Errorf("unexpected Syft Distro (-want +got):\n%s", d) - } }) } } + +var diffOpts = []cmp.Option{ + cmpopts.IgnoreFields(Package{}, "ID", "Locations", "Licenses", "Language", "CPEs"), + cmpopts.IgnoreUnexported(distro.Distro{}), +} diff --git a/grype/pkg/syft_provider.go b/grype/pkg/syft_provider.go index 8cfaec2de1f..9bd8a609cd1 100644 --- a/grype/pkg/syft_provider.go +++ b/grype/pkg/syft_provider.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/anchore/go-collections" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/internal/log" "github.com/anchore/stereoscope" "github.com/anchore/stereoscope/pkg/image" @@ -19,14 +20,7 @@ func syftProvider(userInput string, config ProviderConfig) ([]Package, Context, if err != nil { return nil, Context{}, nil, err } - - defer func() { - if src != nil { - if err := src.Close(); err != nil { - log.Tracef("unable to close source: %+v", err) - } - } - }() + defer log.CloseAndLogError(src, "syft source") s, err := syft.CreateSBOM(context.Background(), src, config.SBOMOptions) if err != nil { @@ -37,14 +31,16 @@ func syftProvider(userInput string, config ProviderConfig) ([]Package, Context, return nil, Context{}, nil, errors.New("no SBOM provided") } - pkgCatalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, s.Artifacts.LinuxDistribution) - srcDescription := src.Describe() + d := distro.FromRelease(s.Artifacts.LinuxDistribution) + + pkgCatalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, d) + packages := FromCollection(pkgCatalog, config.SynthesisConfig) pkgCtx := Context{ Source: &srcDescription, - Distro: s.Artifacts.LinuxDistribution, + Distro: d, } return packages, pkgCtx, s, nil diff --git a/grype/pkg/syft_sbom_provider.go b/grype/pkg/syft_sbom_provider.go index 4c7b88ba7a1..f2ebf9bbd48 100644 --- a/grype/pkg/syft_sbom_provider.go +++ b/grype/pkg/syft_sbom_provider.go @@ -11,66 +11,70 @@ import ( "github.com/gabriel-vasile/mimetype" "github.com/anchore/go-homedir" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/log" "github.com/anchore/syft/syft/format" + "github.com/anchore/syft/syft/format/syftjson" "github.com/anchore/syft/syft/sbom" ) +type SBOMFileMetadata struct { + Path string +} + func syftSBOMProvider(userInput string, config ProviderConfig) ([]Package, Context, *sbom.SBOM, error) { - s, err := getSBOM(userInput) + s, fmtID, path, err := getSBOM(userInput) if err != nil { return nil, Context{}, nil, err } - catalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, s.Artifacts.LinuxDistribution) + src := s.Source + if src.Metadata == nil && path != "" { + src.Metadata = SBOMFileMetadata{ + Path: path, + } + } + + d := distro.FromRelease(s.Artifacts.LinuxDistribution) - return FromCollection(catalog, config.SynthesisConfig), Context{ - Source: &s.Source, - Distro: s.Artifacts.LinuxDistribution, - }, s, nil -} + catalog := removePackagesByOverlap(s.Artifacts.Packages, s.Relationships, d) -func newInputInfo(scheme, contentTye string) *inputInfo { - return &inputInfo{ - Scheme: scheme, - ContentType: contentTye, + var enhancers []Enhancer + if fmtID != syftjson.ID { + enhancers = purlEnhancers } -} -type inputInfo struct { - ContentType string - Scheme string + return FromCollection(catalog, config.SynthesisConfig, enhancers...), Context{ + Source: &src, + Distro: d, + }, s, nil } -func getSBOM(userInput string) (*sbom.SBOM, error) { - reader, err := getSBOMReader(userInput) +func getSBOM(userInput string) (*sbom.SBOM, sbom.FormatID, string, error) { + reader, path, err := getSBOMReader(userInput) if err != nil { - return nil, err + return nil, "", path, err } + s, fmtID, err := readSBOM(reader) + return s, fmtID, path, err +} + +func readSBOM(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, error) { s, fmtID, _, err := format.Decode(reader) if err != nil { - return nil, fmt.Errorf("unable to decode sbom: %w", err) + return nil, "", fmt.Errorf("unable to decode sbom: %w", err) } if fmtID == "" || s == nil { - return nil, errDoesNotProvide + return nil, "", errDoesNotProvide } - return s, nil + return s, fmtID, nil } -func getSBOMReader(userInput string) (r io.ReadSeeker, err error) { - r, _, err = extractReaderAndInfo(userInput) - if err != nil { - return nil, err - } - - return r, nil -} - -func extractReaderAndInfo(userInput string) (io.ReadSeeker, *inputInfo, error) { +func getSBOMReader(userInput string) (io.ReadSeeker, string, error) { switch { // the order of cases matter case userInput == "": @@ -78,44 +82,39 @@ func extractReaderAndInfo(userInput string) (io.ReadSeeker, *inputInfo, error) { // options from the CLI, otherwise we should not assume there is any valid input from stdin. r, err := stdinReader() if err != nil { - return nil, nil, err + return nil, "", err } return decodeStdin(r) + case explicitlySpecifyingPurlList(userInput): + filepath := strings.TrimPrefix(userInput, purlInputPrefix) + return openFile(filepath) + case explicitlySpecifyingSBOM(userInput): filepath := strings.TrimPrefix(userInput, "sbom:") - return parseSBOM("sbom", filepath) + return openFile(filepath) case isPossibleSBOM(userInput): - return parseSBOM("", userInput) + return openFile(userInput) default: - return nil, nil, errDoesNotProvide + return nil, "", errDoesNotProvide } } -func parseSBOM(scheme, path string) (io.ReadSeeker, *inputInfo, error) { - r, err := openFile(path) - if err != nil { - return nil, nil, err - } - info := newInputInfo(scheme, "sbom") - return r, info, nil -} - -func decodeStdin(r io.Reader) (io.ReadSeeker, *inputInfo, error) { +func decodeStdin(r io.Reader) (io.ReadSeeker, string, error) { b, err := io.ReadAll(r) if err != nil { - return nil, nil, fmt.Errorf("failed reading stdin: %w", err) + return nil, "", fmt.Errorf("failed reading stdin: %w", err) } reader := bytes.NewReader(b) _, err = reader.Seek(0, io.SeekStart) if err != nil { - return nil, nil, fmt.Errorf("failed to parse stdin: %w", err) + return nil, "", fmt.Errorf("failed to parse stdin: %w", err) } - return reader, newInputInfo("", "sbom"), nil + return reader, "", nil } func stdinReader() (io.Reader, error) { @@ -131,37 +130,26 @@ func stdinReader() (io.Reader, error) { return os.Stdin, nil } -func closeFile(f *os.File) { - if f == nil { - return - } - - err := f.Close() - if err != nil { - log.Warnf("failed to close file %s: %v", f.Name(), err) - } -} - -func openFile(path string) (*os.File, error) { +func openFile(path string) (io.ReadSeekCloser, string, error) { expandedPath, err := homedir.Expand(path) if err != nil { - return nil, fmt.Errorf("unable to open SBOM: %w", err) + return nil, path, fmt.Errorf("unable to open SBOM: %w", err) } f, err := os.Open(expandedPath) if err != nil { - return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err) + return nil, path, fmt.Errorf("unable to open file %s: %w", expandedPath, err) } - return f, nil + return f, path, nil } func isPossibleSBOM(userInput string) bool { - f, err := openFile(userInput) + f, path, err := openFile(userInput) if err != nil { return false } - defer closeFile(f) + defer log.CloseAndLogError(f, path) mType, err := mimetype.DetectReader(f) if err != nil { @@ -185,3 +173,7 @@ func isAncestorOfMimetype(mType *mimetype.MIME, expected string) bool { func explicitlySpecifyingSBOM(userInput string) bool { return strings.HasPrefix(userInput, "sbom:") } + +func explicitlySpecifyingPurlList(userInput string) bool { + return strings.HasPrefix(userInput, purlInputPrefix) +} diff --git a/grype/pkg/syft_sbom_provider_test.go b/grype/pkg/syft_sbom_provider_test.go index 2d2e48614d1..5d18d06570c 100644 --- a/grype/pkg/syft_sbom_provider_test.go +++ b/grype/pkg/syft_sbom_provider_test.go @@ -1,15 +1,19 @@ package pkg import ( + "slices" "strings" "testing" "github.com/go-test/deep" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/anchore/grype/grype/distro" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" - "github.com/anchore/syft/syft/linux" + "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/source" ) @@ -219,8 +223,8 @@ func TestParseSyftJSON(t *testing.T) { }, }, }, - Distro: &linux.Release{ - Name: "alpine", + Distro: &distro.Distro{ + Type: "alpine", Version: "3.12.0", }, }, @@ -337,9 +341,184 @@ var springImageTestCase = struct { RepoDigests: []string{"springio/gs-spring-boot-docker@sha256:39c2ffc784f5f34862e22c1f2ccdbcb62430736114c13f60111eabdb79decb08"}, }, }, - Distro: &linux.Release{ - Name: "debian", + Distro: &distro.Distro{ + Type: "debian", Version: "9", }, }, } + +func Test_PurlList(t *testing.T) { + tests := []struct { + name string + userInput string + context Context + pkgs []Package + wantErr require.ErrorAssertionFunc + }{ + { + name: "takes multiple purls", + userInput: "purl:test-fixtures/purl/valid-purl.txt", + context: Context{ + Distro: &distro.Distro{ + Type: "debian", + IDLike: []string{"debian"}, + Version: "8", + }, + Source: &source.Description{ + Metadata: SBOMFileMetadata{ + Path: "test-fixtures/purl/valid-purl.txt", + }, + }, + }, + pkgs: []Package{ + { + Name: "ant", + Version: "1.10.8", + Type: pkg.JavaPkg, + PURL: "pkg:maven/org.apache.ant/ant@1.10.8", + Metadata: JavaMetadata{ + PomArtifactID: "ant", + PomGroupID: "org.apache.ant", + }, + }, + { + Name: "log4j-core", + Version: "2.14.1", + Type: pkg.JavaPkg, + PURL: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", + Metadata: JavaMetadata{ + PomArtifactID: "log4j-core", + PomGroupID: "org.apache.logging.log4j", + }, + }, + { + Name: "sysv-rc", + Version: "2.88dsf-59", + Type: pkg.DebPkg, + PURL: "pkg:deb/debian/sysv-rc@2.88dsf-59?arch=all&distro=debian-8&upstream=sysvinit", + Distro: &distro.Distro{Type: distro.Debian, Version: "8", Codename: "", IDLike: []string{"debian"}}, + Upstreams: []UpstreamPackage{ + { + Name: "sysvinit", + }, + }, + }, + }, + }, + { + name: "infer context when distro is present for multiple similar purls", + userInput: "purl:test-fixtures/purl/homogeneous-os.txt", + context: Context{ + Distro: &distro.Distro{ + Type: "alpine", + IDLike: []string{"alpine"}, + Version: "3.20.3", + }, + Source: &source.Description{ + Metadata: SBOMFileMetadata{ + Path: "test-fixtures/purl/homogeneous-os.txt", + }, + }, + }, + pkgs: []Package{ + { + Name: "openssl", + Version: "3.2.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", + Distro: &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}}, + }, + { + Name: "curl", + Version: "7.61.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.3", + Distro: &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}}, + }, + }, + }, + { + name: "different distro info in purls does not infer context", + userInput: "purl:test-fixtures/purl/different-os.txt", + context: Context{ + // important: no distro info inferred + Source: &source.Description{ + Metadata: SBOMFileMetadata{ + Path: "test-fixtures/purl/different-os.txt", + }, + }, + }, + pkgs: []Package{ + { + Name: "openssl", + Version: "3.2.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/openssl@3.2.1?arch=aarch64&distro=alpine-3.20.3", + Distro: &distro.Distro{Type: distro.Alpine, Version: "3.20.3", Codename: "", IDLike: []string{"alpine"}}, + }, + { + Name: "curl", + Version: "7.61.1", + Type: pkg.ApkPkg, + PURL: "pkg:apk/curl@7.61.1?arch=aarch64&distro=alpine-3.20.2", + Distro: &distro.Distro{Type: distro.Alpine, Version: "3.20.2", Codename: "", IDLike: []string{"alpine"}}, + }, + }, + }, + { + name: "fails on path with nonexistant file", + userInput: "purl:tttt/empty.txt", + wantErr: require.Error, + }, + { + name: "fails on invalid path", + userInput: "purl:~&&", + wantErr: require.Error, + }, + { + name: "fails for empty purl file", + userInput: "purl:test-fixtures/purl/empty.json", + wantErr: require.Error, + }, + { + name: "fails on invalid purl in file", + userInput: "purl:test-fixtures/purl/invalid-purl.txt", + wantErr: require.Error, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.wantErr == nil { + tc.wantErr = require.NoError + } + + packages, ctx, _, err := Provide(tc.userInput, ProviderConfig{}) + + tc.wantErr(t, err) + if err != nil { + require.Nil(t, packages) + return + } + + if d := cmp.Diff(tc.context, ctx, diffOpts...); d != "" { + t.Errorf("unexpected context (-want +got):\n%s", d) + } + require.Len(t, packages, len(tc.pkgs)) + + slices.SortFunc(packages, func(a, b Package) int { + return strings.Compare(a.Name, b.Name) + }) + slices.SortFunc(tc.pkgs, func(a, b Package) int { + return strings.Compare(a.Name, b.Name) + }) + + for idx, expected := range tc.pkgs { + if d := cmp.Diff(expected, packages[idx], diffOpts...); d != "" { + t.Errorf("unexpected context (-want +got):\n%s", d) + } + } + }) + } +} diff --git a/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterDir.golden b/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterDir.golden index 4c8e813faf5..34638ea5544 100644 --- a/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterDir.golden +++ b/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterDir.golden @@ -2,10 +2,10 @@ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.6", - "serialNumber": "urn:uuid:d78e14f4-ade8-4948-991c-73318b661114", + "serialNumber": "urn:uuid:c7b9f230-9fb8-43f7-af7a-824cd878a853", "version": 1, "metadata": { - "timestamp": "2025-02-27T13:15:13-05:00", + "timestamp": "2025-05-16T15:26:00-04:00", "tools": { "components": [ { @@ -19,12 +19,12 @@ "component": { "bom-ref": "163686ac6e30c752", "type": "file", - "name": "/var/folders/c0/4y79v5k56bz8v34chcmvq2k80000gp/T/TestCycloneDxPresenterDir3112352663/001" + "name": "/var/folders/8y/ct5nbgtj4p30k10kfmq4p4s00000gn/T/TestCycloneDxPresenterDir76562573/001" } }, "components": [ { - "bom-ref": "9baa2db122fea516", + "bom-ref": "bbb0ba712c2b94ea", "type": "library", "name": "package-1", "version": "1.1.1", @@ -57,7 +57,7 @@ ] }, { - "bom-ref": "pkg:deb/package-2@2.2.2?package-id=7bb53d560434bc7f", + "bom-ref": "pkg:deb/package-2@2.2.2?package-id=74378afe15713625", "type": "library", "name": "package-2", "version": "2.2.2", @@ -89,60 +89,50 @@ ], "vulnerabilities": [ { - "bom-ref": "urn:uuid:e47db483-7ab2-4177-a82b-416b5a37bf93", + "bom-ref": "urn:uuid:983f094f-a20c-4aed-b6f1-d9b417c706cc", "id": "CVE-1999-0001", - "source": { - "name": "source-1" - }, + "source": {}, "references": [ { "id": "CVE-1999-0001", - "source": { - "name": "source-1" - } + "source": {} } ], "ratings": [ { - "score": 4, + "score": 8.2, "severity": "low", - "method": "CVSSv3", - "vector": "another vector" + "method": "CVSSv31", + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H" } ], - "description": "1999-01 description", "affects": [ { - "ref": "9baa2db122fea516" + "ref": "bbb0ba712c2b94ea" } ] }, { - "bom-ref": "urn:uuid:3e3fbcf0-8dda-41a7-97ed-d1122923bf79", + "bom-ref": "urn:uuid:ae00e132-f2c5-4d07-896d-31aaf91e04d1", "id": "CVE-1999-0002", - "source": { - "name": "source-2" - }, + "source": {}, "references": [ { "id": "CVE-1999-0002", - "source": { - "name": "source-2" - } + "source": {} } ], "ratings": [ { - "score": 1, + "score": 8.5, "severity": "critical", - "method": "CVSSv2", - "vector": "vector" + "method": "CVSSv31", + "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H" } ], - "description": "1999-02 description", "affects": [ { - "ref": "pkg:deb/package-2@2.2.2?package-id=7bb53d560434bc7f" + "ref": "pkg:deb/package-2@2.2.2?package-id=74378afe15713625" } ] } diff --git a/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterImage.golden b/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterImage.golden index f5676aedbb4..bafdf481d93 100644 --- a/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterImage.golden +++ b/grype/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxPresenterImage.golden @@ -2,10 +2,10 @@ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.6", - "serialNumber": "urn:uuid:24d4fb73-8881-462c-940a-61b6d37b579d", + "serialNumber": "urn:uuid:e9977dcd-e35e-4053-ab49-932f99e4d240", "version": 1, "metadata": { - "timestamp": "2025-02-27T13:15:13-05:00", + "timestamp": "2025-05-16T15:26:00-04:00", "tools": { "components": [ { @@ -25,7 +25,7 @@ }, "components": [ { - "bom-ref": "9baa2db122fea516", + "bom-ref": "bbb0ba712c2b94ea", "type": "library", "name": "package-1", "version": "1.1.1", @@ -58,7 +58,7 @@ ] }, { - "bom-ref": "pkg:deb/package-2@2.2.2?package-id=7bb53d560434bc7f", + "bom-ref": "pkg:deb/package-2@2.2.2?package-id=74378afe15713625", "type": "library", "name": "package-2", "version": "2.2.2", @@ -90,60 +90,50 @@ ], "vulnerabilities": [ { - "bom-ref": "urn:uuid:638bc45b-a838-4017-b690-dc5d8d3cfe8d", + "bom-ref": "urn:uuid:9f6e21d3-d245-4ee3-9558-cb5cc4936b32", "id": "CVE-1999-0001", - "source": { - "name": "source-1" - }, + "source": {}, "references": [ { "id": "CVE-1999-0001", - "source": { - "name": "source-1" - } + "source": {} } ], "ratings": [ { - "score": 4, + "score": 8.2, "severity": "low", - "method": "CVSSv3", - "vector": "another vector" + "method": "CVSSv31", + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H" } ], - "description": "1999-01 description", "affects": [ { - "ref": "9baa2db122fea516" + "ref": "bbb0ba712c2b94ea" } ] }, { - "bom-ref": "urn:uuid:6592d365-1128-49a0-8a68-570d8188965a", + "bom-ref": "urn:uuid:4fc03529-3b07-41e2-9bc9-c0b2989ccf8e", "id": "CVE-1999-0002", - "source": { - "name": "source-2" - }, + "source": {}, "references": [ { "id": "CVE-1999-0002", - "source": { - "name": "source-2" - } + "source": {} } ], "ratings": [ { - "score": 1, + "score": 8.5, "severity": "critical", - "method": "CVSSv2", - "vector": "vector" + "method": "CVSSv31", + "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H" } ], - "description": "1999-02 description", "affects": [ { - "ref": "pkg:deb/package-2@2.2.2?package-id=7bb53d560434bc7f" + "ref": "pkg:deb/package-2@2.2.2?package-id=74378afe15713625" } ] } diff --git a/grype/presenter/internal/test_helpers.go b/grype/presenter/internal/test_helpers.go index 8c3e8fb12cc..aa2b528abb8 100644 --- a/grype/presenter/internal/test_helpers.go +++ b/grype/presenter/internal/test_helpers.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/anchore/clio" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/presenter/models" @@ -15,7 +16,6 @@ import ( "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/sbom" syftSource "github.com/anchore/syft/syft/source" @@ -99,7 +99,7 @@ func Redact(s []byte) []byte { return s } -func generateMatches(t *testing.T, p1, p2 pkg.Package) match.Matches { +func generateMatches(t *testing.T, p1, p2 pkg.Package) match.Matches { // nolint:funlen t.Helper() matches := []match.Match{ @@ -114,6 +114,29 @@ func generateMatches(t *testing.T, p1, p2 pkg.Package) match.Matches { Versions: []string{"1.2.1", "2.1.3", "3.4.0"}, State: vulnerability.FixStateFixed, }, + Metadata: &vulnerability.Metadata{ + ID: "CVE-1999-0001", + Severity: "Low", + Cvss: []vulnerability.Cvss{ + { + Source: "nvd", + Type: "CVSS", + Version: "3.1", + Vector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H", + Metrics: vulnerability.CvssMetrics{ + BaseScore: 8.2, + }, + }, + }, + KnownExploited: nil, + EPSS: []vulnerability.EPSS{ + { + CVE: "CVE-1999-0001", + EPSS: 0.03, + Percentile: 0.42, + }, + }, + }, }, Package: p1, Details: []match.Detail{ @@ -139,6 +162,34 @@ func generateMatches(t *testing.T, p1, p2 pkg.Package) match.Matches { ID: "CVE-1999-0002", Namespace: "source-2", }, + Metadata: &vulnerability.Metadata{ + ID: "CVE-1999-0002", + Severity: "Critical", + Cvss: []vulnerability.Cvss{ + { + Source: "nvd", + Type: "CVSS", + Version: "3.1", + Vector: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", + Metrics: vulnerability.CvssMetrics{ + BaseScore: 8.5, + }, + }, + }, + KnownExploited: []vulnerability.KnownExploited{ + { + CVE: "CVE-1999-0002", + KnownRansomwareCampaignUse: "Known", + }, + }, + EPSS: []vulnerability.EPSS{ + { + CVE: "CVE-1999-0002", + EPSS: 0.08, + Percentile: 0.53, + }, + }, + }, }, Package: p2, Details: []match.Detail{ @@ -173,6 +224,29 @@ func generateIgnoredMatches(t *testing.T, p pkg.Package) []match.IgnoredMatch { ID: "CVE-1999-0001", Namespace: "source-1", }, + Metadata: &vulnerability.Metadata{ + ID: "CVE-1999-0001", + Severity: "Low", + Cvss: []vulnerability.Cvss{ + { + Source: "nvd", + Type: "CVSS", + Version: "3.1", + Vector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H", + Metrics: vulnerability.CvssMetrics{ + BaseScore: 8.2, + }, + }, + }, + KnownExploited: nil, + EPSS: []vulnerability.EPSS{ + { + CVE: "CVE-1999-0001", + EPSS: 0.03, + Percentile: 0.42, + }, + }, + }, }, Package: p, Details: []match.Detail{ @@ -200,6 +274,34 @@ func generateIgnoredMatches(t *testing.T, p pkg.Package) []match.IgnoredMatch { ID: "CVE-1999-0002", Namespace: "source-2", }, + Metadata: &vulnerability.Metadata{ + ID: "CVE-1999-0002", + Severity: "Critical", + Cvss: []vulnerability.Cvss{ + { + Source: "nvd", + Type: "CVSS", + Version: "3.1", + Vector: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", + Metrics: vulnerability.CvssMetrics{ + BaseScore: 8.5, + }, + }, + }, + KnownExploited: []vulnerability.KnownExploited{ + { + CVE: "CVE-1999-0002", + KnownRansomwareCampaignUse: "Known", + }, + }, + EPSS: []vulnerability.EPSS{ + { + CVE: "CVE-1999-0002", + EPSS: 0.08, + Percentile: 0.53, + }, + }, + }, }, Package: p, Details: []match.Detail{ @@ -224,6 +326,28 @@ func generateIgnoredMatches(t *testing.T, p pkg.Package) []match.IgnoredMatch { ID: "CVE-1999-0004", Namespace: "source-2", }, + Metadata: &vulnerability.Metadata{ + ID: "CVE-1999-0004", + Severity: "High", + Cvss: []vulnerability.Cvss{ + { + Source: "nvd", + Type: "CVSS", + Version: "3.1", + Vector: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:L", + Metrics: vulnerability.CvssMetrics{ + BaseScore: 7.2, + }, + }, + }, + EPSS: []vulnerability.EPSS{ + { + CVE: "CVE-1999-0004", + EPSS: 0.03, + Percentile: 0.75, + }, + }, + }, }, Package: p, Details: []match.Detail{ @@ -382,8 +506,8 @@ func generateContext(t *testing.T, scheme SyftSource) pkg.Context { return pkg.Context{ Source: &desc, - Distro: &linux.Release{ - Name: "centos", + Distro: &distro.Distro{ + Type: "centos", IDLike: []string{ "centos", }, diff --git a/grype/presenter/json/presenter_test.go b/grype/presenter/json/presenter_test.go index 3910b55ce9e..dc2929ae518 100644 --- a/grype/presenter/json/presenter_test.go +++ b/grype/presenter/json/presenter_test.go @@ -12,11 +12,11 @@ import ( "github.com/anchore/clio" "github.com/anchore/go-testutils" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/presenter/internal" "github.com/anchore/grype/grype/presenter/models" - "github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/source" ) @@ -85,8 +85,8 @@ func TestEmptyJsonPresenter(t *testing.T) { ctx := pkg.Context{ Source: &source.Description{}, - Distro: &linux.Release{ - ID: "centos", + Distro: &distro.Distro{ + Type: "centos", IDLike: []string{"rhel"}, Version: "8.0", }, diff --git a/grype/presenter/json/test-fixtures/snapshot/TestJsonDirsPresenter.golden b/grype/presenter/json/test-fixtures/snapshot/TestJsonDirsPresenter.golden index 05f412100c6..d77afa9e331 100644 --- a/grype/presenter/json/test-fixtures/snapshot/TestJsonDirsPresenter.golden +++ b/grype/presenter/json/test-fixtures/snapshot/TestJsonDirsPresenter.golden @@ -4,20 +4,28 @@ "vulnerability": { "id": "CVE-1999-0001", "dataSource": "", - "namespace": "source-1", "severity": "Low", "urls": [], - "description": "1999-01 description", "cvss": [ { - "version": "3.0", - "vector": "another vector", + "source": "nvd", + "type": "CVSS", + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H", "metrics": { - "baseScore": 4 + "baseScore": 8.2 }, "vendorMetadata": {} } ], + "epss": [ + { + "cve": "CVE-1999-0001", + "epss": 0.03, + "percentile": 0.42, + "date": "0001-01-01" + } + ], "fix": { "versions": [ "1.2.1", @@ -26,7 +34,8 @@ ], "state": "fixed" }, - "advisories": [] + "advisories": [], + "risk": 1.68 }, "relatedVulnerabilities": [], "matchDetails": [ @@ -48,7 +57,7 @@ } ], "artifact": { - "id": "9baa2db122fea516", + "id": "bbb0ba712c2b94ea", "name": "package-1", "version": "1.1.1", "type": "rpm", @@ -76,30 +85,40 @@ "vulnerability": { "id": "CVE-1999-0002", "dataSource": "", - "namespace": "source-2", "severity": "Critical", "urls": [], - "description": "1999-02 description", "cvss": [ { - "version": "2.0", - "vector": "vector", + "source": "nvd", + "type": "CVSS", + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", "metrics": { - "baseScore": 1, - "exploitabilityScore": 2, - "impactScore": 3 + "baseScore": 8.5 }, - "vendorMetadata": { - "BaseSeverity": "Low", - "Status": "verified" - } + "vendorMetadata": {} + } + ], + "knownExploited": [ + { + "cve": "CVE-1999-0002", + "knownRansomwareCampaignUse": "Known" + } + ], + "epss": [ + { + "cve": "CVE-1999-0002", + "epss": 0.08, + "percentile": 0.53, + "date": "0001-01-01" } ], "fix": { "versions": [], "state": "" }, - "advisories": [] + "advisories": [], + "risk": 96.25000000000001 }, "relatedVulnerabilities": [], "matchDetails": [ @@ -115,7 +134,7 @@ } ], "artifact": { - "id": "7bb53d560434bc7f", + "id": "74378afe15713625", "name": "package-2", "version": "2.2.2", "type": "deb", diff --git a/grype/presenter/json/test-fixtures/snapshot/TestJsonImgsPresenter.golden b/grype/presenter/json/test-fixtures/snapshot/TestJsonImgsPresenter.golden index adb4ab0de50..b49085fb678 100644 --- a/grype/presenter/json/test-fixtures/snapshot/TestJsonImgsPresenter.golden +++ b/grype/presenter/json/test-fixtures/snapshot/TestJsonImgsPresenter.golden @@ -4,20 +4,28 @@ "vulnerability": { "id": "CVE-1999-0001", "dataSource": "", - "namespace": "source-1", "severity": "Low", "urls": [], - "description": "1999-01 description", "cvss": [ { - "version": "3.0", - "vector": "another vector", + "source": "nvd", + "type": "CVSS", + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H", "metrics": { - "baseScore": 4 + "baseScore": 8.2 }, "vendorMetadata": {} } ], + "epss": [ + { + "cve": "CVE-1999-0001", + "epss": 0.03, + "percentile": 0.42, + "date": "0001-01-01" + } + ], "fix": { "versions": [ "1.2.1", @@ -26,7 +34,8 @@ ], "state": "fixed" }, - "advisories": [] + "advisories": [], + "risk": 1.68 }, "relatedVulnerabilities": [], "matchDetails": [ @@ -48,7 +57,7 @@ } ], "artifact": { - "id": "9baa2db122fea516", + "id": "bbb0ba712c2b94ea", "name": "package-1", "version": "1.1.1", "type": "rpm", @@ -76,30 +85,40 @@ "vulnerability": { "id": "CVE-1999-0002", "dataSource": "", - "namespace": "source-2", "severity": "Critical", "urls": [], - "description": "1999-02 description", "cvss": [ { - "version": "2.0", - "vector": "vector", + "source": "nvd", + "type": "CVSS", + "version": "3.1", + "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", "metrics": { - "baseScore": 1, - "exploitabilityScore": 2, - "impactScore": 3 + "baseScore": 8.5 }, - "vendorMetadata": { - "BaseSeverity": "Low", - "Status": "verified" - } + "vendorMetadata": {} + } + ], + "knownExploited": [ + { + "cve": "CVE-1999-0002", + "knownRansomwareCampaignUse": "Known" + } + ], + "epss": [ + { + "cve": "CVE-1999-0002", + "epss": 0.08, + "percentile": 0.53, + "date": "0001-01-01" } ], "fix": { "versions": [], "state": "" }, - "advisories": [] + "advisories": [], + "risk": 96.25000000000001 }, "relatedVulnerabilities": [], "matchDetails": [ @@ -115,7 +134,7 @@ } ], "artifact": { - "id": "7bb53d560434bc7f", + "id": "74378afe15713625", "name": "package-2", "version": "2.2.2", "type": "deb", diff --git a/grype/presenter/models/distribution.go b/grype/presenter/models/distribution.go index e105a8dac79..a8b9d792afa 100644 --- a/grype/presenter/models/distribution.go +++ b/grype/presenter/models/distribution.go @@ -2,8 +2,6 @@ package models import ( "github.com/anchore/grype/grype/distro" - "github.com/anchore/grype/internal/log" - "github.com/anchore/syft/syft/linux" ) // distribution provides information about a detected Linux distribution. @@ -14,24 +12,11 @@ type distribution struct { } // newDistribution creates a struct with the Linux distribution to be represented in JSON. -func newDistribution(r *linux.Release) distribution { - if r == nil { +func newDistribution(d *distro.Distro) distribution { + if d == nil { return distribution{} } - // attempt to use the strong distro type (like the matchers do) - d, err := distro.NewFromRelease(*r) - if err != nil { - log.Warnf("unable to determine linux distribution: %+v", err) - - // as a fallback use the raw release information - return distribution{ - Name: r.ID, - Version: r.VersionID, - IDLike: cleanIDLike(r.IDLike), - } - } - return distribution{ Name: d.Name(), Version: d.Version, diff --git a/grype/presenter/models/document_test.go b/grype/presenter/models/document_test.go index d81f0dc144f..0cb95916935 100644 --- a/grype/presenter/models/document_test.go +++ b/grype/presenter/models/document_test.go @@ -7,10 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/anchore/clio" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/vulnerability" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" syftSource "github.com/anchore/syft/syft/source" ) @@ -75,8 +75,8 @@ func TestPackagesAreSorted(t *testing.T) { Source: &syftSource.Description{ Metadata: syftSource.DirectoryMetadata{}, }, - Distro: &linux.Release{ - ID: "centos", + Distro: &distro.Distro{ + Type: "centos", IDLike: []string{"rhel"}, Version: "8.0", }, @@ -136,8 +136,8 @@ func TestFixSuggestedVersion(t *testing.T) { Source: &syftSource.Description{ Metadata: syftSource.DirectoryMetadata{}, }, - Distro: &linux.Release{ - ID: "centos", + Distro: &distro.Distro{ + Type: "centos", IDLike: []string{"rhel"}, Version: "8.0", }, diff --git a/grype/presenter/models/sort.go b/grype/presenter/models/sort.go index 8bb8ee10ab9..f1c5895e44f 100644 --- a/grype/presenter/models/sort.go +++ b/grype/presenter/models/sort.go @@ -10,13 +10,18 @@ import ( type SortStrategy string const ( - SortByPackage SortStrategy = "package" + SortByPackage SortStrategy = "package" + SortBySeverity SortStrategy = "severity" + SortByThreat SortStrategy = "epss" + SortByRisk SortStrategy = "risk" + SortByKEV SortStrategy = "kev" + SortByVulnerability SortStrategy = "vulnerability" - defaultSortStrategy = SortByPackage + DefaultSortStrategy = SortByRisk ) func SortStrategies() []SortStrategy { - return []SortStrategy{SortByPackage} + return []SortStrategy{SortByPackage, SortBySeverity, SortByThreat, SortByRisk, SortByKEV, SortByVulnerability} } func (s SortStrategy) String() string { @@ -37,14 +42,85 @@ type sortStrategyImpl []compareFunc // matchSortStrategy provides predefined sort strategies for Match var matchSortStrategy = map[SortStrategy]sortStrategyImpl{ SortByPackage: { - compareByPackageName, - compareByPackageVersion, - compareByPackageType, + comparePackageAttributes, + compareVulnerabilityAttributes, + }, + SortByVulnerability: { + compareVulnerabilityAttributes, + comparePackageAttributes, + }, + SortBySeverity: { + // severity and tangential attributes... + compareBySeverity, + compareByRisk, + compareByEPSSPercentile, + // followed by package attributes... + comparePackageAttributes, + // followed by the remaining vulnerability attributes... + compareByVulnerabilityID, + }, + SortByThreat: { + // epss and tangential attributes... + compareByEPSSPercentile, + compareByRisk, + compareBySeverity, + // followed by package attributes... + comparePackageAttributes, + // followed by the remaining vulnerability attributes... + compareByVulnerabilityID, + }, + SortByRisk: { + // risk and tangential attributes... + compareByRisk, + compareBySeverity, + compareByEPSSPercentile, + // followed by package attributes... + comparePackageAttributes, + // followed by the remaining vulnerability attributes... + compareByVulnerabilityID, + }, + SortByKEV: { + compareByKEV, + // risk and tangential attributes... + compareByRisk, compareBySeverity, + compareByEPSSPercentile, + // followed by package attributes... + comparePackageAttributes, + // followed by the remaining vulnerability attributes... compareByVulnerabilityID, }, } +func compareVulnerabilityAttributes(a, b Match) int { + return combine( + compareByVulnerabilityID, + compareByRisk, + compareBySeverity, + compareByEPSSPercentile, + )(a, b) +} + +func comparePackageAttributes(a, b Match) int { + return combine( + compareByPackageName, + compareByPackageVersion, + compareByPackageType, + )(a, b) +} + +func combine(impls ...compareFunc) compareFunc { + return func(a, b Match) int { + for _, impl := range impls { + result := impl(a, b) + if result != 0 { + return result + } + } + return 0 + } +} + // SortMatches sorts matches based on a strategy name func SortMatches(matches []Match, strategyName SortStrategy) { sortWithStrategy(matches, getSortStrategy(strategyName)) @@ -53,8 +129,8 @@ func SortMatches(matches []Match, strategyName SortStrategy) { func getSortStrategy(strategyName SortStrategy) sortStrategyImpl { strategy, exists := matchSortStrategy[strategyName] if !exists { - log.WithFields("strategy", strategyName).Debugf("unknown sort strategy, falling back to default of %q", defaultSortStrategy) - strategy = matchSortStrategy[defaultSortStrategy] + log.WithFields("strategy", strategyName).Debugf("unknown sort strategy, falling back to default of %q", DefaultSortStrategy) + strategy = matchSortStrategy[DefaultSortStrategy] } return strategy } @@ -87,8 +163,22 @@ func compareByVulnerabilityID(a, b Match) int { } func compareBySeverity(a, b Match) int { - aScore := severityScore(a.Vulnerability.Severity) - bScore := severityScore(b.Vulnerability.Severity) + aScore := severityPriority(a.Vulnerability.Severity) + bScore := severityPriority(b.Vulnerability.Severity) + + switch { + case aScore < bScore: // higher severity first + return -1 + case aScore > bScore: + return 1 + default: + return 0 + } +} + +func compareByEPSSPercentile(a, b Match) int { + aScore := epssPercentile(a.Vulnerability.EPSS) + bScore := epssPercentile(b.Vulnerability.EPSS) switch { case aScore > bScore: // higher severity first @@ -142,20 +232,61 @@ func compareByPackageType(a, b Match) int { } } -// severityScore maps severity strings to numeric scores for comparison -func severityScore(severity string) int { +func compareByRisk(a, b Match) int { + aRisk := a.Vulnerability.Risk + bRisk := b.Vulnerability.Risk + + switch { + case aRisk > bRisk: + return -1 + case aRisk < bRisk: + return 1 + default: + return 0 + } +} + +func compareByKEV(a, b Match) int { + aKEV := len(a.Vulnerability.KnownExploited) + bKEV := len(b.Vulnerability.KnownExploited) + + switch { + case aKEV > bKEV: + return -1 + case aKEV < bKEV: + return 1 + default: + return 0 + } +} + +func epssPercentile(es []EPSS) float64 { + switch len(es) { + case 0: + return 0.0 + case 1: + return es[0].Percentile + } + sort.Slice(es, func(i, j int) bool { + return es[i].Percentile > es[j].Percentile + }) + return es[0].Percentile +} + +// severityPriority maps severity strings to numeric priority for comparison (the lowest value is most severe) +func severityPriority(severity string) int { switch strings.ToLower(severity) { case "critical": - return 5 + return 1 case "high": - return 4 + return 2 case "medium": return 3 case "low": - return 2 + return 4 case "negligible": - return 1 + return 5 default: - return 0 + return 100 // least severe } } diff --git a/grype/presenter/models/sort_test.go b/grype/presenter/models/sort_test.go index 478550fcab4..e86ae0c3a83 100644 --- a/grype/presenter/models/sort_test.go +++ b/grype/presenter/models/sort_test.go @@ -4,16 +4,154 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestSortMatches(t *testing.T) { - matches := []Match{ +func TestSortStrategies(t *testing.T) { + strategies := SortStrategies() + expected := []SortStrategy{ + SortByPackage, + SortBySeverity, + SortByThreat, + SortByRisk, + SortByKEV, + SortByVulnerability, + } + assert.Equal(t, expected, strategies) +} + +func TestSortStrategyString(t *testing.T) { + assert.Equal(t, "package", SortByPackage.String()) + assert.Equal(t, "severity", SortBySeverity.String()) + assert.Equal(t, "epss", SortByThreat.String()) + assert.Equal(t, "risk", SortByRisk.String()) + assert.Equal(t, "kev", SortByKEV.String()) + assert.Equal(t, "vulnerability", SortByVulnerability.String()) +} + +func TestGetSortStrategy(t *testing.T) { + tests := []struct { + name string + strategyName SortStrategy + expected bool + }{ + { + name: "Valid strategy", + strategyName: SortByPackage, + expected: true, + }, + { + name: "Invalid strategy", + strategyName: "invalid", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + strategy := getSortStrategy(tt.strategyName) + validStrategy, _ := matchSortStrategy[tt.strategyName] + + if tt.expected { + require.NotNil(t, strategy) + assert.Equal(t, validStrategy, strategy) + } else { + // Should fallback to default strategy + assert.NotNil(t, strategy) + assert.Equal(t, matchSortStrategy[DefaultSortStrategy], strategy) + } + }) + } +} + +func TestEPSSPercentile(t *testing.T) { + tests := []struct { + name string + epss []EPSS + expected float64 + }{ + { + name: "Empty slice", + epss: []EPSS{}, + expected: 0.0, + }, + { + name: "Single item", + epss: []EPSS{ + {Percentile: 0.75}, + }, + expected: 0.75, + }, + { + name: "Multiple items, already sorted", + epss: []EPSS{ + {Percentile: 0.95}, + {Percentile: 0.75}, + {Percentile: 0.50}, + }, + expected: 0.95, + }, + { + name: "Multiple items, unsorted", + epss: []EPSS{ + {Percentile: 0.50}, + {Percentile: 0.95}, + {Percentile: 0.75}, + }, + expected: 0.95, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := epssPercentile(tt.epss) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSeverityPriority(t *testing.T) { + tests := []struct { + severity string + expected int + }{ + {"critical", 1}, + {"CRITICAL", 1}, + {"high", 2}, + {"HIGH", 2}, + {"medium", 3}, + {"MEDIUM", 3}, + {"low", 4}, + {"LOW", 4}, + {"negligible", 5}, + {"NEGLIGIBLE", 5}, + {"unknown", 100}, + {"", 100}, + } + + for _, tt := range tests { + t.Run(tt.severity, func(t *testing.T) { + result := severityPriority(tt.severity) + assert.Equal(t, tt.expected, result) + }) + } +} + +func createTestMatches() []Match { + return []Match{ { + // match 0: medium severity, high risk, high EPSS, no KEV Vulnerability: Vulnerability{ VulnerabilityMetadata: VulnerabilityMetadata{ ID: "CVE-2023-1111", Severity: "medium", + EPSS: []EPSS{ + {Percentile: 0.90}, + }, + KnownExploited: []KnownExploited{}, // empty KEV }, + Risk: 75.0, }, Artifact: Package{ Name: "package-b", @@ -22,11 +160,17 @@ func TestSortMatches(t *testing.T) { }, }, { + // match 1: critical severity, medium risk, medium EPSS, no KEV Vulnerability: Vulnerability{ VulnerabilityMetadata: VulnerabilityMetadata{ ID: "CVE-2023-2222", Severity: "critical", + EPSS: []EPSS{ + {Percentile: 0.70}, + }, + KnownExploited: []KnownExploited{}, // empty KEV }, + Risk: 50.0, }, Artifact: Package{ Name: "package-a", @@ -35,11 +179,19 @@ func TestSortMatches(t *testing.T) { }, }, { + // match 2: high severity, low risk, low EPSS, has KEV Vulnerability: Vulnerability{ VulnerabilityMetadata: VulnerabilityMetadata{ ID: "CVE-2023-3333", Severity: "high", + EPSS: []EPSS{ + {Percentile: 0.30}, + }, + KnownExploited: []KnownExploited{ + {CVE: "CVE-2023-3333", KnownRansomwareCampaignUse: "No"}, + }, // has KEV }, + Risk: 25.0, }, Artifact: Package{ Name: "package-a", @@ -48,11 +200,17 @@ func TestSortMatches(t *testing.T) { }, }, { + // match 3: low severity, very low risk, very low EPSS, no KEV Vulnerability: Vulnerability{ VulnerabilityMetadata: VulnerabilityMetadata{ ID: "CVE-2023-4444", Severity: "low", + EPSS: []EPSS{ + {Percentile: 0.10}, + }, + KnownExploited: []KnownExploited{}, // empty KEV }, + Risk: 10.0, }, Artifact: Package{ Name: "package-c", @@ -61,11 +219,20 @@ func TestSortMatches(t *testing.T) { }, }, { + // match 4: critical severity, very low risk, medium EPSS, has KEV with ransomware Vulnerability: Vulnerability{ VulnerabilityMetadata: VulnerabilityMetadata{ ID: "CVE-2023-5555", Severity: "critical", + EPSS: []EPSS{ + {Percentile: 0.50}, + }, + KnownExploited: []KnownExploited{ + {CVE: "CVE-2023-5555", KnownRansomwareCampaignUse: "Known"}, + {CVE: "CVE-2023-5555", KnownRansomwareCampaignUse: "Known", Product: "Different Product"}, + }, // has multiple KEV entries with ransomware }, + Risk: 5.0, }, Artifact: Package{ Name: "package-a", @@ -74,122 +241,262 @@ func TestSortMatches(t *testing.T) { }, }, } +} - t.Run("SortByPackage", func(t *testing.T) { - testMatches := deepCopyMatches(matches) - SortMatches(testMatches, SortByPackage) - - expected := []Match{ - // package-a with 1.0.0 version, docker type first (alphabetical) - matches[4], // package-a, 1.0.0, docker, critical - matches[2], // package-a, 1.0.0, npm, high - matches[1], // package-a, 2.0.0, docker, critical - matches[0], // package-b, 1.2.0, npm, medium - matches[3], // package-c, 3.1.0, gem, low - } +func TestAllSortStrategies(t *testing.T) { + matches := createTestMatches() - if diff := cmp.Diff(expected, testMatches); diff != "" { - t.Errorf("SortByPackage mismatch (-want +got):\n%s", diff) - } - }) + tests := []struct { + strategy SortStrategy + expected []int // indexes into the original matches slice + }{ + { + strategy: SortByPackage, + expected: []int{4, 2, 1, 0, 3}, // sorted by package name, version, type + }, + { + strategy: SortByVulnerability, + expected: []int{0, 1, 2, 3, 4}, // sorted by vulnerability ID + }, + { + strategy: SortBySeverity, + expected: []int{1, 4, 2, 0, 3}, // sorted by severity: critical, critical, high, medium, low + }, + { + strategy: SortByThreat, + expected: []int{0, 1, 4, 2, 3}, // sorted by EPSS percentile: 0.90, 0.70, 0.50, 0.30, 0.10 + }, + { + strategy: SortByRisk, + expected: []int{0, 1, 2, 3, 4}, // sorted by risk: 75.0, 50.0, 25.0, 10.0, 5.0 + }, + { + strategy: SortByKEV, + expected: []int{4, 2, 0, 1, 3}, // sorted by KEV count: 2, 1, 0, 0, 0 (with ties broken by risk) + }, + } - t.Run("UnknownStrategy", func(t *testing.T) { - testMatches := deepCopyMatches(matches) - // should use default (package) strategy for unknown strategy names - SortMatches(testMatches, "unknown") - - expected := []Match{ - matches[4], // package-a, 1.0.0, docker - matches[2], // package-a, 1.0.0, npm - matches[1], // package-a, 2.0.0, docker - matches[0], // package-b, 1.2.0, npm - matches[3], // package-c, 3.1.0, gem - } + for _, tt := range tests { + t.Run(string(tt.strategy), func(t *testing.T) { + testMatches := deepCopyMatches(matches) + SortMatches(testMatches, tt.strategy) - if diff := cmp.Diff(expected, testMatches); diff != "" { - t.Errorf("Unknown strategy mismatch (-want +got):\n%s", diff) - } - }) + expected := make([]Match, len(tt.expected)) + for i, idx := range tt.expected { + expected[i] = matches[idx] + } + + if diff := cmp.Diff(expected, testMatches); diff != "" { + t.Errorf("%s mismatch (-want +got):\n%s", tt.strategy, diff) + } + }) + } } -func TestEdgeCases(t *testing.T) { - t.Run("EmptySlice", func(t *testing.T) { - matches := []Match{} - // should not panic on empty slice - SortMatches(matches, SortByPackage) +func TestIndividualCompareFunctions(t *testing.T) { + ms := createTestMatches() + m0 := ms[0] // medium severity, high risk, high EPSS, no KEV + m1 := ms[1] // critical severity, medium risk, medium EPSS, no KEV + m2 := ms[2] // high severity, low risk, low EPSS, has KEV + m3 := ms[3] // low severity, very low risk, very low EPSS, no KEV + m4 := ms[4] // critical severity, very low risk, medium EPSS, has KEV with ransomware - expected := []Match{} - if diff := cmp.Diff(expected, matches); diff != "" { - t.Errorf("Empty slice mismatch (-want +got):\n%s", diff) + tests := []struct { + name string + compareFunc compareFunc + pairs []struct { + a, b Match + expected int } + }{ + { + name: "compareByVulnerabilityID", + compareFunc: compareByVulnerabilityID, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m1, -1}, // CVE-2023-1111 < CVE-2023-2222 + {m1, m0, 1}, // CVE-2023-2222 > CVE-2023-1111 + {m0, m0, 0}, // Same ID + }, + }, + { + name: "compareBySeverity", + compareFunc: compareBySeverity, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m1, 1}, // medium > critical + {m1, m0, -1}, // critical < medium + {m1, m4, 0}, // both critical + {m2, m3, -1}, // high < low + }, + }, + { + name: "compareByEPSSPercentile", + compareFunc: compareByEPSSPercentile, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m1, -1}, // 0.90 > 0.70 + {m1, m0, 1}, // 0.70 < 0.90 + {m1, m4, -1}, // 0.70 > 0.50 + {m4, m1, 1}, // 0.50 < 0.70 + }, + }, + { + name: "compareByPackageName", + compareFunc: compareByPackageName, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m1, 1}, // package-b > package-a + {m1, m0, -1}, // package-a < package-b + {m1, m2, 0}, // both package-a + }, + }, + { + name: "compareByPackageVersion", + compareFunc: compareByPackageVersion, + pairs: []struct { + a, b Match + expected int + }{ + {m1, m2, 1}, // 2.0.0 > 1.0.0 + {m2, m1, -1}, // 1.0.0 < 2.0.0 + {m2, m4, 0}, // both 1.0.0 + }, + }, + { + name: "compareByPackageType", + compareFunc: compareByPackageType, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m1, 1}, // npm > docker + {m1, m0, -1}, // docker < npm + {m0, m2, 0}, // both npm + }, + }, + { + name: "compareByRisk", + compareFunc: compareByRisk, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m1, -1}, // 75.0 > 50.0 + {m1, m0, 1}, // 50.0 < 75.0 + {m3, m4, -1}, // 10.0 > 5.0 + }, + }, + { + name: "compareByKEV", + compareFunc: compareByKEV, + pairs: []struct { + a, b Match + expected int + }{ + {m0, m2, 1}, // 0 < 1 KEV entry + {m2, m0, -1}, // 1 > 0 KEV entry + {m2, m4, 1}, // 1 < 2 KEV entries + {m4, m2, -1}, // 2 > 1 KEV entry + {m0, m1, 0}, // both 0 KEV entries + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, pair := range tt.pairs { + result := tt.compareFunc(pair.a, pair.b) + assert.Equal(t, pair.expected, result, "comparing %v and %v", pair.a.Vulnerability.ID, pair.b.Vulnerability.ID) + } + }) + } +} + +func TestCombinedCompareFunctions(t *testing.T) { + ms := createTestMatches() + m0 := ms[0] // medium severity, high risk, high EPSS, no KEV, package-b + m1 := ms[1] // critical severity, medium risk, medium EPSS, no KEV, package-a + m2 := ms[2] // high severity, low risk, low EPSS, has KEV, package-a + + t.Run("compareVulnerabilityAttributes", func(t *testing.T) { + result := compareVulnerabilityAttributes(m0, m1) + assert.Equal(t, -1, result, "CVE-2023-1111 should come before CVE-2023-2222") + + result = compareVulnerabilityAttributes(m1, m0) + assert.Equal(t, 1, result, "CVE-2023-2222 should come after CVE-2023-1111") }) - t.Run("SingleItem", func(t *testing.T) { - matches := []Match{ - { - Vulnerability: Vulnerability{ - VulnerabilityMetadata: VulnerabilityMetadata{ - ID: "CVE-2023-1111", - Severity: "medium", - }, - }, - Artifact: Package{ - Name: "package-a", - Version: "1.0.0", - }, - }, - } - expected := deepCopyMatches(matches) - // should not change anything with a single item - SortMatches(matches, SortByPackage) + t.Run("comparePackageAttributes", func(t *testing.T) { + result := comparePackageAttributes(m0, m1) + assert.Equal(t, 1, result, "package-b should come after package-a") - if diff := cmp.Diff(expected, matches); diff != "" { - t.Errorf("Single item mismatch (-want +got):\n%s", diff) - } + result = comparePackageAttributes(m1, m2) + assert.Equal(t, 1, result, "package-a 2.0.0 should come after package-a 1.0.0") + + result = comparePackageAttributes(m1, m1) + assert.Equal(t, 0, result, "same package should be equal") }) - t.Run("NilValues", func(t *testing.T) { - matches := []Match{ - { - Vulnerability: Vulnerability{ - VulnerabilityMetadata: VulnerabilityMetadata{ - ID: "CVE-2023-1111", - Severity: "", - }, - }, - Artifact: Package{ - Name: "", - Version: "", - }, - }, - { - Vulnerability: Vulnerability{ - VulnerabilityMetadata: VulnerabilityMetadata{ - ID: "CVE-2023-2222", - Severity: "low", - }, - }, - Artifact: Package{ - Name: "package-a", - Version: "1.0.0", - }, - }, - } + t.Run("combine function", func(t *testing.T) { + // create a combined function that first compares by severity, then by risk if severity is equal + combined := combine(compareBySeverity, compareByRisk) - expected := []Match{ - matches[0], // empty name comes first alphabetically - matches[1], // "package-a" - } + result := combined(m0, m1) + assert.Equal(t, 1, result, "medium should come after critical regardless of risk") - // should handle empty strings properly - SortMatches(matches, SortByPackage) + // create two matches with the same severity but different risk + m5 := m1 // critical severity, risk 50.0 + m6 := m1 + m6.Vulnerability.Risk = 60.0 // critical severity, risk 60.0 - if diff := cmp.Diff(expected, matches); diff != "" { - t.Errorf("Nil values mismatch (-want +got):\n%s", diff) - } + result = combined(m5, m6) + assert.Equal(t, 1, result, "with equal severity, lower risk (50.0) should come after higher risk (60.0)") + + result = combined(m6, m5) + assert.Equal(t, -1, result, "with equal severity, higher risk (60.0) should come before lower risk (50.0)") }) } +func TestSortWithStrategy(t *testing.T) { + matches := createTestMatches() + + // create a custom strategy that sorts only by vulnerability ID + customStrategy := sortStrategyImpl{compareByVulnerabilityID} + + expected := []Match{ + matches[0], // CVE-2023-1111 + matches[1], // CVE-2023-2222 + matches[2], // CVE-2023-3333 + matches[3], // CVE-2023-4444 + matches[4], // CVE-2023-5555 + } + + testMatches := deepCopyMatches(matches) + sortWithStrategy(testMatches, customStrategy) + + if diff := cmp.Diff(expected, testMatches); diff != "" { + t.Errorf("sortWithStrategy mismatch (-want +got):\n%s", diff) + } + + // create an empty strategy (should not change the order) + emptyStrategy := sortStrategyImpl{} + originalMatches := deepCopyMatches(matches) + sortWithStrategy(originalMatches, emptyStrategy) + + if diff := cmp.Diff(matches, originalMatches); diff != "" { + t.Errorf("Empty strategy should not change order (-original +after):\n%s", diff) + } +} + func deepCopyMatches(matches []Match) []Match { result := make([]Match, len(matches)) copy(result, matches) diff --git a/grype/presenter/models/source.go b/grype/presenter/models/source.go index 6bbb1fe6158..f21c0674f2f 100644 --- a/grype/presenter/models/source.go +++ b/grype/presenter/models/source.go @@ -15,9 +15,9 @@ type source struct { // newSource creates a new source object to be represented into JSON. func newSource(src syftSource.Description) (source, error) { switch m := src.Metadata.(type) { - case pkg.PURLFileMetadata: + case pkg.SBOMFileMetadata: return source{ - Type: "purl-file", + Type: "sbom-file", Target: m.Path, }, nil case pkg.PURLLiteralMetadata: diff --git a/grype/presenter/models/source_test.go b/grype/presenter/models/source_test.go index 325b772a4e8..851fdf9a076 100644 --- a/grype/presenter/models/source_test.go +++ b/grype/presenter/models/source_test.go @@ -65,12 +65,12 @@ func TestNewSource(t *testing.T) { { name: "purl-file", metadata: syftSource.Description{ - Metadata: pkg.PURLFileMetadata{ + Metadata: pkg.SBOMFileMetadata{ Path: "/path/to/purls.txt", }, }, expected: source{ - Type: "purl-file", + Type: "sbom-file", Target: "/path/to/purls.txt", }, }, diff --git a/grype/presenter/models/vulnerability.go b/grype/presenter/models/vulnerability.go index 391a9f636de..38216ce37cb 100644 --- a/grype/presenter/models/vulnerability.go +++ b/grype/presenter/models/vulnerability.go @@ -12,6 +12,7 @@ type Vulnerability struct { VulnerabilityMetadata Fix Fix `json:"fix"` Advisories []Advisory `json:"advisories"` + Risk float64 `json:"risk"` } type Fix struct { @@ -52,6 +53,7 @@ func NewVulnerability(vuln vulnerability.Vulnerability, metadata *vulnerability. State: string(vuln.Fix.State), }, Advisories: advisories, + Risk: metadata.RiskScore(), } } func sortVersions(fixedVersions []string, format version.Format) []string { diff --git a/grype/presenter/models/vulnerability_metadata.go b/grype/presenter/models/vulnerability_metadata.go index 98910f7955d..d02e6243227 100644 --- a/grype/presenter/models/vulnerability_metadata.go +++ b/grype/presenter/models/vulnerability_metadata.go @@ -71,9 +71,9 @@ func toKnownExploited(knownExploited []vulnerability.KnownExploited) []KnownExpl CVE: ke.CVE, VendorProject: ke.VendorProject, Product: ke.Product, - DateAdded: ke.DateAdded.Format(time.DateOnly), + DateAdded: formatDate(ke.DateAdded), RequiredAction: ke.RequiredAction, - DueDate: ke.DueDate.Format(time.DateOnly), + DueDate: formatDate(ke.DueDate), KnownRansomwareCampaignUse: ke.KnownRansomwareCampaignUse, Notes: ke.Notes, URLs: ke.URLs, @@ -83,6 +83,13 @@ func toKnownExploited(knownExploited []vulnerability.KnownExploited) []KnownExpl return result } +func formatDate(t *time.Time) string { + if t == nil { + return "" + } + return t.Format(time.DateOnly) +} + func toEPSS(epss []vulnerability.EPSS) []EPSS { result := make([]EPSS, len(epss)) for idx, e := range epss { diff --git a/grype/presenter/sarif/presenter.go b/grype/presenter/sarif/presenter.go index ac8d0b2843c..4bd6a3cc765 100644 --- a/grype/presenter/sarif/presenter.go +++ b/grype/presenter/sarif/presenter.go @@ -407,8 +407,8 @@ func (p Presenter) resultMessage(m models.Match) sarif.Message { src = fmt.Sprintf("at: %s", path) case pkg.PURLLiteralMetadata: src = fmt.Sprintf("from purl literal %q", meta.PURL) - case pkg.PURLFileMetadata: - src = fmt.Sprintf("from purl file %s", meta.Path) + case pkg.SBOMFileMetadata: + src = fmt.Sprintf("from SBOM file %s", meta.Path) } message := fmt.Sprintf("A %s vulnerability in %s package: %s, version %s was found %s", severityText(m), m.Artifact.Type, m.Artifact.Name, m.Artifact.Version, src) diff --git a/grype/presenter/sarif/presenter_test.go b/grype/presenter/sarif/presenter_test.go index cec3081863f..2b30cfec612 100644 --- a/grype/presenter/sarif/presenter_test.go +++ b/grype/presenter/sarif/presenter_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "testing" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,7 +18,7 @@ import ( "github.com/anchore/syft/syft/source/directorysource" ) -var updateSnapshot = flag.Bool("update-sarif", false, "update .golden files for sarif presenters") +var updateSnapshot = flag.Bool("update", false, "update .golden files for sarif presenters") var validatorImage = "ghcr.io/anchore/sarif-validator:0.1.0@sha256:a0729d695e023740f5df6bcb50d134e88149bea59c63a896a204e88f62b564c6" func TestSarifPresenter(t *testing.T) { @@ -57,8 +58,8 @@ func TestSarifPresenter(t *testing.T) { actual = internal.Redact(actual) expected = internal.Redact(expected) - if !bytes.Equal(expected, actual) { - assert.JSONEq(t, string(expected), string(actual)) + if d := cmp.Diff(string(expected), string(actual)); d != "" { + t.Fatalf("(-want +got):\n%s", d) } }) } diff --git a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden index fa07c7fd78a..91900ee484b 100644 --- a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden +++ b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden @@ -16,15 +16,15 @@ "text": "CVE-1999-0001 low vulnerability for package-1 package" }, "fullDescription": { - "text": "1999-01 description" + "text": "Version 1.1.1 is affected with an available fix in versions 1.2.1,2.1.3,3.4.0" }, "helpUri": "https://github.com/anchore/grype", "help": { - "text": "Vulnerability CVE-1999-0001\nSeverity: low\nPackage: package-1\nVersion: 1.1.1\nFix Version: 1.2.1,2.1.3,3.4.0\nType: rpm\nLocation: /some/path/somefile-1.txt\nData Namespace: source-1\nLink: CVE-1999-0001", - "markdown": "**Vulnerability CVE-1999-0001**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| low | package-1 | 1.1.1 | 1.2.1,2.1.3,3.4.0 | rpm | /some/path/somefile-1.txt | source-1 | CVE-1999-0001 |\n" + "text": "Vulnerability CVE-1999-0001\nSeverity: low\nPackage: package-1\nVersion: 1.1.1\nFix Version: 1.2.1,2.1.3,3.4.0\nType: rpm\nLocation: /some/path/somefile-1.txt\nData Namespace: \nLink: CVE-1999-0001", + "markdown": "**Vulnerability CVE-1999-0001**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| low | package-1 | 1.1.1 | 1.2.1,2.1.3,3.4.0 | rpm | /some/path/somefile-1.txt | | CVE-1999-0001 |\n" }, "properties": { - "security-severity": "4.0" + "security-severity": "8.2" } }, { @@ -34,18 +34,18 @@ "text": "CVE-1999-0002 critical vulnerability for package-2 package" }, "fullDescription": { - "text": "1999-02 description" + "text": "Version 2.2.2 is affected with no fixes reported yet." }, "helpUri": "https://github.com/anchore/grype", "help": { - "text": "Vulnerability CVE-1999-0002\nSeverity: critical\nPackage: package-2\nVersion: 2.2.2\nFix Version: \nType: deb\nLocation: /some/path/somefile-2.txt\nData Namespace: source-2\nLink: CVE-1999-0002", - "markdown": "**Vulnerability CVE-1999-0002**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| critical | package-2 | 2.2.2 | | deb | /some/path/somefile-2.txt | source-2 | CVE-1999-0002 |\n" + "text": "Vulnerability CVE-1999-0002\nSeverity: critical\nPackage: package-2\nVersion: 2.2.2\nFix Version: \nType: deb\nLocation: /some/path/somefile-2.txt\nData Namespace: \nLink: CVE-1999-0002", + "markdown": "**Vulnerability CVE-1999-0002**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| critical | package-2 | 2.2.2 | | deb | /some/path/somefile-2.txt | | CVE-1999-0002 |\n" }, "properties": { "purls": [ "pkg:deb/package-2@2.2.2" ], - "security-severity": "1.0" + "security-severity": "8.5" } } ] diff --git a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden index 2de58e3ef4d..b9d33518d2b 100644 --- a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden +++ b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden @@ -16,15 +16,15 @@ "text": "CVE-1999-0001 low vulnerability for package-1 package" }, "fullDescription": { - "text": "1999-01 description" + "text": "Version 1.1.1 is affected with an available fix in versions 1.2.1,2.1.3,3.4.0" }, "helpUri": "https://github.com/anchore/grype", "help": { - "text": "Vulnerability CVE-1999-0001\nSeverity: low\nPackage: package-1\nVersion: 1.1.1\nFix Version: 1.2.1,2.1.3,3.4.0\nType: rpm\nLocation: somefile-1.txt\nData Namespace: source-1\nLink: CVE-1999-0001", - "markdown": "**Vulnerability CVE-1999-0001**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| low | package-1 | 1.1.1 | 1.2.1,2.1.3,3.4.0 | rpm | somefile-1.txt | source-1 | CVE-1999-0001 |\n" + "text": "Vulnerability CVE-1999-0001\nSeverity: low\nPackage: package-1\nVersion: 1.1.1\nFix Version: 1.2.1,2.1.3,3.4.0\nType: rpm\nLocation: somefile-1.txt\nData Namespace: \nLink: CVE-1999-0001", + "markdown": "**Vulnerability CVE-1999-0001**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| low | package-1 | 1.1.1 | 1.2.1,2.1.3,3.4.0 | rpm | somefile-1.txt | | CVE-1999-0001 |\n" }, "properties": { - "security-severity": "4.0" + "security-severity": "8.2" } }, { @@ -34,18 +34,18 @@ "text": "CVE-1999-0002 critical vulnerability for package-2 package" }, "fullDescription": { - "text": "1999-02 description" + "text": "Version 2.2.2 is affected with no fixes reported yet." }, "helpUri": "https://github.com/anchore/grype", "help": { - "text": "Vulnerability CVE-1999-0002\nSeverity: critical\nPackage: package-2\nVersion: 2.2.2\nFix Version: \nType: deb\nLocation: somefile-2.txt\nData Namespace: source-2\nLink: CVE-1999-0002", - "markdown": "**Vulnerability CVE-1999-0002**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| critical | package-2 | 2.2.2 | | deb | somefile-2.txt | source-2 | CVE-1999-0002 |\n" + "text": "Vulnerability CVE-1999-0002\nSeverity: critical\nPackage: package-2\nVersion: 2.2.2\nFix Version: \nType: deb\nLocation: somefile-2.txt\nData Namespace: \nLink: CVE-1999-0002", + "markdown": "**Vulnerability CVE-1999-0002**\n| Severity | Package | Version | Fix Version | Type | Location | Data Namespace | Link |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| critical | package-2 | 2.2.2 | | deb | somefile-2.txt | | CVE-1999-0002 |\n" }, "properties": { "purls": [ "pkg:deb/package-2@2.2.2" ], - "security-severity": "1.0" + "security-severity": "8.5" } } ] diff --git a/grype/presenter/table/__snapshots__/presenter_test.snap b/grype/presenter/table/__snapshots__/presenter_test.snap index 7ea321677bd..b3aa7fcbd1c 100755 --- a/grype/presenter/table/__snapshots__/presenter_test.snap +++ b/grype/presenter/table/__snapshots__/presenter_test.snap @@ -1,15 +1,15 @@ [TestTablePresenter/no_color - 1] -NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY -package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low -package-2 2.2.2 deb CVE-1999-0002 Critical +NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK +package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low 42.00 1.7 +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev) --- [TestTablePresenter/with_color - 1] -NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY -package-1 1.1.1 1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low -package-2 2.2.2 deb CVE-1999-0002 Critical +NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK +package-1 1.1.1 1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low 42.00 1.7 +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 KEV --- @@ -19,35 +19,35 @@ No vulnerabilities found --- [TestHidesIgnoredMatches - 1] -NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY -package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low -package-2 2.2.2 deb CVE-1999-0002 Critical +NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK +package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low 42.00 1.7 +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev) --- [TestDisplaysIgnoredMatches - 1] -NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY -package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low -package-2 2.2.2 deb CVE-1999-0002 Critical -package-2 2.2.2 deb CVE-1999-0001 Low (suppressed) -package-2 2.2.2 deb CVE-1999-0002 Critical (suppressed) -package-2 2.2.2 deb CVE-1999-0004 Critical (suppressed by VEX) +NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK +package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low 42.00 1.7 +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev) +package-2 2.2.2 deb CVE-1999-0001 Low 42.00 1.7 (suppressed) +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev, suppressed) +package-2 2.2.2 deb CVE-1999-0004 High 75.00 2.2 (suppressed by VEX) --- [TestDisplaysDistro - 1] -NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY -package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low (ubuntu:2.5) -package-2 2.2.2 deb CVE-1999-0002 Critical (ubuntu:3.5) +NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK +package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low 42.00 1.7 (ubuntu:2.5) +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev, ubuntu:3.5) --- [TestDisplaysIgnoredMatchesAndDistro - 1] -NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY -package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low (ubuntu:2.5) -package-2 2.2.2 deb CVE-1999-0002 Critical (ubuntu:3.5) -package-2 2.2.2 deb CVE-1999-0001 Low (ubuntu:2.5, suppressed) -package-2 2.2.2 deb CVE-1999-0002 Critical (ubuntu:3.5, suppressed) -package-2 2.2.2 deb CVE-1999-0004 Critical (suppressed by VEX) +NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY EPSS% RISK +package-1 1.1.1 *1.2.1, 2.1.3, 3.4.0 rpm CVE-1999-0001 Low 42.00 1.7 (ubuntu:2.5) +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev, ubuntu:3.5) +package-2 2.2.2 deb CVE-1999-0001 Low 42.00 1.7 (ubuntu:2.5, suppressed) +package-2 2.2.2 deb CVE-1999-0002 Critical 53.00 96.3 (kev, ubuntu:3.5, suppressed) +package-2 2.2.2 deb CVE-1999-0004 High 75.00 2.2 (suppressed by VEX) --- diff --git a/grype/presenter/table/presenter.go b/grype/presenter/table/presenter.go index f936861d893..874a51a72c6 100644 --- a/grype/presenter/table/presenter.go +++ b/grype/presenter/table/presenter.go @@ -26,6 +26,14 @@ type Presenter struct { withColor bool recommendedFixStyle lipgloss.Style + kevStyle lipgloss.Style + criticalStyle lipgloss.Style + highStyle lipgloss.Style + mediumStyle lipgloss.Style + lowStyle lipgloss.Style + negligibleStyle lipgloss.Style + auxiliaryStyle lipgloss.Style + unknownStyle lipgloss.Style } type rows []row @@ -37,21 +45,48 @@ type row struct { PackageType string VulnerabilityID string Severity string + EPSS epss + Risk string Annotation string } +type epss struct { + Score float64 + Percentile float64 +} + +func (e epss) String() string { + percentile := e.Percentile * 100 + switch { + case percentile == 0: + return " N/A" + case percentile < 0.1: + return "< 0.1%" + } + return fmt.Sprintf("%5.2f", percentile) +} + // NewPresenter is a *Presenter constructor func NewPresenter(pb models.PresenterConfig, showSuppressed bool) *Presenter { withColor := supportsColor() fixStyle := lipgloss.NewStyle().Border(lipgloss.Border{Left: "*"}, false, false, false, true) if withColor { - fixStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true).Underline(true) + fixStyle = lipgloss.NewStyle() } return &Presenter{ document: pb.Document, showSuppressed: showSuppressed, withColor: withColor, recommendedFixStyle: fixStyle, + negligibleStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), // dark gray + lowStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("36")), // cyan/teal + mediumStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("178")), // gold/amber + highStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("203")), // salmon/light red + criticalStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("198")).Bold(true), // bright pink + kevStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("198")).Reverse(true).Bold(true), // white on bright pink + //kevStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("198")), // bright pink + auxiliaryStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), // dark gray + unknownStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("12")), // light blue } } @@ -65,7 +100,7 @@ func (p *Presenter) Present(output io.Writer) error { } table := tablewriter.NewWriter(output) - table.SetHeader([]string{"Name", "Installed", "Fixed-In", "Type", "Vulnerability", "Severity"}) + table.SetHeader([]string{"Name", "Installed", "Fixed-In", "Type", "Vulnerability", "Severity", "EPSS%", "Risk"}) table.SetAutoWrapText(false) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) @@ -79,22 +114,7 @@ func (p *Presenter) Present(output io.Writer) error { table.SetTablePadding(" ") table.SetNoWhiteSpace(true) - if p.withColor { - for _, row := range rs.Deduplicate() { - severityColor := getSeverityColor(row.Severity) - table.Rich(row.Columns(), []tablewriter.Colors{ - {}, // name - {}, // version - {}, // fix - {}, // package type - {}, // vulnerability ID - severityColor, // severity - annotationColor, // annotations - }) - } - } else { - table.AppendBulk(rs.Render()) - } + table.AppendBulk(rs.Render()) table.Render() @@ -143,22 +163,34 @@ func supportsColor() bool { return lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("") != "" } -func (p *Presenter) newRow(m models.Match, severitySuffix string, showDistro bool) row { +func (p *Presenter) newRow(m models.Match, extraAnnotation string, showDistro bool) row { var annotations []string if showDistro { if d, err := distro.FromString(m.Vulnerability.Namespace); err == nil { - annotations = append(annotations, fmt.Sprintf("%s:%s", d.DistroType(), d.Version())) + annotations = append(annotations, p.auxiliaryStyle.Render(fmt.Sprintf("%s:%s", d.DistroType(), d.Version()))) } } - if severitySuffix != "" { - annotations = append(annotations, severitySuffix) + if extraAnnotation != "" { + annotations = append(annotations, p.auxiliaryStyle.Render(extraAnnotation)) + } + + var kev, annotation string + if len(m.Vulnerability.KnownExploited) > 0 { + if p.withColor { + kev = p.kevStyle.Reverse(false).Render("") + p.kevStyle.Render("KEV") + p.kevStyle.Reverse(false).Render("") // ⚡❋◆◉፨⿻⨳✖• + } else { + annotations = append([]string{"kev"}, annotations...) + } } - annotation := "" if len(annotations) > 0 { - annotation = fmt.Sprintf("(%s)", strings.Join(annotations, ", ")) + annotation = p.auxiliaryStyle.Render("(") + strings.Join(annotations, p.auxiliaryStyle.Render(", ")) + p.auxiliaryStyle.Render(")") + } + + if kev != "" { + annotation = kev + " " + annotation } return row{ @@ -167,12 +199,58 @@ func (p *Presenter) newRow(m models.Match, severitySuffix string, showDistro boo Fix: p.formatFix(m), PackageType: string(m.Artifact.Type), VulnerabilityID: m.Vulnerability.ID, - Severity: m.Vulnerability.Severity, + Severity: p.formatSeverity(m.Vulnerability.Severity), + EPSS: newEPSS(m.Vulnerability.EPSS), + Risk: p.formatRisk(m.Vulnerability.Risk), Annotation: annotation, } } +func newEPSS(es []models.EPSS) epss { + if len(es) == 0 { + return epss{} + } + return epss{ + Score: es[0].EPSS, + Percentile: es[0].Percentile, + } +} + +func (p *Presenter) formatSeverity(severity string) string { + var severityStyle *lipgloss.Style + switch strings.ToLower(severity) { + case "critical": + severityStyle = &p.criticalStyle + case "high": + severityStyle = &p.highStyle + case "medium": + severityStyle = &p.mediumStyle + case "low": + severityStyle = &p.lowStyle + case "negligible": + severityStyle = &p.negligibleStyle + } + + if severityStyle == nil { + severityStyle = &p.unknownStyle + } + + return severityStyle.Render(severity) +} + +func (p *Presenter) formatRisk(risk float64) string { + // TODO: add color to risk? + switch { + case risk == 0: + return " N/A" + case risk < 0.1: + return "< 0.1" + } + return fmt.Sprintf("%5.1f", risk) +} + func (p *Presenter) formatFix(m models.Match) string { + // adjust the model fix state values for better presentation switch m.Vulnerability.Fix.State { case vulnerability.FixStateWontFix.String(): return "(won't fix)" @@ -180,6 +258,19 @@ func (p *Presenter) formatFix(m models.Match) string { return "" } + // do our best to summarize the fixed versions, de-epmhasize non-recommended versions + // also, since there is not a lot of screen real estate, we will truncate the list of fixed versions + // to ~30 characters (or so) to avoid wrapping. + return p.applyTruncation( + p.formatVersionsToDisplay( + m, + getRecommendedVersions(m), + ), + m.Vulnerability.Fix.Versions, + ) +} + +func getRecommendedVersions(m models.Match) *strset.Set { recommended := strset.New() for _, d := range m.MatchDetails { if d.Fix == nil { @@ -189,25 +280,72 @@ func (p *Presenter) formatFix(m models.Match) string { recommended.Add(d.Fix.SuggestedVersion) } } + return recommended +} - var vers []string +const maxVersionFieldLength = 30 + +func (p *Presenter) formatVersionsToDisplay(m models.Match, recommendedVersions *strset.Set) []string { hasMultipleVersions := len(m.Vulnerability.Fix.Versions) > 1 + shouldHighlightRecommended := hasMultipleVersions && recommendedVersions.Size() > 0 + + var currentCharacterCount int + added := strset.New() + var vers []string + for _, v := range m.Vulnerability.Fix.Versions { - if hasMultipleVersions && recommended.Has(v) { - vers = append(vers, p.recommendedFixStyle.Render(v)) - continue + if added.Has(v) { + continue // skip duplicates + } + + if shouldHighlightRecommended { + if recommendedVersions.Has(v) { + // recommended versions always get added + added.Add(v) + currentCharacterCount += len(v) + vers = append(vers, p.recommendedFixStyle.Render(v)) + continue + } + + // skip not-necessarily-recommended versions if we're running out of space + if currentCharacterCount+len(v) > maxVersionFieldLength { + continue + } + + // add not-necessarily-recommended versions with auxiliary styling + currentCharacterCount += len(v) + added.Add(v) + vers = append(vers, p.auxiliaryStyle.Render(v)) + } else { + // when not prioritizing, add all versions + added.Add(v) + vers = append(vers, v) } - vers = append(vers, v) } - return strings.Join(vers, ", ") + return vers +} + +func (p *Presenter) applyTruncation(formattedVersions []string, allVersions []string) string { + finalVersions := strings.Join(formattedVersions, p.auxiliaryStyle.Render(", ")) + + var characterCount int + for _, v := range allVersions { + characterCount += len(v) + } + + if characterCount > maxVersionFieldLength && len(allVersions) > 1 { + finalVersions += p.auxiliaryStyle.Render(", ...") + } + + return finalVersions } func (r row) Columns() []string { if r.Annotation != "" { - return []string{r.Name, r.Version, r.Fix, r.PackageType, r.VulnerabilityID, r.Severity, r.Annotation} + return []string{r.Name, r.Version, r.Fix, r.PackageType, r.VulnerabilityID, r.Severity, r.EPSS.String(), r.Risk, r.Annotation} } - return []string{r.Name, r.Version, r.Fix, r.PackageType, r.VulnerabilityID, r.Severity} + return []string{r.Name, r.Version, r.Fix, r.PackageType, r.VulnerabilityID, r.Severity, r.EPSS.String(), r.Risk} } func (r row) String() string { @@ -242,25 +380,3 @@ func (rs rows) Deduplicate() []row { // render final columns return deduped } - -func getSeverityColor(severity string) tablewriter.Colors { - severityFontType, severityColor := tablewriter.Normal, tablewriter.Normal - - switch strings.ToLower(severity) { - case "critical": - severityFontType = tablewriter.Bold - severityColor = tablewriter.FgRedColor - case "high": - severityColor = tablewriter.FgRedColor - case "medium": - severityColor = tablewriter.FgYellowColor - case "low": - severityColor = tablewriter.FgGreenColor - case "negligible": - severityColor = tablewriter.FgBlueColor - } - - return tablewriter.Colors{severityFontType, severityColor} -} - -var annotationColor = tablewriter.Colors{tablewriter.FgWhiteColor} diff --git a/grype/presenter/table/presenter_test.go b/grype/presenter/table/presenter_test.go index 2f7394b18f8..24bd168611a 100644 --- a/grype/presenter/table/presenter_test.go +++ b/grype/presenter/table/presenter_test.go @@ -33,18 +33,26 @@ func TestCreateRow(t *testing.T) { Versions: []string{"1.0.2", "2.0.1", "3.0.4"}, State: vulnerability.FixStateFixed.String(), }, + Risk: 87.2, VulnerabilityMetadata: models.VulnerabilityMetadata{ ID: "CVE-1999-0001", Namespace: "source-1", Description: "1999-01 description", - Severity: "Low", + Severity: "Medium", Cvss: []models.Cvss{ { Metrics: models.CvssMetrics{ - BaseScore: 4, + BaseScore: 7, }, - Vector: "another vector", - Version: "3.0", + Vector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:H", + Version: "3.1", + }, + }, + EPSS: []models.EPSS{ + { + CVE: "CVE-1999-0001", + EPSS: 0.3, + Percentile: 0.5, }, }, }, @@ -60,30 +68,43 @@ func TestCreateRow(t *testing.T) { }, }, } + + matchWithKev := match1 + matchWithKev.Vulnerability.KnownExploited = append(matchWithKev.Vulnerability.KnownExploited, models.KnownExploited{ + CVE: "CVE-1999-0001", + KnownRansomwareCampaignUse: "Known", + }) + cases := []struct { - name string - match models.Match - severitySuffix string - expectedRow []string + name string + match models.Match + extraAnnotation string + expectedRow []string }{ { - name: "create row for vulnerability", - match: match1, - severitySuffix: "", - expectedRow: []string{match1.Artifact.Name, match1.Artifact.Version, "1.0.2, *2.0.1, 3.0.4", string(match1.Artifact.Type), match1.Vulnerability.ID, "Low"}, + name: "create row for vulnerability", + match: match1, + extraAnnotation: "", + expectedRow: []string{match1.Artifact.Name, match1.Artifact.Version, "1.0.2, *2.0.1, 3.0.4", string(match1.Artifact.Type), match1.Vulnerability.ID, "Medium", "50.00", " 87.2"}, + }, + { + name: "create row for suppressed vulnerability", + match: match1, + extraAnnotation: appendSuppressed, + expectedRow: []string{match1.Artifact.Name, match1.Artifact.Version, "1.0.2, *2.0.1, 3.0.4", string(match1.Artifact.Type), match1.Vulnerability.ID, "Medium", "50.00", " 87.2", "(suppressed)"}, }, { - name: "create row for suppressed vulnerability", - match: match1, - severitySuffix: appendSuppressed, - expectedRow: []string{match1.Artifact.Name, match1.Artifact.Version, "1.0.2, *2.0.1, 3.0.4", string(match1.Artifact.Type), match1.Vulnerability.ID, "Low", "(suppressed)"}, + name: "create row for suppressed vulnerability + Kev", + match: matchWithKev, + extraAnnotation: appendSuppressed, + expectedRow: []string{match1.Artifact.Name, match1.Artifact.Version, "1.0.2, *2.0.1, 3.0.4", string(match1.Artifact.Type), match1.Vulnerability.ID, "Medium", "50.00", " 87.2", "(kev, suppressed)"}, }, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { p := NewPresenter(models.PresenterConfig{}, false) - row := p.newRow(testCase.match, testCase.severitySuffix, false) + row := p.newRow(testCase.match, testCase.extraAnnotation, false) cols := rows{row}.Render()[0] assert.Equal(t, testCase.expectedRow, cols) @@ -230,7 +251,7 @@ func TestRowsRender(t *testing.T) { result := rs.Render() expected := [][]string{ - {"pkg1", "1.0.0", "1.1.0", "os", "CVE-2023-1234", "critical"}, + {"pkg1", "1.0.0", "1.1.0", "os", "CVE-2023-1234", "critical", "75.00", " N/A"}, } if diff := cmp.Diff(expected, result); diff != "" { @@ -248,9 +269,9 @@ func TestRowsRender(t *testing.T) { result := rs.Render() expected := [][]string{ - {"pkgA", "1.0.0", "", "os", "CVE-2023-1234", "critical"}, - {"pkgB", "2.0.0", "(won't fix)", "os", "CVE-2023-5678", "high"}, - {"pkgC", "3.0.0", "3.1.0", "os", "CVE-2023-9012", "medium"}, + {"pkgA", "1.0.0", "", "os", "CVE-2023-1234", "critical", "75.00", " N/A"}, + {"pkgB", "2.0.0", "(won't fix)", "os", "CVE-2023-5678", "high", "75.00", " N/A"}, + {"pkgC", "3.0.0", "3.1.0", "os", "CVE-2023-9012", "medium", "75.00", " N/A"}, } if diff := cmp.Diff(expected, result); diff != "" { @@ -265,17 +286,15 @@ func TestRowsRender(t *testing.T) { result := rs.Render() expected := [][]string{ - {"pkg1", "1.0.0", "1.1.0", "os", "CVE-2023-1234", "critical"}, + {"pkg1", "1.0.0", "1.1.0", "os", "CVE-2023-1234", "critical", "75.00", " N/A"}, } if diff := cmp.Diff(expected, result); diff != "" { t.Errorf("Render() mismatch (-want +got):\n%s", diff) } - // should have 7 columns: name, version, fix, packageType, vulnID, severity - if len(result[0]) != 6 { - t.Errorf("Expected 7 columns, got %d", len(result[0])) - } + // expected columns: name, version, fix, packageType, vulnID, severity, epss, risk + assert.Len(t, result[0], 8) }) } @@ -290,6 +309,24 @@ func createTestRow(name, version, fix, pkgType, vulnID, severity string, fixStat VulnerabilityMetadata: models.VulnerabilityMetadata{ ID: vulnID, Severity: severity, + Cvss: []models.Cvss{ + { + Source: "nvd", + Type: "CVSS", + Version: "3.1", + Vector: "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:L", + Metrics: models.CvssMetrics{ + BaseScore: 7.2, + }, + }, + }, + EPSS: []models.EPSS{ + { + CVE: vulnID, + EPSS: 0.03, + Percentile: 0.75, + }, + }, }, }, Artifact: models.Package{ diff --git a/grype/version/constraint_expression.go b/grype/version/constraint_expression.go index 9870863b0f6..6fc97cc7865 100644 --- a/grype/version/constraint_expression.go +++ b/grype/version/constraint_expression.go @@ -7,6 +7,8 @@ import ( "text/scanner" ) +var ErrFallbackToFuzzy = fmt.Errorf("falling back to fuzzy version matching") + type constraintExpression struct { units [][]constraintUnit // only supports or'ing a group of and'ed groups comparators [][]Comparator // only supports or'ing a group of and'ed groups @@ -20,7 +22,7 @@ func newConstraintExpression(phrase string, genFn comparatorGenerator) (constrai orUnits := make([][]constraintUnit, len(orParts)) orComparators := make([][]Comparator, len(orParts)) - + var fuzzyErr error for orIdx, andParts := range orParts { andUnits := make([]constraintUnit, len(andParts)) andComparators := make([]Comparator, len(andParts)) @@ -36,7 +38,16 @@ func newConstraintExpression(phrase string, genFn comparatorGenerator) (constrai comparator, err := genFn(*unit) if err != nil { - return constraintExpression{}, fmt.Errorf("failed to create comparator for '%s': %w", unit, err) + // this is a version constraint that could not be parsed as its + // specified type. Try falling back to fuzzy matching so that + // a match can still be attempted. + comparator, err = newFuzzyComparator(*unit) + if err != nil { + return constraintExpression{}, fmt.Errorf("failed to create comparator for '%s': %w", unit, err) + } + // Tell the caller we had to fallback from the specified + // version constraint format + fuzzyErr = ErrFallbackToFuzzy } andComparators[andIdx] = comparator } @@ -48,7 +59,7 @@ func newConstraintExpression(phrase string, genFn comparatorGenerator) (constrai return constraintExpression{ units: orUnits, comparators: orComparators, - }, nil + }, fuzzyErr } func (c *constraintExpression) satisfied(other *Version) (bool, error) { diff --git a/grype/version/constraint_expression_test.go b/grype/version/constraint_expression_test.go index 664fa465443..4f5e8ba40f6 100644 --- a/grype/version/constraint_expression_test.go +++ b/grype/version/constraint_expression_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/go-test/deep" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" ) func TestScanExpression(t *testing.T) { @@ -86,3 +88,91 @@ func TestScanExpression(t *testing.T) { }) } } + +func TestNewConstraintExpression(t *testing.T) { + tests := []struct { + name string + phrase string + genFn comparatorGenerator + expected constraintExpression + wantErr error + }{ + { + name: "single valid constraint", + phrase: "<1.1.1", + genFn: newGolangComparator, + expected: constraintExpression{ + units: [][]constraintUnit{ + {constraintUnit{ + rangeOperator: LT, + version: "1.1.1", + }}, + }, + comparators: [][]Comparator{ + {mustGolangComparator(t, constraintUnit{ + rangeOperator: LT, + version: "1.1.1", + })}, + }, + }, + wantErr: nil, + }, + { + name: "fall back to fuzzy on invalid semver", + phrase: ">9.6.0b1", + genFn: newGolangComparator, + expected: constraintExpression{ + units: [][]constraintUnit{ + {constraintUnit{ + rangeOperator: GT, + version: "9.6.0b1", + }}, + }, + comparators: [][]Comparator{ + {mustFuzzyComparator(t, constraintUnit{ + rangeOperator: GT, + version: "9.6.0b1", + })}, + }, + }, + wantErr: ErrFallbackToFuzzy, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := newConstraintExpression(test.phrase, test.genFn) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + } else { + require.NoError(t, err) + } + + opts := []cmp.Option{ + cmp.AllowUnexported(constraintExpression{}, + constraintUnit{}, golangVersion{}, fuzzyVersion{}, semanticVersion{}), + } + if diff := cmp.Diff(test.expected, actual, opts...); diff != "" { + t.Errorf("actual does not match expected, diff: %s", diff) + } + }) + } +} + +func mustGolangComparator(t *testing.T, unit constraintUnit) Comparator { + t.Helper() + c, err := newGolangComparator(unit) + if err != nil { + t.Fatal(err) + } + return c +} + +func mustFuzzyComparator(t *testing.T, unit constraintUnit) Comparator { + t.Helper() + c, err := newFuzzyComparator(unit) + if err != nil { + t.Fatal(err) + } + return c +} diff --git a/grype/version/pep440_constraint.go b/grype/version/pep440_constraint.go index cc0c0b319ea..4ea4c479355 100644 --- a/grype/version/pep440_constraint.go +++ b/grype/version/pep440_constraint.go @@ -44,7 +44,7 @@ func newPep440Constraint(raw string) (pep440Constraint, error) { constraints, err := newConstraintExpression(raw, newPep440Comparator) if err != nil { - return pep440Constraint{}, fmt.Errorf("unable to parse pep440 constrain phrase %w", err) + return pep440Constraint{}, fmt.Errorf("unable to parse pep440 constraint phrase %w", err) } return pep440Constraint{ diff --git a/grype/vulnerability/metadata.go b/grype/vulnerability/metadata.go index 2032c7d9857..5351f2baa01 100644 --- a/grype/vulnerability/metadata.go +++ b/grype/vulnerability/metadata.go @@ -1,6 +1,7 @@ package vulnerability import ( + "strings" "time" ) @@ -14,6 +15,111 @@ type Metadata struct { Cvss []Cvss KnownExploited []KnownExploited EPSS []EPSS + + // calculated as-needed + risk float64 +} + +// RiskScore computes a basic quantitative risk by combining threat and severity. +// Threat is represented by epss (likelihood of exploitation), and severity by the cvss base score + string severity. +// Impact is currently fixed at 1 and may be integrated into the calculation in future versions. +// Raw risk is epss * (cvss / 10) * impact, then scaled to 0–100 for readability. +// If a vulnerability appears in the KEV list, apply an additional boost to reflect known exploitation. +// Known ransomware campaigns receive a further, distinct boost. +func (m *Metadata) RiskScore() float64 { + if m == nil { + return 0 + } + if m.risk != 0 { + return m.risk + } + m.risk = riskScore(*m) + return m.risk +} + +func riskScore(m Metadata) float64 { + return min(threat(m)*severity(m)*kevModifier(m), 1.0) * 100.0 +} + +func kevModifier(m Metadata) float64 { + if len(m.KnownExploited) > 0 { + for _, kev := range m.KnownExploited { + if strings.ToLower(kev.KnownRansomwareCampaignUse) == "known" { + // consider ransomware campaigns to be a greater kevModifier than other KEV threats + return 1.1 + } + } + return 1.05 // boost the final result, as if there is a greater kevModifier inherently from KEV threats + } + return 1.0 +} + +func threat(m Metadata) float64 { + if len(m.KnownExploited) > 0 { + // per the EPSS guidance, any evidence of exploitation in the wild (not just PoC) should be considered over EPSS data + return 1.0 + } + if len(m.EPSS) == 0 { + return 0.0 + } + return m.EPSS[0].EPSS +} + +// severity returns a 0-1 value, which is a combination of the string severity and the average of the cvss base scores. +// If there are no cvss scores, the string severity is used. Some vendors only update the string severity and not the +// cvss scores, so it's important to consider all sources. We are also not biasing towards any one source (multiple +// cvss scores won't over-weigh the string severity). +func severity(m Metadata) float64 { + // TODO: summarization should take a policy: prefer NVD over CNA or vice versa... + + stringSeverityScore := severityToScore(m.Severity) / 10.0 + avgBaseScore := average(validBaseScores(m.Cvss...)...) / 10.0 + if avgBaseScore == 0 { + return stringSeverityScore + } + return average(stringSeverityScore, avgBaseScore) +} + +func severityToScore(severity string) float64 { + // use the middle of the range for each severity + switch strings.ToLower(severity) { + case "negligible": + return 0.5 + case "low": + return 3.0 + case "medium": + return 5.0 + case "high": + return 7.5 + case "critical": + return 9.0 + } + // the severity value might be "unknown" or an unexpected value. These should not be lost + // in the noise and placed at the bottom of the list... instead we compromise to the middle of the list. + return 5.0 +} + +func validBaseScores(as ...Cvss) []float64 { + var out []float64 + for _, a := range as { + if a.Metrics.BaseScore == 0 { + // this is a mistake... base scores cannot be 0. Don't include this value and bring down the average + continue + } + out = append(out, a.Metrics.BaseScore) + } + return out +} + +func average(as ...float64) float64 { + if len(as) == 0 { + return 0 + } + sum := 0.0 + for _, a := range as { + sum += a + } + return sum / float64(len(as)) } type Cvss struct { diff --git a/grype/vulnerability/metadata_test.go b/grype/vulnerability/metadata_test.go new file mode 100644 index 00000000000..befcaa45a80 --- /dev/null +++ b/grype/vulnerability/metadata_test.go @@ -0,0 +1,457 @@ +package vulnerability + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRiskScore(t *testing.T) { + tests := []struct { + name string + metadata Metadata + expected float64 + }{ + { + name: "nil metadata", + metadata: Metadata{}, + expected: 0, + }, + { + name: "already calculated risk", + metadata: Metadata{ + risk: 42.5, + }, + expected: 42.5, + }, + { + name: "no EPSS data, no KEV", + metadata: Metadata{ + Severity: "high", + Cvss: []Cvss{ + { + Metrics: CvssMetrics{ + BaseScore: 7.5, + }, + }, + }, + }, + expected: 0, // threat is 0 without EPSS or KEV + }, + { + name: "with EPSS data, no KEV", + metadata: Metadata{ + Severity: "high", + EPSS: []EPSS{ + { + EPSS: 0.5, + Percentile: 0.95, + }, + }, + Cvss: []Cvss{ + { + Metrics: CvssMetrics{ + BaseScore: 7.5, + }, + }, + }, + }, + expected: 37.5, // 0.5 * (7.5/10) * 1 * 100 + }, + { + name: "with KEV, no EPSS", + metadata: Metadata{ + Severity: "high", + KnownExploited: []KnownExploited{ + { + CVE: "CVE-2023-1234", + KnownRansomwareCampaignUse: "No", + }, + }, + Cvss: []Cvss{ + { + Metrics: CvssMetrics{ + BaseScore: 7.5, + }, + }, + }, + }, + expected: 78.75, // 1.0 * (7.5/10) * 1.05* 100 + }, + { + name: "with KEV ransomware", + metadata: Metadata{ + Severity: "high", + KnownExploited: []KnownExploited{ + { + CVE: "CVE-2023-1234", + KnownRansomwareCampaignUse: "Known", + }, + }, + Cvss: []Cvss{ + { + Metrics: CvssMetrics{ + BaseScore: 7.5, + }, + }, + }, + }, + expected: 82.5, // 1.0 * (7.5/10) * 1.1 * 100 + }, + { + name: "with severity string only", + metadata: Metadata{ + Severity: "critical", + EPSS: []EPSS{ + { + EPSS: 0.8, + Percentile: 0.99, + }, + }, + }, + expected: 72, // 0.8 * (9.0/10) * 1.0 * 100 + }, + { + name: "with multiple CVSS scores + string severity", + metadata: Metadata{ + Severity: "medium", + EPSS: []EPSS{ + { + EPSS: 0.6, + Percentile: 0.90, + }, + }, + Cvss: []Cvss{ + { + Source: "NVD", + Metrics: CvssMetrics{ + BaseScore: 6.5, + }, + }, + { + Source: "Vendor", + Metrics: CvssMetrics{ + BaseScore: 5.5, + }, + }, + }, + }, + expected: 33, // 0.6 * ( (((6.5+5.5)/2)+5)/2 /10) * 1.0 * 100 + }, + { + name: "with some invalid CVSS scores + string severity", + metadata: Metadata{ + Severity: "medium", + EPSS: []EPSS{ + { + EPSS: 0.4, + Percentile: 0.85, + }, + }, + Cvss: []Cvss{ + { + Source: "NVD", + Metrics: CvssMetrics{ + BaseScore: 0, // invalid, should be ignored + }, + }, + { + Source: "Vendor", + Metrics: CvssMetrics{ + BaseScore: 6.0, + }, + }, + }, + }, + expected: 22, // 0.4 * ((6.0+5)/2 /10) * 1.0 * 100 + }, + { + name: "unknown severity", + metadata: Metadata{ + Severity: "unknown", + EPSS: []EPSS{ + { + EPSS: 0.3, + Percentile: 0.80, + }, + }, + }, + expected: 15, // 0.3 * (5.0/10) * 1.0 * 100 + }, + { + name: "maximum risk clamp", + metadata: Metadata{ + Severity: "critical", + KnownExploited: []KnownExploited{ + { + CVE: "CVE-2023-1234", + KnownRansomwareCampaignUse: "Known", + }, + }, + Cvss: []Cvss{ + { + Metrics: CvssMetrics{ + BaseScore: 10.0, + }, + }, + }, + }, + expected: 100, // clamped to 100 as it would be 1.0 * 1.0 * 1.1 * 100 = 120 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.metadata.RiskScore() + assert.InDelta(t, tt.expected, result, 0.01, "RiskScore method returned incorrect value") + + // test the calculated value is cached + if tt.name != "already calculated risk" && tt.name != "nil metadata" { + require.InDelta(t, tt.expected, tt.metadata.risk, 0.01, "risk was not cached") + } + + // test the standalone function + if tt.name != "nil metadata" && tt.name != "already calculated risk" { + funcResult := riskScore(tt.metadata) + assert.InDelta(t, tt.expected, funcResult, 0.0001, "riskScore function returned incorrect value") + } + }) + } +} + +func TestSeverityToScore(t *testing.T) { + tests := []struct { + severity string + expected float64 + }{ + {"negligible", 0.5}, + {"NEGLIGIBLE", 0.5}, + {"low", 3.0}, + {"LOW", 3.0}, + {"medium", 5.0}, + {"MEDIUM", 5.0}, + {"high", 7.5}, + {"HIGH", 7.5}, + {"critical", 9.0}, + {"CRITICAL", 9.0}, + {"unknown", 5.0}, + {"", 5.0}, + {"something-else", 5.0}, + } + + for _, tt := range tests { + t.Run(tt.severity, func(t *testing.T) { + result := severityToScore(tt.severity) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestAverageCVSS(t *testing.T) { + tests := []struct { + name string + cvss []Cvss + expected float64 + }{ + { + name: "empty slice", + cvss: []Cvss{}, + expected: 0, + }, + { + name: "single valid score", + cvss: []Cvss{ + {Metrics: CvssMetrics{BaseScore: 7.5}}, + }, + expected: 7.5, + }, + { + name: "multiple valid scores", + cvss: []Cvss{ + {Metrics: CvssMetrics{BaseScore: 7.5}}, + {Metrics: CvssMetrics{BaseScore: 8.5}}, + {Metrics: CvssMetrics{BaseScore: 9.0}}, + }, + expected: 8.33333, + }, + { + name: "with invalid scores", + cvss: []Cvss{ + {Metrics: CvssMetrics{BaseScore: 0}}, // invalid + {Metrics: CvssMetrics{BaseScore: 7.5}}, + {Metrics: CvssMetrics{BaseScore: 0}}, // invalid + {Metrics: CvssMetrics{BaseScore: 8.5}}, + }, + expected: 8.0, + }, + { + name: "all invalid scores", + cvss: []Cvss{ + {Metrics: CvssMetrics{BaseScore: 0}}, + {Metrics: CvssMetrics{BaseScore: 0}}, + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := average(validBaseScores(tt.cvss...)...) + assert.InDelta(t, tt.expected, result, 0.00001) + }) + } +} + +func TestThreat(t *testing.T) { + tests := []struct { + name string + metadata Metadata + expected float64 + }{ + { + name: "no EPSS, no KEV", + metadata: Metadata{}, + expected: 0, + }, + { + name: "with EPSS, no KEV", + metadata: Metadata{ + EPSS: []EPSS{ + {EPSS: 0.75}, + }, + }, + expected: 0.75, + }, + { + name: "with KEV, no EPSS", + metadata: Metadata{ + KnownExploited: []KnownExploited{ + {CVE: "CVE-2023-1234"}, + }, + }, + expected: 1.0, + }, + { + name: "with KEV and EPSS", + metadata: Metadata{ + EPSS: []EPSS{ + {EPSS: 0.5}, + }, + KnownExploited: []KnownExploited{ + {CVE: "CVE-2023-1234"}, + }, + }, + expected: 1.0, // KEV takes precedence + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := threat(tt.metadata) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestImpact(t *testing.T) { + tests := []struct { + name string + metadata Metadata + expected float64 + }{ + { + name: "no KEV", + metadata: Metadata{}, + expected: 1.0, + }, + { + name: "KEV without ransomware", + metadata: Metadata{ + KnownExploited: []KnownExploited{ + {KnownRansomwareCampaignUse: "No"}, + }, + }, + expected: 1.05, + }, + { + name: "KEV with ransomware", + metadata: Metadata{ + KnownExploited: []KnownExploited{ + {KnownRansomwareCampaignUse: "Known"}, + }, + }, + expected: 1.1, + }, + { + name: "KEV with case insensitive ransomware", + metadata: Metadata{ + KnownExploited: []KnownExploited{ + {KnownRansomwareCampaignUse: "KNOWN"}, + }, + }, + expected: 1.1, + }, + { + name: "multiple KEV entries, one with ransomware", + metadata: Metadata{ + KnownExploited: []KnownExploited{ + {KnownRansomwareCampaignUse: "No"}, + {KnownRansomwareCampaignUse: "Known"}, + }, + }, + expected: 1.1, // highest wins + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := kevModifier(tt.metadata) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSeverity(t *testing.T) { + tests := []struct { + name string + metadata Metadata + expected float64 + }{ + { + name: "no CVSS, medium severity", + metadata: Metadata{ + Severity: "medium", + }, + expected: 0.5, + }, + { + name: "with CVSS + severity string", + metadata: Metadata{ + Severity: "medium", + Cvss: []Cvss{ + {Metrics: CvssMetrics{BaseScore: 8.0}}, + }, + }, + expected: 0.65, + }, + { + name: "multiple CVSS scores + severity string", + metadata: Metadata{ + Severity: "medium", + Cvss: []Cvss{ + {Metrics: CvssMetrics{BaseScore: 6.0}}, + {Metrics: CvssMetrics{BaseScore: 8.0}}, + }, + }, + expected: 0.6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := severity(tt.metadata) + assert.InDelta(t, tt.expected, result, 0.00001) + }) + } +} diff --git a/grype/vulnerability_matcher.go b/grype/vulnerability_matcher.go index e0889ae50e7..0df6da3ca63 100644 --- a/grype/vulnerability_matcher.go +++ b/grype/vulnerability_matcher.go @@ -20,7 +20,6 @@ import ( "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/grype/internal/bus" "github.com/anchore/grype/internal/log" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -139,7 +138,7 @@ func (m *VulnerabilityMatcher) mergeIgnoredMatches(allIgnoredMatches ...[]match. //nolint:funlen func (m *VulnerabilityMatcher) searchDBForMatches( - release *linux.Release, + d *distro.Distro, packages []pkg.Package, progressMonitor *monitorWriter, ) (match.Matches, error) { @@ -147,19 +146,6 @@ func (m *VulnerabilityMatcher) searchDBForMatches( var allIgnored []match.IgnoredMatch matcherIndex, defaultMatcher := newMatcherIndex(m.Matchers) - var d *distro.Distro - if release != nil { - var err error - d, err = distro.NewFromRelease(*release) - if err != nil { - log.Warnf("unable to determine linux distribution: %+v", err) - } - if d != nil && d.Disabled() { - log.Warnf("unsupported linux distribution: %s", d.Name()) - return match.NewMatches(), nil - } - } - if defaultMatcher == nil { defaultMatcher = stock.NewStockMatcher(stock.MatcherConfig{UseCPEs: true}) } diff --git a/grype/vulnerability_matcher_test.go b/grype/vulnerability_matcher_test.go index baf5658b849..b9a7010578e 100644 --- a/grype/vulnerability_matcher_test.go +++ b/grype/vulnerability_matcher_test.go @@ -29,7 +29,6 @@ import ( "github.com/anchore/grype/internal/bus" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/source" ) @@ -264,9 +263,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { }, }, context: pkg.Context{ - Distro: &linux.Release{ - ID: "debian", - VersionID: "8", + Distro: &distro.Distro{ + Type: "debian", + Version: "8", }, }, }, @@ -281,9 +280,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { neutron2013Pkg, }, context: pkg.Context{ - Distro: &linux.Release{ - ID: "debian", - VersionID: "8", + Distro: &distro.Distro{ + Type: "debian", + Version: "8", }, }, }, @@ -336,9 +335,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { neutron2013Pkg, }, context: pkg.Context{ - Distro: &linux.Release{ - ID: "debian", - VersionID: "8", + Distro: &distro.Distro{ + Type: "debian", + Version: "8", }, }, }, @@ -410,9 +409,9 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { }, }, }, - Distro: &linux.Release{ - ID: "debian", - VersionID: "8", + Distro: &distro.Distro{ + Type: "debian", + Version: "8", }, }, }, @@ -1069,9 +1068,9 @@ func Test_fatalErrors(t *testing.T) { }, }, pkg.Context{ - Distro: &linux.Release{ - ID: "debian", - VersionID: "8", + Distro: &distro.Distro{ + Type: "debian", + Version: "8", }, }, ) diff --git a/templates/html.tmpl b/templates/html.tmpl index 5fbab17f1f6..a550829375d 100644 --- a/templates/html.tmpl +++ b/templates/html.tmpl @@ -1,595 +1,1545 @@ - - - - - - Vulnerability Report - - - - - - - - - - - - - - - - - - - - - - - - - - - -{{/* Initialize counters */}} -{{- $CountCritical := 0 }} -{{- $CountHigh := 0 }} -{{- $CountMedium := 0 }} -{{- $CountLow := 0}} -{{- $CountUnknown := 0 }} - -{{/* Create a list */}} -{{- $FilteredMatches := list }} - -{{/* Loop through all vulns limit output and set count*/}} -{{- range $vuln := .Matches }} - {{/* Use this filter to exclude severity if needed */}} - {{- if or (eq $vuln.Vulnerability.Severity "Critical") (eq $vuln.Vulnerability.Severity "High") (eq $vuln.Vulnerability.Severity "Medium") (eq $vuln.Vulnerability.Severity "Low") (eq $vuln.Vulnerability.Severity "Unknown") }} - {{- $FilteredMatches = append $FilteredMatches $vuln }} - {{- if eq $vuln.Vulnerability.Severity "Critical" }} - {{- $CountCritical = add $CountCritical 1 }} - {{- else if eq $vuln.Vulnerability.Severity "High" }} - {{- $CountHigh = add $CountHigh 1 }} - {{- else if eq $vuln.Vulnerability.Severity "Medium" }} - {{- $CountMedium = add $CountMedium 1 }} - {{- else if eq $vuln.Vulnerability.Severity "Low" }} - {{- $CountLow = add $CountLow 1 }} - {{- else }} - {{- $CountUnknown = add $CountUnknown 1 }} - {{- end }} - {{- end }} -{{- end }} - - -
-
-
-

Container Vulnerability Report

-

Name: {{- if eq (.Source.Type) "image" -}} {{.Source.Target.UserInput}} - {{- else if eq (.Source.Type) "directory" -}} {{.Source.Target}} - {{- else if eq (.Source.Type) "file" -}} {{.Source.Target}} - {{- else -}} unknown - {{- end -}}

-

Type: {{ .Source.Type }}

-

Date: {{.Descriptor.Timestamp}}

-
-
- Grype Logo -
-
-
-
-
Critical
-
{{ $CountCritical }}
-
-
-
High
-
{{ $CountHigh }}
-
-
-
Medium
-
{{ $CountMedium }}
-
-
-
Low
-
{{ $CountLow }}
-
-
-
Unknown
-
{{ $CountUnknown }}
-
-
-
- - - - - - - - - - - - - - - {{- range $FilteredMatches }} - - - - - - - - - - - {{end}} - -
NameVersionTypeVulnerabilitySeverityDescriptionStateFixed In
{{.Artifact.Name}}{{.Artifact.Version}}{{.Artifact.Type}} - {{.Vulnerability.ID}} - {{.Vulnerability.Severity}}{{html .Vulnerability.Description}}{{.Vulnerability.Fix.State}} - {{- if .Vulnerability.Fix.Versions }} -
    - {{- range .Vulnerability.Fix.Versions }} -
  • {{ . }}
  • - {{- end }} -
- {{- else }} - N/A - {{- end }} -
-
-
- - - - - - - + + + + + + Vulnerability Report + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{{/* Initialize counters */}} +{{- $CountCritical := 0 }} +{{- $CountHigh := 0 }} +{{- $CountMedium := 0 }} +{{- $CountLow := 0}} +{{- $CountUnknown := 0 }} + +{{/* Create a list */}} +{{- $FilteredMatches := list }} + +{{/* Loop through all vulns limit output and set count*/}} +{{- range $vuln := .Matches }} + {{/* Use this filter to exclude severity if needed */}} + {{- if or (eq $vuln.Vulnerability.Severity "Critical") (eq $vuln.Vulnerability.Severity "High") (eq $vuln.Vulnerability.Severity "Medium") (eq $vuln.Vulnerability.Severity "Low") (eq $vuln.Vulnerability.Severity "Unknown") }} + {{- $FilteredMatches = append $FilteredMatches $vuln }} + {{- if eq $vuln.Vulnerability.Severity "Critical" }} + {{- $CountCritical = add $CountCritical 1 }} + {{- else if eq $vuln.Vulnerability.Severity "High" }} + {{- $CountHigh = add $CountHigh 1 }} + {{- else if eq $vuln.Vulnerability.Severity "Medium" }} + {{- $CountMedium = add $CountMedium 1 }} + {{- else if eq $vuln.Vulnerability.Severity "Low" }} + {{- $CountLow = add $CountLow 1 }} + {{- else }} + {{- $CountUnknown = add $CountUnknown 1 }} + {{- end }} + {{- end }} +{{- end }} + + +
+
+
+

Vulnerability Report

+
+
Name:
+
{{- if eq (.Source.Type) "image" -}} {{.Source.Target.UserInput}} + {{- else if eq (.Source.Type) "directory" -}} {{.Source.Target}} + {{- else if eq (.Source.Type) "file" -}} {{.Source.Target}} + {{- else -}} unknown + {{- end -}}
+ +
Type:
+
{{ .Source.Type }}
+ + {{- /* Conditionally add ImageID (Checksum) for images */ -}} + {{- if eq .Source.Type "image" -}} + {{- with .Source.Target.ID -}} +
Checksum:
+
{{ . }}
+ {{- end -}} + {{- end -}} + +
Date:
+
+ {{.Descriptor.Timestamp}} + +
+
+
+
+ Grype Logo +
+
+
+
+
Critical
+
{{ $CountCritical }}
+
+
+
High
+
{{ $CountHigh }}
+
+
+
Medium
+
{{ $CountMedium }}
+
+
+
Low
+
{{ $CountLow }}
+
+
+
Unknown
+
{{ $CountUnknown }}
+
+
+
+ + + + + + + + + + + + + + + + + {{- range $FilteredMatches }} + + + + + + + + + + + + + {{- end }} + +
NameVersionTypeVulnerabilitySeverityStateFixed InDescriptionRelated URLsPURL
{{.Artifact.Name}}{{.Artifact.Version}}{{.Artifact.Type}} + {{.Vulnerability.ID}} + {{.Vulnerability.Severity}}{{.Vulnerability.Fix.State}} + {{- if .Vulnerability.Fix.Versions }} +
    + {{- range .Vulnerability.Fix.Versions }} +
  • {{ . }}
  • + {{- end }} +
+ {{- else }} + N/A + {{- end }} +
{{html .Vulnerability.Description}}{{ toJson .Vulnerability.URLs }}{{ .Artifact.PURL }}
+
+
+ + + + + \ No newline at end of file diff --git a/test/integration/compare_sbom_input_vs_lib_test.go b/test/integration/compare_sbom_input_vs_lib_test.go index 80e0db48ed6..eafc94cdbc2 100644 --- a/test/integration/compare_sbom_input_vs_lib_test.go +++ b/test/integration/compare_sbom_input_vs_lib_test.go @@ -54,6 +54,7 @@ func TestCompareSBOMInputToLibResults(t *testing.T) { string(syftPkg.BinaryPkg), // these are removed due to overlap-by-file-ownership string(syftPkg.BitnamiPkg), string(syftPkg.PhpPeclPkg), + string(syftPkg.PhpPearPkg), string(syftPkg.RustPkg), string(syftPkg.KbPkg), string(syftPkg.DartPubPkg), @@ -62,6 +63,7 @@ func TestCompareSBOMInputToLibResults(t *testing.T) { string(syftPkg.ConanPkg), string(syftPkg.HexPkg), string(syftPkg.PortagePkg), + string(syftPkg.HomebrewPkg), string(syftPkg.CocoapodsPkg), string(syftPkg.HackagePkg), string(syftPkg.NixPkg), diff --git a/test/integration/match_by_image_test.go b/test/integration/match_by_image_test.go index 254fa2459e6..9a226cc2157 100644 --- a/test/integration/match_by_image_test.go +++ b/test/integration/match_by_image_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/anchore/grype/grype" + "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/matcher" "github.com/anchore/grype/grype/matcher/dotnet" @@ -30,7 +31,6 @@ import ( "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/cataloging/pkgcataloging" "github.com/anchore/syft/syft/cpe" - "github.com/anchore/syft/syft/linux" syftPkg "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/source" ) @@ -787,7 +787,7 @@ func TestMatchByImage(t *testing.T) { }, }) - actualResults := grype.FindVulnerabilitiesForPackage(theProvider, s.Artifacts.LinuxDistribution, matchers, pkg.FromCollection(s.Artifacts.Packages, pkg.SynthesisConfig{})) + actualResults := grype.FindVulnerabilitiesForPackage(theProvider, distro.FromRelease(s.Artifacts.LinuxDistribution), matchers, pkg.FromCollection(s.Artifacts.Packages, pkg.SynthesisConfig{})) for _, m := range actualResults.Sorted() { for _, d := range m.Details { observedMatchers.Add(string(d.Matcher)) @@ -945,7 +945,6 @@ func vexMatches(t *testing.T, ignoredMatches []match.IgnoredMatch, vexStatus vex }, }, }, - Distro: &linux.Release{}, } vexedMatches, ignoredMatches, err := vexMatcher.ApplyVEX(pctx, &matches, ignoredMatches) diff --git a/test/quality/test-db b/test/quality/test-db index 9fdc4001a56..75c7e136081 100644 --- a/test/quality/test-db +++ b/test/quality/test-db @@ -1 +1 @@ -vulnerability-db_v6.0.2_2025-04-01T01:31:39Z_1743480497.tar.zst +vulnerability-db_v6.0.2_2025-05-01T01:31:33Z_1746072708.tar.zst