diff --git a/AGENTS.md b/AGENTS.md index 67b1e7a..3d3344f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,5 +26,5 @@ This file is for agents changing the gatecheck repository. To use the gatecheck - Check `gatecheck --help` before describing a flag. `docs/cli-reference.md` matches the CLI on main. - Report type detection is a lowercase substring of the filename. See `docs/supported-reports.md`. - Coverage thresholds and EPSS scores are ratios from 0 to 1. -- Exit codes from `cmd/gatecheck`: 0 success, 1 threshold failure (`gatecheck.ErrValidationFailure`), 255 any other error (`os.Exit(-1)`). `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, and `trivy` return the scanner's status. A missing scanner still exits 255. +- Exit codes from `cmd/gatecheck`: 0 success, 1 threshold failure (`gatecheck.ErrValidationFailure`), 255 any other error (`os.Exit(-1)`). `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy`, and `osv-scanner` return the scanner's status. A missing scanner still exits 255. - Do not add copyright notices. diff --git a/CHANGELOG.md b/CHANGELOG.md index da28a79..2e978ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `gatecheck bootstrap` installs pinned Trivy 0.76.1, and `gatecheck trivy` runs it. - Trivy JSON reports work with `list`, `validate`, and `bundle`. Severity limits and CVE allow and deny lists follow the Grype rules. +- `gatecheck bootstrap` installs pinned osv-scanner 2.7.0, and `gatecheck osv-scanner` runs it. +- osv-scanner JSON reports work with `list`, `validate`, and `bundle`. Severity limits and CVE allow and deny lists follow the Grype rules. + +### Changed + +- `gatecheck bootstrap --list` widens the tool column so `osv-scanner` fits. ## [0.9.0] - 2026-10-23 diff --git a/README.md b/README.md index 97a8d80..f6b1a41 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ gatecheck validate --config gatecheck.yaml grype-report.json `gatecheck agent-guide` (alias `llm`) prints a markdown guide for coding agents. The same text is `docs/agent-guide.md`. -`gatecheck bootstrap` installs pinned Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, and Trivy. `gatecheck grype`, `gatecheck syft`, `gatecheck gitleaks`, `gatecheck semgrep`, `gatecheck osemgrep`, and `gatecheck trivy` run them. +`gatecheck bootstrap` installs pinned Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, Trivy, and osv-scanner. `gatecheck grype`, `gatecheck syft`, `gatecheck gitleaks`, `gatecheck semgrep`, `gatecheck osemgrep`, `gatecheck trivy`, and `gatecheck osv-scanner` run them. ### Summarize Reports with List @@ -52,7 +52,7 @@ gatecheck validate --config gatecheck.yaml grype-report.json gatecheck ls --help ``` -List with EPSS scores is supported for Grype and CycloneDX reports. +List with EPSS scores is supported for Grype, CycloneDX, Trivy, and osv-scanner reports. ![Gatecheck List](https://static.gatecheck.dev/gatecheck-list.gif) diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 19467f6..236b367 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -39,7 +39,7 @@ func newBootstrapCommand() *cobra.Command { }) }, } - cmd.Flags().String("tools", "", "comma-separated scanners to install (default grype,syft,gitleaks,semgrep,trivy)") + cmd.Flags().String("tools", "", "comma-separated scanners to install (default grype,syft,gitleaks,semgrep,trivy,osv-scanner)") cmd.Flags().Bool("force", false, "reinstall even when the pinned version is already present") cmd.Flags().Bool("list", false, "print pinned versions, installed versions, and paths") return cmd diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index 04f447c..aca4249 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -39,7 +39,7 @@ func TestBootstrapCommandListAndPassthrough(t *testing.T) { if err := root.Execute(); err != nil { t.Fatal(err) } - for _, version := range []string{bootstrap.GrypeVersion, bootstrap.SyftVersion, bootstrap.GitleaksVersion, bootstrap.SemgrepVersion, bootstrap.TrivyVersion} { + for _, version := range []string{bootstrap.GrypeVersion, bootstrap.SyftVersion, bootstrap.GitleaksVersion, bootstrap.SemgrepVersion, bootstrap.TrivyVersion, bootstrap.OSVScannerVersion} { if !strings.Contains(out.String(), version) { t.Fatalf("list missing %s\n%s", version, out.String()) } diff --git a/cmd/list.go b/cmd/list.go index d62687a..8b86ee3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -var supportedTypes = []string{"grype", "semgrep", "gitleaks", "syft", "cyclonedx", "bundle", "gatecheck", "trivy"} +var supportedTypes = []string{"grype", "semgrep", "gitleaks", "syft", "cyclonedx", "bundle", "gatecheck", "trivy", "osv"} var listCmd = &cobra.Command{ Use: "list", @@ -121,7 +121,7 @@ var listAllCmd = &cobra.Command{ } opts = append(opts, displayOpt) - if epss && slices.ContainsFunc([]string{"grype", "cyclonedx", "trivy"}, supportedFunc) { + if epss && slices.ContainsFunc([]string{"grype", "cyclonedx", "trivy", "osv"}, supportedFunc) { epssOpt, err := gatecheck.WithEPSS(epssFile, epssURL, epssCacheOptions()...) if err != nil { slog.Error("epss fetch failure, skip", "filename", filename, "error", err) @@ -157,7 +157,7 @@ func newListAllCommand() *cobra.Command { } func newListCommand() *cobra.Command { - listCmd.Flags().StringP("input-type", "i", "", "the input filetype if using STDIN [grype|semgrep|gitleaks|syft|bundle|trivy]") + listCmd.Flags().StringP("input-type", "i", "", "the input filetype if using STDIN [grype|semgrep|gitleaks|syft|bundle|trivy|osv]") listCmd.Flags().Bool("markdown", false, "print as a markdown table") listCmd.Flags().Bool("epss", false, "List with EPSS data") RuntimeConfig.EPSSURL.SetupCobra(listCmd) diff --git a/cmd/root.go b/cmd/root.go index b3f955a..5572570 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -106,6 +106,7 @@ func NewGatecheckCommand() *cobra.Command { newPassthroughCommand("semgrep", "Run osemgrep from the managed semgrep wheel"), newPassthroughCommand("osemgrep", "Run osemgrep from the managed semgrep wheel"), newPassthroughCommand("trivy", "Run the managed trivy scanner"), + newPassthroughCommand("osv-scanner", "Run the managed osv-scanner"), ) addCommandOnce(gatecheckCmd, updateCmd) }) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 079bbff..16e59f3 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -1,6 +1,6 @@ # gatecheck -Gatecheck reads a scanner report, compares it to thresholds in a config file, and exits non-zero when a threshold fails. It also prints a table of a report, packs reports into one bundle, and downloads EPSS and KEV data. `gatecheck bootstrap` installs Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, and Trivy, and the matching subcommands run them. Coverage reports still come from the language toolchain. +Gatecheck reads a scanner report, compares it to thresholds in a config file, and exits non-zero when a threshold fails. It also prints a table of a report, packs reports into one bundle, and downloads EPSS and KEV data. `gatecheck bootstrap` installs Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, Trivy, and osv-scanner, and the matching subcommands run them. Coverage reports still come from the language toolchain. `gatecheck` with no arguments prints nothing and exits 0. `gatecheck --help` lists commands. `gatecheck agent-guide` (alias `llm`) prints this file. @@ -14,7 +14,7 @@ Logs go to stderr. Tables, `config init`, downloads, and this guide go to stdout - `validate` compares one report, or one bundle, to a config. - `bundle create` (alias `init`), `bundle add`, and `bundle remove` (alias `rm`) manage a bundle. - `download epss` and `download kev` write upstream data to stdout. -- `bootstrap` installs Grype, Syft, Gitleaks, osemgrep, and Trivy. `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, and `trivy` run those binaries. +- `bootstrap` installs Grype, Syft, Gitleaks, osemgrep, Trivy, and osv-scanner. `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy`, and `osv-scanner` run those binaries. - `update` (aliases `self-update`, `upgrade`) replaces this binary with a GitHub release after checking `checksums.txt`. `update --check` only prints the current and latest versions. - `completion bash|fish|powershell|zsh` prints a shell completion script. - `help [command]` prints the same text as ` --help`. @@ -27,6 +27,8 @@ Global flags, on every command: `-v` / `--verbose` (debug logs) and `--silent` ( Trivy comes from the `aquasecurity/trivy` GitHub release. The asset names look like `trivy_0.76.1_Linux-64bit.tar.gz`, `trivy_0.76.1_macOS-ARM64.tar.gz`, and `trivy_0.76.1_windows-64bit.zip`. The checksum file is `trivy__checksums.txt`. Linux arm is the armv7 archive. Windows arm64 has no published asset. +osv-scanner comes from the `google/osv-scanner` GitHub release as a raw binary, not an archive. Asset names look like `osv-scanner_linux_amd64`, `osv-scanner_darwin_arm64`, and `osv-scanner_windows_amd64.exe`. The checksum file is `osv-scanner_SHA256SUMS`. Published builds are amd64 and arm64 on Linux, macOS, and Windows. The installed name is `osv-scanner`, or `osv-scanner.exe` on Windows. + The directory is `$GATECHECK_HOME/bin` when `GATECHECK_HOME` is set. Otherwise it is `$XDG_DATA_HOME/gatecheck/bin`, or `~/.local/share/gatecheck/bin` on Linux, `~/Library/Application Support/gatecheck/bin` on macOS, and `%LOCALAPPDATA%\gatecheck\bin` on Windows. A tool already at the pinned version is left in place. `--force` downloads it again. `--tools grype,syft` limits the set. `osemgrep` in `--tools` means semgrep. `--list` prints pinned versions, installed versions, and paths, and does not download. @@ -37,13 +39,14 @@ gatecheck bootstrap --list gatecheck grype dir:latest -o json > grype-report.json gatecheck semgrep scan --json --config auto . gatecheck trivy fs --format json --output trivy-report.json . +gatecheck osv-scanner scan source --format json . > osv-scanner-report.json ``` `gatecheck semgrep scan` and `gatecheck osemgrep scan` insert `--experimental` after `scan` when that flag is absent. Other osemgrep commands need `--experimental` from the caller. `gatecheck semgrep --experimental --version` prints the osemgrep version. When the program that runs is the Python `semgrep` on `PATH`, the flag is left alone. `gatecheck osemgrep` looks for the managed binary, then `osemgrep` on `PATH`. -The managed binary wins when it is present. `gatecheck grype`, `gatecheck syft`, `gatecheck gitleaks`, and `gatecheck trivy` otherwise run that name on `PATH`. `gatecheck semgrep` tries `semgrep` and then `osemgrep` on `PATH`. If none exist, the command prints `gatecheck: is not installed; run `gatecheck bootstrap`` and exits 255. A scanner's own exit status is returned as-is. +The managed binary wins when it is present. `gatecheck grype`, `gatecheck syft`, `gatecheck gitleaks`, `gatecheck trivy`, and `gatecheck osv-scanner` otherwise run that name on `PATH`. `gatecheck semgrep` tries `semgrep` and then `osemgrep` on `PATH`. If none exist, the command prints `gatecheck: is not installed; run `gatecheck bootstrap`` and exits 255. A scanner's own exit status is returned as-is. -A platform with no Grype, Syft, Gitleaks, or Trivy asset fails that tool and the command exits 255. A platform with no semgrep wheel prints a skip message on stderr and continues. Current pins are Grype `0.121.0`, Syft `1.55.0`, Gitleaks `8.30.1`, semgrep `1.182.1`, and Trivy `0.76.1`, in `internal/bootstrap/versions.go`. +A platform with no Grype, Syft, Gitleaks, Trivy, or osv-scanner asset fails that tool and the command exits 255. A platform with no semgrep wheel prints a skip message on stderr and continues. Current pins are Grype `0.121.0`, Syft `1.55.0`, Gitleaks `8.30.1`, semgrep `1.182.1`, Trivy `0.76.1`, and osv-scanner `2.7.0`, in `internal/bootstrap/versions.go`. Syft is installed so it can write CycloneDX (`syft -o cyclonedx-json`). A filename containing `syft` is still recognized by `list` and `validate` and then returns an error. CycloneDX is a report format, and coverage tools are left to the language toolchain. @@ -56,9 +59,10 @@ Syft is installed so it can write CycloneDX (`syft -o cyclonedx-json`). A filena 3. `semgrep`: Semgrep JSON. 4. `gitleaks`: Gitleaks JSON, a list of findings. 5. `trivy`: Trivy JSON. Vulnerability entries are listed and validated. Misconfigurations, secrets, and licenses in the same file are ignored. `cyclonedx` is earlier in this list, so `cyclonedx-trivy-sbom.json` is CycloneDX. -6. `syft`: the name is recognized. `list` and `validate` then return an error. Syft is not implemented. -7. `bundle`: a Gatecheck bundle. `list` also accepts a name containing `gatecheck`. `validate` requires the word `bundle`. `project.gcb` matches neither rule. -8. Coverage, when the name contains `lcov`, `clover`, `cobertura`, or `coverage`, or ends in `.info`. +6. `osv`: osv-scanner JSON (`--format json`). One finding per vulnerability group. Aliases of that group are not counted again. License-only packages are skipped. `osv-scanner-report.json` matches. `OSV-scanner.json` does not. +7. `syft`: the name is recognized. `list` and `validate` then return an error. Syft is not implemented. +8. `bundle`: a Gatecheck bundle. `list` also accepts a name containing `gatecheck`. `validate` requires the word `bundle`. `project.gcb` matches neither rule. +9. Coverage, when the name contains `lcov`, `clover`, `cobertura`, or `coverage`, or ends in `.info`. The match is case sensitive. `Grype-report.json` and `CycloneDX.json` are rejected. An earlier word wins, so `semgrep-coverage.json` is Semgrep. @@ -66,7 +70,7 @@ Coverage format, in this order: the name contains `lcov` or ends in `.info` (LCO From standard input, `list` requires `-i` / `--input-type`. The value is matched as the filename `stdin:`, so the same words work, including the coverage words. `list-all` and `validate` do not read standard input. `validate` requires a file path. -`list-all` lists a file only when the name contains `grype`, `semgrep`, `gitleaks`, `syft`, `cyclonedx`, `bundle`, `gatecheck`, or `trivy`. Coverage files are skipped with a warning. There is no `--input-type` on `list-all`. +`list-all` lists a file only when the name contains `grype`, `semgrep`, `gitleaks`, `syft`, `cyclonedx`, `bundle`, `gatecheck`, `trivy`, or `osv`. Coverage files are skipped with a warning. There is no `--input-type` on `list-all`. Inside a bundle, `validate` uses the stored label, which is the base name of the file that was added. Labels that do not match a type are skipped. Syft labels inside a bundle are skipped. @@ -82,18 +86,19 @@ gatecheck config convert --file gatecheck.yaml --output toml `config convert` reads `-f` / `--file` (default `gatecheck.yaml`). The extension selects the decoder: `.yaml`, `.yml`, `.json`, or `.toml`. -A partial file is fine. Omitted fields keep the default. Unknown keys are ignored. With no config, `validate` uses the built-in default: every rule disabled, coverage thresholds `0`. Grype, CycloneDX, Trivy, Semgrep, Gitleaks, coverage, and bundle reports pass under that default. A Syft filename still returns an error. +A partial file is fine. Omitted fields keep the default. Unknown keys are ignored. With no config, `validate` uses the built-in default: every rule disabled, coverage thresholds `0`. Grype, CycloneDX, Trivy, osv-scanner, Semgrep, Gitleaks, coverage, and bundle reports pass under that default. A Syft filename still returns an error. `GATECHECK_CONFIG_FILENAME` is the same setting as `validate --config` / `-f`. Field names below are the ones `config init` emits. In YAML they are case sensitive. `version` (currently `"1"`) and `metadata.tags` do not change validation. -`grype`, `cyclonedx`, and `trivy` use the same fields. +`grype`, `cyclonedx`, `trivy`, and `osv-scanner` use the same fields. The config key is `osv-scanner`. - `severityLimit` has `critical`, `high`, `medium`, and `low`. Each has `enabled` and `limit`. `limit` is a count. The report fails when the count is greater than `limit`. A count equal to `limit` passes. `enabled: true` and `limit: 0` allows none. `enabled: false` skips that severity. `negligible` and `unknown` have no limit field. - `epssLimit.score` and `epssRiskAcceptance.score` are EPSS ratios from 0 to 1, the same scale as the FIRST CSV. The limit fails when a CVE's score is greater than `score`. A score equal to the limit passes. A CVE with no EPSS row passes the limit. Risk acceptance removes a CVE when `score` is greater than that CVE's score. An equal score is kept. A CVE with no EPSS row is kept. - `kevLimitEnabled` fails the report when any remaining CVE is in the CISA catalog. -- `cveLimit` and `cveRiskAcceptance` each have `enabled` and `cves`. A CVE entry is `id` plus an optional `metadata.tags` list. `id` is compared without regard to case. Trivy matches `VulnerabilityID` and each `VendorIDs` entry, so a GHSA primary id still matches a CVE in `VendorIDs`. EPSS and KEV use the first id that starts with `CVE-`. +- `cveLimit` and `cveRiskAcceptance` each have `enabled` and `cves`. A CVE entry is `id` plus an optional `metadata.tags` list. `id` is compared without regard to case. Trivy matches `VulnerabilityID` and each `VendorIDs` entry, so a GHSA primary id still matches a CVE in `VendorIDs`. osv-scanner matches the OSV id and each alias. EPSS and KEV use the first id that starts with `CVE-`. +- osv-scanner `max_severity` is a CVSS base score: `9.0` and above is critical, `7.0` high, `4.0` medium, `0.1` low, and anything lower is unknown. The words `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `MODERATE`, and `UNKNOWN` are accepted. `MODERATE` is medium. A CVSS vector with no numeric score is unknown. `database_specific.severity` is used when `max_severity` is empty. `semgrep.severityLimit` has `error`, `warning`, and `info`, each with `enabled` and `limit`, with the same count rule. `impactRiskAcceptance` has `enabled`, `high`, `medium`, and `low`. When `enabled` is true, a finding is removed when its impact matches a level whose value is true. @@ -101,10 +106,10 @@ Field names below are the ones `config init` emits. In YAML they are case sensit `coverage.lineThreshold`, `functionThreshold`, and `branchThreshold` are ratios from 0 to 1: covered lines, functions, or branches divided by the total. The report fails when its ratio is less than the threshold. A ratio equal to the threshold passes. `0.8` is 80 percent. `80` fails a report whose real line coverage is 0.8, because 0.8 is less than 80. There is no `enabled` flag. The default `0` allows any coverage, because a ratio is never less than 0. `list` prints these numbers as percents in the table footer. The config still wants the ratio. -Grype, CycloneDX, and Trivy rules run in this order. A failure returns immediately, except where a rule only removes findings. Trivy counts vulnerability entries only. +Grype, CycloneDX, Trivy, and osv-scanner rules run in this order. A failure returns immediately, except where a rule only removes findings. Trivy counts vulnerability entries only. osv-scanner counts one finding per group. 1. CVE limit, when enabled. -2. Disabled Grype and Trivy severities are dropped. If the EPSS limit is also enabled, a finding at a disabled severity stays when its EPSS score is greater than or equal to that limit. CycloneDX leaves findings at a disabled severity in the report. +2. Disabled Grype, Trivy, and osv-scanner severities are dropped. If the EPSS limit is also enabled, a finding at a disabled severity stays when its EPSS score is greater than or equal to that limit. CycloneDX leaves findings at a disabled severity in the report. 3. CVE risk acceptance removes matching ids. 4. KEV limit, when `kevLimitEnabled` is true. 5. EPSS risk acceptance. @@ -132,7 +137,7 @@ Opening the report, the config, and any `--epss-filename` or `--kev-filename` ha `--epss-filename` is the EPSS CSV used when an EPSS rule is enabled. `--kev-filename` is the CISA KEV JSON used when `kevLimitEnabled` is true. `--epss-cache-dir` and `--epss-no-cache` apply when that CSV is downloaded. `validate` has no `--epss-url` or `--kev-url` flag. `GATECHECK_EPSS_URL` and `GATECHECK_KEV_URL` still override those APIs when the matching file is not set. The defaults are the same URLs `download` uses. -EPSS data is loaded only when an EPSS limit or EPSS risk acceptance rule is enabled. KEV data is loaded only when `kevLimitEnabled` is true for Grype, CycloneDX, or Trivy. With no local EPSS file, gatecheck downloads the current CSV and caches it until the published date changes. +EPSS data is loaded only when an EPSS limit or EPSS risk acceptance rule is enabled. KEV data is loaded only when `kevLimitEnabled` is true for Grype, CycloneDX, Trivy, or osv-scanner. With no local EPSS file, gatecheck downloads the current CSV and caches it until the published date changes. ## list @@ -143,9 +148,9 @@ grype image:tag -o json | gatecheck ls --input-type grype gatecheck list-all grype-report.json semgrep-report.json ``` -`list` opens the first filename only. Further filenames are ignored. `--markdown` prints a markdown table. A Grype, CycloneDX, Semgrep, Gitleaks, or Trivy report with no findings prints one sentence and no table, with or without `--markdown`: `No grype vulnerabilities`, `No cyclonedx vulnerabilities`, `No semgrep findings`, `No gitleaks findings`, or `No trivy vulnerabilities`. `--epss` adds EPSS score and percentile columns for Grype, CycloneDX, and Trivy. Other report types still list, and those columns are omitted. `--epss-url` empty uses `https://epss.cyentia.com`. `--epss-filename` reads that CSV instead of calling the API. +`list` opens the first filename only. Further filenames are ignored. `--markdown` prints a markdown table. A Grype, CycloneDX, Semgrep, Gitleaks, Trivy, or osv-scanner report with no findings prints one sentence and no table, with or without `--markdown`: `No grype vulnerabilities`, `No cyclonedx vulnerabilities`, `No semgrep findings`, `No gitleaks findings`, `No trivy vulnerabilities`, or `No osv-scanner vulnerabilities`. `--epss` adds EPSS score and percentile columns for Grype, CycloneDX, Trivy, and osv-scanner. Other report types still list, and those columns are omitted. `--epss-url` empty uses `https://epss.cyentia.com`. `--epss-filename` reads that CSV instead of calling the API. -`list-all` prints each filename, then its table, or that sentence when the report has no findings. A file whose name is not a supported `list-all` type is skipped with a warning. A missing file is skipped. A list error is logged and the command continues. The command exits 0. `--epss` adds EPSS data only for filenames that contain `grype`, `cyclonedx`, or `trivy`. There is no `--epss-filename` flag, and `GATECHECK_EPSS_FILENAME` is not read. `GATECHECK_EPSS_URL` overrides the API. `--epss-cache-dir` and `--epss-no-cache` are accepted. +`list-all` prints each filename, then its table, or that sentence when the report has no findings. A file whose name is not a supported `list-all` type is skipped with a warning. A missing file is skipped. A list error is logged and the command continues. The command exits 0. `--epss` adds EPSS data only for filenames that contain `grype`, `cyclonedx`, `trivy`, or `osv`. There is no `--epss-filename` flag, and `GATECHECK_EPSS_FILENAME` is not read. `GATECHECK_EPSS_URL` overrides the API. `--epss-cache-dir` and `--epss-no-cache` are accepted. ## bundle @@ -186,7 +191,7 @@ A non-empty flag wins. An empty flag falls through to the environment variable, | `GATECHECK_EPSS_CACHE` | `download epss`, `list`, `list-all`, `validate` | `--epss-no-cache` when the value is `off` | | `GATECHECK_KEV_URL` | `download kev`, `validate` | `--kev-url` on `download kev` | | `GATECHECK_BUNDLE_TAG` | `bundle create`, `bundle add` | `--tag`, as a comma-separated list | -| `GATECHECK_HOME` | `bootstrap`, `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy` | data directory; binaries are in `bin` under it | +| `GATECHECK_HOME` | `bootstrap`, `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy`, `osv-scanner` | data directory; binaries are in `bin` under it | `GATECHECK_EPSS_CACHE` also disables the cache for `0`, `false`, `no`, `disabled`, `disable`, and `none`. diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 56567e0..5d10074 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -8,7 +8,7 @@ gatecheck bootstrap --tools grype,syft gatecheck bootstrap --list ``` -Pinned versions are compiled into gatecheck. The current pins are Grype `0.121.0`, Syft `1.55.0`, Gitleaks `8.30.1`, semgrep `1.182.1`, and Trivy `0.76.1`. They live in `internal/bootstrap/versions.go`. +Pinned versions are compiled into gatecheck. The current pins are Grype `0.121.0`, Syft `1.55.0`, Gitleaks `8.30.1`, semgrep `1.182.1`, Trivy `0.76.1`, and osv-scanner `2.7.0`. They live in `internal/bootstrap/versions.go`. ## Directory @@ -25,17 +25,18 @@ A second run that already has the pinned file and its version stamp prints `/json`, picks the wheel for this machine, and checks the wheel against the SHA-256 in that JSON. @@ -97,11 +110,12 @@ gatecheck syft dir:. -o cyclonedx-json > cyclonedx-syft.json gatecheck gitleaks detect --report-format json --report-path gitleaks-report.json gatecheck semgrep scan -e 'eval($X)' -l python --json t.py > semgrep-report.json gatecheck trivy fs --format json --output trivy-report.json . +gatecheck osv-scanner scan source --format json . > osv-scanner-report.json ``` Arguments, standard input, standard output, standard error, and the scanner's exit status are passed through. `gatecheck grype --help` is Grype's help. gatecheck does not parse flags after these command names. -The managed binary is used when it is present. Otherwise gatecheck looks on `PATH`: `grype`, `syft`, `gitleaks`, or `trivy` for those commands; `semgrep` and then `osemgrep` for `gatecheck semgrep`; `osemgrep` only for `gatecheck osemgrep`. `--experimental` is inserted only when the program that will run is named `osemgrep`. A Python `semgrep` on `PATH` is left unchanged. +The managed binary is used when it is present. Otherwise gatecheck looks on `PATH`: `grype`, `syft`, `gitleaks`, `trivy`, or `osv-scanner` for those commands; `semgrep` and then `osemgrep` for `gatecheck semgrep`; `osemgrep` only for `gatecheck osemgrep`. `--experimental` is inserted only when the program that will run is named `osemgrep`. A Python `semgrep` on `PATH` is left unchanged. When neither copy exists, the command prints one line and exits 255: @@ -120,3 +134,5 @@ CycloneDX is a report format. Syft writes it with `-o cyclonedx-json`, and Grype Syft's own JSON is still rejected. A filename containing `syft` is recognized, and `list` and `validate` then return an error. Syft is installed so it can write CycloneDX. Trivy JSON (`trivy fs --format json`, `trivy image --format json`, and the other Trivy scanners' JSON output) is a report type. A filename containing `trivy` is scored. `cyclonedx` is checked first, so `cyclonedx-trivy-sbom.json` stays CycloneDX. + +osv-scanner JSON (`osv-scanner scan source --format json`) is a report type. A filename containing `osv` is scored, so `osv-scanner-report.json` works. Each vulnerability group is one finding. Aliases of that group are not counted again. `max_severity` is a CVSS base score. License-only packages are skipped. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 069cdf6..3e2a130 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -67,13 +67,13 @@ gatecheck list [FILE] Aliases: `ls`, `print`. -Prints a table for one report. The first filename is the one that is opened. Further filenames are ignored. Use `list-all` for more than one file. A Grype, CycloneDX, Semgrep, Gitleaks, or Trivy report with no findings prints one sentence and no table: `No grype vulnerabilities`, `No cyclonedx vulnerabilities`, `No semgrep findings`, `No gitleaks findings`, or `No trivy vulnerabilities`. `--markdown` prints that same sentence. +Prints a table for one report. The first filename is the one that is opened. Further filenames are ignored. Use `list-all` for more than one file. A Grype, CycloneDX, Semgrep, Gitleaks, Trivy, or osv-scanner report with no findings prints one sentence and no table: `No grype vulnerabilities`, `No cyclonedx vulnerabilities`, `No semgrep findings`, `No gitleaks findings`, `No trivy vulnerabilities`, or `No osv-scanner vulnerabilities`. `--markdown` prints that same sentence. With no filename, the command reads standard input and `--input-type` is required. -- `-i`, `--input-type`: type word used when reading standard input. It is matched with the same rules as a filename. `grype`, `cyclonedx`, `semgrep`, `gitleaks`, `syft`, `bundle`, `trivy`, and the coverage words all work. See [Supported Reports](./supported-reports.md). +- `-i`, `--input-type`: type word used when reading standard input. It is matched with the same rules as a filename. `grype`, `cyclonedx`, `semgrep`, `gitleaks`, `syft`, `bundle`, `trivy`, `osv`, and the coverage words all work. See [Supported Reports](./supported-reports.md). - `--markdown`: print a markdown table. -- `--epss`: add EPSS score and percentile columns. This changes the table for Grype and CycloneDX. Other report types still list, and the EPSS data is not shown. With no local file, gatecheck downloads the current EPSS CSV and caches it until the published date changes. Requests send a User-Agent with the gatecheck version and project URL. +- `--epss`: add EPSS score and percentile columns. This changes the table for Grype, CycloneDX, Trivy, and osv-scanner. Other report types still list, and the EPSS data is not shown. With no local file, gatecheck downloads the current EPSS CSV and caches it until the published date changes. Requests send a User-Agent with the gatecheck version and project URL. - `--epss-filename`: read that CSV instead of calling the API. `GATECHECK_EPSS_FILENAME` is the same setting. - `--epss-url`: EPSS API URL. An empty value uses `https://epss.cyentia.com`. `GATECHECK_EPSS_URL` is the same setting. - `--epss-cache-dir`: cache directory for that download. `GATECHECK_EPSS_CACHE_DIR` is the same setting. @@ -94,7 +94,7 @@ gatecheck list-all [FILE...] Prints each filename, then its table, or one sentence when that report has no findings. A file whose name is not a supported `list-all` type is skipped with a warning. A missing file is skipped. A list error is logged and the command continues. The command exits 0 after the last file. - `--markdown`: print markdown tables. -- `--epss`: download EPSS data and add it for filenames that contain `grype`, `cyclonedx`, or `trivy`. The download happens per file and is cached until the published date changes. There is no `--epss-filename` flag. `GATECHECK_EPSS_URL` overrides the API. `GATECHECK_EPSS_FILENAME` is not read by this command. Requests send a User-Agent with the gatecheck version and project URL. +- `--epss`: download EPSS data and add it for filenames that contain `grype`, `cyclonedx`, `trivy`, or `osv`. The download happens per file and is cached until the published date changes. There is no `--epss-filename` flag. `GATECHECK_EPSS_URL` overrides the API. `GATECHECK_EPSS_FILENAME` is not read by this command. Requests send a User-Agent with the gatecheck version and project URL. - `--epss-cache-dir`: cache directory for that download. `GATECHECK_EPSS_CACHE_DIR` is the same setting. - `--epss-no-cache`: download on every run. `GATECHECK_EPSS_CACHE=off` does the same. @@ -198,9 +198,9 @@ gatecheck download kev > kev.json gatecheck bootstrap ``` -Downloads Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, and Trivy into the gatecheck data directory. Each GitHub asset is checked against the project's SHA-256 checksum file. The semgrep wheel is checked against the digest in the PyPI JSON. A tool already at the pinned version is skipped. See [Bootstrap](./bootstrap.md). +Downloads Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, Trivy, and osv-scanner into the gatecheck data directory. Each GitHub asset is checked against the project's SHA-256 checksum file. The semgrep wheel is checked against the digest in the PyPI JSON. A tool already at the pinned version is skipped. See [Bootstrap](./bootstrap.md). -- `--tools`: comma-separated scanners. Default `grype,syft,gitleaks,semgrep,trivy`. `osemgrep` selects semgrep. +- `--tools`: comma-separated scanners. Default `grype,syft,gitleaks,semgrep,trivy,osv-scanner`. `osemgrep` selects semgrep. - `--force`: download again when the pinned version is already installed. - `--list`: print pinned version, installed version, and path. This does not download. @@ -211,7 +211,7 @@ gatecheck bootstrap gatecheck bootstrap --list ``` -## gatecheck grype, syft, gitleaks, semgrep, osemgrep, trivy +## gatecheck grype, syft, gitleaks, semgrep, osemgrep, trivy, osv-scanner ``` gatecheck grype [scanner arguments] @@ -220,6 +220,7 @@ gatecheck gitleaks [scanner arguments] gatecheck semgrep [scanner arguments] gatecheck osemgrep [scanner arguments] gatecheck trivy [scanner arguments] +gatecheck osv-scanner [scanner arguments] ``` Runs the managed binary. Arguments and streams are passed through, and the process exits with the scanner's status. Flags after the command name are not parsed by gatecheck, so `gatecheck grype --help` is Grype's help. @@ -305,4 +306,4 @@ Prints help for a command. `gatecheck validate --help` does the same. | `GATECHECK_EPSS_CACHE` | `download epss`, `list`, `list-all`, `validate` | `--epss-no-cache` when the value is `off` | | `GATECHECK_KEV_URL` | `download kev`, `validate` | `--kev-url` on `download kev` | | `GATECHECK_BUNDLE_TAG` | `bundle create`, `bundle add` | `--tag`, as a comma-separated list | -| `GATECHECK_HOME` | `bootstrap`, `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy` | data directory; binaries are in `bin` under it | +| `GATECHECK_HOME` | `bootstrap`, `grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy`, `osv-scanner` | data directory; binaries are in `bin` under it | diff --git a/docs/configuration.md b/docs/configuration.md index 2d1e17b..56e0380 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,6 +109,33 @@ trivy: cveRiskAcceptance: enabled: false cves: [] +osv-scanner: + severityLimit: + critical: + enabled: false + limit: 0 + high: + enabled: false + limit: 0 + medium: + enabled: false + limit: 0 + low: + enabled: false + limit: 0 + epssLimit: + enabled: false + score: 0 + kevLimitEnabled: false + cveLimit: + enabled: false + cves: [] + epssRiskAcceptance: + enabled: false + score: 0 + cveRiskAcceptance: + enabled: false + cves: [] semgrep: severityLimit: error: @@ -137,9 +164,11 @@ coverage: `version` is the string `"1"`. `metadata.tags` is a list of strings. Neither one changes validation. -## Grype, CycloneDX, and Trivy +## Grype, CycloneDX, Trivy, and osv-scanner + +`grype`, `cyclonedx`, `trivy`, and `osv-scanner` use the same fields. Trivy scores vulnerability entries. Misconfigurations, secrets, and licenses in the Trivy JSON are ignored. A Trivy id matches `VulnerabilityID` and each `VendorIDs` entry. -`grype`, `cyclonedx`, and `trivy` use the same fields. Trivy scores vulnerability entries. Misconfigurations, secrets, and licenses in the Trivy JSON are ignored. A Trivy id matches `VulnerabilityID` and each `VendorIDs` entry. +osv-scanner counts one finding per vulnerability group. Aliases of that group are not counted again. License-only packages are skipped. `max_severity` is a CVSS base score: `9.0` and above is critical, `7.0` high, `4.0` medium, `0.1` low, and anything lower is unknown. The words `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `MODERATE`, and `UNKNOWN` are also accepted. `MODERATE` is medium. A CVSS vector with no numeric score is unknown. `database_specific.severity` is used when `max_severity` is empty. Deny, allow, and KEV match the OSV id or any alias. EPSS and KEV look up the first id that starts with `CVE-`. `severityLimit` has `critical`, `high`, `medium`, and `low`. Each has `enabled` and `limit`. `limit` is a count. The report fails when the number of findings at that severity is greater than `limit`. `enabled: true` and `limit: 0` allows none. `enabled: false` skips that severity. diff --git a/docs/examples.md b/docs/examples.md index 486bc1c..17b6a58 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -47,6 +47,9 @@ gatecheck validate --config gatecheck.yaml semgrep-report.json gatecheck bootstrap --tools trivy gatecheck trivy fs --format json --output trivy-report.json . gatecheck validate --config gatecheck.yaml trivy-report.json +gatecheck bootstrap --tools osv-scanner +gatecheck osv-scanner scan source --format json . > osv-scanner-report.json +gatecheck validate --config gatecheck.yaml osv-scanner-report.json ``` `gatecheck semgrep scan` adds `--experimental` when you omit it. That flag selects semgrep's experimental native CLI, osemgrep, which writes the same Semgrep JSON `list` and `validate` already accept. `gatecheck semgrep --experimental --version` prints the osemgrep version. diff --git a/docs/gatecheck-bundle.md b/docs/gatecheck-bundle.md index d58b158..4645ecc 100644 --- a/docs/gatecheck-bundle.md +++ b/docs/gatecheck-bundle.md @@ -8,7 +8,7 @@ A bundle is a gzipped tar with a JSON manifest. It holds the report bytes, a SHA gatecheck bundle create gatecheck-bundle.tar.gz grype-report.json --tag ci ``` -`bundle init` is the same command. The label stored in the manifest is the base name of the target file. That label is what `validate` uses later to pick the report type, so the file needs a word such as `grype`, `semgrep`, or `trivy` in its name. +`bundle init` is the same command. The label stored in the manifest is the base name of the target file. That label is what `validate` uses later to pick the report type, so the file needs a word such as `grype`, `semgrep`, `trivy`, or `osv` in its name. Repeat `--tag` for more than one tag. When the flag is omitted, `GATECHECK_BUNDLE_TAG` is read as a comma-separated list. diff --git a/docs/installation.md b/docs/installation.md index e728f2e..fdcc909 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -64,7 +64,7 @@ gatecheck --help ## Install scanners -After gatecheck itself is installed, `gatecheck bootstrap` downloads pinned releases of Grype, Syft, Gitleaks, and Trivy, and installs semgrep's experimental native CLI as `osemgrep`. The files live under the gatecheck data directory (`GATECHECK_HOME`, or the platform default described in [Bootstrap](./bootstrap.md)). +After gatecheck itself is installed, `gatecheck bootstrap` downloads pinned releases of Grype, Syft, Gitleaks, Trivy, and osv-scanner, and installs semgrep's experimental native CLI as `osemgrep`. The files live under the gatecheck data directory (`GATECHECK_HOME`, or the platform default described in [Bootstrap](./bootstrap.md)). ``` gatecheck bootstrap diff --git a/docs/library-usage.md b/docs/library-usage.md index 563e9b9..6c054b3 100644 --- a/docs/library-usage.md +++ b/docs/library-usage.md @@ -10,7 +10,7 @@ import "github.com/gatecheckdev/gatecheck/pkg/gatecheck" - `NewConfigDecoder` reads `.yaml`, `.yml`, `.json`, or `.toml`. The extension selects the format. - `NewConfigEncoder` writes a config. Pass `.yaml`, `.yml`, `.json`, or `.toml`, including the dot. - `Validate` takes the config, a reader, and the filename. The filename selects the report type with the same words as the CLI. -- `List` writes a table. A Grype, CycloneDX, Semgrep, Gitleaks, or Trivy report with no findings writes one sentence instead, such as `No gitleaks findings` or `No trivy vulnerabilities`. `WithDisplayFormat("markdown")` matches `--markdown` and writes that same sentence when the report is empty. `WithEPSS` matches `--epss` and takes an open CSV file or a URL. +- `List` writes a table. A Grype, CycloneDX, Semgrep, Gitleaks, Trivy, or osv-scanner report with no findings writes one sentence instead, such as `No gitleaks findings` or `No osv-scanner vulnerabilities`. `WithDisplayFormat("markdown")` matches `--markdown` and writes that same sentence when the report is empty. `WithEPSS` matches `--epss` and takes an open CSV file or a URL. - `CreateBundle`, `AppendToBundle`, and `RemoveFromBundle` are `bundle create`, `bundle add`, and `bundle remove`. - `DownloadEPSS` and `DownloadKEV` write the API payloads. `WithEPSSURL`, `WithKEVURL`, `WithEPSSFile`, and `WithKEVFile` override the defaults. diff --git a/docs/list-reports.md b/docs/list-reports.md index f5862c4..ddaf4f3 100644 --- a/docs/list-reports.md +++ b/docs/list-reports.md @@ -24,7 +24,7 @@ The filename (or `--input-type`) has to contain the lowercase report word. `gryp `--markdown` prints a markdown table instead of the plain text table. -`--epss` adds the EPSS score and percentile for Grype and CycloneDX. Other reports ignore that data. With no `--epss-filename`, gatecheck downloads the CSV from FIRST. `GATECHECK_EPSS_FILENAME` and `GATECHECK_EPSS_URL` are the same settings as those flags. +`--epss` adds the EPSS score and percentile for Grype, CycloneDX, Trivy, and osv-scanner. Other reports ignore that data. With no `--epss-filename`, gatecheck downloads the CSV from FIRST. `GATECHECK_EPSS_FILENAME` and `GATECHECK_EPSS_URL` are the same settings as those flags. ```shell gatecheck download epss > epss.csv @@ -38,10 +38,11 @@ Columns: - Semgrep: check id, OWASP ids, severity, impact, link. Runtime errors from the report are logged as warnings. - Gitleaks: rule id, file, commit, start line. - Trivy: severity, package, version, link. With `--epss`: CVE id, severity, EPSS score, EPSS percentile, package, version, link. Misconfigurations are omitted. +- osv-scanner: severity, package, version, link. With `--epss`: id, severity, EPSS score, EPSS percentile, package, version, link. The id is the first CVE alias, or the OSV id when there is no CVE. Groups are listed once. - Coverage: lines, functions, and branches covered, with a percent in the footer. The percent is only for display. The config file uses a ratio. See [Configuration](./configuration.md). - Bundle: label, SHA-256 digest, tags, size. -A Grype, CycloneDX, Semgrep, Gitleaks, or Trivy report with no findings prints one sentence and no table. The lines are `No grype vulnerabilities`, `No cyclonedx vulnerabilities`, `No semgrep findings`, `No gitleaks findings`, and `No trivy vulnerabilities`. `--markdown` prints that same line. +A Grype, CycloneDX, Semgrep, Gitleaks, Trivy, or osv-scanner report with no findings prints one sentence and no table. The lines are `No grype vulnerabilities`, `No cyclonedx vulnerabilities`, `No semgrep findings`, `No gitleaks findings`, `No trivy vulnerabilities`, and `No osv-scanner vulnerabilities`. `--markdown` prints that same line. `list` opens the first filename only. `list-all` walks every filename, prints the name, then the table or that sentence, and skips files it does not support. Coverage files are in that skipped set. `--epss` on `list-all` always uses the API (`GATECHECK_EPSS_URL` to override it). diff --git a/docs/supported-reports.md b/docs/supported-reports.md index d1aad4b..6fbd5ff 100644 --- a/docs/supported-reports.md +++ b/docs/supported-reports.md @@ -7,11 +7,12 @@ 3. `semgrep`: Semgrep JSON. 4. `gitleaks`: Gitleaks JSON (a list of findings). 5. `trivy`: Trivy JSON (`--format json`). Vulnerability entries are listed and validated. Misconfigurations, secrets, and licenses in the same file are ignored. -6. `syft`: the name is recognized. `list` and `validate` both return an error. Syft is not implemented. -7. `bundle`: a Gatecheck bundle. `list` also accepts a name containing `gatecheck`. `validate` requires the word `bundle`. `project.gcb` matches neither rule. -8. Coverage, when the name contains `lcov`, `clover`, `cobertura`, or `coverage`, or when it ends in `.info`. +6. `osv`: osv-scanner JSON (`osv-scanner scan source --format json`). One finding per vulnerability group. Aliases of that group are not counted again. License-only packages are skipped. +7. `syft`: the name is recognized. `list` and `validate` both return an error. Syft is not implemented. +8. `bundle`: a Gatecheck bundle. `list` also accepts a name containing `gatecheck`. `validate` requires the word `bundle`. `project.gcb` matches neither rule. +9. Coverage, when the name contains `lcov`, `clover`, `cobertura`, or `coverage`, or when it ends in `.info`. -The match is case sensitive. `Grype-report.json` and `CycloneDX.json` are rejected. +The match is case sensitive. `Grype-report.json`, `CycloneDX.json`, and `OSV-scanner.json` are rejected. Coverage file format, checked in this order: @@ -27,7 +28,7 @@ From standard input, pass the same word to `--input-type`. The value is matched grype bkimminich/juice-shop:latest -o json | gatecheck list --input-type grype ``` -`list-all` is stricter. It lists a file only when the name contains `grype`, `semgrep`, `gitleaks`, `syft`, `cyclonedx`, `bundle`, `gatecheck`, or `trivy`. Coverage files are skipped with a warning. There is no `--input-type` flag on `list-all`. +`list-all` is stricter. It lists a file only when the name contains `grype`, `semgrep`, `gitleaks`, `syft`, `cyclonedx`, `bundle`, `gatecheck`, `trivy`, or `osv`. Coverage files are skipped with a warning. There is no `--input-type` flag on `list-all`. `cyclonedx` is checked before `trivy`, so `cyclonedx-trivy-sbom.json` is CycloneDX. @@ -42,6 +43,7 @@ What each command can do: | Semgrep | yes | yes | yes | | Gitleaks | yes | yes | yes | | Trivy | yes | yes | yes | +| osv-scanner | yes | yes | yes | | LCOV, Clover, Cobertura | yes | yes | yes | | Syft | error | error | the file is stored, and validation skips that label | | Bundle | when the name contains `bundle` or `gatecheck` | when the name contains `bundle` | yes, as an ordinary file | diff --git a/docs/usage.md b/docs/usage.md index 836869b..b993677 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -12,7 +12,7 @@ Commands: - `gatecheck validate` compares one report, or one bundle, to the config. - `gatecheck bundle create`, `bundle add`, and `bundle remove` manage a bundle. - `gatecheck download epss` and `gatecheck download kev` write the upstream data to standard output. EPSS files are cached until the published date changes. -- `gatecheck bootstrap` installs Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, and Trivy. `gatecheck grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, and `trivy` run those programs. See [Bootstrap](./bootstrap.md). +- `gatecheck bootstrap` installs Grype, Syft, Gitleaks, semgrep's experimental osemgrep CLI, Trivy, and osv-scanner. `gatecheck grype`, `syft`, `gitleaks`, `semgrep`, `osemgrep`, `trivy`, and `osv-scanner` run those programs. See [Bootstrap](./bootstrap.md). - `gatecheck update` (aliases `self-update` and `upgrade`) replaces this binary with a GitHub release after checking the archive checksum. `gatecheck update --check` only prints the current and latest versions. - `gatecheck agent-guide` (alias `llm`) prints a markdown guide for coding agents. - `gatecheck completion` prints a shell completion script. `gatecheck help [command]` prints help for that command. diff --git a/docs/validation.md b/docs/validation.md index 050f089..9c5506d 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -9,7 +9,7 @@ Pass the config with `--config` or `-f`, or with `GATECHECK_CONFIG_FILENAME`. Th The report type comes from the filename. See [Supported Reports](./supported-reports.md). -With no config, gatecheck uses the default: every rule disabled, coverage thresholds `0`. Grype, CycloneDX, Trivy, Semgrep, Gitleaks, coverage, and bundle reports pass under that default. A Syft filename still returns an error. +With no config, gatecheck uses the default: every rule disabled, coverage thresholds `0`. Grype, CycloneDX, Trivy, osv-scanner, Semgrep, Gitleaks, coverage, and bundle reports pass under that default. A Syft filename still returns an error. ## Exit status @@ -19,14 +19,16 @@ With no config, gatecheck uses the default: every rule disabled, coverage thresh `--audit` (`-a`) prints whatever error the check returns and exits 0. That covers a failed threshold, an unknown filename, a Syft file, a decode error, and a failed EPSS or KEV fetch. Opening the report, the config, and any `--epss-filename` or `--kev-filename` happens before the check. A missing file still exits 255. -## Grype, CycloneDX, and Trivy +## Grype, CycloneDX, Trivy, and osv-scanner Trivy uses these same rules. It counts vulnerability entries from `Results[].Vulnerabilities`. Misconfigurations, secrets, and licenses are not scored. An id matches `VulnerabilityID` or a `VendorIDs` entry, without regard to case. EPSS and KEV look up the first id that starts with `CVE-`. +osv-scanner uses these same rules. It counts one finding per vulnerability group, so aliases are not double-counted. License-only packages are skipped. `max_severity` is a CVSS base score (`9.8` is critical, `7.5` is high, `4.0` is medium, `0.1` is low). The words `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `MODERATE`, and `UNKNOWN` are accepted, and `MODERATE` maps to medium. A CVSS vector with no numeric score is unknown unless `database_specific.severity` supplies a rating. Deny, allow, and KEV match the OSV id or any alias. EPSS and KEV look up the first id that starts with `CVE-`. + Rules run in this order. A failure returns immediately, except where a rule only removes findings. 1. **CVE limit.** `cveLimit`, when enabled, fails if any listed id is in the report. Ids are compared without regard to case. -2. **Disabled Grype and Trivy severities.** Grype and Trivy drop findings whose severity limit is disabled. If the EPSS limit is also enabled, a finding at a disabled severity stays in the report when it has an EPSS score greater than or equal to that limit. CycloneDX leaves findings at a disabled severity in the report, so they still go through the KEV and EPSS rules. +2. **Disabled Grype, Trivy, and osv-scanner severities.** Grype, Trivy, and osv-scanner drop findings whose severity limit is disabled. If the EPSS limit is also enabled, a finding at a disabled severity stays in the report when it has an EPSS score greater than or equal to that limit. CycloneDX leaves findings at a disabled severity in the report, so they still go through the KEV and EPSS rules. 3. **CVE risk acceptance.** Matching ids are removed and skip the remaining rules. 4. **KEV limit.** When `kevLimitEnabled` is true, a CVE that is in the catalog fails the report. The catalog is `--kev-filename` or, with no file, the CISA feed. `GATECHECK_KEV_URL` overrides the feed. There is no `--kev-url` flag on `validate`. 5. **EPSS risk acceptance.** A CVE is removed when the configured `score` is greater than that CVE's EPSS score. A CVE with no EPSS row is kept. A CVE whose score is equal to the configured score is kept. diff --git a/internal/bootstrap/assets.go b/internal/bootstrap/assets.go index 8c994bd..831c224 100644 --- a/internal/bootstrap/assets.go +++ b/internal/bootstrap/assets.go @@ -10,6 +10,8 @@ type githubAsset struct { Checksum string Binary string DestName string + // Raw means the release asset is the binary itself, not an archive. + Raw bool } func grypeAsset(version string, p Platform) (githubAsset, error) { @@ -212,6 +214,38 @@ func trivyOSArch(p Platform) (string, string, bool) { return "", "", false } +func osvScannerAsset(version string, p Platform) (githubAsset, error) { + goos, arch, ok := osvOSArch(p) + if !ok { + return githubAsset{}, unsupported("osv-scanner", p) + } + asset := fmt.Sprintf("osv-scanner_%s_%s", goos, arch) + if p.GOOS == "windows" { + asset += ".exe" + } + return githubAsset{ + Asset: asset, + Checksum: "osv-scanner_SHA256SUMS", + Binary: asset, + DestName: exeName("osv-scanner", p.GOOS), + Raw: true, + }, nil +} + +func osvOSArch(p Platform) (string, string, bool) { + switch p.GOOS { + case "darwin", "linux", "windows": + default: + return "", "", false + } + switch p.GOARCH { + case "amd64", "arm64": + return p.GOOS, p.GOARCH, true + default: + return "", "", false + } +} + func exeName(name, goos string) string { if goos == "windows" && !strings.HasSuffix(strings.ToLower(name), ".exe") { return name + ".exe" diff --git a/internal/bootstrap/assets_test.go b/internal/bootstrap/assets_test.go index 18fa2c9..b979e91 100644 --- a/internal/bootstrap/assets_test.go +++ b/internal/bootstrap/assets_test.go @@ -117,6 +117,49 @@ func TestReleaseAssets(t *testing.T) { if _, err := trivyAsset(TrivyVersion, Platform{GOOS: "darwin", GOARCH: "386"}); err == nil { t.Fatal("trivy darwin/386 should fail") } + + osvCases := []struct { + p Platform + want string + }{ + {Platform{GOOS: "linux", GOARCH: "amd64"}, "osv-scanner_linux_amd64"}, + {Platform{GOOS: "linux", GOARCH: "arm64"}, "osv-scanner_linux_arm64"}, + {Platform{GOOS: "darwin", GOARCH: "amd64"}, "osv-scanner_darwin_amd64"}, + {Platform{GOOS: "darwin", GOARCH: "arm64"}, "osv-scanner_darwin_arm64"}, + {Platform{GOOS: "windows", GOARCH: "amd64"}, "osv-scanner_windows_amd64.exe"}, + {Platform{GOOS: "windows", GOARCH: "arm64"}, "osv-scanner_windows_arm64.exe"}, + } + for _, tc := range osvCases { + t.Run(tc.p.String(), func(t *testing.T) { + t.Parallel() + got, err := osvScannerAsset(OSVScannerVersion, tc.p) + if err != nil { + t.Fatal(err) + } + if got.Asset != tc.want || !got.Raw { + t.Fatalf("asset %+v", got) + } + if got.Checksum != "osv-scanner_SHA256SUMS" { + t.Fatalf("checksum %s", got.Checksum) + } + dest := "osv-scanner" + if tc.p.GOOS == "windows" { + dest = "osv-scanner.exe" + } + if got.DestName != dest { + t.Fatalf("dest %s", got.DestName) + } + }) + } + if _, err := osvScannerAsset(OSVScannerVersion, Platform{GOOS: "linux", GOARCH: "386"}); err == nil { + t.Fatal("osv-scanner linux/386 should fail") + } + if _, err := osvScannerAsset(OSVScannerVersion, Platform{GOOS: "darwin", GOARCH: "386"}); err == nil { + t.Fatal("osv-scanner darwin/386 should fail") + } + if _, err := osvScannerAsset(OSVScannerVersion, Platform{GOOS: "linux", GOARCH: "arm"}); err == nil { + t.Fatal("osv-scanner linux/arm should fail") + } } func TestSelectWheelRealNames(t *testing.T) { diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index cfec4d6..064a78f 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -125,7 +125,7 @@ func (o Options) userAgent() string { return "gatecheck (+https://github.com/gatecheckdev/gatecheck)" } -var defaultTools = []string{"grype", "syft", "gitleaks", "semgrep", "trivy"} +var defaultTools = []string{"grype", "syft", "gitleaks", "semgrep", "trivy", "osv-scanner"} func canonicalTools(csv string) ([]string, error) { if strings.TrimSpace(csv) == "" { @@ -184,6 +184,12 @@ func installTool(ctx context.Context, opts Options, tool string) error { return err } return installGitHub(ctx, opts, "trivy", "aquasecurity/trivy", TrivyVersion, asset) + case "osv-scanner": + asset, err := osvScannerAsset(OSVScannerVersion, opts.platform()) + if err != nil { + return err + } + return installGitHub(ctx, opts, "osv-scanner", "google/osv-scanner", OSVScannerVersion, asset) default: return fmt.Errorf("unknown tool %q", tool) } @@ -229,13 +235,22 @@ func installGitHub(ctx context.Context, opts Options, tool, repo, version string return fmt.Errorf("%s: %w", tool, err) } - staged := dest + ".new" - defer func() { _ = os.Remove(staged) }() - if err := extractBinary(archivePath, asset.Binary, staged); err != nil { - return fmt.Errorf("%s: %w", tool, err) - } - if err := fetch.Replace(staged, dest); err != nil { - return fmt.Errorf("%s: %w", tool, err) + if asset.Raw { + if err := os.Chmod(archivePath, 0o755); err != nil { + return fmt.Errorf("%s: %w", tool, err) + } + if err := fetch.Replace(archivePath, dest); err != nil { + return fmt.Errorf("%s: %w", tool, err) + } + } else { + staged := dest + ".new" + defer func() { _ = os.Remove(staged) }() + if err := extractBinary(archivePath, asset.Binary, staged); err != nil { + return fmt.Errorf("%s: %w", tool, err) + } + if err := fetch.Replace(staged, dest); err != nil { + return fmt.Errorf("%s: %w", tool, err) + } } if err := writeVersion(binDir, tool, version); err != nil { return fmt.Errorf("%s: %w", tool, err) @@ -403,11 +418,11 @@ func osemgrepPath(binDir string, p Platform) string { func writeList(w io.Writer, home string, tools []string) error { binDir := BinDir(home) - _, _ = fmt.Fprintf(w, "%-10s %-10s %-10s %s\n", "TOOL", "PINNED", "INSTALLED", "PATH") + _, _ = fmt.Fprintf(w, "%-12s %-10s %-10s %s\n", "TOOL", "PINNED", "INSTALLED", "PATH") for _, tool := range tools { pinned := pinnedVersion(tool) installed, path := installedStatus(binDir, tool) - _, _ = fmt.Fprintf(w, "%-10s %-10s %-10s %s\n", tool, pinned, installed, path) + _, _ = fmt.Fprintf(w, "%-12s %-10s %-10s %s\n", tool, pinned, installed, path) } return nil } @@ -424,6 +439,8 @@ func pinnedVersion(tool string) string { return SemgrepVersion case "trivy": return TrivyVersion + case "osv-scanner": + return OSVScannerVersion default: return "-" } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 850fc03..545f6fb 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -491,6 +491,130 @@ func TestInstallTrivyChecksum(t *testing.T) { } } +func TestInstallOSVScannerRawBinary(t *testing.T) { + body := []byte("osv-scanner-binary") + asset, err := osvScannerAsset(OSVScannerVersion, Platform{GOOS: "linux", GOARCH: "arm64"}) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + good := hex.EncodeToString(sum[:]) + mode := "good" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/google/osv-scanner/releases/download/v"+OSVScannerVersion+"/") { + http.Error(w, "bad url", http.StatusBadRequest) + return + } + switch { + case strings.HasSuffix(r.URL.Path, asset.Checksum): + digest := good + if mode == "bad" { + digest = strings.Repeat("ab", 32) + } + _, _ = fmt.Fprintf(w, "%s %s\n", digest, asset.Asset) + case strings.HasSuffix(r.URL.Path, asset.Asset): + _, _ = w.Write(body) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + home := t.TempDir() + var out bytes.Buffer + err = Bootstrap(context.Background(), Options{ + Home: home, + ToolsCSV: "osv-scanner", + Client: srv.Client(), + GitHubBase: srv.URL, + Platform: Platform{GOOS: "linux", GOARCH: "arm64"}, + Stdout: &out, + Stderr: ioDiscard(), + }) + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(home, "bin", "osv-scanner") + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(got) != "osv-scanner-binary" { + t.Fatalf("binary %q", got) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("mode %v", info.Mode()) + } + } + if !strings.Contains(out.String(), "installed osv-scanner "+OSVScannerVersion+" "+dest) { + t.Fatalf("stdout %q", out.String()) + } + if _, err := os.Stat(filepath.Join(home, "bin", ".download-"+asset.Asset)); !errors.Is(err, os.ErrNotExist) { + t.Fatal("download temp was left behind") + } + + winBody := []byte("from-windows") + winAsset, err := osvScannerAsset(OSVScannerVersion, Platform{GOOS: "windows", GOARCH: "arm64"}) + if err != nil { + t.Fatal(err) + } + winSum := sha256.Sum256(winBody) + winSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, winAsset.Checksum): + _, _ = fmt.Fprintf(w, "%x %s\n", winSum, winAsset.Asset) + case strings.HasSuffix(r.URL.Path, winAsset.Asset): + _, _ = w.Write(winBody) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(winSrv.Close) + winHome := t.TempDir() + err = Bootstrap(context.Background(), Options{ + Home: winHome, + ToolsCSV: "osv-scanner", + Client: winSrv.Client(), + GitHubBase: winSrv.URL, + Platform: Platform{GOOS: "windows", GOARCH: "arm64"}, + Stdout: ioDiscard(), + Stderr: ioDiscard(), + }) + if err != nil { + t.Fatal(err) + } + winGot, err := os.ReadFile(filepath.Join(winHome, "bin", "osv-scanner.exe")) + if err != nil { + t.Fatal(err) + } + if string(winGot) != "from-windows" { + t.Fatalf("windows binary %q", winGot) + } + + mode = "bad" + badHome := t.TempDir() + err = Bootstrap(context.Background(), Options{ + Home: badHome, + ToolsCSV: "osv-scanner", + Client: srv.Client(), + GitHubBase: srv.URL, + Platform: Platform{GOOS: "linux", GOARCH: "arm64"}, + Stdout: ioDiscard(), + Stderr: ioDiscard(), + }) + if !errors.Is(err, fetch.ErrChecksumMismatch) { + t.Fatalf("expected checksum mismatch, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(badHome, "bin", "osv-scanner")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatal("checksum mismatch left a binary") + } +} + func TestUnsupportedGrypeAndUnknownTool(t *testing.T) { client := &http.Client{Transport: denyTransport{t: t}} err := Bootstrap(context.Background(), Options{ @@ -516,17 +640,20 @@ func TestListInstalled(t *testing.T) { t.Fatal(err) } text := buf.String() - if !strings.HasPrefix(text, "TOOL PINNED INSTALLED PATH\n") { + if !strings.HasPrefix(text, "TOOL PINNED INSTALLED PATH\n") { t.Fatalf("header %q", text) } - for _, version := range []string{GrypeVersion, SyftVersion, GitleaksVersion, SemgrepVersion, TrivyVersion} { + for _, version := range []string{GrypeVersion, SyftVersion, GitleaksVersion, SemgrepVersion, TrivyVersion, OSVScannerVersion} { if !strings.Contains(text, version) { t.Fatalf("missing %s in %s", version, text) } } - if strings.Count(text, "\n") != 6 { + if strings.Count(text, "\n") != 7 { t.Fatalf("lines %q", text) } + if !strings.Contains(text, "osv-scanner "+OSVScannerVersion) { + t.Fatalf("osv-scanner row %q", text) + } } func TestUnsafeLibPath(t *testing.T) { diff --git a/internal/bootstrap/docs_test.go b/internal/bootstrap/docs_test.go index 2321927..264bd7d 100644 --- a/internal/bootstrap/docs_test.go +++ b/internal/bootstrap/docs_test.go @@ -17,6 +17,7 @@ func TestDocsMentionPins(t *testing.T) { GitleaksVersion, SemgrepVersion, TrivyVersion, + OSVScannerVersion, "GATECHECK_HOME", "osemgrep", "--experimental", diff --git a/internal/bootstrap/versions.go b/internal/bootstrap/versions.go index 68cd37b..c64edd9 100644 --- a/internal/bootstrap/versions.go +++ b/internal/bootstrap/versions.go @@ -14,4 +14,6 @@ const ( SemgrepVersion = "1.182.1" // TrivyVersion is the aquasecurity/trivy GitHub release. TrivyVersion = "0.76.1" + // OSVScannerVersion is the google/osv-scanner GitHub release. + OSVScannerVersion = "2.7.0" ) diff --git a/pkg/artifacts/osv.go b/pkg/artifacts/osv.go new file mode 100644 index 0000000..8a027e2 --- /dev/null +++ b/pkg/artifacts/osv.go @@ -0,0 +1,261 @@ +package artifacts + +import ( + "encoding/json" + "strconv" + "strings" +) + +// OSVReportMin is the vulnerability portion of osv-scanner --format json. +type OSVReportMin struct { + Results []OSVResult `json:"results"` +} + +// OSVResult is one scanned source. +type OSVResult struct { + Source OSVSource `json:"source"` + Packages []OSVPackage `json:"packages"` +} + +// OSVSource is the lockfile, SBOM, or directory that was scanned. +type OSVSource struct { + Path string `json:"path"` + Type string `json:"type"` +} + +// OSVPackage is one affected package. +type OSVPackage struct { + Package OSVPackageInfo `json:"package"` + Vulnerabilities []OSVVulnerability `json:"vulnerabilities"` + Groups []OSVGroup `json:"groups"` +} + +// OSVPackageInfo identifies a package. +type OSVPackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Ecosystem string `json:"ecosystem"` +} + +// OSVVulnerability is one OSV record. Only the fields used for gating are decoded. +type OSVVulnerability struct { + ID string `json:"id"` + Aliases []string `json:"aliases"` + Summary string `json:"summary"` + Severity []OSVSeverity `json:"severity"` + DatabaseSpecific json.RawMessage `json:"database_specific"` +} + +// OSVSeverity is one OSV severity entry. Score is a CVSS vector or a numeric score. +type OSVSeverity struct { + Type string `json:"type"` + Score string `json:"score"` +} + +// OSVGroup aliases that describe one vulnerability. MaxSeverity is a CVSS base score. +type OSVGroup struct { + IDs []string `json:"ids"` + Aliases []string `json:"aliases"` + MaxSeverity string `json:"max_severity"` +} + +// OSVFinding is one grouped vulnerability. +type OSVFinding struct { + IDs []string + Severity string + Package string + Version string + Link string +} + +// Findings returns one entry per vulnerability group. +// Aliases of the same group are not counted again. License-only packages are skipped. +func (r *OSVReportMin) Findings() []OSVFinding { + if r == nil { + return nil + } + var out []OSVFinding + for _, result := range r.Results { + for _, pkg := range result.Packages { + grouped := map[string]bool{} + for _, group := range pkg.Groups { + finding, ok := findingFromGroup(pkg, group) + if !ok { + continue + } + for _, id := range group.IDs { + grouped[id] = true + } + out = append(out, finding) + } + for _, vuln := range pkg.Vulnerabilities { + if vuln.ID == "" || grouped[vuln.ID] { + continue + } + out = append(out, findingFromVuln(pkg, vuln)) + } + } + } + return out +} + +// CVE returns the first CVE id, or the first id when none start with CVE-. +func (f OSVFinding) CVE() string { + for _, id := range f.IDs { + if strings.HasPrefix(strings.ToUpper(id), "CVE-") { + return id + } + } + if len(f.IDs) == 0 { + return "" + } + return f.IDs[0] +} + +// MatchesID reports whether id equals the OSV id or any alias. +func (f OSVFinding) MatchesID(id string) bool { + for _, got := range f.IDs { + if strings.EqualFold(got, id) { + return true + } + } + return false +} + +func findingFromGroup(pkg OSVPackage, group OSVGroup) (OSVFinding, bool) { + ids := appendIDs(nil, group.IDs...) + ids = appendIDs(ids, group.Aliases...) + severity := ratingFromScore(group.MaxSeverity) + for _, vuln := range pkg.Vulnerabilities { + if !containsID(group.IDs, vuln.ID) && !containsID(ids, vuln.ID) { + continue + } + ids = appendIDs(ids, vuln.ID) + ids = appendIDs(ids, vuln.Aliases...) + severity = betterSeverity(severity, vulnSeverity(vuln)) + } + if len(ids) == 0 { + return OSVFinding{}, false + } + return OSVFinding{ + IDs: ids, + Severity: severity, + Package: pkg.Package.Name, + Version: pkg.Package.Version, + Link: osvLink(ids[0]), + }, true +} + +func findingFromVuln(pkg OSVPackage, vuln OSVVulnerability) OSVFinding { + ids := appendIDs(nil, vuln.ID) + ids = appendIDs(ids, vuln.Aliases...) + link := "-" + if len(ids) > 0 { + link = osvLink(ids[0]) + } + return OSVFinding{ + IDs: ids, + Severity: vulnSeverity(vuln), + Package: pkg.Package.Name, + Version: pkg.Package.Version, + Link: link, + } +} + +func vulnSeverity(vuln OSVVulnerability) string { + severity := ratingFromScore(databaseSeverity(vuln.DatabaseSpecific)) + for _, item := range vuln.Severity { + severity = betterSeverity(severity, ratingFromScore(item.Score)) + } + return severity +} + +func databaseSeverity(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return "" + } + sev, _ := obj["severity"].(string) + return sev +} + +func osvLink(id string) string { + if id == "" { + return "-" + } + return "https://osv.dev/" + id +} + +func appendIDs(ids []string, more ...string) []string { + for _, id := range more { + if id == "" || containsID(ids, id) { + continue + } + ids = append(ids, id) + } + return ids +} + +func containsID(ids []string, want string) bool { + for _, id := range ids { + if strings.EqualFold(id, want) { + return true + } + } + return false +} + +func betterSeverity(a, b string) string { + if severityOrder(a) <= severityOrder(b) { + return a + } + return b +} + +func severityOrder(severity string) int { + switch severity { + case "critical": + return 0 + case "high": + return 1 + case "medium": + return 2 + case "low": + return 3 + case "negligible": + return 4 + default: + return 5 + } +} + +func ratingFromScore(raw string) string { + raw = strings.TrimSpace(raw) + switch strings.ToLower(raw) { + case "critical", "high", "medium", "low", "negligible", "unknown": + return strings.ToLower(raw) + case "moderate": + return "medium" + case "", "none": + return "unknown" + } + score, err := strconv.ParseFloat(raw, 64) + if err != nil { + return "unknown" + } + switch { + case score >= 9: + return "critical" + case score >= 7: + return "high" + case score >= 4: + return "medium" + case score >= 0.1: + return "low" + default: + return "unknown" + } +} diff --git a/pkg/gatecheck/config.go b/pkg/gatecheck/config.go index 92cc0cf..f847cd0 100644 --- a/pkg/gatecheck/config.go +++ b/pkg/gatecheck/config.go @@ -22,14 +22,15 @@ import ( // Metadata fields are intended for arbitrary data and shouldn't // conflict with rule validation type Config struct { - Version string `json:"version" toml:"version" yaml:"version"` - Metadata configMetadata `json:"metadata" toml:"metadata" yaml:"metadata"` - Grype reportWithCVEs `json:"grype" toml:"grype" yaml:"grype"` - Cyclonedx reportWithCVEs `json:"cyclonedx" toml:"cyclonedx" yaml:"cyclonedx"` - Trivy reportWithCVEs `json:"trivy" toml:"trivy" yaml:"trivy"` - Semgrep configSemgrepReport `json:"semgrep" toml:"semgrep" yaml:"semgrep"` - Gitleaks configGitleaksReport `json:"gitleaks" toml:"gitleaks" yaml:"gitleaks"` - Coverage configCoverageReport `json:"coverage" toml:"coverage" yaml:"coverage"` + Version string `json:"version" toml:"version" yaml:"version"` + Metadata configMetadata `json:"metadata" toml:"metadata" yaml:"metadata"` + Grype reportWithCVEs `json:"grype" toml:"grype" yaml:"grype"` + Cyclonedx reportWithCVEs `json:"cyclonedx" toml:"cyclonedx" yaml:"cyclonedx"` + Trivy reportWithCVEs `json:"trivy" toml:"trivy" yaml:"trivy"` + OSVScanner reportWithCVEs `json:"osv-scanner" toml:"osv-scanner" yaml:"osv-scanner"` + Semgrep configSemgrepReport `json:"semgrep" toml:"semgrep" yaml:"semgrep"` + Gitleaks configGitleaksReport `json:"gitleaks" toml:"gitleaks" yaml:"gitleaks"` + Coverage configCoverageReport `json:"coverage" toml:"coverage" yaml:"coverage"` } func (c *Config) String() string { @@ -178,9 +179,10 @@ func NewDefaultConfig() *Config { Low: false, }, }, - Grype: defaultReportWithCVEs(), - Cyclonedx: defaultReportWithCVEs(), - Trivy: defaultReportWithCVEs(), + Grype: defaultReportWithCVEs(), + Cyclonedx: defaultReportWithCVEs(), + Trivy: defaultReportWithCVEs(), + OSVScanner: defaultReportWithCVEs(), Gitleaks: configGitleaksReport{ LimitEnabled: false, }, diff --git a/pkg/gatecheck/list.go b/pkg/gatecheck/list.go index 8d510bd..969e128 100644 --- a/pkg/gatecheck/list.go +++ b/pkg/gatecheck/list.go @@ -103,6 +103,14 @@ func List(dst io.Writer, src io.Reader, inputFilename string, options ...ListOpt err = ListTrivyReport(table, src) } + case strings.Contains(inputFilename, "osv"): + slog.Debug("list", "filename", inputFilename, "filetype", "osv-scanner") + if o.epssData != nil { + err = listOSVWithEPSS(table, src, o.epssData) + } else { + err = ListOSVReport(table, src) + } + case strings.Contains(inputFilename, "syft"): slog.Debug("list", "filename", inputFilename, "filetype", "syft") slog.Warn("syft decoder is not supported yet") diff --git a/pkg/gatecheck/osv.go b/pkg/gatecheck/osv.go new file mode 100644 index 0000000..ceec16b --- /dev/null +++ b/pkg/gatecheck/osv.go @@ -0,0 +1,317 @@ +package gatecheck + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "slices" + "sort" + "strings" + + "github.com/gatecheckdev/gatecheck/pkg/artifacts" + "github.com/gatecheckdev/gatecheck/pkg/epss" + "github.com/gatecheckdev/gatecheck/pkg/format" + "github.com/gatecheckdev/gatecheck/pkg/kev" + "github.com/olekukonko/tablewriter" +) + +func ListOSVReport(table *tablewriter.Table, src io.Reader) error { + findings, err := decodeOSVFindings(src) + if err != nil { + return err + } + if len(findings) == 0 { + return emptyReport("No osv-scanner vulnerabilities") + } + + catLess := format.NewCatagoricLess([]string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"}) + matrix := format.NewSortableMatrix(make([][]string, 0), 0, catLess) + for _, finding := range findings { + matrix.Append([]string{displaySeverity(finding.Severity), finding.Package, finding.Version, finding.Link}) + } + sort.Sort(matrix) + + table.Header([]string{"OSV Severity", "Package", "Version", "Link"}) + return matrix.Table(table) +} + +func listOSVWithEPSS(table *tablewriter.Table, src io.Reader, epssData *epss.Data) error { + findings, err := decodeOSVFindings(src) + if err != nil { + return err + } + if len(findings) == 0 { + return emptyReport("No osv-scanner vulnerabilities") + } + + catLess := format.NewCatagoricLess([]string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"}) + matrix := format.NewSortableMatrix(make([][]string, 0), 1, catLess) + for _, finding := range findings { + score, prctl := "-", "-" + if cve, ok := epssByID(epssData, finding.CVE()); ok { + score = cve.EPSS + prctl = cve.Percentile + } + matrix.Append([]string{ + finding.CVE(), + displaySeverity(finding.Severity), + score, + prctl, + finding.Package, + finding.Version, + finding.Link, + }) + } + sort.Sort(matrix) + + table.Header([]string{"OSV ID", "Severity", "EPSS Score", "EPSS Prctl", "Package", "Version", "Link"}) + return matrix.Table(table) +} + +func decodeOSVFindings(src io.Reader) ([]artifacts.OSVFinding, error) { + report := &artifacts.OSVReportMin{} + slog.Debug("decode osv-scanner report", "format", "json") + if err := json.NewDecoder(src).Decode(report); err != nil { + return nil, err + } + return report.Findings(), nil +} + +func validateOSVReportWithFetch(r io.Reader, config *Config, options *fetchOptions) error { + catalog := kev.NewCatalog() + epssData := new(epss.Data) + if err := LoadCatalogAndData(config, catalog, epssData, options); err != nil { + slog.Error("validate osv-scanner report: load epss data from file or api", "error", err) + return errors.New("cannot run osv-scanner validation: Cannot load external validation data, see log for details") + } + return validateOSVFrom(r, config, catalog, epssData) +} + +func validateOSVFrom(r io.Reader, config *Config, catalog *kev.Catalog, epssData *epss.Data) error { + slog.Debug("validate osv-scanner report") + report := &artifacts.OSVReportMin{} + if err := json.NewDecoder(r).Decode(report); err != nil { + slog.Error("decode osv-scanner report for validation", "error", err) + return errors.New("cannot run osv-scanner validation: Report decoding failed, See log for details") + } + return validateOSVRules(config, report.Findings(), catalog, epssData) +} + +func validateOSVRules(config *Config, findings []artifacts.OSVFinding, catalog *kev.Catalog, data *epss.Data) error { + sortOSVFindings(findings, data) + if !ruleOSVCVEDeny(config, findings) { + return newValidationErr("osv-scanner: CVE explicitly denied") + } + findings = removeIgnoredOSVSeverities(config, findings, data) + findings = ruleOSVCVEAllow(config, findings) + if !ruleOSVKEVLimit(config, findings, catalog) { + return newValidationErr("osv-scanner: CVE matched to KEV Catalog") + } + findings = ruleOSVEPSSAllow(config, findings, data) + if !ruleOSVEPSSLimit(config, findings, data) { + return newValidationErr("osv-scanner: EPSS Limit Exceeded") + } + if !ruleOSVSeverityLimit(config, findings) { + return newValidationErr("osv-scanner: Severity Limit Exceeded") + } + return nil +} + +func sortOSVFindings(findings []artifacts.OSVFinding, data *epss.Data) { + severityRank := []string{"critical", "high", "medium", "low", "negligible", "unknown"} + sort.Slice(findings, func(i, j int) bool { + if findings[i].Severity == findings[j].Severity { + epssi, oki := epssByID(data, findings[i].CVE()) + epssj, okj := epssByID(data, findings[j].CVE()) + return !okj || oki && epssi.EPSSValue() > epssj.EPSSValue() + } + return slices.Index(severityRank, findings[i].Severity) < slices.Index(severityRank, findings[j].Severity) + }) +} + +func removeIgnoredOSVSeverities(config *Config, findings []artifacts.OSVFinding, data *epss.Data) []artifacts.OSVFinding { + hasLimits := map[string]bool{ + "critical": config.OSVScanner.SeverityLimit.Critical.Enabled, + "high": config.OSVScanner.SeverityLimit.High.Enabled, + "medium": config.OSVScanner.SeverityLimit.Medium.Enabled, + "low": config.OSVScanner.SeverityLimit.Low.Enabled, + "unknown": false, + "negligible": false, + } + return slices.DeleteFunc(findings, func(finding artifacts.OSVFinding) bool { + if hasLimits[finding.Severity] { + return false + } + if config.OSVScanner.EPSSLimit.Enabled { + cve, ok := epssByID(data, finding.CVE()) + return !ok || cve.EPSSValue() < config.OSVScanner.EPSSLimit.Score + } + return true + }) +} + +func ruleOSVCVEDeny(config *Config, findings []artifacts.OSVFinding) bool { + if !config.OSVScanner.CVELimit.Enabled { + slog.Debug("cve id limits not enabled", "artifact", "osv-scanner", "count_denied", len(config.OSVScanner.CVELimit.CVEs)) + return true + } + for _, cve := range config.OSVScanner.CVELimit.CVEs { + if slices.ContainsFunc(findings, func(finding artifacts.OSVFinding) bool { + return finding.MatchesID(cve.ID) + }) { + slog.Error("cve matched to Deny List", "artifact", "osv-scanner", "id", cve.ID) + return false + } + } + return true +} + +func ruleOSVCVEAllow(config *Config, findings []artifacts.OSVFinding) []artifacts.OSVFinding { + if !config.OSVScanner.CVERiskAcceptance.Enabled { + slog.Debug("cve id risk acceptance not enabled", "artifact", "osv-scanner") + return findings + } + return slices.DeleteFunc(findings, func(finding artifacts.OSVFinding) bool { + allowed := slices.ContainsFunc(config.OSVScanner.CVERiskAcceptance.CVEs, func(cve configCVE) bool { + return finding.MatchesID(cve.ID) + }) + if allowed { + slog.Info("CVE explicitly allowed, removing from subsequent rules", "id", finding.CVE(), "severity", finding.Severity) + } + return allowed + }) +} + +func ruleOSVKEVLimit(config *Config, findings []artifacts.OSVFinding, catalog *kev.Catalog) bool { + if !config.OSVScanner.KEVLimitEnabled { + slog.Debug("kev limit not enabled", "artifact", "osv-scanner") + return true + } + if catalog == nil { + slog.Error("kev limit enabled but no catalog data exists", "artifact", "osv-scanner") + return false + } + var bad int + for _, finding := range findings { + inCatalog := slices.ContainsFunc(catalog.Vulnerabilities, func(item kev.Vulnerability) bool { + return finding.MatchesID(item.CveID) + }) + if inCatalog { + bad++ + slog.Warn("cve found in kev catalog", "cve_id", finding.CVE()) + } + } + if bad > 0 { + slog.Error("cve(s) found in kev catalog", "vulnerabilities", bad, "kev_catalog_count", len(catalog.Vulnerabilities)) + return false + } + return true +} + +func ruleOSVEPSSAllow(config *Config, findings []artifacts.OSVFinding, data *epss.Data) []artifacts.OSVFinding { + if !config.OSVScanner.EPSSRiskAcceptance.Enabled { + slog.Debug("epss risk acceptance not enabled", "artifact", "osv-scanner") + return findings + } + if data == nil { + slog.Error("epss allowance enabled but no data exists", "artifact", "osv-scanner") + return findings + } + return slices.DeleteFunc(findings, func(finding artifacts.OSVFinding) bool { + cve, ok := epssByID(data, finding.CVE()) + if !ok { + return false + } + if config.OSVScanner.EPSSRiskAcceptance.Score > cve.EPSSValue() { + slog.Info("risk accepted reason: epss score", "cve_id", finding.CVE(), "severity", finding.Severity, "epss_score", cve.EPSS) + return true + } + return false + }) +} + +func ruleOSVEPSSLimit(config *Config, findings []artifacts.OSVFinding, data *epss.Data) bool { + if !config.OSVScanner.EPSSLimit.Enabled { + slog.Debug("epss limit not enabled", "artifact", "osv-scanner") + return true + } + if data == nil { + slog.Error("epss allowance enabled but no data exists", "artifact", "osv-scanner") + return false + } + var bad int + for _, finding := range findings { + cve, ok := epssByID(data, finding.CVE()) + if !ok { + continue + } + if cve.EPSSValue() > config.OSVScanner.EPSSLimit.Score { + bad++ + slog.Warn("epss score limit violation", "cve_id", finding.CVE(), "severity", finding.Severity, "epss_score", cve.EPSS) + } + } + if bad > 0 { + slog.Error("cve(s) with epss scores over limit", "over_limit_cves", bad, "epss_limit_score", config.OSVScanner.EPSSLimit.Score) + return false + } + return true +} + +func ruleOSVSeverityLimit(config *Config, findings []artifacts.OSVFinding) bool { + validationPass := true + limits := map[string]configLimit{ + "critical": config.OSVScanner.SeverityLimit.Critical, + "high": config.OSVScanner.SeverityLimit.High, + "medium": config.OSVScanner.SeverityLimit.Medium, + "low": config.OSVScanner.SeverityLimit.Low, + } + for _, severity := range []string{"critical", "high", "medium", "low"} { + configuredLimit := limits[severity] + var count int + for _, finding := range findings { + if finding.Severity == severity { + count++ + } + } + if !configuredLimit.Enabled { + slog.Debug("severity limit not enabled", "artifact", "osv-scanner", "severity", severity, "reported", count) + continue + } + if count > int(configuredLimit.Limit) { + slog.Error("osv-scanner severity limit exceeded", "severity", severity, "report", count, "limit", configuredLimit.Limit) + validationPass = false + continue + } + slog.Info("severity limit valid", "artifact", "osv-scanner", "severity", severity, "reported", count, "limit", configuredLimit.Limit) + } + return validationPass +} + +func epssByID(data *epss.Data, id string) (epss.CVE, bool) { + if data == nil || data.CVEs == nil || id == "" { + return epss.CVE{}, false + } + if cve, ok := data.CVEs[id]; ok { + return cve, true + } + cve, ok := data.CVEs[strings.ToUpper(id)] + return cve, ok +} + +func displaySeverity(severity string) string { + switch severity { + case "critical": + return "Critical" + case "high": + return "High" + case "medium": + return "Medium" + case "low": + return "Low" + case "negligible": + return "Negligible" + default: + return "Unknown" + } +} diff --git a/pkg/gatecheck/osv_test.go b/pkg/gatecheck/osv_test.go new file mode 100644 index 0000000..03d8d13 --- /dev/null +++ b/pkg/gatecheck/osv_test.go @@ -0,0 +1,202 @@ +package gatecheck + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/gatecheckdev/gatecheck/pkg/artifacts" + "github.com/gatecheckdev/gatecheck/pkg/epss" + "github.com/gatecheckdev/gatecheck/pkg/kev" + "github.com/pelletier/go-toml/v2" + "gopkg.in/yaml.v3" +) + +func TestOSVListAndValidate(t *testing.T) { + report := mustRead(t, "../../test/osv-scanner-report.json") + + t.Run("list groups once", func(t *testing.T) { + got := renderList(t, "osv-scanner-report.json", report, nil) + for _, want := range []string{"OSV SEVERITY", "github.com/gogo/protobuf", "regex", "ansi_term", "https://osv.dev/GO-2021-0053", "Critical", "High", "Low"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q\n%s", want, got) + } + } + if strings.Count(got, "github.com/gogo/protobuf") != 1 { + t.Fatalf("grouped aliases were listed more than once\n%s", got) + } + if strings.Contains(got, "license") { + t.Fatalf("license text was listed\n%s", got) + } + }) + + t.Run("short name and stdin type", func(t *testing.T) { + got := renderList(t, "osv.json", report, nil) + if !strings.Contains(got, "regex") { + t.Fatalf("osv.json was not detected\n%s", got) + } + got = renderList(t, "stdin:osv-scanner", report, nil) + if !strings.Contains(got, "ansi_term") { + t.Fatalf("input type was not detected\n%s", got) + } + }) + + t.Run("list epss uses cve alias", func(t *testing.T) { + got := renderList(t, "reports/osv-scanner.json", report, []ListOptionFunc{withTestEPSS(t)}) + if !strings.Contains(got, "CVE-2021-3121") || !strings.Contains(got, "CVE-2022-24713") { + t.Fatalf("cve aliases missing\n%s", got) + } + }) + + t.Run("empty", func(t *testing.T) { + got := renderList(t, "osv-scanner.json", []byte(`{"results":[]}`), nil) + if got != "No osv-scanner vulnerabilities\n" { + t.Fatalf("got %q", got) + } + }) + + t.Run("case sensitive filename", func(t *testing.T) { + err := List(&bytes.Buffer{}, bytes.NewReader(report), "OSV-scanner.json") + if err == nil { + t.Fatal("expected an unsupported filename") + } + }) + + t.Run("severity limit counts groups", func(t *testing.T) { + config := NewDefaultConfig() + config.OSVScanner.SeverityLimit.Critical.Enabled = true + config.OSVScanner.SeverityLimit.Critical.Limit = 0 + err := Validate(config, bytes.NewReader(report), "osv-scanner-report.json") + if !errors.Is(err, ErrValidationFailure) || !strings.Contains(err.Error(), "osv-scanner: Severity Limit Exceeded") { + t.Fatalf("err %v", err) + } + config.OSVScanner.SeverityLimit.Critical.Limit = 1 + if err := Validate(config, bytes.NewReader(report), "osv-scanner-report.json"); err != nil { + t.Fatal(err) + } + }) + + t.Run("deny and allow aliases", func(t *testing.T) { + config := NewDefaultConfig() + config.OSVScanner.CVELimit.Enabled = true + config.OSVScanner.CVELimit.CVEs = []configCVE{{ID: "cve-2021-3121"}} + err := Validate(config, bytes.NewReader(report), "osv.json") + if !errors.Is(err, ErrValidationFailure) || !strings.Contains(err.Error(), "CVE explicitly denied") { + t.Fatalf("err %v", err) + } + + config = NewDefaultConfig() + config.OSVScanner.SeverityLimit.Critical.Enabled = true + config.OSVScanner.CVERiskAcceptance.Enabled = true + config.OSVScanner.CVERiskAcceptance.CVEs = []configCVE{{ID: "GHSA-m5pq-gvj9-9vr8"}} + if err := Validate(config, bytes.NewReader(report), "osv-scanner-report.json"); err != nil { + t.Fatal(err) + } + }) + + t.Run("bundle", func(t *testing.T) { + config := NewDefaultConfig() + config.OSVScanner.SeverityLimit.Low.Enabled = true + config.OSVScanner.SeverityLimit.Low.Limit = 0 + var bundle bytes.Buffer + if err := CreateBundle(&bundle, bytes.NewReader(report), "osv-scanner-report.json", []string{"ci"}); err != nil { + t.Fatal(err) + } + err := Validate(config, bytes.NewReader(bundle.Bytes()), "gatecheck-bundle.tar.gz") + if !errors.Is(err, ErrValidationFailure) || !strings.Contains(err.Error(), "Severity Limit Exceeded") { + t.Fatalf("err %v", err) + } + }) + + t.Run("moderate word", func(t *testing.T) { + raw := []byte(`{"results":[{"packages":[{"package":{"name":"left-pad","version":"1.0.0"},"vulnerabilities":[{"id":"GHSA-moderate","database_specific":{"severity":"MODERATE"}}],"groups":[{"ids":["GHSA-moderate"],"max_severity":""}]}]}]}`) + config := NewDefaultConfig() + config.OSVScanner.SeverityLimit.Medium.Enabled = true + config.OSVScanner.SeverityLimit.Medium.Limit = 0 + err := Validate(config, bytes.NewReader(raw), "osv-scanner.json") + if !errors.Is(err, ErrValidationFailure) { + t.Fatalf("err %v", err) + } + }) +} + +func TestOSVEPSSAndKEV(t *testing.T) { + findings := []artifacts.OSVFinding{{ + IDs: []string{"GHSA-m5pq-gvj9-9vr8", "CVE-2022-24713"}, + Severity: "critical", + Package: "regex", + Version: "1.5.1", + }} + data := &epss.Data{CVEs: map[string]epss.CVE{ + "CVE-2022-24713": {EPSS: "0.42000", Percentile: "0.70000"}, + }} + config := NewDefaultConfig() + config.OSVScanner.SeverityLimit.Critical.Enabled = true + config.OSVScanner.SeverityLimit.Critical.Limit = 5 + config.OSVScanner.EPSSLimit.Enabled = true + config.OSVScanner.EPSSLimit.Score = 0.4 + err := validateOSVRules(config, findings, nil, data) + if !errors.Is(err, ErrValidationFailure) || !strings.Contains(err.Error(), "EPSS Limit Exceeded") { + t.Fatalf("err %v", err) + } + + config.OSVScanner.EPSSLimit.Score = 0.42 + if err := validateOSVRules(config, append([]artifacts.OSVFinding(nil), findings...), nil, data); err != nil { + t.Fatal(err) + } + + kept := []artifacts.OSVFinding{{ + IDs: []string{"CVE-2022-24713"}, + Severity: "high", + Package: "regex", + Version: "1.5.1", + }} + config = NewDefaultConfig() + config.OSVScanner.SeverityLimit.High.Enabled = true + config.OSVScanner.SeverityLimit.High.Limit = 5 + config.OSVScanner.KEVLimitEnabled = true + catalog := &kev.Catalog{Vulnerabilities: []kev.Vulnerability{{CveID: "CVE-2022-24713"}}} + err = validateOSVRules(config, kept, catalog, nil) + if !errors.Is(err, ErrValidationFailure) || !strings.Contains(err.Error(), "KEV Catalog") { + t.Fatalf("err %v", err) + } +} + +func TestOSVConfigRoundTrip(t *testing.T) { + config := NewDefaultConfig() + config.OSVScanner.SeverityLimit.High.Enabled = true + config.OSVScanner.SeverityLimit.High.Limit = 3 + config.OSVScanner.CVELimit.Enabled = true + config.OSVScanner.CVELimit.CVEs = []configCVE{{ID: "CVE-2021-3121"}} + + var yamlBuf bytes.Buffer + if err := EncodeConfigTo(&yamlBuf, config, "yaml"); err != nil { + t.Fatal(err) + } + if !strings.Contains(yamlBuf.String(), "osv-scanner:") { + t.Fatalf("yaml missing key\n%s", yamlBuf.String()) + } + var decoded Config + if err := yaml.Unmarshal(yamlBuf.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if !decoded.OSVScanner.SeverityLimit.High.Enabled || decoded.OSVScanner.SeverityLimit.High.Limit != 3 { + t.Fatalf("yaml %+v", decoded.OSVScanner.SeverityLimit.High) + } + if decoded.OSVScanner.CVELimit.CVEs[0].ID != "CVE-2021-3121" { + t.Fatalf("yaml deny %+v", decoded.OSVScanner.CVELimit) + } + + var tomlBuf bytes.Buffer + if err := EncodeConfigTo(&tomlBuf, config, "toml"); err != nil { + t.Fatal(err) + } + decoded = Config{} + if err := toml.Unmarshal(tomlBuf.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if decoded.OSVScanner.CVELimit.CVEs[0].ID != "CVE-2021-3121" { + t.Fatalf("toml %+v", decoded.OSVScanner.CVELimit) + } +} diff --git a/pkg/gatecheck/validate.go b/pkg/gatecheck/validate.go index a026c66..f151a05 100644 --- a/pkg/gatecheck/validate.go +++ b/pkg/gatecheck/validate.go @@ -53,6 +53,10 @@ func Validate(config *Config, reportSrc io.Reader, targetFilename string, option slog.Debug("validate", "filename", targetFilename, "filetype", "trivy") return validateTrivyReportWithFetch(reportSrc, config, options) + case strings.Contains(targetFilename, "osv"): + slog.Debug("validate", "filename", targetFilename, "filetype", "osv-scanner") + return validateOSVReportWithFetch(reportSrc, config, options) + case strings.Contains(targetFilename, "syft"): slog.Debug("validate", "filename", targetFilename, "filetype", "syft") return errors.New("syft validation not supported yet") @@ -604,7 +608,7 @@ func loadDataFromFileOrAPI(epssData *epss.Data, options *fetchOptions) error { } func LoadCatalogAndData(config *Config, catalog *kev.Catalog, epssData *epss.Data, options *fetchOptions) error { - if config.Grype.KEVLimitEnabled || config.Cyclonedx.KEVLimitEnabled || config.Trivy.KEVLimitEnabled { + if config.Grype.KEVLimitEnabled || config.Cyclonedx.KEVLimitEnabled || config.Trivy.KEVLimitEnabled || config.OSVScanner.KEVLimitEnabled { if err := loadCatalogFromFileOrAPI(catalog, options); err != nil { return err } @@ -613,8 +617,9 @@ func LoadCatalogAndData(config *Config, catalog *kev.Catalog, epssData *epss.Dat grypeEPSSNeeded := config.Grype.EPSSLimit.Enabled || config.Grype.EPSSRiskAcceptance.Enabled cyclonedxEPSSNeeded := config.Cyclonedx.EPSSLimit.Enabled || config.Cyclonedx.EPSSRiskAcceptance.Enabled trivyEPSSNeeded := config.Trivy.EPSSLimit.Enabled || config.Trivy.EPSSRiskAcceptance.Enabled + osvEPSSNeeded := config.OSVScanner.EPSSLimit.Enabled || config.OSVScanner.EPSSRiskAcceptance.Enabled - if grypeEPSSNeeded || cyclonedxEPSSNeeded || trivyEPSSNeeded { + if grypeEPSSNeeded || cyclonedxEPSSNeeded || trivyEPSSNeeded || osvEPSSNeeded { if err := loadDataFromFileOrAPI(epssData, options); err != nil { return err } @@ -772,6 +777,9 @@ func validateBundle(r io.Reader, config *Config, options *fetchOptions) error { case strings.Contains(fileLabel, "trivy"): err := validateTrivyFrom(bytes.NewBuffer(bundle.FileBytes(fileLabel)), config, catalog, epssData) errs = errors.Join(errs, err) + case strings.Contains(fileLabel, "osv"): + err := validateOSVFrom(bytes.NewBuffer(bundle.FileBytes(fileLabel)), config, catalog, epssData) + errs = errors.Join(errs, err) case artifacts.IsCoverageReport(fileLabel): err := validateCoverage(bytes.NewBuffer(bundle.FileBytes(fileLabel)), fileLabel, config) errs = errors.Join(errs, err) diff --git a/skills/gatecheck/SKILL.md b/skills/gatecheck/SKILL.md index 854dcf0..1ad08b5 100644 --- a/skills/gatecheck/SKILL.md +++ b/skills/gatecheck/SKILL.md @@ -7,7 +7,7 @@ description: Use when running gatecheck to list, validate, or bundle scanner rep Run `gatecheck agent-guide` and follow that output. The alias is `gatecheck llm`. -`gatecheck bootstrap` can install Trivy, and a filename containing `trivy` is a Trivy JSON report. Severity limits and CVE allow and deny lists use the same fields as Grype. +`gatecheck bootstrap` can install Trivy and osv-scanner. A filename containing `trivy` is a Trivy JSON report, and a filename containing `osv` is an osv-scanner JSON report. Severity limits and CVE allow and deny lists use the same fields as Grype. The command prints `docs/agent-guide.md`. That file is embedded in the binary, so the command and the file are the same text. Prefer the command when the `gatecheck` binary is installed. diff --git a/test/osv-scanner-report.json b/test/osv-scanner-report.json new file mode 100644 index 0000000..5076707 --- /dev/null +++ b/test/osv-scanner-report.json @@ -0,0 +1,106 @@ +{ + "results": [ + { + "source": { + "path": "/src/go.mod", + "type": "lockfile" + }, + "packages": [ + { + "package": { + "name": "github.com/gogo/protobuf", + "version": "1.3.1", + "ecosystem": "Go" + }, + "vulnerabilities": [ + { + "schema_version": "1.6.0", + "id": "GO-2021-0053", + "aliases": ["CVE-2021-3121", "GHSA-c3h9-896r-86jm"], + "summary": "Panic due to improper input validation in github.com/gogo/protobuf", + "details": "Maliciously crafted input to generated Unmarshal methods can cause an out-of-bounds panic.", + "database_specific": { + "severity": "HIGH", + "url": "https://pkg.go.dev/vuln/GO-2021-0053" + } + } + ], + "groups": [ + { + "ids": ["GO-2021-0053"], + "aliases": ["CVE-2021-3121", "GHSA-c3h9-896r-86jm"], + "max_severity": "7.5" + } + ] + } + ] + }, + { + "source": { + "path": "/src/Cargo.lock", + "type": "lockfile" + }, + "packages": [ + { + "package": { + "name": "regex", + "version": "1.5.1", + "ecosystem": "crates.io" + }, + "vulnerabilities": [ + { + "id": "GHSA-m5pq-gvj9-9vr8", + "aliases": ["CVE-2022-24713"], + "summary": "Rust's regex crate vulnerable to regular expression denial of service", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "database_specific": { + "severity": "HIGH" + } + } + ], + "groups": [ + { + "ids": ["GHSA-m5pq-gvj9-9vr8"], + "aliases": ["CVE-2022-24713"], + "max_severity": "9.8" + } + ] + }, + { + "package": { + "name": "ansi_term", + "version": "0.12.1", + "ecosystem": "crates.io" + }, + "vulnerabilities": [ + { + "id": "RUSTSEC-2021-0145", + "summary": "ansi_term is unmaintained", + "database_specific": { + "severity": "LOW" + } + } + ], + "groups": [ + { + "ids": ["RUSTSEC-2021-0145"], + "aliases": null, + "max_severity": "3.1" + } + ] + } + ] + } + ], + "experimental_config": { + "licenses": { + "summary": false, + "allowlist": null + } + } +}