feat(report): add support for CycloneDX (#1081)

Co-authored-by: tspearconquest <81998567+tspearconquest@users.noreply.github.com>
Co-authored-by: knqyf263 <knqyf263@gmail.com>
This commit is contained in:
Masahiro331
2022-02-23 01:01:15 +09:00
committed by GitHub
parent 77cab6e0b9
commit 58ade462b4
17 changed files with 1534 additions and 138 deletions

View File

@@ -185,6 +185,8 @@ Failures: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 0)
- **Suitable for CI** such as GitHub Actions, Jenkins, GitLab CI, etc. - **Suitable for CI** such as GitHub Actions, Jenkins, GitLab CI, etc.
- Support multiple targets - Support multiple targets
- container image, local filesystem and remote git repository - container image, local filesystem and remote git repository
- Supply chain security (SBOM support)
- Support CycloneDX
# Integrations # Integrations
- [GitHub Actions][action] - [GitHub Actions][action]

View File

@@ -0,0 +1,175 @@
# CycloneDX
Trivy generates JSON reports in the [CycloneDX][cyclonedx] format.
Note that XML format is not supported at the moment.
You can specify `cyclonedx` with the `--format` option.
```
$ trivy image --format cyclonedx --output result.json alpine:3.15
```
<details>
<summary>Result</summary>
```
$ cat result.json | jq .
{
"bomFormat": "CycloneDX",
"specVersion": "1.3",
"serialNumber": "urn:uuid:2be5773d-7cd3-4b4b-90a5-e165474ddace",
"version": 1,
"metadata": {
"timestamp": "2022-02-22T15:11:40.270597Z",
"tools": [
{
"vendor": "aquasecurity",
"name": "trivy",
"version": "dev"
}
],
"component": {
"bom-ref": "pkg:oci/alpine@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300?repository_url=index.docker.io%2Flibrary%2Falpine&arch=amd64",
"type": "container",
"name": "alpine:3.15",
"version": "",
"purl": "pkg:oci/alpine@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300?repository_url=index.docker.io%2Flibrary%2Falpine&arch=amd64",
"properties": [
{
"name": "aquasecurity:trivy:SchemaVersion",
"value": "2"
},
{
"name": "aquasecurity:trivy:ImageID",
"value": "sha256:c059bfaa849c4d8e4aecaeb3a10c2d9b3d85f5165c66ad3a4d937758128c4d18"
},
{
"name": "aquasecurity:trivy:RepoDigest",
"value": "alpine@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300"
},
{
"name": "aquasecurity:trivy:DiffID",
"value": "sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759"
},
{
"name": "aquasecurity:trivy:RepoTag",
"value": "alpine:3.15"
}
]
}
},
"components": [
{
"bom-ref": "pkg:apk/alpine/alpine-baselayout@3.2.0-r18?distro=3.15.0",
"type": "library",
"name": "alpine-baselayout",
"version": "3.2.0-r18",
"licenses": [
{
"expression": "GPL-2.0-only"
}
],
"purl": "pkg:apk/alpine/alpine-baselayout@3.2.0-r18?distro=3.15.0",
"properties": [
{
"name": "aquasecurity:trivy:SrcName",
"value": "alpine-baselayout"
},
{
"name": "aquasecurity:trivy:SrcVersion",
"value": "3.2.0-r18"
},
{
"name": "aquasecurity:trivy:LayerDigest",
"value": "sha256:59bf1c3509f33515622619af21ed55bbe26d24913cedbca106468a5fb37a50c3"
},
{
"name": "aquasecurity:trivy:LayerDiffID",
"value": "sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759"
}
]
},
...(snip)...
{
"bom-ref": "pkg:apk/alpine/zlib@1.2.11-r3?distro=3.15.0",
"type": "library",
"name": "zlib",
"version": "1.2.11-r3",
"licenses": [
{
"expression": "Zlib"
}
],
"purl": "pkg:apk/alpine/zlib@1.2.11-r3?distro=3.15.0",
"properties": [
{
"name": "aquasecurity:trivy:SrcName",
"value": "zlib"
},
{
"name": "aquasecurity:trivy:SrcVersion",
"value": "1.2.11-r3"
},
{
"name": "aquasecurity:trivy:LayerDigest",
"value": "sha256:59bf1c3509f33515622619af21ed55bbe26d24913cedbca106468a5fb37a50c3"
},
{
"name": "aquasecurity:trivy:LayerDiffID",
"value": "sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759"
}
]
},
{
"bom-ref": "3da6a469-964d-4b4e-b67d-e94ec7c88d37",
"type": "operating-system",
"name": "alpine",
"version": "3.15.0",
"properties": [
{
"name": "aquasecurity:trivy:Type",
"value": "alpine"
},
{
"name": "aquasecurity:trivy:Class",
"value": "os-pkgs"
}
]
}
],
"dependencies": [
{
"ref": "3da6a469-964d-4b4e-b67d-e94ec7c88d37",
"dependsOn": [
"pkg:apk/alpine/alpine-baselayout@3.2.0-r18?distro=3.15.0",
"pkg:apk/alpine/alpine-keys@2.4-r1?distro=3.15.0",
"pkg:apk/alpine/apk-tools@2.12.7-r3?distro=3.15.0",
"pkg:apk/alpine/busybox@1.34.1-r3?distro=3.15.0",
"pkg:apk/alpine/ca-certificates-bundle@20191127-r7?distro=3.15.0",
"pkg:apk/alpine/libc-utils@0.7.2-r3?distro=3.15.0",
"pkg:apk/alpine/libcrypto1.1@1.1.1l-r7?distro=3.15.0",
"pkg:apk/alpine/libretls@3.3.4-r2?distro=3.15.0",
"pkg:apk/alpine/libssl1.1@1.1.1l-r7?distro=3.15.0",
"pkg:apk/alpine/musl@1.2.2-r7?distro=3.15.0",
"pkg:apk/alpine/musl-utils@1.2.2-r7?distro=3.15.0",
"pkg:apk/alpine/scanelf@1.3.3-r0?distro=3.15.0",
"pkg:apk/alpine/ssl_client@1.34.1-r3?distro=3.15.0",
"pkg:apk/alpine/zlib@1.2.11-r3?distro=3.15.0"
]
},
{
"ref": "pkg:oci/alpine@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300?repository_url=index.docker.io%2Flibrary%2Falpine&arch=amd64",
"dependsOn": [
"3da6a469-964d-4b4e-b67d-e94ec7c88d37"
]
}
]
}
```
</details>
!!! caution
It doesn't support vulnerabilities yet, but installed packages.
[cyclonedx]: https://cyclonedx.org/

View File

@@ -55,6 +55,8 @@ See [Integrations][integrations] for details.
- An image directory compliant with [OCI Image Format][oci] - An image directory compliant with [OCI Image Format][oci]
- local filesystem and rootfs - local filesystem and rootfs
- remote git repository - remote git repository
- SBOM (Software Bill of Materials) support
- CycloneDX
Please see [LICENSE][license] for Trivy licensing information. Please see [LICENSE][license] for Trivy licensing information.

