Compare commits

...
38 Commits
Author SHA1 Message Date
Alex Goodman 5ea14d6e8b [wip] prototype
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-03-11 10:00:29 -04:00
anchore-actions-token-generator[bot]andwillmurphyscode 75455f050a chore(deps): update anchore dependencies (#4631)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: willmurphyscode <12529630+willmurphyscode@users.noreply.github.com>
2026-03-09 18:10:53 +00:00
22e78c7be1 chore(deps): update tools to latest versions (#4630)
* chore(deps): update tools to latest versions

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore(lint): fix errors in new golangci-lint

Two fixes:

First, replace sb.WriteString(fmt.Sprintf(...)) with fmt.Fprintf(&sb, ...)
Second, suppress errors where we read from the local file system at a
user provided path. This is a CLI tool, and reads from user provided
paths on the local file system by design.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
Co-authored-by: spiffcs <32073428+spiffcs@users.noreply.github.com>
Co-authored-by: Will Murphy <willmurphyscode@users.noreply.github.com>
2026-03-09 12:17:09 -04:00
anchore-actions-token-generator[bot]andwagoodman d2461a9e0a chore(deps): update SPDX license list (#4637)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: wagoodman <590471+wagoodman@users.noreply.github.com>
2026-03-09 11:02:47 -04:00
dependabot[bot] 01f0e332c2 chore(deps): bump actions/download-artifact from 7.0.0 to 8.0.0 (#4658)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.0.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 10:37:33 -04:00
dependabot[bot] c88051d74e chore(deps): bump github.com/cloudflare/circl from 1.6.1 to 1.6.3 (#4638)
Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.6.1 to 1.6.3.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.6.1...v1.6.3)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 10:34:11 -04:00
dependabot[bot] 7d3d1c6237 chore(deps): bump the actions-minor-patch group across 2 directories with 2 updates (#4657)
Bumps the actions-minor-patch group with 2 updates in the / directory: [actions/setup-go](https://github.com/actions/setup-go) and [anchore/sbom-action](https://github.com/anchore/sbom-action).
Bumps the actions-minor-patch group with 1 update in the /.github/actions/bootstrap directory: [actions/setup-go](https://github.com/actions/setup-go).


Updates `actions/setup-go` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4b73464bb391d4059bd26b0524d20df3927bd417)

Updates `anchore/sbom-action` from 0.22.2 to 0.23.0
- [Release notes](https://github.com/anchore/sbom-action/releases)
- [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md)
- [Commits](https://github.com/anchore/sbom-action/compare/28d71544de8eaf1b958d335707167c5f783590ad...17ae1740179002c89186b61233e0f892c3118b11)

Updates `actions/setup-go` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5...4b73464bb391d4059bd26b0524d20df3927bd417)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
- dependency-name: anchore/sbom-action
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
- dependency-name: actions/setup-go
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 10:33:14 -04:00
dependabot[bot] dcba765d86 chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#4659)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 10:32:22 -04:00
dependabot[bot] 2c201469c3 chore(deps): bump go.opentelemetry.io/otel/sdk from 1.39.0 to 1.40.0 (#4646)
Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.39.0 to 1.40.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.39.0...v1.40.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.40.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 10:29:46 -04:00
anchore-actions-token-generator[bot]andwagoodman c583da1c15 chore(deps): update CPE dictionary index (#4647)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: wagoodman <590471+wagoodman@users.noreply.github.com>
2026-03-09 10:26:42 -04:00
dependabot[bot] 22014b6022 chore(deps): bump the go-minor-patch group across 1 directory with 5 updates (#4661)
Bumps the go-minor-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/github/go-spdx/v2](https://github.com/github/go-spdx) | `2.3.6` | `2.4.0` |
| [github.com/go-git/go-billy/v5](https://github.com/go-git/go-billy) | `5.7.0` | `5.8.0` |
| [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) | `5.16.5` | `5.17.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.50.0` | `0.51.0` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.45.0` | `1.46.1` |



Updates `github.com/github/go-spdx/v2` from 2.3.6 to 2.4.0
- [Release notes](https://github.com/github/go-spdx/releases)
- [Commits](https://github.com/github/go-spdx/compare/v2.3.6...v2.4.0)

Updates `github.com/go-git/go-billy/v5` from 5.7.0 to 5.8.0
- [Release notes](https://github.com/go-git/go-billy/releases)
- [Commits](https://github.com/go-git/go-billy/compare/v5.7.0...v5.8.0)

Updates `github.com/go-git/go-git/v5` from 5.16.5 to 5.17.0
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.17.0)

Updates `golang.org/x/net` from 0.50.0 to 0.51.0
- [Commits](https://github.com/golang/net/compare/v0.50.0...v0.51.0)

Updates `modernc.org/sqlite` from 1.45.0 to 1.46.1
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.45.0...v1.46.1)

---
updated-dependencies:
- dependency-name: github.com/github/go-spdx/v2
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/go-git/go-billy/v5
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: golang.org/x/net
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 10:20:06 -04:00
Alex Goodman b5e85c3ea5 chore: migrate fixtures to testdata (#4651)
* migrate fixtures to testdata

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: correct broken symlinks after testdata migration

The migration from test-fixtures to testdata broke several symlinks:
- elf-test-fixtures symlinks pointed to old test-fixtures paths
- elf-test-fixtures needed to be renamed to elf-testdata
- image-pkg-coverage symlink pointed to test-fixtures instead of testdata

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: handle missing classifiers/bin directory in Makefile

The clean-fingerprint target was failing when classifiers/bin doesn't
exist (e.g., on fresh clone without downloaded binaries).

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: add gitignore negation for jar/zip fixtures in test/cli

The jar and zip files in test/cli/testdata/image-unknowns were being
gitignored by the root .gitignore patterns. This caused them to be
untracked and not included when building docker images in CI, resulting
in Test_Unknowns failures since the test expects errors from corrupt
archive files that weren't present.

Add a .gitignore in test/cli/testdata to negate the exclusions for
these specific test fixture files.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* switch fixture cache to v2

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: update expected versions for rebuilt fixtures

Update test expectations for packages that have been updated in
upstream repositories when docker images are rebuilt:
- glibc: 2.42-r4 → 2.43-r1 (wolfi)
- php: 8.2.29 → 8.2.30 (ubuntu/apache)

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* upgrade go

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: add go-shlex dependency for testdata manager tool

The manager tool in syft/pkg/cataloger/binary/testdata/ imports
go-shlex, but since it's in a testdata directory, Go doesn't track
its dependencies. This caused CI failures when go.mod didn't
explicitly list the dependency.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* refactor: move binary classifier manager to internal/

Move the manager tool from testdata/manager to internal/manager so
that Go properly tracks its dependencies. Code in testdata directories
is ignored by Go for dependency tracking, which caused CI failures
when go.mod didn't explicitly list transitive dependencies.

This is a cleaner solution than manually adding dependencies to go.mod
for code that happens to live in testdata.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: add gitignore negations for test fixtures blocked by root patterns

Multiple test fixtures were being blocked by root-level gitignore patterns
like bin/, *.jar, *.tar, and *.exe. This adds targeted .gitignore files with
negation patterns to allow these specific test fixtures to be tracked:

- syft/linux/testdata/os/busybox/bin/busybox (blocked by bin/)
- syft/pkg/cataloger/java/testdata/corrupt/example.{jar,tar} (blocked by *.jar, *.tar)
- syft/pkg/cataloger/binary/testdata/classifiers/snippets/go-version-hint/**/bin/go (blocked by bin/)
- syft/pkg/cataloger/bitnami/testdata/no-rel/.../bin/redis-server (blocked by bin/)

Also updates the bitnami test expectation to include the newly required
.gitignore files in the test fixture.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: update glibc version expectation (2.43-r1 -> 2.43-r2)

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add capability drift check as unit step

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* dont clear test observations before drift detection

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* bump stereoscope commit to main

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-03-06 19:42:04 +00:00
Dimitri John LedkovandKeith Zantow 35278f3d3d fix(java): improve lz4 detection (#4642)
Signed-off-by: Dimitri John Ledkov <dimitri.ledkov@surgut.co.uk>
Signed-off-by: Keith Zantow <kzantow@gmail.com>
Co-authored-by: Keith Zantow <kzantow@gmail.com>
2026-02-27 14:38:05 -05:00
Paweł Pałucha db76d85d51 fix: use correct hashes for empty files (#4620)
Signed-off-by: Paweł Pałucha <pawel.palucha@chainguard.dev>
2026-02-24 09:52:29 -05:00
witchcraze e9e7e20cc8 fix: grafana classifier (#4635)
Signed-off-by: witchcraze <witchcraze@gmail.com>
2026-02-23 09:38:02 -05:00
anchore-actions-token-generator[bot]andwagoodman eb072deb9c chore(deps): update CPE dictionary index (#4636)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: wagoodman <590471+wagoodman@users.noreply.github.com>
2026-02-23 08:19:30 -05:00
dependabot[bot] f4fc2d669a chore(deps): bump github/codeql-action (#4634)
Bumps the actions-minor-patch group with 1 update in the / directory: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.31.10 to 4.32.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdefb33c0f6224e58673d9004f47f7cb3e328b89...9e907b5e64f6b83e7804b09294d44122997950d6)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.32.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 08:41:46 -05:00
dependabot[bot] f5110f109a chore(deps): bump github.com/charmbracelet/bubbles from 0.21.1 to 1.0.0 (#4633)
Bumps [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles) from 0.21.1 to 1.0.0.
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v0.21.1...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/bubbles
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 08:41:21 -05:00
dependabot[bot] 612eadb22e chore(deps): bump the go-minor-patch group with 5 updates (#4632)
Bumps the go-minor-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.32.0` | `0.33.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.49.0` | `0.50.0` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.44.3` | `1.45.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.41.0` | `0.42.0` |
| [github.com/gpustack/gguf-parser-go](https://github.com/gpustack/gguf-parser-go) | `0.23.1` | `0.24.0` |


Updates `golang.org/x/mod` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/mod/compare/v0.32.0...v0.33.0)

Updates `golang.org/x/net` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

Updates `modernc.org/sqlite` from 1.44.3 to 1.45.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.44.3...v1.45.0)

Updates `golang.org/x/tools` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.41.0...v0.42.0)

Updates `github.com/gpustack/gguf-parser-go` from 0.23.1 to 0.24.0
- [Release notes](https://github.com/gpustack/gguf-parser-go/releases)
- [Commits](https://github.com/gpustack/gguf-parser-go/compare/v0.23.1...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: golang.org/x/tools
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/gpustack/gguf-parser-go
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 08:40:09 -05:00
Will Murphy 0a3f7bb06e chore: call cleanup on tmpfile and replace some io.ReadAlls with streams (#4629)
* fix(deb and snaps): prevent excess reads

Previously, Syft could allocate excess memory or tempfile space if there
were highly compressed objects in deb archives, or at paths where the
kernel changelog was expected by the snap cataloger. Use io.LimitReaders
for extracting parts of deb archives, and refactor the snap cataloger's
reading of the kernel changelog to use a streaming parsing, eliminating
the possibility of excess allocation.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>

* fix: always cleanup temp file from file source

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>

* use streaming strategy for deb archives

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>

---------

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
2026-02-17 17:32:35 -05:00
Christopher Angelo Phillips 2fe5f9c7b8 fix: bumps go mod version to 1.25; ci takes latest patch (#4628)
---------
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
2026-02-17 12:04:51 -05:00
anchore-actions-token-generator[bot]andspiffcs f70631a719 chore(deps): update tools to latest versions (#4614)
* chore(deps): update tools to latest versions

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore: ci rules revive

Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Co-authored-by: spiffcs <32073428+spiffcs@users.noreply.github.com>
2026-02-17 11:19:37 -05:00
dependabot[bot] 0bb3741c87 chore(deps): bump the actions-minor-patch group across 1 directory with 2 updates (#4622)
Bumps the actions-minor-patch group with 2 updates in the / directory: [anchore/sbom-action](https://github.com/anchore/sbom-action) and [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).


Updates `anchore/sbom-action` from 0.21.1 to 0.22.2
- [Release notes](https://github.com/anchore/sbom-action/releases)
- [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md)
- [Commits](https://github.com/anchore/sbom-action/compare/0b82b0b1a22399a1c542d4d656f70cd903571b5c...28d71544de8eaf1b958d335707167c5f783590ad)

Updates `zizmorcore/zizmor-action` from 0.4.1 to 0.5.0
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/135698455da5c3b3e55f73f4419e481ab68cdd95...0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d)

---
updated-dependencies:
- dependency-name: anchore/sbom-action
  dependency-version: 0.22.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 11:16:26 -05:00
dependabot[bot] 458ebbbff8 chore(deps): bump the go-minor-patch group with 2 updates (#4621)
Bumps the go-minor-patch group with 2 updates: [github.com/CycloneDX/cyclonedx-go](https://github.com/CycloneDX/cyclonedx-go) and [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles).


Updates `github.com/CycloneDX/cyclonedx-go` from 0.9.3 to 0.10.0
- [Release notes](https://github.com/CycloneDX/cyclonedx-go/releases)
- [Commits](https://github.com/CycloneDX/cyclonedx-go/compare/v0.9.3...v0.10.0)

Updates `github.com/charmbracelet/bubbles` from 0.21.0 to 0.21.1
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v0.21.0...v0.21.1)

---
updated-dependencies:
- dependency-name: github.com/CycloneDX/cyclonedx-go
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/charmbracelet/bubbles
  dependency-version: 0.21.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 10:14:13 -05:00
anchore-actions-token-generator[bot]andwagoodman fb3f560e43 chore(deps): update CPE dictionary index (#4623)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: wagoodman <590471+wagoodman@users.noreply.github.com>
2026-02-17 09:50:12 -05:00
Scott Hebert 89b901b20b Use redhat as namespace for hummingbird rpms (#4615)
The namespace value of `redhat` signifies this as an RPM package
produced and distributed by Red Hat.

Signed-off-by: Scott Hebert <scoheb@gmail.com>
2026-02-11 14:19:20 -05:00
anchore-actions-token-generator[bot]andwillmurphyscode 9872ff36ba chore(deps): update anchore dependencies (#4613)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: willmurphyscode <12529630+willmurphyscode@users.noreply.github.com>
2026-02-10 17:19:56 +00:00
dependabot[bot] 31c503124f chore(deps): bump github.com/go-git/go-git/v5 from 5.16.4 to 5.16.5 (#4612)
Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.4 to 5.16.5.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.4...v5.16.5)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.16.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 08:25:31 -05:00
Christopher Angelo PhillipsandAlex Goodman 2c5e193f7a feat: Add support for scanning GGUF models from OCI registries (#4335)
---------
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-02-09 16:05:52 -05:00
anchore-actions-token-generator[bot]andwagoodman 3a23cfff1d chore(deps): update CPE dictionary index (#4610)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: wagoodman <590471+wagoodman@users.noreply.github.com>
2026-02-08 22:02:34 -05:00
dependabot[bot] 443de210ca chore(deps): bump github.com/bmatcuk/doublestar/v4 (#4606)
Bumps the go-minor-patch group with 1 update: [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar).


Updates `github.com/bmatcuk/doublestar/v4` from 4.9.2 to 4.10.0
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.9.2...v4.10.0)

---
updated-dependencies:
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 13:20:24 -05:00
dependabot[bot] 1af8b1acaa chore(deps): bump the actions-minor-patch group across 2 directories with 2 updates (#4607)
Bumps the actions-minor-patch group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).
Bumps the actions-minor-patch group with 1 update in the /.github/actions/bootstrap directory: [actions/cache](https://github.com/actions/cache).


Updates `docker/login-action` from 3.6.0 to 3.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/5e57cd118135c172c3672efd75eb46360885c0ef...c94ce9fb468520275223c153574b00df6fe4bcc9)

Updates `actions/cache` from 5.0.1 to 5.0.3
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/9255dc7a253b0ccc959486e2bca901246202afeb...cdf6c1fa76f9f475f3d7449005a359c84ca0f306)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
- dependency-name: actions/cache
  dependency-version: 5.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 13:20:12 -05:00
Rez MossandChristopher Phillips c185657d71 feat: add yarn lock dev dep detection; fixed #4548
---------
Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
2026-02-05 22:27:17 +00:00
Will Murphy 48ee12be0c ci(generate-capabilities): serialize writing and reading yaml (#4602)
Otherwise sometimes the test that reads will run during the test that
writes and fail because the yaml file is in a partially written state.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
2026-02-05 11:35:45 -05:00
anchore-actions-token-generator[bot]andwillmurphyscode 0b05f0ed69 chore(deps): update CPE dictionary index (#4601)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: willmurphyscode <12529630+willmurphyscode@users.noreply.github.com>
2026-02-05 15:29:00 +00:00
Will Murphy 138cb1be0e fix(cpe-generation): set start and end date (#4600)
* fix(cpe-generation): set start and end date

Previously, the update job was silently failing because the NVD API
returns a 404 with no body if a start date is specified but not an end
date. Further, the API returns an error if more than 120 days are in
range of the start and end date.

Update the API client to:
1. Return a non-nil error on http 404
2. Chunk the date range into 120 day chunks
3. Pass start and end date to avoid errors.

Also add more tolerant timestamp parsing since the previous update job
would fail with timestamp format errors.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>

* refactor(cpe-generator): remove callbacks

Previously, this job had callbacks that were there to make sure that
incremental progress could be written to disk. However, incremental
progress was not being written to disk, and there were issues related to
the callbacks like double logging. Therefore, just remove the callbacks
and do simple imperative code to page through the API results.

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>

---------

Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
2026-02-05 09:54:24 -05:00
Peter Bücker 6755377554 fix: CPE detection for APK libavif to use aomedia vendor (#4597)
NVD uses "aomedia" as the vendor for libavif CVEs. This change adds
libavif to the APK package CPE candidate additions with "aomedia" as
an additional vendor, enabling Syft/Grype to match CVEs like
CVE-2025-48174 and CVE-2025-48175.

Signed-off-by: Peter Bücker <peter.buecker@gmail.com>
2026-02-05 09:11:44 +00:00
anchore-actions-token-generator[bot]andspiffcs 540c08a41b chore(deps): update tools to latest versions (#4594)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: spiffcs <32073428+spiffcs@users.noreply.github.com>
2026-02-04 09:26:09 -05:00
1679 changed files with 12361 additions and 1873 deletions
+7 -7
View File
@@ -2,7 +2,7 @@ tools:
# we want to use a pinned version of binny to manage the toolchain (so binny manages itself!)
- name: binny
version:
want: v0.11.1
want: v0.11.3
method: github-release
with:
repo: anchore/binny
@@ -26,7 +26,7 @@ tools:
# used for linting
- name: golangci-lint
version:
want: v2.8.0
want: v2.11.2
method: github-release
with:
repo: golangci/golangci-lint
@@ -42,7 +42,7 @@ tools:
# used for signing the checksums file at release
- name: cosign
version:
want: v3.0.4
want: v3.0.5
method: github-release
with:
repo: sigstore/cosign
@@ -58,7 +58,7 @@ tools:
# used to release all artifacts
- name: goreleaser
version:
want: v2.13.3
want: v2.14.2
method: github-release
with:
repo: goreleaser/goreleaser
@@ -90,7 +90,7 @@ tools:
# used for running all local and CI tasks
- name: task
version:
want: v3.48.0
want: v3.49.1
method: github-release
with:
repo: go-task/task
@@ -98,7 +98,7 @@ tools:
# used for triggering a release
- name: gh
version:
want: v2.86.0
want: v2.87.3
method: github-release
with:
repo: cli/cli
@@ -114,7 +114,7 @@ tools:
# used to upload test fixture cache
- name: yq
version:
want: v4.52.2
want: v4.52.4
method: github-release
with:
repo: mikefarah/yq
+5 -4
View File
@@ -5,7 +5,7 @@ inputs:
go-version:
description: "Go version to install"
required: true
default: ">= 1.24"
default: "1.26.x"
go-dependencies:
description: "Download go dependencies"
required: true
@@ -29,15 +29,16 @@ runs:
using: "composite"
steps:
# note: go mod and build is automatically cached on default with v4+
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
if: inputs.go-version != ''
with:
go-version: ${{ inputs.go-version }}
check-latest: true
- name: Restore tool cache
if: inputs.tools == 'true'
id: tool-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ github.workspace }}/.tool
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }}
@@ -66,7 +67,7 @@ runs:
- name: Restore ORAS cache from github actions
if: inputs.download-test-fixture-cache == 'true'
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ github.workspace }}/.tmp/oras-cache
key: ${{ inputs.cache-key-prefix }}-oras-cache
+1 -1
View File
@@ -6,7 +6,7 @@ if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
exit 1
fi
if ! make generate-capabilities; then
if ! make generate-capabilities REFRESH=false; then
echo "Generating capability descriptions failed"
exit 1
fi
@@ -33,9 +33,9 @@ def is_git_tracked_or_untracked(directory):
def find_test_fixture_dirs_with_images(base_dir):
"""Find directories that contain 'test-fixtures' and at least one 'image-*' directory."""
"""Find directories that contain 'testdata' and at least one 'image-*' directory."""
for root, dirs, files in os.walk(base_dir):
if 'test-fixtures' in root:
if 'testdata' in root:
image_dirs = [d for d in dirs if d.startswith('image-')]
if image_dirs:
yield os.path.realpath(root)
+5 -4
View File
@@ -41,13 +41,14 @@ jobs:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 #v6.2.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
with:
go-version-file: go.mod
check-latest: true
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -72,4 +73,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
+3 -3
View File
@@ -150,13 +150,13 @@ jobs:
uses: ./.github/actions/bootstrap
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef #v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 #v3.7.0
with:
username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }}
password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef #v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 #v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -186,7 +186,7 @@ jobs:
# for updating brew formula in anchore/homebrew-syft
GITHUB_BREW_TOKEN: ${{ secrets.ANCHOREOPS_GITHUB_OSS_WRITE_TOKEN }}
- uses: anchore/sbom-action@0b82b0b1a22399a1c542d4d656f70cd903571b5c #v0.21.1
- uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 #v0.23.0
continue-on-error: true
with:
file: go.mod
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: "Run zizmor"
uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
with:
config: .github/zizmor.yml
# Disable SARIF upload so the step is a simple pass/fail gate
+7 -4
View File
@@ -58,6 +58,9 @@ jobs:
- name: Run unit tests
run: make unit
- name: Check for capability drift
run: make check-capability-drift
Integration-Test:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
name: "Integration tests"
@@ -106,7 +109,7 @@ jobs:
run: make snapshot-smoke-test
- name: Upload snapshot artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f #v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0
with:
name: snapshot
path: snapshot/
@@ -131,7 +134,7 @@ jobs:
download-test-fixture-cache: true
- name: Download snapshot artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 #v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
with:
name: snapshot
path: snapshot
@@ -175,7 +178,7 @@ jobs:
download-test-fixture-cache: true
- name: Download snapshot artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 #v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
with:
name: snapshot
path: snapshot
@@ -208,7 +211,7 @@ jobs:
download-test-fixture-cache: true
- name: Download snapshot artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 #v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
with:
name: snapshot
path: snapshot
+1
View File
@@ -37,6 +37,7 @@ VERSION
coverage.txt
*.log
**/test-fixtures/test-observations.json
**/testdata/test-observations.json
# probable archives
.images
+1 -2
View File
@@ -50,10 +50,9 @@ linters:
- legacy
- std-error-handling
rules:
# internal/os contains OS feature detection logic; the name reflects its purpose
# we have multiple packages in syft that might overlap with the stblib; their names reflect their purpose
- linters:
- revive
path: internal/os/
text: "var-naming: avoid package names that conflict"
paths:
- third_party$
+32 -16
View File
@@ -8,7 +8,9 @@ vars:
OWNER: anchore
PROJECT: syft
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:latest
# v1: when fixtures were located at test-fixtures dirs
# v2: migration to testdata dirs
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:v2
# static file dirs
TOOL_DIR: .tool
@@ -73,7 +75,6 @@ tasks:
- task: check-licenses
- task: lint
- task: check-json-schema-drift
- task: check-capability-drift
- task: check-binary-fixture-size
test:
@@ -199,7 +200,7 @@ tasks:
check-binary-fixture-size:
desc: Ensure that the binary test fixtures are not too large
cmds:
- .github/scripts/check_binary_fixture_size.sh syft/pkg/cataloger/binary/test-fixtures/classifiers/snippets
- .github/scripts/check_binary_fixture_size.sh syft/pkg/cataloger/binary/testdata/classifiers/snippets
## Testing tasks #################################
@@ -266,9 +267,9 @@ tasks:
fingerprints:
desc: Generate fingerprints for all non-docker test fixture
silent: true
# this will look for `test-fixtures/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
# this will look for `testdata/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
generates:
- '**/test-fixtures/**/*.fingerprint'
- '**/testdata/**/*.fingerprint'
- test/install/cache.fingerprint
cmds:
- |
@@ -277,7 +278,7 @@ tasks:
RESET='\033[0m'
echo -e "${YELLOW}creating fingerprint files for non-docker fixtures...${RESET}"
for dir in $(find . -type d -name 'test-fixtures'); do
for dir in $(find . -type d -name 'testdata'); do
if [ -f "$dir/Makefile" ]; then
# for debugging...
#echo -e "${YELLOW}• calculating fingerprints in $dir... ${RESET}"
@@ -370,7 +371,7 @@ tasks:
build-fixtures:
desc: Generate all non-docker test fixtures
silent: true
# this will look for `test-fixtures/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
# this will look for `testdata/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
cmds:
- |
# we want to stop on the first build error
@@ -381,7 +382,7 @@ tasks:
RESET='\033[0m'
# Use a for loop with command substitution to avoid subshell issues
for dir in $(find . -type d -name 'test-fixtures'); do
for dir in $(find . -type d -name 'testdata'); do
if [ -f "$dir/Makefile" ]; then
echo -e "${YELLOW}${BOLD}generating fixtures in $dir${RESET}"
make -C "$dir" fixtures
@@ -435,7 +436,7 @@ tasks:
- "echo 'Docker daemon cache:'"
- "docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}:{{`{{.Tag}}`}}' | grep stereoscope-fixture- | sort"
- "echo '\nTar cache:'"
- 'find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" | sort'
- 'find . -type f -wholename "**/testdata/cache/stereoscope-fixture-*.tar" | sort'
check-docker-cache:
desc: Ensure docker caches aren't using too much disk space
@@ -469,7 +470,7 @@ tasks:
- "cd test/install && make ci-test-mac"
generate-compare-file:
cmd: "go run ./cmd/syft {{ .COMPARE_TEST_IMAGE }} -o json > {{ .COMPARE_DIR }}/test-fixtures/acceptance-{{ .COMPARE_TEST_IMAGE }}.json"
cmd: "go run ./cmd/syft {{ .COMPARE_TEST_IMAGE }} -o json > {{ .COMPARE_DIR }}/testdata/acceptance-{{ .COMPARE_TEST_IMAGE }}.json"
compare-mac:
deps: [tmpdir]
@@ -537,15 +538,21 @@ tasks:
deps:
- tmpdir
- fixtures
vars:
# set REFRESH=true to run package tests first and refresh test observations (default: true)
REFRESH: '{{ .REFRESH | default "true" }}'
cmds:
# remove all test observations prior to regenerating
- task: clean-test-observations
if: '{{ eq .REFRESH "true" }}'
# this is required to update test observations; such evidence is used to update the packages/*.yaml
- "go test ./syft/pkg/... -count=1"
- cmd: "go test ./syft/pkg/... -count=1"
if: '{{ eq .REFRESH "true" }}'
- "go generate ./internal/capabilities/..."
- "gofmt -s -w ./internal/capabilities"
# now that we have the latest capabilities, run completeness tests to ensure this is self-consistent
- "SYFT_ENABLE_COMPLETENESS_TESTS=true go test ./internal/capabilities/... -count=1"
# note: -p 1 forces sequential package execution to avoid race between generate (writes) and internal (reads)
- "SYFT_ENABLE_COMPLETENESS_TESTS=true go test -p 1 ./internal/capabilities/... -count=1"
## Build-related targets #################################
@@ -638,6 +645,15 @@ tasks:
## Cleanup targets #################################
clean:
desc: Remove all cache files and old builds
cmds:
- task: clean-snapshot
- task: clean-cache
- task: clean-test-observations
- task: clean-docker-cache
- task: clean-oras-cache
clean-snapshot:
desc: Remove any snapshot builds
cmds:
@@ -647,7 +663,7 @@ tasks:
clean-docker-cache:
desc: Remove all docker cache tars and images from the daemon
cmds:
- find . -type d -wholename "**/test-fixtures/cache" | xargs rm -rf
- find . -type d -wholename "**/testdata/cache" | xargs rm -rf
- docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}' | grep stereoscope-fixture- | awk '{print $1}' | uniq | xargs -r docker rmi --force
clean-oras-cache:
@@ -664,7 +680,7 @@ tasks:
RESET='\033[0m'
# Use a for loop with command substitution to avoid subshell issues
for dir in $(find . -type d -name 'test-fixtures'); do
for dir in $(find . -type d -name 'testdata'); do
if [ -f "$dir/Makefile" ]; then
echo -e "${YELLOW}${BOLD}deleting ephemeral test fixtures in $dir${RESET}"
(make -C "$dir" clean)
@@ -674,6 +690,6 @@ tasks:
- rm -f {{ .LAST_CACHE_PULL_FILE }} {{ .CACHE_PATHS_FILE }}
clean-test-observations:
desc: Remove all test observations (i.e. test-fixtures/test-observations.json)
desc: Remove all test observations (i.e. testdata/test-observations.json)
cmds:
- find . -type f -wholename "**/test-fixtures/test-observations.json" | xargs rm -f
- find . -type f -wholename "**/testdata/test-observations.json" | xargs rm -f
+1
View File
@@ -48,6 +48,7 @@ func create(id clio.Identification, out io.Writer) (clio.Application, *cobra.Com
commands.Cataloger(app),
commands.Attest(app),
commands.Convert(app),
commands.Split(app),
clio.VersionCommand(id, schemaVersion),
clio.ConfigCommand(app, nil),
cranecmd.NewCmdAuthLogin(id.Name), // syft login uses the same command as crane
+1 -1
View File
@@ -219,7 +219,7 @@ func (l attestLogFrame) View() string {
sb := strings.Builder{}
for _, line := range l.lines {
sb.WriteString(fmt.Sprintf(" %s %s\n", l.borderStype.Render("░░"), line))
fmt.Fprintf(&sb, " %s %s\n", l.borderStype.Render("░░"), line)
}
return sb.String()
+1 -1
View File
@@ -49,7 +49,7 @@ func AppClioSetupConfig(id clio.Identification, out io.Writer) *clio.SetupConfig
},
).
WithPostRuns(func(_ *clio.State, _ error) {
stereoscope.Cleanup()
stereoscope.Cleanup() //nolint:staticcheck // we don't have access to the image object here
})
return clioCfg
}
+3 -1
View File
@@ -59,7 +59,9 @@ const (
nonImageSchemeHelp = ` {{.appName}} {{.command}} dir:path/to/yourproject read directly from a path on disk (any directory)
{{.appName}} {{.command}} file:path/to/yourproject/file read directly from a path on disk (any single file)
`
scanSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp
modelSchemeHelp = ` {{.appName}} {{.command}} oci-model-registry:ai/llama3.2 scan an OCI model artifact from a registry (e.g. Docker Hub AI models)
`
scanSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + modelSchemeHelp + nonImageSchemeHelp
scanHelp = scanExample + scanSchemeHelp
)
+6 -6
View File
@@ -19,30 +19,30 @@ func Test_scanOptions_validateLegacyOptionsNotUsed(t *testing.T) {
},
{
name: "config file with no legacy options",
cfg: "test-fixtures/scan-configs/no-legacy-options.yaml",
cfg: "testdata/scan-configs/no-legacy-options.yaml",
},
{
name: "config file with default image pull source legacy option",
cfg: "test-fixtures/scan-configs/with-default-pull-source.yaml",
cfg: "testdata/scan-configs/with-default-pull-source.yaml",
wantErr: assertErrorContains("source.image.default-pull-source"),
},
{
name: "config file with exclude-binary-overlap-by-ownership legacy option",
cfg: "test-fixtures/scan-configs/with-exclude-binary-overlap-by-ownership.yaml",
cfg: "testdata/scan-configs/with-exclude-binary-overlap-by-ownership.yaml",
wantErr: assertErrorContains("package.exclude-binary-overlap-by-ownership"),
},
{
name: "config file with file string legacy option",
cfg: "test-fixtures/scan-configs/with-file-string.yaml",
cfg: "testdata/scan-configs/with-file-string.yaml",
wantErr: assertErrorContains("outputs"),
},
{
name: "config file with file section",
cfg: "test-fixtures/scan-configs/with-file-section.yaml",
cfg: "testdata/scan-configs/with-file-section.yaml",
},
{
name: "config file with base-path legacy option",
cfg: "test-fixtures/scan-configs/with-base-path.yaml",
cfg: "testdata/scan-configs/with-base-path.yaml",
wantErr: assertErrorContains("source.base-path"),
},
}
+264
View File
@@ -0,0 +1,264 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"github.com/anchore/fangs"
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/cmd/syft/internal/split"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
)
const (
splitExample = ` {{.appName}} {{.command}} image.sbom.json split SBOM into one file per package
{{.appName}} {{.command}} image.sbom.json --pkg musl split only the musl package
{{.appName}} {{.command}} image.sbom.json --pkg musl --dir /tmp write output to /tmp directory
{{.appName}} {{.command}} image.sbom.json --drop source --drop location:fsid drop source and filesystem IDs
`
)
// SplitOptions defines the options for the split command
type SplitOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
Packages []string `yaml:"packages" json:"packages" mapstructure:"packages"`
OutputDir string `yaml:"output-dir" json:"output-dir" mapstructure:"output-dir"`
Drop []string `yaml:"drop" json:"drop" mapstructure:"drop"`
}
var _ interface {
clio.FlagAdder
fangs.FieldDescriber
} = (*SplitOptions)(nil)
func (o *SplitOptions) AddFlags(flags clio.FlagSet) {
flags.StringArrayVarP(&o.Packages, "pkg", "p", "package(s) to split (can be specified multiple times)")
flags.StringVarP(&o.OutputDir, "dir", "d", "output directory for split SBOMs (default: current directory)")
flags.StringArrayVarP(&o.Drop, "drop", "", "drop options to apply (source, descriptor, distro, pkg:*, file:*, location:*, all)")
}
func (o *SplitOptions) DescribeFields(descriptions fangs.FieldDescriptionSet) {
descriptions.Add(&o.Packages, "package queries to match for splitting (ID, PURL, name, or name@version)")
descriptions.Add(&o.OutputDir, "output directory for split SBOM files")
descriptions.Add(&o.Drop, "options for dropping SBOM sections (source, descriptor, distro, pkg:licenses, pkg:metadata.files, file:metadata, file:digests, file:executable, file:unknowns, file:licenses, file:contents, location:fsid, location:non-primary-evidence, all)")
}
// Split creates the split command
func Split(app clio.Application) *cobra.Command {
id := app.ID()
opts := &SplitOptions{
UpdateCheck: options.DefaultUpdateCheck(),
OutputDir: ".",
}
return app.SetupCommand(&cobra.Command{
Use: "split [SOURCE-SBOM] [flags]",
Short: "Split an SBOM into separate SBOMs per package",
Long: `[Experimental] Split a syft-format SBOM into one or more SBOMs, one per package.
Each output SBOM contains only the target package, its related packages (connected via
ownership-by-file-overlap and evident-by relationships), and their associated files.
If no --pkg flags are specified, creates one SBOM file per package in the source SBOM.
If --pkg flags are specified, only creates SBOM files for the matching packages.
Package matching (in order of precedence):
1. Exact package ID
2. Exact PURL or PURL prefix
3. Case-insensitive package name
4. name@version format
Drop options:
source Drop the source object entirely
descriptor Drop the descriptor object
distro Drop the distro (Linux distribution) object
pkg:licenses Drop package licenses
pkg:metadata.files Drop files from package metadata (for FileOwner types)
file:metadata Drop file metadata (size, permissions, etc.)
file:digests Drop file digests
file:executable Drop executable metadata
file:unknowns Drop unknown file entries
file:licenses Drop file-level licenses
file:contents Drop file contents
location:fsid Clear FileSystemID from all coordinates
location:non-primary-evidence Drop locations without "evidence": "primary" annotation
all Apply all drop options above`,
Example: internal.Tprintf(splitExample, map[string]interface{}{
"appName": id.Name,
"command": "split",
}),
Args: validateSplitArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(_ *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return RunSplit(opts, args[0])
},
}, opts)
}
func validateSplitArgs(cmd *cobra.Command, args []string) error {
return validateArgs(cmd, args, "an SBOM argument is required")
}
// RunSplit executes the split operation
func RunSplit(opts *SplitOptions, userInput string) error {
log.Warn("split is an experimental feature, run `syft split -h` for help")
// validate drop options
for _, d := range opts.Drop {
if !split.ValidDropOption(d) {
return fmt.Errorf("invalid drop option: %q", d)
}
}
// parse drop options
dropOpts := split.ParseDropOptions(opts.Drop)
dropLocationFSID := split.HasDropLocationFSID(dropOpts)
dropNonPrimaryEvidence := split.HasDropLocationNonPrimaryEvidence(dropOpts)
// read SBOM
var reader io.ReadSeekCloser
if userInput == "-" {
reader = internal.NewBufferedSeeker(os.Stdin)
} else {
f, err := os.Open(userInput)
if err != nil {
return fmt.Errorf("failed to open SBOM file: %w", err)
}
defer func() {
_ = f.Close()
}()
reader = f
}
s, formatID, _, err := format.Decode(reader)
if err != nil {
return fmt.Errorf("failed to decode SBOM: %w", err)
}
if s == nil {
return fmt.Errorf("no SBOM produced")
}
// warn if not syft-json format
if formatID != syftjson.ID {
log.Warnf("input SBOM is not syft-json format (detected: %s), some data may be lost", formatID)
}
// determine target packages
var targetPackages []pkg.Package
if len(opts.Packages) == 0 {
// split all packages
targetPackages = s.Artifacts.Packages.Sorted()
} else {
// match specified packages
targetPackages = split.MatchPackages(s.Artifacts.Packages, opts.Packages)
if len(targetPackages) == 0 {
return fmt.Errorf("no packages matched the specified queries: %v", opts.Packages)
}
log.Infof("matched %d package(s) for splitting", len(targetPackages))
}
// ensure output directory exists
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// split SBOM
results := split.Split(*s, targetPackages, dropLocationFSID, dropNonPrimaryEvidence)
// create encoder
encoder, err := syftjson.NewFormatEncoderWithConfig(syftjson.EncoderConfig{
Pretty: true,
})
if err != nil {
return fmt.Errorf("failed to create encoder: %w", err)
}
// get fields to remove from JSON output
fieldsToRemove := split.GetJSONFieldsToRemove(dropOpts)
// write output files
for _, result := range results {
// apply drop options (excluding location:* options which are handled in Split)
filteredDropOpts := make([]split.DropOption, 0, len(dropOpts))
for _, opt := range dropOpts {
if opt != split.DropLocationFSID && opt != split.DropLocationNonPrimaryEvidence {
filteredDropOpts = append(filteredDropOpts, opt)
}
}
split.ApplyDropOptions(&result.SBOM, filteredDropOpts)
// generate output filename using package ID
outputFile := filepath.Join(opts.OutputDir, fmt.Sprintf("%s.json", result.TargetPackage.ID()))
if err := writeSBOMWithFieldRemoval(encoder, result.SBOM, outputFile, fieldsToRemove); err != nil {
return fmt.Errorf("failed to write SBOM for package %s: %w", result.TargetPackage.Name, err)
}
log.Infof("wrote %s (%s@%s)", outputFile, result.TargetPackage.Name, result.TargetPackage.Version)
}
log.Infof("split complete: %d SBOM(s) written to %s", len(results), opts.OutputDir)
return nil
}
func writeSBOMWithFieldRemoval(encoder sbom.FormatEncoder, s sbom.SBOM, outputFile string, fieldsToRemove []string) error {
// if no fields to remove, use direct encoding
if len(fieldsToRemove) == 0 {
f, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer func() {
_ = f.Close()
}()
return encoder.Encode(f, s)
}
// encode to buffer first
var buf bytes.Buffer
if err := encoder.Encode(&buf, s); err != nil {
return fmt.Errorf("failed to encode SBOM: %w", err)
}
// parse as generic map to remove fields
var doc map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
return fmt.Errorf("failed to parse encoded SBOM: %w", err)
}
// remove specified fields
for _, field := range fieldsToRemove {
delete(doc, field)
}
// re-encode with pretty printing
output, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return fmt.Errorf("failed to re-encode SBOM: %w", err)
}
// write to file
if err := os.WriteFile(outputFile, append(output, '\n'), 0o644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package commands
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunSplit_BasicExecution(t *testing.T) {
// use the example SBOM
sbomPath := "../../../../examples/decode_sbom/alpine.syft.json"
if _, err := os.Stat(sbomPath); os.IsNotExist(err) {
t.Skip("example SBOM not found, skipping integration test")
}
// create temporary output directory
tmpDir, err := os.MkdirTemp("", "syft-split-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
opts := &SplitOptions{
Packages: []string{"alpine-baselayout"},
OutputDir: tmpDir,
Drop: []string{},
}
err = RunSplit(opts, sbomPath)
require.NoError(t, err)
// verify output files were created
entries, err := os.ReadDir(tmpDir)
require.NoError(t, err)
assert.NotEmpty(t, entries, "expected at least one output file")
// verify output file is valid JSON
for _, entry := range entries {
if entry.IsDir() {
continue
}
content, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
require.NoError(t, err)
assert.NotEmpty(t, content)
// basic JSON validation - should start with {
assert.True(t, len(content) > 0 && content[0] == '{', "output should be valid JSON")
}
}
func TestRunSplit_InvalidDropOption(t *testing.T) {
opts := &SplitOptions{
Packages: []string{"test"},
OutputDir: ".",
Drop: []string{"invalid-option"},
}
err := RunSplit(opts, "some-sbom.json")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid drop option")
}
func TestRunSplit_NoMatchingPackages(t *testing.T) {
sbomPath := "../../../../examples/decode_sbom/alpine.syft.json"
if _, err := os.Stat(sbomPath); os.IsNotExist(err) {
t.Skip("example SBOM not found, skipping integration test")
}
tmpDir, err := os.MkdirTemp("", "syft-split-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
opts := &SplitOptions{
Packages: []string{"nonexistent-package-xyz"},
OutputDir: tmpDir,
Drop: []string{},
}
err = RunSplit(opts, sbomPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "no packages matched")
}
func TestRunSplit_WithDropOptions(t *testing.T) {
sbomPath := "../../../../examples/decode_sbom/alpine.syft.json"
if _, err := os.Stat(sbomPath); os.IsNotExist(err) {
t.Skip("example SBOM not found, skipping integration test")
}
tmpDir, err := os.MkdirTemp("", "syft-split-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
opts := &SplitOptions{
Packages: []string{"alpine-baselayout"},
OutputDir: tmpDir,
Drop: []string{"source", "location:fsid"},
}
err = RunSplit(opts, sbomPath)
require.NoError(t, err)
// verify output file was created
entries, err := os.ReadDir(tmpDir)
require.NoError(t, err)
assert.NotEmpty(t, entries)
}
func TestRunSplit_FileNotFound(t *testing.T) {
opts := &SplitOptions{
Packages: []string{"test"},
OutputDir: ".",
Drop: []string{},
}
err := RunSplit(opts, "/nonexistent/path/sbom.json")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to open SBOM file")
}
+1 -1
View File
@@ -122,7 +122,7 @@ func formatVersionOptions(nameVersionPairs []string) string {
for _, name := range sortedAvailableFormats {
s.WriteString("\n")
s.WriteString(fmt.Sprintf(" - %s", name))
fmt.Fprintf(&s, " - %s", name)
if len(availableVersions[name]) > 0 {
s.WriteString(" @ ")
+307
View File
@@ -0,0 +1,307 @@
package split
import (
"slices"
"strings"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
// DropOption represents a drop option for filtering SBOM contents
type DropOption string
const (
DropSource DropOption = "source"
DropDescriptor DropOption = "descriptor"
DropDistro DropOption = "distro"
DropPkgLicenses DropOption = "pkg:licenses"
DropPkgMetadataFiles DropOption = "pkg:metadata.files"
DropFileMetadata DropOption = "file:metadata"
DropFileDigests DropOption = "file:digests"
DropFileExecutable DropOption = "file:executable"
DropFileUnknowns DropOption = "file:unknowns"
DropFileLicenses DropOption = "file:licenses"
DropFileContents DropOption = "file:contents"
DropLocationFSID DropOption = "location:fsid"
DropLocationNonPrimaryEvidence DropOption = "location:non-primary-evidence"
DropAll DropOption = "all"
)
// AllDropOptions returns all valid drop options (excluding "all")
func AllDropOptions() []DropOption {
return []DropOption{
DropSource,
DropDescriptor,
DropDistro,
DropPkgLicenses,
DropPkgMetadataFiles,
DropFileMetadata,
DropFileDigests,
DropFileExecutable,
DropFileUnknowns,
DropFileLicenses,
DropFileContents,
DropLocationFSID,
DropLocationNonPrimaryEvidence,
}
}
// ParseDropOptions parses string values into DropOption values
func ParseDropOptions(values []string) []DropOption {
var opts []DropOption
for _, v := range values {
opt := DropOption(strings.ToLower(strings.TrimSpace(v)))
if opt == DropAll {
return AllDropOptions()
}
opts = append(opts, opt)
}
return opts
}
// ApplyDropOptions applies the specified drop options to the SBOM
func ApplyDropOptions(s *sbom.SBOM, opts []DropOption) {
if s == nil || len(opts) == 0 {
return
}
for _, opt := range opts {
switch opt {
case DropSource:
s.Source = source.Description{}
case DropDescriptor:
s.Descriptor = sbom.Descriptor{}
case DropDistro:
s.Artifacts.LinuxDistribution = nil
case DropPkgLicenses:
clearPackageLicenses(s)
case DropPkgMetadataFiles:
clearPackageMetadataFiles(s)
case DropFileMetadata:
s.Artifacts.FileMetadata = nil
case DropFileDigests:
s.Artifacts.FileDigests = nil
case DropFileExecutable:
s.Artifacts.Executables = nil
case DropFileUnknowns:
s.Artifacts.Unknowns = nil
case DropFileLicenses:
s.Artifacts.FileLicenses = nil
case DropFileContents:
s.Artifacts.FileContents = nil
case DropLocationFSID:
clearFileSystemIDs(s)
case DropLocationNonPrimaryEvidence:
clearNonPrimaryEvidenceLocations(s)
}
}
}
// GetJSONFieldsToRemove returns the JSON field names that should be completely removed from output
func GetJSONFieldsToRemove(opts []DropOption) []string {
var fields []string
for _, opt := range opts {
switch opt {
case DropSource:
fields = append(fields, "source")
case DropDescriptor:
fields = append(fields, "descriptor")
case DropDistro:
fields = append(fields, "distro")
}
}
return fields
}
// clearFileSystemIDs clears FileSystemID from all coordinates in file artifacts and relationships.
// Note: package locations are handled separately in the splitter when creating new packages.
func clearFileSystemIDs(s *sbom.SBOM) {
// clear from file metadata
if s.Artifacts.FileMetadata != nil {
newMetadata := make(map[file.Coordinates]file.Metadata)
for coord, meta := range s.Artifacts.FileMetadata {
newCoord := file.Coordinates{RealPath: coord.RealPath}
newMetadata[newCoord] = meta
}
s.Artifacts.FileMetadata = newMetadata
}
// clear from file digests
if s.Artifacts.FileDigests != nil {
newDigests := make(map[file.Coordinates][]file.Digest)
for coord, digests := range s.Artifacts.FileDigests {
newCoord := file.Coordinates{RealPath: coord.RealPath}
newDigests[newCoord] = digests
}
s.Artifacts.FileDigests = newDigests
}
// clear from file contents
if s.Artifacts.FileContents != nil {
newContents := make(map[file.Coordinates]string)
for coord, contents := range s.Artifacts.FileContents {
newCoord := file.Coordinates{RealPath: coord.RealPath}
newContents[newCoord] = contents
}
s.Artifacts.FileContents = newContents
}
// clear from file licenses
if s.Artifacts.FileLicenses != nil {
newLicenses := make(map[file.Coordinates][]file.License)
for coord, licenses := range s.Artifacts.FileLicenses {
newCoord := file.Coordinates{RealPath: coord.RealPath}
newLicenses[newCoord] = licenses
}
s.Artifacts.FileLicenses = newLicenses
}
// clear from executables
if s.Artifacts.Executables != nil {
newExec := make(map[file.Coordinates]file.Executable)
for coord, exec := range s.Artifacts.Executables {
newCoord := file.Coordinates{RealPath: coord.RealPath}
newExec[newCoord] = exec
}
s.Artifacts.Executables = newExec
}
// clear from unknowns
if s.Artifacts.Unknowns != nil {
newUnknowns := make(map[file.Coordinates][]string)
for coord, unknowns := range s.Artifacts.Unknowns {
newCoord := file.Coordinates{RealPath: coord.RealPath}
newUnknowns[newCoord] = unknowns
}
s.Artifacts.Unknowns = newUnknowns
}
// clear from relationships that reference file coordinates
newRelationships := make([]artifact.Relationship, 0, len(s.Relationships))
for _, rel := range s.Relationships {
newRel := rel
if coord, ok := rel.From.(file.Coordinates); ok {
newRel.From = file.Coordinates{RealPath: coord.RealPath}
}
if coord, ok := rel.To.(file.Coordinates); ok {
newRel.To = file.Coordinates{RealPath: coord.RealPath}
}
newRelationships = append(newRelationships, newRel)
}
s.Relationships = newRelationships
}
// clearPackageLicenses removes licenses from all packages in the SBOM
func clearPackageLicenses(s *sbom.SBOM) {
if s.Artifacts.Packages == nil {
return
}
for p := range s.Artifacts.Packages.Enumerate() {
p.Licenses = pkg.NewLicenseSet()
s.Artifacts.Packages.Delete(p.ID())
s.Artifacts.Packages.Add(p)
}
}
// clearNonPrimaryEvidenceLocations removes locations that don't have "evidence": "primary" annotation
func clearNonPrimaryEvidenceLocations(s *sbom.SBOM) {
if s.Artifacts.Packages == nil {
return
}
for p := range s.Artifacts.Packages.Enumerate() {
newLocations := file.NewLocationSet()
for _, loc := range p.Locations.ToSlice() {
if loc.Annotations != nil && loc.Annotations["evidence"] == "primary" {
newLocations.Add(loc)
}
}
p.Locations = newLocations
s.Artifacts.Packages.Delete(p.ID())
s.Artifacts.Packages.Add(p)
}
}
// clearPackageMetadataFiles clears the Files field from any package metadata that implements FileOwner
func clearPackageMetadataFiles(s *sbom.SBOM) {
if s.Artifacts.Packages == nil {
return
}
for p := range s.Artifacts.Packages.Enumerate() {
if p.Metadata == nil {
continue
}
newMetadata := clearMetadataFiles(p.Metadata)
if newMetadata != nil {
p.Metadata = newMetadata
s.Artifacts.Packages.Delete(p.ID())
s.Artifacts.Packages.Add(p)
}
}
}
// clearMetadataFiles returns a copy of the metadata with Files field cleared if it implements FileOwner.
// Returns nil if the metadata type is not recognized as a FileOwner implementer.
func clearMetadataFiles(metadata any) any {
switch m := metadata.(type) {
case pkg.ApkDBEntry:
m.Files = nil
return m
case pkg.RpmDBEntry:
m.Files = nil
return m
case pkg.DpkgDBEntry:
m.Files = nil
return m
case pkg.AlpmDBEntry:
m.Files = nil
return m
case pkg.PortageEntry:
m.Files = nil
return m
case pkg.NixStoreEntry:
m.Files = nil
return m
case pkg.PythonPackage:
m.Files = nil
return m
case pkg.CondaMetaPackage:
m.Files = nil
return m
case pkg.BitnamiSBOMEntry:
m.Files = nil
return m
case pkg.JavaVMInstallation:
m.Files = nil
return m
}
return nil
}
// ValidDropOption checks if a string is a valid drop option
func ValidDropOption(s string) bool {
opt := DropOption(strings.ToLower(strings.TrimSpace(s)))
if opt == DropAll {
return true
}
return slices.Contains(AllDropOptions(), opt)
}
// HasDropLocationFSID checks if the drop options include location:fsid
func HasDropLocationFSID(opts []DropOption) bool {
return slices.Contains(opts, DropLocationFSID)
}
// HasDropLocationNonPrimaryEvidence checks if the drop options include location:non-primary-evidence
func HasDropLocationNonPrimaryEvidence(opts []DropOption) bool {
return slices.Contains(opts, DropLocationNonPrimaryEvidence)
}
+499
View File
@@ -0,0 +1,499 @@
package split
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
func TestParseDropOptions(t *testing.T) {
tests := []struct {
name string
values []string
want []DropOption
}{
{
name: "single option",
values: []string{"source"},
want: []DropOption{DropSource},
},
{
name: "multiple options",
values: []string{"source", "descriptor", "file:digests"},
want: []DropOption{DropSource, DropDescriptor, DropFileDigests},
},
{
name: "all option expands",
values: []string{"all"},
want: AllDropOptions(),
},
{
name: "case insensitive",
values: []string{"SOURCE", "File:Digests"},
want: []DropOption{DropSource, DropFileDigests},
},
{
name: "with whitespace",
values: []string{" source ", " location:fsid"},
want: []DropOption{DropSource, DropLocationFSID},
},
{
name: "empty values",
values: []string{},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseDropOptions(tt.values)
assert.Equal(t, tt.want, got)
})
}
}
func TestValidDropOption(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{name: "valid source", input: "source", want: true},
{name: "valid descriptor", input: "descriptor", want: true},
{name: "valid distro", input: "distro", want: true},
{name: "valid file:metadata", input: "file:metadata", want: true},
{name: "valid file:digests", input: "file:digests", want: true},
{name: "valid file:executable", input: "file:executable", want: true},
{name: "valid file:unknowns", input: "file:unknowns", want: true},
{name: "valid file:licenses", input: "file:licenses", want: true},
{name: "valid file:contents", input: "file:contents", want: true},
{name: "valid location:fsid", input: "location:fsid", want: true},
{name: "valid location:non-primary-evidence", input: "location:non-primary-evidence", want: true},
{name: "valid pkg:licenses", input: "pkg:licenses", want: true},
{name: "valid pkg:metadata.files", input: "pkg:metadata.files", want: true},
{name: "valid all", input: "all", want: true},
{name: "case insensitive", input: "SOURCE", want: true},
{name: "invalid option", input: "invalid", want: false},
{name: "partial match", input: "file", want: false},
{name: "empty string", input: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ValidDropOption(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
func TestApplyDropOptions(t *testing.T) {
coord := file.Coordinates{
RealPath: "/test/path",
FileSystemID: "layer123",
}
baseSBOM := func() *sbom.SBOM {
return &sbom.SBOM{
Source: source.Description{
ID: "test-id",
Name: "test-source",
},
Descriptor: sbom.Descriptor{
Name: "syft",
Version: "1.0.0",
},
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(),
FileMetadata: map[file.Coordinates]file.Metadata{coord: {}},
FileDigests: map[file.Coordinates][]file.Digest{coord: {{Algorithm: "sha256", Value: "abc"}}},
FileContents: map[file.Coordinates]string{coord: "contents"},
FileLicenses: map[file.Coordinates][]file.License{coord: {{Value: "MIT"}}},
Executables: map[file.Coordinates]file.Executable{coord: {}},
Unknowns: map[file.Coordinates][]string{coord: {"unknown"}},
},
Relationships: []artifact.Relationship{
{
From: coord,
To: coord,
Type: artifact.EvidentByRelationship,
},
},
}
}
tests := []struct {
name string
opts []DropOption
verify func(t *testing.T, s *sbom.SBOM)
}{
{
name: "drop source",
opts: []DropOption{DropSource},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Empty(t, s.Source.ID)
assert.Empty(t, s.Source.Name)
// other fields should be preserved
assert.Equal(t, "syft", s.Descriptor.Name)
},
},
{
name: "drop descriptor",
opts: []DropOption{DropDescriptor},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Empty(t, s.Descriptor.Name)
assert.Empty(t, s.Descriptor.Version)
// other fields should be preserved
assert.Equal(t, "test-id", s.Source.ID)
},
},
{
name: "drop distro",
opts: []DropOption{DropDistro},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.LinuxDistribution)
// other fields should be preserved
assert.Equal(t, "test-id", s.Source.ID)
},
},
{
name: "drop file:metadata",
opts: []DropOption{DropFileMetadata},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.FileMetadata)
// other file artifacts should be preserved
assert.NotNil(t, s.Artifacts.FileDigests)
},
},
{
name: "drop file:digests",
opts: []DropOption{DropFileDigests},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.FileDigests)
// other file artifacts should be preserved
assert.NotNil(t, s.Artifacts.FileMetadata)
},
},
{
name: "drop file:executable",
opts: []DropOption{DropFileExecutable},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.Executables)
},
},
{
name: "drop file:unknowns",
opts: []DropOption{DropFileUnknowns},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.Unknowns)
},
},
{
name: "drop file:licenses",
opts: []DropOption{DropFileLicenses},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.FileLicenses)
},
},
{
name: "drop file:contents",
opts: []DropOption{DropFileContents},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Nil(t, s.Artifacts.FileContents)
},
},
{
name: "drop location:fsid clears FileSystemID",
opts: []DropOption{DropLocationFSID},
verify: func(t *testing.T, s *sbom.SBOM) {
// check that FileSystemID is cleared from file metadata
for coord := range s.Artifacts.FileMetadata {
assert.Empty(t, coord.FileSystemID, "FileSystemID should be empty in FileMetadata")
assert.Equal(t, "/test/path", coord.RealPath)
}
// check relationships
for _, rel := range s.Relationships {
if c, ok := rel.From.(file.Coordinates); ok {
assert.Empty(t, c.FileSystemID, "FileSystemID should be empty in relationship From")
}
if c, ok := rel.To.(file.Coordinates); ok {
assert.Empty(t, c.FileSystemID, "FileSystemID should be empty in relationship To")
}
}
},
},
{
name: "drop pkg:metadata.files clears files from ApkDBEntry",
opts: []DropOption{DropPkgMetadataFiles},
verify: func(t *testing.T, s *sbom.SBOM) {
// first add a package with ApkDBEntry metadata
p := pkg.Package{
Name: "test-apk",
Version: "1.0.0",
Type: pkg.ApkPkg,
Metadata: pkg.ApkDBEntry{
Package: "test-apk",
Files: []pkg.ApkFileRecord{
{Path: "/usr/bin/test"},
},
},
}
s.Artifacts.Packages.Add(p)
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
// verify files were cleared
for p := range s.Artifacts.Packages.Enumerate() {
if p.Name == "test-apk" {
meta, ok := p.Metadata.(pkg.ApkDBEntry)
require.True(t, ok)
assert.Nil(t, meta.Files)
}
}
},
},
{
name: "drop pkg:metadata.files clears files from RpmDBEntry",
opts: []DropOption{DropPkgMetadataFiles},
verify: func(t *testing.T, s *sbom.SBOM) {
p := pkg.Package{
Name: "test-rpm",
Version: "1.0.0",
Type: pkg.RpmPkg,
Metadata: pkg.RpmDBEntry{
Name: "test-rpm",
Files: []pkg.RpmFileRecord{
{Path: "/usr/bin/test"},
},
},
}
s.Artifacts.Packages.Add(p)
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
for p := range s.Artifacts.Packages.Enumerate() {
if p.Name == "test-rpm" {
meta, ok := p.Metadata.(pkg.RpmDBEntry)
require.True(t, ok)
assert.Nil(t, meta.Files)
}
}
},
},
{
name: "drop pkg:metadata.files clears files from DpkgDBEntry",
opts: []DropOption{DropPkgMetadataFiles},
verify: func(t *testing.T, s *sbom.SBOM) {
p := pkg.Package{
Name: "test-deb",
Version: "1.0.0",
Type: pkg.DebPkg,
Metadata: pkg.DpkgDBEntry{
Package: "test-deb",
Files: []pkg.DpkgFileRecord{
{Path: "/usr/bin/test"},
},
},
}
s.Artifacts.Packages.Add(p)
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
for p := range s.Artifacts.Packages.Enumerate() {
if p.Name == "test-deb" {
meta, ok := p.Metadata.(pkg.DpkgDBEntry)
require.True(t, ok)
assert.Nil(t, meta.Files)
}
}
},
},
{
name: "drop pkg:metadata.files clears files from PythonPackage",
opts: []DropOption{DropPkgMetadataFiles},
verify: func(t *testing.T, s *sbom.SBOM) {
p := pkg.Package{
Name: "test-python",
Version: "1.0.0",
Type: pkg.PythonPkg,
Metadata: pkg.PythonPackage{
Name: "test-python",
Files: []pkg.PythonFileRecord{
{Path: "/usr/lib/python/test.py"},
},
},
}
s.Artifacts.Packages.Add(p)
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
for p := range s.Artifacts.Packages.Enumerate() {
if p.Name == "test-python" {
meta, ok := p.Metadata.(pkg.PythonPackage)
require.True(t, ok)
assert.Nil(t, meta.Files)
}
}
},
},
{
name: "drop pkg:metadata.files does nothing for non-FileOwner metadata",
opts: []DropOption{DropPkgMetadataFiles},
verify: func(t *testing.T, s *sbom.SBOM) {
// add a package with metadata that doesn't implement FileOwner
p := pkg.Package{
Name: "test-npm",
Version: "1.0.0",
Type: pkg.NpmPkg,
Metadata: pkg.NpmPackage{
Name: "test-npm",
Version: "1.0.0",
},
}
s.Artifacts.Packages.Add(p)
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
// verify package still exists with metadata intact
for p := range s.Artifacts.Packages.Enumerate() {
if p.Name == "test-npm" {
meta, ok := p.Metadata.(pkg.NpmPackage)
require.True(t, ok)
assert.Equal(t, "test-npm", meta.Name)
}
}
},
},
{
name: "nil SBOM does not panic",
opts: []DropOption{DropSource},
verify: func(t *testing.T, s *sbom.SBOM) {
// nothing to verify, just ensure no panic
},
},
{
name: "empty options does nothing",
opts: []DropOption{},
verify: func(t *testing.T, s *sbom.SBOM) {
assert.Equal(t, "test-id", s.Source.ID)
assert.Equal(t, "syft", s.Descriptor.Name)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s *sbom.SBOM
if tt.name != "nil SBOM does not panic" {
s = baseSBOM()
}
// should not panic
require.NotPanics(t, func() {
ApplyDropOptions(s, tt.opts)
})
if s != nil {
tt.verify(t, s)
}
})
}
}
func TestHasDropLocationFSID(t *testing.T) {
tests := []struct {
name string
opts []DropOption
want bool
}{
{
name: "has location:fsid",
opts: []DropOption{DropSource, DropLocationFSID, DropDescriptor},
want: true,
},
{
name: "no location:fsid",
opts: []DropOption{DropSource, DropDescriptor},
want: false,
},
{
name: "empty opts",
opts: []DropOption{},
want: false,
},
{
name: "nil opts",
opts: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HasDropLocationFSID(tt.opts)
assert.Equal(t, tt.want, got)
})
}
}
func TestGetJSONFieldsToRemove(t *testing.T) {
tests := []struct {
name string
opts []DropOption
want []string
}{
{
name: "source only",
opts: []DropOption{DropSource},
want: []string{"source"},
},
{
name: "descriptor only",
opts: []DropOption{DropDescriptor},
want: []string{"descriptor"},
},
{
name: "distro only",
opts: []DropOption{DropDistro},
want: []string{"distro"},
},
{
name: "all three",
opts: []DropOption{DropSource, DropDescriptor, DropDistro},
want: []string{"source", "descriptor", "distro"},
},
{
name: "mixed with file options",
opts: []DropOption{DropSource, DropFileDigests, DropDescriptor, DropFileMetadata},
want: []string{"source", "descriptor"},
},
{
name: "file options only returns empty",
opts: []DropOption{DropFileDigests, DropFileMetadata, DropLocationFSID},
want: nil,
},
{
name: "empty opts",
opts: []DropOption{},
want: nil,
},
{
name: "nil opts",
opts: nil,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetJSONFieldsToRemove(tt.opts)
assert.Equal(t, tt.want, got)
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package split
import (
"strings"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg"
)
// MatchPackages finds packages in the collection that match the given queries.
// Match order (first match wins):
// 1. Exact package ID match
// 2. Exact PURL match or PURL prefix match
// 3. Case-insensitive name match
// 4. name@version format match
func MatchPackages(collection *pkg.Collection, queries []string) []pkg.Package {
if collection == nil || len(queries) == 0 {
return nil
}
// build indexes for efficient lookups
byID := make(map[artifact.ID]pkg.Package)
byPURL := make(map[string]pkg.Package)
byNameLower := make(map[string][]pkg.Package)
byNameVersion := make(map[string]pkg.Package)
for p := range collection.Enumerate() {
byID[p.ID()] = p
if p.PURL != "" {
byPURL[p.PURL] = p
}
nameLower := strings.ToLower(p.Name)
byNameLower[nameLower] = append(byNameLower[nameLower], p)
nameVersion := strings.ToLower(p.Name + "@" + p.Version)
byNameVersion[nameVersion] = p
}
// track matched packages to avoid duplicates
matched := make(map[artifact.ID]pkg.Package)
for _, query := range queries {
// 1. exact package ID match
if p, ok := byID[artifact.ID(query)]; ok {
matched[p.ID()] = p
continue
}
// 2. exact PURL match
if p, ok := byPURL[query]; ok {
matched[p.ID()] = p
continue
}
// 2b. PURL prefix match (e.g., "pkg:apk/alpine/musl" matches "pkg:apk/alpine/musl@1.2.2")
if strings.HasPrefix(query, "pkg:") {
for purl, p := range byPURL {
if strings.HasPrefix(purl, query) {
matched[p.ID()] = p
}
}
if len(matched) > 0 {
continue
}
}
queryLower := strings.ToLower(query)
// 3. case-insensitive name match
if pkgs, ok := byNameLower[queryLower]; ok {
for _, p := range pkgs {
matched[p.ID()] = p
}
continue
}
// 4. name@version format match
if p, ok := byNameVersion[queryLower]; ok {
matched[p.ID()] = p
continue
}
}
// convert map to slice
result := make([]pkg.Package, 0, len(matched))
for _, p := range matched {
result = append(result, p)
}
// sort for stable output
pkg.Sort(result)
return result
}
+124
View File
@@ -0,0 +1,124 @@
package split
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/pkg"
)
func TestMatchPackages(t *testing.T) {
// create test packages
pkgA := pkg.Package{
Name: "alpine-baselayout",
Version: "3.2.0-r7",
PURL: "pkg:apk/alpine/alpine-baselayout@3.2.0-r7",
}
pkgA.SetID()
pkgB := pkg.Package{
Name: "musl",
Version: "1.2.2-r0",
PURL: "pkg:apk/alpine/musl@1.2.2-r0",
}
pkgB.SetID()
pkgC := pkg.Package{
Name: "Musl", // different case
Version: "2.0.0",
PURL: "pkg:apk/alpine/Musl@2.0.0",
}
pkgC.SetID()
collection := pkg.NewCollection(pkgA, pkgB, pkgC)
tests := []struct {
name string
queries []string
want []pkg.Package
}{
{
name: "match by exact package ID",
queries: []string{string(pkgA.ID())},
want: []pkg.Package{pkgA},
},
{
name: "match by exact PURL",
queries: []string{"pkg:apk/alpine/musl@1.2.2-r0"},
want: []pkg.Package{pkgB},
},
{
name: "match by PURL prefix",
queries: []string{"pkg:apk/alpine/musl"},
want: []pkg.Package{pkgB},
},
{
name: "match by case-insensitive name",
queries: []string{"musl"},
want: []pkg.Package{pkgB, pkgC},
},
{
name: "match by name@version",
queries: []string{"musl@1.2.2-r0"},
want: []pkg.Package{pkgB},
},
{
name: "multiple queries",
queries: []string{"alpine-baselayout", "musl@1.2.2-r0"},
want: []pkg.Package{pkgA, pkgB},
},
{
name: "no match",
queries: []string{"nonexistent"},
want: []pkg.Package{},
},
{
name: "empty queries",
queries: []string{},
want: nil,
},
{
name: "nil collection returns nil",
queries: []string{"musl"},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
coll := collection
if tt.name == "nil collection returns nil" {
coll = nil
}
got := MatchPackages(coll, tt.queries)
if tt.want == nil {
require.Nil(t, got)
return
}
require.Len(t, got, len(tt.want))
if len(tt.want) == 0 {
return
}
// sort both for comparison
pkg.Sort(tt.want)
// compare using cmp.Diff, ignoring unexported fields
opts := []cmp.Option{
cmpopts.IgnoreUnexported(pkg.Package{}),
cmpopts.IgnoreFields(pkg.Package{}, "Locations", "Licenses", "CPEs"),
}
if diff := cmp.Diff(tt.want, got, opts...); diff != "" {
t.Errorf("MatchPackages() mismatch (-want +got):\n%s", diff)
}
})
}
}
@@ -0,0 +1,158 @@
package split
import (
"slices"
"strings"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
)
// relationshipIndex indexes relationships for efficient lookup by from/to IDs.
// This is a simplified version tailored for the split functionality.
type relationshipIndex struct {
relationships []*sortableRelationship
fromID map[artifact.ID]*mappedRelationships
toID map[artifact.ID]*mappedRelationships
}
// newRelationshipIndex creates a new relationship index from the given relationships
func newRelationshipIndex(relationships ...artifact.Relationship) *relationshipIndex {
idx := &relationshipIndex{
fromID: make(map[artifact.ID]*mappedRelationships),
toID: make(map[artifact.ID]*mappedRelationships),
}
for _, r := range relationships {
// prevent duplicates
if idx.contains(r) {
continue
}
fromID := r.From.ID()
toID := r.To.ID()
sr := &sortableRelationship{
from: fromID,
to: toID,
relationship: r,
}
idx.relationships = append(idx.relationships, sr)
// add from -> to mapping
if idx.fromID[fromID] == nil {
idx.fromID[fromID] = &mappedRelationships{}
}
idx.fromID[fromID].add(toID, sr)
// add to -> from mapping
if idx.toID[toID] == nil {
idx.toID[toID] = &mappedRelationships{}
}
idx.toID[toID].add(fromID, sr)
}
return idx
}
// from returns all relationships from the given identifiable, filtered by types
func (i *relationshipIndex) from(identifiable artifact.Identifiable, types ...artifact.RelationshipType) []artifact.Relationship {
return toSortedSlice(fromMapped(i.fromID, identifiable), types)
}
// to returns all relationships to the given identifiable, filtered by types
func (i *relationshipIndex) to(identifiable artifact.Identifiable, types ...artifact.RelationshipType) []artifact.Relationship {
return toSortedSlice(fromMapped(i.toID, identifiable), types)
}
// coordinates returns all file coordinates referenced by relationships for the given identifiable
func (i *relationshipIndex) coordinates(identifiable artifact.Identifiable, types ...artifact.RelationshipType) []file.Coordinates {
// get relationships in both directions
rels := append(fromMapped(i.fromID, identifiable), fromMapped(i.toID, identifiable)...)
sorted := toSortedSlice(rels, types)
var coords []file.Coordinates
for _, rel := range sorted {
if c, ok := rel.From.(file.Coordinates); ok {
coords = append(coords, c)
}
if c, ok := rel.To.(file.Coordinates); ok {
coords = append(coords, c)
}
}
return coords
}
// all returns all relationships, optionally filtered by types
func (i *relationshipIndex) all(types ...artifact.RelationshipType) []artifact.Relationship {
return toSortedSlice(i.relationships, types)
}
// contains checks if the relationship is already in the index
func (i *relationshipIndex) contains(r artifact.Relationship) bool {
mapped := i.fromID[r.From.ID()]
if mapped == nil {
return false
}
typeMap := mapped.typeMap[r.Type]
if typeMap == nil {
return false
}
return typeMap[r.To.ID()] != nil
}
type mappedRelationships struct {
typeMap map[artifact.RelationshipType]map[artifact.ID]*sortableRelationship
allRelated []*sortableRelationship
}
func (m *mappedRelationships) add(id artifact.ID, sr *sortableRelationship) {
m.allRelated = append(m.allRelated, sr)
if m.typeMap == nil {
m.typeMap = make(map[artifact.RelationshipType]map[artifact.ID]*sortableRelationship)
}
if m.typeMap[sr.relationship.Type] == nil {
m.typeMap[sr.relationship.Type] = make(map[artifact.ID]*sortableRelationship)
}
m.typeMap[sr.relationship.Type][id] = sr
}
type sortableRelationship struct {
from artifact.ID
to artifact.ID
relationship artifact.Relationship
}
func fromMapped(idMap map[artifact.ID]*mappedRelationships, identifiable artifact.Identifiable) []*sortableRelationship {
if identifiable == nil {
return nil
}
mapped := idMap[identifiable.ID()]
if mapped == nil {
return nil
}
return mapped.allRelated
}
func toSortedSlice(relationships []*sortableRelationship, types []artifact.RelationshipType) []artifact.Relationship {
slices.SortFunc(relationships, func(a, b *sortableRelationship) int {
cmp := strings.Compare(string(a.relationship.Type), string(b.relationship.Type))
if cmp != 0 {
return cmp
}
cmp = strings.Compare(string(a.from), string(b.from))
if cmp != 0 {
return cmp
}
return strings.Compare(string(a.to), string(b.to))
})
var out []artifact.Relationship
for _, r := range relationships {
if len(types) == 0 || slices.Contains(types, r.relationship.Type) {
out = append(out, r.relationship)
}
}
return out
}
+314
View File
@@ -0,0 +1,314 @@
package split
import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
)
// allowedRelationshipTypes are the only relationship types allowed in split output
var allowedRelationshipTypes = []artifact.RelationshipType{
artifact.OwnershipByFileOverlapRelationship,
artifact.EvidentByRelationship,
}
// Result represents the result of splitting an SBOM for a single target package
type Result struct {
TargetPackage pkg.Package
SBOM sbom.SBOM
}
// Split splits the source SBOM into separate SBOMs, one for each target package.
// Each output SBOM contains the target package, its connected packages (via ownership-by-file-overlap
// and evident-by relationships), and all related files.
func Split(source sbom.SBOM, targetPackages []pkg.Package, dropLocationFSID, dropNonPrimaryEvidence bool) []Result {
if len(targetPackages) == 0 {
return nil
}
relIndex := newRelationshipIndex(source.Relationships...)
results := make([]Result, 0, len(targetPackages))
for _, target := range targetPackages {
result := splitForPackage(source, target, relIndex, dropLocationFSID, dropNonPrimaryEvidence)
results = append(results, result)
}
return results
}
// splitForPackage creates a new SBOM containing only the target package and its related artifacts
func splitForPackage(source sbom.SBOM, target pkg.Package, relIndex *relationshipIndex, dropLocationFSID, dropNonPrimaryEvidence bool) Result {
// find all connected packages via BFS
connectedPkgs := findConnectedPackages(source, target, relIndex)
// collect all kept package IDs for relationship filtering
keptPkgIDs := make(map[artifact.ID]bool)
for _, p := range connectedPkgs {
keptPkgIDs[p.ID()] = true
}
// find related file coordinates from relationships and package locations
keptCoords := collectFileCoordinates(connectedPkgs, relIndex, dropLocationFSID, dropNonPrimaryEvidence)
// build filtered SBOM
filteredSBOM := buildFilteredSBOM(source, connectedPkgs, keptPkgIDs, keptCoords, relIndex, dropLocationFSID, dropNonPrimaryEvidence)
return Result{
TargetPackage: target,
SBOM: filteredSBOM,
}
}
// findConnectedPackages uses BFS to find all packages connected to the target via traversal relationships
func findConnectedPackages(source sbom.SBOM, target pkg.Package, relIndex *relationshipIndex) []pkg.Package {
visited := make(map[artifact.ID]bool)
var result []pkg.Package
queue := []pkg.Package{target}
visited[target.ID()] = true
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
result = append(result, current)
// find connected packages through relationships (both directions)
for _, rel := range relIndex.from(current, allowedRelationshipTypes...) {
toID := rel.To.ID()
if visited[toID] {
continue
}
// check if the target is a package in the source collection
if p := source.Artifacts.Packages.Package(toID); p != nil {
visited[toID] = true
queue = append(queue, *p)
}
}
for _, rel := range relIndex.to(current, allowedRelationshipTypes...) {
fromID := rel.From.ID()
if visited[fromID] {
continue
}
// check if the source is a package in the source collection
if p := source.Artifacts.Packages.Package(fromID); p != nil {
visited[fromID] = true
queue = append(queue, *p)
}
}
}
return result
}
// collectFileCoordinates gathers all file coordinates related to the kept packages
func collectFileCoordinates(packages []pkg.Package, relIndex *relationshipIndex, dropLocationFSID, dropNonPrimaryEvidence bool) map[file.Coordinates]bool {
coords := make(map[file.Coordinates]bool)
for _, p := range packages {
// collect coordinates from package locations
for _, loc := range p.Locations.ToSlice() {
// skip non-primary evidence locations if requested
if dropNonPrimaryEvidence {
if loc.Annotations == nil || loc.Annotations["evidence"] != "primary" {
continue
}
}
coord := loc.Coordinates
if dropLocationFSID {
coord = file.Coordinates{RealPath: coord.RealPath}
}
coords[coord] = true
}
// collect coordinates from allowed relationship types only
for _, c := range relIndex.coordinates(p, allowedRelationshipTypes...) {
coord := c
if dropLocationFSID {
coord = file.Coordinates{RealPath: c.RealPath}
}
coords[coord] = true
}
}
return coords
}
// buildFilteredSBOM creates a new SBOM with only the kept packages, files, and relationships
func buildFilteredSBOM(source sbom.SBOM, packages []pkg.Package, keptPkgIDs map[artifact.ID]bool, keptCoords map[file.Coordinates]bool, relIndex *relationshipIndex, dropLocationFSID, dropNonPrimaryEvidence bool) sbom.SBOM {
// create new package collection
newPkgCollection := pkg.NewCollection()
for _, p := range packages {
// filter non-primary evidence locations if requested
if dropNonPrimaryEvidence {
p = filterPackageNonPrimaryLocations(p)
}
// if dropLocationFSID is enabled, we need to clear FileSystemID from package locations
if dropLocationFSID {
p = clearPackageFileSystemIDs(p)
}
newPkgCollection.Add(p)
}
// filter file artifacts
filteredSBOM := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: newPkgCollection,
FileMetadata: filterFileMap(source.Artifacts.FileMetadata, keptCoords, dropLocationFSID),
FileDigests: filterFileMap(source.Artifacts.FileDigests, keptCoords, dropLocationFSID),
FileContents: filterFileMap(source.Artifacts.FileContents, keptCoords, dropLocationFSID),
FileLicenses: filterFileMap(source.Artifacts.FileLicenses, keptCoords, dropLocationFSID),
Executables: filterFileMap(source.Artifacts.Executables, keptCoords, dropLocationFSID),
Unknowns: filterFileMap(source.Artifacts.Unknowns, keptCoords, dropLocationFSID),
LinuxDistribution: source.Artifacts.LinuxDistribution,
},
Relationships: filterRelationships(relIndex.all(), keptPkgIDs, keptCoords, dropLocationFSID),
Source: source.Source,
Descriptor: source.Descriptor,
}
return filteredSBOM
}
// filterPackageNonPrimaryLocations creates a copy of the package with only primary evidence locations
func filterPackageNonPrimaryLocations(p pkg.Package) pkg.Package {
newLocations := file.NewLocationSet()
for _, loc := range p.Locations.ToSlice() {
if loc.Annotations != nil && loc.Annotations["evidence"] == "primary" {
newLocations.Add(loc)
}
}
p.Locations = newLocations
return p
}
// filterFileMap filters a map of file.Coordinates to only include kept coordinates
func filterFileMap[T any](m map[file.Coordinates]T, keptCoords map[file.Coordinates]bool, dropLocationFSID bool) map[file.Coordinates]T {
if m == nil {
return nil
}
result := make(map[file.Coordinates]T)
for coord, value := range m {
checkCoord := coord
if dropLocationFSID {
checkCoord = file.Coordinates{RealPath: coord.RealPath}
}
if keptCoords[checkCoord] {
outputCoord := coord
if dropLocationFSID {
outputCoord = file.Coordinates{RealPath: coord.RealPath}
}
result[outputCoord] = value
}
}
if len(result) == 0 {
return nil
}
return result
}
// filterRelationships filters relationships to only include allowed types referencing kept artifacts
func filterRelationships(relationships []artifact.Relationship, keptPkgIDs map[artifact.ID]bool, keptCoords map[file.Coordinates]bool, dropLocationFSID bool) []artifact.Relationship {
var result []artifact.Relationship
for _, rel := range relationships {
// only keep allowed relationship types
if !isAllowedRelationshipType(rel.Type) {
continue
}
// check if both ends of the relationship reference kept artifacts
fromKept := isArtifactKept(rel.From, keptPkgIDs, keptCoords, dropLocationFSID)
toKept := isArtifactKept(rel.To, keptPkgIDs, keptCoords, dropLocationFSID)
if fromKept && toKept {
newRel := rel
if dropLocationFSID {
if coord, ok := rel.From.(file.Coordinates); ok {
newRel.From = file.Coordinates{RealPath: coord.RealPath}
}
if coord, ok := rel.To.(file.Coordinates); ok {
newRel.To = file.Coordinates{RealPath: coord.RealPath}
}
}
result = append(result, newRel)
}
}
return result
}
// isAllowedRelationshipType checks if a relationship type is in the allowed list
func isAllowedRelationshipType(t artifact.RelationshipType) bool {
for _, allowed := range allowedRelationshipTypes {
if t == allowed {
return true
}
}
return false
}
// isArtifactKept checks if an artifact (package or file) is in the kept set
func isArtifactKept(a artifact.Identifiable, keptPkgIDs map[artifact.ID]bool, keptCoords map[file.Coordinates]bool, dropLocationFSID bool) bool {
if a == nil {
return false
}
// check if it's a file coordinate
if coord, ok := a.(file.Coordinates); ok {
checkCoord := coord
if dropLocationFSID {
checkCoord = file.Coordinates{RealPath: coord.RealPath}
}
return keptCoords[checkCoord]
}
// otherwise check package ID
return keptPkgIDs[a.ID()]
}
// clearPackageFileSystemIDs creates a copy of the package with FileSystemID cleared from all locations
func clearPackageFileSystemIDs(p pkg.Package) pkg.Package {
newLocations := file.NewLocationSet()
for _, loc := range p.Locations.ToSlice() {
newLoc := file.Location{
LocationData: file.LocationData{
Coordinates: file.Coordinates{
RealPath: loc.RealPath,
// FileSystemID intentionally left empty
},
AccessPath: loc.AccessPath,
},
LocationMetadata: loc.LocationMetadata,
}
newLocations.Add(newLoc)
}
p.Locations = newLocations
// also clear from license locations
newLicenses := pkg.NewLicenseSet()
for _, lic := range p.Licenses.ToSlice() {
newLicLocs := file.NewLocationSet()
for _, loc := range lic.Locations.ToSlice() {
newLoc := file.Location{
LocationData: file.LocationData{
Coordinates: file.Coordinates{
RealPath: loc.RealPath,
},
AccessPath: loc.AccessPath,
},
LocationMetadata: loc.LocationMetadata,
}
newLicLocs.Add(newLoc)
}
lic.Locations = newLicLocs
newLicenses.Add(lic)
}
p.Licenses = newLicenses
return p
}
+606
View File
@@ -0,0 +1,606 @@
package split
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
func TestSplit(t *testing.T) {
// create test coordinates
coord1 := file.Coordinates{RealPath: "/lib/apk/db/installed", FileSystemID: "layer1"}
coord2 := file.Coordinates{RealPath: "/usr/lib/libmusl.so", FileSystemID: "layer1"}
coord3 := file.Coordinates{RealPath: "/unrelated/file", FileSystemID: "layer1"}
// create test packages
pkgA := pkg.Package{
Name: "alpine-baselayout",
Version: "3.2.0-r7",
Type: pkg.ApkPkg,
Locations: file.NewLocationSet(file.NewLocationFromCoordinates(coord1)),
}
pkgA.SetID()
pkgB := pkg.Package{
Name: "musl",
Version: "1.2.2-r0",
Type: pkg.ApkPkg,
Locations: file.NewLocationSet(file.NewLocationFromCoordinates(coord2)),
}
pkgB.SetID()
pkgC := pkg.Package{
Name: "unrelated",
Version: "1.0.0",
Type: pkg.ApkPkg,
Locations: file.NewLocationSet(file.NewLocationFromCoordinates(coord3)),
}
pkgC.SetID()
// create source SBOM with relationships
sourceSBOM := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(pkgA, pkgB, pkgC),
FileMetadata: map[file.Coordinates]file.Metadata{
coord1: {MIMEType: "text/plain"},
coord2: {MIMEType: "application/x-sharedlib"},
coord3: {MIMEType: "text/plain"},
},
FileDigests: map[file.Coordinates][]file.Digest{
coord1: {{Algorithm: "sha256", Value: "abc123"}},
coord2: {{Algorithm: "sha256", Value: "def456"}},
coord3: {{Algorithm: "sha256", Value: "ghi789"}},
},
LinuxDistribution: &linux.Release{ID: "alpine", VersionID: "3.12"},
},
Relationships: []artifact.Relationship{
// pkgA owns pkgB via file overlap
{
From: pkgA,
To: pkgB,
Type: artifact.OwnershipByFileOverlapRelationship,
},
// pkgA is evident by coord1
{
From: pkgA,
To: coord1,
Type: artifact.EvidentByRelationship,
},
// pkgB is evident by coord2
{
From: pkgB,
To: coord2,
Type: artifact.EvidentByRelationship,
},
// pkgC is evident by coord3 (unrelated)
{
From: pkgC,
To: coord3,
Type: artifact.EvidentByRelationship,
},
},
Source: source.Description{
ID: "test-source-id",
Name: "test-image",
},
Descriptor: sbom.Descriptor{
Name: "syft",
Version: "test",
},
}
tests := []struct {
name string
targetPackages []pkg.Package
dropLocationFSID bool
dropNonPrimaryEvidence bool
wantCount int
verify func(t *testing.T, results []Result)
}{
{
name: "split single package with connected packages",
targetPackages: []pkg.Package{pkgA},
dropLocationFSID: false,
wantCount: 1,
verify: func(t *testing.T, results []Result) {
require.Len(t, results, 1)
result := results[0]
// target package should be pkgA
assert.Equal(t, pkgA.Name, result.TargetPackage.Name)
// should include both pkgA and pkgB (connected via ownership)
assert.Equal(t, 2, result.SBOM.Artifacts.Packages.PackageCount())
// should include coord1 and coord2 (related to pkgA and pkgB)
assert.Contains(t, result.SBOM.Artifacts.FileMetadata, coord1)
assert.Contains(t, result.SBOM.Artifacts.FileMetadata, coord2)
// should NOT include coord3 (unrelated)
assert.NotContains(t, result.SBOM.Artifacts.FileMetadata, coord3)
// source and descriptor should be preserved
assert.Equal(t, "test-source-id", result.SBOM.Source.ID)
assert.Equal(t, "syft", result.SBOM.Descriptor.Name)
// linux distribution should be preserved
require.NotNil(t, result.SBOM.Artifacts.LinuxDistribution)
assert.Equal(t, "alpine", result.SBOM.Artifacts.LinuxDistribution.ID)
},
},
{
name: "split unrelated package",
targetPackages: []pkg.Package{pkgC},
dropLocationFSID: false,
wantCount: 1,
verify: func(t *testing.T, results []Result) {
require.Len(t, results, 1)
result := results[0]
// should only include pkgC
assert.Equal(t, 1, result.SBOM.Artifacts.Packages.PackageCount())
// should only include coord3
assert.Contains(t, result.SBOM.Artifacts.FileMetadata, coord3)
assert.NotContains(t, result.SBOM.Artifacts.FileMetadata, coord1)
assert.NotContains(t, result.SBOM.Artifacts.FileMetadata, coord2)
},
},
{
name: "split with dropLocationFSID",
targetPackages: []pkg.Package{pkgA},
dropLocationFSID: true,
wantCount: 1,
verify: func(t *testing.T, results []Result) {
require.Len(t, results, 1)
result := results[0]
// FileSystemID should be cleared from coordinates
for coord := range result.SBOM.Artifacts.FileMetadata {
assert.Empty(t, coord.FileSystemID, "FileSystemID should be empty")
}
// package locations should also have FileSystemID cleared
for p := range result.SBOM.Artifacts.Packages.Enumerate() {
for _, loc := range p.Locations.ToSlice() {
assert.Empty(t, loc.FileSystemID, "package location FileSystemID should be empty")
}
}
},
},
{
name: "split multiple packages",
targetPackages: []pkg.Package{pkgA, pkgC},
dropLocationFSID: false,
wantCount: 2,
verify: func(t *testing.T, results []Result) {
require.Len(t, results, 2)
// first result should be for pkgA
assert.Equal(t, pkgA.Name, results[0].TargetPackage.Name)
// second result should be for pkgC
assert.Equal(t, pkgC.Name, results[1].TargetPackage.Name)
},
},
{
name: "empty target packages",
targetPackages: []pkg.Package{},
dropLocationFSID: false,
wantCount: 0,
verify: func(t *testing.T, results []Result) {
assert.Nil(t, results)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := Split(sourceSBOM, tt.targetPackages, tt.dropLocationFSID, tt.dropNonPrimaryEvidence)
if tt.wantCount == 0 {
assert.Nil(t, results)
} else {
require.Len(t, results, tt.wantCount)
}
if tt.verify != nil {
tt.verify(t, results)
}
})
}
}
func TestFindConnectedPackages(t *testing.T) {
// create test packages
pkgA := pkg.Package{Name: "a", Version: "1.0"}
pkgA.SetID()
pkgB := pkg.Package{Name: "b", Version: "1.0"}
pkgB.SetID()
pkgC := pkg.Package{Name: "c", Version: "1.0"}
pkgC.SetID()
pkgD := pkg.Package{Name: "d", Version: "1.0"}
pkgD.SetID()
tests := []struct {
name string
target pkg.Package
relationships []artifact.Relationship
wantNames []string
}{
{
name: "single package no relationships",
target: pkgA,
relationships: []artifact.Relationship{
// pkgB -> pkgC (unrelated)
{From: pkgB, To: pkgC, Type: artifact.OwnershipByFileOverlapRelationship},
},
wantNames: []string{"a"},
},
{
name: "connected via ownership-by-file-overlap",
target: pkgA,
relationships: []artifact.Relationship{
{From: pkgA, To: pkgB, Type: artifact.OwnershipByFileOverlapRelationship},
},
wantNames: []string{"a", "b"},
},
{
name: "connected via evident-by (to package)",
target: pkgA,
relationships: []artifact.Relationship{
{From: pkgA, To: pkgB, Type: artifact.EvidentByRelationship},
},
wantNames: []string{"a", "b"},
},
{
name: "chain of connections",
target: pkgA,
relationships: []artifact.Relationship{
{From: pkgA, To: pkgB, Type: artifact.OwnershipByFileOverlapRelationship},
{From: pkgB, To: pkgC, Type: artifact.OwnershipByFileOverlapRelationship},
},
wantNames: []string{"a", "b", "c"},
},
{
name: "circular relationships",
target: pkgA,
relationships: []artifact.Relationship{
{From: pkgA, To: pkgB, Type: artifact.OwnershipByFileOverlapRelationship},
{From: pkgB, To: pkgC, Type: artifact.OwnershipByFileOverlapRelationship},
{From: pkgC, To: pkgA, Type: artifact.OwnershipByFileOverlapRelationship},
},
wantNames: []string{"a", "b", "c"},
},
{
name: "ignores contains relationship",
target: pkgA,
relationships: []artifact.Relationship{
{From: pkgA, To: pkgB, Type: artifact.ContainsRelationship},
},
wantNames: []string{"a"},
},
{
name: "ignores dependency-of relationship",
target: pkgA,
relationships: []artifact.Relationship{
{From: pkgA, To: pkgB, Type: artifact.DependencyOfRelationship},
},
wantNames: []string{"a"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sourceSBOM := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(pkgA, pkgB, pkgC, pkgD),
},
Relationships: tt.relationships,
}
relIndex := newRelationshipIndex(sourceSBOM.Relationships...)
got := findConnectedPackages(sourceSBOM, tt.target, relIndex)
gotNames := make([]string, len(got))
for i, p := range got {
gotNames[i] = p.Name
}
// sort for comparison
assert.ElementsMatch(t, tt.wantNames, gotNames)
})
}
}
func TestClearPackageFileSystemIDs(t *testing.T) {
coord := file.Coordinates{RealPath: "/test/path", FileSystemID: "layer123"}
loc := file.NewLocationFromCoordinates(coord)
loc.AccessPath = "/test/access"
licLoc := file.NewLocationFromCoordinates(coord)
lic := pkg.License{
Value: "MIT",
Locations: file.NewLocationSet(licLoc),
}
p := pkg.Package{
Name: "test",
Version: "1.0",
Locations: file.NewLocationSet(loc),
Licenses: pkg.NewLicenseSet(lic),
}
result := clearPackageFileSystemIDs(p)
// check package locations
for _, l := range result.Locations.ToSlice() {
assert.Empty(t, l.FileSystemID, "package location FileSystemID should be empty")
assert.Equal(t, "/test/path", l.RealPath)
assert.Equal(t, "/test/access", l.AccessPath)
}
// check license locations
for _, l := range result.Licenses.ToSlice() {
for _, ll := range l.Locations.ToSlice() {
assert.Empty(t, ll.FileSystemID, "license location FileSystemID should be empty")
}
}
}
func TestPackageIDStabilityThroughSplit(t *testing.T) {
// this test verifies that package IDs remain stable through all split transformations,
// including dropping licenses, filtering locations, and clearing filesystem IDs.
// Package IDs are content-addressable and should NOT change when we modify packages
// during the split process.
// create a package with multiple locations (primary and non-primary evidence)
primaryLoc := file.NewLocation("/lib/apk/db/installed")
primaryLoc.Annotations = map[string]string{"evidence": "primary"}
nonPrimaryLoc := file.NewLocation("/some/other/path")
nonPrimaryLoc.Annotations = map[string]string{"evidence": "supporting"}
// add filesystem IDs to both
primaryLoc.Coordinates.FileSystemID = "layer123"
nonPrimaryLoc.Coordinates.FileSystemID = "layer456"
// create license with location
licLoc := file.NewLocation("/lib/apk/db/installed")
licLoc.Coordinates.FileSystemID = "layer123"
lic := pkg.License{
Value: "MIT",
Locations: file.NewLocationSet(licLoc),
}
testPkg := pkg.Package{
Name: "test-package",
Version: "1.0.0",
Type: pkg.ApkPkg,
Locations: file.NewLocationSet(primaryLoc, nonPrimaryLoc),
Licenses: pkg.NewLicenseSet(lic),
}
testPkg.SetID()
originalID := testPkg.ID()
// verify original ID is set
require.NotEmpty(t, originalID, "original package ID should be set")
// create a simple SBOM for the split operation
sourceSBOM := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(testPkg),
FileMetadata: map[file.Coordinates]file.Metadata{
primaryLoc.Coordinates: {MIMEType: "text/plain"},
nonPrimaryLoc.Coordinates: {MIMEType: "text/plain"},
},
},
Relationships: []artifact.Relationship{
{
From: testPkg,
To: primaryLoc.Coordinates,
Type: artifact.EvidentByRelationship,
},
},
Source: source.Description{
ID: "test-source",
Name: "test",
},
Descriptor: sbom.Descriptor{
Name: "syft",
Version: "test",
},
}
tests := []struct {
name string
dropLocationFSID bool
dropNonPrimaryEvidence bool
}{
{
name: "no drop options",
dropLocationFSID: false,
dropNonPrimaryEvidence: false,
},
{
name: "drop location:fsid only",
dropLocationFSID: true,
dropNonPrimaryEvidence: false,
},
{
name: "drop location:non-primary-evidence only",
dropLocationFSID: false,
dropNonPrimaryEvidence: true,
},
{
name: "drop both location options",
dropLocationFSID: true,
dropNonPrimaryEvidence: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := Split(sourceSBOM, []pkg.Package{testPkg}, tt.dropLocationFSID, tt.dropNonPrimaryEvidence)
require.Len(t, results, 1)
resultSBOM := results[0].SBOM
// verify there's exactly one package in the result
require.Equal(t, 1, resultSBOM.Artifacts.Packages.PackageCount())
// get the package from the result
var resultPkg *pkg.Package
for p := range resultSBOM.Artifacts.Packages.Enumerate() {
resultPkg = &p
break
}
require.NotNil(t, resultPkg)
// THE CRITICAL ASSERTION: package ID must remain stable
assert.Equal(t, originalID, resultPkg.ID(),
"package ID changed after split with dropLocationFSID=%v, dropNonPrimaryEvidence=%v",
tt.dropLocationFSID, tt.dropNonPrimaryEvidence)
// verify the target package ID also matches
assert.Equal(t, originalID, results[0].TargetPackage.ID(),
"target package ID changed")
// additional verification: if we dropped non-primary evidence, locations should be filtered
if tt.dropNonPrimaryEvidence {
locs := resultPkg.Locations.ToSlice()
for _, loc := range locs {
assert.Equal(t, "primary", loc.Annotations["evidence"],
"non-primary locations should be filtered out")
}
}
// additional verification: if we dropped fsid, it should be empty
if tt.dropLocationFSID {
for _, loc := range resultPkg.Locations.ToSlice() {
assert.Empty(t, loc.FileSystemID, "FileSystemID should be cleared")
}
}
})
}
}
func TestPackageIDStabilityWithDropOptions(t *testing.T) {
// this test verifies that package IDs remain stable when using ApplyDropOptions
// which includes pkg:licenses
coord := file.Coordinates{RealPath: "/test/path", FileSystemID: "layer123"}
loc := file.NewLocationFromCoordinates(coord)
loc.Annotations = map[string]string{"evidence": "primary"}
lic := pkg.License{
Value: "GPL-2.0",
Locations: file.NewLocationSet(loc),
}
testPkg := pkg.Package{
Name: "license-test-pkg",
Version: "2.0.0",
Type: pkg.ApkPkg,
Locations: file.NewLocationSet(loc),
Licenses: pkg.NewLicenseSet(lic),
Metadata: pkg.ApkDBEntry{
Package: "license-test-pkg",
Files: []pkg.ApkFileRecord{
{Path: "/usr/bin/test"},
},
},
}
testPkg.SetID()
originalID := testPkg.ID()
require.NotEmpty(t, originalID)
// create SBOM
testSBOM := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(testPkg),
},
Source: source.Description{
ID: "test",
Name: "test",
},
Descriptor: sbom.Descriptor{
Name: "syft",
Version: "test",
},
}
tests := []struct {
name string
opts []DropOption
}{
{
name: "drop pkg:licenses",
opts: []DropOption{DropPkgLicenses},
},
{
name: "drop location:fsid via ApplyDropOptions",
opts: []DropOption{DropLocationFSID},
},
{
name: "drop location:non-primary-evidence via ApplyDropOptions",
opts: []DropOption{DropLocationNonPrimaryEvidence},
},
{
name: "drop all location and pkg options",
opts: []DropOption{DropPkgLicenses, DropLocationFSID, DropLocationNonPrimaryEvidence},
},
{
name: "drop pkg:metadata.files",
opts: []DropOption{DropPkgMetadataFiles},
},
{
name: "drop all pkg options",
opts: []DropOption{DropPkgLicenses, DropPkgMetadataFiles},
},
{
name: "drop source and descriptor (should not affect package ID)",
opts: []DropOption{DropSource, DropDescriptor, DropDistro},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// make a copy of the SBOM for this test
sbomCopy := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(testPkg),
},
Source: testSBOM.Source,
Descriptor: testSBOM.Descriptor,
}
// apply drop options
ApplyDropOptions(&sbomCopy, tt.opts)
// get the package from the modified SBOM
var resultPkg *pkg.Package
for p := range sbomCopy.Artifacts.Packages.Enumerate() {
resultPkg = &p
break
}
require.NotNil(t, resultPkg)
// THE CRITICAL ASSERTION: package ID must remain stable
assert.Equal(t, originalID, resultPkg.ID(),
"package ID changed after applying drop options: %v", tt.opts)
})
}
}
@@ -125,7 +125,7 @@ func TestPkgCoverageImage(t *testing.T) {
}
func TestPkgCoverageDirectory(t *testing.T) {
sbom, _ := catalogDirectory(t, "test-fixtures/image-pkg-coverage")
sbom, _ := catalogDirectory(t, "testdata/image-pkg-coverage")
observedLanguages := strset.New()
definedLanguages := strset.New()
@@ -261,7 +261,7 @@ func TestPkgCoverageImage_HasEvidence(t *testing.T) {
}
func TestPkgCoverageDirectory_HasEvidence(t *testing.T) {
sbom, _ := catalogDirectory(t, "test-fixtures/image-pkg-coverage")
sbom, _ := catalogDirectory(t, "testdata/image-pkg-coverage")
var cases []testCase
cases = append(cases, commonTestCases...)
@@ -22,7 +22,7 @@ import (
func TestFileCataloging_Default(t *testing.T) {
cfg := options.DefaultCatalog().ToSBOMConfig(clio.Identification{})
cfg = cfg.WithFilesConfig(filecataloging.DefaultConfig())
sbom, _ := catalogDirectoryWithConfig(t, "test-fixtures/files", cfg)
sbom, _ := catalogDirectoryWithConfig(t, "testdata/files", cfg)
var metadata map[file.Coordinates]file.Metadata
@@ -48,13 +48,13 @@ func TestFileCataloging_AllFiles(t *testing.T) {
SkipFilesAboveSize: 30,
},
})
sbom, _ := catalogDirectoryWithConfig(t, "test-fixtures/files", cfg)
sbom, _ := catalogDirectoryWithConfig(t, "testdata/files", cfg)
pwd, err := os.Getwd()
require.NoError(t, err)
testPath := func(path string) string {
return filepath.Join(pwd, "test-fixtures/files", path)
return filepath.Join(pwd, "testdata/files", path)
}
metadata := map[file.Coordinates]file.Metadata{
@@ -11,7 +11,7 @@ import (
)
func TestNpmPackageLockDirectory(t *testing.T) {
sbom, _ := catalogDirectory(t, "test-fixtures/npm-lock")
sbom, _ := catalogDirectory(t, "testdata/npm-lock")
foundPackages := strset.New()
@@ -32,10 +32,11 @@ func TestNpmPackageLockDirectory(t *testing.T) {
}
func TestYarnPackageLockDirectory(t *testing.T) {
sbom, _ := catalogDirectory(t, "test-fixtures/yarn-lock")
sbom, _ := catalogDirectory(t, "testdata/yarn-lock")
foundPackages := strset.New()
expectedPackages := strset.New("async@0.9.2", "async@3.2.3", "merge-objects@1.0.5", "should-type@1.3.0", "@4lolo/resize-observer-polyfill@1.5.2")
// merge-objects and should-type are devDependencies in package.json and are excluded by default
expectedPackages := strset.New("async@0.9.2", "async@3.2.3", "@4lolo/resize-observer-polyfill@1.5.2")
for actualPkg := range sbom.Artifacts.Packages.Enumerate(pkg.NpmPkg) {
for _, actualLocation := range actualPkg.Locations.ToSlice() {
@@ -26,7 +26,7 @@ func TestBinaryElfRelationships(t *testing.T) {
}
// run the test...
sbom, _ := catalogFixtureImage(t, "elf-test-fixtures", source.SquashedScope)
sbom, _ := catalogFixtureImage(t, "elf-testdata", source.SquashedScope)
// get a mapping of package names to their IDs
nameToId := map[string]artifact.ID{}
@@ -278,7 +278,7 @@ func packageCatalogerExports(t *testing.T) map[string]exportTokenSet {
if info.IsDir() ||
!strings.HasSuffix(info.Name(), ".go") ||
strings.HasSuffix(info.Name(), "_test.go") ||
strings.Contains(path, "test-fixtures") ||
strings.Contains(path, "testdata") ||
strings.Contains(path, "internal") {
return nil
}
@@ -1 +0,0 @@
../../../../../../syft/pkg/cataloger/binary/test-fixtures/elf-test-fixtures
@@ -1 +0,0 @@
See the syft/cataloger/java/test-fixtures/java-builds dir to generate test fixtures and copy to here manually.
@@ -1,5 +1,5 @@
# we should strive to not commit blobs to the repo and strive to keep the build process of how blobs are acquired in-repo.
# this blob is generated from syft/syft/catalogers/java/test-fixtures/java-builds , however, preserving the build process
# this blob is generated from syft/syft/catalogers/java/testdata/java-builds , however, preserving the build process
# twice in the repo seems redundant (even via symlink). Given that the fixture is a few kilobytes in size, the build process is already
# captured, and integration tests should only be testing if jars can be discovered (not necessarily depth in java detection
# functionality), committing it seems like an acceptable exception.
+1
View File
@@ -0,0 +1 @@
../../../../../../syft/pkg/cataloger/binary/testdata/elf-testdata
@@ -0,0 +1 @@
See the syft/cataloger/java/testdata/java-builds dir to generate test fixtures and copy to here manually.

Some files were not shown because too many files have changed in this diff Show More