Compare commits

...
Author SHA1 Message Date
Christopher Phillips 83dfbcc24a fix(binary): require gzip identity evidence in gzip classifier
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
2026-08-13 13:35:48 -04:00
Alex Goodman 68da404bd7 fix(make): don't let ambient RACE leak into raceEnabled test (#5152)
The two "RACE is unset" cases only skipped the t.Setenv call, so they
inherited whatever RACE was in the environment. Running `make test` with
RACE=false exported job-wide flipped the CI-default case and failed.

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-08-05 10:00:17 -04:00
Alex Goodman 35bf33bcf0 fix(make): explicit integration test timeout + single race switch (#5151)
with a cold fixture cache the integration suite builds and saves 18 docker
images across 36 sequential tests, which walks past `go test`'s default 10m
timeout and takes the fixture cache rebuild down with it. that suite now runs
`go test` directly with `-timeout=30m` (gotest.Tasks() has no timeout option),
plus `-count=1` since the built fixtures are the side effect we're actually
after and a test cache hit would skip producing them.

also adds `RACE` as one switch for the race detector across every suite:

- `RACE=false make test` drops `-race` from unit + integration and skips the
  race smoke, worth doing on a cache rebuild where the wall clock is all
  docker builds anyway
- `RACE=true` forces it on locally
- unset behaves as before: on in CI, off locally and on windows

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-08-04 10:46:35 -04:00
anchore-oss-update-botandanchore-oss-update-bot 7f73d6a603 chore(deps): update CPE dictionary index (#5148)
Signed-off-by: anchore-oss-update-bot <anchore-oss-update-bot@users.noreply.github.com>
Co-authored-by: anchore-oss-update-bot <anchore-oss-update-bot@users.noreply.github.com>
2026-08-03 13:20:36 -04:00
dependabot[bot] ef40eec38d chore(deps): bump github.com/klauspost/compress from 1.19.0 to 1.19.1 (#5140)
Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.19.0 to 1.19.1.
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.19.0...v1.19.1)

---
updated-dependencies:
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:50:21 +00:00
dependabot[bot] 80212d87c2 chore(deps): bump github.com/go-git/go-billy/v5 from 5.9.0 to 5.9.1 (#5139)
Bumps [github.com/go-git/go-billy/v5](https://github.com/go-git/go-billy) from 5.9.0 to 5.9.1.
- [Release notes](https://github.com/go-git/go-billy/releases)
- [Commits](https://github.com/go-git/go-billy/compare/v5.9.0...v5.9.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-billy/v5
  dependency-version: 5.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:35:46 +00:00
dependabot[bot] b14123ecc1 chore(deps): bump github.com/magiconair/properties (#5141)
Bumps [github.com/magiconair/properties](https://github.com/magiconair/properties) from 1.8.10 to 1.18.11.
- [Release notes](https://github.com/magiconair/properties/releases)
- [Commits](https://github.com/magiconair/properties/compare/v1.8.10...v1.18.11)

---
updated-dependencies:
- dependency-name: github.com/magiconair/properties
  dependency-version: 1.18.11
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:34:20 +00:00
dependabot[bot] e00aeb16e4 chore(deps): bump github.com/ulikunitz/xz from 0.5.15 to 0.5.16 (#5142)
Bumps [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) from 0.5.15 to 0.5.16.
- [Commits](https://github.com/ulikunitz/xz/compare/v0.5.15...v0.5.16)

---
updated-dependencies:
- dependency-name: github.com/ulikunitz/xz
  dependency-version: 0.5.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:32:58 +00:00
dependabot[bot] fa6f6d70f0 chore(deps): bump modernc.org/sqlite from 1.54.0 to 1.55.0 (#5138)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.54.0 to 1.55.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.54.0...v1.55.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:29:04 +00:00
dependabot[bot] 0975d80dc0 chore(deps): bump github.com/jedib0t/go-pretty/v6 from 6.8.2 to 6.8.3 (#5137)
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.8.2 to 6.8.3.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.8.2...v6.8.3)

---
updated-dependencies:
- dependency-name: github.com/jedib0t/go-pretty/v6
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:28:49 +00:00
dependabot[bot] 4d823eaaa7 chore(deps): bump github.com/diskfs/go-diskfs from 1.9.3 to 1.9.4 (#5136)
Bumps [github.com/diskfs/go-diskfs](https://github.com/diskfs/go-diskfs) from 1.9.3 to 1.9.4.
- [Commits](https://github.com/diskfs/go-diskfs/compare/v1.9.3...v1.9.4)

---
updated-dependencies:
- dependency-name: github.com/diskfs/go-diskfs
  dependency-version: 1.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:27:56 +00:00
dependabot[bot] 138e42b428 chore(deps): bump zizmorcore/zizmor-action from 0.6.0 to 0.6.1 (#5134)
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.0 to 0.6.1.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/6599ee8b7a49aef6a770f63d261d214911a7ce02...6fc4b006235f201fdab3722e17240ab420d580e5)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:27:32 +00:00
dependabot[bot] ecd8fb7740 chore(deps): bump docker/login-action from 4.4.0 to 4.5.1 (#5135)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.5.1.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:27:08 +00:00
anchore-oss-update-botandanchore-oss-update-bot dd639c09b2 chore(deps): update tool versions (#5124)
Signed-off-by: anchore-oss-update-bot <anchore-oss-update-bot@users.noreply.github.com>
Co-authored-by: anchore-oss-update-bot <anchore-oss-update-bot@users.noreply.github.com>
2026-07-30 10:59:36 +00:00
Christopher Angelo PhillipsandAlex Goodman 31a352d030 fix(snap): release temp directories on every snap failure path
---------
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-07-29 14:50:20 -04:00
Alex Goodman 08e913a2ce Report each hardlink as its own file when scanning images (#5029)
* report each hardlink as its own file when scanning images

image scans previously collapsed a set of hardlinks onto a single file, so only
one path per inode showed up in results. dir scans report every hardlink path,
which made image vs dir SBOMs of the same filesystem diverge (and produce
different SPDX `packageVerificationCode` values for packages that own hardlinked
files).

now both image resolvers (squash and all-layers) surface each hardlink at its
own path as a regular file bound to the target's content, matching dir scans.

user-facing impact:
- SBOMs for images containing hardlinks will list more `file` entries
- SPDX `packageVerificationCode` values change for affected packages, now
  matching the equivalent `dir:` scan
- adds `file.NewVirtualLocationFromImage` to the public API

fixes #5019

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

* fix busybox test assertion

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

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-07-29 13:50:43 -04:00
dependabot[bot]andChristopher Phillips 5ef5b1badc chore(deps): bump anchore/workflows/.github/workflows/check-version-available.yaml (#5033)
Bumps [anchore/workflows/.github/workflows/check-version-available.yaml](https://github.com/anchore/workflows) from 0.7.2 to 0.8.0.
- [Release notes](https://github.com/anchore/workflows/releases)
- [Commits](https://github.com/anchore/workflows/compare/b0c30a80409130d329aaa356fd64a34d8c0b3375...7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4)

---
updated-dependencies:
- dependency-name: anchore/workflows/.github/workflows/check-version-available.yaml
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
2026-07-29 17:33:37 +00:00
dependabot[bot]andChristopher Phillips b236f8c1f7 chore(deps): bump github.com/jedib0t/go-pretty/v6 from 6.8.1 to 6.8.2 (#5051)
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.8.1 to 6.8.2.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.8.1...v6.8.2)

---
updated-dependencies:
- dependency-name: github.com/jedib0t/go-pretty/v6
  dependency-version: 6.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
2026-07-29 17:30:07 +00:00
Yashas Gunderiaandychampion 476ce60768 fix(binary): detect Deno 1.11 and 2.9 versions
Signed-off-by: ychampion <ychampion@users.noreply.github.com>
Co-authored-by: ychampion <ychampion@users.noreply.github.com>
2026-07-29 16:33:46 +00:00
27 changed files with 936 additions and 96 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ tools:
# used to upload test fixture cache
- name: oras
version:
want: v1.3.2
want: v1.3.3
method: github-release
with:
repo: oras-project/oras
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
if: ${{ github.event.inputs.phase == 'all' }}
permissions:
contents: read # required for fetching tags
uses: anchore/workflows/.github/workflows/check-version-available.yaml@b0c30a80409130d329aaa356fd64a34d8c0b3375 # v0.7.2
uses: anchore/workflows/.github/workflows/check-version-available.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0
with:
version: ${{ github.event.inputs.version }}
@@ -89,13 +89,13 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 #v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 #v4.5.1
with:
username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }}
password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }}
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 #v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 #v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: "Run zizmor"
uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
with:
# there is a pass/fail gate as a repo ruleset (if there is no ruleset configured then the action will pass by default)
advanced-security: true
+52 -9
View File
@@ -3,8 +3,10 @@ package main
import (
"path/filepath"
"runtime"
"strconv"
. "github.com/anchore/go-make"
"github.com/anchore/go-make/config"
"github.com/anchore/go-make/file"
"github.com/anchore/go-make/lang"
"github.com/anchore/go-make/run"
@@ -27,22 +29,43 @@ func main() {
gotest.Name("unit"),
gotest.ExcludeGlob("**/test/**"),
gotest.CoverageThreshold(62),
race(),
),
// integration tests: native go-make Task. The race-detector smoke against a
// real image stays bundled here (RunsOn integration) so `make integration`
// behaves like the Taskfile version did.
gotest.Tasks(
gotest.Name("integration"),
gotest.IncludeGlob("./cmd/syft/internal/test/integration/..."),
gotest.Verbose(),
gotest.NoCoverage(),
),
// integration tests: run `go test` directly instead of via gotest.Tasks(), which
// has no way to set a timeout. The suite is a single package of ~36 sequential
// tests over 18 docker fixture images, so with a cold fixture cache (every image
// built + saved inline, under -race) it runs well past `go test`'s default 10m
// timeout -- which is exactly the case when regenerating the fixture cache from
// scratch. -count=1 keeps the go test cache from short-circuiting a run whose
// side effect (the built fixtures) is the thing we're after.
//
// The race-detector smoke against a real image stays bundled here (RunsOn
// integration) so `make integration` behaves like the Taskfile version did.
Task{
Name: "integration",
Description: "run integration tests",
RunsOn: lang.List("test"),
Run: func() {
raceFlag := ""
if raceEnabled() {
raceFlag = " -race"
}
Run(
"go test -count=1 -timeout=30m -v"+raceFlag+" ./cmd/syft/internal/test/integration/...",
run.Env("GODEBUG", "dontfreezetheworld=1"),
)
},
},
Task{
Name: "integration:race-smoke",
Description: "exercise the CLI with the race detector",
RunsOn: lang.List("integration"),
Run: func() {
if !raceEnabled() {
Log("race detector disabled (RACE=false); skipping race smoke")
return
}
Run("go run -race cmd/syft/main.go anchore/test_images:grype-quality-dotnet-69f15d2")
},
},
@@ -132,6 +155,26 @@ func main() {
)
}
// raceEnabled is the single switch for the race detector across every test suite.
// Unset it and we keep go-make's behavior (on in CI, off locally and on windows);
// set RACE=false to turn it off everywhere -- worth doing when rebuilding the test
// fixture cache from scratch, where every suite is dominated by building docker
// fixtures and the race detector only adds wall clock. RACE=true forces it on.
func raceEnabled() bool {
if enabled, err := strconv.ParseBool(config.Env("RACE", "")); err == nil {
return enabled
}
return config.CI && !config.Windows
}
// race applies raceEnabled() to a gotest suite. gotest exposes no functional option
// for the race detector, but gotest.Config.Race is exported.
func race() gotest.Option {
return func(c *gotest.Config) {
c.Race = raceEnabled()
}
}
// snapshotBinPath replicates the SNAPSHOT_BIN computation from the prior Taskfile:
// <repoRoot>/snapshot/<os>-build_<os>_<arch>/syft, where arch maps amd64->amd64_v1
// and arm64->arm64_v8.0 to match goreleaser's per-target output directory naming.
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"os"
"testing"
"github.com/anchore/go-make/config"
"github.com/anchore/go-make/tasks/gotest"
)
func TestRaceEnabled(t *testing.T) {
tests := []struct {
name string
env string // "" means RACE is unset
ci bool
want bool
}{
{name: "default in CI", ci: true, want: true},
{name: "default locally", ci: false, want: false},
{name: "explicitly off in CI", env: "false", ci: true, want: false},
{name: "explicitly on locally", env: "true", ci: false, want: true},
{name: "unparseable falls back to default", env: "yes-please", ci: true, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// always go through t.Setenv (it registers the restore) so an ambient
// RACE from the caller's environment can't leak into the unset cases
t.Setenv("RACE", tt.env)
if tt.env == "" {
os.Unsetenv("RACE")
}
orig := config.CI
config.CI = tt.ci
t.Cleanup(func() { config.CI = orig })
if got := raceEnabled(); got != tt.want {
t.Errorf("raceEnabled() = %v, want %v", got, tt.want)
}
// the gotest suites get the same answer through the functional option
var cfg gotest.Config
race()(&cfg)
if cfg.Race != tt.want {
t.Errorf("race() set Race = %v, want %v", cfg.Race, tt.want)
}
})
}
}
+1
View File
@@ -125,6 +125,7 @@ tasks:
desc: Run tests for pipeline utils
cmds:
- cmd: .github/scripts/labeler_test.py
- cmd: go test -C .make ./...
snapshot-smoke-test:
desc: Run a smoke test on the snapshot builds + docker images
+7 -7
View File
@@ -34,14 +34,14 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/dave/jennifer v1.7.1
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da
github.com/diskfs/go-diskfs v1.9.3
github.com/diskfs/go-diskfs v1.9.4
github.com/distribution/reference v0.6.0
github.com/dustin/go-humanize v1.0.1
github.com/elliotchance/phpserialize v1.4.0
github.com/facebookincubator/nvdtools v0.1.5
github.com/github/go-spdx/v2 v2.7.0
github.com/gkampitakis/go-snaps v0.5.23
github.com/go-git/go-billy/v5 v5.9.0
github.com/go-git/go-billy/v5 v5.9.1
github.com/go-git/go-git/v5 v5.19.1
github.com/go-test/deep v1.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
@@ -59,11 +59,11 @@ require (
github.com/hashicorp/hcl/v2 v2.24.0
github.com/iancoleman/strcase v0.3.0
github.com/invopop/jsonschema v0.14.0
github.com/jedib0t/go-pretty/v6 v6.8.1
github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/jinzhu/copier v0.4.0
github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953
github.com/klauspost/compress v1.19.0
github.com/magiconair/properties v1.8.10
github.com/klauspost/compress v1.19.1
github.com/magiconair/properties v1.18.11
github.com/mholt/archives v0.1.5
github.com/moby/sys/mountinfo v0.7.2
github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1
@@ -83,7 +83,7 @@ require (
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.15
github.com/ulikunitz/xz v0.5.16
github.com/vbatts/go-mtree v0.7.0
github.com/vifraa/gopom v1.0.0
github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651
@@ -98,7 +98,7 @@ require (
golang.org/x/time v0.15.0
golang.org/x/tools v0.48.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.54.0
modernc.org/sqlite v1.55.0
)
require (
+14 -14
View File
@@ -333,8 +333,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da h1:ZOjWpVsFZ06eIhnh4mkaceTiVoktdU67+M7KDHJ268M=
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da/go.mod h1:B3tI9iGHi4imdLi4Asdha1Sc6feLMTfPLXh9IUYmysk=
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4=
github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM=
github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
@@ -418,8 +418,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA=
github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
@@ -624,8 +624,8 @@ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8i
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jedib0t/go-pretty/v6 v6.8.1 h1:0fkCNhjrX0zPpwkWaDYU5VMrygg41Tu197mWILIJoqQ=
github.com/jedib0t/go-pretty/v6 v6.8.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
@@ -645,8 +645,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -670,8 +670,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/magiconair/properties v1.18.11 h1:j5ozYZl0zCjG7ahMDH0GWIobOvvUzT0BdAguG0ViKy0=
github.com/magiconair/properties v1.18.11/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
@@ -947,8 +947,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
github.com/vbatts/go-mtree v0.7.0 h1:ytmOc3MTRidZiBi9VBCyZ2BHe4fZS47L5v7BVXDWW4E=
github.com/vbatts/go-mtree v0.7.0/go.mod h1:EjdpFC+LZy1TXbRGNa1MKKgjQ+7ew3foMFJK8o4/TdY=
github.com/vifraa/gopom v1.0.0 h1:L9XlKbyvid8PAIK8nr0lihMApJQg/12OBvMA28BcWh0=
@@ -1535,8 +1535,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+25
View File
@@ -152,6 +152,31 @@ func NewLocationFromImage(accessPath string, ref file.Reference, img *image.Imag
}
}
// NewVirtualLocationFromImage creates a new Location surfaced at realPath but whose contents and metadata are
// described by the given reference. This is used to present a hardlink as the underlying type it points to (at the
// hardlink's own path) so that image results are in parity with directory results, which cannot distinguish a
// hardlink from a regular file. Note that RealPath (the hardlink's own path) intentionally differs from
// ref.RealPath (the target's path); callers must not assume the two agree for these locations.
func NewVirtualLocationFromImage(realPath, accessPath string, ref file.Reference, img *image.Image) Location {
// the FileSystemID comes from the target ref's layer. a hardlink and its target are always materialized in the
// same layer tar (a tar hardlink entry can only reference a file within the same archive), so the target's layer
// digest is also the hardlink path's layer.
layer := img.FileCatalog.Layer(ref)
return Location{
LocationData: LocationData{
Coordinates: Coordinates{
RealPath: realPath,
FileSystemID: layer.Metadata.Digest,
},
AccessPath: accessPath,
ref: ref,
},
LocationMetadata: LocationMetadata{
Annotations: map[string]string{},
},
}
}
// NewLocationFromDirectory creates a new Location representing the given path (extracted from the Reference) relative to the given directory.
func NewLocationFromDirectory(responsePath string, fd string, ref file.Reference) Location {
return Location{
@@ -53,8 +53,20 @@ func (r *ContainerImageAllLayers) HasPath(path string) bool {
return false
}
func (r *ContainerImageAllLayers) fileByRef(ref stereoscopeFile.Reference, uniqueFileIDs stereoscopeFile.ReferenceSet, layerIdx int) ([]stereoscopeFile.Reference, error) {
uniqueFiles := make([]stereoscopeFile.Reference, 0)
func (r *ContainerImageAllLayers) locationsByRef(ref stereoscopeFile.Reference, accessPath string, uniqueFileIDs stereoscopeFile.ReferenceSet, layerPos int) ([]file.Location, error) {
uniqueLocations := make([]file.Location, 0)
// if the access path is itself a hardlink, surface it as the underlying type it points to, at its own path
// (bound to the target's content), so that image results are in parity with directory results (which cannot
// tell a hardlink from a regular file). the path-based lookup is required because the search that produced ref
// already followed the basename link, collapsing the hardlink onto its target.
if ownRef, targetRef, ok := r.hardLinkAtPath(accessPath, r.layers[layerPos]); ok {
if !uniqueFileIDs.Contains(ownRef) {
uniqueFileIDs.Add(ownRef)
uniqueLocations = append(uniqueLocations, file.NewVirtualLocationFromImage(string(ownRef.RealPath), accessPath, targetRef, r.img))
}
return uniqueLocations, nil
}
// since there is potentially considerable work for each symlink/hardlink that needs to be resolved, let's check to see if this is a symlink/hardlink first
entry, err := r.img.FileCatalog.Get(ref)
@@ -65,22 +77,41 @@ func (r *ContainerImageAllLayers) fileByRef(ref stereoscopeFile.Reference, uniqu
if entry.Type == stereoscopeFile.TypeHardLink || entry.Type == stereoscopeFile.TypeSymLink {
// a link may resolve in this layer or higher, assuming a squashed tree is used to search
// we should search all possible resolutions within the valid source
for _, subLayerIdx := range r.layers[layerIdx:] {
for _, subLayerIdx := range r.layers[layerPos:] {
resolvedRef, err := r.img.ResolveLinkByLayerSquash(ref, subLayerIdx)
if err != nil {
return nil, fmt.Errorf("failed to resolve link from layer (layer=%d ref=%+v): %w", subLayerIdx, ref, err)
}
if resolvedRef.HasReference() && !uniqueFileIDs.Contains(*resolvedRef.Reference) {
uniqueFileIDs.Add(*resolvedRef.Reference)
uniqueFiles = append(uniqueFiles, *resolvedRef.Reference)
uniqueLocations = append(uniqueLocations, file.NewLocationFromImage(accessPath, *resolvedRef.Reference, r.img))
}
}
} else if !uniqueFileIDs.Contains(ref) {
uniqueFileIDs.Add(ref)
uniqueFiles = append(uniqueFiles, ref)
uniqueLocations = append(uniqueLocations, file.NewLocationFromImage(accessPath, ref, r.img))
}
return uniqueFiles, nil
return uniqueLocations, nil
}
// hardLinkAtPath returns the hardlink's own reference and its resolved target reference when the basename of path is a
// hardlink within the given layer. ok is false when path does not exist there or is not a hardlink. The lookup does
// not follow the basename link so that the hardlink's own path is preserved. This adds a tree walk per
// matched ref (path x layer); if it shows up in profiles, fold the hardlink check into the existing search resolution.
func (r *ContainerImageAllLayers) hardLinkAtPath(path string, layerIdx int) (stereoscopeFile.Reference, stereoscopeFile.Reference, bool) {
var own stereoscopeFile.Reference
// use the squashed-to-layer view (not just this layer's additions) so a hardlink added in a lower layer is still
// detected when the path is searched at a higher layer; otherwise it would collapse onto its target there.
exists, resolution, err := r.img.Layers[layerIdx].SquashedTree.File(stereoscopeFile.Path(path))
if err != nil || !exists || !resolution.HasReference() {
return own, own, false
}
target, ok := r.resolveHardLinkTarget(*resolution.Reference, layerIdx)
if !ok {
return own, own, false
}
return *resolution.Reference, target, true
}
// FilesByPath returns all file.References that match the given paths from any layer in the image.
@@ -112,14 +143,13 @@ func (r *ContainerImageAllLayers) FilesByPath(paths ...string) ([]file.Location,
}
}
results, err := r.fileByRef(*ref.Reference, uniqueFileIDs, idx)
locations, err := r.locationsByRef(*ref.Reference, path, uniqueFileIDs, idx)
if err != nil {
return nil, err
}
for _, result := range results {
l := file.NewLocationFromImage(path, result, r.img)
r.annotateLocation(&l)
uniqueLocations = append(uniqueLocations, l)
for i := range locations {
r.annotateLocation(&locations[i])
uniqueLocations = append(uniqueLocations, locations[i])
}
}
}
@@ -158,14 +188,13 @@ func (r *ContainerImageAllLayers) FilesByGlob(patterns ...string) ([]file.Locati
}
}
refResults, err := r.fileByRef(*result.Reference, uniqueFileIDs, idx)
locations, err := r.locationsByRef(*result.Reference, string(result.RequestPath), uniqueFileIDs, idx)
if err != nil {
return nil, err
}
for _, refResult := range refResults {
l := file.NewLocationFromImage(string(result.RequestPath), refResult, r.img)
r.annotateLocation(&l)
uniqueLocations = append(uniqueLocations, l)
for i := range locations {
r.annotateLocation(&locations[i])
uniqueLocations = append(uniqueLocations, locations[i])
}
}
}
@@ -233,14 +262,13 @@ func (r *ContainerImageAllLayers) FilesByMIMEType(types ...string) ([]file.Locat
continue
}
refResults, err := r.fileByRef(*ref.Reference, uniqueFileIDs, idx)
locations, err := r.locationsByRef(*ref.Reference, string(ref.RequestPath), uniqueFileIDs, idx)
if err != nil {
return nil, err
}
for _, refResult := range refResults {
l := file.NewLocationFromImage(string(ref.RequestPath), refResult, r.img)
r.annotateLocation(&l)
uniqueLocations = append(uniqueLocations, l)
for i := range locations {
r.annotateLocation(&locations[i])
uniqueLocations = append(uniqueLocations, locations[i])
}
}
}
@@ -256,6 +284,11 @@ func (r *ContainerImageAllLayers) AllLocations(ctx context.Context) <-chan file.
tree := r.img.Layers[layerIdx].Tree
for _, ref := range tree.AllFiles(stereoscopeFile.AllTypes()...) {
l := file.NewLocationFromImage(string(ref.RealPath), ref, r.img)
// surface a hardlink as the underlying type it points to (at its own path) so image results match
// directory results, which cannot distinguish a hardlink from a regular file.
if targetRef, ok := r.resolveHardLinkTarget(ref, layerIdx); ok {
l = file.NewVirtualLocationFromImage(string(ref.RealPath), string(ref.RealPath), targetRef, r.img)
}
r.annotateLocation(&l)
select {
case <-ctx.Done():
@@ -273,6 +306,21 @@ func (r *ContainerImageAllLayers) FileMetadataByLocation(location file.Location)
return fileMetadataByLocation(r.img, location)
}
// resolveHardLinkTarget returns the reference of a hardlink's underlying target (resolved relative to the given layer)
// when ref is a hardlink; ok is false otherwise. No resolution is performed for non-hardlinks (symlinks keep their
// existing resolution semantics).
func (r *ContainerImageAllLayers) resolveHardLinkTarget(ref stereoscopeFile.Reference, layerIdx int) (stereoscopeFile.Reference, bool) {
metadata, err := r.img.FileCatalog.Get(ref)
if err != nil || metadata.Type != stereoscopeFile.TypeHardLink {
return ref, false
}
resolved, err := r.img.ResolveLinkByLayerSquash(ref, layerIdx)
if err != nil || !resolved.HasReference() {
return ref, false
}
return *resolved.Reference, true
}
func (r *ContainerImageAllLayers) annotateLocation(l *file.Location) {
if !r.markVisibility || l == nil {
return
@@ -282,26 +330,24 @@ func (r *ContainerImageAllLayers) annotateLocation(l *file.Location) {
annotation := file.VisibleAnnotation
// if we find a location for a path that matches the query (e.g. **/node_modules) but is not present in the squashed tree, skip it
ref, err := r.img.SquashedSearchContext.SearchByPath(l.RealPath, filetree.DoNotFollowDeadBasenameLinks)
if err != nil || !ref.HasReference() {
annotation = file.HiddenAnnotation
} else if ref.ID() != givenRef.ID() {
// we may have the path in the squashed tree, but this must not be in the same layer
if !r.pathResolvesToRef(l.RealPath, givenRef) {
annotation = file.HiddenAnnotation
}
// not only should the real path to the file exist, but the way we took to get there should also exist
// (e.g. if we are looking for /etc/passwd, but the real path is /etc/passwd -> /etc/passwd-1, then we should
// make certain that /etc/passwd-1 exists)
if annotation == file.VisibleAnnotation && l.AccessPath != "" {
ref, err := r.img.SquashedSearchContext.SearchByPath(l.AccessPath, filetree.DoNotFollowDeadBasenameLinks)
if err != nil || !ref.HasReference() {
annotation = file.HiddenAnnotation
} else if ref.ID() != givenRef.ID() {
// we may have the path in the squashed tree, but this must not be in the same layer
annotation = file.HiddenAnnotation
}
if annotation == file.VisibleAnnotation && l.AccessPath != "" && !r.pathResolvesToRef(l.AccessPath, givenRef) {
annotation = file.HiddenAnnotation
}
l.Annotations[file.VisibleAnnotationKey] = annotation
}
// pathResolvesToRef reports whether the given path in the squashed tree resolves to the given reference.
// SearchByPath always follows basename links, so a hardlink surfaced at its own path (whose reference is its
// target) still resolves to the target here and is correctly considered visible.
func (r *ContainerImageAllLayers) pathResolvesToRef(path string, target stereoscopeFile.Reference) bool {
ref, err := r.img.SquashedSearchContext.SearchByPath(path, filetree.DoNotFollowDeadBasenameLinks)
return err == nil && ref.HasReference() && ref.ID() == target.ID()
}
@@ -40,6 +40,17 @@ func (r *ContainerImageSquash) FilesByPath(paths ...string) ([]file.Location, er
uniqueLocations := make([]file.Location, 0)
for _, path := range paths {
// if the requested path is itself a hardlink, surface it at its own path (bound to its target's content)
// rather than collapsing it onto the target's path, so that image results are in parity with directory
// results (which cannot tell a hardlink from a regular file).
if ownRef, targetRef, ok := r.hardLinkAtPath(path); ok {
if !uniqueFileIDs.Contains(ownRef) {
uniqueFileIDs.Add(ownRef)
uniqueLocations = append(uniqueLocations, file.NewVirtualLocationFromImage(string(ownRef.RealPath), path, targetRef, r.img))
}
continue
}
ref, err := r.img.SquashedSearchContext.SearchByPath(path, filetree.FollowBasenameLinks)
if err != nil {
return nil, err
@@ -82,7 +93,7 @@ func (r *ContainerImageSquash) FilesByPath(paths ...string) ([]file.Location, er
//
//nolint:gocognit
func (r *ContainerImageSquash) FilesByGlob(patterns ...string) ([]file.Location, error) {
uniqueFileIDs := stereoscopeFile.NewFileReferenceSet()
uniqueCoordinates := file.NewCoordinateSet()
uniqueLocations := make([]file.Location, 0)
for _, pattern := range patterns {
@@ -116,10 +127,14 @@ func (r *ContainerImageSquash) FilesByGlob(patterns ...string) ([]file.Location,
return nil, fmt.Errorf("failed to find files by path (result=%+v): %w", result, err)
}
for _, resolvedLocation := range resolvedLocations {
if uniqueFileIDs.Contains(resolvedLocation.Reference()) {
// dedup on the surfaced coordinate rather than the underlying reference: distinct hardlinks share a
// single target reference but each has its own real path, so a reference-based dedup would collapse
// them back onto one entry (the exact behavior this parity fix removes). symlink resolutions keep
// their target's real path, so they still collapse as before.
if uniqueCoordinates.Contains(resolvedLocation.Coordinates) {
continue
}
uniqueFileIDs.Add(resolvedLocation.Reference())
uniqueCoordinates.Add(resolvedLocation.Coordinates)
uniqueLocations = append(uniqueLocations, resolvedLocation)
}
}
@@ -179,10 +194,16 @@ func (r *ContainerImageSquash) AllLocations(ctx context.Context) <-chan file.Loc
go func() {
defer close(results)
for _, ref := range r.img.SquashedTree().AllFiles(stereoscopeFile.AllTypes()...) {
loc := file.NewLocationFromImage(string(ref.RealPath), ref, r.img)
// surface a hardlink as the underlying type it points to (at its own path) so image results match
// directory results, which cannot distinguish a hardlink from a regular file.
if targetRef, ok := r.resolveHardLinkTarget(ref); ok {
loc = file.NewVirtualLocationFromImage(string(ref.RealPath), string(ref.RealPath), targetRef, r.img)
}
select {
case <-ctx.Done():
return
case results <- file.NewLocationFromImage(string(ref.RealPath), ref, r.img):
case results <- loc:
continue
}
}
@@ -190,6 +211,37 @@ func (r *ContainerImageSquash) AllLocations(ctx context.Context) <-chan file.Loc
return results
}
// hardLinkAtPath returns the hardlink's own reference and its resolved target reference when the basename of path is a
// hardlink. ok is false when path does not exist or is not a hardlink. The lookup does not follow the basename link so
// that the hardlink's own path is preserved. This adds a tree walk per FilesByPath path; if it shows up in
// profiles, fold the hardlink check into the existing SearchByPath resolution.
func (r *ContainerImageSquash) hardLinkAtPath(path string) (stereoscopeFile.Reference, stereoscopeFile.Reference, bool) {
var own stereoscopeFile.Reference
exists, resolution, err := r.img.SquashedTree().File(stereoscopeFile.Path(path))
if err != nil || !exists || !resolution.HasReference() {
return own, own, false
}
target, ok := r.resolveHardLinkTarget(*resolution.Reference)
if !ok {
return own, own, false
}
return *resolution.Reference, target, true
}
// resolveHardLinkTarget returns the reference of a hardlink's underlying target when ref is a hardlink; ok is false
// otherwise. No resolution is performed for non-hardlinks (symlinks keep their existing resolution semantics).
func (r *ContainerImageSquash) resolveHardLinkTarget(ref stereoscopeFile.Reference) (stereoscopeFile.Reference, bool) {
metadata, err := r.img.FileCatalog.Get(ref)
if err != nil || metadata.Type != stereoscopeFile.TypeHardLink {
return ref, false
}
resolved, err := r.img.ResolveLinkByImageSquash(ref)
if err != nil || !resolved.HasReference() {
return ref, false
}
return *resolved.Reference, true
}
func (r *ContainerImageSquash) FilesByMIMEType(types ...string) ([]file.Location, error) {
refs, err := r.img.SquashedSearchContext.SearchByMIMEType(types...)
if err != nil {
@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stereoscopeFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/file"
)
@@ -574,3 +575,87 @@ func TestSquashResolver_AllLocations(t *testing.T) {
assert.ElementsMatchf(t, expected, pathsList, "expected all paths to be indexed, but found different paths: \n%s", cmp.Diff(expected, paths.List()))
}
// a hardlink should be surfaced at its own path as the underlying type it points to (a regular file with the target's
// content and metadata), so that image results are in parity with directory results (which cannot distinguish a
// hardlink from a regular file). this must hold for both the squashed and all-layers resolvers.
func TestImageResolvers_Hardlinks(t *testing.T) {
img := imagetest.GetFixtureImage(t, "docker-archive", "image-hardlinks")
resolvers := map[string]file.Resolver{}
squash, err := NewFromContainerImageSquash(img)
require.NoError(t, err)
resolvers["squashed"] = squash
allLayers, err := NewFromContainerImageAllLayers(img)
require.NoError(t, err)
resolvers["all-layers"] = allLayers
const wantContents = "hardlinked contents\n"
// asserts a location surfaces the hardlink at its own path as a regular file with the target's content, and is
// not marked hidden (all-layers annotates visibility; squash leaves the annotation unset, which must not read as
// hidden either).
assertHardlink := func(t *testing.T, resolver file.Resolver, wantPath string, loc file.Location) {
t.Helper()
assert.Equal(t, wantPath, loc.RealPath, "expected the hardlink's own path, not the target's")
assert.NotEqual(t, file.HiddenAnnotation, loc.Annotations[file.VisibleAnnotationKey], "path=%s should not be hidden", wantPath)
meta, err := resolver.FileMetadataByLocation(loc)
require.NoError(t, err)
assert.Equal(t, stereoscopeFile.TypeRegular, meta.Type, "path=%s", wantPath)
reader, err := resolver.FileContentsByLocation(loc)
require.NoError(t, err)
actual, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, wantContents, string(actual), "path=%s", wantPath)
}
for name, resolver := range resolvers {
t.Run(name, func(t *testing.T) {
t.Run("FilesByPath surfaces a hardlink at its own path with the target's content", func(t *testing.T) {
for _, path := range []string{"/hardlink-a", "/hardlink-b", "/file.txt"} {
locs, err := resolver.FilesByPath(path)
require.NoError(t, err)
require.Len(t, locs, 1, "path=%s", path)
assertHardlink(t, resolver, path, locs[0])
}
})
t.Run("FilesByGlob surfaces every hardlink in a matched set, not just one", func(t *testing.T) {
// the whole point of #5019: a glob that matches multiple hardlinks to the same target must return
// all of them (each at its own path), not collapse them onto a single entry.
locs, err := resolver.FilesByGlob("**/hardlink-*")
require.NoError(t, err)
byPath := map[string]file.Location{}
for _, loc := range locs {
_, dup := byPath[loc.RealPath]
assert.Falsef(t, dup, "path %s emitted more than once", loc.RealPath)
byPath[loc.RealPath] = loc
}
for _, path := range []string{"/hardlink-a", "/hardlink-b"} {
loc, ok := byPath[path]
require.Truef(t, ok, "expected glob to surface %s", path)
assertHardlink(t, resolver, path, loc)
}
})
t.Run("AllLocations includes every hardlink path as a regular file exactly once", func(t *testing.T) {
counts := map[string]int{}
locsByPath := map[string]file.Location{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for loc := range resolver.AllLocations(ctx) {
counts[loc.RealPath]++
locsByPath[loc.RealPath] = loc
}
for _, path := range []string{"/file.txt", "/hardlink-a", "/hardlink-b"} {
require.Equalf(t, 1, counts[path], "expected %s to be reported exactly once by AllLocations", path)
assertHardlink(t, resolver, path, locsByPath[path])
}
})
})
}
}
@@ -0,0 +1,12 @@
# LAYER 0:
FROM busybox:1.34.0@sha256:e8e5cca392e3cf056fcdb3093e7ac2bf83fcf28b3bcf5818fe8ae71cf360c231
# LAYER 1: a regular file with two hardlinks to it, all created in the same layer so the layer tar contains one
# regular file entry and two hardlink (tar TypeLink) entries pointing at it.
RUN echo "hardlinked contents" > /file.txt && ln /file.txt /hardlink-a && ln /file.txt /hardlink-b
# squash representation (of the files added here; busybox base also contributes hardlinked applets under /bin)
# .
# ├── file.txt
# ├── hardlink-a (hardlink -> file.txt)
# └── hardlink-b (hardlink -> file.txt)
@@ -1499,6 +1499,28 @@ func Test_Cataloger_PositiveCases(t *testing.T) {
Metadata: metadata("deno-binary"),
},
},
{
logicalFixture: "deno/1.11.3/linux-amd64",
expected: pkg.Package{
Name: "deno",
Version: "1.11.3",
Type: "binary",
PURL: "pkg:generic/deno@1.11.3",
Locations: locations("deno"),
Metadata: metadata("deno-binary"),
},
},
{
logicalFixture: "deno/1.11.4/linux-amd64",
expected: pkg.Package{
Name: "deno",
Version: "1.11.4",
Type: "binary",
PURL: "pkg:generic/deno@1.11.4",
Locations: locations("deno"),
Metadata: metadata("deno-binary"),
},
},
{
logicalFixture: "deno/1.16.4/linux-amd64",
expected: pkg.Package{
@@ -1565,6 +1587,17 @@ func Test_Cataloger_PositiveCases(t *testing.T) {
Metadata: metadata("deno-binary"),
},
},
{
logicalFixture: "deno/2.9.2/linux-amd64",
expected: pkg.Package{
Name: "deno",
Version: "2.9.2",
Type: "binary",
PURL: "pkg:generic/deno@2.9.2",
Locations: locations("deno"),
Metadata: metadata("deno-binary"),
},
},
{
logicalFixture: "bun/0.5.9/linux-amd64",
expected: pkg.Package{
@@ -3036,8 +3069,8 @@ func Test_Cataloger_DefaultClassifiers_PositiveCases_Image(t *testing.T) {
Name: "busybox",
Version: "1.35.0",
PURL: "pkg:generic/busybox@1.35.0",
Locations: locations("/bin/["),
Metadata: metadata("busybox-binary", "/bin/[", "/bin/busybox"),
Locations: locations("/bin/busybox"),
Metadata: metadata("busybox-binary", "/bin/busybox"),
},
},
}
+15
View File
@@ -588,11 +588,20 @@ func DefaultClassifiers() []binutils.Classifier {
Class: "deno-binary",
FileGlob: "**/deno",
EvidenceMatcher: binutils.MatchAny(
m.FileContentsVersionMatcher(
// Deno/2.6.3Deno/
// Deno/1.41.0cli/
`Deno/(?P<version>[0-9]+\.[0-9]+\.[0-9]+)(Deno/|cli/)`,
),
m.FileContentsVersionMatcher(
// Deno/2.6.3
// Deno/1.41.0
`Deno/(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`,
),
m.FileContentsVersionMatcher(
// cli/tools/standalone.rsdeno-canary/f4bed1081456089559c82441a13c4fb700840cac1.11.3dlwindows
`deno-canary/[0-9a-z]{40}(?P<version>[0-9]+\.[0-9]+\.[0-9]+)`,
),
m.FileContentsVersionMatcher(
// deno::tools::standalonedeno-65db94feba9d4d51a09b74629f566dbc90484fbarelease/v1.29.4windows
// cli/tools/standalone.rsdeno-74064c9d8c222b33b2a552ea0af1054f57002a96release/v1.28.3windows
@@ -889,7 +898,13 @@ func DefaultClassifiers() []binutils.Classifier {
{
Class: "gzip-binary",
FileGlob: "**/gzip",
// GNU gzip assembles its version banner at runtime from argv[0], so the version is stored as a
// bare NUL-delimited token with nothing to anchor against. Require a gzip-specific string
// elsewhere in the file before trusting that token, otherwise any binary reachable by a path
// named "gzip" (such as the busybox multicall binary, which provides a gzip applet by symlink)
// is reported as GNU gzip with an arbitrary version.
EvidenceMatcher: m.FileContentsVersionMatcher(
`not in gzip format|bug-gzip@gnu\.org|GZIP environment variable`,
`\x00(?P<version>[0-9]+\.[0-9]+)\x00`,
),
Package: "gzip",
Binary file not shown.
@@ -0,0 +1,9 @@
name: deno
offset: 4221667
length: 120
snippetSha256: 89509d15b73fd2d901b20ebfe82c67fd27f70e7b01dcbf376e7e80e6c80f97d9
fileSha256: 530a95c93ddfa41bd4d72d665e677d5af9a4275f1520e638305e60af5083a6d6
### byte snippet to follow ###
g ctrl+d or close()
Error: Uncaught cli/tools/standalone.rsdeno-canary/f4bed1081456089559c82441a13c4fb700840cac1.11.3dlw
@@ -0,0 +1,9 @@
name: deno
offset: 4225763
length: 120
snippetSha256: b47e8cf36bdf93e4bc63a2d7bd0a5d759975f12751a66908661777eba7e90025
fileSha256: 1a55c23bb0b666d1b26d9d16f3a9d27210b6b13421031f7f18ff18abfcb8664f
### byte snippet to follow ###
g ctrl+d or close()
Error: Uncaught cli/tools/standalone.rsdeno-canary/ed16f4332263e81bf00a39ebc2200291b1fcbb8e1.11.4dlw
+24
View File
@@ -140,6 +140,22 @@ from-images:
paths:
- /usr/bin/deno
- name: deno
version: 1.11.3
images:
- ref: denoland/deno:1.11.3@sha256:340d071dc71c8403c75713694db04f59b8cb7f6c7244798cd7447d55f2750596
platform: linux/amd64
paths:
- /usr/bin/deno
- name: deno
version: 1.11.4
images:
- ref: denoland/deno:1.11.4@sha256:8e9a449a50b71c779ad1c10ec79055b20a331e78a3852818fac47bc3e85b49f4
platform: linux/amd64
paths:
- /usr/bin/deno
- name: deno
version: 1.16.4
images:
@@ -188,6 +204,14 @@ from-images:
paths:
- /usr/bin/deno
- name: deno
version: 2.9.2
images:
- ref: denoland/deno:ubuntu-2.9.2@sha256:0d27941f871700ebd95953c36fb1a817135c20f5afe3a645935672dfab72b2e9
platform: linux/amd64
paths:
- /usr/bin/deno
# haskell:7.10.3 detail information is missing.
- name: haskell-ghc
version: 7.10.3
@@ -2333,6 +2333,9 @@
"@ais-ltd/strategyen": [
"cpe:2.3:a:ais-ltd:strategyen:*:*:*:*:*:node.js:*:*"
],
"@amoy/common": [
"cpe:2.3:a:amoyjs:common:*:*:*:*:*:node.js:*:*"
],
"@angular/core": [
"cpe:2.3:a:angular:angular:*:*:*:*:*:node.js:*:*",
"cpe:2.3:a:angularjs:angularjs:*:*:*:*:*:*:*:*"
@@ -4252,6 +4255,9 @@
"json8-merge-patch": [
"cpe:2.3:a:json8-merge-patch_project:json8-merge-patch:*:*:*:*:*:node.js:*:*"
],
"jsonpath": [
"cpe:2.3:a:dchester:jsonpath:*:*:*:*:*:node.js:*:*"
],
"jsonpointer": [
"cpe:2.3:a:janl:jsonpointer:*:*:*:*:*:node.js:*:*"
],
@@ -6858,6 +6864,9 @@
"pydantic-ai": [
"cpe:2.3:a:pydantic:pydantic_ai:*:*:*:*:*:python:*:*"
],
"pydantic-settings": [
"cpe:2.3:a:pydantic:pydantic-settings:*:*:*:*:*:*:*:*"
],
"pydash": [
"cpe:2.3:a:derrickgilland:pydash:*:*:*:*:*:python:*:*"
],
@@ -7208,6 +7217,9 @@
"encoded_id-rails": [
"cpe:2.3:a:diaconou:encodedid\\:\\:rails:*:*:*:*:*:ruby:*:*"
],
"excon": [
"cpe:2.3:a:excon_project:excon:*:*:*:*:*:ruby:*:*"
],
"faye": [
"cpe:2.3:a:faye_project:faye:*:*:*:*:*:ruby:*:*"
],
@@ -7615,6 +7627,9 @@
"ammonia": [
"cpe:2.3:a:ammonia_project:ammonia:*:*:*:*:*:rust:*:*"
],
"anchor-lang": [
"cpe:2.3:a:anchor-lang:anchor:*:*:*:*:*:rust:*:*"
],
"anymap": [
"cpe:2.3:a:anymap_project:anymap:*:*:*:*:*:rust:*:*"
],
@@ -7984,6 +7999,9 @@
"one2html": [
"cpe:2.3:a:msiemens:one2html:*:*:*:*:*:rust:*:*"
],
"openssl": [
"cpe:2.3:a:sfackler:openssl:*:*:*:*:*:rust:*:*"
],
"outer_cgi": [
"cpe:2.3:a:outer_cgi_project:outer_cgi:*:*:*:*:*:rust:*:*"
],
@@ -8883,6 +8901,9 @@
"album-and-image-gallery-plus-lightbox": [
"cpe:2.3:a:essentialplugin:album_and_image_gallery_plus_lightbox:*:*:*:*:free:wordpress:*:*"
],
"alemha-watermark": [
"cpe:2.3:a:mnbaa:alemha_watermark:*:*:*:*:*:wordpress:*:*"
],
"alfred-click-collect": [
"cpe:2.3:a:alfred24_click_\\\u0026_collect_project:alfred24_click_\\\u0026_collect:*:*:*:*:*:wordpress:*:*"
],
@@ -9510,6 +9531,9 @@
"barcode-scanner-lite-pos-to-manage-products-inventory-and-orders": [
"cpe:2.3:a:ukrsolution:barcode_scanner_and_inventory_manager:*:*:*:*:*:wordpress:*:*"
],
"base64-encoderdecoder": [
"cpe:2.3:a:mranderson:base64_encoder\\/decoder:*:*:*:*:*:wordpress:*:*"
],
"basic-interactive-world-map": [
"cpe:2.3:a:wpmapplugins:basic_interactive_world_map:*:*:*:*:*:wordpress:*:*"
],
@@ -9964,6 +9988,9 @@
"bsuite": [
"cpe:2.3:a:bsuite_project:bsuite:*:*:*:*:*:wordpress:*:*"
],
"bu-section-editing": [
"cpe:2.3:a:bu:bu_section_editing:*:*:*:*:*:wordpress:*:*"
],
"bubble-menu": [
"cpe:2.3:a:wow-company:bubble_menu:*:*:*:*:free:wordpress:*:*"
],
@@ -11688,6 +11715,9 @@
"donation-thermometer": [
"cpe:2.3:a:donation_thermometer_project:donation_thermometer:*:*:*:*:*:wordpress:*:*"
],
"donations-block": [
"cpe:2.3:a:bharatkambariya:donation_block_for_paypal:*:*:*:*:*:wordpress:*:*"
],
"donations-for-woocommerce": [
"cpe:2.3:a:wpzone:potent_donations_for_woocommerce:*:*:*:*:*:wordpress:*:*"
],
@@ -11775,6 +11805,9 @@
"dtabs": [
"cpe:2.3:a:dtabs_project:dtabs:*:*:*:*:*:wordpress:*:*"
],
"dts-simple-share": [
"cpe:2.3:a:missionmike:simple_share:*:*:*:*:*:wordpress:*:*"
],
"duitku-social-payment-gateway": [
"cpe:2.3:a:duitku:duitku_payment_gateway:*:*:*:*:*:wordpress:*:*"
],
@@ -12763,6 +12796,9 @@
"five-minute-webshop": [
"cpe:2.3:a:five_minute_webshop_project:five_minute_webshop:*:*:*:*:*:wordpress:*:*"
],
"fl3r-accessibility-suite": [
"cpe:2.3:a:fl3r:fl3r_accessibility_suite:*:*:*:*:*:wordpress:*:*"
],
"fl3r-feelbox": [
"cpe:2.3:a:armandofiore:fl3r_feelbox:*:*:*:*:*:wordpress:*:*",
"cpe:2.3:a:fl3r-feelbox_project:fl3r-feelbox:*:*:*:*:*:wordpress:*:*"
@@ -13280,6 +13316,9 @@
"gf-block-ips": [
"cpe:2.3:a:brightplugins:block_ips_for_gravity_forms:*:*:*:*:*:wordpress:*:*"
],
"gf-constant-contact": [
"cpe:2.3:a:rocketgenius:constant_contact:*:*:*:*:*:wordpress:*:*"
],
"gf-custom-style": [
"cpe:2.3:a:alefypimentel:gf_custom_style:*:*:*:*:*:wordpress:*:*"
],
@@ -15469,6 +15508,9 @@
"mihdan-index-now": [
"cpe:2.3:a:kobzarev:index_now:*:*:*:*:*:wordpress:*:*"
],
"mini-course-generator": [
"cpe:2.3:a:minicoursegenerator:mini_course_generator:*:*:*:*:*:wordpress:*:*"
],
"mini-mail-dashboard-widget": [
"cpe:2.3:a:mini_mail_dashboard_widget_project:mini_mail_dashboard_widget:*:*:*:*:*:wordpress:*:*"
],
@@ -15502,6 +15544,9 @@
"ml-slider": [
"cpe:2.3:a:metaslider:slider\\,_gallery\\,_and_carousel:*:*:*:*:*:wordpress:*:*"
],
"mm-breaking-news": [
"cpe:2.3:a:mm-breaking_news_project:mm-breaking_news:*:*:*:*:*:wordpress:*:*"
],
"mmm-file-list": [
"cpe:2.3:a:mediamanifesto:mmm_simple_file_list:*:*:*:*:*:wordpress:*:*"
],
@@ -15588,6 +15633,9 @@
"mortgage-calculators-wp": [
"cpe:2.3:a:lenderd:mortgage_calculators_wp:*:*:*:*:*:wordpress:*:*"
],
"mortgage-loan-calculator": [
"cpe:2.3:a:mlcalc:mortgage_loan_calculator:*:*:*:*:*:wordpress:*:*"
],
"most-and-least-read-posts-widget": [
"cpe:2.3:a:whiletrue:most_and_least_read_posts_widget:*:*:*:*:*:wordpress:*:*"
],
@@ -16081,6 +16129,9 @@
"official-sendle-shipping-method": [
"cpe:2.3:a:joovii:sendle_shipping:*:*:*:*:*:wordpress:*:*"
],
"offload-videos-bunny-netaws-s3": [
"cpe:2.3:a:webgarh:offload_videos:*:*:*:*:*:wordpress:*:*"
],
"og-tags": [
"cpe:2.3:a:og_tags_project:og_tags:*:*:*:*:*:wordpress:*:*"
],
@@ -17247,7 +17298,10 @@
"cpe:2.3:a:wepupil:quiz_expert_-_easy_quiz_maker\\,_exam_and_test_manager:*:*:*:*:*:wordpress:*:*"
],
"quiz-maker": [
"cpe:2.3:a:ays-pro:quiz_maker:*:*:*:*:*:wordpress:*:*"
"cpe:2.3:a:ays-pro:quiz_maker:*:*:*:*:*:wordpress:*:*",
"cpe:2.3:a:ays-pro:quiz_maker:*:*:*:*:agency:wordpress:*:*",
"cpe:2.3:a:ays-pro:quiz_maker:*:*:*:*:business:wordpress:*:*",
"cpe:2.3:a:ays-pro:quiz_maker:*:*:*:*:developer:wordpress:*:*"
],
"quiz-master-next": [
"cpe:2.3:a:expresstech:quiz_and_survey_master:*:*:*:*:*:wordpress:*:*",
@@ -17825,6 +17879,9 @@
"schema-app-structured-data-for-schemaorg": [
"cpe:2.3:a:schemaapp:schema_app_structured_data:*:*:*:*:*:wordpress:*:*"
],
"school-management": [
"cpe:2.3:a:dasinfomedia:school_management_system:*:*:*:*:*:wordpress:*:*"
],
"school-management-system": [
"cpe:2.3:a:weblizar:school_management_-_education_\\\u0026_learning_management:*:*:*:*:*:wordpress:*:*"
],
@@ -18209,6 +18266,9 @@
"simple-author-box": [
"cpe:2.3:a:webfactoryltd:simple_author_box:*:*:*:*:*:wordpress:*:*"
],
"simple-backup": [
"cpe:2.3:a:mywebsiteadvisor:simple_backup:*:*:*:*:*:wordpress:*:*"
],
"simple-banner": [
"cpe:2.3:a:simple_banner_project:simple_banner:*:*:*:*:*:wordpress:*:*"
],
@@ -18991,6 +19051,9 @@
"srbtranslatin": [
"cpe:2.3:a:srbtranslatin_project:srbtranslatin:*:*:*:*:*:wordpress:*:*"
],
"ssv-events": [
"cpe:2.3:a:moridrin:ssv_events:*:*:*:*:*:wordpress:*:*"
],
"st-daily-tip": [
"cpe:2.3:a:sanskruti:st-daily-tip:*:*:*:*:*:wordpress:*:*"
],
@@ -19105,6 +19168,9 @@
"strategery-migrations": [
"cpe:2.3:a:strategery_migrations_project:strategery_migrations:*:*:*:*:*:wordpress:*:*"
],
"stratum": [
"cpe:2.3:a:motopress:stratum:*:*:*:*:*:wordpress:*:*"
],
"stray-quotes": [
"cpe:2.3:a:unalignedcode:stray_random_quotes:*:*:*:*:*:wordpress:*:*"
],
@@ -19135,6 +19201,9 @@
"subaccounts-for-woocommerce": [
"cpe:2.3:a:mediaticus:subaccounts_for_woocommerce:*:*:*:*:*:wordpress:*:*"
],
"sublanguage": [
"cpe:2.3:a:maximeschoeni:sublanguage:*:*:*:*:*:wordpress:*:*"
],
"subscribe-sidebar": [
"cpe:2.3:a:blubrry:subscribe_sidebar:*:*:*:*:*:wordpress:*:*"
],
@@ -19571,12 +19640,18 @@
"timetics": [
"cpe:2.3:a:arraytics:wp_timetics:*:*:*:*:*:wordpress:*:*"
],
"timezonecalculator": [
"cpe:2.3:a:bernhard-riedl:timezonecalculator:*:*:*:*:*:wordpress:*:*"
],
"timthumb-vulnerability-scanner": [
"cpe:2.3:a:peterbutler:timthumb_vulnerability_scanner:*:*:*:*:*:wordpress:*:*"
],
"tinychat-roomspy": [
"cpe:2.3:a:tinychat:room_spy:*:*:*:*:*:wordpress:*:*"
],
"tinymce-and-tinymce-advanced-professsional-formats-and-styles": [
"cpe:2.3:a:blackbam:tinymce_and_tinymce_advanced_professsional_formats_and_styles:*:*:*:*:*:wordpress:*:*"
],
"tinymce-custom-styles": [
"cpe:2.3:a:tinymce_custom_styles_project:tinymce_custom_styles:*:*:*:*:*:wordpress:*:*"
],
@@ -19645,6 +19720,9 @@
"tournamatch": [
"cpe:2.3:a:tournamatch:tournamatch:*:*:*:*:*:wordpress:*:*"
],
"tournament-bracket-generator": [
"cpe:2.3:a:blakelong:tournament_bracket_generator:*:*:*:*:*:wordpress:*:*"
],
"tp-education": [
"cpe:2.3:a:themepalace:tp_education:*:*:*:*:*:wordpress:*:*"
],
@@ -20412,6 +20490,9 @@
"weaverx-theme-support": [
"cpe:2.3:a:weavertheme:weaver_xtreme_theme_support:*:*:*:*:*:wordpress:*:*"
],
"web-application-firewall": [
"cpe:2.3:a:miniorange:web_application_firewall:*:*:*:*:*:wordpress:*:*"
],
"web-directory-free": [
"cpe:2.3:a:salephpscripts:web_directory_free:*:*:*:*:*:wordpress:*:*"
],
@@ -20941,6 +21022,9 @@
"wooemailreport": [
"cpe:2.3:a:eggemplo:woocommerce_email_report:*:*:*:*:*:wordpress:*:*"
],
"wooexim": [
"cpe:2.3:a:bulktheme:wooexim:*:*:*:*:*:wordpress:*:*"
],
"wooframework-tweaks": [
"cpe:2.3:a:woocommerce:wooframework_tweaks:*:*:*:*:*:wordpress:*:*"
],
@@ -21754,6 +21838,9 @@
"wp-jobs": [
"cpe:2.3:a:intensewp:wp_jobs:*:*:*:*:*:wordpress:*:*"
],
"wp-jobs2careers": [
"cpe:2.3:a:skipthedrive:wp_talroo:*:*:*:*:*:wordpress:*:*"
],
"wp-jquery-lightbox": [
"cpe:2.3:a:lightpress:lightbox:*:*:*:*:*:wordpress:*:*"
],
@@ -22730,7 +22817,8 @@
"cpe:2.3:a:maennchen1:wpshopgermany_-_protected_shops:*:*:*:*:*:wordpress:*:*"
],
"wpshopify": [
"cpe:2.3:a:shopwp_project:shopwp:*:*:*:*:*:wordpress:*:*"
"cpe:2.3:a:shopwp_project:shopwp:*:*:*:*:*:wordpress:*:*",
"cpe:2.3:a:wpshop:shopwp:*:*:*:*:*:wordpress:*:*"
],
"wpsimpletools-log-viewer": [
"cpe:2.3:a:wpsimpletools:basic_log_viewer:*:*:*:*:*:wordpress:*:*"
@@ -23504,6 +23592,9 @@
"tijaji": [
"cpe:2.3:a:tijaji:tijaji:*:*:*:*:*:wordpress:*:*"
],
"total": [
"cpe:2.3:a:hashthemes:total:*:*:*:*:*:wordpress:*:*"
],
"transcend": [
"cpe:2.3:a:cpothemes:transcend:*:*:*:*:*:wordpress:*:*"
],
+10 -5
View File
@@ -1,6 +1,7 @@
package snapsource
import (
"errors"
"fmt"
"github.com/goccy/go-yaml"
@@ -21,6 +22,11 @@ type snapManifest struct {
const manifestLocation = "/meta/snap.yaml"
// errNoManifest means the payload is a readable squashfs that simply does not carry a snap manifest.
// This is distinct from a manifest that is present but unusable, and callers are expected to treat
// the two differently.
var errNoManifest = errors.New("no snap manifest file found")
func parseManifest(resolver file.Resolver) (*snapManifest, error) {
locations, err := resolver.FilesByPath(manifestLocation)
if err != nil {
@@ -28,7 +34,7 @@ func parseManifest(resolver file.Resolver) (*snapManifest, error) {
}
if len(locations) == 0 {
return nil, fmt.Errorf("no snap manifest file found")
return nil, errNoManifest
}
if len(locations) > 1 {
@@ -48,9 +54,8 @@ func parseManifest(resolver file.Resolver) (*snapManifest, error) {
return nil, fmt.Errorf("unable to decode snap manifest file: %w", err)
}
if manifest.Name == "" || manifest.Version == "" {
return nil, fmt.Errorf("invalid snap manifest file: missing name or version")
}
// note: name and version are deliberately not validated here. a manifest that decoded but is
// missing them still carries usable fields (summary, base, grade, ...) and throwing it away would
// describe less than we know. the caller surfaces the missing identity.
return &manifest, nil
}
+16 -5
View File
@@ -77,22 +77,33 @@ func getRemoteSnapFile(ctx context.Context, fs afero.Fs, getter intFile.Getter,
return newSnapFileFromRemote(ctx, fs, cfg, getter, info)
}
func newSnapFileFromRemote(ctx context.Context, fs afero.Fs, cfg Config, getter intFile.Getter, info *remoteSnap) (*snapFile, error) {
func newSnapFileFromRemote(ctx context.Context, fs afero.Fs, cfg Config, getter intFile.Getter, info *remoteSnap) (_ *snapFile, err error) {
t, err := afero.TempDir(fs, "", "syft-snap-")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
closer := func() error {
return fs.RemoveAll(t)
}
// any failure past this point must not leave the temp directory (or a partially downloaded snap)
// behind. On success the caller owns the directory by way of snapFile.Cleanup.
defer func() {
if err == nil {
return
}
if closeErr := closer(); closeErr != nil {
log.WithFields("error", closeErr, "directory", t).Warn("unable to remove temp directory for snap")
}
}()
snapFilePath := path.Join(t, path.Base(info.URL))
err = downloadSnap(getter, info, snapFilePath)
if err != nil {
return nil, fmt.Errorf("failed to download snap file: %w", err)
}
closer := func() error {
return fs.RemoveAll(t)
}
mimeType, digests, err := getSnapFileInfo(ctx, fs, snapFilePath, cfg.DigestAlgorithms)
if err != nil {
return nil, err
+46 -9
View File
@@ -3,6 +3,7 @@ package snapsource
import (
"context"
"crypto"
"errors"
"fmt"
"io"
"os"
@@ -119,7 +120,21 @@ func newFromPath(cfg Config, f *snapFile) (source.Source, error) {
closer: f.Cleanup,
}
return s, s.extractManifest()
if err := s.extractManifest(); err != nil {
// It is tried on any path ending in .snap or .squashfs, so declining one is routine and
// a later provider usually succeeds. When the user asked for --from snap there is no
// fallback and this error reaches them intact.
log.WithFields("error", err, "path", f.Path).Debug("unable to read snap")
// never hand back a source alongside an error: callers that discard the source on error would
// leave behind the open squashfs file and the temp directory holding a downloaded snap
if closeErr := s.Close(); closeErr != nil {
log.WithFields("error", closeErr, "path", f.Path).Warn("unable to clean up snap source")
}
return nil, err
}
return s, nil
}
func (s *snapSource) extractManifest() error {
@@ -128,13 +143,26 @@ func (s *snapSource) extractManifest() error {
return fmt.Errorf("unable to create snap file resolver: %w", err)
}
// a manifest problem must never fail the source. this source is the only provider that can descend
// into squashfs (filesource cannot), so returning an error here would fall through to a provider
// that reports zero packages for a payload that has plenty. describe what we can instead.
manifest, err := parseManifest(r)
if err != nil {
return fmt.Errorf("unable to parse snap manifest file: %w", err)
switch {
case errors.Is(err, errNoManifest):
// not every squashfs payload is a snap, and one without a manifest is still worth cataloging
log.WithFields("path", s.squashfsPath).Debug("no snap manifest file found")
return nil
case err != nil:
// the manifest is there but unusable, which is worth surfacing: the SBOM will have no source
// name or version to show for it
log.WithFields("error", err, "path", s.squashfsPath).Warn("unable to parse snap manifest file")
return nil
}
if manifest != nil {
s.manifest = *manifest
s.manifest = *manifest
if s.manifest.Name == "" || s.manifest.Version == "" {
log.WithFields("path", s.squashfsPath).Warn("snap manifest is missing name or version")
}
return nil
}
@@ -176,25 +204,34 @@ func (s snapSource) Describe() source.Description {
}
}
// Close releases everything the source holds. Every step runs even if an earlier one fails, since
// bailing early would skip the temp directory removal and leave a downloaded snap on disk. Safe to
// call more than once.
func (s *snapSource) Close() error {
s.mutex.Lock()
defer s.mutex.Unlock()
var errs error
if s.squashFileCloser != nil {
if err := s.squashFileCloser(); err != nil {
return fmt.Errorf("unable to close snap resolver: %w", err)
errs = errors.Join(errs, fmt.Errorf("unable to close snap resolver: %w", err))
}
s.squashFileCloser = nil
}
s.resolver = nil
if s.fs != nil {
if err := s.fs.Close(); err != nil {
return fmt.Errorf("unable to close snap squashfs: %w", err)
errs = errors.Join(errs, fmt.Errorf("unable to close snap squashfs: %w", err))
}
s.fs = nil
}
if s.closer != nil {
if err := s.closer(); err != nil {
return fmt.Errorf("unable to close snap source: %w", err)
errs = errors.Join(errs, fmt.Errorf("unable to close snap source: %w", err))
}
s.closer = nil
}
return nil
return errs
}
func (s *snapSource) FileResolver(_ source.Scope) (file.Resolver, error) {
+210
View File
@@ -2,15 +2,23 @@ package snapsource
import (
"crypto"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
diskFile "github.com/diskfs/go-diskfs/backend/file"
"github.com/diskfs/go-diskfs/filesystem/squashfs"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/go-homedir"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/syft/source"
)
func TestNewFromLocal(t *testing.T) {
@@ -84,3 +92,205 @@ func TestNewFromLocal(t *testing.T) {
})
}
}
func TestNewFromPathOnResolverError(t *testing.T) {
tests := []struct {
name string
// remote snaps carry a cleanup closure for the temp directory holding the download; local snaps
// have none, since the file belongs to the user
remote bool
wantCleanupRuns int
wantFileRemoved bool
}{
{
name: "downloaded snap is cleaned up",
remote: true,
wantCleanupRuns: 1,
wantFileRemoved: true,
},
{
name: "local snap file is left in place",
remote: false,
wantCleanupRuns: 0,
wantFileRemoved: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// the resolver reads the squashfs from the real filesystem, so the payload must live on disk
tempDir := t.TempDir()
snapPath := filepath.Join(tempDir, "not-really-a-snap.snap")
require.NoError(t, os.WriteFile(snapPath, []byte("not squashfs"), 0600))
var cleanupRuns int
f := &snapFile{
Path: snapPath,
MimeType: "text/plain",
}
if tt.remote {
f.Cleanup = func() error {
cleanupRuns++
return os.RemoveAll(tempDir)
}
}
got, err := newFromPath(Config{Request: snapPath}, f)
require.Error(t, err)
assert.Contains(t, err.Error(), "unable to create snap file resolver")
// a source returned alongside an error is discarded by callers without being closed, which
// would leave the downloaded snap behind
assert.Nil(t, got, "expected nil source on error")
assert.Equal(t, tt.wantCleanupRuns, cleanupRuns)
_, statErr := os.Stat(snapPath)
if tt.wantFileRemoved {
assert.True(t, os.IsNotExist(statErr), "snap file was not cleaned up")
} else {
assert.NoError(t, statErr, "the user's snap file must never be removed")
}
})
}
}
// a manifest problem must never fail the source: snapsource is the only provider that can descend
// into squashfs, so an error here falls through to filesource and every package inside the payload
// silently disappears from the SBOM.
func TestNewFromPathToleratesManifestProblems(t *testing.T) {
tests := []struct {
name string
manifest string // written to /meta/snap.yaml; empty means no manifest at all
wantName string
wantVersion string
wantSummary string
}{
{
name: "no manifest at all",
manifest: "",
},
{
name: "manifest is not valid yaml",
manifest: "name: etcd\n\tversion: 3.4\n",
},
{
name: "manifest is missing name and version",
manifest: "summary: a thing\nbase: core22\n",
wantSummary: "a thing",
},
{
name: "manifest is complete",
manifest: "name: etcd\nversion: 3.4.0\nsummary: a thing\n",
wantName: "etcd",
wantVersion: "3.4.0",
wantSummary: "a thing",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
snapPath := writeSquashfs(t, tt.manifest)
src, err := newFromPath(Config{Request: snapPath}, &snapFile{Path: snapPath})
require.NoError(t, err, "a manifest problem must not fail the source")
require.NotNil(t, src)
t.Cleanup(func() { _ = src.Close() })
desc := src.Describe()
assert.Equal(t, tt.wantName, desc.Name)
assert.Equal(t, tt.wantVersion, desc.Version)
meta, ok := desc.Metadata.(source.SnapMetadata)
require.True(t, ok, "expected snap metadata")
assert.Equal(t, tt.wantSummary, meta.Summary, "fields that did decode must survive")
// the whole point: the payload is still walkable, so cataloging sees what is inside
r, err := src.FileResolver(source.SquashedScope)
require.NoError(t, err)
locations, err := r.FilesByPath("/payload.txt")
require.NoError(t, err)
assert.Len(t, locations, 1, "contents must remain catalogable regardless of the manifest")
})
}
}
func TestSnapSourceClose(t *testing.T) {
newSource := func(closeErr, cleanupErr error) (*snapSource, *int) {
var cleanupRuns int
return &snapSource{
mutex: &sync.Mutex{},
squashFileCloser: func() error {
return closeErr
},
closer: func() error {
cleanupRuns++
return cleanupErr
},
}, &cleanupRuns
}
t.Run("temp directory is removed even when closing the file handle fails", func(t *testing.T) {
s, cleanupRuns := newSource(errors.New("handle is busy"), nil)
err := s.Close()
// the whole point: a failure here used to return early and skip the cleanup below, leaving a
// downloaded snap on disk
require.Error(t, err)
assert.Contains(t, err.Error(), "handle is busy")
assert.Equal(t, 1, *cleanupRuns, "temp directory removal was skipped")
})
t.Run("every failure is reported, not just the first", func(t *testing.T) {
s, _ := newSource(errors.New("handle is busy"), errors.New("directory is busy"))
err := s.Close()
require.Error(t, err)
assert.Contains(t, err.Error(), "handle is busy")
assert.Contains(t, err.Error(), "directory is busy")
})
t.Run("is safe to call twice", func(t *testing.T) {
s, cleanupRuns := newSource(nil, nil)
require.NoError(t, s.Close())
require.NoError(t, s.Close())
assert.Equal(t, 1, *cleanupRuns, "cleanup must not run again on a second close")
})
}
// writeSquashfs builds a real squashfs image containing /payload.txt and, when manifest is non-empty,
// /meta/snap.yaml. Returns the path to the image.
func writeSquashfs(t *testing.T, manifest string) string {
t.Helper()
snapPath := filepath.Join(t.TempDir(), "test.snap")
f, err := os.Create(snapPath)
require.NoError(t, err)
defer func() { require.NoError(t, f.Close()) }()
fs, err := squashfs.Create(diskFile.New(f, false), 0, 0, 4096)
require.NoError(t, err)
write := func(path, contents string) {
t.Helper()
w, err := fs.OpenFile(path, os.O_CREATE|os.O_RDWR)
require.NoError(t, err)
_, err = w.Write([]byte(contents))
require.NoError(t, err)
}
// note: go-diskfs wants paths relative to the image root, without a leading slash
require.NoError(t, fs.Mkdir("."))
write("payload.txt", "hello")
if manifest != "" {
require.NoError(t, fs.Mkdir("meta"))
write(strings.TrimPrefix(manifestLocation, "/"), manifest)
}
require.NoError(t, fs.Finalize(squashfs.FinalizeOptions{}))
return snapPath
}
+83
View File
@@ -264,6 +264,10 @@ func TestNewSnapFileFromRemote(t *testing.T) {
_, err = fs.Stat(result.Path)
assert.True(t, os.IsNotExist(err))
// cleanup owns the whole temp directory, not just the snap file within it
_, err = fs.Stat(filepath.Dir(result.Path))
assert.True(t, os.IsNotExist(err), "temp directory outlived the cleanup closure")
},
},
{
@@ -315,6 +319,75 @@ func TestNewSnapFileFromRemote(t *testing.T) {
expectError: true,
errorMsg: "failed to download snap file",
},
{
name: "download writes partial data then fails",
cfg: Config{
DigestAlgorithms: []crypto.Hash{crypto.SHA256},
},
info: &remoteSnap{
snapIdentity: snapIdentity{
Name: "partial-snap",
Channel: "stable",
Architecture: "amd64",
},
URL: "https://api.snapcraft.io/download/partial_snap.snap",
},
setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/partial_snap.snap", mock.Anything).Run(func(args mock.Arguments) {
// simulate a transfer that writes some bytes before the connection drops
require.NoError(t, createMockSquashfsFile(fs, args.String(0)))
}).Return(fmt.Errorf("connection reset by peer"))
},
expectError: true,
errorMsg: "failed to download snap file",
},
{
// isSquashFSFile trusts the .snap extension, so non-squashfs content under that name is accepted
name: "downloaded file has a snap extension but non-snap content",
cfg: Config{
DigestAlgorithms: []crypto.Hash{crypto.SHA256},
},
info: &remoteSnap{
snapIdentity: snapIdentity{
Name: "not-a-snap",
Channel: "stable",
Architecture: "amd64",
},
URL: "https://api.snapcraft.io/download/not_a_snap.snap",
},
setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/not_a_snap.snap", mock.Anything).Run(func(args mock.Arguments) {
require.NoError(t, afero.WriteFile(fs, args.String(0), []byte("not squashfs"), 0644))
}).Return(nil)
},
expectError: false,
validate: func(t *testing.T, result *snapFile, fs afero.Fs) {
assert.NotNil(t, result)
assert.Contains(t, result.Path, "not_a_snap.snap")
assert.NotNil(t, result.Cleanup)
},
},
{
name: "downloaded file is not a snap",
cfg: Config{
DigestAlgorithms: []crypto.Hash{crypto.SHA256},
},
info: &remoteSnap{
snapIdentity: snapIdentity{
Name: "not-a-snap",
Channel: "stable",
Architecture: "amd64",
},
URL: "https://api.snapcraft.io/download/not_a_snap.tar.gz",
},
setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/not_a_snap.tar.gz", mock.Anything).Run(func(args mock.Arguments) {
require.NoError(t, afero.WriteFile(fs, args.String(0), []byte("not squashfs"), 0644))
}).Return(nil)
},
expectError: true,
errorMsg: "not a valid squashfs/snap file",
},
}
for _, tt := range tests {
@@ -334,8 +407,18 @@ func TestNewSnapFileFromRemote(t *testing.T) {
assert.Contains(t, err.Error(), tt.errorMsg)
}
assert.Nil(t, result)
// the temp directory and any partially downloaded payload must not be left behind. The
// download destination handed to the getter lives directly under the temp directory.
require.Len(t, mockGetter.Calls, 1)
tempDir := filepath.Dir(mockGetter.Calls[0].Arguments.String(0))
_, statErr := fs.Stat(tempDir)
assert.True(t, os.IsNotExist(statErr), "temp directory %q was not cleaned up after failure", tempDir)
} else {
require.NoError(t, err)
if result.Cleanup != nil {
t.Cleanup(func() { _ = result.Cleanup() })
}
if tt.validate != nil {
tt.validate(t, result, fs)
}