2
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/aquasecurity/trivy
go 1.16 go 1.16
require ( require (
github.com/CycloneDX/cyclonedx-go v0.4.0
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/sprig v2.22.0+incompatible github.com/Masterminds/sprig v2.22.0+incompatible
github.com/Microsoft/hcsshim v0.9.2 // indirect github.com/Microsoft/hcsshim v0.9.2 // indirect
@@ -28,6 +29,7 @@ require (
github.com/goccy/go-yaml v1.8.2 // indirect github.com/goccy/go-yaml v1.8.2 // indirect
github.com/golang/protobuf v1.5.2 github.com/golang/protobuf v1.5.2
github.com/google/go-containerregistry v0.7.1-0.20211214010025-a65b7844a475 github.com/google/go-containerregistry v0.7.1-0.20211214010025-a65b7844a475
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0 github.com/google/wire v0.5.0
github.com/hashicorp/go-getter v1.5.11 github.com/hashicorp/go-getter v1.5.11
github.com/huandu/xstrings v1.3.2 // indirect github.com/huandu/xstrings v1.3.2 // indirect

4
go.sum
View File

@@ -141,6 +141,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU= github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/CycloneDX/cyclonedx-go v0.4.0 h1:Wz4QZ9B4RXGWIWTypVLEOVJgOdFfy5mcS5PGNzUkZxU=
github.com/CycloneDX/cyclonedx-go v0.4.0/go.mod h1:rmRcf//gT7PIzovatusbWi377xqCg1FS4jyST0GH20E=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Djarvur/go-err113 v0.0.0-20200410182137-af658d038157/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/Djarvur/go-err113 v0.0.0-20200410182137-af658d038157/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
@@ -315,6 +317,8 @@ github.com/bombsimon/wsl/v2 v2.2.0/go.mod h1:Azh8c3XGEJl9LyX0/sFC+CKMc7Ssgua0g+6
github.com/bombsimon/wsl/v3 v3.0.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc= github.com/bombsimon/wsl/v3 v3.0.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc=
github.com/bombsimon/wsl/v3 v3.1.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc= github.com/bombsimon/wsl/v3 v3.1.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc=
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs=
github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/briandowns/spinner v1.12.0 h1:72O0PzqGJb6G3KgrcIOtL/JAGGZ5ptOMCn9cUHmqsmw= github.com/briandowns/spinner v1.12.0 h1:72O0PzqGJb6G3KgrcIOtL/JAGGZ5ptOMCn9cUHmqsmw=
github.com/briandowns/spinner v1.12.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/briandowns/spinner v1.12.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=

View File

@@ -5,6 +5,7 @@ package integration
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -13,6 +14,7 @@ import (
"testing" "testing"
"time" "time"
cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -32,6 +34,7 @@ type csArgs struct {
Input string Input string
ClientToken string ClientToken string
ClientTokenHeader string ClientTokenHeader string
ListAllPackages bool
} }
func TestClientServer(t *testing.T) { func TestClientServer(t *testing.T) {
@@ -322,6 +325,55 @@ func TestClientServerWithTemplate(t *testing.T) {
} }
} }
func TestClientServerWithCycloneDX(t *testing.T) {
tests := []struct {
name string
args csArgs
wantComponentsCount int
wantDependenciesCount int
wantDependsOnCount []int
}{
{
name: "fluentd with RubyGems with CycloneDX format",
args: csArgs{
Format: "cyclonedx",
Input: "testdata/fixtures/images/fluentd-multiple-lockfiles.tar.gz",
},
wantComponentsCount: 161,
wantDependenciesCount: 2,
wantDependsOnCount: []int{
105,
56,
},
},
}
app, addr, cacheDir := setup(t, setupOptions{})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
osArgs, outputFile := setupClient(t, tt.args, addr, cacheDir, "")
// Run Trivy client
err := app.Run(osArgs)
require.NoError(t, err)
f, err := os.Open(outputFile)
require.NoError(t, err)
defer f.Close()
var got cdx.BOM
err = json.NewDecoder(f).Decode(&got)
require.NoError(t, err)
assert.EqualValues(t, tt.wantComponentsCount, len(*got.Components))
assert.EqualValues(t, tt.wantDependenciesCount, len(*got.Dependencies))
for i, dep := range *got.Dependencies {
assert.EqualValues(t, tt.wantDependsOnCount[i], len(*dep.Dependencies))
}
})
}
}
func TestClientServerWithToken(t *testing.T) { func TestClientServerWithToken(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -71,6 +71,8 @@ nav:
- Overview: advanced/index.md - Overview: advanced/index.md
- Plugins: advanced/plugins.md - Plugins: advanced/plugins.md
- Air-Gapped Environment: advanced/air-gap.md - Air-Gapped Environment: advanced/air-gap.md
- SBOM:
- CycloneDX: advanced/sbom/cyclonedx.md
- Integrations: - Integrations:
- Overview: advanced/integrations/index.md - Overview: advanced/integrations/index.md
- GitHub Actions: advanced/integrations/github-actions.md - GitHub Actions: advanced/integrations/github-actions.md

View File

@@ -97,7 +97,7 @@ func TestOption_Init(t *testing.T) {
name: "invalid option combination: --template enabled without --format", name: "invalid option combination: --template enabled without --format",
args: []string{"--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--template is ignored because --format template is not specified. Use --template option with --format template option.", "'--template' is ignored because '--format template' is not specified. Use '--template' option with '--format template' option.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{
@@ -116,7 +116,7 @@ func TestOption_Init(t *testing.T) {
name: "invalid option combination: --template and --format json", name: "invalid option combination: --template and --format json",
args: []string{"--format", "json", "--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--format", "json", "--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--template is ignored because --format json is specified. Use --template option with --format template option.", "'--template' is ignored because '--format json' is specified. Use '--template' option with '--format template' option.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{
@@ -136,7 +136,7 @@ func TestOption_Init(t *testing.T) {
name: "invalid option combination: --format template without --template", name: "invalid option combination: --format template without --template",
args: []string{"--format", "template", "--severity", "MEDIUM", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--format", "template", "--severity", "MEDIUM", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--format template is ignored because --template not is specified. Specify --template option when you use --format template.", "'--format template' is ignored because '--template' is not specified. Specify '--template' option when you use '--format template'.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{

View File

@@ -142,7 +142,7 @@ func TestConfig_Init(t *testing.T) {
name: "invalid option combination: --template enabled without --format", name: "invalid option combination: --template enabled without --format",
args: []string{"--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--template is ignored because --format template is not specified. Use --template option with --format template option.", "'--template' is ignored because '--format template' is not specified. Use '--template' option with '--format template' option.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{
@@ -162,7 +162,7 @@ func TestConfig_Init(t *testing.T) {
name: "invalid option combination: --template and --format json", name: "invalid option combination: --template and --format json",
args: []string{"--format", "json", "--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--format", "json", "--template", "@contrib/gitlab.tpl", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--template is ignored because --format json is specified. Use --template option with --format template option.", "'--template' is ignored because '--format json' is specified. Use '--template' option with '--format template' option.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{
@@ -183,7 +183,7 @@ func TestConfig_Init(t *testing.T) {
name: "invalid option combination: --format template without --template", name: "invalid option combination: --format template without --template",
args: []string{"--format", "template", "--severity", "MEDIUM", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--format", "template", "--severity", "MEDIUM", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--format template is ignored because --template not is specified. Specify --template option when you use --format template.", "'--format template' is ignored because '--template' is not specified. Specify '--template' option when you use '--format template'.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{
@@ -203,7 +203,7 @@ func TestConfig_Init(t *testing.T) {
name: "invalid option combination: --format template without --template", name: "invalid option combination: --format template without --template",
args: []string{"--format", "template", "--severity", "MEDIUM", "gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"--format", "template", "--severity", "MEDIUM", "gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--format template is ignored because --template not is specified. Specify --template option when you use --format template.", "'--format template' is ignored because '--template' is not specified. Specify '--template' option when you use '--format template'.",
}, },
want: Option{ want: Option{
ReportOption: option.ReportOption{ ReportOption: option.ReportOption{

View File

@@ -7,13 +7,11 @@ import (
// ImageOption holds the options for scanning images // ImageOption holds the options for scanning images
type ImageOption struct { type ImageOption struct {
ScanRemovedPkgs bool ScanRemovedPkgs bool
ListAllPkgs bool
} }
// NewImageOption is the factory method to return ImageOption // NewImageOption is the factory method to return ImageOption
func NewImageOption(c *cli.Context) ImageOption { func NewImageOption(c *cli.Context) ImageOption {
return ImageOption{ return ImageOption{
ScanRemovedPkgs: c.Bool("removed-pkgs"), ScanRemovedPkgs: c.Bool("removed-pkgs"),
ListAllPkgs: c.Bool("list-all-pkgs"),
} }
} }

View File

@@ -35,6 +35,7 @@ type ReportOption struct {
SecurityChecks []string SecurityChecks []string
Output io.Writer Output io.Writer
Severities []dbTypes.Severity Severities []dbTypes.Severity
ListAllPkgs bool
} }
// NewReportOption is the factory method to return ReportOption // NewReportOption is the factory method to return ReportOption
@@ -51,31 +52,35 @@ func NewReportOption(c *cli.Context) ReportOption {
IgnoreFile: c.String("ignorefile"), IgnoreFile: c.String("ignorefile"),
IgnoreUnfixed: c.Bool("ignore-unfixed"), IgnoreUnfixed: c.Bool("ignore-unfixed"),
ExitCode: c.Int("exit-code"), ExitCode: c.Int("exit-code"),
ListAllPkgs: c.Bool("list-all-pkgs"),
} }
} }
// Init initializes the ReportOption // Init initializes the ReportOption
func (c *ReportOption) Init(output io.Writer, logger *zap.SugaredLogger) error { func (c *ReportOption) Init(output io.Writer, logger *zap.SugaredLogger) error {
var err error
if c.Template != "" { if c.Template != "" {
if c.Format == "" { if c.Format == "" {
logger.Warn("--template is ignored because --format template is not specified. Use --template option with --format template option.") logger.Warn("'--template' is ignored because '--format template' is not specified. Use '--template' option with '--format template' option.")
} else if c.Format != "template" { } else if c.Format != "template" {
logger.Warnf("--template is ignored because --format %s is specified. Use --template option with --format template option.", c.Format) logger.Warnf("'--template' is ignored because '--format %s' is specified. Use '--template' option with '--format template' option.", c.Format)
}
} else {
if c.Format == "template" {
logger.Warn("'--format template' is ignored because '--template' is not specified. Specify '--template' option when you use '--format template'.")
} }
} }
if c.Format == "template" && c.Template == "" {
logger.Warn("--format template is ignored because --template not is specified. Specify --template option when you use --format template.") if c.forceListAllPkgs(logger) {
c.ListAllPkgs = true
} }
c.Severities = splitSeverity(logger, c.severities) c.Severities = splitSeverity(logger, c.severities)
if err = c.populateVulnTypes(); err != nil { if err := c.populateVulnTypes(); err != nil {
return xerrors.Errorf("vuln type: %w", err) return xerrors.Errorf("vuln type: %w", err)
} }
if err = c.populateSecurityChecks(); err != nil { if err := c.populateSecurityChecks(); err != nil {
return xerrors.Errorf("security checks: %w", err) return xerrors.Errorf("security checks: %w", err)
} }
@@ -86,6 +91,7 @@ func (c *ReportOption) Init(output io.Writer, logger *zap.SugaredLogger) error {
// The output is os.Stdout by default // The output is os.Stdout by default
if c.output != "" { if c.output != "" {
var err error
if output, err = os.Create(c.output); err != nil { if output, err = os.Create(c.output); err != nil {
return xerrors.Errorf("failed to create an output file: %w", err) return xerrors.Errorf("failed to create an output file: %w", err)
} }
@@ -124,6 +130,14 @@ func (c *ReportOption) populateSecurityChecks() error {
return nil return nil
} }
func (c *ReportOption) forceListAllPkgs(logger *zap.SugaredLogger) bool {
if c.Format == "cyclonedx" && !c.ListAllPkgs {
logger.Debugf("'--format cyclonedx' automatically enables '--list-all-pkgs'.")
return true
}
return false
}
func splitSeverity(logger *zap.SugaredLogger, severity string) []dbTypes.Severity { func splitSeverity(logger *zap.SugaredLogger, severity string) []dbTypes.Severity {
logger.Debugf("Severities: %s", severity) logger.Debugf("Severities: %s", severity)
var severities []dbTypes.Severity var severities []dbTypes.Severity

View File

@@ -24,10 +24,12 @@ func TestReportReportConfig_Init(t *testing.T) {
severities string severities string
IgnoreFile string IgnoreFile string
IgnoreUnfixed bool IgnoreUnfixed bool
listAllPksgs bool
ExitCode int ExitCode int
VulnType []string VulnType []string
Output *os.File Output *os.File
Severities []dbTypes.Severity Severities []dbTypes.Severity
debug bool
} }
tests := []struct { tests := []struct {
name string name string
@@ -70,6 +72,49 @@ func TestReportReportConfig_Init(t *testing.T) {
Output: os.Stdout, Output: os.Stdout,
}, },
}, },
{
name: "happy path with an cyclonedx",
fields: fields{
severities: "CRITICAL",
vulnType: "os,library",
securityChecks: "vuln",
Format: "cyclonedx",
listAllPksgs: true,
},
args: []string{"centos:7"},
want: ReportOption{
Severities: []dbTypes.Severity{dbTypes.SeverityCritical},
VulnType: []string{types.VulnTypeOS, types.VulnTypeLibrary},
SecurityChecks: []string{types.SecurityCheckVulnerability},
Format: "cyclonedx",
Output: os.Stdout,
ListAllPkgs: true,
},
},
{
name: "happy path with an cyclonedx option list-all-pkgs is false",
fields: fields{
severities: "CRITICAL",
vulnType: "os,library",
securityChecks: "vuln",
Format: "cyclonedx",
listAllPksgs: false,
debug: true,
},
args: []string{"centos:7"},
logs: []string{
"'--format cyclonedx' automatically enables '--list-all-pkgs'.",
"Severities: CRITICAL",
},
want: ReportOption{
Severities: []dbTypes.Severity{dbTypes.SeverityCritical},
VulnType: []string{types.VulnTypeOS, types.VulnTypeLibrary},
SecurityChecks: []string{types.SecurityCheckVulnerability},
Format: "cyclonedx",
Output: os.Stdout,
ListAllPkgs: true,
},
},
{ {
name: "invalid option combination: --template enabled without --format", name: "invalid option combination: --template enabled without --format",
fields: fields{ fields: fields{
@@ -80,7 +125,7 @@ func TestReportReportConfig_Init(t *testing.T) {
}, },
args: []string{"gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--template is ignored because --format template is not specified. Use --template option with --format template option.", "'--template' is ignored because '--format template' is not specified. Use '--template' option with '--format template' option.",
}, },
want: ReportOption{ want: ReportOption{
Output: os.Stdout, Output: os.Stdout,
@@ -101,7 +146,7 @@ func TestReportReportConfig_Init(t *testing.T) {
}, },
args: []string{"gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--template is ignored because --format json is specified. Use --template option with --format template option.", "'--template' is ignored because '--format json' is specified. Use '--template' option with '--format template' option.",
}, },
want: ReportOption{ want: ReportOption{
Format: "json", Format: "json",
@@ -122,7 +167,7 @@ func TestReportReportConfig_Init(t *testing.T) {
}, },
args: []string{"gitlab/gitlab-ce:12.7.2-ce.0"}, args: []string{"gitlab/gitlab-ce:12.7.2-ce.0"},
logs: []string{ logs: []string{
"--format template is ignored because --template not is specified. Specify --template option when you use --format template.", "'--format template' is ignored because '--template' is not specified. Specify '--template' option when you use '--format template'.",
}, },
want: ReportOption{ want: ReportOption{
Format: "template", Format: "template",
@@ -135,7 +180,12 @@ func TestReportReportConfig_Init(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
core, obs := observer.New(zap.InfoLevel) level := zap.InfoLevel
if tt.fields.debug {
level = zap.DebugLevel
}
core, obs := observer.New(level)
logger := zap.New(core) logger := zap.New(core)
set := flag.NewFlagSet("test", 0) set := flag.NewFlagSet("test", 0)
@@ -151,9 +201,9 @@ func TestReportReportConfig_Init(t *testing.T) {
IgnoreFile: tt.fields.IgnoreFile, IgnoreFile: tt.fields.IgnoreFile,
IgnoreUnfixed: tt.fields.IgnoreUnfixed, IgnoreUnfixed: tt.fields.IgnoreUnfixed,
ExitCode: tt.fields.ExitCode, ExitCode: tt.fields.ExitCode,
ListAllPkgs: tt.fields.listAllPksgs,
Output: tt.fields.Output, Output: tt.fields.Output,
} }
err := c.Init(os.Stdout, logger.Sugar()) err := c.Init(os.Stdout, logger.Sugar())
// tests log messages // tests log messages

View File

@@ -2,7 +2,6 @@ package purl
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
cn "github.com/google/go-containerregistry/pkg/name" cn "github.com/google/go-containerregistry/pkg/name"
@@ -20,15 +19,36 @@ const (
TypeOCI = "oci" TypeOCI = "oci"
) )
// nolint: gocyclo type PackageURL struct {
func NewPackageURL(t string, metadata types.Metadata, pkg ftypes.Package) (packageurl.PackageURL, error) { packageurl.PackageURL
ptype := purlType(t) FilePath string
}
func (purl PackageURL) BOMRef() string {
// 'bom-ref' must be unique within BOM, but PURLs may conflict
// when the same packages are installed in an artifact.
// In that case, we prefer to make PURLs unique by adding file paths,
// rather than using UUIDs, even if it is not PURL technically.
// ref. https://cyclonedx.org/use-cases/#dependency-graph
if purl.FilePath != "" {
purl.Qualifiers = append(purl.Qualifiers,
packageurl.Qualifier{
Key: "file_path",
Value: purl.FilePath,
},
)
}
return purl.PackageURL.String()
}
// nolint: gocyclo
func NewPackageURL(t string, metadata types.Metadata, pkg ftypes.Package) (PackageURL, error) {
var qualifiers packageurl.Qualifiers var qualifiers packageurl.Qualifiers
if metadata.OS != nil { if metadata.OS != nil {
qualifiers = parseQualifier(pkg, metadata.OS.Name) qualifiers = parseQualifier(pkg)
} }
ptype := purlType(t)
name := pkg.Name name := pkg.Name
version := utils.FormatVersion(pkg) version := utils.FormatVersion(pkg)
namespace := "" namespace := ""
@@ -41,7 +61,7 @@ func NewPackageURL(t string, metadata types.Metadata, pkg ftypes.Package) (packa
case packageurl.TypeDebian: case packageurl.TypeDebian:
qualifiers = append(qualifiers, parseDeb(metadata.OS)...) qualifiers = append(qualifiers, parseDeb(metadata.OS)...)
namespace = metadata.OS.Family namespace = metadata.OS.Family
case string(analyzer.TypeApk): // TODO: replace with packageurl.TypeApk case string(analyzer.TypeApk): // TODO: replace with packageurl.TypeApk once they add it.
qualifiers = append(qualifiers, parseApk(metadata.OS)...) qualifiers = append(qualifiers, parseApk(metadata.OS)...)
namespace = metadata.OS.Family namespace = metadata.OS.Family
case packageurl.TypeMaven: case packageurl.TypeMaven:
@@ -55,15 +75,23 @@ func NewPackageURL(t string, metadata types.Metadata, pkg ftypes.Package) (packa
case packageurl.TypeNPM: case packageurl.TypeNPM:
namespace, name = parseNpm(name) namespace, name = parseNpm(name)
case packageurl.TypeOCI: case packageurl.TypeOCI:
return parseOCI(metadata) purl, err := parseOCI(metadata)
if err != nil {
return PackageURL{}, err
}
return PackageURL{PackageURL: purl}, nil
} }
return *packageurl.NewPackageURL(ptype, namespace, name, version, qualifiers, ""), nil return PackageURL{
PackageURL: *packageurl.NewPackageURL(ptype, namespace, name, version, qualifiers, ""),
FilePath: pkg.FilePath,
}, nil
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#oci
func parseOCI(metadata types.Metadata) (packageurl.PackageURL, error) { func parseOCI(metadata types.Metadata) (packageurl.PackageURL, error) {
if len(metadata.RepoDigests) == 0 { if len(metadata.RepoDigests) == 0 {
return packageurl.PackageURL{}, xerrors.New("repository digests empty error") return *packageurl.NewPackageURL("", "", "", "", nil, ""), nil
} }
digest, err := cn.NewDigest(metadata.RepoDigests[0]) digest, err := cn.NewDigest(metadata.RepoDigests[0])
@@ -99,6 +127,7 @@ func parseApk(fos *ftypes.OS) packageurl.Qualifiers {
} }
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#deb
func parseDeb(fos *ftypes.OS) packageurl.Qualifiers { func parseDeb(fos *ftypes.OS) packageurl.Qualifiers {
distro := fmt.Sprintf("%s-%s", fos.Family, fos.Name) distro := fmt.Sprintf("%s-%s", fos.Family, fos.Name)
return packageurl.Qualifiers{ return packageurl.Qualifiers{
@@ -109,6 +138,7 @@ func parseDeb(fos *ftypes.OS) packageurl.Qualifiers {
} }
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#rpm
func parseRPM(fos *ftypes.OS, modularityLabel string) (string, packageurl.Qualifiers) { func parseRPM(fos *ftypes.OS, modularityLabel string) (string, packageurl.Qualifiers) {
// SLES string has whitespace // SLES string has whitespace
family := fos.Family family := fos.Family
@@ -117,7 +147,6 @@ func parseRPM(fos *ftypes.OS, modularityLabel string) (string, packageurl.Qualif
} }
distro := fmt.Sprintf("%s-%s", family, fos.Name) distro := fmt.Sprintf("%s-%s", family, fos.Name)
qualifiers := packageurl.Qualifiers{ qualifiers := packageurl.Qualifiers{
{ {
Key: "distro", Key: "distro",
@@ -134,54 +163,36 @@ func parseRPM(fos *ftypes.OS, modularityLabel string) (string, packageurl.Qualif
return family, qualifiers return family, qualifiers
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#maven
func parseMaven(pkgName string) (string, string) { func parseMaven(pkgName string) (string, string) {
var namespace string // The group id is the "namespace" and the artifact id is the "name".
name := strings.ReplaceAll(pkgName, ":", "/") name := strings.ReplaceAll(pkgName, ":", "/")
index := strings.LastIndex(name, "/") return parsePkgName(name)
if index != -1 {
namespace = name[:index]
name = name[index+1:]
}
return namespace, name
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#golang
func parseGolang(pkgName string) (string, string) { func parseGolang(pkgName string) (string, string) {
var namespace string
name := strings.ToLower(pkgName) name := strings.ToLower(pkgName)
index := strings.LastIndex(name, "/") return parsePkgName(name)
if index != -1 {
namespace = name[:index]
name = name[index+1:]
}
return namespace, name
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#pypi
func parsePyPI(pkgName string) string { func parsePyPI(pkgName string) string {
// PyPi treats - and _ as the same character and is not case-sensitive.
// Therefore a Pypi package name must be lowercased and underscore "_" replaced with a dash "-".
return strings.ToLower(strings.ReplaceAll(pkgName, "_", "-")) return strings.ToLower(strings.ReplaceAll(pkgName, "_", "-"))
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#composer
func parseComposer(pkgName string) (string, string) { func parseComposer(pkgName string) (string, string) {
var namespace, name string return parsePkgName(pkgName)
index := strings.LastIndex(pkgName, "/")
if index != -1 {
namespace = pkgName[:index]
name = pkgName[index+1:]
}
return namespace, name
} }
// ref. https://github.com/package-url/purl-spec/blob/a748c36ad415c8aeffe2b8a4a5d8a50d16d6d85f/PURL-TYPES.rst#npm
func parseNpm(pkgName string) (string, string) { func parseNpm(pkgName string) (string, string) {
var namespace string // the name must be lowercased
name := strings.ToLower(pkgName) name := strings.ToLower(pkgName)
index := strings.LastIndex(pkgName, "/") return parsePkgName(name)
if index != -1 {
namespace = name[:index]
name = name[index+1:]
}
return namespace, name
} }
func purlType(t string) string { func purlType(t string) string {
@@ -210,7 +221,7 @@ func purlType(t string) string {
return t return t
} }
func parseQualifier(pkg ftypes.Package, distro string) packageurl.Qualifiers { func parseQualifier(pkg ftypes.Package) packageurl.Qualifiers {
qualifiers := packageurl.Qualifiers{} qualifiers := packageurl.Qualifiers{}
if pkg.Arch != "" { if pkg.Arch != "" {
qualifiers = append(qualifiers, packageurl.Qualifier{ qualifiers = append(qualifiers, packageurl.Qualifier{
@@ -218,11 +229,16 @@ func parseQualifier(pkg ftypes.Package, distro string) packageurl.Qualifiers {
Value: pkg.Arch, Value: pkg.Arch,
}) })
} }
if pkg.Epoch != 0 {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "epoch",
Value: strconv.Itoa(pkg.Epoch),
})
}
return qualifiers return qualifiers
} }
func parsePkgName(name string) (string, string) {
var namespace string
index := strings.LastIndex(name, "/")
if index != -1 {
namespace = name[:index]
name = name[index+1:]
}
return namespace, name
}

View File

@@ -22,7 +22,7 @@ func TestNewPackageURL(t *testing.T) {
typ string typ string
pkg ftypes.Package pkg ftypes.Package
metadata types.Metadata metadata types.Metadata
want packageurl.PackageURL want purl.PackageURL
wantErr string wantErr string
}{ }{
{ {
@@ -32,11 +32,13 @@ func TestNewPackageURL(t *testing.T) {
Name: "org.springframework:spring-core", Name: "org.springframework:spring-core",
Version: "5.3.14", Version: "5.3.14",
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeMaven, PackageURL: packageurl.PackageURL{
Namespace: "org.springframework", Type: packageurl.TypeMaven,
Name: "spring-core", Namespace: "org.springframework",
Version: "5.3.14", Name: "spring-core",
Version: "5.3.14",
},
}, },
}, },
{ {
@@ -46,11 +48,13 @@ func TestNewPackageURL(t *testing.T) {
Name: "@xtuc/ieee754", Name: "@xtuc/ieee754",
Version: "1.2.0", Version: "1.2.0",
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeNPM, PackageURL: packageurl.PackageURL{
Namespace: "@xtuc", Type: packageurl.TypeNPM,
Name: "ieee754", Namespace: "@xtuc",
Version: "1.2.0", Name: "ieee754",
Version: "1.2.0",
},
}, },
}, },
{ {
@@ -60,10 +64,12 @@ func TestNewPackageURL(t *testing.T) {
Name: "lodash", Name: "lodash",
Version: "4.17.21", Version: "4.17.21",
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeNPM, PackageURL: packageurl.PackageURL{
Name: "lodash", Type: packageurl.TypeNPM,
Version: "4.17.21", Name: "lodash",
Version: "4.17.21",
},
}, },
}, },
{ {
@@ -73,10 +79,12 @@ func TestNewPackageURL(t *testing.T) {
Name: "Django_test", Name: "Django_test",
Version: "1.2.0", Version: "1.2.0",
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypePyPi, PackageURL: packageurl.PackageURL{
Name: "django-test", Type: packageurl.TypePyPi,
Version: "1.2.0", Name: "django-test",
Version: "1.2.0",
},
}, },
}, },
{ {
@@ -86,11 +94,13 @@ func TestNewPackageURL(t *testing.T) {
Name: "symfony/contracts", Name: "symfony/contracts",
Version: "v1.0.2", Version: "v1.0.2",
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeComposer, PackageURL: packageurl.PackageURL{
Namespace: "symfony", Type: packageurl.TypeComposer,
Name: "contracts", Namespace: "symfony",
Version: "v1.0.2", Name: "contracts",
Version: "v1.0.2",
},
}, },
}, },
{ {
@@ -100,11 +110,13 @@ func TestNewPackageURL(t *testing.T) {
Name: "github.com/go-sql-driver/Mysql", Name: "github.com/go-sql-driver/Mysql",
Version: "v1.5.0", Version: "v1.5.0",
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeGolang, PackageURL: packageurl.PackageURL{
Namespace: "github.com/go-sql-driver", Type: packageurl.TypeGolang,
Name: "mysql", Namespace: "github.com/go-sql-driver",
Version: "v1.5.0", Name: "mysql",
Version: "v1.5.0",
},
}, },
}, },
{ {
@@ -129,19 +141,21 @@ func TestNewPackageURL(t *testing.T) {
Name: "8", Name: "8",
}, },
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeRPM, PackageURL: packageurl.PackageURL{
Namespace: "redhat", Type: packageurl.TypeRPM,
Name: "acl", Namespace: "redhat",
Version: "2.2.53-1.el8", Name: "acl",
Qualifiers: packageurl.Qualifiers{ Version: "2.2.53-1.el8",
{ Qualifiers: packageurl.Qualifiers{
Key: "arch", {
Value: "aarch64", Key: "arch",
}, Value: "aarch64",
{ },
Key: "distro", {
Value: "redhat-8", Key: "distro",
Value: "redhat-8",
},
}, },
}, },
}, },
@@ -161,23 +175,45 @@ func TestNewPackageURL(t *testing.T) {
Architecture: "amd64", Architecture: "amd64",
}, },
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeOCI, PackageURL: packageurl.PackageURL{
Namespace: "", Type: packageurl.TypeOCI,
Name: "core", Namespace: "",
Version: "sha256:8fe1727132b2506c17ba0e1f6a6ed8a016bb1f5735e43b2738cd3fd1979b6260", Name: "core",
Qualifiers: packageurl.Qualifiers{ Version: "sha256:8fe1727132b2506c17ba0e1f6a6ed8a016bb1f5735e43b2738cd3fd1979b6260",
{ Qualifiers: packageurl.Qualifiers{
Key: "repository_url", {
Value: "cblmariner2preview.azurecr.io/base/core", Key: "repository_url",
}, Value: "cblmariner2preview.azurecr.io/base/core",
{ },
Key: "arch", {
Value: "amd64", Key: "arch",
Value: "amd64",
},
}, },
}, },
}, },
}, },
{
name: "container local",
typ: purl.TypeOCI,
metadata: types.Metadata{
RepoTags: []string{},
RepoDigests: []string{},
ImageConfig: v1.ConfigFile{
Architecture: "amd64",
},
ImageID: "sha256:8fe1727132b2506c17ba0e1f6a6ed8a016bb1f5735e43b2738cd3fd1979b6260",
},
want: purl.PackageURL{
PackageURL: packageurl.PackageURL{
Type: "",
Namespace: "",
Name: "",
Version: "",
},
},
},
{ {
name: "container with implicit registry", name: "container with implicit registry",
typ: purl.TypeOCI, typ: purl.TypeOCI,
@@ -194,19 +230,21 @@ func TestNewPackageURL(t *testing.T) {
Architecture: "amd64", Architecture: "amd64",
}, },
}, },
want: packageurl.PackageURL{ want: purl.PackageURL{
Type: packageurl.TypeOCI, PackageURL: packageurl.PackageURL{
Namespace: "", Type: packageurl.TypeOCI,
Name: "alpine", Namespace: "",
Version: "sha256:8fe1727132b2506c17ba0e1f6a6ed8a016bb1f5735e43b2738cd3fd1979b6260", Name: "alpine",
Qualifiers: packageurl.Qualifiers{ Version: "sha256:8fe1727132b2506c17ba0e1f6a6ed8a016bb1f5735e43b2738cd3fd1979b6260",
{ Qualifiers: packageurl.Qualifiers{
Key: "repository_url", {
Value: "index.docker.io/library/alpine", Key: "repository_url",
}, Value: "index.docker.io/library/alpine",
{ },
Key: "arch", {
Value: "amd64", Key: "arch",
Value: "amd64",
},
}, },
}, },
}, },

View File

@@ -0,0 +1,365 @@
package cyclonedx
import (
"io"
"strconv"
"time"
cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/google/uuid"
"golang.org/x/xerrors"
"k8s.io/utils/clock"
ftypes "github.com/aquasecurity/fanal/types"
"github.com/aquasecurity/trivy/pkg/purl"
"github.com/aquasecurity/trivy/pkg/types"
)
const (
Namespace = "aquasecurity:trivy:"
PropertySchemaVersion = "SchemaVersion"
PropertyType = "Type"
PropertyClass = "Class"
// Image properties
PropertySize = "Size"
PropertyImageID = "ImageID"
PropertyRepoDigest = "RepoDigest"
PropertyDiffID = "DiffID"
PropertyRepoTag = "RepoTag"
// Package properties
PropertySrcName = "SrcName"
PropertySrcVersion = "SrcVersion"
PropertySrcRelease = "SrcRelease"
PropertySrcEpoch = "SrcEpoch"
PropertyModularitylabel = "Modularitylabel"
PropertyFilePath = "FilePath"
PropertyLayerDigest = "LayerDigest"
PropertyLayerDiffID = "LayerDiffID"
)
// Writer implements types.Writer
type Writer struct {
output io.Writer
version string
*options
}
type newUUID func() uuid.UUID
type options struct {
format cdx.BOMFileFormat
clock clock.Clock
newUUID newUUID
}
type option func(*options)
func WithFormat(format cdx.BOMFileFormat) option {
return func(opts *options) {
opts.format = format
}
}
func WithClock(clock clock.Clock) option {
return func(opts *options) {
opts.clock = clock
}
}
func WithNewUUID(newUUID newUUID) option {
return func(opts *options) {
opts.newUUID = newUUID
}
}
func NewWriter(output io.Writer, version string, opts ...option) Writer {
o := &options{
format: cdx.BOMFileFormatJSON,
clock: clock.RealClock{},
newUUID: uuid.New,
}
for _, opt := range opts {
opt(o)
}
return Writer{
output: output,
version: version,
options: o,
}
}
// Write writes the results in CycloneDX format
func (cw Writer) Write(report types.Report) error {
bom, err := cw.convertToBom(report, cw.version)
if err != nil {
return xerrors.Errorf("failed to convert bom: %w", err)
}
if err = cdx.NewBOMEncoder(cw.output, cw.format).Encode(bom); err != nil {
return xerrors.Errorf("failed to encode bom: %w", err)
}
return nil
}
func (cw *Writer) convertToBom(r types.Report, version string) (*cdx.BOM, error) {
bom := cdx.NewBOM()
bom.SerialNumber = cw.options.newUUID().URN()
metadataComponent, err := cw.reportToComponent(r)
if err != nil {
return nil, xerrors.Errorf("failed to parse metadata component: %w", err)
}
bom.Metadata = &cdx.Metadata{
Timestamp: cw.clock.Now().UTC().Format(time.RFC3339Nano),
Tools: &[]cdx.Tool{
{
Vendor: "aquasecurity",
Name: "trivy",
Version: version,
},
},
Component: metadataComponent,
}
bom.Components, bom.Dependencies, err = cw.parseComponents(r, bom.Metadata.Component.BOMRef)
if err != nil {
return nil, xerrors.Errorf("failed to parse components: %w", err)
}
return bom, nil
}
func (cw *Writer) parseComponents(r types.Report, bomRef string) (*[]cdx.Component, *[]cdx.Dependency, error) {
var components []cdx.Component
var dependencies []cdx.Dependency
var metadataDependencies []cdx.Dependency
libraryUniqMap := map[string]struct{}{}
for _, result := range r.Results {
var componentDependencies []cdx.Dependency
for _, pkg := range result.Packages {
pkgComponent, err := cw.pkgToComponent(result.Type, r.Metadata, pkg)
if err != nil {
return nil, nil, xerrors.Errorf("failed to parse pkg: %w", err)
}
// When multiple lock files have the same dependency with the same name and version,
// "bom-ref" (PURL technically) of Library components may conflict.
// In that case, only one Library component will be added and
// some Application components will refer to the same component.
// e.g.
// Application component (/app1/package-lock.json)
// |
// | Application component (/app2/package-lock.json)
// | |
// └----┴----> Library component (npm package, express-4.17.3)
//
if _, ok := libraryUniqMap[pkgComponent.BOMRef]; !ok {
libraryUniqMap[pkgComponent.BOMRef] = struct{}{}
// For components
// ref. https://cyclonedx.org/use-cases/#inventory
//
// TODO: All packages are flattened at the moment. We should construct dependency tree.
components = append(components, pkgComponent)
}
componentDependencies = append(componentDependencies, cdx.Dependency{Ref: pkgComponent.BOMRef})
}
if result.Type == ftypes.NodePkg || result.Type == ftypes.PythonPkg || result.Type == ftypes.GoBinary ||
result.Type == ftypes.GemSpec || result.Type == ftypes.Jar {
// If a package is language-specific package that isn't associated with a lock file,
// it will be a dependency of a component under "metadata".
// e.g.
// Container component (alpine:3.15) ----------------------- #1
// -> Library component (npm package, express-4.17.3) ---- #2
// -> Library component (python package, django-4.0.2) --- #2
// -> etc.
// ref. https://cyclonedx.org/use-cases/#inventory
// Dependency graph from #1 to #2
metadataDependencies = append(metadataDependencies, componentDependencies...)
} else {
// If a package is OS package, it will be a dependency of "Operating System" component.
// e.g.
// Container component (alpine:3.15) --------------------- #1
// -> Operating System Component (Alpine Linux 3.15) --- #2
// -> Library component (bash-4.12) ------------------ #3
// -> Library component (vim-8.2) ------------------ #3
// -> etc.
//
// Else if a package is language-specific package associated with a lock file,
// it will be a dependency of "Application" component.
// e.g.
// Container component (alpine:3.15) ------------------------ #1
// -> Application component (/app/package-lock.json) ------ #2
// -> Library component (npm package, express-4.17.3) --- #3
// -> Library component (npm package, lodash-4.17.21) --- #3
// -> etc.
resultComponent := cw.resultToComponent(result, r.Metadata.OS)
components = append(components, resultComponent)
// Dependency graph from #2 to #3
dependencies = append(dependencies,
cdx.Dependency{Ref: resultComponent.BOMRef, Dependencies: &componentDependencies},
)
// Dependency graph from #1 to #2
metadataDependencies = append(metadataDependencies, cdx.Dependency{Ref: resultComponent.BOMRef})
}
}
dependencies = append(dependencies,
cdx.Dependency{Ref: bomRef, Dependencies: &metadataDependencies},
)
return &components, &dependencies, nil
}
func (cw *Writer) pkgToComponent(t string, meta types.Metadata, pkg ftypes.Package) (cdx.Component, error) {
pu, err := purl.NewPackageURL(t, meta, pkg)
if err != nil {
return cdx.Component{}, xerrors.Errorf("failed to new package purl: %w", err)
}
properties := parseProperties(pkg)
component := cdx.Component{
Type: cdx.ComponentTypeLibrary,
Name: pkg.Name,
Version: pu.Version,
BOMRef: pu.BOMRef(),
PackageURL: pu.ToString(),
Properties: &properties,
}
if pkg.License != "" {
component.Licenses = &cdx.Licenses{
cdx.LicenseChoice{Expression: pkg.License},
}
}
return component, nil
}
func (cw *Writer) reportToComponent(r types.Report) (*cdx.Component, error) {
component := &cdx.Component{
Name: r.ArtifactName,
}
properties := []cdx.Property{
property(PropertySchemaVersion, strconv.Itoa(r.SchemaVersion)),
}
if r.Metadata.Size != 0 {
properties = appendProperties(properties, PropertySize, strconv.FormatInt(r.Metadata.Size, 10))
}
switch r.ArtifactType {
case ftypes.ArtifactContainerImage:
component.Type = cdx.ComponentTypeContainer
p, err := purl.NewPackageURL(purl.TypeOCI, r.Metadata, ftypes.Package{})
if err != nil {
return nil, xerrors.Errorf("failed to new package url for oci: %w", err)
}
properties = appendProperties(properties, PropertyImageID, r.Metadata.ImageID)
if p.Type == "" {
component.BOMRef = cw.newUUID().String()
} else {
component.BOMRef = p.ToString()
component.PackageURL = p.ToString()
}
case ftypes.ArtifactFilesystem, ftypes.ArtifactRemoteRepository:
component.Type = cdx.ComponentTypeApplication
component.BOMRef = cw.newUUID().String()
}
for _, d := range r.Metadata.RepoDigests {
properties = appendProperties(properties, PropertyRepoDigest, d)
}
for _, d := range r.Metadata.DiffIDs {
properties = appendProperties(properties, PropertyDiffID, d)
}
for _, t := range r.Metadata.RepoTags {
properties = appendProperties(properties, PropertyRepoTag, t)
}
component.Properties = &properties
return component, nil
}
func (cw Writer) resultToComponent(r types.Result, osFound *ftypes.OS) cdx.Component {
component := cdx.Component{
Name: r.Target,
Properties: &[]cdx.Property{
property(PropertyType, r.Type),
property(PropertyClass, string(r.Class)),
},
}
switch r.Class {
case types.ClassOSPkg:
// UUID needs to be generated since Operating System Component cannot generate PURL.
// https://cyclonedx.org/use-cases/#known-vulnerabilities
component.BOMRef = cw.newUUID().String()
if osFound != nil {
component.Name = osFound.Family
component.Version = osFound.Name
}
component.Type = cdx.ComponentTypeOS
case types.ClassLangPkg:
// UUID needs to be generated since Application Component cannot generate PURL.
// https://cyclonedx.org/use-cases/#known-vulnerabilities
component.BOMRef = cw.newUUID().String()
component.Type = cdx.ComponentTypeApplication
case types.ClassConfig:
// TODO: Config support
component.BOMRef = cw.newUUID().String()
component.Type = cdx.ComponentTypeFile
}
return component
}
func parseProperties(pkg ftypes.Package) []cdx.Property {
props := []struct {
name string
value string
}{
{PropertyFilePath, pkg.FilePath},
{PropertySrcName, pkg.SrcName},
{PropertySrcVersion, pkg.SrcVersion},
{PropertySrcRelease, pkg.SrcRelease},
{PropertySrcEpoch, strconv.Itoa(pkg.SrcEpoch)},
{PropertyModularitylabel, pkg.Modularitylabel},
{PropertyLayerDigest, pkg.Layer.Digest},
{PropertyLayerDiffID, pkg.Layer.DiffID},
}
var properties []cdx.Property
for _, prop := range props {
properties = appendProperties(properties, prop.name, prop.value)
}
return properties
}
func appendProperties(properties []cdx.Property, key, value string) []cdx.Property {
if value == "" || (key == PropertySrcEpoch && value == "0") {
return properties
}
return append(properties, property(key, value))
}
func property(key, value string) cdx.Property {
return cdx.Property{
Name: Namespace + key,
Value: value,
}
}

View File

@@ -0,0 +1,672 @@
package cyclonedx_test
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"time"
cdx "github.com/CycloneDX/cyclonedx-go"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
fake "k8s.io/utils/clock/testing"
fos "github.com/aquasecurity/fanal/analyzer/os"
ftypes "github.com/aquasecurity/fanal/types"
"github.com/aquasecurity/trivy/pkg/report"
"github.com/aquasecurity/trivy/pkg/report/cyclonedx"
"github.com/aquasecurity/trivy/pkg/types"
)
func TestWriter_Write(t *testing.T) {
testCases := []struct {
name string
inputReport types.Report
wantSBOM *cdx.BOM
}{
{
name: "happy path for container scan",
inputReport: types.Report{
SchemaVersion: report.SchemaVersion,
ArtifactName: "rails:latest",
ArtifactType: ftypes.ArtifactContainerImage,
Metadata: types.Metadata{
Size: 1024,
OS: &ftypes.OS{
Family: fos.CentOS,
Name: "8.3.2011",
Eosl: true,
},
ImageID: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6",
RepoTags: []string{"rails:latest"},
DiffIDs: []string{"sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a"},
RepoDigests: []string{"rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177"},
ImageConfig: v1.ConfigFile{
Architecture: "arm64",
},
},
Results: types.Results{
{
Target: "rails:latest (centos 8.3.2011)",
Class: types.ClassOSPkg,
Type: fos.CentOS,
Packages: []ftypes.Package{
{
Name: "acl",
Version: "2.2.53",
Release: "1.el8",
Epoch: 0,
Arch: "aarch64",
SrcName: "acl",
SrcVersion: "2.2.53",
SrcRelease: "1.el8",
SrcEpoch: 0,
Modularitylabel: "",
License: "GPLv2+",
},
},
},
{
Target: "app/subproject/Gemfile.lock",
Class: types.ClassLangPkg,
Type: "bundler",
Packages: []ftypes.Package{
{
Name: "actioncable",
Version: "7.0.0",
},
{
Name: "actioncontroller",
Version: "7.0.0",
},
},
},
{
Target: "app/Gemfile.lock",
Class: types.ClassLangPkg,
Type: ftypes.Bundler,
Packages: []ftypes.Package{
{
Name: "actioncable",
Version: "7.0.0",
},
},
},
},
},
wantSBOM: &cdx.BOM{
BOMFormat: "CycloneDX",
SpecVersion: "1.3",
SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000001",
Version: 1,
Metadata: &cdx.Metadata{
Timestamp: "2021-08-25T12:20:30.000000005Z",
Tools: &[]cdx.Tool{
{
Name: "trivy",
Vendor: "aquasecurity",
Version: "dev",
},
},
Component: &cdx.Component{
Type: cdx.ComponentTypeContainer,
BOMRef: "pkg:oci/rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177?repository_url=index.docker.io%2Flibrary%2Frails&arch=arm64",
PackageURL: "pkg:oci/rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177?repository_url=index.docker.io%2Flibrary%2Frails&arch=arm64",
Name: "rails:latest",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SchemaVersion",
Value: "2",
},
{
Name: "aquasecurity:trivy:Size",
Value: "1024",
},
{
Name: "aquasecurity:trivy:ImageID",
Value: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6",
},
{
Name: "aquasecurity:trivy:RepoDigest",
Value: "rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177",
},
{
Name: "aquasecurity:trivy:DiffID",
Value: "sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a",
},
{
Name: "aquasecurity:trivy:RepoTag",
Value: "rails:latest",
},
},
},
},
Components: &[]cdx.Component{
{
BOMRef: "pkg:rpm/centos/acl@2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011",
Type: cdx.ComponentTypeLibrary,
Name: "acl",
Version: "2.2.53-1.el8",
Licenses: &cdx.Licenses{
cdx.LicenseChoice{Expression: "GPLv2+"},
},
PackageURL: "pkg:rpm/centos/acl@2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SrcName",
Value: "acl",
},
{
Name: "aquasecurity:trivy:SrcVersion",
Value: "2.2.53",
},
{
Name: "aquasecurity:trivy:SrcRelease",
Value: "1.el8",
},
},
},
{
BOMRef: "3ff14136-e09f-4df9-80ea-000000000002",
Type: cdx.ComponentTypeOS,
Name: "centos",
Version: "8.3.2011",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:Type",
Value: "centos",
},
{
Name: "aquasecurity:trivy:Class",
Value: "os-pkgs",
},
},
},
{
BOMRef: "pkg:gem/actioncable@7.0.0",
Type: cdx.ComponentTypeLibrary,
Name: "actioncable",
Version: "7.0.0",
PackageURL: "pkg:gem/actioncable@7.0.0",
},
{
BOMRef: "pkg:gem/actioncontroller@7.0.0",
Type: cdx.ComponentTypeLibrary,
Name: "actioncontroller",
Version: "7.0.0",
PackageURL: "pkg:gem/actioncontroller@7.0.0",
},
{
BOMRef: "3ff14136-e09f-4df9-80ea-000000000003",
Type: cdx.ComponentTypeApplication,
Name: "app/subproject/Gemfile.lock",
Version: "",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:Type",
Value: "bundler",
},
{
Name: "aquasecurity:trivy:Class",
Value: "lang-pkgs",
},
},
},
{
BOMRef: "3ff14136-e09f-4df9-80ea-000000000004",
Type: cdx.ComponentTypeApplication,
Name: "app/Gemfile.lock",
Version: "",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:Type",
Value: "bundler",
},
{
Name: "aquasecurity:trivy:Class",
Value: "lang-pkgs",
},
},
},
},
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000002",
Dependencies: &[]cdx.Dependency{
{
Ref: "pkg:rpm/centos/acl@2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011",
},
},
},
{
Ref: "3ff14136-e09f-4df9-80ea-000000000003",
Dependencies: &[]cdx.Dependency{
{
Ref: "pkg:gem/actioncable@7.0.0",
},
{
Ref: "pkg:gem/actioncontroller@7.0.0",
},
},
},
{
Ref: "3ff14136-e09f-4df9-80ea-000000000004",
Dependencies: &[]cdx.Dependency{
{
Ref: "pkg:gem/actioncable@7.0.0",
},
},
},
{
Ref: "pkg:oci/rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177?repository_url=index.docker.io%2Flibrary%2Frails&arch=arm64",
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000002",
},
{
Ref: "3ff14136-e09f-4df9-80ea-000000000003",
},
{
Ref: "3ff14136-e09f-4df9-80ea-000000000004",
},
},
},
},
},
},
{
name: "happy path for local container scan",
inputReport: types.Report{
SchemaVersion: report.SchemaVersion,
ArtifactName: "centos:latest",
ArtifactType: ftypes.ArtifactContainerImage,
Metadata: types.Metadata{
Size: 1024,
OS: &ftypes.OS{
Family: "centos",
Name: "8.3.2011",
Eosl: true,
},
ImageID: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6",
RepoTags: []string{"centos:latest"},
RepoDigests: []string{},
ImageConfig: v1.ConfigFile{
Architecture: "arm64",
},
},
Results: types.Results{
{
Target: "centos:latest (centos 8.3.2011)",
Class: types.ClassOSPkg,
Type: fos.CentOS,
Packages: []ftypes.Package{
{
Name: "acl",
Version: "2.2.53",
Release: "1.el8",
Epoch: 1,
Arch: "aarch64",
SrcName: "acl",
SrcVersion: "2.2.53",
SrcRelease: "1.el8",
SrcEpoch: 1,
Modularitylabel: "",
License: "GPLv2+",
},
},
},
},
},
wantSBOM: &cdx.BOM{
BOMFormat: "CycloneDX",
SpecVersion: "1.3",
SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000001",
Version: 1,
Metadata: &cdx.Metadata{
Timestamp: "2021-08-25T12:20:30.000000005Z",
Tools: &[]cdx.Tool{
{
Name: "trivy",
Vendor: "aquasecurity",
Version: "dev",
},
},
Component: &cdx.Component{
Type: cdx.ComponentTypeContainer,
BOMRef: "3ff14136-e09f-4df9-80ea-000000000002",
PackageURL: "",
Name: "centos:latest",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SchemaVersion",
Value: "2",
},
{
Name: "aquasecurity:trivy:Size",
Value: "1024",
},
{
Name: "aquasecurity:trivy:ImageID",
Value: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6",
},
{
Name: "aquasecurity:trivy:RepoTag",
Value: "centos:latest",
},
},
},
},
Components: &[]cdx.Component{
{
BOMRef: "pkg:rpm/centos/acl@1:2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011",
Type: cdx.ComponentTypeLibrary,
Name: "acl",
Version: "1:2.2.53-1.el8",
Licenses: &cdx.Licenses{
cdx.LicenseChoice{Expression: "GPLv2+"},
},
PackageURL: "pkg:rpm/centos/acl@1:2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SrcName",
Value: "acl",
},
{
Name: "aquasecurity:trivy:SrcVersion",
Value: "2.2.53",
},
{
Name: "aquasecurity:trivy:SrcRelease",
Value: "1.el8",
},
{
Name: "aquasecurity:trivy:SrcEpoch",
Value: "1",
},
},
},
{
BOMRef: "3ff14136-e09f-4df9-80ea-000000000003",
Type: cdx.ComponentTypeOS,
Name: "centos",
Version: "8.3.2011",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:Type",
Value: "centos",
},
{
Name: "aquasecurity:trivy:Class",
Value: "os-pkgs",
},
},
},
},
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000003",
Dependencies: &[]cdx.Dependency{
{
Ref: "pkg:rpm/centos/acl@1:2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011",
},
},
},
{
Ref: "3ff14136-e09f-4df9-80ea-000000000002",
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000003",
},
},
},
},
},
},
{
name: "happy path for fs scan",
inputReport: types.Report{
SchemaVersion: report.SchemaVersion,
ArtifactName: "masahiro331/CVE-2021-41098",
ArtifactType: ftypes.ArtifactFilesystem,
Results: types.Results{
{
Target: "Gemfile.lock",
Class: types.ClassLangPkg,
Type: ftypes.Bundler,
Packages: []ftypes.Package{
{
Name: "actioncable",
Version: "6.1.4.1",
},
},
},
},
},
wantSBOM: &cdx.BOM{
BOMFormat: "CycloneDX",
SpecVersion: "1.3",
SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000001",
Version: 1,
Metadata: &cdx.Metadata{
Timestamp: "2021-08-25T12:20:30.000000005Z",
Tools: &[]cdx.Tool{
{
Name: "trivy",
Vendor: "aquasecurity",
Version: "dev",
},
},
Component: &cdx.Component{
BOMRef: "3ff14136-e09f-4df9-80ea-000000000002",
Type: cdx.ComponentTypeApplication,
Name: "masahiro331/CVE-2021-41098",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SchemaVersion",
Value: "2",
},
},
},
},
Components: &[]cdx.Component{
{
BOMRef: "pkg:gem/actioncable@6.1.4.1",
Type: "library",
Name: "actioncable",
Version: "6.1.4.1",
PackageURL: "pkg:gem/actioncable@6.1.4.1",
},
{
BOMRef: "3ff14136-e09f-4df9-80ea-000000000003",
Type: cdx.ComponentTypeApplication,
Name: "Gemfile.lock",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:Type",
Value: "bundler",
},
{
Name: "aquasecurity:trivy:Class",
Value: "lang-pkgs",
},
},
},
},
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000003",
Dependencies: &[]cdx.Dependency{
{
Ref: "pkg:gem/actioncable@6.1.4.1",
},
},
},
{
Ref: "3ff14136-e09f-4df9-80ea-000000000002",
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000003",
},
},
},
},
},
},
{
name: "happy path aggregate results",
inputReport: types.Report{
SchemaVersion: report.SchemaVersion,
ArtifactName: "test-aggregate",
ArtifactType: ftypes.ArtifactRemoteRepository,
Results: types.Results{
{
Target: "Node.js",
Class: types.ClassLangPkg,
Type: ftypes.NodePkg,
Packages: []ftypes.Package{
{
Name: "ruby-typeprof",
Version: "0.20.1",
License: "MIT",
Layer: ftypes.Layer{
DiffID: "sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e",
},
FilePath: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json",
},
},
},
},
},
wantSBOM: &cdx.BOM{
BOMFormat: "CycloneDX",
SpecVersion: "1.3",
SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000001",
Version: 1,
Metadata: &cdx.Metadata{
Timestamp: "2021-08-25T12:20:30.000000005Z",
Tools: &[]cdx.Tool{
{
Name: "trivy",
Vendor: "aquasecurity",
Version: "dev",
},
},
Component: &cdx.Component{
Type: cdx.ComponentTypeApplication,
Name: "test-aggregate",
BOMRef: "3ff14136-e09f-4df9-80ea-000000000002",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SchemaVersion",
Value: "2",
},
},
},
},
Components: &[]cdx.Component{
{
BOMRef: "pkg:npm/ruby-typeprof@0.20.1?file_path=usr%2Flocal%2Flib%2Fruby%2Fgems%2F3.1.0%2Fgems%2Ftypeprof-0.21.1%2Fvscode%2Fpackage.json",
Type: "library",
Name: "ruby-typeprof",
Version: "0.20.1",
PackageURL: "pkg:npm/ruby-typeprof@0.20.1",
Licenses: &cdx.Licenses{
cdx.LicenseChoice{Expression: "MIT"},
},
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:FilePath",
Value: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json",
},
{
Name: "aquasecurity:trivy:LayerDiffID",
Value: "sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e",
},
},
},
},
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000002",
Dependencies: &[]cdx.Dependency{
{
Ref: "pkg:npm/ruby-typeprof@0.20.1?file_path=usr%2Flocal%2Flib%2Fruby%2Fgems%2F3.1.0%2Fgems%2Ftypeprof-0.21.1%2Fvscode%2Fpackage.json",
},
},
},
},
},
},
{
name: "happy path empty",
inputReport: types.Report{
SchemaVersion: report.SchemaVersion,
ArtifactName: "empty/path",
ArtifactType: ftypes.ArtifactFilesystem,
Results: types.Results{},
},
wantSBOM: &cdx.BOM{
BOMFormat: "CycloneDX",
SpecVersion: "1.3",
SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000001",
Version: 1,
Metadata: &cdx.Metadata{
Timestamp: "2021-08-25T12:20:30.000000005Z",
Tools: &[]cdx.Tool{
{
Name: "trivy",
Vendor: "aquasecurity",
Version: "dev",
},
},
Component: &cdx.Component{
Type: cdx.ComponentTypeApplication,
Name: "empty/path",
BOMRef: "3ff14136-e09f-4df9-80ea-000000000002",
Properties: &[]cdx.Property{
{
Name: "aquasecurity:trivy:SchemaVersion",
Value: "2",
},
},
},
},
Dependencies: &[]cdx.Dependency{
{
Ref: "3ff14136-e09f-4df9-80ea-000000000002",
},
},
},
},
}
clock := fake.NewFakeClock(time.Date(2021, 8, 25, 12, 20, 30, 5, time.UTC))
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var count int
newUUID := func() uuid.UUID {
count++
return uuid.Must(uuid.Parse(fmt.Sprintf("3ff14136-e09f-4df9-80ea-%012d", count)))
}
output := bytes.NewBuffer(nil)
writer := cyclonedx.NewWriter(output, "dev", cyclonedx.WithClock(clock), cyclonedx.WithNewUUID(newUUID))
err := writer.Write(tc.inputReport)
require.NoError(t, err)
var got cdx.BOM
err = json.NewDecoder(output).Decode(&got)
require.NoError(t, err)
assert.Equal(t, *tc.wantSBOM, got)
})
}
}

View File

@@ -10,6 +10,7 @@ import (
dbTypes "github.com/aquasecurity/trivy-db/pkg/types" dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
"github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/report/cyclonedx"
"github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/types"
) )
@@ -45,6 +46,9 @@ func Write(report types.Report, option Option) error {
} }
case "json": case "json":
writer = &JSONWriter{Output: option.Output} writer = &JSONWriter{Output: option.Output}
case "cyclonedx":
// TODO: support xml format option with cyclonedx writer
writer = cyclonedx.NewWriter(option.Output, option.AppVersion)
case "template": case "template":
// We keep `sarif.tpl` template working for backward compatibility for a while. // We keep `sarif.tpl` template working for backward compatibility for a while.
if strings.HasPrefix(option.OutputTemplate, "@") && filepath.Base(option.OutputTemplate) == "sarif.tpl" { if strings.HasPrefix(option.OutputTemplate, "@") && filepath.Base(option.OutputTemplate) == "sarif.tpl" {