feat(vex): VEX Repository support (#7206)

Signed-off-by: knqyf263 <knqyf263@gmail.com>
Co-authored-by: DmitriyLewen <91113035+DmitriyLewen@users.noreply.github.com>
This commit is contained in:
Teppei Fukuda
2024-07-25 16:18:37 +04:00
committed by GitHub
parent 174b1e3515
commit 88ba46047c
77 changed files with 3505 additions and 651 deletions

View File

@@ -151,7 +151,7 @@ jobs:
runs-on: ${{ matrix.operating-system }}
strategy:
matrix:
operating-system: [ubuntu-latest-m, windows-latest, macos-latest]
operating-system: [ubuntu-latest, windows-latest, macos-latest]
env:
DOCKER_CLI_EXPERIMENTAL: "enabled"
steps:

View File

@@ -1,10 +1,11 @@
# Cache
The cache directory includes
- Cache of previous scans (Scan cache).
- [Vulnerability Database][trivy-db][^1]
- [Java Index Database][trivy-java-db][^2]
- [Misconfiguration Checks][misconf-checks][^3]
- Cache of previous scans.
- [VEX Repositories](../supply-chain/vex/repo.md)
The cache option is common to all scanners.

View File

@@ -493,7 +493,7 @@ You can find more example checks [here](https://github.com/aquasecurity/trivy/tr
| Secret | |
| License | |
Please refer to the [VEX documentation](../supply-chain/vex.md) for the details.
Please refer to the [VEX documentation](../supply-chain/vex/index.md) for the details.
[^1]: license name is used as id for `.trivyignore.yaml` files.

View File

@@ -56,5 +56,6 @@ trivy [global flags] command [flags] target
* [trivy sbom](trivy_sbom.md) - Scan SBOM for vulnerabilities and licenses
* [trivy server](trivy_server.md) - Server mode
* [trivy version](trivy_version.md) - Print the version
* [trivy vex](trivy_vex.md) - [EXPERIMENTAL] VEX utilities
* [trivy vm](trivy_vm.md) - [EXPERIMENTAL] Scan a virtual machine image

View File

@@ -28,6 +28,7 @@ trivy clean [flags]
-h, --help help for clean
--java-db remove Java database
--scan-cache remove scan cache (container and VM image analysis results)
--vex-repo remove VEX repositories
--vuln-db remove vulnerability database
```

View File

@@ -82,6 +82,7 @@ trivy filesystem [flags] PATH
--skip-dirs strings specify the directories or glob patterns to skip
--skip-files strings specify the files or glob patterns to skip
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
-t, --template string output template
--tf-exclude-downloaded-modules exclude misconfigurations for downloaded terraform modules
--tf-vars strings specify paths to override the Terraform tfvars files
@@ -89,7 +90,7 @@ trivy filesystem [flags] PATH
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--trace enable more verbose trace output for custom queries
--username strings username. Comma-separated usernames allowed.
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -103,13 +103,14 @@ trivy image [flags] IMAGE_NAME
--skip-dirs strings specify the directories or glob patterns to skip
--skip-files strings specify the files or glob patterns to skip
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
-t, --template string output template
--tf-exclude-downloaded-modules exclude misconfigurations for downloaded terraform modules
--token string for authentication in client/server mode
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--trace enable more verbose trace output for custom queries
--username strings username. Comma-separated usernames allowed.
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -98,12 +98,13 @@ trivy kubernetes [flags] [CONTEXT]
--skip-files strings specify the files or glob patterns to skip
--skip-images skip the downloading and scanning of images (vulnerabilities and secrets) in the cluster resources
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
-t, --template string output template
--tf-exclude-downloaded-modules exclude misconfigurations for downloaded terraform modules
--tolerations strings specify node-collector job tolerations (example: key1=value1:NoExecute,key2=value2:NoSchedule)
--trace enable more verbose trace output for custom queries
--username strings username. Comma-separated usernames allowed.
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -81,6 +81,7 @@ trivy repository [flags] (REPO_PATH | REPO_URL)
--skip-dirs strings specify the directories or glob patterns to skip
--skip-files strings specify the files or glob patterns to skip
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
--tag string pass the tag name to be scanned
-t, --template string output template
--tf-exclude-downloaded-modules exclude misconfigurations for downloaded terraform modules
@@ -89,7 +90,7 @@ trivy repository [flags] (REPO_PATH | REPO_URL)
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--trace enable more verbose trace output for custom queries
--username strings username. Comma-separated usernames allowed.
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -83,6 +83,7 @@ trivy rootfs [flags] ROOTDIR
--skip-dirs strings specify the directories or glob patterns to skip
--skip-files strings specify the files or glob patterns to skip
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
-t, --template string output template
--tf-exclude-downloaded-modules exclude misconfigurations for downloaded terraform modules
--tf-vars strings specify paths to override the Terraform tfvars files
@@ -90,7 +91,7 @@ trivy rootfs [flags] ROOTDIR
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--trace enable more verbose trace output for custom queries
--username strings username. Comma-separated usernames allowed.
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -58,10 +58,11 @@ trivy sbom [flags] SBOM_PATH
--skip-dirs strings specify the directories or glob patterns to skip
--skip-files strings specify the files or glob patterns to skip
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
-t, --template string output template
--token string for authentication in client/server mode
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -0,0 +1,28 @@
## trivy vex
[EXPERIMENTAL] VEX utilities
### Options
```
-h, --help help for vex
```
### Options inherited from parent commands
```
--cache-dir string cache directory (default "/path/to/cache")
-c, --config string config path (default "trivy.yaml")
-d, --debug debug mode
--generate-default-config write the default config to trivy-default.yaml
--insecure allow insecure server connections
-q, --quiet suppress progress bar and log output
--timeout duration timeout (default 5m0s)
-v, --version show version
```
### SEE ALSO
* [trivy](trivy.md) - Unified security scanner
* [trivy vex repo](trivy_vex_repo.md) - Manage VEX repositories

View File

@@ -0,0 +1,44 @@
## trivy vex repo
Manage VEX repositories
### Examples
```
# Initialize the configuration file
$ trivy vex repo init
# List VEX repositories
$ trivy vex repo list
# Download the VEX repositories
$ trivy vex repo download
```
### Options
```
-h, --help help for repo
```
### Options inherited from parent commands
```
--cache-dir string cache directory (default "/path/to/cache")
-c, --config string config path (default "trivy.yaml")
-d, --debug debug mode
--generate-default-config write the default config to trivy-default.yaml
--insecure allow insecure server connections
-q, --quiet suppress progress bar and log output
--timeout duration timeout (default 5m0s)
-v, --version show version
```
### SEE ALSO
* [trivy vex](trivy_vex.md) - [EXPERIMENTAL] VEX utilities
* [trivy vex repo download](trivy_vex_repo_download.md) - Download the VEX repositories
* [trivy vex repo init](trivy_vex_repo_init.md) - Initialize a configuration file
* [trivy vex repo list](trivy_vex_repo_list.md) - List VEX repositories

View File

@@ -0,0 +1,35 @@
## trivy vex repo download
Download the VEX repositories
### Synopsis
Downloads enabled VEX repositories. If specific repository names are provided as arguments, only those repositories will be downloaded. Otherwise, all enabled repositories are downloaded.
```
trivy vex repo download [REPO_NAMES] [flags]
```
### Options
```
-h, --help help for download
```
### Options inherited from parent commands
```
--cache-dir string cache directory (default "/path/to/cache")
-c, --config string config path (default "trivy.yaml")
-d, --debug debug mode
--generate-default-config write the default config to trivy-default.yaml
--insecure allow insecure server connections
-q, --quiet suppress progress bar and log output
--timeout duration timeout (default 5m0s)
-v, --version show version
```
### SEE ALSO
* [trivy vex repo](trivy_vex_repo.md) - Manage VEX repositories

View File

@@ -0,0 +1,31 @@
## trivy vex repo init
Initialize a configuration file
```
trivy vex repo init [flags]
```
### Options
```
-h, --help help for init
```
### Options inherited from parent commands
```
--cache-dir string cache directory (default "/path/to/cache")
-c, --config string config path (default "trivy.yaml")
-d, --debug debug mode
--generate-default-config write the default config to trivy-default.yaml
--insecure allow insecure server connections
-q, --quiet suppress progress bar and log output
--timeout duration timeout (default 5m0s)
-v, --version show version
```
### SEE ALSO
* [trivy vex repo](trivy_vex_repo.md) - Manage VEX repositories

View File

@@ -0,0 +1,31 @@
## trivy vex repo list
List VEX repositories
```
trivy vex repo list [flags]
```
### Options
```
-h, --help help for list
```
### Options inherited from parent commands
```
--cache-dir string cache directory (default "/path/to/cache")
-c, --config string config path (default "trivy.yaml")
-d, --debug debug mode
--generate-default-config write the default config to trivy-default.yaml
--insecure allow insecure server connections
-q, --quiet suppress progress bar and log output
--timeout duration timeout (default 5m0s)
-v, --version show version
```
### SEE ALSO
* [trivy vex repo](trivy_vex_repo.md) - Manage VEX repositories

View File

@@ -72,11 +72,12 @@ trivy vm [flags] VM_IMAGE
--skip-dirs strings specify the directories or glob patterns to skip
--skip-files strings specify the files or glob patterns to skip
--skip-java-db-update skip updating Java index database
--skip-vex-repo-update [EXPERIMENTAL] Skip VEX Repository update
-t, --template string output template
--tf-exclude-downloaded-modules exclude misconfigurations for downloaded terraform modules
--token string for authentication in client/server mode
--token-header string specify a header name for token in client/server mode (default "Trivy-Token")
--vex string [EXPERIMENTAL] file path to VEX
--vex strings [EXPERIMENTAL] VEX sources ("repo" or file path)
```
### Options inherited from parent commands

View File

@@ -1,11 +1,11 @@
# Vulnerability Exploitability Exchange (VEX)
# Local VEX Files
!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.
Trivy supports filtering detected vulnerabilities using [the Vulnerability Exploitability Exchange (VEX)](https://www.ntia.gov/files/ntia/publications/vex_one-page_summary.pdf), a standardized format for sharing and exchanging information about vulnerabilities.
By providing VEX during scanning, it is possible to filter vulnerabilities based on their status.
Currently, Trivy supports the following three formats:
In addition to [VEX repositories](./repo.md), Trivy also supports the use of local VEX files for vulnerability filtering.
This method is useful when you have specific VEX documents that you want to apply to your scans.
Currently, Trivy supports the following formats:
- [CycloneDX](https://cyclonedx.org/capabilities/vex/)
- [OpenVEX](https://github.com/openvex/spec)

View File

@@ -0,0 +1,33 @@
# Vulnerability Exploitability Exchange (VEX)
!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.
Trivy supports filtering detected vulnerabilities using the [Vulnerability Exploitability eXchange (VEX)](https://www.ntia.gov/files/ntia/publications/vex_one-page_summary.pdf), a standardized format for sharing and exchanging information about vulnerabilities.
By providing VEX during scanning, it is possible to filter vulnerabilities based on their status.
## VEX Usage Methods
Trivy currently supports two methods for utilizing VEX:
1. [VEX Repository](./repo.md)
2. [Local VEX Files](./file.md)
### Enabling VEX
To enable VEX, use the `--vex` option.
You can specify the method to use:
- To enable the VEX Repository: `--vex repo`
- To use a local VEX file: `--vex /path/to/vex-document.json`
```bash
$ trivy image ghcr.io/aquasecurity/trivy:0.52.0 --vex repo
```
You can enable both methods simultaneously.
The order of specification determines the priority:
- `--vex repo --vex /path/to/vex-document.json`: VEX Repository has priority
- `--vex /path/to/vex-document.json --vex repo`: Local file has priority
For detailed information on each method, please refer to each page.

View File

@@ -0,0 +1,210 @@
# VEX Repository
!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.
## Using VEX Repository
Trivy can download and utilize VEX documents from repositories that comply with [the VEX Repository Specification][vex-repo].
While it's planned to be enabled by default in the future, currently it can be activated by explicitly specifying `--vex repo`.
```
$ trivy image ghcr.io/aquasecurity/trivy:0.52.0 --vex repo
2024-07-20T11:22:58+04:00 INFO [vex] The default repository config has been created
file_path="/Users/teppei/.trivy/vex/repository.yaml"
2024-07-20T11:23:23+04:00 INFO [vex] Updating repository... repo="default" url="https://github.com/aquasecurity/vexhub"
```
During scanning, Trivy generates PURLs for discovered packages and searches for matching PURLs in the VEX Repository.
If a match is found, the corresponding VEX is utilized.
### Configuration File
#### Default Configuration
When `--vex repo` is specified for the first time, a default configuration file is created at `$HOME/.trivy/vex/repository.yaml`.
The home directory can be configured through environment variable `$XDG_DATA_HOME`.
You can also create the configuration file in advance using the `trivy vex repo init` command and edit it.
The default configuration file looks like this:
```yaml
repositories:
- name: default
url: https://github.com/aquasecurity/vexhub
enabled: true
username: ""
password: ""
token: ""
```
By default, [VEX Hub][vexhub] managed by Aqua Security is used.
VEX Hub primarily trusts VEX documents published by the package maintainers.
#### Show Configuration
You can see the config file path and the configured repositories with `trivy vex repo list`:
```bash
$ trivy vex repo list
VEX Repositories (config: /home/username/.trivy/vex/repository.yaml)
- Name: default
URL: https://github.com/aquasecurity/vexhub
Status: Enabled
```
#### Custom Repositories
If you want to trust VEX documents published by other organizations or use your own VEX repository, you can specify a custom repository that complies with [the VEX Repository Specification][vex-repo].
You can add a custom repository as below:
```yaml
- name: custom
url: https://example.com/custom-repo
enabled: true
```
#### Authentication
For private repositories:
- `username`/`password` can be used for Basic authentication
- `token` can be used for Bearer authentication
```yaml
- name: custom
url: https://example.com/custom-repo
enabled: true
token: "my-token"
```
#### Repository Priority
The priority of VEX repositories is determined by their order in the configuration file.
You can add repositories with higher priority than the default or even remove the default VEX Hub.
```yaml
- name: repo1
url: https://example.com/repo1
- name: repo2
url: https://example.com/repo2
```
In this configuration, when Trivy detects a vulnerability in a package, it generates a PURL for that package and searches for matching VEX documents in the configured repositories.
The search process follows this order:
1. Trivy first looks for a VEX document matching the package's PURL in `repo1`.
2. If no matching VEX document is found in `repo1`, Trivy then searches in `repo2`.
3. This process continues through all configured repositories until a match is found.
If a matching VEX document is found in any repository (e.g., `repo1`), the search stops, and Trivy uses that VEX document.
Subsequent repositories (e.g., `repo2`) are not checked for that specific vulnerability and package combination.
It's important to note that the first matching VEX document found determines the final status of the vulnerability.
For example, if `repo1` states that a package is "Affected" by a vulnerability, this status will be used even if `repo2` states that the same package is "Not Affected" for the same vulnerability.
The "Affected" status from the higher-priority repository (`repo1`) takes precedence, and Trivy will consider the package as affected by the vulnerability.
### Repository Updates
VEX repositories are automatically updated during scanning.
Updates are performed based on the update frequency specified by the repository.
To disable auto-update, pass `--skip-vex-repo-update`.
```shell
$ trivy image ghcr.io/aquasecurity/trivy:0.50.0 --vex repo --skip-vex-repo-update
```
To download VEX repositories in advance without scanning, use `trivy vex repo download`.
The cache can be cleared with `trivy clean --vex-repo`.
### Displaying Filtered Vulnerabilities
To see which vulnerabilities were filtered and why, use the `--show-suppressed` option:
```shell
$ trivy image ghcr.io/aquasecurity/trivy:0.50.0 --vex repo --show-suppressed
...
Suppressed Vulnerabilities (Total: 4)
=====================================
┌───────────────┬────────────────┬──────────┬──────────────┬───────────────────────────────────────────────────┬──────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Statement │ Source │
├───────────────┼────────────────┼──────────┼──────────────┼───────────────────────────────────────────────────┼──────────────────────────────────────────┤
│ busybox │ CVE-2023-42364 │ MEDIUM │ not_affected │ vulnerable_code_cannot_be_controlled_by_adversary │ VEX Repository: default │
│ │ │ │ │ │ (https://github.com/aquasecurity/vexhub)
│ ├────────────────┤ │ │ │ │
│ │ CVE-2023-42365 │ │ │ │ │
│ │ │ │ │ │ │
├───────────────┼────────────────┤ │ │ │ │
│ busybox-binsh │ CVE-2023-42364 │ │ │ │ │
│ │ │ │ │ │ │
│ ├────────────────┤ │ │ │ │
│ │ CVE-2023-42365 │ │ │ │ │
│ │ │ │ │ │ │
└───────────────┴────────────────┴──────────┴──────────────┴───────────────────────────────────────────────────┴──────────────────────────────────────────┘
```
## Publishing VEX Documents
### For OSS Projects
As an OSS developer or maintainer, you may encounter vulnerabilities in the packages your project depends on.
These vulnerabilities might be discovered through your own scans or reported by third parties using your OSS project.
While Trivy strives to minimize false positives, it doesn't perform code graph analysis, which means it can't evaluate exploitability at the code level.
Consequently, Trivy may report vulnerabilities even in cases where:
1. The vulnerable function in a dependency is never called in your project.
2. The vulnerable code cannot be controlled by an attacker in the context of your project.
If you're confident that a reported vulnerability in a dependency doesn't affect your OSS project or container image, you can publish a VEX document to reduce noise in Trivy scans.
To assess exploitability, you have several options:
1. Manual assessment: As a maintainer, you can read the source code and determine if the vulnerability is exploitable in your project's context.
2. Automated assessment: You can use SAST (Static Application Security Testing) tools or similar tools to analyze the code and determine exploitability.
By publishing VEX documents in the source repository, Trivy can automatically utilize them through VEX Hub.
The main steps are:
1. Generate a VEX document
2. Commit the VEX document to the `.vex/` directory in the source repository (e.g., [Trivy's VEX][trivy-vex])
3. Register your project's [PURL][purl] in VEX Hub
Step 3 is only necessary once.
After that, updating the VEX file in your repository will automatically be fetched by VEX Hub and utilized by Trivy.
See the [VEX Hub repository][vexhub] for more information.
If you want to issue a VEX for an OSS project that you don't maintain, consider first proposing the VEX publication to the original repository.
Many OSS maintainers are open to contributions that improve the security posture of their projects.
However, if your proposal is not accepted, or if you want to issue a VEX with statements that differ from the maintainer's judgment, you may want to consider creating a [custom repository](#hosting-custom-repositories).
### For Private Projects
If you're working on private software or personal projects, you have several options:
1. [Local VEX files](./file.md): You can create local VEX files and have Trivy read them during scans. This is suitable for individual use or small teams.
2. [.trivyignore](../../configuration/filtering.md#trivyignore): For simpler cases, using a .trivyignore file might be sufficient to suppress specific vulnerabilities.
3. [Custom repositories](#hosting-custom-repositories): For large organizations wanting to share VEX information for internally used software across different departments, setting up a custom VEX repository might be the best approach.
## Hosting Custom Repositories
While the principle is to store VEX documents for OSS packages in the source repository, it's possible to create a custom repository if that's difficult.
There are various use cases for providing custom repositories:
- A Pull Request to add a VEX document upstream was not merged
- Consolidating VEX documents output by SAST tools
- Publishing vendor-specific VEX documents that differ from OSS maintainer statements
- Creating a private VEX repository to publish common VEX for your company
In these cases, you can create a repository that complies with [the VEX Repository Specification][vex-repo] to make it available for use with Trivy.
[vex-repo]: https://github.com/aquasecurity/vex-repo-spec
[vexhub]: https://github.com/aquasecurity/vexhub
[trivy-vex]: https://github.com/aquasecurity/trivy/blob/b76a7250912cfc028cfef743f0f98cd81b39f8aa/.vex/trivy.openvex.json
[purl]: https://github.com/package-url/purl-spec

2
go.mod
View File

@@ -52,6 +52,7 @@ require (
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/go-containerregistry v0.20.0
github.com/google/go-github/v62 v62.0.0
github.com/google/licenseclassifier/v2 v2.0.0
github.com/google/uuid v1.6.0
github.com/google/wire v0.6.0
@@ -244,6 +245,7 @@ require (
github.com/google/btree v1.1.2 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect

4
go.sum
View File

@@ -1357,6 +1357,10 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
github.com/google/go-containerregistry v0.20.0 h1:wRqHpOeVh3DnenOrPy9xDOLdnLatiGuuNRVelR2gSbg=
github.com/google/go-containerregistry v0.20.0/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4=
github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=

View File

@@ -28,11 +28,13 @@ import (
"github.com/aquasecurity/trivy-db/pkg/metadata"
"github.com/aquasecurity/trivy/internal/dbtest"
"github.com/aquasecurity/trivy/internal/testutil"
"github.com/aquasecurity/trivy/pkg/clock"
"github.com/aquasecurity/trivy/pkg/commands"
"github.com/aquasecurity/trivy/pkg/db"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/uuid"
"github.com/aquasecurity/trivy/pkg/vex/repo"
_ "modernc.org/sqlite"
)
@@ -68,6 +70,43 @@ func initDB(t *testing.T) string {
return cacheDir
}
func initVEXRepository(t *testing.T, homeDir, cacheDir string) {
t.Helper()
// Copy config directory
configSrc := "testdata/fixtures/vex/config/repository.yaml"
configDst := filepath.Join(homeDir, ".trivy", "vex", "repository.yaml")
testutil.CopyFile(t, configSrc, configDst)
// Copy repository directory
repoSrc := "testdata/fixtures/vex/repositories"
repoDst := filepath.Join(cacheDir, "vex", "repositories")
testutil.CopyDir(t, repoSrc, repoDst)
// Copy VEX file
vexSrc := "testdata/fixtures/vex/file/openvex.json"
repoDir := filepath.Join(repoDst, "default")
vexDst := filepath.Join(repoDir, "0.1", "openvex.json")
testutil.CopyFile(t, vexSrc, vexDst)
// Write a dummy cache metadata
testutil.MustWriteJSON(t, filepath.Join(repoDir, "cache.json"), repo.CacheMetadata{
UpdatedAt: time.Now(),
})
// Verify that necessary files exist
requiredFiles := []string{
configDst,
filepath.Join(repoDir, "vex-repository.json"),
filepath.Join(repoDir, "0.1", "index.json"),
filepath.Join(repoDir, "0.1", "openvex.json"),
}
for _, file := range requiredFiles {
require.FileExists(t, file)
}
}
func getFreePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {

View File

@@ -8,9 +8,10 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/pkg/fanal/artifact"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/stretchr/testify/require"
)
// TestRepository tests `trivy repo` with the local code repositories
@@ -32,6 +33,7 @@ func TestRepository(t *testing.T) {
format types.Format
includeDevDeps bool
parallel int
vex string
}
tests := []struct {
name string
@@ -74,6 +76,24 @@ func TestRepository(t *testing.T) {
},
golden: "testdata/gomod.json.golden",
},
{
name: "gomod with local VEX file",
args: args{
scanner: types.VulnerabilityScanner,
input: "testdata/fixtures/repo/gomod",
vex: "testdata/fixtures/vex/file/openvex.json",
},
golden: "testdata/gomod-vex.json.golden",
},
{
name: "gomod with VEX repository",
args: args{
scanner: types.VulnerabilityScanner,
input: "testdata/fixtures/repo/gomod",
vex: "repo",
},
golden: "testdata/gomod-vex.json.golden",
},
{
name: "npm",
args: args{
@@ -437,9 +457,15 @@ func TestRepository(t *testing.T) {
// Set up testing DB
cacheDir := initDB(t)
// Set a temp dir so that modules will not be loaded
// Set up VEX
initVEXRepository(t, cacheDir, cacheDir)
// Set a temp dir so that the VEX config will be loaded and modules will not be loaded
t.Setenv("XDG_DATA_HOME", cacheDir)
// Disable Go license detection
t.Setenv("GOPATH", cacheDir)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
command := "repo"
@@ -532,6 +558,10 @@ func TestRepository(t *testing.T) {
osArgs = append(osArgs, "--secret-config", tt.args.secretConfig)
}
if tt.args.vex != "" {
osArgs = append(osArgs, "--vex", tt.args.vex)
}
runTest(t, osArgs, tt.golden, "", format, runOptions{
fakeUUID: "3ff14136-e09f-4df9-80ea-%012d",
override: tt.override,

View File

@@ -0,0 +1,4 @@
repositories:
- name: default
url: https://localhost
enabled: true

View File

@@ -0,0 +1,23 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "aquasecurity/trivy:613fd55abbc2857b5ca28b07a26f3cd4c8b0ddc4c8a97c57497a2d4c4880d7fc",
"author": "Aqua Security",
"timestamp": "2024-07-09T11:38:00.115697+04:00",
"version": 1,
"statements": [
{
"vulnerability": { "@id": "CVE-2022-23628" },
"products": [
{
"@id": "pkg:golang/github.com/testdata/testdata",
"subcomponents": [
{ "@id": "pkg:golang/github.com/open-policy-agent/opa@0.35.0" }
]
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The vulnerable code isn't called"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"version": 1,
"packages": [
{
"ID": "pkg:golang/github.com/testdata/testdata",
"Location": "./openvex.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"name": "Test VEX Repository",
"description": "VEX Repository for Testing",
"versions": [
{
"spec_version": "0.1",
"locations": [
{
"url": "never used"
}
],
"update_interval": "24h"
}
]
}

View File

@@ -0,0 +1,148 @@
{
"SchemaVersion": 2,
"CreatedAt": "2021-08-25T12:20:30.000000005Z",
"ArtifactName": "testdata/fixtures/repo/gomod",
"ArtifactType": "repository",
"Metadata": {
"ImageConfig": {
"architecture": "",
"created": "0001-01-01T00:00:00Z",
"os": "",
"rootfs": {
"type": "",
"diff_ids": null
},
"config": {}
}
},
"Results": [
{
"Target": "go.mod",
"Class": "lang-pkgs",
"Type": "gomod",
"Vulnerabilities": [
{
"VulnerabilityID": "GMS-2022-20",
"PkgID": "github.com/docker/distribution@v2.7.1+incompatible",
"PkgName": "github.com/docker/distribution",
"PkgIdentifier": {
"PURL": "pkg:golang/github.com/docker/distribution@2.7.1%2Bincompatible",
"UID": "de19cd663ca047a8"
},
"InstalledVersion": "2.7.1+incompatible",
"FixedVersion": "v2.8.0",
"Status": "fixed",
"Layer": {},
"DataSource": {
"ID": "ghsa",
"Name": "GitHub Security Advisory Go",
"URL": "https://github.com/advisories?query=type%3Areviewed+ecosystem%3Ago"
},
"Title": "OCI Manifest Type Confusion Issue",
"Description": "### Impact\n\nSystems that rely on digest equivalence for image attestations may be vulnerable to type confusion.",
"Severity": "UNKNOWN",
"References": [
"https://github.com/advisories/GHSA-qq97-vm5h-rrhg",
"https://github.com/distribution/distribution/commit/b59a6f827947f9e0e67df0cfb571046de4733586",
"https://github.com/distribution/distribution/security/advisories/GHSA-qq97-vm5h-rrhg",
"https://github.com/opencontainers/image-spec/pull/411"
]
},
{
"VulnerabilityID": "CVE-2021-38561",
"PkgID": "golang.org/x/text@v0.3.6",
"PkgName": "golang.org/x/text",
"PkgIdentifier": {
"PURL": "pkg:golang/golang.org/x/text@0.3.6",
"UID": "825dc613c0f39d45"
},
"InstalledVersion": "0.3.6",
"FixedVersion": "0.3.7",
"Status": "fixed",
"Layer": {},
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2021-38561",
"DataSource": {
"ID": "ghsa",
"Name": "GitHub Security Advisory Go",
"URL": "https://github.com/advisories?query=type%3Areviewed+ecosystem%3Ago"
},
"Description": "Due to improper index calculation, an incorrectly formatted language tag can cause Parse\nto panic via an out of bounds read. If Parse is used to process untrusted user inputs,\nthis may be used as a vector for a denial of service attack.\n",
"Severity": "UNKNOWN",
"References": [
"https://go-review.googlesource.com/c/text/+/340830",
"https://go.googlesource.com/text/+/383b2e75a7a4198c42f8f87833eefb772868a56f",
"https://pkg.go.dev/vuln/GO-2021-0113"
]
}
]
},
{
"Target": "submod/go.mod",
"Class": "lang-pkgs",
"Type": "gomod",
"Vulnerabilities": [
{
"VulnerabilityID": "GMS-2022-20",
"PkgID": "github.com/docker/distribution@v2.7.1+incompatible",
"PkgName": "github.com/docker/distribution",
"PkgIdentifier": {
"PURL": "pkg:golang/github.com/docker/distribution@2.7.1%2Bincompatible",
"UID": "94376dc37054a7e8"
},
"InstalledVersion": "2.7.1+incompatible",
"FixedVersion": "v2.8.0",
"Status": "fixed",
"Layer": {},
"DataSource": {
"ID": "ghsa",
"Name": "GitHub Security Advisory Go",
"URL": "https://github.com/advisories?query=type%3Areviewed+ecosystem%3Ago"
},
"Title": "OCI Manifest Type Confusion Issue",
"Description": "### Impact\n\nSystems that rely on digest equivalence for image attestations may be vulnerable to type confusion.",
"Severity": "UNKNOWN",
"References": [
"https://github.com/advisories/GHSA-qq97-vm5h-rrhg",
"https://github.com/distribution/distribution/commit/b59a6f827947f9e0e67df0cfb571046de4733586",
"https://github.com/distribution/distribution/security/advisories/GHSA-qq97-vm5h-rrhg",
"https://github.com/opencontainers/image-spec/pull/411"
]
}
]
},
{
"Target": "submod2/go.mod",
"Class": "lang-pkgs",
"Type": "gomod",
"Vulnerabilities": [
{
"VulnerabilityID": "GMS-2022-20",
"PkgID": "github.com/docker/distribution@v2.7.1+incompatible",
"PkgName": "github.com/docker/distribution",
"PkgIdentifier": {
"PURL": "pkg:golang/github.com/docker/distribution@2.7.1%2Bincompatible",
"UID": "94306cdcf85fb50a"
},
"InstalledVersion": "2.7.1+incompatible",
"FixedVersion": "v2.8.0",
"Status": "fixed",
"Layer": {},
"DataSource": {
"ID": "ghsa",
"Name": "GitHub Security Advisory Go",
"URL": "https://github.com/advisories?query=type%3Areviewed+ecosystem%3Ago"
},
"Title": "OCI Manifest Type Confusion Issue",
"Description": "### Impact\n\nSystems that rely on digest equivalence for image attestations may be vulnerable to type confusion.",
"Severity": "UNKNOWN",
"References": [
"https://github.com/advisories/GHSA-qq97-vm5h-rrhg",
"https://github.com/distribution/distribution/commit/b59a6f827947f9e0e67df0cfb571046de4733586",
"https://github.com/distribution/distribution/security/advisories/GHSA-qq97-vm5h-rrhg",
"https://github.com/opencontainers/image-spec/pull/411"
]
}
]
}
]
}

View File

@@ -1,15 +1,24 @@
package testutil
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
)
func CopyFile(t *testing.T, src, dst string) {
MustMkdirAll(t, filepath.Dir(dst))
_, err := fsutils.CopyFile(src, dst)
require.NoError(t, err)
}
// CopyDir copies the directory content from src to dst.
// It supports only simple cases for testing.
func CopyDir(t *testing.T, src, dst string) {
@@ -34,3 +43,50 @@ func CopyDir(t *testing.T, src, dst string) {
}
}
}
func MustWriteYAML(t *testing.T, path string, data any) {
t.Helper()
MustMkdirAll(t, filepath.Dir(path))
f, err := os.Create(path)
require.NoError(t, err)
defer f.Close()
require.NoError(t, yaml.NewEncoder(f).Encode(data))
}
func MustReadYAML(t *testing.T, path string, out any) {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
defer f.Close()
require.NoError(t, yaml.NewDecoder(f).Decode(out))
}
func MustMkdirAll(t *testing.T, dir string) {
err := os.MkdirAll(dir, 0750)
require.NoError(t, err)
}
func MustReadJSON(t *testing.T, filePath string, v any) {
b, err := os.ReadFile(filePath)
require.NoError(t, err)
err = json.Unmarshal(b, v)
require.NoError(t, err)
}
func MustWriteJSON(t *testing.T, filePath string, v any) {
data, err := json.Marshal(v)
require.NoError(t, err)
MustWriteFile(t, filePath, data)
}
func MustWriteFile(t *testing.T, filePath string, content []byte) {
dir := filepath.Dir(filePath)
MustMkdirAll(t, dir)
err := os.WriteFile(filePath, content, 0600)
require.NoError(t, err)
}

View File

@@ -124,10 +124,13 @@ nav:
- Supply Chain:
- SBOM: docs/supply-chain/sbom.md
- Attestation:
- SBOM: docs/supply-chain/attestation/sbom.md
- Cosign Vulnerability Scan Record: docs/supply-chain/attestation/vuln.md
- SBOM Attestation in Rekor: docs/supply-chain/attestation/rekor.md
- VEX: docs/supply-chain/vex.md
- SBOM: docs/supply-chain/attestation/sbom.md
- Cosign Vulnerability Scan Record: docs/supply-chain/attestation/vuln.md
- SBOM Attestation in Rekor: docs/supply-chain/attestation/rekor.md
- VEX:
- Overview: docs/supply-chain/vex/index.md
- VEX Repository: docs/supply-chain/vex/repo.md
- Local VEX Files: docs/supply-chain/vex/file.md
- Compliance:
- Built-in Compliance: docs/compliance/compliance.md
- Custom Compliance: docs/compliance/contrib-compliance.md

View File

@@ -27,6 +27,7 @@ import (
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/version"
"github.com/aquasecurity/trivy/pkg/version/app"
vexrepo "github.com/aquasecurity/trivy/pkg/vex/repo"
xstrings "github.com/aquasecurity/trivy/pkg/x/strings"
)
@@ -98,6 +99,7 @@ func NewApp() *cobra.Command {
NewVersionCommand(globalFlags),
NewVMCommand(globalFlags),
NewCleanCommand(globalFlags),
NewVEXCommand(globalFlags),
)
if plugins := loadPluginCommands(); len(plugins) > 0 {
@@ -1228,6 +1230,92 @@ func NewCleanCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
return cmd
}
func NewVEXCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
vexFlags := &flag.Flags{
GlobalFlagGroup: globalFlags,
}
var vexOptions flag.Options
cmd := &cobra.Command{
Use: "vex subcommand",
GroupID: groupManagement,
Short: "[EXPERIMENTAL] VEX utilities",
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
cmd.SetContext(log.WithContextPrefix(cmd.Context(), "vex"))
vexOptions, err = vexFlags.ToOptions(args)
if err != nil {
return err
}
return nil
},
}
repoCmd := &cobra.Command{
Use: "repo subcommand",
Short: "Manage VEX repositories",
SilenceErrors: true,
SilenceUsage: true,
Example: ` # Initialize the configuration file
$ trivy vex repo init
# List VEX repositories
$ trivy vex repo list
# Download the VEX repositories
$ trivy vex repo download
`,
}
repoCmd.AddCommand(
&cobra.Command{
Use: "init",
Short: "Initialize a configuration file",
SilenceErrors: true,
SilenceUsage: true,
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if err := vexrepo.NewManager(vexOptions.CacheDir).Init(cmd.Context()); err != nil {
return xerrors.Errorf("config init error: %w", err)
}
return nil
},
},
&cobra.Command{
Use: "list",
Short: "List VEX repositories",
SilenceErrors: true,
SilenceUsage: true,
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if err := vexrepo.NewManager(vexOptions.CacheDir).List(cmd.Context()); err != nil {
return xerrors.Errorf("list error: %w", err)
}
return nil
},
},
&cobra.Command{
Use: "download [REPO_NAMES]",
Short: "Download the VEX repositories",
Long: `Downloads enabled VEX repositories. If specific repository names are provided as arguments, only those repositories will be downloaded. Otherwise, all enabled repositories are downloaded.`,
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
err := vexrepo.NewManager(vexOptions.CacheDir).DownloadRepositories(cmd.Context(), args,
vexrepo.Options{Insecure: vexOptions.Insecure})
if err != nil {
return xerrors.Errorf("repository download error: %w", err)
}
return nil
},
},
)
cmd.AddCommand(repoCmd)
return cmd
}
func NewVersionCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command {
var versionFormat string
cmd := &cobra.Command{

View File

@@ -119,6 +119,11 @@ func NewRunner(ctx context.Context, cliOptions flag.Options, opts ...RunnerOptio
return nil, xerrors.Errorf("DB error: %w", err)
}
// Update the VEX repositories if needed
if err := operation.DownloadVEXRepositories(ctx, cliOptions); err != nil {
return nil, xerrors.Errorf("VEX repositories download error: %w", err)
}
// Initialize WASM modules
m, err := module.NewManager(ctx, module.Options{
Dir: cliOptions.ModuleDir,

View File

@@ -12,13 +12,15 @@ import (
"github.com/aquasecurity/trivy/pkg/javadb"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/policy"
"github.com/aquasecurity/trivy/pkg/vex/repo"
)
func Run(ctx context.Context, opts flag.Options) error {
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
if !opts.CleanAll && !opts.CleanScanCache && !opts.CleanVulnerabilityDB && !opts.CleanJavaDB && !opts.CleanChecksBundle {
if !opts.CleanAll && !opts.CleanScanCache && !opts.CleanVulnerabilityDB && !opts.CleanJavaDB &&
!opts.CleanChecksBundle && !opts.CleanVEXRepositories {
return xerrors.New("no clean option is specified")
}
@@ -49,6 +51,12 @@ func Run(ctx context.Context, opts flag.Options) error {
return xerrors.Errorf("check bundle clean error: %w", err)
}
}
if opts.CleanVEXRepositories {
if err := cleanVEXRepositories(opts); err != nil {
return xerrors.Errorf("VEX repositories clean error: %w", err)
}
}
return nil
}
@@ -102,3 +110,11 @@ func cleanCheckBundle(opts flag.Options) error {
}
return nil
}
func cleanVEXRepositories(opts flag.Options) error {
log.Info("Removing VEX repositories...")
if err := repo.NewManager(opts.CacheDir).Clear(); err != nil {
return xerrors.Errorf("clear VEX repositories: %w", err)
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"sync"
"github.com/google/go-containerregistry/pkg/name"
"github.com/samber/lo"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/db"
@@ -13,6 +14,8 @@ import (
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/policy"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
"github.com/aquasecurity/trivy/pkg/vex/repo"
)
var mu sync.Mutex
@@ -23,6 +26,7 @@ func DownloadDB(ctx context.Context, appVersion, cacheDir string, dbRepository n
mu.Lock()
defer mu.Unlock()
ctx = log.WithContextPrefix(ctx, "db")
dbDir := db.Dir(cacheDir)
client := db.NewClient(dbDir, quiet, db.WithDBRepository(dbRepository))
needsUpdate, err := client.NeedsUpdate(ctx, appVersion, skipUpdate)
@@ -31,8 +35,8 @@ func DownloadDB(ctx context.Context, appVersion, cacheDir string, dbRepository n
}
if needsUpdate {
log.Info("Need to update DB")
log.Info("Downloading DB...", log.String("repository", dbRepository.String()))
log.InfoContext(ctx, "Need to update DB")
log.InfoContext(ctx, "Downloading DB...", log.String("repository", dbRepository.String()))
if err = client.Download(ctx, dbDir, opt); err != nil {
return xerrors.Errorf("failed to download vulnerability DB: %w", err)
}
@@ -45,6 +49,35 @@ func DownloadDB(ctx context.Context, appVersion, cacheDir string, dbRepository n
return nil
}
func DownloadVEXRepositories(ctx context.Context, opts flag.Options) error {
ctx = log.WithContextPrefix(ctx, "vex")
if opts.SkipVEXRepoUpdate {
log.InfoContext(ctx, "Skipping VEX repository update")
return nil
}
mu.Lock()
defer mu.Unlock()
// Download VEX repositories only if `--vex repo` is passed.
_, enabled := lo.Find(opts.VEXSources, func(src vex.Source) bool {
return src.Type == vex.TypeRepository
})
if !enabled {
return nil
}
err := repo.NewManager(opts.CacheDir).DownloadRepositories(ctx, nil, repo.Options{
Insecure: opts.Insecure,
})
if err != nil {
return xerrors.Errorf("failed to download vex repositories: %w", err)
}
return nil
}
// InitBuiltinPolicies downloads the built-in policies and loads them
func InitBuiltinPolicies(ctx context.Context, cacheDir string, quiet, skipUpdate bool, checkBundleRepository string, registryOpts ftypes.RegistryOptions) ([]string, error) {
mu.Lock()

View File

@@ -38,6 +38,9 @@ func ID(ltype types.LangType, name, version string) string {
// UID calculates the hash of the package for the unique ID
func UID(filePath string, pkg types.Package) string {
if pkg.Identifier.UID != "" {
return pkg.Identifier.UID
}
v := map[string]any{
"filePath": filePath, // To differentiate the hash of the same package but different file path
"pkg": pkg,

View File

@@ -1,16 +1,40 @@
package downloader
import (
"cmp"
"context"
"crypto/tls"
"errors"
"maps"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/google/go-github/v62/github"
getter "github.com/hashicorp/go-getter"
"github.com/samber/lo"
"golang.org/x/xerrors"
)
var ErrSkipDownload = errors.New("skip download")
type Options struct {
Insecure bool
Auth Auth
ETag string
ClientMode getter.ClientMode
}
type Auth struct {
Username string
Password string
Token string
}
// DownloadToTempDir downloads the configured source to a temp dir.
func DownloadToTempDir(ctx context.Context, url string, insecure bool) (string, error) {
func DownloadToTempDir(ctx context.Context, src string, opts Options) (string, error) {
tempDir, err := os.MkdirTemp("", "trivy-download")
if err != nil {
return "", xerrors.Errorf("failed to create a temp dir: %w", err)
@@ -21,7 +45,7 @@ func DownloadToTempDir(ctx context.Context, url string, insecure bool) (string,
return "", xerrors.Errorf("unable to get the current dir: %w", err)
}
if err = Download(ctx, url, tempDir, pwd, insecure); err != nil {
if _, err = Download(ctx, src, tempDir, pwd, opts); err != nil {
return "", xerrors.Errorf("download error: %w", err)
}
@@ -29,13 +53,13 @@ func DownloadToTempDir(ctx context.Context, url string, insecure bool) (string,
}
// Download downloads the configured source to the destination.
func Download(ctx context.Context, src, dst, pwd string, insecure bool) error {
func Download(ctx context.Context, src, dst, pwd string, opts Options) (string, error) {
// go-getter doesn't allow the dst directory already exists if the src is directory.
_ = os.RemoveAll(dst)
var opts []getter.ClientOption
if insecure {
opts = append(opts, getter.WithInsecure())
var clientOpts []getter.ClientOption
if opts.Insecure {
clientOpts = append(clientOpts, getter.WithInsecure())
}
// Clone the global map so that it will not be accessed concurrently.
@@ -47,8 +71,16 @@ func Download(ctx context.Context, src, dst, pwd string, insecure bool) error {
// Since "httpGetter" is a global pointer and the state is shared,
// once it is executed without "WithInsecure()",
// it cannot enable WithInsecure() afterwards because its state is preserved.
// Therefore, we need to create a new "HttpGetter" instance every time.
// cf. https://github.com/hashicorp/go-getter/blob/5a63fd9c0d5b8da8a6805e8c283f46f0dacb30b3/get.go#L63-L65
httpGetter := &getter.HttpGetter{Netrc: true}
transport := NewCustomTransport(opts)
httpGetter := &getter.HttpGetter{
Netrc: true,
Client: &http.Client{
Transport: transport,
Timeout: time.Minute * 5,
},
}
getters["http"] = httpGetter
getters["https"] = httpGetter
@@ -59,13 +91,110 @@ func Download(ctx context.Context, src, dst, pwd string, insecure bool) error {
Dst: dst,
Pwd: pwd,
Getters: getters,
Mode: getter.ClientModeAny,
Options: opts,
Mode: lo.Ternary(opts.ClientMode == 0, getter.ClientModeAny, opts.ClientMode),
Options: clientOpts,
}
if err := client.Get(); err != nil {
return xerrors.Errorf("failed to download: %w", err)
return "", xerrors.Errorf("failed to download %s: %w", src, err)
}
return nil
return transport.newETag, nil
}
type CustomTransport struct {
auth Auth
cachedETag string
newETag string
insecure bool
}
func NewCustomTransport(opts Options) *CustomTransport {
return &CustomTransport{
auth: opts.Auth,
cachedETag: opts.ETag,
insecure: opts.Insecure,
}
}
func (t *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.cachedETag != "" {
req.Header.Set("If-None-Match", t.cachedETag)
}
if t.auth.Token != "" {
req.Header.Set("Authorization", "Bearer "+t.auth.Token)
} else if t.auth.Username != "" || t.auth.Password != "" {
req.SetBasicAuth(t.auth.Username, t.auth.Password)
}
var transport http.RoundTripper
if req.URL.Host == "github.com" {
transport = NewGitHubTransport(req.URL, t.insecure, t.auth.Token)
}
if transport == nil {
transport = httpTransport(t.insecure)
}
res, err := transport.RoundTrip(req)
if err != nil {
return nil, xerrors.Errorf("failed to round trip: %w", err)
}
switch res.StatusCode {
case http.StatusOK, http.StatusPartialContent:
// Update the ETag
t.newETag = res.Header.Get("ETag")
case http.StatusNotModified:
return nil, ErrSkipDownload
}
return res, nil
}
func NewGitHubTransport(u *url.URL, insecure bool, token string) http.RoundTripper {
client := newGitHubClient(insecure, token)
ss := strings.SplitN(u.Path, "/", 4)
if len(ss) < 4 || strings.HasPrefix(ss[3], "archive/") {
// Use the default transport from go-github for authentication
return client.Client().Transport
}
return &GitHubContentTransport{
owner: ss[1],
repo: ss[2],
filePath: ss[3],
client: client,
}
}
// GitHubContentTransport is a round tripper for downloading the GitHub content.
type GitHubContentTransport struct {
owner string
repo string
filePath string
client *github.Client
}
// RoundTrip calls the GitHub API to download the content.
func (t *GitHubContentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
_, res, err := t.client.Repositories.DownloadContents(req.Context(), t.owner, t.repo, t.filePath, nil)
if err != nil {
return nil, xerrors.Errorf("failed to get the file content: %w", err)
}
return res.Response, nil
}
func newGitHubClient(insecure bool, token string) *github.Client {
client := github.NewClient(&http.Client{Transport: httpTransport(insecure)})
token = cmp.Or(token, os.Getenv("GITHUB_TOKEN"))
if token != "" {
client = client.WithAuthToken(token)
}
return client
}
func httpTransport(insecure bool) *http.Transport {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: insecure}
return tr
}

View File

@@ -44,7 +44,9 @@ func TestDownload(t *testing.T) {
dst := t.TempDir()
// Execute the download
err := downloader.Download(context.Background(), server.URL, dst, "", tt.insecure)
_, err := downloader.Download(context.Background(), server.URL, dst, "", downloader.Options{
Insecure: tt.insecure,
})
if tt.wantErr {
assert.Error(t, err)

View File

@@ -27,31 +27,39 @@ var (
ConfigName: "clean.checks-bundle",
Usage: "remove checks bundle",
}
CleanVEXRepo = Flag[bool]{
Name: "vex-repo",
ConfigName: "clean.vex-repo",
Usage: "remove VEX repositories",
}
)
type CleanFlagGroup struct {
CleanAll *Flag[bool]
CleanScanCache *Flag[bool]
CleanVulnerabilityDB *Flag[bool]
CleanJavaDB *Flag[bool]
CleanChecksBundle *Flag[bool]
CleanScanCache *Flag[bool]
CleanVEXRepositories *Flag[bool]
}
type CleanOptions struct {
CleanAll bool
CleanScanCache bool
CleanVulnerabilityDB bool
CleanJavaDB bool
CleanChecksBundle bool
CleanScanCache bool
CleanVEXRepositories bool
}
func NewCleanFlagGroup() *CleanFlagGroup {
return &CleanFlagGroup{
CleanAll: CleanAll.Clone(),
CleanScanCache: CleanScanCache.Clone(),
CleanVulnerabilityDB: CleanVulnerabilityDB.Clone(),
CleanJavaDB: CleanJavaDB.Clone(),
CleanChecksBundle: CleanChecksBundle.Clone(),
CleanScanCache: CleanScanCache.Clone(),
CleanVEXRepositories: CleanVEXRepo.Clone(),
}
}
@@ -62,10 +70,11 @@ func (fg *CleanFlagGroup) Name() string {
func (fg *CleanFlagGroup) Flags() []Flagger {
return []Flagger{
fg.CleanAll,
fg.CleanScanCache,
fg.CleanVulnerabilityDB,
fg.CleanJavaDB,
fg.CleanChecksBundle,
fg.CleanScanCache,
fg.CleanVEXRepositories,
}
}
@@ -80,5 +89,6 @@ func (fg *CleanFlagGroup) ToOptions() (CleanOptions, error) {
CleanJavaDB: fg.CleanJavaDB.Value(),
CleanChecksBundle: fg.CleanChecksBundle.Value(),
CleanScanCache: fg.CleanScanCache.Value(),
CleanVEXRepositories: fg.CleanVEXRepositories.Value(),
}, nil
}

View File

@@ -432,15 +432,16 @@ func (o *Options) RegistryOpts() ftypes.RegistryOptions {
}
// FilterOpts returns options for filtering
func (o *Options) FilterOpts() result.FilterOption {
return result.FilterOption{
func (o *Options) FilterOpts() result.FilterOptions {
return result.FilterOptions{
Severities: o.Severities,
IgnoreStatuses: o.IgnoreStatuses,
IncludeNonFailures: o.IncludeNonFailures,
IgnoreFile: o.IgnoreFile,
PolicyFile: o.IgnorePolicy,
IgnoreLicenses: o.IgnoredLicenses,
VEXPath: o.VEXPath,
CacheDir: o.CacheDir,
VEXSources: o.VEXSources,
}
}

View File

@@ -5,6 +5,7 @@ import (
dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/vex"
)
var (
@@ -19,30 +20,37 @@ var (
Values: dbTypes.Statuses,
Usage: "comma-separated list of vulnerability status to ignore",
}
VEXFlag = Flag[string]{
VEXFlag = Flag[[]string]{
Name: "vex",
ConfigName: "vulnerability.vex",
Default: "",
Usage: "[EXPERIMENTAL] file path to VEX",
Usage: `[EXPERIMENTAL] VEX sources ("repo" or file path)`,
}
SkipVEXRepoUpdateFlag = Flag[bool]{
Name: "skip-vex-repo-update",
ConfigName: "vulnerability.skip-vex-repo-update",
Usage: `[EXPERIMENTAL] Skip VEX Repository update`,
}
)
type VulnerabilityFlagGroup struct {
IgnoreUnfixed *Flag[bool]
IgnoreStatus *Flag[[]string]
VEXPath *Flag[string]
IgnoreUnfixed *Flag[bool]
IgnoreStatus *Flag[[]string]
VEX *Flag[[]string]
SkipVEXRepoUpdate *Flag[bool]
}
type VulnerabilityOptions struct {
IgnoreStatuses []dbTypes.Status
VEXPath string
IgnoreStatuses []dbTypes.Status
VEXSources []vex.Source
SkipVEXRepoUpdate bool
}
func NewVulnerabilityFlagGroup() *VulnerabilityFlagGroup {
return &VulnerabilityFlagGroup{
IgnoreUnfixed: IgnoreUnfixedFlag.Clone(),
IgnoreStatus: IgnoreStatusFlag.Clone(),
VEXPath: VEXFlag.Clone(),
IgnoreUnfixed: IgnoreUnfixedFlag.Clone(),
IgnoreStatus: IgnoreStatusFlag.Clone(),
VEX: VEXFlag.Clone(),
SkipVEXRepoUpdate: SkipVEXRepoUpdateFlag.Clone(),
}
}
@@ -54,7 +62,8 @@ func (f *VulnerabilityFlagGroup) Flags() []Flagger {
return []Flagger{
f.IgnoreUnfixed,
f.IgnoreStatus,
f.VEXPath,
f.VEX,
f.SkipVEXRepoUpdate,
}
}
@@ -88,6 +97,9 @@ func (f *VulnerabilityFlagGroup) ToOptions() (VulnerabilityOptions, error) {
return VulnerabilityOptions{
IgnoreStatuses: ignoreStatuses,
VEXPath: f.VEXPath.Value(),
VEXSources: lo.Map(f.VEX.Value(), func(s string, _ int) vex.Source {
return vex.NewSource(s)
}),
SkipVEXRepoUpdate: f.SkipVEXRepoUpdate.Value(),
}, nil
}

View File

@@ -68,7 +68,12 @@ func Errorf(format string, args ...any) { slog.Default().Error(fmt.Sprintf(forma
// Fatal for logging fatal errors
func Fatal(msg string, args ...any) {
// Fatal errors should be logged to stderr even if the logger is disabled.
New(NewHandler(os.Stderr, &Options{})).Log(context.Background(), LevelFatal, msg, args...)
if h, ok := slog.Default().Handler().(*ColorHandler); ok {
h.out = os.Stderr
} else {
slog.SetDefault(New(NewHandler(os.Stderr, &Options{})))
}
slog.Default().Log(context.Background(), LevelFatal, msg, args...)
os.Exit(1)
}

View File

@@ -189,7 +189,7 @@ func (a *Artifact) download(ctx context.Context, layer v1.Layer, fileName, dir s
// Decompress the downloaded file if it is compressed and copy it into the dst
// NOTE: it's local copying, the insecure option doesn't matter.
if err = downloader.Download(ctx, f.Name(), dir, dir, false); err != nil {
if _, err = downloader.Download(ctx, f.Name(), dir, dir, downloader.Options{}); err != nil {
return xerrors.Errorf("download error: %w", err)
}

View File

@@ -34,7 +34,8 @@ type Index struct {
func (m *Manager) Update(ctx context.Context, opts Options) error {
m.logger.InfoContext(ctx, "Updating the plugin index...", log.String("url", m.indexURL))
if err := downloader.Download(ctx, m.indexURL, filepath.Dir(m.indexPath), "", opts.Insecure); err != nil {
if _, err := downloader.Download(ctx, m.indexURL, filepath.Dir(m.indexPath), "",
downloader.Options{Insecure: opts.Insecure}); err != nil {
return xerrors.Errorf("unable to download the plugin index: %w", err)
}
return nil

View File

@@ -23,7 +23,7 @@ import (
const configFile = "plugin.yaml"
var (
pluginsRelativeDir = filepath.Join(".trivy", "plugins")
pluginsDir = "plugins"
_defaultManager *Manager
)
@@ -58,7 +58,7 @@ type Manager struct {
}
func NewManager(opts ...ManagerOption) *Manager {
root := filepath.Join(fsutils.HomeDir(), pluginsRelativeDir)
root := filepath.Join(fsutils.TrivyHomeDir(), pluginsDir)
m := &Manager{
w: os.Stdout,
indexURL: indexURL,
@@ -111,7 +111,7 @@ func (m *Manager) Install(ctx context.Context, arg string, opts Options) (Plugin
}
func (m *Manager) install(ctx context.Context, src string, opts Options) (Plugin, error) {
tempDir, err := downloader.DownloadToTempDir(ctx, src, opts.Insecure)
tempDir, err := downloader.DownloadToTempDir(ctx, src, downloader.Options{Insecure: opts.Insecure})
if err != nil {
return Plugin{}, xerrors.Errorf("download failed: %w", err)
}

View File

@@ -155,7 +155,7 @@ func (p *Plugin) install(ctx context.Context, dst, pwd string, opts Options) err
p.Installed.Platform = lo.FromPtr(platform.Selector)
log.DebugContext(ctx, "Downloading the execution file...", log.String("uri", platform.URI))
if err = downloader.Download(ctx, platform.URI, dst, pwd, opts.Insecure); err != nil {
if _, err = downloader.Download(ctx, platform.URI, dst, pwd, downloader.Options{Insecure: opts.Insecure}); err != nil {
return xerrors.Errorf("unable to download the execution file (%s): %w", platform.URI, err)
}
return nil
@@ -165,5 +165,5 @@ func (p *Plugin) Dir() string {
if p.dir != "" {
return p.dir
}
return filepath.Join(fsutils.HomeDir(), pluginsRelativeDir, p.Name)
return filepath.Join(fsutils.TrivyHomeDir(), pluginsDir, p.Name)
}

View File

@@ -13,8 +13,6 @@ import (
"golang.org/x/xerrors"
dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
"github.com/aquasecurity/trivy/pkg/sbom/core"
sbomio "github.com/aquasecurity/trivy/pkg/sbom/io"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
)
@@ -24,31 +22,35 @@ const (
DefaultIgnoreFile = ".trivyignore"
)
type FilterOption struct {
type FilterOptions struct {
Severities []dbTypes.Severity
IgnoreStatuses []dbTypes.Status
IncludeNonFailures bool
IgnoreFile string
PolicyFile string
IgnoreLicenses []string
VEXPath string
CacheDir string
VEXSources []vex.Source
}
// Filter filters out the report
func Filter(ctx context.Context, report types.Report, opt FilterOption) error {
ignoreConf, err := ParseIgnoreFile(ctx, opt.IgnoreFile)
func Filter(ctx context.Context, report types.Report, opts FilterOptions) error {
ignoreConf, err := ParseIgnoreFile(ctx, opts.IgnoreFile)
if err != nil {
return xerrors.Errorf("%s error: %w", opt.IgnoreFile, err)
return xerrors.Errorf("%s error: %w", opts.IgnoreFile, err)
}
for i := range report.Results {
if err = FilterResult(ctx, &report.Results[i], ignoreConf, opt); err != nil {
if err = FilterResult(ctx, &report.Results[i], ignoreConf, opts); err != nil {
return xerrors.Errorf("unable to filter vulnerabilities: %w", err)
}
}
// Filter out vulnerabilities based on the given VEX document.
if err = filterByVEX(report, opt); err != nil {
if err = vex.Filter(ctx, &report, vex.Options{
CacheDir: opts.CacheDir,
Sources: opts.VEXSources,
}); err != nil {
return xerrors.Errorf("VEX error: %w", err)
}
@@ -56,7 +58,7 @@ func Filter(ctx context.Context, report types.Report, opt FilterOption) error {
}
// FilterResult filters out the result
func FilterResult(ctx context.Context, result *types.Result, ignoreConf IgnoreConfig, opt FilterOption) error {
func FilterResult(ctx context.Context, result *types.Result, ignoreConf IgnoreConfig, opt FilterOptions) error {
// Convert dbTypes.Severity to string
severities := lo.Map(opt.Severities, func(s dbTypes.Severity, _ int) string {
return s.String()
@@ -77,31 +79,6 @@ func FilterResult(ctx context.Context, result *types.Result, ignoreConf IgnoreCo
return nil
}
// filterByVEX determines whether a detected vulnerability should be filtered out based on the provided VEX document.
// If the VEX document is not nil and the vulnerability is either not affected or fixed according to the VEX statement,
// the vulnerability is filtered out.
func filterByVEX(report types.Report, opt FilterOption) error {
vexDoc, err := vex.New(opt.VEXPath, report)
if err != nil {
return err
} else if vexDoc == nil {
return nil
}
bom, err := sbomio.NewEncoder(core.Options{Parents: true}).Encode(report)
if err != nil {
return xerrors.Errorf("unable to encode the SBOM: %w", err)
}
for i, result := range report.Results {
if len(result.Vulnerabilities) == 0 {
continue
}
vexDoc.Filter(&report.Results[i], bom)
}
return nil
}
func filterVulnerabilities(result *types.Result, severities []string, ignoreStatuses []dbTypes.Status, ignoreConfig IgnoreConfig) {
uniqVulns := make(map[string]types.DetectedVulnerability)
for _, vuln := range result.Vulnerabilities {

View File

@@ -15,6 +15,7 @@ import (
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/result"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
)
func TestFilter(t *testing.T) {
@@ -291,7 +292,7 @@ func TestFilter(t *testing.T) {
Type: types.FindingTypeVulnerability,
Status: types.FindingStatusNotAffected,
Statement: "vulnerable_code_not_in_execute_path",
Source: "OpenVEX",
Source: "testdata/openvex.json",
Finding: vuln1,
},
},
@@ -1007,9 +1008,17 @@ func TestFilter(t *testing.T) {
fakeTime := time.Date(2020, 8, 10, 7, 28, 17, 958601, time.UTC)
ctx := clock.With(context.Background(), fakeTime)
err := result.Filter(ctx, tt.args.report, result.FilterOption{
var vexSources []vex.Source
if tt.args.vexPath != "" {
vexSources = append(vexSources, vex.Source{
Type: vex.TypeFile,
FilePath: tt.args.vexPath,
})
}
err := result.Filter(ctx, tt.args.report, result.FilterOptions{
Severities: tt.args.severities,
VEXPath: tt.args.vexPath,
VEXSources: vexSources,
IgnoreStatuses: tt.args.ignoreStatuses,
IgnoreFile: tt.args.ignoreFile,
PolicyFile: tt.args.policyFile,

View File

@@ -137,11 +137,11 @@ func (m *Decoder) decodeComponents(ctx context.Context, sbom *types.SBOM) error
// Third-party SBOMs may contain packages in types other than "Library"
if c.Type == core.TypeLibrary || c.PkgIdentifier.PURL != nil {
pkg, err := m.decodeLibrary(ctx, c)
pkg, err := m.decodePackage(ctx, c)
if errors.Is(err, ErrUnsupportedType) || errors.Is(err, ErrPURLEmpty) {
continue
} else if err != nil {
return xerrors.Errorf("failed to decode library: %w", err)
return xerrors.Errorf("failed to decode package: %w", err)
}
m.pkgs[id] = pkg
}
@@ -184,7 +184,7 @@ func (m *Decoder) decodeApplication(c *core.Component) *ftypes.Application {
return &app
}
func (m *Decoder) decodeLibrary(ctx context.Context, c *core.Component) (*ftypes.Package, error) {
func (m *Decoder) decodePackage(ctx context.Context, c *core.Component) (*ftypes.Package, error) {
p := (*purl.PackageURL)(c.PkgIdentifier.PURL)
if p == nil {
m.logger.DebugContext(ctx, "Skipping a component without PURL",

View File

@@ -35,6 +35,10 @@ func (e *Encoder) Encode(report types.Report) (*core.BOM, error) {
}
e.bom = core.NewBOM(e.opts)
if report.BOM != nil {
e.bom.SerialNumber = report.BOM.SerialNumber
e.bom.Version = report.BOM.Version
}
e.bom.AddComponent(root)
for _, result := range report.Results {

View File

@@ -28,6 +28,10 @@ func HomeDir() string {
return homeDir
}
func TrivyHomeDir() string {
return filepath.Join(HomeDir(), ".trivy")
}
// CopyFile copies the file content from scr to dst
func CopyFile(src, dst string) (int64, error) {
sourceFileStat, err := os.Stat(src)

View File

@@ -12,6 +12,7 @@ import (
type CSAF struct {
advisory csaf.Advisory
source string
logger *log.Logger
}
@@ -20,9 +21,10 @@ type relationship struct {
SubProducts []*purl.PackageURL
}
func newCSAF(advisory csaf.Advisory) VEX {
func newCSAF(advisory csaf.Advisory, source string) *CSAF {
return &CSAF{
advisory: advisory,
source: source,
logger: log.WithPrefix("vex").With(log.String("format", "CSAF")),
}
}
@@ -43,7 +45,7 @@ func (v *CSAF) NotAffected(vuln types.DetectedVulnerability, product, subProduct
if status == "" {
return types.ModifiedFinding{}, false
}
return types.NewModifiedFinding(vuln, status, v.statement(found), "CSAF VEX"), true
return types.NewModifiedFinding(vuln, status, v.statement(found), v.source), true
}
func (v *CSAF) match(vuln *csaf.Vulnerability, product, subProduct *core.Component) types.FindingStatus {

View File

@@ -11,58 +11,48 @@ import (
type CycloneDX struct {
sbom *core.BOM
statements []Statement
statements map[string]Statement
logger *log.Logger
}
type Statement struct {
VulnerabilityID string
Affects []string
Status types.FindingStatus
Justification string
Affects []string
Status types.FindingStatus
Justification string
}
func newCycloneDX(sbom *core.BOM, vex *cdx.BOM) *CycloneDX {
var stmts []Statement
statements := make(map[string]Statement)
for _, vuln := range lo.FromPtr(vex.Vulnerabilities) {
affects := lo.Map(lo.FromPtr(vuln.Affects), func(item cdx.Affects, index int) string {
return item.Ref
})
analysis := lo.FromPtr(vuln.Analysis)
stmts = append(stmts, Statement{
VulnerabilityID: vuln.ID,
Affects: affects,
Status: cdxStatus(analysis.State),
Justification: string(analysis.Justification),
})
statements[vuln.ID] = Statement{
Affects: affects,
Status: cdxStatus(analysis.State),
Justification: string(analysis.Justification),
}
}
return &CycloneDX{
sbom: sbom,
statements: stmts,
statements: statements,
logger: log.WithPrefix("vex").With(log.String("format", "CycloneDX")),
}
}
func (v *CycloneDX) Filter(result *types.Result, _ *core.BOM) {
result.Vulnerabilities = lo.Filter(result.Vulnerabilities, func(vuln types.DetectedVulnerability, _ int) bool {
stmt, ok := lo.Find(v.statements, func(item Statement) bool {
return item.VulnerabilityID == vuln.VulnerabilityID
})
if !ok {
return true
}
if !v.affected(vuln, stmt) {
result.ModifiedFindings = append(result.ModifiedFindings,
types.NewModifiedFinding(vuln, stmt.Status, stmt.Justification, "CycloneDX VEX"))
return false
}
return true
})
}
func (v *CycloneDX) NotAffected(vuln types.DetectedVulnerability, product, _ *core.Component) (types.ModifiedFinding, bool) {
stmt, ok := v.statements[vuln.VulnerabilityID]
if !ok {
return types.ModifiedFinding{}, false
}
func (v *CycloneDX) affected(vuln types.DetectedVulnerability, stmt Statement) bool {
for _, affect := range stmt.Affects {
if stmt.Status != types.FindingStatusNotAffected && stmt.Status != types.FindingStatusFixed {
continue
}
// Affect must be BOM-Link at the moment
link, err := cdx.ParseBOMLink(affect)
if err != nil {
@@ -75,11 +65,11 @@ func (v *CycloneDX) affected(vuln types.DetectedVulnerability, stmt Statement) b
log.Int("version", link.Version()))
continue
}
if vuln.PkgIdentifier.Match(link.Reference()) && (stmt.Status == types.FindingStatusNotAffected || stmt.Status == types.FindingStatusFixed) {
return false
if product.PkgIdentifier.Match(link.Reference()) {
return types.NewModifiedFinding(vuln, stmt.Status, stmt.Justification, "CycloneDX VEX"), true
}
}
return true
return types.ModifiedFinding{}, false
}
func cdxStatus(s cdx.ImpactAnalysisState) types.FindingStatus {

98
pkg/vex/document.go Normal file
View File

@@ -0,0 +1,98 @@
package vex
import (
"encoding/json"
"io"
"os"
"github.com/csaf-poc/csaf_distribution/v3/csaf"
"github.com/hashicorp/go-multierror"
openvex "github.com/openvex/go-vex/pkg/vex"
"github.com/sirupsen/logrus"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/fanal/artifact"
"github.com/aquasecurity/trivy/pkg/sbom"
"github.com/aquasecurity/trivy/pkg/sbom/cyclonedx"
"github.com/aquasecurity/trivy/pkg/types"
)
func NewDocument(filePath string, report *types.Report) (VEX, error) {
if filePath == "" {
return nil, xerrors.New("VEX file path is empty")
}
f, err := os.Open(filePath)
if err != nil {
return nil, xerrors.Errorf("file open error: %w", err)
}
defer f.Close()
var errs error
// Try CycloneDX JSON
if ok, err := sbom.IsCycloneDXJSON(f); err != nil {
errs = multierror.Append(errs, err)
} else if ok {
return decodeCycloneDXJSON(f, report)
}
// Try OpenVEX
if v, err := decodeOpenVEX(f, filePath); err != nil {
errs = multierror.Append(errs, err)
} else if v != nil {
return v, nil
}
// Try CSAF
if v, err := decodeCSAF(f, filePath); err != nil {
errs = multierror.Append(errs, err)
} else if v != nil {
return v, nil
}
return nil, xerrors.Errorf("unable to load VEX: %w", errs)
}
func decodeCycloneDXJSON(r io.ReadSeeker, report *types.Report) (*CycloneDX, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
vex, err := cyclonedx.DecodeJSON(r)
if err != nil {
return nil, xerrors.Errorf("json decode error: %w", err)
}
if report.ArtifactType != artifact.TypeCycloneDX {
return nil, xerrors.New("CycloneDX VEX can be used with CycloneDX SBOM")
}
return newCycloneDX(report.BOM, vex), nil
}
func decodeOpenVEX(r io.ReadSeeker, source string) (*OpenVEX, error) {
// openvex/go-vex outputs log messages by default
logrus.SetOutput(io.Discard)
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
var openVEX openvex.VEX
if err := json.NewDecoder(r).Decode(&openVEX); err != nil {
return nil, err
}
if openVEX.Context == "" {
return nil, nil
}
return newOpenVEX(openVEX, source), nil
}
func decodeCSAF(r io.ReadSeeker, source string) (*CSAF, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
var adv csaf.Advisory
if err := json.NewDecoder(r).Decode(&adv); err != nil {
return nil, err
}
if adv.Vulnerabilities == nil {
return nil, nil
}
return newCSAF(adv, source), nil
}

View File

@@ -8,12 +8,14 @@ import (
)
type OpenVEX struct {
vex openvex.VEX
vex openvex.VEX
source string
}
func newOpenVEX(vex openvex.VEX) VEX {
func newOpenVEX(vex openvex.VEX, source string) *OpenVEX {
return &OpenVEX{
vex: vex,
vex: vex,
source: source,
}
}
@@ -32,7 +34,7 @@ func (v *OpenVEX) NotAffected(vuln types.DetectedVulnerability, product, subComp
// cf. https://github.com/openvex/spec/blob/fa5ba0c0afedb008dc5ebad418548cacf16a3ca7/OPENVEX-SPEC.md#the-vex-statement
stmt := stmts[len(stmts)-1]
if stmt.Status == openvex.StatusNotAffected || stmt.Status == openvex.StatusFixed {
modifiedFindings := types.NewModifiedFinding(vuln, findingStatus(stmt.Status), string(stmt.Justification), "OpenVEX")
modifiedFindings := types.NewModifiedFinding(vuln, findingStatus(stmt.Status), string(stmt.Justification), v.source)
return modifiedFindings, true
}
return types.ModifiedFinding{}, false

143
pkg/vex/repo.go Normal file
View File

@@ -0,0 +1,143 @@
package vex
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/package-url/packageurl-go"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex/repo"
xsync "github.com/aquasecurity/trivy/pkg/x/sync"
)
var errNoRepository = errors.New("no available VEX repository found")
// RepositoryIndex wraps the repository index
type RepositoryIndex struct {
Name string
URL string
repo.Index
}
type RepositorySet struct {
indexes []RepositoryIndex
logOnce *xsync.Map[string, *sync.Once]
logger *log.Logger
}
func NewRepositorySet(ctx context.Context, cacheDir string) (*RepositorySet, error) {
conf, err := repo.NewManager(cacheDir).Config(ctx)
if err != nil {
return nil, xerrors.Errorf("failed to get VEX repository config: %w", err)
}
logger := log.WithPrefix("vex")
var indexes []RepositoryIndex
for _, r := range conf.EnabledRepositories() {
index, err := r.Index(ctx)
if errors.Is(err, os.ErrNotExist) {
logger.Warn("VEX repository not found locally, skipping this repository", log.String("repo", r.Name))
continue
} else if err != nil {
return nil, xerrors.Errorf("failed to get VEX repository index: %w", err)
}
indexes = append(indexes, RepositoryIndex{
Name: r.Name,
URL: r.URL,
Index: index,
})
}
if len(indexes) == 0 {
logger.Warn("No available VEX repository found locally")
return nil, errNoRepository
}
return &RepositorySet{
indexes: indexes, // In precedence order
logOnce: new(xsync.Map[string, *sync.Once]),
logger: logger,
}, nil
}
func (rs *RepositorySet) NotAffected(vuln types.DetectedVulnerability, product, subComponent *core.Component) (types.ModifiedFinding, bool) {
if product == nil || product.PkgIdentifier.PURL == nil {
return types.ModifiedFinding{}, false
}
p := *product.PkgIdentifier.PURL
// Exclude version, qualifiers, and subpath from the package URL except for OCI
// cf. https://github.com/aquasecurity/vex-repo-spec?tab=readme-ov-file#32-indexjson
p.Version = ""
p.Qualifiers = nil
p.Subpath = ""
if p.Type == packageurl.TypeOCI {
// For OCI artifacts, we consider "repository_url" is part of name.
for _, q := range product.PkgIdentifier.PURL.Qualifiers {
if q.Key == "repository_url" {
p.Qualifiers = packageurl.Qualifiers{q}
break
}
}
}
pkgID := p.String() // PURL without version, qualifiers, and subpath
for _, index := range rs.indexes {
entry, ok := index.Packages[pkgID]
if !ok {
continue
}
rs.logVEXFound(pkgID, index.Name, index.URL, entry.Location)
source := fmt.Sprintf("VEX Repository: %s (%s)", index.Name, index.URL)
doc, err := rs.OpenDocument(source, filepath.Dir(index.Path), entry)
if err != nil {
rs.logger.Warn("Failed to open the VEX document", log.String("location", entry.Location), log.Err(err))
return types.ModifiedFinding{}, false
}
if m, notAffected := doc.NotAffected(vuln, product, subComponent); notAffected {
return m, notAffected
}
break // Stop searching for the next VEX document as this repository has higher precedence.
}
return types.ModifiedFinding{}, false
}
func (rs *RepositorySet) OpenDocument(source, dir string, entry repo.PackageEntry) (VEX, error) {
f, err := os.Open(filepath.Join(dir, entry.Location))
if err != nil {
return nil, xerrors.Errorf("failed to open the VEX document: %w", err)
}
defer f.Close()
switch entry.Format {
case "openvex", "":
return decodeOpenVEX(f, source)
case "csaf":
return decodeCSAF(f, source)
default:
return nil, xerrors.Errorf("unsupported VEX format: %s", entry.Format)
}
}
func (rs *RepositorySet) logVEXFound(pkgID, repoName, repoURL, filePath string) {
once, _ := rs.logOnce.LoadOrStore(pkgID, &sync.Once{})
once.Do(func() {
rs.logger.Debug("VEX found in the repository",
log.String("package", pkgID),
log.String("repo", repoName),
log.String("repo_url", repoURL),
log.FilePath(filePath),
)
})
}

189
pkg/vex/repo/manager.go Normal file
View File

@@ -0,0 +1,189 @@
package repo
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/samber/lo"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
)
const (
defaultVEXHubURL = "https://github.com/aquasecurity/vexhub"
vexDir = "vex"
repoDir = "repositories"
)
type ManagerOption func(indexer *Manager)
func WithWriter(w io.Writer) ManagerOption {
return func(manager *Manager) {
manager.w = w
}
}
type Config struct {
Repositories []Repository `json:"repositories"`
}
func (c *Config) EnabledRepositories() []Repository {
return lo.Filter(c.Repositories, func(r Repository, _ int) bool {
return r.Enabled
})
}
type Options struct {
Insecure bool
}
// Manager manages the repositories
type Manager struct {
w io.Writer
configFile string
cacheDir string
}
func NewManager(cacheRoot string, opts ...ManagerOption) *Manager {
m := &Manager{
w: os.Stdout,
configFile: filepath.Join(fsutils.TrivyHomeDir(), vexDir, "repository.yaml"),
cacheDir: filepath.Join(cacheRoot, vexDir),
}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *Manager) writeConfig(conf Config) error {
if err := os.MkdirAll(filepath.Dir(m.configFile), 0700); err != nil {
return xerrors.Errorf("failed to mkdir: %w", err)
}
f, err := os.Create(m.configFile)
if err != nil {
return xerrors.Errorf("failed to create a file: %w", err)
}
defer f.Close()
e := yaml.NewEncoder(f)
e.SetIndent(2)
if err = e.Encode(conf); err != nil {
return xerrors.Errorf("JSON encode error: %w", err)
}
return nil
}
func (m *Manager) Config(ctx context.Context) (Config, error) {
if !fsutils.FileExists(m.configFile) {
log.DebugContext(ctx, "No repository config found", log.String("path", m.configFile))
if err := m.Init(ctx); err != nil {
return Config{}, xerrors.Errorf("unable to initialize the VEX repository config: %w", err)
}
}
f, err := os.Open(m.configFile)
if err != nil {
return Config{}, xerrors.Errorf("unable to open a file: %w", err)
}
defer f.Close()
var conf Config
if err = yaml.NewDecoder(f).Decode(&conf); err != nil {
return conf, xerrors.Errorf("unable to decode metadata: %w", err)
}
for i, repo := range conf.Repositories {
conf.Repositories[i].dir = filepath.Join(m.cacheDir, repoDir, repo.Name)
}
return conf, nil
}
func (m *Manager) Init(ctx context.Context) error {
if fsutils.FileExists(m.configFile) {
log.InfoContext(ctx, "The configuration file already exists", log.String("path", m.configFile))
return nil
}
err := m.writeConfig(Config{
Repositories: []Repository{
{
Name: "default",
URL: defaultVEXHubURL,
Enabled: true,
},
},
})
if err != nil {
return xerrors.Errorf("failed to write the default config: %w", err)
}
log.InfoContext(ctx, "The default repository config has been created", log.FilePath(m.configFile))
return nil
}
func (m *Manager) DownloadRepositories(ctx context.Context, names []string, opts Options) error {
conf, err := m.Config(ctx)
if err != nil {
return xerrors.Errorf("unable to read config: %w", err)
}
repos := lo.Filter(conf.EnabledRepositories(), func(r Repository, _ int) bool {
return len(names) == 0 || slices.Contains(names, r.Name)
})
if len(repos) == 0 {
log.WarnContext(ctx, "No enabled repositories found in config", log.String("path", m.configFile))
return nil
}
for _, repo := range repos {
if err = repo.Update(ctx, opts); err != nil {
return xerrors.Errorf("failed to update the repository: %w", err)
}
}
return nil
}
// List returns a list of all repositories in the configuration
func (m *Manager) List(ctx context.Context) error {
conf, err := m.Config(ctx)
if err != nil {
return xerrors.Errorf("unable to read config: %w", err)
}
var output strings.Builder
output.WriteString(fmt.Sprintf("VEX Repositories (config: %s)\n\n", m.configFile))
if len(conf.Repositories) == 0 {
output.WriteString("No repositories configured.\n")
} else {
for _, repo := range conf.Repositories {
status := "Enabled"
if !repo.Enabled {
status = "Disabled"
}
output.WriteString(fmt.Sprintf("- Name: %s\n URL: %s\n Status: %s\n\n", repo.Name, repo.URL, status))
}
}
if _, err = io.WriteString(m.w, output.String()); err != nil {
return xerrors.Errorf("failed to write output: %w", err)
}
return nil
}
func (m *Manager) Clear() error {
return os.RemoveAll(m.cacheDir)
}

View File

@@ -0,0 +1,335 @@
package repo_test
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/internal/testutil"
"github.com/aquasecurity/trivy/pkg/vex/repo"
)
func TestManager_Config(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, string)
want repo.Config
wantErr string
}{
{
name: "config file exists",
setup: func(t *testing.T, dir string) {
config := repo.Config{
Repositories: []repo.Repository{
{
Name: "test-repo",
URL: "https://example.com/repo",
Enabled: true,
},
},
}
configPath := filepath.Join(dir, ".trivy", "vex", "repository.yaml")
testutil.MustWriteYAML(t, configPath, config)
},
want: repo.Config{
Repositories: []repo.Repository{
{
Name: "test-repo",
URL: "https://example.com/repo",
Enabled: true,
},
},
},
},
{
name: "config file does not exist",
setup: func(t *testing.T, dir string) {},
want: repo.Config{
Repositories: []repo.Repository{
{
Name: "default",
URL: "https://github.com/aquasecurity/vexhub",
Enabled: true,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("XDG_DATA_HOME", tempDir)
m := repo.NewManager(tempDir)
tt.setup(t, tempDir)
got, err := m.Config(context.Background())
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.EqualExportedValues(t, tt.want, got)
})
}
}
func TestManager_Init(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, string)
want repo.Config
wantErr string
}{
{
name: "successful init",
setup: func(t *testing.T, dir string) {},
want: repo.Config{
Repositories: []repo.Repository{
{
Name: "default",
URL: "https://github.com/aquasecurity/vexhub",
Enabled: true,
},
},
},
},
{
name: "config already exists",
setup: func(t *testing.T, dir string) {
configPath := filepath.Join(dir, ".trivy", "vex", "repository.yaml")
testutil.MustWriteYAML(t, configPath, repo.Config{})
},
want: repo.Config{
Repositories: []repo.Repository{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("XDG_DATA_HOME", tempDir)
m := repo.NewManager(tempDir)
tt.setup(t, tempDir)
err := m.Init(context.Background())
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
configPath := filepath.Join(tempDir, ".trivy", "vex", "repository.yaml")
assert.FileExists(t, configPath)
var got repo.Config
testutil.MustReadYAML(t, configPath, &got)
assert.Equal(t, tt.want, got)
})
}
}
func TestManager_DownloadRepositories(t *testing.T) {
ts := setUpRepository(t)
defer ts.Close()
tests := []struct {
name string
config repo.Config
location string
names []string
wantErr string
wantDownload bool
}{
{
name: "successful download",
config: repo.Config{
Repositories: []repo.Repository{
{
Name: "test-repo",
URL: ts.URL,
Enabled: true,
},
},
},
location: ts.URL + "/archive.zip",
wantDownload: true,
},
{
name: "no enabled repositories",
config: repo.Config{
Repositories: []repo.Repository{
{
Name: "test-repo",
URL: "https://localhost:10000", // Will not be reached
Enabled: false,
},
},
},
location: ts.URL + "/archive.zip",
wantDownload: false,
},
{
name: "download specific repository",
config: repo.Config{
Repositories: []repo.Repository{
{
Name: "another-repo",
URL: "https://example.com/repo",
Enabled: true,
},
{
Name: "test-repo",
URL: ts.URL,
Enabled: true,
},
},
},
location: ts.URL + "/archive.zip",
names: []string{"test-repo"},
wantDownload: true,
},
{
name: "download error",
config: repo.Config{
Repositories: []repo.Repository{
{
Name: "test-repo",
URL: ts.URL,
Enabled: true,
},
},
},
location: ts.URL + "/error",
wantErr: "failed to download the repository",
wantDownload: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("XDG_DATA_HOME", tempDir)
m := repo.NewManager(tempDir)
configPath := filepath.Join(tempDir, ".trivy", "vex", "repository.yaml")
testutil.MustWriteYAML(t, configPath, tt.config)
manifestPath := filepath.Join(tempDir, "vex", "repositories", "test-repo", "vex-repository.json")
manifest.Versions[0].Locations[0].URL = tt.location
testutil.MustWriteJSON(t, manifestPath, manifest)
err := m.DownloadRepositories(context.Background(), tt.names, repo.Options{})
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
// Check if the repository was downloaded
if tt.wantDownload {
repoDir := filepath.Join(tempDir, "vex", "repositories", "test-repo")
assert.DirExists(t, repoDir)
assert.FileExists(t, filepath.Join(repoDir, "vex-repository.json"))
assert.FileExists(t, filepath.Join(repoDir, "0.1", "index.json"))
}
})
}
}
func TestManager_List(t *testing.T) {
tests := []struct {
name string
config repo.Config
want string
wantErr string
}{
{
name: "list repositories",
config: repo.Config{
Repositories: []repo.Repository{
{
Name: "default",
URL: "https://github.com/aquasecurity/vexhub",
Enabled: true,
},
{
Name: "custom",
URL: "https://example.com/custom-vex-repo",
Enabled: false,
},
},
},
want: `VEX Repositories (config: %s)
- Name: default
URL: https://github.com/aquasecurity/vexhub
Status: Enabled
- Name: custom
URL: https://example.com/custom-vex-repo
Status: Disabled
`,
},
{
name: "no repositories",
config: repo.Config{
Repositories: []repo.Repository{},
},
want: `VEX Repositories (config: %s)
No repositories configured.
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("XDG_DATA_HOME", tempDir)
configPath := filepath.Join(tempDir, ".trivy", "vex", "repository.yaml")
testutil.MustWriteYAML(t, configPath, tt.config)
var buf bytes.Buffer
m := repo.NewManager(tempDir, repo.WithWriter(&buf))
err := m.List(context.Background())
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
want := fmt.Sprintf(tt.want, configPath)
require.NoError(t, err)
assert.Equal(t, want, buf.String())
})
}
}
func TestManager_Clear(t *testing.T) {
tempDir := t.TempDir()
m := repo.NewManager(tempDir)
// Create some dummy files
cacheDir := filepath.Join(tempDir, "vex")
require.NoError(t, os.MkdirAll(cacheDir, 0755))
dummyFile := filepath.Join(cacheDir, "dummy.txt")
require.NoError(t, os.WriteFile(dummyFile, []byte("dummy"), 0644))
err := m.Clear()
require.NoError(t, err)
// Check if the cache directory was removed
_, err = os.Stat(cacheDir)
assert.True(t, os.IsNotExist(err))
}

327
pkg/vex/repo/repo.go Normal file
View File

@@ -0,0 +1,327 @@
package repo
import (
"context"
"encoding/json"
"errors"
"net/url"
"os"
"path"
"path/filepath"
"time"
"github.com/hashicorp/go-getter"
"github.com/samber/lo"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/clock"
"github.com/aquasecurity/trivy/pkg/downloader"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
)
const (
SchemaVersion = "0.1"
manifestFile = "vex-repository.json"
indexFile = "index.json"
cacheMetadataFile = "cache.json"
)
type Manifest struct {
Name string `json:"name"`
Description string `json:"description"`
Versions []Version `json:"versions"`
}
type Version struct {
SpecVersion string `json:"spec_version"`
Locations []Location `json:"locations"`
UpdateInterval Duration `json:"update_interval"`
}
// Duration is a wrapper around time.Duration that implements UnmarshalJSON
type Duration struct {
time.Duration
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
// UnmarshalJSON implements the json.Unmarshaler interface
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return xerrors.Errorf("duration unmarshal error: %w", err)
}
var err error
d.Duration, err = time.ParseDuration(s)
if err != nil {
return xerrors.Errorf("duration parse error: %w", err)
}
return nil
}
type Location struct {
URL string `json:"url"`
}
type Index struct {
Path string // Path to the index file
UpdatedAt time.Time
Packages map[string]PackageEntry
}
type PackageEntry struct {
ID string `json:"id"`
Location string `json:"location"`
Format string `json:"format"`
}
type RawIndex struct {
UpdatedAt time.Time `json:"updated_at"`
Packages []PackageEntry `json:"packages"`
}
type Repository struct {
Name string
URL string
Enabled bool
Username string
Password string
Token string // For Bearer
dir string // Root directory for this VEX repository, $CACHE_DIR/vex/repositories/$REPO_NAME/
}
type CacheMetadata struct {
UpdatedAt time.Time // Last updated time
ETags map[string]string // Last ETag for each URL
}
func (r *Repository) Manifest(ctx context.Context, opts Options) (Manifest, error) {
filePath := filepath.Join(r.dir, manifestFile)
if !fsutils.FileExists(filePath) {
if err := r.downloadManifest(ctx, opts); err != nil {
return Manifest{}, xerrors.Errorf("failed to download the repository metadata: %w", err)
}
}
log.DebugContext(ctx, "Reading the repository metadata...", log.String("repo", r.Name), log.FilePath(filePath))
f, err := os.Open(filePath)
if err != nil {
return Manifest{}, xerrors.Errorf("failed to open the file: %w", err)
}
defer f.Close()
var manifest Manifest
if err = json.NewDecoder(f).Decode(&manifest); err != nil {
return Manifest{}, xerrors.Errorf("failed to decode the metadata: %w", err)
}
return manifest, nil
}
func (r *Repository) Index(ctx context.Context) (Index, error) {
filePath := filepath.Join(r.dir, SchemaVersion, indexFile)
log.DebugContext(ctx, "Reading the repository index...", log.String("repo", r.Name), log.FilePath(filePath))
f, err := os.Open(filePath)
if err != nil {
return Index{}, xerrors.Errorf("failed to open the file: %w", err)
}
defer f.Close()
var raw RawIndex
if err = json.NewDecoder(f).Decode(&raw); err != nil {
return Index{}, xerrors.Errorf("failed to decode the index: %w", err)
}
return Index{
Path: filePath,
UpdatedAt: raw.UpdatedAt,
Packages: lo.KeyBy(raw.Packages, func(p PackageEntry) string { return p.ID }),
}, nil
}
func (r *Repository) downloadManifest(ctx context.Context, opts Options) error {
if err := os.MkdirAll(r.dir, 0700); err != nil {
return xerrors.Errorf("failed to mkdir: %w", err)
}
u, err := url.Parse(r.URL)
if err != nil {
return xerrors.Errorf("failed to parse the URL: %w", err)
}
if u.Host == "github.com" {
u.Path = path.Join(u.Path, manifestFile)
} else {
u.Path = path.Join(u.Path, ".well-known", manifestFile)
}
log.DebugContext(ctx, "Downloading the repository metadata...", log.String("url", u.String()), log.String("dst", r.dir))
_, err = downloader.Download(ctx, u.String(), filepath.Join(r.dir, manifestFile), ".", downloader.Options{
Insecure: opts.Insecure,
Auth: downloader.Auth{
Username: r.Username,
Password: r.Password,
Token: r.Token,
},
ClientMode: getter.ClientModeFile,
})
if err != nil {
_ = os.RemoveAll(r.dir)
return xerrors.Errorf("failed to download the repository: %w", err)
}
return nil
}
func (r *Repository) Update(ctx context.Context, opts Options) error {
manifest, err := r.Manifest(ctx, opts)
if err != nil {
return xerrors.Errorf("failed to get the repository metadata: %w", err)
}
ver, err := r.selectSupportedVersion(manifest.Versions)
if err != nil {
return xerrors.Errorf("version %s not found", SchemaVersion)
}
versionDir := filepath.Join(r.dir, SchemaVersion)
if !r.needUpdate(ctx, ver, versionDir) {
log.InfoContext(ctx, "No need to check repository updates", log.String("repo", r.Name))
return nil
}
log.InfoContext(ctx, "Updating repository...", log.String("repo", r.Name), log.String("url", r.URL))
if err = r.download(ctx, ver, versionDir, opts); err != nil {
return xerrors.Errorf("failed to download the repository: %w", err)
}
return err
}
func (r *Repository) needUpdate(ctx context.Context, ver Version, versionDir string) bool {
if !fsutils.DirExists(versionDir) {
return true
}
m, err := r.cacheMetadata()
if err != nil {
log.DebugContext(ctx, "Failed to get repository cache metadata", log.String("repo", r.Name), log.Err(err))
return true
}
now := clock.Clock(ctx).Now()
log.DebugContext(ctx, "Checking if the repository needs to be updated...", log.String("repo", r.Name),
log.Time("last_update", m.UpdatedAt), log.Duration("update_interval", ver.UpdateInterval.Duration))
if now.After(m.UpdatedAt.Add(ver.UpdateInterval.Duration)) {
return true
}
return false
}
func (r *Repository) download(ctx context.Context, ver Version, dst string, opts Options) error {
if len(ver.Locations) == 0 {
return xerrors.Errorf("no locations found for version %s", ver.SpecVersion)
}
if err := os.MkdirAll(dst, 0700); err != nil {
return xerrors.Errorf("failed to mkdir: %w", err)
}
m, err := r.cacheMetadata()
if err != nil {
return xerrors.Errorf("failed to get the repository cache metadata: %w", err)
}
etags := lo.Ternary(m.ETags == nil, make(map[string]string), m.ETags)
var errs error
for _, loc := range ver.Locations {
logger := log.With(log.String("repo", r.Name))
logger.DebugContext(ctx, "Downloading repository to cache dir...", log.String("url", loc.URL),
log.String("dir", dst), log.String("etag", etags[loc.URL]))
etag, err := downloader.Download(ctx, loc.URL, dst, ".", downloader.Options{
Insecure: opts.Insecure,
Auth: downloader.Auth{
Username: r.Username,
Password: r.Password,
Token: r.Token,
},
ETag: etags[loc.URL],
})
switch {
case errors.Is(err, downloader.ErrSkipDownload):
logger.DebugContext(ctx, "No updates in the repository", log.String("url", r.URL))
etag = etags[loc.URL] // Keep the old ETag
// Update last updated time so that Trivy will not try to download the same URL soon
case err != nil:
errs = errors.Join(errs, err)
continue // Try the next location
default:
// Successfully downloaded
}
// Update the cache metadata
etags[loc.URL] = etag
now := clock.Clock(ctx).Now()
if err = r.updateCacheMetadata(ctx, CacheMetadata{
UpdatedAt: now,
ETags: etags,
}); err != nil {
return xerrors.Errorf("failed to update the repository cache metadata: %w", err)
}
logger.DebugContext(ctx, "Updated repository cache metadata", log.String("etag", etag),
log.Time("updated_at", now))
return nil
}
if errs != nil {
return xerrors.Errorf("failed to download the repository: %w", errs)
}
return nil
}
func (r *Repository) cacheMetadata() (CacheMetadata, error) {
filePath := filepath.Join(r.dir, cacheMetadataFile)
if !fsutils.FileExists(filePath) {
return CacheMetadata{}, nil
}
f, err := os.Open(filePath)
if err != nil {
return CacheMetadata{}, xerrors.Errorf("failed to open the file: %w", err)
}
defer f.Close()
var metadata CacheMetadata
if err = json.NewDecoder(f).Decode(&metadata); err != nil {
return CacheMetadata{}, xerrors.Errorf("failed to decode the cache metadata: %w", err)
}
return metadata, nil
}
func (r *Repository) selectSupportedVersion(versions []Version) (Version, error) {
for _, ver := range versions {
// Versions should exactly match until the spec version reaches 1.0.
// After reaching 1.0, we can select the latest version that has the same major version.
if ver.SpecVersion == SchemaVersion {
return ver, nil
}
}
return Version{}, xerrors.New("no supported version found")
}
func (r *Repository) updateCacheMetadata(ctx context.Context, metadata CacheMetadata) error {
filePath := filepath.Join(r.dir, cacheMetadataFile)
log.DebugContext(ctx, "Updating repository cache metadata...", log.FilePath(filePath))
f, err := os.Create(filePath)
if err != nil {
return xerrors.Errorf("failed to create the file: %w", err)
}
defer f.Close()
if err = json.NewEncoder(f).Encode(metadata); err != nil {
return xerrors.Errorf("failed to encode the metadata: %w", err)
}
return nil
}

366
pkg/vex/repo/repo_test.go Normal file
View File

@@ -0,0 +1,366 @@
package repo_test
import (
"archive/zip"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/internal/testutil"
"github.com/aquasecurity/trivy/pkg/clock"
"github.com/aquasecurity/trivy/pkg/vex/repo"
)
var manifest = repo.Manifest{
Name: "test-repo",
Description: "test repository",
Versions: []repo.Version{
{
SpecVersion: "0.1",
Locations: []repo.Location{
{
URL: "https://localhost",
},
},
UpdateInterval: repo.Duration{Duration: time.Hour * 24},
},
},
}
func TestRepository_Manifest(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/vex-repository.json" {
err := json.NewEncoder(w).Encode(manifest)
assert.NoError(t, err)
}
http.Error(w, "error", http.StatusInternalServerError)
}))
t.Cleanup(ts.Close)
tests := []struct {
name string
setup func(*testing.T, string, *repo.Repository)
want repo.Manifest
wantErr string
}{
{
name: "local manifest exists",
setup: func(t *testing.T, dir string, _ *repo.Repository) {
manifestFile := filepath.Join(dir, "vex", "repositories", "test-repo", "vex-repository.json")
testutil.MustWriteJSON(t, manifestFile, manifest)
},
want: manifest,
},
{
name: "fetch from remote",
setup: func(t *testing.T, dir string, r *repo.Repository) {
r.URL = ts.URL
},
want: manifest,
},
{
name: "http error",
setup: func(t *testing.T, dir string, r *repo.Repository) {
r.URL = ts.URL + "/error"
},
wantErr: "failed to download the repository metadata",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir, m := setupManager(t)
conf, err := m.Config(context.Background())
require.NoError(t, err)
r := conf.Repositories[0]
tt.setup(t, tempDir, &r)
got, err := r.Manifest(context.Background(), repo.Options{})
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestRepository_Index(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, string, *repo.Repository)
want repo.Index
wantErr string
}{
{
name: "local index exists",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
indexData := repo.RawIndex{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
Packages: []repo.PackageEntry{
{
ID: "pkg1",
Location: "location1",
Format: "format1",
},
{
ID: "pkg2",
Location: "location2",
Format: "format2",
},
},
}
indexPath := filepath.Join(cacheDir, "vex", "repositories", r.Name, "0.1", "index.json")
testutil.MustWriteJSON(t, indexPath, indexData)
},
want: repo.Index{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
Packages: map[string]repo.PackageEntry{
"pkg1": {
ID: "pkg1",
Location: "location1",
Format: "format1",
},
"pkg2": {
ID: "pkg2",
Location: "location2",
Format: "format2",
},
},
},
},
{
name: "index file not found",
setup: func(*testing.T, string, *repo.Repository) {},
wantErr: "failed to open the file",
},
{
name: "invalid JSON in index file",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
indexPath := filepath.Join(cacheDir, "vex", "repositories", r.Name, "0.1", "index.json")
testutil.MustWriteFile(t, indexPath, []byte("invalid JSON"))
},
wantErr: "failed to decode the index",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir, m := setupManager(t)
conf, err := m.Config(context.Background())
require.NoError(t, err)
r := conf.Repositories[0]
tt.setup(t, tempDir, &r)
got, err := r.Index(context.Background())
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
tt.want.Path = filepath.Join(tempDir, "vex", "repositories", r.Name, "0.1", "index.json")
assert.Equal(t, tt.want, got)
})
}
}
func TestRepository_Update(t *testing.T) {
ts := setUpRepository(t)
defer ts.Close()
tests := []struct {
name string
setup func(*testing.T, string, *repo.Repository)
clockTime time.Time
wantErr string
wantCache repo.CacheMetadata
}{
{
name: "successful update",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
setUpManifest(t, cacheDir, ts.URL+"/archive.zip")
},
clockTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
wantCache: repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "new-etag"},
},
},
{
name: "no update needed (within update interval)",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
setUpManifest(t, cacheDir, "") // No location as the test server is not used
repoDir := filepath.Join(cacheDir, "vex", "repositories", r.Name)
testutil.MustMkdirAll(t, filepath.Join(repoDir, "0.1"))
cacheMetadata := repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "current-etag"},
}
testutil.MustWriteJSON(t, filepath.Join(repoDir, "cache.json"), cacheMetadata)
},
clockTime: time.Date(2023, 1, 1, 1, 30, 0, 0, time.UTC),
wantCache: repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "current-etag"},
},
},
{
name: "update needed (update interval passed)",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
setUpManifest(t, cacheDir, ts.URL+"/archive.zip")
repoDir := filepath.Join(cacheDir, "vex", "repositories", r.Name)
testutil.MustMkdirAll(t, filepath.Join(repoDir, "0.1"))
cacheMetadata := repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "old-etag"},
}
testutil.MustWriteJSON(t, filepath.Join(repoDir, "cache.json"), cacheMetadata)
},
clockTime: time.Date(2023, 1, 2, 3, 0, 0, 0, time.UTC),
wantCache: repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 2, 3, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "new-etag"},
},
},
{
name: "no update needed (304 Not Modified)",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
setUpManifest(t, cacheDir, ts.URL+"/archive.zip")
repoDir := filepath.Join(cacheDir, "vex", "repositories", r.Name)
testutil.MustMkdirAll(t, filepath.Join(repoDir, "0.1"))
cacheMetadata := repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "current-etag"},
}
testutil.MustWriteJSON(t, filepath.Join(repoDir, "cache.json"), cacheMetadata)
},
clockTime: time.Date(2023, 1, 2, 3, 0, 0, 0, time.UTC),
wantCache: repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 2, 3, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "current-etag"},
},
},
{
name: "update with no existing cache.json",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
setUpManifest(t, cacheDir, ts.URL+"/archive.zip")
repoDir := filepath.Join(cacheDir, "vex", "repositories", r.Name)
testutil.MustMkdirAll(t, filepath.Join(repoDir, "0.1"))
},
clockTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
wantCache: repo.CacheMetadata{
UpdatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
ETags: map[string]string{ts.URL + "/archive.zip": "new-etag"},
},
},
{
name: "manifest not found",
setup: func(*testing.T, string, *repo.Repository) {},
clockTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
wantErr: "failed to get the repository metadata",
},
{
name: "download error",
setup: func(t *testing.T, cacheDir string, r *repo.Repository) {
setUpManifest(t, cacheDir, ts.URL+"/error")
repoDir := filepath.Join(cacheDir, "vex", "repositories", r.Name)
testutil.MustMkdirAll(t, filepath.Join(repoDir, "0.1"))
},
clockTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
wantErr: "failed to download the repository",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir, m := setupManager(t)
conf, err := m.Config(context.Background())
require.NoError(t, err)
r := conf.Repositories[0]
r.URL = ts.URL + "/vex-repository.json"
tt.setup(t, tempDir, &r)
ctx := clock.With(context.Background(), tt.clockTime)
err = r.Update(ctx, repo.Options{})
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
cacheFile := filepath.Join(tempDir, "vex", "repositories", r.Name, "cache.json")
var gotCache repo.CacheMetadata
testutil.MustReadJSON(t, cacheFile, &gotCache)
assert.Equal(t, tt.wantCache, gotCache)
})
}
}
func setupManager(t *testing.T) (string, *repo.Manager) {
tempDir := t.TempDir()
t.Setenv("XDG_DATA_HOME", "testdata")
return tempDir, repo.NewManager(tempDir)
}
func setUpManifest(t *testing.T, dir, url string) {
manifest := repo.Manifest{
Name: "test-repo",
Description: "test repository",
Versions: []repo.Version{
{
SpecVersion: "0.1",
Locations: []repo.Location{
{
URL: url,
},
},
UpdateInterval: repo.Duration{Duration: time.Hour * 24},
},
},
}
manifestPath := filepath.Join(dir, "vex", "repositories", "test-repo", "vex-repository.json")
testutil.MustWriteJSON(t, manifestPath, manifest)
}
func setUpRepository(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.zip":
if r.Header.Get("If-None-Match") == "current-etag" {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("ETag", "new-etag")
zw := zip.NewWriter(w)
assert.NoError(t, zw.AddFS(os.DirFS("testdata/test-repo")))
assert.NoError(t, zw.Close())
case "/error":
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}

View File

@@ -0,0 +1,4 @@
repositories:
- name: "test-repo"
url: "https://localhost"
enabled: true

View File

@@ -0,0 +1,9 @@
{
"version": 1,
"packages": [
{
"ID": "pkg:golang/github.com/aquasecurity/trivy",
"Location": "test"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "Test Repository",
"description": "Test Repository",
"versions": {
"v0": {
"spec_version": "v0.1",
"locations": [
{
"url": "Must be filled in tests"
}
],
"update_interval": "24h"
}
},
"latest_version": "v0"
}

113
pkg/vex/repo_test.go Normal file
View File

@@ -0,0 +1,113 @@
package vex_test
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/vex"
)
var bashComponent = core.Component{
Name: bashPackage.Name,
Version: bashPackage.Version,
PkgIdentifier: bashPackage.Identifier,
}
func TestRepositorySet_NotAffected(t *testing.T) {
tests := []struct {
name string
cacheDir string
configContent string
vuln types.DetectedVulnerability
product core.Component
wantModified types.ModifiedFinding
wantNotAffected bool
}{
{
name: "single repository - not affected",
cacheDir: "testdata/single-repo",
configContent: `
repositories:
- name: default
url: https://example.com/vex/default
enabled: true
`,
vuln: vuln3,
product: bashComponent,
wantModified: types.ModifiedFinding{
Type: types.FindingTypeVulnerability,
Finding: vuln3,
Status: types.FindingStatusNotAffected,
Statement: "vulnerable_code_not_in_execute_path",
Source: "VEX Repository: default (https://example.com/vex/default)",
},
wantNotAffected: true,
},
{
name: "multiple repositories - high priority affected",
cacheDir: "testdata/multi-repos",
configContent: `
repositories:
- name: high-priority
url: https://example.com/vex/high-priority
enabled: true
- name: default
url: https://example.com/vex/default
enabled: true
`,
vuln: vuln3,
product: bashComponent,
wantNotAffected: false,
},
{
name: "no matching VEX data",
cacheDir: "testdata/single-repo",
configContent: `
repositories:
- name: default
url: https://example.com/vex/default
enabled: true
`,
vuln: vuln4,
product: bashComponent,
wantNotAffected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a temporary directory for each test
tmpDir := t.TempDir()
// Set XDG_DATA_HOME to the temporary directory
t.Setenv("XDG_DATA_HOME", tmpDir)
// Create the vex directory in the temporary directory
vexDir := filepath.Join(tmpDir, ".trivy", "vex")
err := os.MkdirAll(vexDir, 0755)
require.NoError(t, err)
// Write the config file
configPath := filepath.Join(vexDir, "repository.yaml")
err = os.WriteFile(configPath, []byte(tt.configContent), 0644)
require.NoError(t, err)
ctx := context.Background()
rs, err := vex.NewRepositorySet(ctx, tt.cacheDir)
require.NoError(t, err)
modified, notAffected := rs.NotAffected(tt.vuln, &tt.product, nil)
assert.Equal(t, tt.wantNotAffected, notAffected)
if tt.wantNotAffected {
assert.Equal(t, tt.wantModified, modified)
}
})
}
}

View File

@@ -37,22 +37,22 @@
"branches": [
{
"category": "product_version",
"name": "2.9.3-2",
"name": "2.0.0",
"product": {
"name": "Argo CD 2.9.3-2",
"product_id": "argo-cd-2.9.3-2-amd64-debian-12",
"name": "go-direct1 v2.0.0",
"product_id": "go-direct1-v2.0.0",
"product_identification_helper": {
"purl": "pkg:bitnami/argo-cd@2.9.3-2?arch=amd64\u0026distro=debian-12"
"purl": "pkg:golang/github.com/aquasecurity/go-direct1@2.0.0"
}
}
}
],
"category": "product_name",
"name": "Argo CD"
"name": "go-direct1"
}
],
"category": "vendor",
"name": "VMWare, Inc."
"name": "bar"
},
{
"branches": [
@@ -60,45 +60,45 @@
"branches": [
{
"category": "product_version",
"name": "v0.24.2",
"name": "v4.0.0",
"product": {
"name": "client-go v0.24.2",
"product_id": "client-go-v0.24.2",
"name": "go-transitive v4.0.0",
"product_id": "go-transitive-v4.0.0",
"product_identification_helper": {
"purl": "pkg:golang/k8s.io/client-go@0.24.2"
"purl": "pkg:golang/github.com/aquasecurity/go-transitive@4.0.0"
}
}
}
],
"category": "product_name",
"name": "client-go"
"name": "go-transitive"
}
],
"category": "vendor",
"name": "k8s.io"
"name": "foo"
}
],
"relationships": [
{
"product_reference": "client-go-v0.24.2",
"product_reference": "go-transitive-v4.0.0",
"category": "default_component_of",
"relates_to_product_reference": "argo-cd-2.9.3-2-amd64-debian-12",
"relates_to_product_reference": "go-direct1-v2.0.0",
"full_product_name": {
"product_id": "argo-cd-2.9.3-2-amd64-debian-12-client-go",
"name": "Argo CD uses kubernetes golang library"
"product_id": "go-direct1-v2.0.0-go-transitive-v4.0.0",
"name": "go-direct1 uses go-transitive"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2023-2727",
"cve": "CVE-2024-0001",
"flags": [
{
"date": "2024-01-04T17:17:25+01:00",
"label": "vulnerable_code_cannot_be_controlled_by_adversary",
"product_ids": [
"argo-cd-2.9.3-2-amd64-debian-12-client-go"
"go-direct1-v2.0.0-go-transitive-v4.0.0"
]
}
],
@@ -111,14 +111,14 @@
],
"product_status": {
"known_not_affected": [
"argo-cd-2.9.3-2-amd64-debian-12-client-go"
"go-direct1-v2.0.0-go-transitive-v4.0.0"
]
},
"threats": [
{
"category": "impact",
"date": "2024-01-04T17:17:25+01:00",
"details": "The asset uses the component as a dependency in the code, but the vulnerability only affects Kubernetes clusters https://github.com/kubernetes/kubernetes/issues/118640"
"details": "vulnerable_code_not_in_execute_path"
}
]
}

View File

@@ -45,28 +45,28 @@
"branches": [
{
"category": "product_version",
"name": "v0.24.2",
"name": "v4.0.0",
"product": {
"name": "client-go v0.24.2",
"product_id": "client-go-v0.24.2",
"name": "go-transitive v4.0.0",
"product_id": "go-transitive-v4.0.0",
"product_identification_helper": {
"purl": "pkg:golang/k8s.io/client-go@0.24.2"
"purl": "pkg:golang/github.com/aquasecurity/go-transitive@4.0.0"
}
}
}
],
"category": "product_name",
"name": "client-go"
"name": "go-transitive"
}
],
"category": "vendor",
"name": "k8s.io"
"name": "foo"
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2023-2727",
"cve": "CVE-2024-0001",
"notes": [
{
"category": "description",
@@ -76,13 +76,13 @@
],
"product_status": {
"known_not_affected": [
"client-go-v0.24.2"
"go-transitive-v4.0.0"
]
},
"threats": [
{
"category": "impact",
"details": "The asset uses the component as a dependency in the code, but the vulnerability only affects Kubernetes clusters https://github.com/kubernetes/kubernetes/issues/118640"
"details": "vulnerable_code_not_in_execute_path"
}
]
}

View File

@@ -4,10 +4,10 @@
"version": 1,
"vulnerabilities": [
{
"id": "CVE-2018-7489",
"id": "CVE-2021-44228",
"source": {
"name": "NVD",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2019-9997"
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-44228"
},
"analysis": {
"state": "not_affected",
@@ -15,7 +15,7 @@
},
"affects": [
{
"ref": "urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.8.0"
"ref": "urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#pkg:maven/org.springframework.boot/spring-boot@2.6.0"
}
]
},

View File

@@ -0,0 +1,22 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/public/vex-5d6e2706",
"author": "Example Author",
"role": "Document Creator",
"timestamp": "2023-07-01T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"@id": "CVE-2022-3715"
},
"products": [
{
"@id": "pkg:deb/debian/bash@5.3"
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"updated_at": "2024-07-01T00:00:00Z",
"packages": [
{
"id": "pkg:deb/debian/bash",
"location": "bash-vex.json",
"format": "openvex"
}
]
}

View File

@@ -0,0 +1,21 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/public/vex-5d6e2706",
"author": "Example Author",
"role": "Document Creator",
"timestamp": "2023-07-01T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"@id": "CVE-2022-3715"
},
"products": [
{
"@id": "pkg:deb/debian/bash@5.3"
}
],
"status": "affected"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"updated_at": "2024-07-01T00:00:00Z",
"packages": [
{
"id": "pkg:deb/debian/bash",
"location": "bash-vex.json",
"format": "openvex"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"author": "Aqua Security",
"role": "Project Release Bot",
"timestamp": "2023-01-16T19:07:16.853479631-06:00",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "CVE-2022-3715"
},
"products": [
{
"@id": "pkg:oci/mismatch",
"subcomponents": [
{
"@id": "pkg:deb/debian/bash"
}
]
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}

View File

@@ -0,0 +1,22 @@
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/public/vex-5d6e2706",
"author": "Example Author",
"role": "Document Creator",
"timestamp": "2023-07-01T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"@id": "CVE-2022-3715"
},
"products": [
{
"@id": "pkg:deb/debian/bash@5.3"
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"updated_at": "2024-07-01T00:00:00Z",
"packages": [
{
"id": "pkg:deb/debian/bash",
"location": "bash-vex.json",
"format": "openvex"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"name": "Test VEX Repository",
"description": "VEX Repository for Testing",
"versions": [
{
"spec_version": "0.1",
"locations": [
{
"url": "never used"
}
],
"update_interval": "24h"
}
]
}

View File

@@ -1,115 +1,128 @@
package vex
import (
"encoding/json"
"io"
"os"
"context"
"errors"
"github.com/csaf-poc/csaf_distribution/v3/csaf"
"github.com/hashicorp/go-multierror"
openvex "github.com/openvex/go-vex/pkg/vex"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/fanal/artifact"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/sbom"
"github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/sbom/cyclonedx"
sbomio "github.com/aquasecurity/trivy/pkg/sbom/io"
"github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/uuid"
)
const (
TypeFile SourceType = "file"
TypeRepository SourceType = "repo"
)
// VEX represents Vulnerability Exploitability eXchange. It abstracts multiple VEX formats.
// Note: This is in the experimental stage and does not yet support many specifications.
// The implementation may change significantly.
type VEX interface {
Filter(*types.Result, *core.BOM)
NotAffected(vuln types.DetectedVulnerability, product, subComponent *core.Component) (types.ModifiedFinding, bool)
}
func New(filePath string, report types.Report) (VEX, error) {
if filePath == "" {
return nil, nil
}
f, err := os.Open(filePath)
if err != nil {
return nil, xerrors.Errorf("file open error: %w", err)
}
defer f.Close()
var errs error
// Try CycloneDX JSON
if ok, err := sbom.IsCycloneDXJSON(f); err != nil {
errs = multierror.Append(errs, err)
} else if ok {
return decodeCycloneDXJSON(f, report)
}
// Try OpenVEX
if v, err := decodeOpenVEX(f); err != nil {
errs = multierror.Append(errs, err)
} else if v != nil {
return v, nil
}
// Try CSAF
if v, err := decodeCSAF(f); err != nil {
errs = multierror.Append(errs, err)
} else if v != nil {
return v, nil
}
return nil, xerrors.Errorf("unable to load VEX: %w", errs)
type Client struct {
VEXes []VEX
}
func decodeCycloneDXJSON(r io.ReadSeeker, report types.Report) (VEX, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
vex, err := cyclonedx.DecodeJSON(r)
if err != nil {
return nil, xerrors.Errorf("json decode error: %w", err)
}
if report.ArtifactType != artifact.TypeCycloneDX {
return nil, xerrors.New("CycloneDX VEX can be used with CycloneDX SBOM")
}
return newCycloneDX(report.BOM, vex), nil
type Options struct {
CacheDir string
Sources []Source
}
func decodeOpenVEX(r io.ReadSeeker) (VEX, error) {
// openvex/go-vex outputs log messages by default
logrus.SetOutput(io.Discard)
type SourceType string
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
}
var openVEX openvex.VEX
if err := json.NewDecoder(r).Decode(&openVEX); err != nil {
return nil, err
}
if openVEX.Context == "" {
return nil, nil
}
return newOpenVEX(openVEX), nil
type Source struct {
Type SourceType
FilePath string // Used only for the file type
}
func decodeCSAF(r io.ReadSeeker) (VEX, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return nil, xerrors.Errorf("seek error: %w", err)
func NewSource(src string) Source {
switch src {
case "repository", "repo":
return Source{Type: TypeRepository}
default:
return Source{
Type: TypeFile,
FilePath: src,
}
}
var adv csaf.Advisory
if err := json.NewDecoder(r).Decode(&adv); err != nil {
return nil, err
}
if adv.Vulnerabilities == nil {
return nil, nil
}
return newCSAF(adv), nil
}
type NotAffected func(vuln types.DetectedVulnerability, product, subComponent *core.Component) (types.ModifiedFinding, bool)
// Filter determines whether a detected vulnerability should be filtered out based on the provided VEX document.
// If the VEX document is passed and the vulnerability is either not affected or fixed according to the VEX statement,
// the vulnerability is filtered out.
func Filter(ctx context.Context, report *types.Report, opts Options) error {
ctx = log.WithContextPrefix(ctx, "vex")
client, err := New(ctx, report, opts)
if err != nil {
return xerrors.Errorf("VEX error: %w", err)
} else if client == nil {
return nil
}
bom, err := sbomio.NewEncoder(core.Options{Parents: true}).Encode(*report)
if err != nil {
return xerrors.Errorf("unable to encode the SBOM: %w", err)
}
for i, result := range report.Results {
if len(result.Vulnerabilities) == 0 {
continue
}
filterVulnerabilities(&report.Results[i], bom, client.NotAffected)
}
return nil
}
func New(ctx context.Context, report *types.Report, opts Options) (*Client, error) {
var vexes []VEX
for _, src := range opts.Sources {
var v VEX
var err error
switch src.Type {
case TypeFile:
v, err = NewDocument(src.FilePath, report)
if err != nil {
return nil, xerrors.Errorf("unable to load VEX: %w", err)
}
case TypeRepository:
v, err = NewRepositorySet(ctx, opts.CacheDir)
if errors.Is(err, errNoRepository) {
continue
} else if err != nil {
return nil, xerrors.Errorf("failed to create a vex repository set: %w", err)
}
default:
log.Warn("Unsupported VEX source", log.String("type", string(src.Type)))
continue
}
vexes = append(vexes, v)
}
if len(vexes) == 0 {
log.DebugContext(ctx, "VEX filtering is disabled")
return nil, nil
}
return &Client{VEXes: vexes}, nil
}
func (c *Client) NotAffected(vuln types.DetectedVulnerability, product, subComponent *core.Component) (types.ModifiedFinding, bool) {
for _, v := range c.VEXes {
if m, notAffected := v.NotAffected(vuln, product, subComponent); notAffected {
return m, true
}
}
return types.ModifiedFinding{}, false
}
func filterVulnerabilities(result *types.Result, bom *core.BOM, fn NotAffected) {
components := lo.MapEntries(bom.Components(), func(id uuid.UUID, component *core.Component) (string, *core.Component) {
return component.PkgIdentifier.UID, component
@@ -122,16 +135,20 @@ func filterVulnerabilities(result *types.Result, bom *core.BOM, fn NotAffected)
return true // Should never reach here
}
var modified types.ModifiedFinding
notAffectedFn := func(c, leaf *core.Component) bool {
modified, notAffected := fn(vuln, c, leaf)
m, notAffected := fn(vuln, c, leaf)
if notAffected {
result.ModifiedFindings = append(result.ModifiedFindings, modified)
return true
modified = m // Take the last modified finding if multiple VEX states "not affected"
}
return false
return notAffected
}
return reachRoot(c, bom.Components(), bom.Parents(), notAffectedFn)
if !reachRoot(c, bom.Components(), bom.Parents(), notAffectedFn) {
result.ModifiedFindings = append(result.ModifiedFindings, modified)
return false
}
return true
})
}

File diff suppressed because it is too large Load Diff