diff --git a/.binny.yaml b/.binny.yaml index 115e63729..da8e9cab2 100644 --- a/.binny.yaml +++ b/.binny.yaml @@ -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 @@ -24,7 +24,7 @@ tools: # used to parse JSON/YAML annotations on the fixture cache image - name: yq version: - want: v4.53.2 + want: v4.53.3 method: github-release with: repo: mikefarah/yq diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d93313ed3..8218feecf 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,22 +21,27 @@ on: options: - "all" - "install-script-only" + skip-checks: + description: skip the check-gate (release even if checks haven't passed on main) + type: boolean + default: false + required: false jobs: version-available: if: ${{ github.event.inputs.phase == 'all' }} permissions: contents: read # required for fetching tags - uses: anchore/workflows/.github/workflows/check-version-available.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + uses: anchore/workflows/.github/workflows/check-version-available.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0 with: version: ${{ github.event.inputs.version }} check-gate: - if: ${{ github.event.inputs.phase == 'all' }} + if: ${{ github.event.inputs.phase == 'all' && !inputs.skip-checks }} permissions: contents: read checks: read # required for getting the status of specific check names - uses: anchore/workflows/.github/workflows/check-gate.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + uses: anchore/workflows/.github/workflows/check-gate.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0 with: # these are checks that should be run on pull-request and merges to main. # we do NOT want to kick off a release if these have not been verified on main. @@ -45,20 +50,32 @@ jobs: release: needs: [check-gate, version-available] - if: ${{ github.event.inputs.phase == 'all' }} + # run even when check-gate is skipped, but never when version-available + # failed/was skipped, nor when check-gate failed or was cancelled. note: + # always() disables the implicit success() gate on ALL needs, so the + # version-available requirement must be re-asserted explicitly here. + if: >- + ${{ always() + && needs.version-available.result == 'success' + && !contains(fromJSON('["failure", "cancelled"]'), needs.check-gate.result) }} environment: release # runs-on.com: compute instances for parallel builds # spot disabled: reliability for build workflows (used for releases too) # goreleaser uses parallelism of 12, so we need more CPUs # s3-cache: faster actions cache - # tmpfs: faster io-intensive workflows - runs-on: runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=s3-cache+tmpfs + # volume: enlarge dist/ disk -- the default 40GB EBS root fills up during the multi-arch + # image + binary build and OOMs (runs out of disk); size it well above peak usage. + # provisioned throughput/iops above the gp3 free baseline (125mbs/3000iops) approximate + # the tmpfs IO speed on disk, for ~1c/run. + # note: tmpfs intentionally omitted -- a RAM-backed dist/ never touches the enlarged + # volume and competes with the build's memory peak, so keep dist/ on disk. + runs-on: runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=s3-cache/volume=120gb:gp3:500mbs:4000iops permissions: contents: write # required for creating the GitHub release and pushing the version tag packages: write # required for publishing release artifacts to GitHub packages id-token: write # required for keyless signing (cosign/sigstore OIDC) steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 with: fetch-depth: 0 persist-credentials: true @@ -66,14 +83,19 @@ jobs: - name: Bootstrap environment uses: ./.github/actions/bootstrap + # dockers_v2 builds multi-platform manifests in a single buildx invocation, which requires a + # docker-container driver builder (the default builder on the runner cannot build multi-platform). + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Login to Docker Hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f #v4.6.0 with: username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }} password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }} - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f #v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -82,8 +104,8 @@ jobs: - name: Build & publish release artifacts run: make ci-release env: - # used for pushing tags - DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + # for pushing tags (does not inherit workflow permissions) + TAG_TOKEN: ${{ secrets.TAG_TOKEN }} RELEASE_VERSION: ${{ github.event.inputs.version }} # for mac signing and notarization... QUILL_SIGN_P12: ${{ secrets.ANCHORE_APPLE_DEVELOPER_ID_CERT_CHAIN }} @@ -107,7 +129,7 @@ jobs: if: ${{ always() && (needs.release.result == 'success' || github.event.inputs.phase == 'install-script-only') }} permissions: contents: read # required for the reusable workflow to check out the repo and publish the install script - uses: anchore/workflows/.github/workflows/release-install-script.yaml@b3e328b5ae31ba96297e2ed9a6124e5e6352a4c5 # v0.7.0 + uses: anchore/workflows/.github/workflows/release-install-script.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0 with: tag: ${{ github.event.inputs.version }} secrets: diff --git a/cmd/syft/internal/options/catalog.go b/cmd/syft/internal/options/catalog.go index bcb8ab66f..f1f2599a5 100644 --- a/cmd/syft/internal/options/catalog.go +++ b/cmd/syft/internal/options/catalog.go @@ -99,6 +99,7 @@ func (cfg Catalog) ToSBOMConfig(id clio.Identification) *syft.CreateSBOMConfig { WithComplianceConfig(cfg.ToComplianceConfig()). WithUnknownsConfig(cfg.ToUnknownsConfig()). WithSearchConfig(cfg.ToSearchConfig()). + WithArchiveConfig(cfg.ToArchiveConfig()). WithPackagesConfig(cfg.ToPackagesConfig()). WithLicenseConfig(cfg.ToLicenseConfig()). WithFilesConfig(cfg.ToFilesConfig()). @@ -115,6 +116,19 @@ func (cfg Catalog) ToSearchConfig() cataloging.SearchConfig { } } +func (cfg Catalog) ToArchiveConfig() cataloging.ArchiveSearchConfig { + // the exclusion patterns come from the same --exclude flag that reaches the source, narrowed to + // the ones whose shape reaches inside an archive. CreateSBOM fills the field from the source only + // when it is empty, so this value wins. + return cataloging.DefaultArchiveSearchConfig(). + WithIncludeIndexedArchives(cfg.Package.SearchIndexedArchives). + WithIncludeUnindexedArchives(cfg.Package.SearchUnindexedArchives). + WithMaxDepth(cfg.Package.NestedArchiveMaxDepth). + WithMaxMemoryBytes(cfg.Package.NestedArchiveMaxMemoryBytes). + WithMaxDiskBytes(cfg.Package.NestedArchiveMaxDiskBytes). + WithExclusionPatterns(cataloging.ArchiveExclusionPatterns(cfg.Exclusions)) +} + func (cfg Catalog) ToRelationshipsConfig() cataloging.RelationshipsConfig { return cataloging.RelationshipsConfig{ PackageFileOwnership: cfg.Relationships.PackageFileOwnership, diff --git a/cmd/syft/internal/options/pkg.go b/cmd/syft/internal/options/pkg.go index 14ea68882..24f5f5a50 100644 --- a/cmd/syft/internal/options/pkg.go +++ b/cmd/syft/internal/options/pkg.go @@ -6,9 +6,12 @@ import ( ) type packageConfig struct { - SearchUnindexedArchives bool `yaml:"search-unindexed-archives" json:"search-unindexed-archives" mapstructure:"search-unindexed-archives"` - SearchIndexedArchives bool `yaml:"search-indexed-archives" json:"search-indexed-archives" mapstructure:"search-indexed-archives"` - ExcludeBinaryOverlapByOwnership bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"` // exclude synthetic binary packages owned by os package files + SearchUnindexedArchives bool `yaml:"search-unindexed-archives" json:"search-unindexed-archives" mapstructure:"search-unindexed-archives"` + SearchIndexedArchives bool `yaml:"search-indexed-archives" json:"search-indexed-archives" mapstructure:"search-indexed-archives"` + ExcludeBinaryOverlapByOwnership bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"` // exclude synthetic binary packages owned by os package files + NestedArchiveMaxDepth int `yaml:"nested-archive-max-depth" json:"nested-archive-max-depth" mapstructure:"nested-archive-max-depth"` + NestedArchiveMaxMemoryBytes int64 `yaml:"nested-archive-max-memory-bytes" json:"nested-archive-max-memory-bytes" mapstructure:"nested-archive-max-memory-bytes"` + NestedArchiveMaxDiskBytes int64 `yaml:"nested-archive-max-disk-bytes" json:"nested-archive-max-disk-bytes" mapstructure:"nested-archive-max-disk-bytes"` } var _ interface { @@ -23,6 +26,18 @@ note: enabling this may result in a performance impact since all discovered comp note: for now this only applies to the java package cataloger`) descriptions.Add(&o.ExcludeBinaryOverlapByOwnership, `allows users to exclude synthetic binary packages from the sbom these packages are removed if an overlap with a non-synthetic package is found`) + descriptions.Add(&o.NestedArchiveMaxDepth, `maximum depth to recursively catalog nested archives, treating each archive as its own filesystem (0 = disabled, -1 = unlimited) +when enabled, all archives (including java archives) are recursively cataloged by the archive cataloger +and the java cataloger does not unarchive nested archives itself`) + descriptions.Add(&o.NestedArchiveMaxMemoryBytes, `maximum bytes of nested archive content held in memory at once (0 = none, always overflow to disk; negative = unbounded) +this is an in-use limit: it falls as each archive is released, so it bounds a scan's peak rather than its total +content is held in memory while this limit admits it and overflowed to disk instead of being refused when it does not +there is no separate setting for the size at which content overflows; this limit alone decides it +only applies when nested-archive-max-depth is enabled`) + descriptions.Add(&o.NestedArchiveMaxDiskBytes, `maximum bytes of nested archive content on disk at once, overflow content plus extracted entries (0 = none, nothing overflows; negative = unbounded) +this is an in-use limit: it falls as each archive is released, so it bounds a scan's peak rather than its total +an archive that would exceed it is skipped, since there is nowhere further to overflow +only applies when nested-archive-max-depth is enabled`) } func defaultPackageConfig() packageConfig { @@ -31,5 +46,8 @@ func defaultPackageConfig() packageConfig { SearchIndexedArchives: c.IncludeIndexedArchives, SearchUnindexedArchives: c.IncludeUnindexedArchives, ExcludeBinaryOverlapByOwnership: true, + NestedArchiveMaxDepth: c.MaxDepth, + NestedArchiveMaxMemoryBytes: c.MaxMemoryBytes, + NestedArchiveMaxDiskBytes: c.MaxDiskBytes, } } diff --git a/cmd/syft/internal/options/pkg_test.go b/cmd/syft/internal/options/pkg_test.go new file mode 100644 index 000000000..579254ec5 --- /dev/null +++ b/cmd/syft/internal/options/pkg_test.go @@ -0,0 +1,91 @@ +package options + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/cataloging" +) + +func Test_packageConfigDefaultsMatchArchiveSearchConfig(t *testing.T) { + // the nested-archive settings must default to the values in syft/cataloging rather than + // restating them, so a config round trip with nothing set is a no-op + want := cataloging.DefaultArchiveSearchConfig() + got := defaultPackageConfig() + + assert.Equal(t, want.MaxDepth, got.NestedArchiveMaxDepth) + assert.Equal(t, want.MaxMemoryBytes, got.NestedArchiveMaxMemoryBytes) + assert.Equal(t, want.MaxDiskBytes, got.NestedArchiveMaxDiskBytes) + + require.Zero(t, got.NestedArchiveMaxDepth, "nested archive cataloging must be off by default") +} + +func Test_packageConfigNamesNoArchiveExclusionSetting(t *testing.T) { + // nested archive cataloging honors the scan's own --exclude patterns, so the application config + // must name no archive-specific exclusion key. Asserted over the yaml tags rather than over the + // Go field names, because the key is what a user writes and what a config dump shows. + var keys []string + typ := reflect.TypeOf(packageConfig{}) + for i := 0; i < typ.NumField(); i++ { + keys = append(keys, typ.Field(i).Tag.Get("yaml")) + } + + assert.Equal(t, []string{ + "search-unindexed-archives", + "search-indexed-archives", + "exclude-binary-overlap-by-ownership", + "nested-archive-max-depth", + "nested-archive-max-memory-bytes", + "nested-archive-max-disk-bytes", + }, keys) +} + +func Test_ToArchiveConfig(t *testing.T) { + defaults := cataloging.DefaultArchiveSearchConfig() + + t.Run("defaults round trip unchanged", func(t *testing.T) { + cfg := Catalog{Package: defaultPackageConfig()} + got := cfg.ToArchiveConfig() + + assert.Equal(t, defaults.MaxDepth, got.MaxDepth) + assert.Equal(t, defaults.MaxMemoryBytes, got.MaxMemoryBytes) + assert.Equal(t, defaults.MaxDiskBytes, got.MaxDiskBytes) + }) + + t.Run("every setting is threaded through", func(t *testing.T) { + // a key that parses but is not carried into the archive config is invisible in a + // config dump, so assert each one arrives + cfg := Catalog{Package: packageConfig{ + NestedArchiveMaxDepth: 3, + NestedArchiveMaxMemoryBytes: 111, + NestedArchiveMaxDiskBytes: 3333, + SearchIndexedArchives: true, + SearchUnindexedArchives: true, + }} + got := cfg.ToArchiveConfig() + + assert.Equal(t, 3, got.MaxDepth) + assert.Equal(t, int64(111), got.MaxMemoryBytes) + assert.Equal(t, int64(3333), got.MaxDiskBytes) + assert.True(t, got.IncludeIndexedArchives) + assert.True(t, got.IncludeUnindexedArchives) + }) + + t.Run("--exclude patterns arrive already filtered to the in-scope subset, with no source involved", func(t *testing.T) { + // every-boundary-populates-the-patterns: the CLI is one of the two boundaries that fills + // ExclusionPatterns, from Catalog.Exclusions, applying the same shape rule the library + // entry point applies to what the source publishes - only a pattern that reaches inside an + // archive (any-depth, "**/") is in scope. A root-anchored or one-level pattern is not, and + // no source is consulted to produce this: Catalog carries no source at all. + cfg := Catalog{Exclusions: []string{"./root-anchored", "**/*.rpm", "*/one-level", "**/vendor"}} + assert.Equal(t, []string{"**/*.rpm", "**/vendor"}, cfg.ToArchiveConfig().ExclusionPatterns) + }) + + t.Run("no --exclude patterns leaves the field empty", func(t *testing.T) { + cfg := Catalog{} + assert.Empty(t, cfg.ToArchiveConfig().ExclusionPatterns) + }) +} diff --git a/cmd/syft/internal/test/integration/package_catalogers_represented_test.go b/cmd/syft/internal/test/integration/package_catalogers_represented_test.go index ec7452eb8..92d04c08d 100644 --- a/cmd/syft/internal/test/integration/package_catalogers_represented_test.go +++ b/cmd/syft/internal/test/integration/package_catalogers_represented_test.go @@ -50,7 +50,13 @@ func TestAllPackageCatalogersReachableInTasks(t *testing.T) { constructorCount += constructors.Size() } - assert.Equal(t, len(taskTagsByName), constructorCount, "mismatch in number of cataloger constructors and task names") + // java has two archive catalogers - one that reads an archive file and one that reads an archive + // already extracted to a filesystem - and the java archive slot in the task list yields whichever + // the config selects, never both. So exactly one constructor has no task under the default config. + const configSelectedAlternates = 1 + + assert.Equal(t, len(taskTagsByName)+configSelectedAlternates, constructorCount, + "mismatch in number of cataloger constructors and task names") exceptions := strset.New( // not reachable since they are deprecated diff --git a/cmd/syft/internal/ui/ui.go b/cmd/syft/internal/ui/ui.go index e0354b583..60ba6fed5 100644 --- a/cmd/syft/internal/ui/ui.go +++ b/cmd/syft/internal/ui/ui.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "strings" "sync" "time" @@ -184,7 +185,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m UI) View() string { - return m.frame.View() + v := m.frame.View() + // always end on an empty line: bubbletea erases the line the cursor rests on when the program stops, so + // a frame whose last line carries content loses it as the run finishes. Until now a log line in + // the footer happened to supply that newline, which is why this only showed on sources that log + // nothing at all. + if !strings.HasSuffix(v, "\n") { + return v + "\n" + } + return v } func runWithTimeout(timeout time.Duration, fn func() error) (err error) { diff --git a/cmd/syft/internal/ui/ui_test.go b/cmd/syft/internal/ui/ui_test.go new file mode 100644 index 000000000..ec8b3ecd7 --- /dev/null +++ b/cmd/syft/internal/ui/ui_test.go @@ -0,0 +1,31 @@ +package ui + +import ( + "io" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/require" + + "github.com/anchore/bubbly/bubbles/frame" +) + +var _ tea.Model = (*staticModel)(nil) + +type staticModel string + +func (m staticModel) Init() tea.Cmd { return nil } + +func (m staticModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + +func (m staticModel) View() string { return string(m) } + +func Test_UI_viewEndsOnAnEmptyLine(t *testing.T) { + // bubbletea erases the line the cursor rests on when the program stops, so a view whose last line + // carries content loses that line as the run finishes. Until a log line happened to be in the + // footer, that is what dropped the last row of the cataloging tree on any source that logs nothing. + subject := New(io.Discard, false) + subject.frame.(*frame.Frame).AppendModel(staticModel("first row\nlast row")) + + require.Equal(t, "first row\nlast row\n", subject.View()) +} diff --git a/docs/archive-cataloging-design.md b/docs/archive-cataloging-design.md deleted file mode 100644 index 132f437be..000000000 --- a/docs/archive-cataloging-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# Recursive Archive Cataloging — Design - -Status: **approved, in implementation** -Branch: `feat/recursive-archive-combined` - -## Background - -Syft can scan inside Java archives (JAR/WAR/EAR, including shaded JARs) but ignores the -contents of generic archives (`.tar`, `.tar.gz`/`.tgz`, `.zip`, `.apk`, `.gem`, `.iso`, …) -found in a scanned directory or image. Tracked by issues -[#246](https://github.com/anchore/syft/issues/246), -[#1379](https://github.com/anchore/syft/issues/1379), -[#2407](https://github.com/anchore/syft/issues/2407). - -Two community PRs attempted this and are **superseded** by this design: - -- **#4044** (@Rupikz) — recursion at the `fileresolver` layer. -- **#4761** (@toabctl) — a composite resolver + orchestrator wired into `CreateSBOM`. - -Both author's commits are preserved in this branch's history for credit; their -approaches are replaced for the reasons below. - -## Why the prior approaches were rejected - -Search in syft *is* an index lookup. `FilesByPath`/`FilesByGlob` -(`syft/internal/fileresolver/filetree_resolver.go`) call -`SearchContext.SearchByPath(...)` and then `r.Index.Get(ref)`; a file that is not in -*that resolver's own* `filetree.Index` causes the lookup to error and the file is -**silently skipped**. A composite/wrapped resolver that merges archive contents without -merging a real index therefore excludes files from indexing and breaks search. Pushing -recursion into the directory resolver also smears archive contents into the parent's -tree/chroot and forces a signature change on every `NewFromDirectory` caller. - -## Core principle - -**Each archive is treated as its own fully-indexed, standalone filesystem** — exactly how -syft already treats container image layers. Extract the archive to a temp directory and -build a resolver with `fileresolver.NewFromDirectory(tempDir, …)`, which produces a -complete `Tree` + `Index` + `SearchContext`. No resolver merging, no shared index. - -## Design decisions (locked) - -1. **Detection is content-based** via `internal/file.IdentifyArchive` (catches `.tgz`, - renamed archives, etc.). -2. **A new `archive-cataloger` Task** (`internal/task/`) drives - detect → extract → run the cataloger sub-pipeline → recurse. It is added to the - package-cataloger task group, **before** relationship/unknowns tasks. -3. **Provenance is expressed only via file-level `CONTAINS` relationships** in the first - iteration: `From` = the archive file's `Coordinates`, `To` = each nested file/package. - No changes to `Location`/`Coordinates`/metadata schema. -4. **`Coordinates` is never modified** — it must remain a comparable two-string struct - (used as `map[file.Coordinates]…` keys throughout `sbom.Artifacts` and as the - `LocationData` key in `LocationSet`). -5. **Node identity uses a per-archive `FileSystemID`** (an *existing* `Coordinates` - field — no struct change). The chain rule is: - - ``` - childFSID = parentFSID == "" ? archiveAccessPath - : parentFSID + "/" + archiveAccessPath - ``` - - - Uses the archive Location's **AccessPath**. - - For non-image sources the root FSID is empty, so the chain starts with the relative - archive path (e.g. `some/path/to.zip/some/nested/path.tar.gz`). Image sources start - from the layer digest (e.g. `/some/path/to.zip/…`). - - The `FiletreeResolver` carries a `fileSystemID` and stamps it onto every returned - `Location`, the same pattern image-layer resolvers already use. This keeps - same-named files in different archives from colliding in the coordinate-keyed tables, - with no post-processing in catalogers. -6. **Java is left as-is in phase 1.** The generic archive cataloger recurses only into - **non-JAR** types; detection excludes the formats the Java cataloger owns - (`.jar/.war/.ear/.par/.sar/.nar/.jpi/.hpi/.kar/.far/.lpkg/.rar/.zap`). Unifying JARs - into the generic mechanism is deferred. -7. **Disabled by default.** Opt-in via `--archive-max-depth` / `SYFT_ARCHIVE_MAX_DEPTH` - (default `0`), plus the extraction safety limits (max extraction size, file count, - total bytes) carried over from the prior work. - -## Reused from the prior PRs - -- `internal/archive/extractor.go` — archive extractor + zip-bomb / size-limit guards. -- `syft/cataloging/archive_search.go` — the `ArchiveSearchConfig` (depth + limits). -- `syft/pkg/cataloger/java/parse_pom_xml.go` — skip `META-INF/maven/*/pom.xml` phantom - packages. - -## Implementation phases - -| Phase | Work | -|------|------| -| 0 | Remove PR-4044's resolver recursion and PR-4761's composite/orchestrator; restore the 2-arg `NewFromDirectory`. Keep the items listed under "Reused" above. | -| 1 | `FiletreeResolver.fileSystemID` + stamp on all Location-returning call sites; add `NewFromDirectoryWithFS(root, base, fileSystemID, …)` (existing `NewFromDirectory` delegates with `""`). | -| 2 | `extractToResolver(...)` — extract to a temp dir and build a standalone indexed resolver with the computed `childFSID`. | -| 3 | The `archive-cataloger` Task — detect (non-JAR) archives, run the package/file cataloger sub-pipeline into a scratch builder, merge into the shared builder, emit `CONTAINS` edges, and recurse to `MaxDepth`. | -| 4 | Wire the task into `makeTaskGroups`; add `--archive-max-depth` (default 0) and limits. | -| 5 | Tests: FSID stamping, extractor limits/zip-slip, nested-fixture FSID/relationship/dedup assertions, CLI integration. | diff --git a/go.mod b/go.mod index 98943414e..822d3739f 100644 --- a/go.mod +++ b/go.mod @@ -11,18 +11,18 @@ require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/acobaugh/osrelease v0.1.0 github.com/adrg/xdg v0.5.3 - github.com/anchore/bubbly v0.2.0 - github.com/anchore/clio v0.1.0 - github.com/anchore/fangs v0.1.0 - github.com/anchore/go-collections v0.1.0 - github.com/anchore/go-homedir v0.1.0 - github.com/anchore/go-logger v0.1.0 - github.com/anchore/go-macholibre v0.1.0 - github.com/anchore/go-rpmdb v0.1.0 - github.com/anchore/go-sync v0.1.0 + github.com/anchore/bubbly v0.2.1 + github.com/anchore/clio v0.1.1 + github.com/anchore/fangs v0.1.1 + github.com/anchore/go-collections v0.1.1 + github.com/anchore/go-homedir v0.1.1 + github.com/anchore/go-logger v0.1.1 + github.com/anchore/go-macholibre v0.1.1 + github.com/anchore/go-rpmdb v0.2.0 + github.com/anchore/go-sync v0.1.1 github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b github.com/anchore/packageurl-go v0.2.0 - github.com/anchore/stereoscope v0.2.1 + github.com/anchore/stereoscope v0.3.1-0.20260820190420-3cd14ecb9fbf github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/aquasecurity/go-pep440-version v0.0.1 github.com/bitnami/go-version v0.0.0-20250131085805-b1f57a8634ef @@ -34,36 +34,36 @@ 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.22 - github.com/go-git/go-billy/v5 v5.9.0 - github.com/go-git/go-git/v5 v5.19.1 + github.com/gkampitakis/go-snaps v0.5.23 + github.com/go-git/go-billy/v5 v5.9.1 + github.com/go-git/go-git/v5 v5.19.2 github.com/go-test/deep v1.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/goccy/go-yaml v1.19.2 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.6 + github.com/google/go-containerregistry v0.21.9 github.com/google/licensecheck v0.3.1 github.com/google/uuid v1.6.0 github.com/gookit/color v1.6.1 - github.com/gpustack/gguf-parser-go v0.24.1 + github.com/gpustack/gguf-parser-go v0.25.0 github.com/hashicorp/go-cleanhttp v0.5.2 - github.com/hashicorp/go-getter v1.8.6 + github.com/hashicorp/go-getter v1.8.8 github.com/hashicorp/go-multierror v1.1.1 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.7.10 + 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.18.6 - github.com/magiconair/properties v1.8.10 + github.com/klauspost/compress v1.19.2 + 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 @@ -79,11 +79,11 @@ require ( github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e github.com/sergi/go-diff v1.4.0 github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb - github.com/spdx/tools-golang v0.5.7 + github.com/spdx/tools-golang v0.6.0-rc4 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/stretchr/testify v1.12.0 + 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 @@ -91,14 +91,14 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 go.uber.org/goleak v1.3.0 - go.yaml.in/yaml/v3 v3.0.4 + go.yaml.in/yaml/v3 v3.0.5 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f - golang.org/x/mod v0.36.0 - golang.org/x/net v0.55.0 + golang.org/x/mod v0.40.0 + golang.org/x/net v0.58.0 golang.org/x/time v0.15.0 - golang.org/x/tools v0.45.0 + golang.org/x/tools v0.49.0 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.51.0 + modernc.org/sqlite v1.56.0 ) require ( @@ -107,50 +107,49 @@ require ( ) require ( - cel.dev/expr v0.25.1 // indirect + cel.dev/expr v0.25.2 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.22.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.5.3 // indirect - cloud.google.com/go/monitoring v1.24.3 // indirect - cloud.google.com/go/storage v1.61.3 // indirect + cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/monitoring v1.29.0 // indirect + cloud.google.com/go/storage v1.64.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/DataDog/zstd v1.5.5 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/agext/levenshtein v1.2.1 // indirect - github.com/anchore/go-lzo v0.1.0 // indirect - github.com/anchore/go-struct-converter v0.1.0 // indirect + github.com/anchore/go-lzo v0.1.1 // indirect + github.com/anchore/go-struct-converter v0.2.0-rc2 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.35 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect + github.com/aws/smithy-go v1.27.6 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect @@ -159,18 +158,18 @@ require ( github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.10.0 // indirect - github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/cgroups/v3 v3.1.3 // indirect github.com/containerd/containerd/api v1.11.1 // indirect - github.com/containerd/containerd/v2 v2.3.1 // indirect + github.com/containerd/containerd/v2 v2.3.3 // indirect github.com/containerd/continuity v0.5.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -181,21 +180,20 @@ require ( github.com/containerd/ttrpc v1.2.8 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v29.4.3+incompatible // indirect + github.com/docker/cli v29.7.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect - github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/gkampitakis/ciinfo v0.3.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect @@ -204,13 +202,13 @@ require ( github.com/go-restruct/restruct v1.2.0-alpha // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect + github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.17.0 // indirect - github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/henvic/httpretty v0.1.4 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -225,9 +223,9 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect @@ -237,8 +235,8 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect - github.com/moby/moby/api v1.54.2 // indirect - github.com/moby/moby/client v0.4.1 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/user v0.4.0 // indirect @@ -256,14 +254,15 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/pborman/indent v1.2.1 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/piprate/json-gold v0.7.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -278,10 +277,10 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/sylabs/sif/v2 v2.24.0 // indirect + github.com/sylabs/sif/v2 v2.24.1 // indirect github.com/sylabs/squashfs v1.0.6 // indirect github.com/therootcompany/xz v1.0.1 // indirect github.com/tidwall/gjson v1.19.0 // indirect @@ -296,32 +295,32 @@ require ( github.com/zclconf/go-cty v1.16.3 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gonum.org/v1/gonum v0.17.0 // indirect - google.golang.org/api v0.271.0 // indirect - google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect - google.golang.org/grpc v1.80.0 // indirect + google.golang.org/api v0.292.0 // indirect + google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - modernc.org/libc v1.72.3 // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 6ec8c27e3..3527f8d35 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -31,8 +31,8 @@ cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0c cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= @@ -46,14 +46,14 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= -cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= -cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= -cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= -cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= -cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= -cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -63,10 +63,10 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= -cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= -cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= -cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg= +cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -82,14 +82,14 @@ github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByX github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= @@ -104,8 +104,8 @@ github.com/Microsoft/hcsshim v0.15.0-rc.1/go.mod h1:HWvvUPIy9HF6LotILj1G4VyS065r github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= -github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= @@ -120,34 +120,34 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/anchore/bubbly v0.2.0 h1:VE6nLlWsYV+bTOqqCzS46NirrVmvHah3UQDtFcjzgHU= -github.com/anchore/bubbly v0.2.0/go.mod h1:zRg1mfRCCe4vq1t9Ibu24YurErmnp9LAVCbpnpsb1pQ= -github.com/anchore/clio v0.1.0 h1:lrlSgD38V0T6TLjOG5Kn3miEXCQyYr6ZlwTSnW1WIJg= -github.com/anchore/clio v0.1.0/go.mod h1:z4l6oHaU8x/yQ3BnES4Ekh4eLqFSYlj1jQpk7waPkJs= -github.com/anchore/fangs v0.1.0 h1:EidxQN2XDRWn8q2n9pCVZXrOEnvpV1CuPLiWMyWG/vE= -github.com/anchore/fangs v0.1.0/go.mod h1:5YocMj6a0MC+KiBXBP0/ocOfc54DvlsOcpkAxmPWgx0= -github.com/anchore/go-collections v0.1.0 h1:vh2LE/j6HLKC27pENkas+J+h4BK4nOte9b7pDjlhY9w= -github.com/anchore/go-collections v0.1.0/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8= -github.com/anchore/go-homedir v0.1.0 h1:WP+lNLOz2og8hzCkY7IccyFNInVKsrjogzV19G5G8p0= -github.com/anchore/go-homedir v0.1.0/go.mod h1:9B0DGhbmMAJVGGEbrlb+PkORM5eDFCEOtZ3xQ22qaLA= -github.com/anchore/go-logger v0.1.0 h1:3pE9Sli4SE1I/mtV+2pZXzim+aWj83BRaneVXeMgD3A= -github.com/anchore/go-logger v0.1.0/go.mod h1:0l63X5VyHqfWQwsNaTmkgXErsfohze3NAFCXFdhSjdQ= -github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= -github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= -github.com/anchore/go-macholibre v0.1.0 h1:qHbdusBZNcZM/uuKf1Psa9xxAFSoyRTps8GW9gpJgsg= -github.com/anchore/go-macholibre v0.1.0/go.mod h1:eu0gbwaZ+ocVFJLePdmPPDKU8MboV1MKsUCr36Ckd5s= -github.com/anchore/go-rpmdb v0.1.0 h1:Q8dc208/HYzCqhx0L1zurfm1UPil24hlo9NjkdFmLdE= -github.com/anchore/go-rpmdb v0.1.0/go.mod h1:eQVa6QFGzKy0qMcnW2pez0XBczvgwSjw9vA23qifEyU= -github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= -github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= -github.com/anchore/go-sync v0.1.0 h1:1TEZM7jISrvtoBMOF79xP0caQKASAtgW1yKqc0EjyZg= -github.com/anchore/go-sync v0.1.0/go.mod h1:Iposeub0kHipoTei1icj4Tys0SJN+cCdxEnkS7bZUFs= +github.com/anchore/bubbly v0.2.1 h1:b3ZNBefY8ks5DI4SQ7BXSbBIsN6eoaTD6s1su7NuCBo= +github.com/anchore/bubbly v0.2.1/go.mod h1:o6a9aH3/fCn+mb5g1T1tBbQZ4hGR0yO5/uD1d2Vtago= +github.com/anchore/clio v0.1.1 h1:9bkYnaFbpT0J3G0qSHyQqa/lpK5zV0C9Gtgh82H/jj0= +github.com/anchore/clio v0.1.1/go.mod h1:CFPIopJlakJbBpKtAL/EPpj4W9hgBO7PfFc8N2FAtlE= +github.com/anchore/fangs v0.1.1 h1:NBTHocmWM2/0gNhebpoE/wEB0Z0nxh2lESww2o/Uf5M= +github.com/anchore/fangs v0.1.1/go.mod h1:82oZuk4+RynOvAoxnXKmI1zO5xk/kbOw+5t4fAAPSsk= +github.com/anchore/go-collections v0.1.1 h1:kGlmSehs+ctTBZr+98F2+taHeolHy+XDx1Nczl8GZc8= +github.com/anchore/go-collections v0.1.1/go.mod h1:zgDbPSNDpfU47tVo7aW+2suG7+vXB1Sq1qtDhRJknQ0= +github.com/anchore/go-homedir v0.1.1 h1:KpaO5BPrVT7cVMcEZ9KfFfEi0iuo3DtagPGl0C744X0= +github.com/anchore/go-homedir v0.1.1/go.mod h1:9B0DGhbmMAJVGGEbrlb+PkORM5eDFCEOtZ3xQ22qaLA= +github.com/anchore/go-logger v0.1.1 h1:HPBNmQVBdYliwaFVUL7oJ3UvSlaoXAMj/ZagAIYaCpA= +github.com/anchore/go-logger v0.1.1/go.mod h1:ekWuh5BkZVwyXnyEd4fwL29N4pNLssgRsqh2+kFN/b8= +github.com/anchore/go-lzo v0.1.1 h1:IwL/fvkdtlIrYIXck6WxZ3nb8WjjHziYYmGxlooyOnM= +github.com/anchore/go-lzo v0.1.1/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= +github.com/anchore/go-macholibre v0.1.1 h1:sTBHi3S1WVIOCamc6NyT8Sn9mBmD30DCTfjfVm4k6/M= +github.com/anchore/go-macholibre v0.1.1/go.mod h1:YNq2610RlvGCK0Za+Klz/OCMtUaEnwwAGA/VfUAQaro= +github.com/anchore/go-rpmdb v0.2.0 h1:ixcyMg/guhBL9XjBVwhTv9CG48xO07Qkg2uDX2+zx/c= +github.com/anchore/go-rpmdb v0.2.0/go.mod h1:ATsRlpCXstnoYfzqBfhwGw0U2dolx1BBQ9rAU8jWBAw= +github.com/anchore/go-struct-converter v0.2.0-rc2 h1:q+859fW2/jbHJHB2etbNfRlFwYpknyvbqqk1hUdamQ4= +github.com/anchore/go-struct-converter v0.2.0-rc2/go.mod h1:cDBA5vhcR62nXWo8QH9/Kk2807o65ISaHPNPX66L+Uw= +github.com/anchore/go-sync v0.1.1 h1:91SZ+YqUIIHmf2jPAYZPuHqM/ZqnK1pVJDWtET6+AJE= +github.com/anchore/go-sync v0.1.1/go.mod h1:3haGsk2BnaoVhsfqae8Kd0FKIAILE1Hw69P4Xo5iVBw= github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZVsCYMrIZBpFxwV26CbsuoEh5muXD5I1Ods= github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.2.0 h1:CkrM4RMUwrEGAiE1OVlxaZNzWj0TuHRey7o4T/EAErk= github.com/anchore/packageurl-go v0.2.0/go.mod h1:2JCgOQMIsqZ7TmliXG4PnUthPJAKE3mWQbsW2XHjAOE= -github.com/anchore/stereoscope v0.2.1 h1:x9c4LCPGh53tKDAQ22RqEUftEnL7tphJavSRke/aICE= -github.com/anchore/stereoscope v0.2.1/go.mod h1:PYx3fD4lvBVsYoQ/fBdauhZ5hmkRrJgw1B73svKx7/U= +github.com/anchore/stereoscope v0.3.1-0.20260820190420-3cd14ecb9fbf h1:eTQTL06tadOz+V4ubhXOlc5oKQ11cXbyo/clnxlcsnU= +github.com/anchore/stereoscope v0.3.1-0.20260820190420-3cd14ecb9fbf/go.mod h1:cWQ8fLFblnkJ0c3BSFY+BwXe4DcJDdysP/gnoWHVb7s= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -169,44 +169,42 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= -github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= +github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= +github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 h1:HpN6GgZ3T8pSvRp81ZsgumNjlvRsa+9M0ZL2o6W4uLY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -248,8 +246,8 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -274,10 +272,10 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= -github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= -github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= -github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -290,14 +288,14 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd/api v1.11.1 h1:h8nfoDW9+fNsC/9TwiAHj8B1GzXKtR4eFtkhi/X5RLU= github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw= -github.com/containerd/containerd/v2 v2.3.1 h1:4dVXBdlvotRBlaP2TmNbY/EGc06KJrMDDUqQdxX/HOk= -github.com/containerd/containerd/v2 v2.3.1/go.mod h1:xVoxGPWZBwwph8DF2IbDhriLKdHfjdpO0b3wFP9wQ1I= +github.com/containerd/containerd/v2 v2.3.3 h1:MUNBVVBTBpPll7KPh5GTvkC3cfG03PQLAHVdsUoue9k= +github.com/containerd/containerd/v2 v2.3.3/go.mod h1:rHKGm3VW6wNrINb3x8mNT+w7qYXFVElTt/8HTuxVhD4= github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -333,18 +331,18 @@ 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= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= -github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= -github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= @@ -371,14 +369,14 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erofs/go-erofs v0.3.0 h1:o/W5ABAA3sHYl97WL93dacKEfeDpJhdFf3c2snAti7I= @@ -389,8 +387,8 @@ github.com/facebookincubator/nvdtools v0.1.5/go.mod h1:Kh55SAWnjckS96TBSrXI99KrE github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= @@ -403,27 +401,27 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z0KY= github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-snaps v0.5.22 h1:xg9omphRnbDnimMCl1KqznC4krlxOGpkB0vDSfX2P7M= -github.com/gkampitakis/go-snaps v0.5.22/go.mod h1:uy3lVzCCRRsAwYqSocyw5fY8xRLCYEfqoOJNxr8HonM= +github.com/gkampitakis/go-snaps v0.5.23 h1:okh5QR48zpUjpWtu65AtqxdCY8huJq+dEDuUzd1PuKg= +github.com/gkampitakis/go-snaps v0.5.23/go.mod h1:uy3lVzCCRRsAwYqSocyw5fY8xRLCYEfqoOJNxr8HonM= github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= 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= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -509,8 +507,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= -github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= +github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= @@ -537,32 +535,32 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= +github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= -github.com/gpustack/gguf-parser-go v0.24.1 h1:nTYtL8HFK6ZhB90RKBu4oX2b3ZHpJLrMmKRfL9w9Cyc= -github.com/gpustack/gguf-parser-go v0.24.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0= +github.com/gpustack/gguf-parser-go v0.25.0 h1:1AMBhMKtI24nTtn588Bq53FqNiOvEw1x9Nb4HbRrThs= +github.com/gpustack/gguf-parser-go v0.25.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 h1:vTCWu1wbdYo7PEZFem/rlr01+Un+wwVmI7wiegFdRLk= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 h1:mymLUKThnV9wFvogOK8NnsMP9/vlhnjXY98gr2QIGW8= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74/go.mod h1:Bh9qYL8ehmDxSg14Tk8oxFCP90XHfs6NxV1D84884xA= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -572,8 +570,8 @@ github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtng github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-getter v1.8.6 h1:9sQboWULaydVphxc4S64oAI4YqpuCk7nPmvbk131ebY= -github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo= +github.com/hashicorp/go-getter v1.8.8 h1:sRakhf+EH6s0LZLO2IgBwZ6hAFp+fZOZMNREwVELV80= +github.com/hashicorp/go-getter v1.8.8/go.mod h1:fqFlibKpwfns/s4oljLB3upJspyfFLQhS7031PlfUDc= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -589,8 +587,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= @@ -624,8 +622,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.7.10 h1:B/2qW2Bkv2L6n14PP8o1kx75kWzHOQ3YTluWzg9icac= -github.com/jedib0t/go-pretty/v6 v6.7.10/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/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -644,8 +642,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.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/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= @@ -669,8 +667,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= @@ -687,12 +685,12 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -722,10 +720,10 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= -github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= -github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= -github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -785,10 +783,12 @@ github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrp github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/piprate/json-gold v0.7.0 h1:bEMirgA5y8Z2loTQfxyIFfY+EflxH1CTP6r/KIlcJNw= +github.com/piprate/json-gold v0.7.0/go.mod h1:RVhE35veDX19r5gfUAR+IYHkAUuPwJO8Ie/qVeFaIzw= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -809,6 +809,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= @@ -877,8 +879,8 @@ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cw github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb h1:bLo8hvc8XFm9J47r690TUKBzcjSWdJDxmjXJZ+/f92U= github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= -github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg= -github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw= +github.com/spdx/tools-golang v0.6.0-rc4 h1:2GkvNr0DcnJHY9BDm3OYHo229jZS/h4qYDK+tHYXPOo= +github.com/spdx/tools-golang v0.6.0-rc4/go.mod h1:ruCHu3shgy7bVbZ7gtEU4Gq4fI08n2SdXtgV5PoN/OM= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -897,14 +899,14 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -915,13 +917,13 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/sylabs/sif/v2 v2.24.0 h1:1wB5uMDUQYjk8AckTySaDcP9YnpMb1LyDRr1Jt9A10w= -github.com/sylabs/sif/v2 v2.24.0/go.mod h1:DbXWqWZ1hdLSU+K9ipdds5AmZeHWsyxCOj/oQakBa88= +github.com/sylabs/sif/v2 v2.24.1 h1:OhTOfTwBaGXfbWYWXK9C6Pojma3A1bTaNJ6CEhyuKok= +github.com/sylabs/sif/v2 v2.24.1/go.mod h1:PRq9MoP0g+p0qE5ZRGrOhyVAUdHrPsAVmXHgoiW6VU0= github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= @@ -942,8 +944,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= @@ -993,32 +995,35 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= @@ -1035,8 +1040,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1076,8 +1081,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1122,8 +1127,8 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1155,8 +1160,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1231,13 +1236,13 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1248,8 +1253,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1310,8 +1315,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1352,8 +1357,8 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= -google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= -google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= +google.golang.org/api v0.292.0 h1:Ewiwo/GTtiaPZSNAZQUcWLh8AYDEoPmIXyJfeoTSMHU= +google.golang.org/api v0.292.0/go.mod h1:07kjmMnFGm2RQuCza2EZM/5N68G/fVvFb1xKjWqoFA0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1424,12 +1429,12 @@ google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1457,8 +1462,8 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1505,20 +1510,20 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -1527,8 +1532,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.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= -modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= 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= diff --git a/internal/archive/content.go b/internal/archive/content.go new file mode 100644 index 000000000..ccf13ef70 --- /dev/null +++ b/internal/archive/content.go @@ -0,0 +1,175 @@ +package archive + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + intFile "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/internal/log" +) + +// copyChunkSize is how much is read, charged and written at a time. It bounds how far a charge can +// be ahead of what has actually been written, and matches io.Copy's own buffer size. +const copyChunkSize = 32 * 1024 + +// ReaderAtSeeker is the random access an archive format needs. A zip reads its central directory +// from the end of the stream, so a plain io.Reader will not do; both a *bytes.Reader over content +// held in memory and an *os.File over content overflowed to disk satisfy it. +type ReaderAtSeeker interface { + io.Reader + io.ReaderAt + io.Seeker +} + +// Content is one archive's bytes, wherever the limiter routed them. +// +// Name is the archive's file name and is used for format identification only - never to open +// anything - so content held in memory needs no file on disk at all. +type Content struct { + Name string + Reader ReaderAtSeeker + + // closer releases the overflow file's handle; nil for content held in memory. + closer io.Closer +} + +// Close releases the handle on overflow content. It is a no-op for content held in memory. +func (c Content) Close() error { + if c.closer == nil { + return nil + } + return c.closer.Close() +} + +// OpenFileContent opens an archive already on disk as Content. The caller closes it. +func OpenFileContent(path string) (Content, error) { + f, err := os.Open(path) + if err != nil { + return Content{}, err + } + return Content{Name: filepath.Base(path), Reader: f, closer: f}, nil +} + +// holdContent routes one archive's bytes: held in memory and charged to the memory limit while that +// limit admits them, overflowed into workDir and charged to the disk limit once it does not. Where the +// boundary falls is decided entirely by the memory limit - there is no separate configured size at +// which overflowing begins. +// +// Content is read and charged incrementally, copyChunkSize at a time, rather than read speculatively +// into a buffer ahead of any charge: nothing is ever held beyond what the memory limit would admit at +// that moment, so a single archive far larger than the limit is never buffered past it. A zero +// memory limit refuses every chunk, so its content overflows to disk in full; a negative memory limit +// never refuses, so nothing overflows for want of memory. +// +// When the limit refuses a chunk part way through, what has already been read leads the overflow rather +// than being re-read from the start or lost, and the memory it was charged against is refunded since +// it is about to be charged to disk instead. +// +// Memory pressure degrades to disk - content that would take memory in use past the memory limit +// overflows instead of being refused, because there is somewhere for it to go. The disk limit is +// terminal: there is nowhere further, so content that would take it past the disk limit yields +// ErrDiskLimitReached and the archive is skipped. Nothing waits for capacity; see ExtractToResolver. +func holdContent(r io.Reader, workDir, name string, charge *Charge, notify Notify) (Content, error) { + var held bytes.Buffer + chunk := make([]byte, copyChunkSize) + for { + n, readErr := r.Read(chunk) + if n > 0 { + if !charge.Memory(int64(n)) { + // the memory limit refused this chunk: overflow what is already held plus this chunk + // plus whatever remains, without re-reading from the start + if notify != nil { + notify(ContentOverflowed{Archive: name, Bytes: int64(held.Len() + n), Reason: overflowReason(charge)}) + } + charge.RefundMemory(int64(held.Len())) + return overflowContent(io.MultiReader(&held, bytes.NewReader(chunk[:n]), r), workDir, name, charge) + } + held.Write(chunk[:n]) + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return Content{Name: name, Reader: bytes.NewReader(held.Bytes())}, nil + } + return Content{}, fmt.Errorf("unable to read archive content: %w", readErr) + } + } +} + +// overflowContent writes an archive's bytes into workDir, charging the disk limit as they land, and +// returns a reader over the file positioned at its start. +// +// This is the only filesystem write left in the extraction path - an archive's entries are headers in +// one tar and traverse no path - and the name it writes under is derived from where the archive was +// found, which inside another archive is text the archive supplied. So the destination is checked the +// same two ways an entry's path used to be: lexically, and then against where it really lands on the +// filesystem. +func overflowContent(r io.Reader, workDir, name string, charge *Charge) (Content, error) { + dest, err := intFile.SafeJoin(workDir, name) + if err != nil { + return Content{}, fmt.Errorf("refusing to overflow archive content to %q: %w", name, err) + } + if inside, err := resolvesInsideRoot(workDir, dest); err != nil || !inside { + return Content{}, fmt.Errorf("refusing to overflow archive content outside its work directory: %q", name) + } + f, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return Content{}, fmt.Errorf("unable to create temp archive file: %w", err) + } + + written, limitReached, err := copyCharged(f, r, charge) + if err == nil && limitReached { + err = ErrDiskLimitReached + } + if err == nil { + _, err = f.Seek(0, io.SeekStart) + } + if err != nil { + charge.RefundDisk(written) + if closeErr := f.Close(); closeErr != nil { + log.WithFields("path", dest, "error", closeErr).Trace("unable to close overflow archive file") + } + if rmErr := os.Remove(dest); rmErr != nil { + log.WithFields("path", dest, "error", rmErr).Trace("unable to remove overflow archive file") + } + if errors.Is(err, ErrDiskLimitReached) { + return Content{}, err + } + return Content{}, fmt.Errorf("unable to write temp archive file: %w", err) + } + + return Content{Name: name, Reader: f, closer: f}, nil +} + +// copyCharged copies src into dst, charging every chunk to the disk limit before it is written, so +// what bounds the copy is what actually lands rather than any size the archive declared. It stops at +// the first refused charge and says so, leaving the caller to decide whether that truncates one +// entry or skips a whole archive. +func copyCharged(dst io.Writer, src io.Reader, charge *Charge) (written int64, limitReached bool, err error) { + buf := make([]byte, copyChunkSize) + for { + n, readErr := src.Read(buf) + if n > 0 { + if !charge.Disk(int64(n)) { + return written, true, nil + } + w, writeErr := dst.Write(buf[:n]) + written += int64(w) + if w < n { + charge.RefundDisk(int64(n - w)) + } + if writeErr != nil { + return written, false, writeErr + } + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return written, false, nil + } + return written, false, readErr + } + } +} diff --git a/internal/archive/content_test.go b/internal/archive/content_test.go new file mode 100644 index 000000000..fe64fe405 --- /dev/null +++ b/internal/archive/content_test.go @@ -0,0 +1,255 @@ +package archive + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// filesIn lists the regular files directly in dir, which is how a test tells content held in memory +// from content overflowed to disk. +func filesIn(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + var names []string + for _, e := range entries { + if !e.IsDir() { + names = append(names, e.Name()) + } + } + return names +} + +func readAll(t *testing.T, c Content) []byte { + t.Helper() + _, err := c.Reader.Seek(0, io.SeekStart) + require.NoError(t, err) + body, err := io.ReadAll(c.Reader) + require.NoError(t, err) + return body +} + +func TestHoldContent_heldWhileTheLimitAdmitsIt(t *testing.T) { + // the referent for the memory limit: an archive small enough to hold needs no file on disk at + // all, where every archive used to be copied to a temp file purely so an extractor could seek it + workDir := t.TempDir() + body := []byte(strings.Repeat("a", 100)) + limiter := NewLimiter(Limits{MaxMemoryBytes: 1000, MaxDiskBytes: 1000}) + charge := limiter.Charge() + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.IsType(t, &bytes.Reader{}, held.Reader) + assert.Equal(t, body, readAll(t, held)) + assert.Empty(t, filesIn(t, workDir), "content held in memory must write nothing to disk") + + mem, disk := charge.Held() + assert.Equal(t, int64(100), mem) + assert.Zero(t, disk) +} + +func TestHoldContent_overflowsWhenTheLimitDoesNotAdmitIt(t *testing.T) { + // where the boundary falls is decided by the memory limit and nothing else: an archive that does + // not fit overflows in full, and the bytes already read to discover that lead the overflow rather than + // being lost + workDir := t.TempDir() + body := []byte(strings.Repeat("b", 250)) + limiter := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 10_000}) + charge := limiter.Charge() + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir)) + assert.Equal(t, body, readAll(t, held), + "the bytes read to decide the routing must lead the overflow rather than being lost") + + mem, disk := charge.Held() + assert.Zero(t, mem, "nothing is charged to a limit that refused") + assert.Equal(t, int64(250), disk) +} + +// peakTrackingReader records the highest memory charge observed at any point while reading, which is +// how a test tells that a large archive was never buffered beyond what the limit would admit - +// nothing about the routing decision is otherwise observable from outside holdContent. +type peakTrackingReader struct { + r io.Reader + charge *Charge + peak int64 +} + +func (p *peakTrackingReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if mem, _ := p.charge.Held(); mem > p.peak { + p.peak = mem + } + return n, err +} + +func TestHoldContent_largeArchiveNeverBuffersBeyondTheLimit(t *testing.T) { + // an archive many times the memory limit must overflow, and at no point along the way may more than + // the limit would admit be held while the routing is being decided - it is never read + // speculatively into memory that has no room for it + workDir := t.TempDir() + const limit = int64(3 * copyChunkSize) + body := []byte(strings.Repeat("c", 10*copyChunkSize)) + limiter := NewLimiter(Limits{MaxMemoryBytes: limit, MaxDiskBytes: int64(len(body)) + 1}) + charge := limiter.Charge() + tracked := &peakTrackingReader{r: bytes.NewReader(body), charge: charge} + + held, err := holdContent(tracked, workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir), "an archive this much larger than the limit must overflow") + assert.Equal(t, body, readAll(t, held)) + assert.LessOrEqual(t, tracked.peak, limit, + "memory charged must never exceed the limit, even transiently, while routing is decided") + + mem, disk := charge.Held() + assert.Zero(t, mem) + assert.Equal(t, int64(len(body)), disk) +} + +func TestHoldContent_diskLimitIsTerminal(t *testing.T) { + // there is nowhere further to overflow, so content that will not fit is refused and the caller skips + // the archive rather than waiting for capacity that nothing will release + workDir := t.TempDir() + body := []byte(strings.Repeat("d", 500)) + limiter := NewLimiter(Limits{MaxDiskBytes: 100}) + charge := limiter.Charge() + + _, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.ErrorIs(t, err, ErrDiskLimitReached) + + assert.Empty(t, filesIn(t, workDir), "the partial overflow must not be left behind") + mem, disk := charge.Held() + assert.Zero(t, mem) + assert.Zero(t, disk, "bytes no longer on disk must be refunded, or the limit only ever rises") +} + +func TestHoldContent_zeroDiskLimitSkipsRatherThanFails(t *testing.T) { + // a zero disk limit has the same outcome as a positive disk limit exceeded: content that will + // not fit anywhere yields ErrDiskLimitReached so the caller skips the archive, rather than the + // scan failing + workDir := t.TempDir() + body := []byte(strings.Repeat("f", 500)) + limiter := NewLimiter(Limits{MaxMemoryBytes: 10, MaxDiskBytes: 0}) + charge := limiter.Charge() + + _, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.ErrorIs(t, err, ErrDiskLimitReached) + + assert.Empty(t, filesIn(t, workDir), "no partial overflow is left behind") + mem, disk := charge.Held() + assert.Zero(t, mem) + assert.Zero(t, disk) +} + +func TestHoldContent_threeStateLimits(t *testing.T) { + // both limits read positive/zero/negative the same way: positive is the limit, zero means none + // of that resource at all, negative means unbounded. A caller can bound one without bounding the + // other. + body := []byte(strings.Repeat("e", 500)) + + t.Run("a zero memory limit sends everything to disk", func(t *testing.T) { + // zero does not mean unlimited memory: the memory limit decides a routing question rather + // than only capping an accumulator, so zero means the memory side of that routing is never + // taken + workDir := t.TempDir() + limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 10_000}) + charge := limiter.Charge() + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir), "a zero memory limit holds nothing in memory") + mem, disk := charge.Held() + assert.Zero(t, mem) + assert.Equal(t, int64(500), disk, "and the disk limit still applies to what it overflowed") + }) + + t.Run("a negative memory limit holds content regardless of how much is already held", func(t *testing.T) { + workDir := t.TempDir() + limiter := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: 10_000}) + charge := limiter.Charge() + require.True(t, charge.Memory(1_000_000), "a negative memory limit admits any amount already") + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Empty(t, filesIn(t, workDir), "nothing overflows for want of memory when the limit is negative") + mem, disk := charge.Held() + assert.Equal(t, int64(1_000_000+len(body)), mem) + assert.Zero(t, disk) + }) + + t.Run("a zero disk limit writes nothing to disk while content the memory limit admits is still cataloged", func(t *testing.T) { + workDir := t.TempDir() + limiter := NewLimiter(Limits{MaxMemoryBytes: 10_000, MaxDiskBytes: 0}) + charge := limiter.Charge() + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Empty(t, filesIn(t, workDir), "content the memory limit admits never touches disk") + mem, disk := charge.Held() + assert.Equal(t, int64(len(body)), mem) + assert.Zero(t, disk) + }) + + t.Run("a negative disk limit overflows with no ceiling", func(t *testing.T) { + workDir := t.TempDir() + limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1}) + charge := limiter.Charge() + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", charge, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Equal(t, []string{"app.zip"}, filesIn(t, workDir)) + assert.Equal(t, body, readAll(t, held)) + }) +} + +func TestHoldContent_noLimitsAtAll(t *testing.T) { + // a nil charge is what an extraction with no configured bounds gets, and it must route content + // rather than panic - holding everything in memory, since nothing is there to refuse it + workDir := t.TempDir() + body := []byte("small enough") + + held, err := holdContent(bytes.NewReader(body), workDir, "app.zip", nil, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, held.Close()) }) + + assert.Equal(t, body, readAll(t, held)) + assert.Empty(t, filesIn(t, workDir)) +} + +func TestOpenFileContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.zip") + require.NoError(t, os.WriteFile(path, []byte("body"), 0o600)) + + content, err := OpenFileContent(path) + require.NoError(t, err) + assert.Equal(t, "app.zip", content.Name) + assert.Equal(t, []byte("body"), readAll(t, content)) + require.NoError(t, content.Close()) + + _, err = OpenFileContent(filepath.Join(dir, "missing.zip")) + assert.Error(t, err) +} diff --git a/internal/archive/entrystore.go b/internal/archive/entrystore.go new file mode 100644 index 000000000..1225a3f99 --- /dev/null +++ b/internal/archive/entrystore.go @@ -0,0 +1,359 @@ +package archive + +import ( + "archive/tar" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sync" + + "github.com/mholt/archives" + + "github.com/anchore/syft/internal/log" +) + +// Entry is one archive entry held by an EntryStore: its header, and a reference to its bytes wherever +// they currently live. +// +// The bytes move; the entry does not. That is the point of this type - an index built over these +// entries stays valid when content is pushed out to disk, because what changes is the entry's +// reference and not its identity or its place in any index. +type Entry struct { + Header tar.Header + + // ref names where this entry's bytes are right now. Guarded by the store's mutex, because a swap + // to disk rewrites it while readers may be asking for it. + ref blobRef +} + +// blobRef is either bytes held in memory or a region of the store's overflow file. Exactly one of the +// two is live: mem is nil once the entry has been written out. +type blobRef struct { + mem []byte + offset int64 + length int64 +} + +// EntryStore holds the entries of one extracted archive, in memory while the memory limit admits them +// and in a single overflow file once it does not. +// +// This replaces writing every archive's entries into a tar and indexing that tar by seek offset. A +// tar had to be on disk before there was anything to index, so a four-kilobyte jar paid for a file +// write and a tar walk before a cataloger could read a byte of it. Here the index is over the entries +// themselves, so a small archive never touches the disk at all, and a large one moves its bytes +// without the index noticing. +// +// The store owns the overflow file and closes it on Close. Readers handed out by Open borrow it, so +// they are only valid until then - which matches the lifetime the archive cataloger already gives an +// extracted archive. +type EntryStore struct { + mu sync.RWMutex + + entries []*Entry + + // heldInMemory is the bytes currently held by entries whose ref is memory-backed. It falls to zero + // when the store spills. + heldInMemory int64 + + workDir string + file *os.File + written int64 + + // archive names this store's archive in the events it sends, so a spill can be attributed to the + // file that caused it rather than reported as an anonymous byte count. + archive string + notify Notify +} + +// overflowBlobName is the file an archive's entries are written into once they no longer fit in +// memory. Unlike the tar it replaces it has no framing: the entries' offsets live in the store, so +// nothing has to be parsed to find them again. +const overflowBlobName = "contents.blob" + +// indexRecordOverhead approximates the fixed memory kept per entry no matter its content: the *Entry, +// its tar.Header, and the resolver node built over it. It is an over-estimate of the fixed part, with +// the variable part - the entry's name and link target - added on top, so the bound errs towards +// charging more rather than letting the index outrun what was accounted for. See Charge.IndexRecord. +var indexRecordOverhead = int64(reflect.TypeOf((*tar.Header)(nil)).Elem().Size()) + +// indexRecordCost is what one entry's index record charges: the fixed overhead plus the bytes of the +// two strings the header carries by value. +func indexRecordCost(hdr tar.Header) int64 { + return indexRecordOverhead + int64(len(hdr.Name)) + int64(len(hdr.Linkname)) +} + +var _ EntrySink = (*EntryStore)(nil) + +// NewEntryStore returns a store that writes its overflow blob into workDir, naming archive in the +// events it sends. +// +// How much it holds in memory is not a property of the store: it asks the scan's limiter, entry by +// entry, and moves what it is holding out to disk the first time the limiter says no. That keeps one +// answer to "how much memory is this scan using" rather than a per-archive budget that no one set. +func NewEntryStore(workDir, archive string, notify Notify) *EntryStore { + return &EntryStore{workDir: workDir, archive: archive, notify: notify} +} + +// Add stores one entry's header and content, and reports the entry. +// +// Content is read here rather than referenced: an archive's reader is sequential, so an entry that is +// not read now cannot be read later. Where it lands is decided as it arrives, which is what keeps the +// decision out of every read path downstream. +// +// It is read and charged copyChunkSize at a time rather than into a buffer sized by anything the +// entry declared, for the reason holdContent reads an archive's own bytes that way: a header's size +// is the archive's claim about itself, so an entry far larger than the memory limit would be held +// past it in full before the limiter was ever asked. Here the limiter is asked per chunk, and the +// first refusal moves this entry and everything the store is holding out to the overflow blob. +func (s *EntryStore) Add(hdr tar.Header, contents io.Reader, charge *Charge) (*Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // charge this entry's index record before it is held, so an archive of millions of near-empty + // entries is bounded by the same budgets its content is rather than growing the index without + // limit. The record overflows to disk when memory is full and is only refused when neither budget + // admits it; a refusal leaves the archive truncated at the entries already stored. + if !charge.IndexRecord(indexRecordCost(hdr)) { + return nil, ErrDiskLimitReached + } + + entry := &Entry{Header: hdr} + s.entries = append(s.entries, entry) + + if hdr.Typeflag != tar.TypeReg || contents == nil { + return entry, nil + } + + var held bytes.Buffer + chunk := make([]byte, copyChunkSize) + for { + n, readErr := contents.Read(chunk) + if n > 0 { + if !charge.Memory(int64(n)) { + // memory is full: what is held of this entry joins the spill, and the rest of it + // streams straight into the blob behind it rather than being read into memory first + entry.ref = memRef(&held) + s.heldInMemory += entry.ref.length + rest := io.MultiReader(bytes.NewReader(chunk[:n]), contents) + return entry, s.spillThroughLocked(entry, rest, charge) + } + held.Write(chunk[:n]) + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + break + } + return nil, fmt.Errorf("unable to read archive entry %q: %w", hdr.Name, readErr) + } + } + + entry.ref = memRef(&held) + s.heldInMemory += entry.ref.length + return entry, nil +} + +// memRef is a reference to bytes held in memory. The slice is taken non-nil even when it is empty, +// because nil is how blobRef says "these bytes are on disk" and a zero-length entry is not. +func memRef(held *bytes.Buffer) blobRef { + body := held.Bytes() + if body == nil { + body = []byte{} + } + return blobRef{mem: body, length: int64(len(body))} +} + +// spillThroughLocked moves everything the store is holding out to the overflow blob and writes the +// rest of entry's content in behind it, charging the disk limit as it lands. +// +// entry is the newest, so spillLocked writes it last and its region ends where the blob does - which +// is exactly where the rest of its content is about to go, so the two are one contiguous region and +// the entry keeps a single reference to it. +func (s *EntryStore) spillThroughLocked(entry *Entry, rest io.Reader, charge *Charge) error { + spilled := entry.ref.length + moved, movedBytes, err := s.spillLocked(charge) + if err != nil { + return err + } + + written, limitReached, err := copyCharged(io.NewOffsetWriter(s.file, s.written), rest, charge) + s.written += written + entry.ref = blobRef{offset: s.written - written - spilled, length: spilled + written} + + // reported once the whole move is done, so the event counts the bytes that streamed through as + // well as the ones that were being held: they all went to disk for the same reason. + if s.notify != nil && moved > 0 { + s.notify(EntriesOverflowed{ + Archive: s.archive, + Entries: moved, + Bytes: movedBytes + written, + Reason: overflowReason(charge), + }) + } + + if err != nil { + return fmt.Errorf("unable to write archive entry %q to the overflow blob: %w", entry.Header.Name, err) + } + if limitReached { + return ErrDiskLimitReached + } + return nil +} + +// spillLocked writes every memory-backed entry into the overflow blob and repoints it there, +// reporting what it moved. The index over these entries is untouched: an entry's identity is the +// pointer, and only its reference to its bytes changes. +// +// It does not send the event for the move: the entry that triggered the spill is still streaming in +// behind it, and an event sent from here would report a byte count short by whatever that entry has +// left. spillThroughLocked sends it once both halves have landed. +func (s *EntryStore) spillLocked(charge *Charge) (moved int, movedBytes int64, err error) { + if s.file == nil { + f, err := os.OpenFile(filepath.Join(s.workDir, overflowBlobName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return 0, 0, fmt.Errorf("unable to create overflow blob: %w", err) + } + s.file = f + } + + for _, entry := range s.entries { + if entry.ref.mem == nil { + continue + } + body := entry.ref.mem + if !charge.Disk(int64(len(body))) { + return moved, movedBytes, ErrDiskLimitReached + } + charged := charge != nil + if _, err := s.file.WriteAt(body, s.written); err != nil { + return moved, movedBytes, fmt.Errorf("unable to write archive entry %q to the overflow blob: %w", entry.Header.Name, err) + } + entry.ref = blobRef{offset: s.written, length: int64(len(body))} + s.written += int64(len(body)) + moved++ + movedBytes += int64(len(body)) + if charged { + // the bytes are on disk now, so the memory they were charged for is no longer held + charge.RefundMemory(int64(len(body))) + } + } + s.heldInMemory = 0 + return moved, movedBytes, nil +} + +// overflowReason distinguishes an archive pushed to disk by pressure from one that was never going to +// be held at all, which are different findings: the first says the scan is at its memory bound, the +// second says the bound is zero. +func overflowReason(charge *Charge) OverflowReason { + if charge != nil && charge.limiter != nil && charge.limiter.limits.MaxMemoryBytes == 0 { + return MemoryHoldsNothing + } + return MemoryLimitReached +} + +// Entries returns the entries stored so far, in the order they were added. +func (s *EntryStore) Entries() []*Entry { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*Entry, len(s.entries)) + copy(out, s.entries) + return out +} + +// Open returns a reader over one entry's content, from memory or from the overflow blob as the entry +// currently stands. The reader is Read, Seek and ReadAt either way, so a nested archive is handed to +// an archive format where it lies rather than being copied out first. +func (s *EntryStore) Open(entry *Entry) (ReaderAtSeeker, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if entry.ref.mem != nil { + return bytes.NewReader(entry.ref.mem), nil + } + if entry.ref.length == 0 { + return bytes.NewReader(nil), nil + } + if s.file == nil { + return nil, fmt.Errorf("archive entry %q has no content", entry.Header.Name) + } + return io.NewSectionReader(s.file, entry.ref.offset, entry.ref.length), nil +} + +// HeldInMemory reports the entry content currently held in memory, for tests and for the scan's own +// statistics. +func (s *EntryStore) HeldInMemory() int64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.heldInMemory +} + +// Close releases the overflow blob. Readers handed out by Open do not outlive it. +func (s *EntryStore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.file == nil { + return nil + } + err := s.file.Close() + s.file = nil + return err +} + +// AddEntry stores one archive entry, so an EntryStore can stand where an OverflowTar stands. +// +// Note what is NOT here: no header encoding, no block padding, and no end-of-archive marker, because +// there is no framing to write. The disk limit is charged only when content actually reaches the disk, +// which for a small archive is never - so an archive that fits in memory costs the limiter nothing on +// disk and costs the machine no write at all. +func (s *EntryStore) AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error { + hdr, ok := entryHeader(f) + if !ok { + return nil + } + + var contents io.ReadCloser + if f.Mode().IsRegular() { + opened, err := f.Open() + if err != nil { + log.WithFields("entry", f.NameInArchive, "error", err). + Trace("unable to open archive entry, skipping it") + return nil + } + contents = opened + } + + entry, err := s.Add(*hdr, contents, limits.Charge) + if contents != nil { + if closeErr := contents.Close(); closeErr != nil { + log.WithFields("entry", f.NameInArchive, "error", closeErr).Trace("unable to close archive entry") + } + } + switch { + case errors.Is(err, ErrDiskLimitReached): + // nowhere left to put this archive's content: the entries stored so far stay, and the archive + // is reported as truncated rather than discarded + result.Truncation = TruncatedByDiskLimit + return errTruncated + case err != nil: + return err + } + + result.FilesExtracted++ + result.BytesWritten = s.OnDisk() + _ = entry + return nil +} + +// Finish has nothing to write: a store's entries are complete as they are added, with no framing to +// close off. +func (s *EntryStore) Finish(*ExtractionResult, ExtractionLimits) {} + +// OnDisk reports the entry content written out so far, which is what the disk limit was charged for. +func (s *EntryStore) OnDisk() int64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.written +} diff --git a/internal/archive/entrystore_test.go b/internal/archive/entrystore_test.go new file mode 100644 index 000000000..f23813a96 --- /dev/null +++ b/internal/archive/entrystore_test.go @@ -0,0 +1,199 @@ +package archive + +import ( + "archive/tar" + "bytes" + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func regularHeader(name string, size int64) tar.Header { + return tar.Header{Name: name, Size: size, Mode: 0o600, Typeflag: tar.TypeReg} +} + +func readEntry(t *testing.T, r ReaderAtSeeker) string { + t.Helper() + _, err := r.Seek(0, io.SeekStart) + require.NoError(t, err) + b, err := io.ReadAll(r) + require.NoError(t, err) + return string(b) +} + +func TestEntryStore_holdsSmallContentInMemory(t *testing.T) { + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + charge := memCharge(1024) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + entry, err := s.Add(regularHeader("a.txt", 5), bytes.NewReader([]byte("hello")), charge) + require.NoError(t, err) + + assert.Equal(t, int64(5), s.HeldInMemory()) + assert.NoFileExists(t, filepath.Join(dir, overflowBlobName), + "a small archive must not touch the disk at all - that is the whole point of the store") + + r, err := s.Open(entry) + require.NoError(t, err) + assert.Equal(t, "hello", readEntry(t, r)) +} + +func TestEntryStore_spillsToDiskAndKeepsTheSameEntries(t *testing.T) { + // the index over these entries is not rebuilt when content moves, so the entry pointers and their + // contents must both survive the move + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + charge := memCharge(8) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + first, err := s.Add(regularHeader("a.txt", 4), bytes.NewReader([]byte("aaaa")), charge) + require.NoError(t, err) + require.Equal(t, int64(4), s.HeldInMemory()) + + // the second entry takes the store past its memory bound, so everything held moves out + second, err := s.Add(regularHeader("b.txt", 6), bytes.NewReader([]byte("bbbbbb")), charge) + require.NoError(t, err) + + assert.Zero(t, s.HeldInMemory(), "everything must have moved out, not just the entry that did not fit") + assert.FileExists(t, filepath.Join(dir, overflowBlobName)) + + // the same entry pointers still read their own content, from their new home + firstReader, err := s.Open(first) + require.NoError(t, err) + assert.Equal(t, "aaaa", readEntry(t, firstReader)) + + secondReader, err := s.Open(second) + require.NoError(t, err) + assert.Equal(t, "bbbbbb", readEntry(t, secondReader)) + + assert.Equal(t, []*Entry{first, second}, s.Entries(), "order and identity must be unchanged by the move") +} + +func TestEntryStore_zeroMemoryHoldsNothing(t *testing.T) { + // the reading a zero memory limit has everywhere else: none of that resource may be used + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + charge := memCharge(0) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + entry, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), charge) + require.NoError(t, err) + + assert.Zero(t, s.HeldInMemory()) + assert.FileExists(t, filepath.Join(dir, overflowBlobName)) + + r, err := s.Open(entry) + require.NoError(t, err) + assert.Equal(t, "abc", readEntry(t, r)) +} + +func TestEntryStore_negativeMemoryNeverSpills(t *testing.T) { + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + charge := memCharge(-1) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + for _, body := range []string{"one", "two", "three"} { + _, err := s.Add(regularHeader(body+".txt", int64(len(body))), bytes.NewReader([]byte(body)), charge) + require.NoError(t, err) + } + + assert.Equal(t, int64(11), s.HeldInMemory()) + assert.NoFileExists(t, filepath.Join(dir, overflowBlobName)) +} + +func TestEntryStore_spillIsRefusedWhenDiskIsNotAvailable(t *testing.T) { + // content that does not fit in memory and cannot be written has nowhere to go, and the archive is + // skipped rather than cataloged as if it were empty + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + nowhereToPutIt := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0}).Charge() + _, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), nowhereToPutIt) + assert.ErrorIs(t, err, ErrDiskLimitReached) +} + +func TestEntryStore_directoryEntriesHoldNoContent(t *testing.T) { + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + charge := memCharge(1024) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + entry, err := s.Add(tar.Header{Name: "lib/", Typeflag: tar.TypeDir, Mode: 0o755}, nil, charge) + require.NoError(t, err) + + assert.Zero(t, s.HeldInMemory()) + r, err := s.Open(entry) + require.NoError(t, err) + assert.Empty(t, readEntry(t, r)) +} + +// memCharge is a charge against a memory bound with disk left unbounded, which is the shape the store +// reads: hold while memory admits, move out when it does not. +func memCharge(maxMemory int64) *Charge { + return NewLimiter(Limits{MaxMemoryBytes: maxMemory, MaxDiskBytes: -1}).Charge() +} + +func TestEntryStore_chargesIndexRecordEvenForEmptyContent(t *testing.T) { + // an empty entry still charges its index record - the header and node held to reach it - which is + // what bounds entry count, the dimension the content-byte budgets never bounded. + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + limiter := NewLimiter(Limits{MaxMemoryBytes: 1 << 20, MaxDiskBytes: -1}) + charge := limiter.Charge() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + hdr := regularHeader("a.txt", 0) + _, err := s.Add(hdr, bytes.NewReader(nil), charge) + require.NoError(t, err) + + mem, _ := limiter.InUse() + assert.Equal(t, indexRecordCost(hdr), mem, "the index record is charged to memory") + assert.Equal(t, int64(0), s.HeldInMemory(), "no content was held") +} + +func TestEntryStore_indexOverflowsToDiskWhenMemoryIsZero(t *testing.T) { + // the index cannot be refused just because memory is zero: a resolver still needs the records to + // reach content that lives entirely on disk. With disk unbounded, every entry is admitted and its + // record charged to disk instead. + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + limiter := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: -1}) + charge := limiter.Charge() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + hdr := regularHeader("a.txt", 0) + _, err := s.Add(hdr, bytes.NewReader(nil), charge) + require.NoError(t, err) + + mem, disk := limiter.InUse() + assert.Equal(t, int64(0), mem, "memory budget is zero, so nothing is charged there") + assert.Equal(t, indexRecordCost(hdr), disk, "the index record overflows to the disk budget") +} + +func TestEntryStore_refusesEntriesWhenNoBudgetAdmitsTheIndex(t *testing.T) { + // with memory zero and only a few index records' worth of disk, the store admits a bounded number + // of entries and then refuses - an archive of millions of empty entries can no longer grow the + // index without limit. + dir := t.TempDir() + s := NewEntryStore(dir, "app.zip", nil) + room := indexRecordCost(regularHeader("x", 0)) * 3 + charge := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: room}).Charge() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + var err error + admitted := 0 + for i := 0; i < 100; i++ { + if _, err = s.Add(regularHeader("x", 0), bytes.NewReader(nil), charge); err != nil { + break + } + admitted++ + } + require.ErrorIs(t, err, ErrDiskLimitReached) + assert.Equal(t, 3, admitted, "exactly the index records that fit the disk budget were admitted") +} diff --git a/internal/archive/extractor.go b/internal/archive/extractor.go index d11cce5ca..a259b46f6 100644 --- a/internal/archive/extractor.go +++ b/internal/archive/extractor.go @@ -1,7 +1,9 @@ package archive import ( + "bytes" "context" + "errors" "fmt" "io" "io/fs" @@ -18,31 +20,135 @@ import ( // Extractor extracts archive contents to a destination directory. type Extractor interface { - // CanExtract returns true if this extractor can handle the given file. - CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool + // CanExtract returns true if this extractor can handle the given archive content. + CanExtract(ctx context.Context, content Content) bool - // Extract extracts the archive contents to destDir, respecting the given limits. - // Returns the number of files extracted and total bytes written. - Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) + // Extract writes the archive's entries into sink - one store per archive, not one file per entry - + // respecting the given limits. Returns how many entries were written and how many bytes that took. + Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) } -// ExtractionLimits defines safety limits for archive extraction. +// EntrySink is where an extractor puts the entries it reads out of an archive. +// +// Two implementations exist because they store an archive differently and the difference is +// measurable: OverflowTar writes every entry into one tar on disk and is indexed by seek offset, +// while EntryStore holds entries in memory until a bound says otherwise and is indexed by the entries +// themselves. The extractors do not care which they were given, with one exception - see +// VerbatimSink. +type EntrySink interface { + // AddEntry stores one entry of the archive. + AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error + + // Finish is called once the last entry has been offered, for whatever a sink has to write after + // its content. + Finish(result *ExtractionResult, limits ExtractionLimits) +} + +// VerbatimSink is an EntrySink that stores archives in tar form, so a tar-family archive can be +// copied into it as it stands rather than decoded and re-encoded entry by entry. +// +// This is the one place the storage format shows through the extractor interface, and it is worth the +// leak: a tar.gz copied verbatim is one streamed decompression, where walking it costs a read per +// entry. A sink that does not store tars does not implement this and gets the entry-by-entry path. +type VerbatimSink interface { + EntrySink + CopyVerbatim(r io.Reader, result *ExtractionResult, limits ExtractionLimits) error +} + +// ExtractionLimits defines safety limits for extracting one archive. type ExtractionLimits struct { - MaxExtractionSizeBytes int64 - MaxFileCount int + + // Charge is this archive's draw on the scan's resource limiter. Entry bytes are charged to it as + // they land, never from the size the entry declared, and a refused charge truncates the + // extraction. A nil Charge enforces no byte bound. + // + // It is a handle rather than a remaining-bytes value because what it charges has to be given back + // when the archive is released, and a value copied into a call cannot be. + Charge *Charge } +// TruncationReason names the configured bound that stopped an extraction early. The empty value +// means the archive was extracted in full. +type TruncationReason string + +const ( + // TruncatedByDiskLimit means placing more of this archive's content would have taken the scan's + // disk usage past the disk limit. Named rather than reported as a bare truncation because a breach + // has to be attributed to the archive that triggered it, and saying which bound was reached is + // half of that attribution. + TruncatedByDiskLimit TruncationReason = "disk limit" + + // TruncatedByLimit means a bound stopped the extraction but the walker replaced the error that + // said which one. Defensive: both bounds record their own reason on the result before stopping. + TruncatedByLimit TruncationReason = "configured limit" +) + +// errTruncated stops an in-progress archive walk when a configured limit is reached. It never +// escapes this package: the extractors convert it into a successful, truncated ExtractionResult. +var errTruncated = errors.New("archive extraction stopped at a configured limit") + // ExtractionResult holds the result of an extraction operation. +// +// Reaching a configured limit is NOT an error: it is a truncation. Whatever was written before the +// limit is left on disk and is usable, so callers can still catalog what was extracted rather than +// discarding the whole archive. Check Truncated to tell a complete extraction from a partial one; +// a non-nil error from Extract means the extraction failed and nothing usable was produced. type ExtractionResult struct { + // FilesExtracted counts the entries this archive's filesystem holds - the records its overflow tar + // was indexed into: regular files, symlinks, and directory entries. It is the count after indexing + // rather than the count written, so an entry dropped by an exclusion pattern is not counted as + // present. Reported, not bounded. FilesExtracted int - BytesWritten int64 + + // BytesWritten is what this archive put on disk: every byte of the overflow tar, headers and block + // padding included, because that is what the disk limit was charged for. + BytesWritten int64 + + // Truncation names the limit that stopped extraction early, or "" when the archive was + // extracted in full. + Truncation TruncationReason } -// DefaultExtractionLimits returns limits from the given config. -func DefaultExtractionLimits(cfg cataloging.ArchiveSearchConfig) ExtractionLimits { - return ExtractionLimits{ - MaxExtractionSizeBytes: cfg.MaxExtractionSizeBytes, - MaxFileCount: cfg.MaxFileCount, +// Truncated reports whether a configured limit stopped this extraction before the end of the +// archive. +func (r ExtractionResult) Truncated() bool { + return r.Truncation != "" +} + +// finishExtraction turns a walk that stopped at a configured limit into a successful, truncated +// result. +// +// The limit signal is carried on the result rather than only in the returned error because the +// underlying archive walkers are free to wrap or replace a handler's error, in which case +// errors.Is(err, errTruncated) would not match and a truncation would be reported as a failure - +// discarding an archive syft could have cataloged. The error is still checked, so a walker that +// passes it through cleanly is handled too. +func finishExtraction(result ExtractionResult, err error) (ExtractionResult, error) { + if result.Truncated() || errors.Is(err, errTruncated) { + if !result.Truncated() { + // the walker swallowed the reason but passed the sentinel through + result.Truncation = TruncatedByLimit + } + return result, nil + } + return result, err +} + +// DefaultExtractionLimits returns the per-archive limits from the given config. +// +// Nothing is read from the config: every bound this scan enforces is an in-use limit held by the +// scan's limiter, and the only per-archive field left is the Charge the caller draws against it. +// The parameter stays so the two Default* constructors are called the same way, and so a per-archive +// bound that is genuinely per-archive has somewhere to land. +func DefaultExtractionLimits(_ cataloging.ArchiveSearchConfig) ExtractionLimits { + return ExtractionLimits{} +} + +// DefaultLimits returns the scan-wide in-use limits from the given config. +func DefaultLimits(cfg cataloging.ArchiveSearchConfig) Limits { + return Limits{ + MaxMemoryBytes: cfg.MaxMemoryBytes, + MaxDiskBytes: cfg.MaxDiskBytes, } } @@ -54,90 +160,82 @@ func DefaultExtractors() []Extractor { } } -// FindExtractor finds an extractor that can handle the given file, or nil if none can. -func FindExtractor(ctx context.Context, extractors []Extractor, path string) Extractor { - f, err := os.Open(path) - if err != nil { - log.Tracef("unable to open file for archive detection: %v", err) - return nil - } - defer f.Close() - +// FindExtractor finds an extractor that can handle the given archive content, or nil if none can. +func FindExtractor(ctx context.Context, extractors []Extractor, content Content) Extractor { for _, ext := range extractors { - if ext.CanExtract(ctx, path, f) { - return ext - } - // reset for next extractor - if _, err := f.Seek(0, io.SeekStart); err != nil { - log.Tracef("unable to seek file for archive detection: %v", err) + // every extractor sniffs from the start, whether the content is a file or a buffer + if _, err := content.Reader.Seek(0, io.SeekStart); err != nil { + log.Tracef("unable to seek archive content for detection: %v", err) return nil } + if ext.CanExtract(ctx, content) { + return ext + } } return nil } -// IsExcludedExtension checks if a file path has an excluded extension. -func IsExcludedExtension(path string, excludeExtensions []string) bool { - for _, ext := range excludeExtensions { - if strings.HasSuffix(strings.ToLower(path), strings.ToLower(ext)) { - return true - } - } - return false -} - // ZipExtractor extracts zip-based archives (zip, jar, war, ear, etc.). type ZipExtractor struct{} -func (z *ZipExtractor) CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool { - format, _, err := intFile.IdentifyArchive(ctx, path, reader) - if err != nil { +// CanExtract reports whether this content is a zip, asking two questions in an order that matters. +// +// First, what does the content say it is, reading from the start? A format that identifies itself at +// offset zero is what this content is, and if that format is not zip then it is not a zip - a tar +// holding one jar ends in that jar's end-of-central-directory record, and reading it as a zip +// instead of as a tar would lose everything else the tar holds. +// +// Only when nothing identifies from the head is the end-of-central-directory record consulted. That +// record is what defines a zip - a reader locates the central directory through it - and it is the +// only thing that can see an archive whose head is something else entirely. A self-extracting +// archive is a launcher stub with a zip concatenated onto it, so no magic at offset zero can find +// it; the canonical case is a Spring Boot executable jar, a shell script followed by a whole zip. +// +// Identification is intFile.IdentifyArchive, which matches on the filename as well as the content, +// and it is left that way. Dropping the name makes the answer content's alone, which reads better - +// but archives.Identify with no path has to try every format's content matcher against every +// candidate, and measured on a stock ruby image that took the scan from 17 seconds to 45. The name +// cannot admit a non-archive in practice: a file only reaches an extractor after its sniffed content +// type put it in the candidate set, so the prose named notes.zip that this would wrongly identify is +// never offered here at all. `#content-based-archive-detection` is upheld by the candidate set, and +// Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError is where that is asserted. +func (z *ZipExtractor) CanExtract(ctx context.Context, content Content) bool { + format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader) + if err == nil { + _, isZip := format.(archives.Zip) + return isZip + } + + if _, seekErr := content.Reader.Seek(0, io.SeekStart); seekErr != nil { return false } - _, ok := format.(archives.Zip) - return ok + return HasZipEndOfCentralDirectory(content.Reader) } -func (z *ZipExtractor) Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) { +func (z *ZipExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) { var result ExtractionResult - f, err := os.Open(path) - if err != nil { - return result, fmt.Errorf("unable to open zip archive %q: %w", path, err) + if _, err := content.Reader.Seek(0, io.SeekStart); err != nil { + return result, fmt.Errorf("unable to seek zip archive %q: %w", content.Name, err) } - defer f.Close() - return result, archives.Zip{}.Extract(ctx, f, func(_ context.Context, file archives.FileInfo) error { - if file.IsDir() { - joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) - if err != nil { - return err - } - return os.MkdirAll(joinedPath, 0o755) - } - - if limits.MaxFileCount > 0 && result.FilesExtracted >= limits.MaxFileCount { - return fmt.Errorf("archive file count limit reached (%d files)", limits.MaxFileCount) - } - - joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(joinedPath), 0o755); err != nil { - return fmt.Errorf("unable to create parent dir: %w", err) - } - - return extractFileWithLimits(file, joinedPath, destDir, &result, limits) + // mholt/archives imposes no extraction target: the FileHandler is the caller's, so an entry can + // be written into a tar header rather than onto the filesystem. Nothing about the library had to + // be customized - the old code wrote files because the handler wrote files. + err := archives.Zip{}.Extract(ctx, content.Reader, func(_ context.Context, file archives.FileInfo) error { + return sink.AddEntry(file, &result, limits) }) + + sink.Finish(&result, limits) + + return finishExtraction(result, err) } // TarExtractor extracts tar-based archives (tar, tar.gz, tar.bz2, tar.xz, tar.zst). type TarExtractor struct{} -func (t *TarExtractor) CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool { - format, _, err := intFile.IdentifyArchive(ctx, path, reader) +func (t *TarExtractor) CanExtract(ctx context.Context, content Content) bool { + format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader) if err != nil { return false } @@ -149,125 +247,166 @@ func (t *TarExtractor) CanExtract(ctx context.Context, path string, reader io.Re return ok } -func (t *TarExtractor) Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) { +func (t *TarExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) { var result ExtractionResult - f, err := os.Open(path) - if err != nil { - return result, fmt.Errorf("unable to open tar archive %q: %w", path, err) + if _, err := content.Reader.Seek(0, io.SeekStart); err != nil { + return result, fmt.Errorf("unable to seek tar archive %q: %w", content.Name, err) } - defer f.Close() - format, readerAfterIdentify, err := intFile.IdentifyArchive(ctx, path, f) + format, readerAfterIdentify, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader) if err != nil { - return result, fmt.Errorf("unable to identify archive format for %q: %w", path, err) + return result, fmt.Errorf("unable to identify archive format for %q: %w", content.Name, err) } extractor, ok := format.(archives.Extractor) if !ok { - return result, fmt.Errorf("file format does not support extraction: %s", path) + return result, fmt.Errorf("file format does not support extraction: %s", content.Name) } // use the reader returned by IdentifyArchive since it may have consumed some bytes - extractReader := readerAfterIdentify + var extractReader = readerAfterIdentify if extractReader == nil { - extractReader = f + extractReader = content.Reader } - return result, extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error { - if file.IsDir() { - joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) - if err != nil { - return err + // a tar handed to a sink that stores tars is not walked at all: a compressed tar is decompressed + // once into the sink's file and a plain one is copied, and the entries are first looked at when + // the file is indexed. Where the sink stores entries rather than a tar there is nothing to copy + // into, so the tar is walked like any other archive. + if verbatim, isVerbatim := sink.(VerbatimSink); isVerbatim { + if plain, ok := tarStream(format, extractReader); ok { + err = verbatim.CopyVerbatim(plain, &result, limits) + if closeErr := plain.Close(); closeErr != nil { + log.WithFields("archive", content.Name, "error", closeErr).Trace("unable to close decompressed tar stream") } - return os.MkdirAll(joinedPath, 0o755) + return finishExtraction(result, err) } + } - if limits.MaxFileCount > 0 && result.FilesExtracted >= limits.MaxFileCount { - return fmt.Errorf("archive file count limit reached (%d files)", limits.MaxFileCount) - } - - joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive) - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(joinedPath), 0o755); err != nil { - return fmt.Errorf("unable to create parent dir: %w", err) - } - - return extractFileWithLimits(file, joinedPath, destDir, &result, limits) + // anything else - 7z, rar, or a tar going into a sink that does not store tars - is walked entry + // by entry + err = extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error { + return sink.AddEntry(file, &result, limits) }) + + sink.Finish(&result, limits) + + return finishExtraction(result, err) } -// extractFileWithLimits extracts a single file from an archive, respecting size limits. -// Symlinks are written as real symlinks (only when the target stays inside destDir); -// non-regular file types other than symlinks are skipped to avoid writing bogus content. -func extractFileWithLimits(file archives.FileInfo, destPath, destDir string, result *ExtractionResult, limits ExtractionLimits) error { - mode := file.Mode() - if mode.Type()&fs.ModeSymlink != 0 { - if err := writeSafeSymlink(file.LinkTarget, destPath, destDir); err != nil { - log.WithFields("entry", file.NameInArchive, "target", file.LinkTarget, "error", err).Debug("skipping unsafe symlink in archive") - return nil - } - result.FilesExtracted++ - return nil - } - if !mode.IsRegular() { - // skip device nodes, fifos, sockets, etc. — they can't safely round-trip - log.WithFields("entry", file.NameInArchive, "mode", mode).Debug("skipping non-regular archive entry") - return nil - } - - src, err := file.Open() +// resolvesInsideRoot reports whether writing to path really lands inside root, following any symlink +// that already exists along the way the same way the filesystem will. +// +// The one filesystem write left in this package is the archive's own bytes overflowing into its work +// directory, under a name derived from where the archive was found - which inside another archive is +// attacker-controlled text. That is what this now guards; entries are tar headers and traverse no +// path at all. See overflowContent. +// +// It walks path one component at a time rather than calling filepath.EvalSymlinks on the whole +// thing, because the leaf of an entry being extracted does not exist yet, which makes EvalSymlinks +// fail rather than answer. The first component that does not exist ends the walk: nothing below it +// exists either, so whatever is created there is created fresh under a path already known to be +// inside the root. +func resolvesInsideRoot(root, path string) (bool, error) { + // the root itself may sit under a symlinked directory (a temp dir on macOS, for one), so + // containment is judged against its real location + realRoot, err := filepath.EvalSymlinks(root) if err != nil { - return fmt.Errorf("unable to open archive entry %q: %w", file.NameInArchive, err) + return false, fmt.Errorf("unable to resolve extraction root: %w", err) } - defer src.Close() - dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + rel, err := filepath.Rel(root, path) if err != nil { - return fmt.Errorf("unable to create dest file %q: %w", destPath, err) + return false, err + } + if !isWithin(filepath.Join(realRoot, rel), realRoot) { + return false, nil } - defer dst.Close() - var reader io.Reader = src - - // enforce per-archive total size limit - if limits.MaxExtractionSizeBytes > 0 { - remaining := limits.MaxExtractionSizeBytes - result.BytesWritten - if remaining <= 0 { - return fmt.Errorf("archive extraction size limit reached (%d bytes)", limits.MaxExtractionSizeBytes) + current := realRoot + for _, part := range strings.Split(filepath.ToSlash(rel), "/") { + if part == "" || part == "." { + continue } - reader = io.LimitReader(src, remaining+1) // +1 to detect exceeding the limit + current = filepath.Join(current, part) + + info, err := os.Lstat(current) + if err != nil { + if os.IsNotExist(err) { + break + } + return false, err + } + if info.Mode()&fs.ModeSymlink == 0 { + continue + } + + resolved, err := resolveLink(current) + if err != nil { + return false, err + } + if !isWithin(resolved, realRoot) { + return false, nil + } + current = resolved } - n, err := io.Copy(dst, reader) - result.BytesWritten += n - result.FilesExtracted++ - - if limits.MaxExtractionSizeBytes > 0 && result.BytesWritten > limits.MaxExtractionSizeBytes { - return fmt.Errorf("archive extraction size limit reached (%d bytes)", limits.MaxExtractionSizeBytes) - } - - return err + return isWithin(current, realRoot), nil } -// writeSafeSymlink creates a symlink at destPath only if target is relative and -// resolves — relative to destPath's directory — to a location inside destDir. +// resolveLink returns where a symlink really points, falling back to the lexically cleaned target +// when the target does not exist yet - a dangling link still redirects a write. +func resolveLink(path string) (string, error) { + target, err := os.Readlink(path) + if err != nil { + return "", err + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(path), target) + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + if !os.IsNotExist(err) { + return "", err + } + return filepath.Clean(target), nil + } + return resolved, nil +} + +// isWithin reports whether path is root or sits beneath it, compared on a path boundary rather than +// as a string prefix so a sibling sharing the prefix does not pass. +func isWithin(path, root string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// writeSafeSymlink creates a symlink at destPath only if target is relative and really resolves, +// relative to destPath's directory, to a location inside destDir. // // Absolute targets are rejected outright: os.Symlink writes the literal target // string, so an absolute target like "/etc/passwd" would resolve on the host // filesystem when read, regardless of any safety check we performed against // destDir at extraction time. // -// NOTE: this guards against the single-symlink-escape pattern, but a malicious -// archive can still attempt a multi-step chain where two cooperating link -// entries together resolve outside destDir (e.g., one entry creates a benign -// link, a later entry writes through it). Fully preventing that requires -// either openat/O_NOFOLLOW per path component during extraction or -// transitively resolving every newly-created symlink against all prior ones -// — neither is implemented here. +// A relative target is resolved against the REAL location of destPath's directory, not against its +// lexical one, because two cooperating entries can otherwise each pass on their own and together +// point outside: a link to "." followed by a link to ".." inside it resolves lexically to the root +// while really landing above it, and repeating the pair climbs a level each time. The parent of +// every write is checked the same way before the write happens, so a link that slipped through here +// still could not be written through - this is the earlier of two refusals, not the only one. +// +// It has no caller since an archive's entries became headers in one tar rather than files on disk: a +// symlink entry is now a header write, which creates nothing and traverses nothing, and where the +// link points is decided inside the archive's own filetree. It is kept rather than retired because +// that is a judgement about every path that could write a link, not about this one call site, and +// because the reasoning above is the expensive part to reconstruct. +// +//nolint:unused // retained deliberately; see the note above func writeSafeSymlink(target, destPath, destDir string) error { if target == "" { return fmt.Errorf("empty link target") @@ -275,11 +414,99 @@ func writeSafeSymlink(target, destPath, destDir string) error { if filepath.IsAbs(target) { return fmt.Errorf("absolute symlink target not allowed") } - resolved := filepath.Join(filepath.Dir(destPath), target) - cleanRoot := filepath.Clean(destDir) - cleanResolved := filepath.Clean(resolved) - if cleanResolved != cleanRoot && !strings.HasPrefix(cleanResolved, cleanRoot+string(filepath.Separator)) { + + realRoot, err := filepath.EvalSymlinks(destDir) + if err != nil { + return fmt.Errorf("unable to resolve extraction root: %w", err) + } + realParent, err := filepath.EvalSymlinks(filepath.Dir(destPath)) + if err != nil { + return fmt.Errorf("unable to resolve link parent: %w", err) + } + if !isWithin(realParent, realRoot) { + return fmt.Errorf("symlink parent escapes extraction root") + } + + resolved := filepath.Join(realParent, target) + if !isWithin(resolved, realRoot) { return fmt.Errorf("symlink target escapes extraction root") } + // the target itself may run through links planted by earlier entries + if existing, err := filepath.EvalSymlinks(resolved); err == nil && !isWithin(existing, realRoot) { + return fmt.Errorf("symlink target resolves outside extraction root") + } + return os.Symlink(target, destPath) } + +// zipEOCDSignature is the end-of-central-directory record's signature. A zip is defined by this +// record: it is what a reader seeks to in order to find the central directory, which is why a zip +// with something concatenated in front of it still opens. +var zipEOCDSignature = []byte{'P', 'K', 0x05, 0x06} + +// maxZipEOCDSearch is how far back the record can be. The EOCD is 22 bytes and ends with a comment +// of up to 65535 bytes, so it starts no earlier than this many bytes from the end. +const maxZipEOCDSearch = 22 + 65535 + +// zipLocalFileHeaderSignature starts every entry in a zip. In a self-extracting archive the first +// one follows the stub, so it is what a bounded read of the head can find. +var zipLocalFileHeaderSignature = []byte{'P', 'K', 0x03, 0x04} + +// MaxAppendedArchiveStubBytes is how far into a file a hidden archive is looked for. A Spring Boot +// launcher stub is about 9 KB, so this is generous by a wide margin; a stub longer than this hides +// its archive from the walk, which is the cost of not reading every candidate to the end. +const MaxAppendedArchiveStubBytes = 64 * 1024 + +// MayHideAnAppendedArchive reports whether the head of this stream contains a zip local file header, +// and returns a reader that replays what it consumed. +// +// It exists to make the candidate set affordable. A file whose head sniffs as a script, an +// executable, or as nothing recognizable may still be an archive wearing a stub, but almost none of +// them are - and the authoritative test, the end-of-central-directory record, is at the far end of +// the file, so applying it to every such candidate means reading every executable in a scan to its +// end. Measured on a stock ruby image that doubled the scan. +// +// So the head is checked first, cheaply and with a bound, and only a file that shows an entry +// signature there is read in full and put to the real test. This narrows what is examined; it never +// admits anything, because a file that passes here is still refused by the extractor unless it ends +// in a valid central directory. +func MayHideAnAppendedArchive(r io.Reader) (io.Reader, bool) { + head := make([]byte, MaxAppendedArchiveStubBytes) + n, err := io.ReadFull(r, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return io.MultiReader(bytes.NewReader(head[:n]), r), false + } + head = head[:n] + return io.MultiReader(bytes.NewReader(head), r), bytes.Contains(head, zipLocalFileHeaderSignature) +} + +// HasZipEndOfCentralDirectory reports whether the content ends in a zip end-of-central-directory +// record, which is the definition of a zip and the only part of one whose position is knowable +// without parsing. +// +// This is what identifies an archive that content sniffing types by its head: a self-extracting +// archive is a launcher stub with a zip concatenated onto it, so its first bytes are the stub's and +// no magic-at-offset-zero test can see the archive. The record at the end is unambiguous, and +// mholt/archives extracts such a file correctly once it is asked to - it reads through +// archive/zip, which locates the central directory from the end. +// +// It is a content test, so it cannot make a non-archive extractable: a file named `notes.zip` that +// is prose has no such record and is refused here exactly as it is refused by format identification. +func HasZipEndOfCentralDirectory(r ReaderAtSeeker) bool { + size, err := r.Seek(0, io.SeekEnd) + if err != nil || size < int64(len(zipEOCDSignature)) { + return false + } + + window := int64(maxZipEOCDSearch) + if size < window { + window = size + } + + buf := make([]byte, window) + if _, err := r.ReadAt(buf, size-window); err != nil && !errors.Is(err, io.EOF) { + return false + } + + return bytes.LastIndex(buf, zipEOCDSignature) >= 0 +} diff --git a/internal/archive/extractor_test.go b/internal/archive/extractor_test.go index 715f90418..0aa7b4739 100644 --- a/internal/archive/extractor_test.go +++ b/internal/archive/extractor_test.go @@ -3,11 +3,16 @@ package archive import ( "archive/tar" "archive/zip" + "bytes" "compress/gzip" "context" + "errors" + "fmt" + "io" "io/fs" "os" "path/filepath" + "sort" "strings" "testing" @@ -17,6 +22,96 @@ import ( "github.com/anchore/syft/syft/cataloging" ) +// fileContent opens an archive already on disk as Content, which is what the extractors take now +// that an archive's bytes may be held in memory instead. Registered for close so a test that opens +// several does not leak handles. +func fileContent(t *testing.T, path string) Content { + t.Helper() + c, err := OpenFileContent(path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + return c +} + +// diskCharge returns a charge against a disk limit of the given size, for a test that wants the byte +// bound to bite without standing up a whole scan. +func diskCharge(max int64) *Charge { + return NewLimiter(Limits{MaxDiskBytes: max}).Charge() +} + +// sortedNames fixes the entry order of a test archive. Limits are enforced as the walk proceeds, so +// which entries land before a truncation is a function of entry order and a map's order is random. +func sortedNames(files map[string]string) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// overflowEntry is one entry read back out of an overflow tar. Assertions are made against these rather +// than against files in a directory, because an archive's entries are now headers in one file. +type overflowEntry struct { + name string + body string + linkTarget string + mode fs.FileMode + typeflag byte +} + +// overflowFor makes the one tar an archive's entries are written into, in the test's own temp space. +func overflowFor(t *testing.T) *OverflowTar { + t.Helper() + s, err := newOverflowTar(filepath.Join(t.TempDir(), overflowTarName)) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return s +} + +// readOverflow reads an overflow tar back, keyed by entry name. It closes the tar first: the writer holds +// it open for the header rewrites that give each entry the size that actually arrived. +func readOverflow(t *testing.T, s *OverflowTar) map[string]overflowEntry { + t.Helper() + require.NoError(t, s.Close()) + + f, err := os.Open(s.Path()) + require.NoError(t, err) + defer f.Close() + + out := map[string]overflowEntry{} + tr := tar.NewReader(f) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + body, err := io.ReadAll(tr) + require.NoError(t, err) + out[hdr.Name] = overflowEntry{ + name: hdr.Name, + body: string(body), + linkTarget: hdr.Linkname, + mode: hdr.FileInfo().Mode(), + typeflag: hdr.Typeflag, + } + } + return out +} + +// overflowNames is the entry names an overflow tar holds, sorted. +func overflowNames(t *testing.T, s *OverflowTar) []string { + t.Helper() + entries := readOverflow(t, s) + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + return names +} + func createTestZip(t *testing.T, dir string, files map[string]string) string { t.Helper() zipPath := filepath.Join(dir, "test.zip") @@ -25,10 +120,10 @@ func createTestZip(t *testing.T, dir string, files map[string]string) string { defer f.Close() w := zip.NewWriter(f) - for name, content := range files { + for _, name := range sortedNames(files) { fw, err := w.Create(name) require.NoError(t, err) - _, err = fw.Write([]byte(content)) + _, err = fw.Write([]byte(files[name])) require.NoError(t, err) } require.NoError(t, w.Close()) @@ -45,7 +140,8 @@ func createTestTarGz(t *testing.T, dir string, files map[string]string) string { gw := gzip.NewWriter(f) tw := tar.NewWriter(gw) - for name, content := range files { + for _, name := range sortedNames(files) { + content := files[name] hdr := &tar.Header{ Name: name, Mode: 0o644, @@ -68,21 +164,13 @@ func TestZipExtractor_CanExtract(t *testing.T) { ext := &ZipExtractor{} - f, err := os.Open(zipPath) - require.NoError(t, err) - defer f.Close() - - assert.True(t, ext.CanExtract(ctx, zipPath, f)) + assert.True(t, ext.CanExtract(ctx, fileContent(t, zipPath))) // non-zip file nonZipPath := filepath.Join(dir, "notazip.txt") require.NoError(t, os.WriteFile(nonZipPath, []byte("just text"), 0o644)) - f2, err := os.Open(nonZipPath) - require.NoError(t, err) - defer f2.Close() - - assert.False(t, ext.CanExtract(ctx, nonZipPath, f2)) + assert.False(t, ext.CanExtract(ctx, fileContent(t, nonZipPath))) } func TestZipExtractor_Extract(t *testing.T) { @@ -96,21 +184,21 @@ func TestZipExtractor_Extract(t *testing.T) { zipPath := createTestZip(t, dir, files) ext := &ZipExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - result, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{}) + result, err := ext.Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{}) require.NoError(t, err) assert.Equal(t, 2, result.FilesExtracted) - // verify extracted files - content, err := os.ReadFile(filepath.Join(destDir, "file1.txt")) - require.NoError(t, err) - assert.Equal(t, "content1", string(content)) + // both entries are in the one tar, under their own names and with their own content + entries := readOverflow(t, overflow) + assert.Equal(t, "content1", entries["file1.txt"].body) + assert.Equal(t, "content2", entries["dir/file2.txt"].body) - content, err = os.ReadFile(filepath.Join(destDir, "dir", "file2.txt")) + // and the archive occupies exactly one filesystem entry, which is the whole point + info, err := os.Stat(overflow.Path()) require.NoError(t, err) - assert.Equal(t, "content2", string(content)) + assert.True(t, info.Mode().IsRegular()) } func TestZipExtractor_Extract_FileLimitReached(t *testing.T) { @@ -125,12 +213,21 @@ func TestZipExtractor_Extract_FileLimitReached(t *testing.T) { zipPath := createTestZip(t, dir, files) ext := &ZipExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - _, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{MaxFileCount: 2}) - require.Error(t, err) - assert.Contains(t, err.Error(), "file count limit") + // reaching a limit is a truncation, not a failure: what was written before the limit stays in the + // tar so the caller can still catalog it. Two entries of one byte each cost two header blocks and + // two data blocks, so four blocks of room admits two entries and refuses the third. + result, err := ext.Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{ + Charge: diskCharge(4 * 512), + }) + require.NoError(t, err) + assert.True(t, result.Truncated()) + assert.Equal(t, TruncatedByDiskLimit, result.Truncation) + assert.Equal(t, 2, result.FilesExtracted) + + assert.Equal(t, []string{"file1.txt", "file2.txt"}, overflowNames(t, overflow), + "the two entries written before the limit must still be readable") } func TestZipExtractor_Extract_SizeLimitReached(t *testing.T) { @@ -143,12 +240,19 @@ func TestZipExtractor_Extract_SizeLimitReached(t *testing.T) { zipPath := createTestZip(t, dir, files) ext := &ZipExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - _, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{MaxExtractionSizeBytes: 50}) - require.Error(t, err) - assert.Contains(t, err.Error(), "size limit") + result, err := ext.Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{Charge: diskCharge(50)}) + require.NoError(t, err) + assert.True(t, result.Truncated()) + assert.Equal(t, TruncatedByDiskLimit, result.Truncation) + + // the single entry blew the budget on its own, so it is dropped rather than left half-written: + // a partial jar or pom that a cataloger parses anyway is worse than an absent one. The budget is + // spent on the entry's header before any of its content, because a header is bytes on disk too. + assert.Zero(t, result.FilesExtracted) + assert.Zero(t, result.BytesWritten) + assert.Empty(t, overflowNames(t, overflow), "the over-limit entry must not be left behind") } func TestTarExtractor_CanExtract(t *testing.T) { @@ -158,12 +262,7 @@ func TestTarExtractor_CanExtract(t *testing.T) { tarPath := createTestTarGz(t, dir, map[string]string{"hello.txt": "world"}) ext := &TarExtractor{} - - f, err := os.Open(tarPath) - require.NoError(t, err) - defer f.Close() - - assert.True(t, ext.CanExtract(ctx, tarPath, f)) + assert.True(t, ext.CanExtract(ctx, fileContent(t, tarPath))) } func TestTarExtractor_Extract(t *testing.T) { @@ -177,21 +276,20 @@ func TestTarExtractor_Extract(t *testing.T) { tarPath := createTestTarGz(t, dir, files) ext := &TarExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - result, err := ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + result, err := ext.Extract(ctx, fileContent(t, tarPath), overflow, ExtractionLimits{}) require.NoError(t, err) - assert.Equal(t, 2, result.FilesExtracted) - // verify extracted files - content, err := os.ReadFile(filepath.Join(destDir, "file1.txt")) - require.NoError(t, err) - assert.Equal(t, "content1", string(content)) + // a compressed tar is decompressed once into the overflow file and never walked, so the extractor + // counts nothing: how many entries this archive contributes is the index's answer, reported by + // ExtractToResolver. What the extractor promises is that the file it produced is that tar. + assert.Zero(t, result.FilesExtracted, "a tar is copied, not walked; the index does the counting") + assert.NotZero(t, result.BytesWritten) - content, err = os.ReadFile(filepath.Join(destDir, "dir", "file2.txt")) - require.NoError(t, err) - assert.Equal(t, "content2", string(content)) + entries := readOverflow(t, overflow) + assert.Equal(t, "content1", entries["file1.txt"].body) + assert.Equal(t, "content2", entries["dir/file2.txt"].body) } func TestFindExtractor(t *testing.T) { @@ -202,33 +300,32 @@ func TestFindExtractor(t *testing.T) { // zip file should match zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"}) - ext := FindExtractor(ctx, extractors, zipPath) + ext := FindExtractor(ctx, extractors, fileContent(t, zipPath)) require.NotNil(t, ext) assert.IsType(t, &ZipExtractor{}, ext) // tar.gz should match tarPath := createTestTarGz(t, dir, map[string]string{"hello.txt": "world"}) - ext = FindExtractor(ctx, extractors, tarPath) + ext = FindExtractor(ctx, extractors, fileContent(t, tarPath)) require.NotNil(t, ext) assert.IsType(t, &TarExtractor{}, ext) // non-archive should return nil textPath := filepath.Join(dir, "plain.txt") require.NoError(t, os.WriteFile(textPath, []byte("just text"), 0o644)) - ext = FindExtractor(ctx, extractors, textPath) + ext = FindExtractor(ctx, extractors, fileContent(t, textPath)) assert.Nil(t, ext) } -func TestIsExcludedExtension(t *testing.T) { - assert.True(t, IsExcludedExtension("/path/to/file.rpm", []string{".rpm", ".deb"})) - assert.True(t, IsExcludedExtension("/path/to/file.deb", []string{".rpm", ".deb"})) - assert.False(t, IsExcludedExtension("/path/to/file.zip", []string{".rpm", ".deb"})) - assert.False(t, IsExcludedExtension("/path/to/file.zip", nil)) -} - func TestFindExtractor_NoReaders(t *testing.T) { + // content no extractor claims yields nil rather than an error, which is how a file that matched a + // broad mime filter but is not an archive gets skipped ctx := context.Background() - ext := FindExtractor(ctx, DefaultExtractors(), "/nonexistent/path") + dir := t.TempDir() + path := filepath.Join(dir, "plain.txt") + require.NoError(t, os.WriteFile(path, []byte("just text"), 0o644)) + + ext := FindExtractor(ctx, DefaultExtractors(), fileContent(t, path)) assert.Nil(t, ext) } @@ -241,11 +338,7 @@ func TestTarExtractor_CanExtract_NonTarFile(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("not a tar"), 0o644)) ext := &TarExtractor{} - f, err := os.Open(path) - require.NoError(t, err) - defer f.Close() - - assert.False(t, ext.CanExtract(ctx, path, f)) + assert.False(t, ext.CanExtract(ctx, fileContent(t, path))) } func TestTarExtractor_CanExtract_ZipFileReturnsFalse(t *testing.T) { @@ -255,26 +348,32 @@ func TestTarExtractor_CanExtract_ZipFileReturnsFalse(t *testing.T) { zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"}) ext := &TarExtractor{} - f, err := os.Open(zipPath) - require.NoError(t, err) - defer f.Close() // tar extractor should NOT claim to handle zip files - assert.False(t, ext.CanExtract(ctx, zipPath, f)) + assert.False(t, ext.CanExtract(ctx, fileContent(t, zipPath))) } func TestDefaultExtractionLimits(t *testing.T) { + limits := DefaultExtractionLimits(cataloging.ArchiveSearchConfig{}) + assert.Nil(t, limits.Charge, "the charge belongs to one archive, so the config cannot supply it") +} + +func TestDefaultLimits(t *testing.T) { cfg := cataloging.ArchiveSearchConfig{ - MaxExtractionSizeBytes: 1024, - MaxFileCount: 50, + MaxMemoryBytes: 111, + MaxDiskBytes: 222, } - limits := DefaultExtractionLimits(cfg) - assert.Equal(t, int64(1024), limits.MaxExtractionSizeBytes) - assert.Equal(t, 50, limits.MaxFileCount) + limits := DefaultLimits(cfg) + assert.Equal(t, int64(111), limits.MaxMemoryBytes) + assert.Equal(t, int64(222), limits.MaxDiskBytes) } func TestZipExtractor_Extract_ZipSlipPrevented(t *testing.T) { - // Create a zip with a path traversal entry + // An entry named "../../etc/passwd" no longer has anywhere to go: it becomes a field in a tar + // header, and writing a header traverses no path. So the extraction succeeds and nothing is + // created anywhere, where before the entry was refused outright and the archive failed with it. + // Where the name still matters is the path the SBOM reports, and that is clamped when the tar is + // indexed - see the archive resolver's own tests. dir := t.TempDir() ctx := context.Background() @@ -291,22 +390,28 @@ func TestZipExtractor_Extract_ZipSlipPrevented(t *testing.T) { require.NoError(t, f.Close()) ext := &ZipExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - _, err = ext.Extract(ctx, zipPath, destDir, ExtractionLimits{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "path traversal") + result, err := ext.Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{}) + require.NoError(t, err) + assert.Equal(t, 1, result.FilesExtracted) - // verify evil file was not created + // nothing is created outside the tar, at the traversed path or anywhere else _, statErr := os.Stat(filepath.Join(dir, "etc", "passwd")) assert.True(t, os.IsNotExist(statErr)) + _, statErr = os.Stat(filepath.Join(filepath.Dir(overflow.Path()), "etc")) + assert.True(t, os.IsNotExist(statErr)) + + // the name is carried through unsanitized, because this is the archive's own record of what it + // held; sanitizing happens once, where the logical filetree is built + assert.Equal(t, []string{"../../etc/passwd"}, overflowNames(t, overflow)) } -func TestTarExtractor_Extract_SymlinkInsideRootIsCreated(t *testing.T) { - // A tar archive with a symlink whose target stays inside the extraction - // directory should be written as a real symlink, not as a regular file - // containing the link target as bytes. +func TestTarExtractor_Extract_SymlinkIsRecordedAsALink(t *testing.T) { + // A symlink entry is recorded as a symlink header carrying its target, not as a regular file + // containing the target as bytes and not as a link on the real filesystem. Nothing is created, so + // there is nothing for a target to redirect; where the link points is decided inside the archive's + // own filetree when the tar is indexed. dir := t.TempDir() ctx := context.Background() @@ -338,27 +443,29 @@ func TestTarExtractor_Extract_SymlinkInsideRootIsCreated(t *testing.T) { require.NoError(t, f.Close()) ext := &TarExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - _, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + _, err = ext.Extract(ctx, fileContent(t, tarPath), overflow, ExtractionLimits{}) require.NoError(t, err) - // the link should be a real symlink - info, err := os.Lstat(filepath.Join(destDir, "link.txt")) - require.NoError(t, err) - assert.NotZero(t, info.Mode()&fs.ModeSymlink, "expected link.txt to be a symlink, got mode %v", info.Mode()) + entries := readOverflow(t, overflow) + link := entries["link.txt"] + assert.Equal(t, byte(tar.TypeSymlink), link.typeflag, "link.txt must stay a link, not become a file") + assert.Equal(t, "real.txt", link.linkTarget) + assert.Empty(t, link.body, "a link carries a target, not content") + assert.Equal(t, "real content", entries["real.txt"].body) - // reading through the link should return the target's content - readBack, err := os.ReadFile(filepath.Join(destDir, "link.txt")) - require.NoError(t, err) - assert.Equal(t, "real content", string(readBack)) + // and no link exists on the real filesystem beside the tar + _, statErr := os.Lstat(filepath.Join(filepath.Dir(overflow.Path()), "link.txt")) + assert.True(t, os.IsNotExist(statErr)) } -func TestTarExtractor_Extract_SymlinkEscapingRootIsSkipped(t *testing.T) { - // A symlink whose target resolves outside the extraction directory must not - // be created — neither as a symlink nor as a regular file. Extraction itself - // should succeed (the unsafe entry is skipped, not fatal). +func TestTarExtractor_Extract_SymlinkEscapingRootCreatesNothing(t *testing.T) { + // A link target climbing out of the archive used to be refused at extraction, because extraction + // created a real link that a later entry could be written through. Now the entry is a header: the + // target is recorded as the archive gave it and nothing is created, so there is no write to + // redirect. The target is clamped to the archive's own root when the filetree is built, which is + // what keeps it from naming the host - see the archive resolver's own tests. dir := t.TempDir() ctx := context.Background() @@ -381,23 +488,25 @@ func TestTarExtractor_Extract_SymlinkEscapingRootIsSkipped(t *testing.T) { require.NoError(t, f.Close()) ext := &TarExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - _, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + _, err = ext.Extract(ctx, fileContent(t, tarPath), overflow, ExtractionLimits{}) require.NoError(t, err) - // nothing should have been created at the destination - _, statErr := os.Lstat(filepath.Join(destDir, "passwd")) - assert.True(t, os.IsNotExist(statErr), "expected no entry to be created for escaping symlink") + // nothing on the real filesystem beside the tar + _, statErr := os.Lstat(filepath.Join(filepath.Dir(overflow.Path()), "passwd")) + assert.True(t, os.IsNotExist(statErr), "no link may be created for an escaping symlink") + + entries := readOverflow(t, overflow) + assert.Equal(t, byte(tar.TypeSymlink), entries["passwd"].typeflag) + assert.Equal(t, "../../../../etc/passwd", entries["passwd"].linkTarget) } -func TestTarExtractor_Extract_AbsoluteSymlinkTargetIsSkipped(t *testing.T) { - // Absolute symlink targets must be rejected: os.Symlink writes the literal - // target, so once the symlink is read it resolves on the host filesystem - // regardless of any safety check at extraction time. Verifying both that - // the symlink itself is not created and (importantly) that nothing was - // written under destDir along the absolute path. +func TestTarExtractor_Extract_AbsoluteSymlinkTargetCreatesNothing(t *testing.T) { + // An absolute target had to be rejected while extraction created real links: os.Symlink writes the + // literal string, so reading the link resolved on the host filesystem whatever was checked at + // extraction time. A header write creates no link, so there is nothing that resolves anywhere; + // the target is re-rooted inside the archive when the filetree is built. dir := t.TempDir() ctx := context.Background() @@ -420,15 +529,16 @@ func TestTarExtractor_Extract_AbsoluteSymlinkTargetIsSkipped(t *testing.T) { require.NoError(t, f.Close()) ext := &TarExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - _, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) + _, err = ext.Extract(ctx, fileContent(t, tarPath), overflow, ExtractionLimits{}) require.NoError(t, err) - // the symlink itself must not exist - _, statErr := os.Lstat(filepath.Join(destDir, "shadow")) - assert.True(t, os.IsNotExist(statErr), "expected absolute-target symlink to be skipped") + _, statErr := os.Lstat(filepath.Join(filepath.Dir(overflow.Path()), "shadow")) + assert.True(t, os.IsNotExist(statErr), "no link may be created for an absolute target") + + entries := readOverflow(t, overflow) + assert.Equal(t, "/etc/shadow", entries["shadow"].linkTarget) } func TestZipExtractor_Extract_ReadOnlyDirectoryPermissions(t *testing.T) { @@ -465,16 +575,21 @@ func TestZipExtractor_Extract_ReadOnlyDirectoryPermissions(t *testing.T) { require.NoError(t, f.Close()) ext := &ZipExtractor{} - destDir := filepath.Join(dir, "extracted") - require.NoError(t, os.MkdirAll(destDir, 0o755)) + overflow := overflowFor(t) - result, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{}) + result, err := ext.Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{}) require.NoError(t, err) - assert.Equal(t, 1, result.FilesExtracted) + // two entries: the directory and the file. A directory carries no bytes of its own, but its header + // and padding are charged to the disk limit, which is what bounds an archive of nothing but + // directories. + assert.Equal(t, 2, result.FilesExtracted) - content, err := os.ReadFile(filepath.Join(destDir, "META-INF", "MANIFEST.MF")) - require.NoError(t, err) - assert.Equal(t, "Manifest-Version: 1.0", string(content)) + // the directory's mode no longer decides whether the file beside it can be written - nothing is + // written into a directory - but both entries must be recorded, modes and all + entries := readOverflow(t, overflow) + assert.Equal(t, "Manifest-Version: 1.0", entries["META-INF/MANIFEST.MF"].body) + assert.Equal(t, fs.FileMode(0o555), entries["META-INF/"].mode.Perm()) + assert.Equal(t, byte(tar.TypeDir), entries["META-INF/"].typeflag) } func TestTarExtractor_Extract_ReadOnlyDirectoryPermissions(t *testing.T) { @@ -512,14 +627,655 @@ func TestTarExtractor_Extract_ReadOnlyDirectoryPermissions(t *testing.T) { require.NoError(t, f.Close()) ext := &TarExtractor{} - destDir := filepath.Join(dir, "extracted") + overflow := overflowFor(t) + + _, err = ext.Extract(ctx, fileContent(t, tarPath), overflow, ExtractionLimits{}) + require.NoError(t, err) + + entries := readOverflow(t, overflow) + assert.Equal(t, "Manifest-Version: 1.0", entries["META-INF/MANIFEST.MF"].body) + assert.Equal(t, fs.FileMode(0o555), entries["META-INF/"].mode.Perm()) +} + +func TestZipExtractor_Extract_nonPositiveLimitDisablesOnlyThatLimit(t *testing.T) { + // <= 0 means "no limit" per limit, so a caller can opt out of one without opting out of all of + // them. Substituting a default here would silently re-impose a limit the user turned off. + dir := t.TempDir() + ctx := context.Background() + + files := map[string]string{} + for i := range 20 { + files[fmt.Sprintf("file%02d.txt", i)] = "x" + } + files["big.txt"] = strings.Repeat("y", 500) + zipPath := createTestZip(t, dir, files) + + t.Run("the disk limit bites", func(t *testing.T) { + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflowFor(t), ExtractionLimits{ + Charge: diskCharge(100), + }) + require.NoError(t, err) + assert.Equal(t, TruncatedByDiskLimit, result.Truncation, "the disk limit must still bite") + }) + + t.Run("unbounded extracts everything", func(t *testing.T) { + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflowFor(t), ExtractionLimits{}) + require.NoError(t, err) + assert.False(t, result.Truncated()) + assert.Equal(t, len(files), result.FilesExtracted) + }) +} + +func TestZipExtractor_Extract_dataEdgeCases(t *testing.T) { + ctx := context.Background() + + t.Run("empty archive is a non-event", func(t *testing.T) { + dir := t.TempDir() + zipPath := createTestZip(t, dir, map[string]string{}) + + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflowFor(t), ExtractionLimits{}) + require.NoError(t, err, "a valid archive containing nothing is not a failure") + assert.Zero(t, result.FilesExtracted) + assert.False(t, result.Truncated()) + }) + + t.Run("zero-byte entry is recorded", func(t *testing.T) { + dir := t.TempDir() + zipPath := createTestZip(t, dir, map[string]string{"empty.txt": ""}) + overflow := overflowFor(t) + + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{}) + require.NoError(t, err) + assert.Equal(t, 1, result.FilesExtracted) + + // its header and the end-of-archive marker are bytes on disk even though the entry carries + // none, which is exactly why the disk limit counts what is written rather than entry sizes + assert.Equal(t, int64(3*tarBlockSize), result.BytesWritten) + + entries := readOverflow(t, overflow) + require.Contains(t, entries, "empty.txt") + assert.Empty(t, entries["empty.txt"].body) + }) + + t.Run("absolute entry path is contained", func(t *testing.T) { + // distinct from ../ traversal: an entry literally named /etc/hosts must land inside the + // extraction dir, never at the host path + dir := t.TempDir() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("/etc/hosts") + require.NoError(t, err) + _, err = w.Write([]byte("pwned")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + zipPath := filepath.Join(dir, "abs.zip") + require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0o600)) + + overflow := overflowFor(t) + + _, err = (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{}) + require.NoError(t, err) + + // it is one entry in the tar and nothing at the host path; the leading slash is dropped when + // the filetree is built, so it reads as a path inside this archive + assert.Equal(t, "pwned", readOverflow(t, overflow)["/etc/hosts"].body) + _, statErr := os.Stat(filepath.Join(dir, "etc", "hosts")) + assert.True(t, os.IsNotExist(statErr)) + }) + + t.Run("unicode and non-ascii entry names survive", func(t *testing.T) { + dir := t.TempDir() + names := map[string]string{ + "café/naïve.txt": "accents", + "日本語/ファイル.txt": "japanese", + "emoji-🎉.txt": "emoji", + } + zipPath := createTestZip(t, dir, names) + overflow := overflowFor(t) + + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{}) + require.NoError(t, err) + assert.Equal(t, len(names), result.FilesExtracted) + + entries := readOverflow(t, overflow) + for name, want := range names { + require.Contains(t, entries, name, "entry %q must be recorded under its own name", name) + assert.Equal(t, want, entries[name].body) + } + }) +} + +func TestExtract_diskLimitTruncatesOneArchive(t *testing.T) { + // the disk limit is enforced as the entries land, not between archives: a bound checked only + // between archives lets one archive overshoot by everything it writes. + // + // The budget is expressed in tar blocks because that is now what an entry costs: a 512-byte header + // plus its content padded up to the next block. Two entries here cost one block of header and one + // of data each, so a budget of two and a half entries' worth admits the first whole and refuses + // the second at its header. + ctx := context.Background() + files := map[string]string{ + "a/small.txt": strings.Repeat("s", 50), + "b/big.txt": strings.Repeat("b", 500), + } + + dir := t.TempDir() + archivePath := createTestZip(t, dir, files) + overflow := overflowFor(t) + + charge := diskCharge(3 * tarBlockSize) + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, archivePath), overflow, ExtractionLimits{Charge: charge}) + require.NoError(t, err, "reaching a limit is a truncation, not a failure") + + assert.Equal(t, TruncatedByDiskLimit, result.Truncation, + "a limit breach reports its own reason, so the log can name which bound was reached") + assert.Equal(t, 1, result.FilesExtracted) + assert.Equal(t, int64(2*tarBlockSize), result.BytesWritten, "one header and one padded data block") + + _, disk := charge.Held() + assert.Equal(t, int64(2*tarBlockSize), disk, + "the limiter holds exactly what landed: the dropped entry's header is refunded") + + // the partial tar is still a readable tar: what was written before the limit filled stays, and the + // entry that did not fit is absent rather than short + entries := readOverflow(t, overflow) + assert.Len(t, entries["a/small.txt"].body, 50) + assert.NotContains(t, entries, "b/big.txt") +} + +func TestTarExtractor_Extract_diskLimitTruncatesTheCopy(t *testing.T) { + // a tar-family archive is copied rather than walked, so the disk limit stops it at a copy chunk + // rather than on an entry boundary. What is left is a tar that ends part way through an entry, and + // keeping the entries that precede that point is the indexer's job - see the archive resolver's + // tests. Here the assertion is that the copy stops, says why, and holds only what it wrote. + ctx := context.Background() + dir := t.TempDir() + + // two entries either side of a copy chunk, so the limit can bite in the middle of the stream + // rather than refusing the first chunk and writing nothing at all + files := map[string]string{ + "a/first.txt": strings.Repeat("a", 40*1024), + "b/second.txt": strings.Repeat("b", 40*1024), + } + archivePath := createTestTarGz(t, dir, files) + overflow := overflowFor(t) + + charge := diskCharge(64 * 1024) + result, err := (&TarExtractor{}).Extract(ctx, fileContent(t, archivePath), overflow, ExtractionLimits{Charge: charge}) + require.NoError(t, err) + + assert.Equal(t, TruncatedByDiskLimit, result.Truncation) + assert.Equal(t, int64(64*1024), result.BytesWritten, "every byte written was charged, and no more") + + _, disk := charge.Held() + assert.Equal(t, result.BytesWritten, disk) + + info, err := os.Stat(overflow.Path()) + require.NoError(t, err) + assert.Equal(t, result.BytesWritten, info.Size(), "the tar holds exactly what was charged for") +} + +func TestExtract_diskLimitFallsWhenAnArchiveIsReleased(t *testing.T) { + // the whole point of a limiter over a counter: two archives of the same size both extract in full + // when the first is released between them, and the second is bounded when it is not + ctx := context.Background() + dir := t.TempDir() + zipPath := createTestZip(t, dir, map[string]string{"a.txt": strings.Repeat("a", 200)}) + + // one entry of 200 bytes costs a header block, a padded data block and the end-of-archive marker: + // four blocks. A limit of five leaves no room for a second archive while the first is held. + const oneArchive = 4 * tarBlockSize + limiter := NewLimiter(Limits{MaxDiskBytes: 5 * tarBlockSize}) + + first := limiter.Charge() + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflowFor(t), ExtractionLimits{Charge: first}) + require.NoError(t, err) + require.False(t, result.Truncated()) + require.Equal(t, int64(oneArchive), result.BytesWritten) + + t.Run("a second archive is bounded while the first is still held", func(t *testing.T) { + second := limiter.Charge() + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflowFor(t), ExtractionLimits{Charge: second}) + require.NoError(t, err) + assert.Equal(t, TruncatedByDiskLimit, result.Truncation) + second.Release() + }) + + t.Run("and extracts in full once the first is released", func(t *testing.T) { + first.Release() + _, disk := limiter.InUse() + require.Zero(t, disk, "releasing every charge must take the limiter back to nothing") + + third := limiter.Charge() + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflowFor(t), ExtractionLimits{Charge: third}) + require.NoError(t, err) + assert.False(t, result.Truncated(), "a counter would have refused this; a limiter does not") + assert.Equal(t, int64(oneArchive), result.BytesWritten) + }) +} + +// createDirOnlyZip writes a zip of nothing but directory entries. +func createDirOnlyZip(t *testing.T, dir string, count int) string { + t.Helper() + zipPath := filepath.Join(dir, "dirs.zip") + f, err := os.Create(zipPath) + require.NoError(t, err) + defer f.Close() + + w := zip.NewWriter(f) + for i := range count { + hdr := &zip.FileHeader{Name: fmt.Sprintf("d%04d/", i)} + hdr.SetMode(fs.ModeDir | 0o755) + _, err := w.CreateHeader(hdr) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return zipPath +} + +// createDirOnlyTarGz writes a gzipped tar of nothing but directory entries. +func createDirOnlyTarGz(t *testing.T, dir string, count int) string { + t.Helper() + tarPath := filepath.Join(dir, "dirs.tar.gz") + f, err := os.Create(tarPath) + require.NoError(t, err) + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + for i := range count { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: fmt.Sprintf("d%04d/", i), + Typeflag: tar.TypeDir, + Mode: 0o755, + })) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return tarPath +} + +func TestExtract_directoryEntriesAreBoundedByDisk(t *testing.T) { + // an archive of directory entries alone carries no content, so what bounds it is the disk limit + // charging each entry's header and the tar's padding. That is the resource such an archive + // actually consumes, and it is the only bound left now that the entry count is gone. + ctx := context.Background() + + t.Run("zip", func(t *testing.T) { + dir := t.TempDir() + zipPath := createDirOnlyZip(t, dir, 50) + overflow := overflowFor(t) + + // each directory entry is one header block; room for twelve blocks admits ten entries plus the + // two end-of-archive blocks + result, err := (&ZipExtractor{}).Extract(ctx, fileContent(t, zipPath), overflow, ExtractionLimits{ + Charge: diskCharge(12 * tarBlockSize), + }) + require.NoError(t, err, "a bound is a truncation, not a failure") + assert.Equal(t, TruncatedByDiskLimit, result.Truncation) + assert.Less(t, result.FilesExtracted, 50, "the bound must stop the archive short") + assert.NotEmpty(t, overflowNames(t, overflow), "what was written before the bound stays readable") + }) + + t.Run("tar.gz is copied whole, so the disk limit is what stops it", func(t *testing.T) { + // a tar is not walked entry by entry: the copy takes the whole archive, and the disk limit is + // charged as those bytes land + dir := t.TempDir() + tarPath := createDirOnlyTarGz(t, dir, 50) + overflow := overflowFor(t) + + result, err := (&TarExtractor{}).Extract(ctx, fileContent(t, tarPath), overflow, ExtractionLimits{ + Charge: diskCharge(-1), + }) + require.NoError(t, err) + assert.False(t, result.Truncated()) + assert.Len(t, overflowNames(t, overflow), 50) + }) +} + +// symlinkEntry describes one archive entry for the escape tests: a symlink when linkTarget is set, +// a regular file otherwise. +type symlinkEntry struct { + name string + linkTarget string + body string +} + +func createSymlinkChainTarGz(t *testing.T, dir, name string, entries []symlinkEntry) string { + t.Helper() + path := filepath.Join(dir, name) + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + for _, e := range entries { + if e.linkTarget != "" { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: e.name, + Typeflag: tar.TypeSymlink, + Linkname: e.linkTarget, + Mode: 0o777, + })) + continue + } + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: e.name, + Mode: 0o644, + Size: int64(len(e.body)), + })) + _, err := tw.Write([]byte(e.body)) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return path +} + +func createSymlinkChainZip(t *testing.T, dir, name string, entries []symlinkEntry) string { + t.Helper() + path := filepath.Join(dir, name) + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + w := zip.NewWriter(f) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name} + body := e.body + if e.linkTarget != "" { + // a zip stores a symlink as an entry whose mode carries the symlink bit and whose + // content is the link target + hdr.SetMode(fs.ModeSymlink | 0o777) + body = e.linkTarget + } else { + hdr.SetMode(0o644) + } + fw, err := w.CreateHeader(hdr) + require.NoError(t, err) + _, err = fw.Write([]byte(body)) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return path +} + +// findEscapedFiles returns every path under base, outside destDir, whose basename is name. +func findEscapedFiles(t *testing.T, base, destDir, name string) []string { + t.Helper() + var found []string + require.NoError(t, filepath.WalkDir(base, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil // an escaping symlink can make a walk stumble; that is not the assertion + } + if entry.IsDir() { + return nil + } + if filepath.Base(path) != name { + return nil + } + if rel, relErr := filepath.Rel(destDir, path); relErr == nil && !strings.HasPrefix(rel, "..") { + return nil + } + found = append(found, path) + return nil + })) + return found +} + +func TestExtract_symlinkChainEscapeWritesNothing(t *testing.T) { + // two cooperating link entries used to each pass a lexical check and together resolve outside the + // extraction directory: "d" -> "." landed on the root, so "d/up" -> ".." was created AS + // "/up" -> ".." and pointed above it, and a third entry was written through it. + // + // The chain needed real links on a real filesystem to work. Entries are headers now, so the whole + // class is gone rather than closed: no link is created, so there is nothing for a later entry to + // be written through, and no entry is written anywhere in the first place. The archive is kept in + // the suite because "nothing outside the tar is created" is the property to keep asserting, and + // this is the input that would have violated it. + chain := []symlinkEntry{ + {name: "d", linkTarget: "."}, + {name: "d/up", linkTarget: ".."}, + {name: "up/PWNED", body: "arbitrary write"}, + } + + // three link pairs, each climbing one more level. A fix that only walked one level would pass a + // one-level test and still let this through. + escalating := []symlinkEntry{ + {name: "a", linkTarget: "."}, + {name: "a/b", linkTarget: ".."}, + {name: "b/c", linkTarget: "."}, + {name: "b/c/d", linkTarget: ".."}, + {name: "b/d/e", linkTarget: "."}, + {name: "b/d/e/f", linkTarget: ".."}, + {name: "b/d/f/PWNED", body: "arbitrary write, three levels up"}, + } + + for _, tc := range []struct { + name string + entries []symlinkEntry + }{ + {name: "one pair", entries: chain}, + {name: "three pairs escalating", entries: escalating}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Run("tar.gz", func(t *testing.T) { + assertNoEscape(t, createSymlinkChainTarGz, func() Extractor { return &TarExtractor{} }, "chain.tar.gz", tc.entries) + }) + t.Run("zip", func(t *testing.T) { + assertNoEscape(t, createSymlinkChainZip, func() Extractor { return &ZipExtractor{} }, "chain.zip", tc.entries) + }) + }) + } +} + +func assertNoEscape(t *testing.T, build func(*testing.T, string, string, []symlinkEntry) string, newExtractor func() Extractor, archiveName string, entries []symlinkEntry) { + t.Helper() + + // several levels of headroom above the extraction directory, all inside the test's own temp + // tree, so an escape has somewhere to land and is still cleaned up + base := t.TempDir() + destDir := filepath.Join(base, "one", "two", "three", "contents") require.NoError(t, os.MkdirAll(destDir, 0o755)) - result, err := ext.Extract(ctx, tarPath, destDir, ExtractionLimits{}) - require.NoError(t, err) - assert.Equal(t, 1, result.FilesExtracted) + archivePath := build(t, base, archiveName, entries) - extracted, err := os.ReadFile(filepath.Join(destDir, "META-INF", "MANIFEST.MF")) + overflow, err := newOverflowTar(filepath.Join(destDir, overflowTarName)) require.NoError(t, err) - assert.Equal(t, "Manifest-Version: 1.0", string(extracted)) + t.Cleanup(func() { _ = overflow.Close() }) + + _, err = newExtractor().Extract(context.Background(), fileContent(t, archivePath), overflow, ExtractionLimits{}) + require.NoError(t, err, "an unsafe entry is skipped, not a failure of the whole archive") + + assert.Empty(t, findEscapedFiles(t, base, destDir, "PWNED"), + "nothing may be written outside the extraction directory") + + // and nothing was created inside it either, other than the one tar + created, err := os.ReadDir(destDir) + require.NoError(t, err) + require.Len(t, created, 1) + assert.Equal(t, overflowTarName, created[0].Name()) +} + +func TestTarExtractor_Extract_legitimateRelativeSymlinkIsCarriedThrough(t *testing.T) { + // containing an escape must not cost what real archives contain: a relative link into a sibling + // directory of the archive's own tree keeps its target verbatim, so the filetree can resolve it. + // That it then resolves is asserted where resolution happens, in the archive resolver's tests. + dir := t.TempDir() + + archivePath := createSymlinkChainTarGz(t, dir, "legit.tar.gz", []symlinkEntry{ + {name: "lib/real.txt", body: "real content"}, + {name: "bin/link.txt", linkTarget: "../lib/real.txt"}, + {name: "lib/sub/deep.txt", body: "deep"}, + {name: "bin/deep.txt", linkTarget: "../lib/sub/deep.txt"}, + }) + overflow := overflowFor(t) + + _, err := (&TarExtractor{}).Extract(context.Background(), fileContent(t, archivePath), overflow, ExtractionLimits{}) + require.NoError(t, err) + + entries := readOverflow(t, overflow) + assert.Equal(t, "../lib/real.txt", entries["bin/link.txt"].linkTarget) + assert.Equal(t, "../lib/sub/deep.txt", entries["bin/deep.txt"].linkTarget) + assert.Equal(t, "real content", entries["lib/real.txt"].body) + assert.Equal(t, "deep", entries["lib/sub/deep.txt"].body) +} + +func TestExtract_siblingOfExtractionDirectoryWritesNothing(t *testing.T) { + // the realistic form of the string-prefix hole SafeJoin was fixed for: an entry naming + // "../contents-evil/..." shares the extraction directory's prefix and is outside it. + // + // Both extractors used to fail the archive here, because SafeJoin refused the entry. Nothing joins + // an entry name to a path any more, so the entry is recorded as the name it claims and no + // filesystem path is derived from it at all. The assertion that matters is unchanged: nothing is + // written beside the extraction directory. + ctx := context.Background() + + for _, tc := range []struct { + name string + build func(t *testing.T, dir string, files map[string]string) string + extract func(path string, overflow *OverflowTar) (ExtractionResult, error) + }{ + { + name: "zip", + build: createTestZip, + extract: func(path string, overflow *OverflowTar) (ExtractionResult, error) { + return (&ZipExtractor{}).Extract(ctx, fileContent(t, path), overflow, ExtractionLimits{}) + }, + }, + { + name: "tar.gz", + build: createTestTarGz, + extract: func(path string, overflow *OverflowTar) (ExtractionResult, error) { + return (&TarExtractor{}).Extract(ctx, fileContent(t, path), overflow, ExtractionLimits{}) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + base := t.TempDir() + destDir := filepath.Join(base, "contents") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + archivePath := tc.build(t, base, map[string]string{"../contents-evil/x.txt": "escaped"}) + + overflow, err := newOverflowTar(filepath.Join(base, overflowTarName)) + require.NoError(t, err) + t.Cleanup(func() { _ = overflow.Close() }) + + _, err = tc.extract(archivePath, overflow) + require.NoError(t, err, "a hostile name is a header field, not a failure") + + _, statErr := os.Stat(filepath.Join(base, "contents-evil", "x.txt")) + assert.True(t, os.IsNotExist(statErr), + "nothing may be written to a sibling of the extraction directory") + + entries, err := os.ReadDir(destDir) + require.NoError(t, err) + assert.Empty(t, entries, "the extraction directory stays empty: entries live in the tar") + + assert.Equal(t, "escaped", readOverflow(t, overflow)["../contents-evil/x.txt"].body) + }) + } +} + +// scriptPrefixedZip is a self-extracting archive in the shape Spring Boot produces: a launcher +// script with a complete zip concatenated onto it. Built rather than committed, so what makes the +// case is visible in the test. +func scriptPrefixedZip(t *testing.T, prefix string, files map[string]string) []byte { + t.Helper() + var zbuf bytes.Buffer + w := zip.NewWriter(&zbuf) + for _, name := range sortedNames(files) { + f, err := w.Create(name) + require.NoError(t, err) + _, err = f.Write([]byte(files[name])) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return append([]byte(prefix), zbuf.Bytes()...) +} + +func bytesContent(name string, data []byte) Content { + return Content{Name: name, Reader: bytes.NewReader(data)} +} + +func Test_HasZipEndOfCentralDirectory(t *testing.T) { + var plainZip bytes.Buffer + zw := zip.NewWriter(&plainZip) + f, err := zw.Create("a.txt") + require.NoError(t, err) + _, err = f.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + var tarGz bytes.Buffer + gw := gzip.NewWriter(&tarGz) + tw := tar.NewWriter(gw) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "a.txt", Size: 5, Mode: 0o600})) + _, err = tw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + tests := []struct { + name string + data []byte + want bool + }{ + {"a plain zip", plainZip.Bytes(), true}, + { + "a zip behind a launcher script - the case content sniffing types by its head", + scriptPrefixedZip(t, "#!/bin/bash\nexec java -jar \"$0\" \"$@\"\nexit 0\n", + map[string]string{"META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n"}), + true, + }, + {"prose, whatever it is named", []byte("this is not an archive, it is a sentence"), false}, + {"a tar.gz, which is an archive but not a zip", tarGz.Bytes(), false}, + {"empty", nil, false}, + {"shorter than the signature", []byte("PK"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HasZipEndOfCentralDirectory(bytes.NewReader(tt.data))) + }) + } +} + +func Test_ZipExtractor_canExtractAZipBehindALauncherScript(t *testing.T) { + // the regression this closes: a Spring Boot executable jar sniffs as text/x-shellscript, so + // format identification refuses it and every package inside is lost + data := scriptPrefixedZip(t, "#!/bin/bash\necho launcher\nexit 0\n", map[string]string{ + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "BOOT-INF/lib/dep.jar": "not really a jar, just bytes", + }) + + extractor := &ZipExtractor{} + require.True(t, extractor.CanExtract(context.Background(), bytesContent("app.jar", data)), + "a zip behind a launcher script must be recognized") + + // and it extracts, because archive/zip finds the central directory from the end + overflow, err := newOverflowTar(filepath.Join(t.TempDir(), "app.jar.tar")) + require.NoError(t, err) + result, err := extractor.Extract(context.Background(), bytesContent("app.jar", data), overflow, + ExtractionLimits{Charge: diskCharge(-1)}) + require.NoError(t, err) + assert.Positive(t, result.BytesWritten, "the entries behind the script prefix must actually be written") + assert.Empty(t, string(result.Truncation)) +} + +func Test_ZipExtractor_refusesANonArchiveThatNothingIdentifies(t *testing.T) { + // the probe must not turn a file with no central directory into an archive. Note the name matters + // here in a way it does not in a scan: identification matches on it, so `notes.zip` is claimed by + // name before the probe is reached. That is why the scenario `misnamed non-archive is not + // extracted` is asserted at the task level, where the candidate set decides - see + // Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError. + extractor := &ZipExtractor{} + for _, name := range []string{"app.jar", "bundle", "launcher"} { + t.Run(name, func(t *testing.T) { + content := bytesContent(name, []byte("plain text, no central directory anywhere in it")) + assert.False(t, extractor.CanExtract(context.Background(), content)) + }) + } } diff --git a/internal/archive/limits.go b/internal/archive/limits.go new file mode 100644 index 000000000..0b01adc9b --- /dev/null +++ b/internal/archive/limits.go @@ -0,0 +1,255 @@ +package archive + +import ( + "errors" + "sync" +) + +// ErrDiskLimitReached reports that an archive's content could not be admitted because placing it +// would have taken disk usage past the disk limit. It is not a failure of the scan: the archive is +// skipped and the walk continues. +var ErrDiskLimitReached = errors.New("archive content would exceed the disk limit") + +// Limits bounds how much archive content one scan may hold at once, and decides where an +// archive's content is held. +// +// These are in-use limits rather than counters. An in-use limit measures what is held right now and +// falls when an archive is released, so what it bounds is a scan's peak concurrent usage. A bound on +// bytes ever written measures no state that ever exists, because every archive's extraction directory +// is removed on the way out: it would restrict a long scan of small archives that never held much at +// once, and permit a deep scan that held far more. +// +// Both fields read three states the same way: positive is the limit, zero means none of that +// resource may be used at all, and negative means that resource is not bounded. Unbounded is a real +// hazard on either - a crafted archive can exhaust memory or disk - so it is reachable only by asking +// for it explicitly with a negative value, never as the reading of an unset or zeroed field. +type Limits struct { + // MaxMemoryBytes bounds the archive content held in memory at once. Content is held in memory + // while this admits it and overflows to disk when it does not - there is no separate configured size + // at which overflowing begins. Zero holds nothing, sending every archive's content to disk; + // negative holds content regardless of how much is already held. + MaxMemoryBytes int64 + + // MaxDiskBytes bounds the archive content placed on disk at once: content overflowed there instead + // of being held in memory, plus the bytes of the entries extracted from it. Zero writes nothing + // to disk, so content that does not fit in memory has nowhere to go and its archive is skipped; + // negative overflows as needed with no ceiling. + MaxDiskBytes int64 +} + +// Limiter measures how much archive content a scan is holding right now. One instance per task run, +// which is one scan - the only scoping under which "held at once" means anything. +// +// Guarded by a mutex even though the archive walk is sequential today: the archive task merges into +// a shared builder while top-level catalogers run, so this code already lives in a concurrent +// neighbourhood and an unguarded total here would be a data race the day someone parallelises the +// walk. +// +// A nil *Limiter enforces nothing, which is what an extraction with no configured bounds gets. +type Limiter struct { + limits Limits + mu sync.Mutex + memory int64 + disk int64 + + // peaks are the high-water marks of the two gauges above: the most this scan held at any one + // moment, rather than the sum of what every archive held. They only ever rise, which is the one + // place in this type where that is correct - a peak that fell on release would be measuring the + // same thing the gauge already measures. + peakMemory int64 + peakDisk int64 +} + +// NewLimiter returns a limiter enforcing the given limits. +func NewLimiter(limits Limits) *Limiter { + return &Limiter{limits: limits} +} + +// MaxDiskBytes returns the configured disk bound, for attributing a skip in a log line. +func (l *Limiter) MaxDiskBytes() int64 { + if l == nil { + return 0 + } + return l.limits.MaxDiskBytes +} + +// Peak reports the most archive content this scan held at any one moment, in memory and on disk. +// These are the same quantities the limits bound, so they say how close a scan came to them. +func (l *Limiter) Peak() (memory, disk int64) { + if l == nil { + return 0, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + return l.peakMemory, l.peakDisk +} + +// InUse reports the archive content held right now, in memory and on disk. +func (l *Limiter) InUse() (memory, disk int64) { + if l == nil { + return 0, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + return l.memory, l.disk +} + +// Charge starts one archive's accounting against this limiter. Releasing the returned charge gives +// back exactly what it took, which is what makes this a limiter rather than a counter. +func (l *Limiter) Charge() *Charge { + if l == nil { + return nil + } + return &Charge{limiter: l} +} + +// Charge is one archive's accumulated draw on the limiter. +// +// Bytes are charged to it as they land, never from a size an entry declared: both tar and zip +// headers carry an entry size and a crafted archive is free to lie about it, so a reservation made +// against a declared size bounds what an attacker claims rather than what the machine does. The +// charge accumulates what was actually taken, so Release returns exactly that. +// +// A nil *Charge charges nothing and refuses nothing. +type Charge struct { + limiter *Limiter + memory int64 + disk int64 +} + +// admitsThreeState reports whether n more bytes may be charged against a limit that is already +// holding held, reading the limit the same way on both bounds it governs: positive is the limit +// itself, zero refuses unconditionally (none of that resource may be used at all), and negative +// admits unconditionally (that resource is not bounded). One reading shared by Memory and Disk +// rather than the same three-way condition written twice, since the two have already drifted once. +func admitsThreeState(limit, held, n int64) bool { + switch { + case limit < 0: + return true + case limit == 0: + return false + default: + return held+n <= limit + } +} + +// Memory charges n bytes of content held in memory, reporting false when the memory limit does not +// admit it - which for a zero limit is every chunk, sending content to disk unconditionally, since +// there is no separate threshold naming a size at which content overflows. Nothing is charged when it +// reports false. +func (c *Charge) Memory(n int64) bool { + if c == nil || c.limiter == nil || n <= 0 { + return true + } + l := c.limiter + l.mu.Lock() + defer l.mu.Unlock() + if !admitsThreeState(l.limits.MaxMemoryBytes, l.memory, n) { + return false + } + l.memory += n + c.memory += n + if l.memory > l.peakMemory { + l.peakMemory = l.memory + } + return true +} + +// Disk charges n bytes of content placed on disk, reporting false when the disk limit does not admit +// it - which for a zero limit is every chunk, so content with nowhere further to go leaves its +// archive skipped exactly as content exceeding a positive disk limit does. Nothing is charged when it +// reports false. +func (c *Charge) Disk(n int64) bool { + if c == nil || c.limiter == nil || n <= 0 { + return true + } + l := c.limiter + l.mu.Lock() + defer l.mu.Unlock() + if !admitsThreeState(l.limits.MaxDiskBytes, l.disk, n) { + return false + } + l.disk += n + c.disk += n + if l.disk > l.peakDisk { + l.peakDisk = l.disk + } + return true +} + +// IndexRecord charges the in-memory cost of holding one entry's index record - the *Entry, its +// header, and the resolver node built over it - preferring the memory budget and overflowing to the +// disk budget when memory will not admit it. +// +// It overflows rather than refusing because the index is not content: a resolver needs an entry's +// record to reach that entry's bytes at all, and the memory budget may legitimately be zero, so an +// index that could be refused would leave an archive whose content is entirely on disk with no way to +// read it back. What this bounds instead is entry count - the one dimension the byte budgets never +// bounded, so an archive of millions of empty entries could grow the index without limit. It reports +// false only when neither budget admits the record, which truncates the archive. What it charges is +// released by Release along with everything else this charge took, so the index accounting falls when +// the archive does. +func (c *Charge) IndexRecord(n int64) bool { + if c == nil || c.limiter == nil || n <= 0 { + return true + } + if c.Memory(n) { + return true + } + return c.Disk(n) +} + +// RefundDisk gives back n bytes charged for content that was removed again, so a partial file +// dropped at a limit does not leave the limiter holding bytes that are no longer on disk. +func (c *Charge) RefundDisk(n int64) { + if c == nil || c.limiter == nil || n <= 0 { + return + } + l := c.limiter + l.mu.Lock() + defer l.mu.Unlock() + if n > c.disk { + n = c.disk + } + l.disk -= n + c.disk -= n +} + +// RefundMemory gives back n bytes charged for content no longer held in memory. +func (c *Charge) RefundMemory(n int64) { + if c == nil || c.limiter == nil || n <= 0 { + return + } + l := c.limiter + l.mu.Lock() + defer l.mu.Unlock() + if n > c.memory { + n = c.memory + } + l.memory -= n + c.memory -= n +} + +// Held reports what this charge is currently holding, in memory and on disk. +func (c *Charge) Held() (memory, disk int64) { + if c == nil || c.limiter == nil { + return 0, 0 + } + c.limiter.mu.Lock() + defer c.limiter.mu.Unlock() + return c.memory, c.disk +} + +// Release returns everything this charge took, so both limits fall by what this archive held. Safe +// to call more than once. +func (c *Charge) Release() { + if c == nil || c.limiter == nil { + return + } + l := c.limiter + l.mu.Lock() + defer l.mu.Unlock() + l.memory -= c.memory + l.disk -= c.disk + c.memory, c.disk = 0, 0 +} diff --git a/internal/archive/limits_test.go b/internal/archive/limits_test.go new file mode 100644 index 000000000..32e367146 --- /dev/null +++ b/internal/archive/limits_test.go @@ -0,0 +1,210 @@ +package archive + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimiter_chargeAndRelease(t *testing.T) { + limiter := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 200}) + + first := limiter.Charge() + require.True(t, first.Memory(60)) + require.True(t, first.Disk(150)) + + mem, disk := limiter.InUse() + assert.Equal(t, int64(60), mem) + assert.Equal(t, int64(150), disk) + + second := limiter.Charge() + assert.False(t, second.Memory(50), "60 + 50 is over the memory limit") + assert.False(t, second.Disk(60), "150 + 60 is over the disk limit") + assert.True(t, second.Memory(40), "and exactly at the limit is admitted") + + mem, _ = limiter.InUse() + assert.Equal(t, int64(100), mem, "a refused charge takes nothing") + + first.Release() + mem, disk = limiter.InUse() + assert.Equal(t, int64(40), mem, "release gives back exactly what that charge took") + assert.Zero(t, disk) + + first.Release() + mem, _ = limiter.InUse() + assert.Equal(t, int64(40), mem, "releasing twice must not give back what was never taken") +} + +func TestLimiter_refund(t *testing.T) { + // a partial entry dropped at a bound is no longer on disk, so the limiter must not go on holding it + limiter := NewLimiter(Limits{MaxDiskBytes: 100}) + charge := limiter.Charge() + + require.True(t, charge.Disk(80)) + charge.RefundDisk(80) + + _, disk := limiter.InUse() + assert.Zero(t, disk) + assert.True(t, charge.Disk(100), "the refunded room is available again") + + charge.RefundDisk(500) + _, disk = limiter.InUse() + assert.Zero(t, disk, "a refund larger than what was charged cannot drive the limiter negative") +} + +func TestLimiter_boundsAreIndependent(t *testing.T) { + // a caller may bound one limit without bounding the other: a limit on one side of the pair does + // not change how the other side reads its own value. + memoryBounded := NewLimiter(Limits{MaxMemoryBytes: 10, MaxDiskBytes: -1}).Charge() + assert.False(t, memoryBounded.Memory(50)) + assert.True(t, memoryBounded.Disk(1_000_000), "a negative disk limit admits anything") + + diskBounded := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 10}).Charge() + assert.False(t, diskBounded.Memory(1_000_000), "a zero memory limit refuses everything rather than admitting anything") + assert.False(t, diskBounded.Disk(50)) +} + +func TestLimiter_threeStateReadingIsUniform(t *testing.T) { + // Memory and Disk read their limit the same way: positive is the limit, zero refuses + // unconditionally, negative admits unconditionally. The two used to disagree in opposite + // directions - Memory got zero right and negative wrong, Disk got negative right and zero wrong. + t.Run("positive is the limit", func(t *testing.T) { + c := NewLimiter(Limits{MaxMemoryBytes: 100, MaxDiskBytes: 100}).Charge() + assert.True(t, c.Memory(100)) + assert.False(t, c.Memory(1)) + assert.True(t, c.Disk(100)) + assert.False(t, c.Disk(1)) + }) + + t.Run("zero refuses unconditionally on both", func(t *testing.T) { + c := NewLimiter(Limits{MaxMemoryBytes: 0, MaxDiskBytes: 0}).Charge() + assert.False(t, c.Memory(1)) + assert.False(t, c.Disk(1)) + }) + + t.Run("negative admits unconditionally on both", func(t *testing.T) { + c := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}).Charge() + assert.True(t, c.Memory(1_000_000_000)) + assert.True(t, c.Disk(1_000_000_000)) + }) +} + +func TestLimiter_nilIsUnbounded(t *testing.T) { + // what an extraction with no configured bounds gets: charges succeed and nothing is measured. + // This is distinct from a configured but non-positive MaxMemoryBytes, which refuses instead - + // there is no limiter here at all to refuse anything. + var limiter *Limiter + charge := limiter.Charge() + assert.Nil(t, charge) + + assert.True(t, charge.Memory(1_000_000)) + assert.True(t, charge.Disk(1_000_000)) + charge.RefundDisk(10) + charge.Release() + + mem, disk := charge.Held() + assert.Zero(t, mem) + assert.Zero(t, disk) + + mem, disk = limiter.InUse() + assert.Zero(t, mem) + assert.Zero(t, disk) + assert.Zero(t, limiter.MaxDiskBytes()) +} + +func TestLimiter_concurrentChargesAgree(t *testing.T) { + // the walk is sequential today, but the archive task merges into a shared builder while top-level + // catalogers run, so the accounting is guarded rather than left for whoever parallelises it. + // Both limits need an explicit value here: zero now refuses every charge on either bound, so + // MaxMemoryBytes is set comfortably above what this test charges and MaxDiskBytes is negative + // for unbounded, rather than leaving either at its zero value. + limiter := NewLimiter(Limits{MaxMemoryBytes: 1_000_000, MaxDiskBytes: -1}) + + var wg sync.WaitGroup + charges := make([]*Charge, 50) + for i := range charges { + charges[i] = limiter.Charge() + wg.Add(1) + go func(c *Charge) { + defer wg.Done() + for range 100 { + c.Memory(1) + c.Disk(2) + } + }(charges[i]) + } + wg.Wait() + + mem, disk := limiter.InUse() + assert.Equal(t, int64(50*100), mem) + assert.Equal(t, int64(50*100*2), disk) + + for _, c := range charges { + c.Release() + } + mem, disk = limiter.InUse() + assert.Zero(t, mem) + assert.Zero(t, disk) +} + +func Test_Limiter_peakIsAHighWaterMark(t *testing.T) { + // the peak is what the scan held at its worst moment, so it must survive the release that takes + // the in-use gauge back down - a peak that fell on release would measure what InUse already does + l := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}) + + first := l.Charge() + require.True(t, first.Memory(100)) + require.True(t, first.Disk(300)) + first.Release() + + second := l.Charge() + require.True(t, second.Memory(40)) + require.True(t, second.Disk(50)) + + memory, disk := l.InUse() + assert.Equal(t, int64(40), memory) + assert.Equal(t, int64(50), disk) + + peakMemory, peakDisk := l.Peak() + assert.Equal(t, int64(100), peakMemory, "peak memory is the most held at once, not the most recently held") + assert.Equal(t, int64(300), peakDisk, "and the same for disk") +} + +func Test_Limiter_peakCountsConcurrentHoldersTogether(t *testing.T) { + // two archives held at the same time peak at their sum, which is the number the limits are + // enforced against and therefore the number worth reporting + l := NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}) + + outer := l.Charge() + require.True(t, outer.Memory(100)) + inner := l.Charge() + require.True(t, inner.Memory(60)) + inner.Release() + outer.Release() + + peakMemory, _ := l.Peak() + assert.Equal(t, int64(160), peakMemory) +} + +func Test_Limiter_peakOfANilLimiterIsZero(t *testing.T) { + var l *Limiter + memory, disk := l.Peak() + assert.Zero(t, memory) + assert.Zero(t, disk) +} + +func Test_Limiter_refusedChargeDoesNotMoveThePeak(t *testing.T) { + // nothing is charged when a limit refuses, so nothing may be recorded either: a peak that rose + // on refusal would report bytes the scan never held + l := NewLimiter(Limits{MaxMemoryBytes: 50, MaxDiskBytes: 50}) + + c := l.Charge() + assert.False(t, c.Memory(500)) + assert.False(t, c.Disk(500)) + + peakMemory, peakDisk := l.Peak() + assert.Zero(t, peakMemory) + assert.Zero(t, peakDisk) +} diff --git a/internal/archive/notify.go b/internal/archive/notify.go new file mode 100644 index 000000000..fd3246d5c --- /dev/null +++ b/internal/archive/notify.go @@ -0,0 +1,117 @@ +package archive + +import ( + "github.com/anchore/syft/internal/log" +) + +// Notify receives structured events from archive extraction. A nil Notify is the no-op. +// +// The signature is deliberately `func(any)` rather than a set of typed callbacks or an interface with +// a method per event. Extraction is a hot path - a scan of a big image enters thousands of archives - +// and what instrumentation costs there has to be nothing when it is off. A single func field is one +// nil check, and a handler that cares about one event type asserts for it and ignores the rest, so +// adding an event never changes a signature or breaks a handler that did not ask for it. +// +// Guard the call rather than relying on a nil check inside the callee: +// +// if notify != nil { +// notify(EntriesOverflowed{Archive: name, Bytes: n}) +// } +// +// Building the event value is what costs - putting a struct in an `any` allocates - so the guard is +// what makes "off" free rather than merely cheap. Send exists for the paths where that does not +// matter. +type Notify func(msg any) + +// Send delivers one event, doing nothing when there is no handler. Convenient, but it builds the +// event whether or not anyone is listening: on a hot path, guard the call instead. +func (n Notify) Send(msg any) { + if n == nil { + return + } + n(msg) +} + +// Tee returns a Notify that delivers to each of the given handlers, skipping the nil ones, and nil +// when none of them are live - so a tee of nothing stays free. +func Tee(handlers ...Notify) Notify { + var live []Notify + for _, h := range handlers { + if h != nil { + live = append(live, h) + } + } + switch len(live) { + case 0: + return nil + case 1: + return live[0] + } + return func(msg any) { + for _, h := range live { + h(msg) + } + } +} + +// OverflowReason says why content was written to disk rather than held. +type OverflowReason string + +const ( + // MemoryLimitReached means the scan was already holding as much as the memory limit admits. + MemoryLimitReached OverflowReason = "memory limit reached" + + // MemoryHoldsNothing means the memory limit is zero, so nothing is ever held and every archive's + // content goes to disk by policy rather than by pressure. + MemoryHoldsNothing OverflowReason = "memory limit is zero" +) + +// ContentOverflowed reports that one archive's own bytes were written to disk instead of being held in +// memory. Bytes is the size of the archive. +type ContentOverflowed struct { + Archive string + Bytes int64 + Reason OverflowReason +} + +// EntriesOverflowed reports that the entries an archive's store was holding have been written to its +// overflow blob. Entries and Bytes are what moved, not what the archive holds in total: a store spills +// everything it is holding at once, and may spill more than once as it fills again. +type EntriesOverflowed struct { + Archive string + Entries int + Bytes int64 + Reason OverflowReason +} + +// Skipped reports that an archive contributed nothing, and why. Not a failure - the scan +// continues - but the packages inside it are absent from the SBOM, which is worth being able to see. +type Skipped struct { + Archive string + Reason string +} + +// Truncated reports that an archive was cataloged from part of its contents, naming the bound +// that stopped it. +type Truncated struct { + Archive string + Reason TruncationReason +} + +// LogNotify is the default handler: it writes each event to the debug log. Anything it does not +// recognize is ignored rather than logged blindly, so an event added for one consumer does not turn +// into noise for everyone. +func LogNotify(msg any) { + switch e := msg.(type) { + case ContentOverflowed: + log.WithFields("archive", e.Archive, "bytes", e.Bytes, "reason", string(e.Reason)). + Debug("archive content written to disk rather than held in memory") + case EntriesOverflowed: + log.WithFields("archive", e.Archive, "entries", e.Entries, "bytes", e.Bytes, "reason", string(e.Reason)). + Debug("archive entries written to disk rather than held in memory") + case Skipped: + log.WithFields("archive", e.Archive, "reason", e.Reason).Debug("archive skipped") + case Truncated: + log.WithFields("archive", e.Archive, "reason", string(e.Reason)).Debug("archive cataloged from part of its contents") + } +} diff --git a/internal/archive/notify_test.go b/internal/archive/notify_test.go new file mode 100644 index 000000000..3013c8dfb --- /dev/null +++ b/internal/archive/notify_test.go @@ -0,0 +1,98 @@ +package archive + +import ( + "archive/tar" + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recorder is what a consumer of these events looks like: one func, a type assertion for what it +// cares about, and silence for everything else. +type recorder struct { + overflows []EntriesOverflowed + other int +} + +func (r *recorder) notify(msg any) { + if e, ok := msg.(EntriesOverflowed); ok { + r.overflows = append(r.overflows, e) + return + } + r.other++ +} + +func TestNotify_reportsWhichArchiveSpilledAndWhy(t *testing.T) { + rec := &recorder{} + s := NewEntryStore(t.TempDir(), "lib/app.jar", rec.notify) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + charge := memCharge(8) + for _, body := range []string{"aaaa", "bbbbbb"} { + _, err := s.Add(regularHeader(body+".txt", int64(len(body))), bytes.NewReader([]byte(body)), charge) + require.NoError(t, err) + } + + require.Len(t, rec.overflows, 1) + assert.Equal(t, "lib/app.jar", rec.overflows[0].Archive, "the event has to name the archive, or it says nothing actionable") + assert.Equal(t, MemoryLimitReached, rec.overflows[0].Reason) + assert.Equal(t, 2, rec.overflows[0].Entries, "a store moves everything it is holding, not just the entry that did not fit") + assert.Equal(t, int64(10), rec.overflows[0].Bytes) +} + +func TestNotify_distinguishesPressureFromPolicy(t *testing.T) { + // a scan at its memory bound and a scan whose bound is zero are different findings: the first says + // the limit is biting, the second says nothing was ever going to be held + rec := &recorder{} + s := NewEntryStore(t.TempDir(), "app.jar", rec.notify) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + _, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), memCharge(0)) + require.NoError(t, err) + + require.Len(t, rec.overflows, 1) + assert.Equal(t, MemoryHoldsNothing, rec.overflows[0].Reason) +} + +func TestNotify_nilHandlerIsTheNoOp(t *testing.T) { + s := NewEntryStore(t.TempDir(), "app.jar", nil) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + _, err := s.Add(regularHeader("a.txt", 3), bytes.NewReader([]byte("abc")), memCharge(0)) + assert.NoError(t, err, "no handler must not mean no extraction") +} + +func TestNotify_teeDeliversToEveryLiveHandler(t *testing.T) { + first, second := &recorder{}, &recorder{} + tee := Tee(nil, first.notify, nil, second.notify) + require.NotNil(t, tee) + + tee(EntriesOverflowed{Archive: "app.jar", Entries: 1}) + + assert.Len(t, first.overflows, 1) + assert.Len(t, second.overflows, 1) +} + +func TestNotify_teeOfNothingIsNil(t *testing.T) { + // a tee that would deliver to no one stays nil, so the guard at every call site keeps it free + assert.Nil(t, Tee()) + assert.Nil(t, Tee(nil, nil)) +} + +func TestNotify_sendIsNilSafe(t *testing.T) { + var n Notify + n.Send(EntriesOverflowed{}) +} + +func TestNotify_directoryEntriesDoNotSpill(t *testing.T) { + // a directory carries no content, so it can never be the thing that pushes a store to disk + rec := &recorder{} + s := NewEntryStore(t.TempDir(), "app.jar", rec.notify) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + _, err := s.Add(tar.Header{Name: "lib/", Typeflag: tar.TypeDir, Mode: 0o755}, nil, memCharge(0)) + require.NoError(t, err) + assert.Empty(t, rec.overflows) +} diff --git a/internal/archive/overflow.go b/internal/archive/overflow.go new file mode 100644 index 000000000..929c43a78 --- /dev/null +++ b/internal/archive/overflow.go @@ -0,0 +1,352 @@ +package archive + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "io/fs" + "os" + "strings" + "time" + + "github.com/mholt/archives" + + "github.com/anchore/syft/internal/log" +) + +// tarBlockSize is the tar format's fixed block size. Every header occupies a whole number of blocks +// and every entry's data is padded up to one, which is why the bytes an archive puts on disk are not +// the sum of its entry sizes - see OverflowTar. +const tarBlockSize = 512 + +// overflowTarName is the basename of the one file an archive's entries are written into. It sits beside +// the archive's logical root rather than inside it, so nothing in the archive's own filesystem can +// name it. +const overflowTarName = "contents.tar" + +// OverflowTar is the single file one archive's entries are overflowed into. +// +// The point of the whole shape is that a scan's filesystem cost tracks the number of archives it +// finds rather than the number of entries those archives hold. Expanding every entry as its own file +// makes an archive of a hundred thousand entries a hundred thousand inodes - exhausting inodes on a +// filesystem with bytes to spare, and making cleanup proportional to content as well. One tar per +// archive is one inode per archive, and stereoscope's TarIndex then gives direct access to any entry +// by seek offset, so nothing has to be re-read or re-decompressed to reach the end of it. +// +// It is written by hand rather than through a tar.Writer because tar wants an entry's size in the +// header, before its data, and a size an archive declares is exactly what must not be trusted: both +// tar and zip headers carry a size and a crafted archive is free to lie about it. So each entry's +// header is reserved, its data is copied and charged as it lands, and the header is then written over +// the reservation with the size that actually arrived. The reservation is exact because the header is +// encoded as GNU, whose numeric fields are fixed width - the encoded length of a header therefore +// depends on its name and link target and not at all on its size. That is asserted per entry rather +// than assumed, and an entry whose two encodings disagree is dropped rather than corrupting the tar. +type OverflowTar struct { + path string + file *os.File + + // offset is where the next byte written will land, which is also the number of bytes this tar + // holds. Tracked rather than queried so a header can be written back over its reservation. + offset int64 + + // verbatim records that this tar was copied wholesale from an already-tar-shaped stream rather + // than assembled entry by entry, in which case it carries the source's own end-of-archive marker + // and must not be given a second one. + verbatim bool +} + +func newOverflowTar(path string) (*OverflowTar, error) { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return nil, fmt.Errorf("unable to create overflow tar %q: %w", path, err) + } + return &OverflowTar{path: path, file: f}, nil +} + +// Path is where this tar lives, which is what stereoscope's TarIndex needs: it indexes by seek +// offset and therefore wants a seekable, uncompressed file rather than a stream. +func (s *OverflowTar) Path() string { + return s.path +} + +func (s *OverflowTar) Close() error { + if s == nil || s.file == nil { + return nil + } + err := s.file.Close() + s.file = nil + return err +} + +// put charges b to the disk limit and writes it, reporting whether the limit refused it. Nothing is +// written when the charge is refused, so the tar never holds bytes the limiter is not accounting for. +func (s *OverflowTar) put(b []byte, result *ExtractionResult, limits ExtractionLimits) (refused bool, err error) { + if len(b) == 0 { + return false, nil + } + if !limits.Charge.Disk(int64(len(b))) { + return true, nil + } + n, err := s.file.Write(b) + s.offset += int64(n) + result.BytesWritten += int64(n) + if n < len(b) { + limits.Charge.RefundDisk(int64(len(b) - n)) + } + return false, err +} + +// AddEntry writes one archive entry into the tar: a header, its data, and the padding that takes the +// entry up to a block boundary. +// +// Directory entries carry no data, so what bounds an archive of a million of them is the disk limit +// charging their headers and padding, which is the only resource they actually consume. +func (s *OverflowTar) AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error { + hdr, ok := entryHeader(f) + if !ok { + return nil + } + + reserved, err := encodeHeader(hdr, 0) + if err != nil { + // GNU could not represent this entry; let the tar package pick a format it can, and rely on + // the length check below to drop the entry if that choice makes the length size-dependent + hdr.Format = tar.FormatUnknown + reserved, err = encodeHeader(hdr, 0) + if err != nil { + log.WithFields("entry", f.NameInArchive, "error", err). + Debug("skipping archive entry whose header cannot be encoded") + return nil + } + } + + start := s.offset + if refused, err := s.put(reserved, result, limits); err != nil { + return err + } else if refused { + return s.dropEntry(start, result, limits) + } + + written, refused, err := s.copyEntryData(f, hdr, result, limits) + if err != nil { + return err + } + if refused { + // this entry would have taken the scan's disk usage past the disk limit. Drop it rather than + // leaving a silently short one behind: a half-written jar or pom is worse than an absent one, + // because a cataloger will try to parse it and may believe what it finds. + return s.dropEntry(start, result, limits) + } + + final, err := encodeHeader(hdr, written) + if err != nil || len(final) != len(reserved) { + // the reservation and the real header disagree on length, so writing the real header would + // either overwrite the entry's first bytes or leave a gap - either way an unreadable tar. + // GNU's fixed-width numeric fields make this unreachable in practice; it is checked because + // the alternative to checking is silent corruption. + log.WithFields("entry", f.NameInArchive, "error", err). + Debug("skipping archive entry whose header length depends on its size") + return s.dropEntry(start, result, limits) + } + if _, err := s.file.WriteAt(final, start); err != nil { + return fmt.Errorf("unable to write header for archive entry %q: %w", f.NameInArchive, err) + } + + if pad := (tarBlockSize - written%tarBlockSize) % tarBlockSize; pad > 0 { + if refused, err := s.put(make([]byte, pad), result, limits); err != nil { + return err + } else if refused { + return s.dropEntry(start, result, limits) + } + } + + result.FilesExtracted++ + return nil +} + +// copyEntryData copies one entry's content into the tar, charging every chunk as it lands, and reports +// how much actually arrived. A size the archive declared is never used: both tar and zip headers carry +// one and a crafted archive is free to lie about it, so the header is written afterwards from what was +// counted here. An entry carrying no data - a directory, a link - copies nothing. +func (s *OverflowTar) copyEntryData(f archives.FileInfo, hdr *tar.Header, result *ExtractionResult, limits ExtractionLimits) (written int64, refused bool, err error) { + if hdr.Typeflag != tar.TypeReg { + return 0, false, nil + } + + src, err := f.Open() + if err != nil { + return 0, false, fmt.Errorf("unable to open archive entry %q: %w", f.NameInArchive, err) + } + defer func() { + if closeErr := src.Close(); closeErr != nil { + log.WithFields("entry", f.NameInArchive, "error", closeErr).Trace("unable to close archive entry") + } + }() + + written, refused, err = copyCharged(s.file, src, limits.Charge) + s.offset += written + result.BytesWritten += written + return written, refused, err +} + +// dropEntry rewinds the tar to where the entry being written began, refunding what that entry +// charged since those bytes are no longer on disk, and stops the extraction. Whatever was written +// before this entry is a complete, readable tar and is still cataloged; see ExtractionResult. +func (s *OverflowTar) dropEntry(start int64, result *ExtractionResult, limits ExtractionLimits) error { + spent := s.offset - start + if err := s.file.Truncate(start); err != nil { + log.WithFields("path", s.path, "error", err).Trace("unable to rewind overflow tar past an over-limit entry") + } + if _, err := s.file.Seek(start, io.SeekStart); err != nil { + log.WithFields("path", s.path, "error", err).Trace("unable to seek overflow tar past an over-limit entry") + } + s.offset = start + limits.Charge.RefundDisk(spent) + result.BytesWritten -= spent + if result.Truncation == "" { + result.Truncation = TruncatedByDiskLimit + } + return errTruncated +} + +// CopyVerbatim writes an already-tar-shaped stream into the overflow file as it stands, which is what a +// tar-family archive gets: a compressed tar is decompressed once into a plain tar rather than +// expanded entry by entry, and a plain tar is copied. Charging is per chunk as the bytes land, so a +// decompression bomb is bounded by the disk limit rather than by what the archive claimed. +// +// A refused chunk leaves the tar ending part way through an entry. That is deliberately not repaired +// here: indexing keeps every entry it could read and reports the tail as a truncation, which is the +// same outcome as stopping on a whole-entry boundary and needs no second mechanism. +func (s *OverflowTar) CopyVerbatim(r io.Reader, result *ExtractionResult, limits ExtractionLimits) error { + s.verbatim = true + + written, refused, err := copyCharged(s.file, r, limits.Charge) + s.offset += written + result.BytesWritten += written + if err != nil { + return err + } + if refused { + result.Truncation = TruncatedByDiskLimit + return errTruncated + } + return nil +} + +// Finish writes the tar's end-of-archive marker. +// +// A marker that does not fit within the disk limit is not an error: Go's tar reader stops at the +// first zero block or at the end of the file, so a tar that simply ends reads exactly the same. +func (s *OverflowTar) Finish(result *ExtractionResult, limits ExtractionLimits) { + if s.verbatim { + // the source stream carried its own marker + return + } + if refused, err := s.put(make([]byte, 2*tarBlockSize), result, limits); err != nil || refused { + log.WithFields("path", s.path, "error", err).Trace("overflow tar written without an end-of-archive marker") + } +} + +// entryHeader builds the tar header for one archive entry, or reports false when the entry is not +// something worth carrying. +// +// Symlinks are recorded as symlink headers rather than being created and checked as real links, +// because writing a header traverses no path: nothing is created, so there is nothing for a crafted +// target to redirect. Where a link points is decided when the archive's filetree is built, inside +// that tree, so a target naming the host filesystem resolves to nothing rather than to the host. +// +// Device nodes, fifos and sockets are skipped, as they were when entries were files: they cannot +// round-trip and no cataloger can read one. +func entryHeader(f archives.FileInfo) (*tar.Header, bool) { + mode := f.Mode() + if !mode.IsRegular() && !mode.IsDir() && mode.Type()&fs.ModeSymlink == 0 { + log.WithFields("entry", f.NameInArchive, "mode", mode).Debug("skipping non-regular archive entry") + return nil, false + } + + hdr, err := tar.FileInfoHeader(f.FileInfo, f.LinkTarget) + if err != nil { + log.WithFields("entry", f.NameInArchive, "error", err).Debug("skipping archive entry with an unusable header") + return nil, false + } + + // the name is carried through as the archive gave it, unsanitized. Nothing is written through it + // - it is a field in a header - and the one place it can still mislead is the logical filetree, + // so it is sanitized there, once, rather than in each of the two paths that write a tar. + hdr.Name = f.NameInArchive + if f.IsDir() && !strings.HasSuffix(hdr.Name, "/") { + hdr.Name += "/" + } + hdr.Linkname = f.LinkTarget + + // GNU rather than the format the tar package would pick: its numeric fields are fixed width, so + // the encoded length of a header does not depend on the size recorded in it. That is what lets + // the header be reserved before the entry's real size is known and written afterwards. Fields GNU + // cannot carry are cleared instead of forcing the encoder into PAX, where a large size becomes an + // extra record and the length stops being predictable. + hdr.Format = tar.FormatGNU + hdr.PAXRecords = nil + hdr.Uname, hdr.Gname = "", "" + hdr.AccessTime, hdr.ChangeTime = time.Time{}, time.Time{} + hdr.ModTime = hdr.ModTime.Truncate(time.Second) + + return hdr, true +} + +// encodeHeader returns the bytes one header occupies in a tar, for the given size. It includes +// whatever the format needs around it - a GNU long-name record for a name a header field cannot +// hold - because all of it is written before the entry's data and all of it has to be reserved. +func encodeHeader(hdr *tar.Header, size int64) ([]byte, error) { + h := *hdr + h.Size = size + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + // WriteHeader emits the header, and any record the format needs ahead of it, before it returns. + // Close is deliberately not called: it would append the end-of-archive marker, which belongs to + // the tar being assembled and not to one header in it. + if err := tw.WriteHeader(&h); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// tarStream returns the plain-tar byte stream for a tar-family archive, decompressing once where the +// archive is a compressed tar, and reports false for anything that is not a tar. +// +// This is what makes a compressed tar strictly cheaper than it was: it used to be expanded entry by +// entry into a tree of files, and here it is decompressed once into a file that is already the +// storage format, then indexed by seek offset. A plain tar is copied for the same reason - TarIndex +// needs a seekable uncompressed file, and the archive's own bytes may be held in memory. +func tarStream(format archives.Format, r io.Reader) (io.ReadCloser, bool) { + switch f := format.(type) { + case archives.Tar: + return io.NopCloser(r), true + case archives.CompressedArchive: + if !isTarFormat(f) { + return nil, false + } + if f.Compression == nil { + return io.NopCloser(r), true + } + rc, err := f.OpenReader(r) + if err != nil { + log.WithFields("error", err).Debug("unable to decompress tar-family archive") + return nil, false + } + return rc, true + } + return nil, false +} + +// isTarFormat reports whether the archival half of a compressed archive is a tar. Identify sets both +// halves for a compressed tar, so either answers; both are checked because only one is guaranteed to +// be non-nil in general. +func isTarFormat(f archives.CompressedArchive) bool { + if _, ok := f.Archival.(archives.Tar); ok { + return true + } + _, ok := f.Extraction.(archives.Tar) + return ok +} diff --git a/internal/archive/overflow_test.go b/internal/archive/overflow_test.go new file mode 100644 index 000000000..d51a0959a --- /dev/null +++ b/internal/archive/overflow_test.go @@ -0,0 +1,221 @@ +package archive + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + stereoscopeFile "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/syft/syft/file" +) + +// manyEntryZip builds a zip of count small entries, in sorted name order. +func manyEntryZip(t *testing.T, count int) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for i := range count { + w, err := zw.Create(fmt.Sprintf("e%06d.txt", i)) + require.NoError(t, err) + _, err = w.Write([]byte("x")) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return &buf +} + +// extractedOverflow extracts one archive and hands back where its content went, along with the work +// directory holding it - which is what a filesystem cost has to be measured against. +func extractedOverflow(t *testing.T, content io.Reader, limits ExtractionLimits) (*ExtractedArchive, Overflow, string) { + t.Helper() + + var got Overflow + factory := func(overflow Overflow) (file.Resolver, IndexResult, error) { + got = overflow + return nil, IndexResult{}, nil + } + + extracted, err := ExtractToResolver( + context.Background(), content, "app.zip", "", "app.zip", + DefaultExtractors(), NewLimiter(Limits{MaxMemoryBytes: -1, MaxDiskBytes: -1}), limits, factory, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, extracted) + t.Cleanup(extracted.Cleanup) + + return extracted, got, filepath.Dir(got.TarPath) +} + +// filesystemEntries is every directory entry under dir, path by path, which is the cost the +// requirement is about: an archive must not put one of these on the filesystem per entry it holds. +func filesystemEntries(t *testing.T, dir string) []string { + t.Helper() + var out []string + require.NoError(t, filepath.WalkDir(dir, func(p string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(dir, p) + if relErr != nil { + return relErr + } + if rel == "." { + return nil + } + out = append(out, filepath.ToSlash(rel)) + return nil + })) + return out +} + +func TestOverflow_filesystemCostDoesNotTrackEntryCount(t *testing.T) { + // the requirement this whole shape exists for: an archive of a hundred thousand entries must not + // put a hundred thousand files on the filesystem. Asserted as an exact set of paths for two + // archives three orders of magnitude apart in entry count, rather than as a bound - a count that + // merely grows slowly would pass a bound and still be proportional to content. + small, smallOverflow, smallWork := extractedOverflow(t, manyEntryZip(t, 2), ExtractionLimits{}) + large, largeOverflow, largeWork := extractedOverflow(t, manyEntryZip(t, 5000), ExtractionLimits{}) + + want := []string{"contents", "contents.tar"} + assert.Equal(t, want, filesystemEntries(t, smallWork)) + assert.Equal(t, want, filesystemEntries(t, largeWork), + "5000 entries must cost the same filesystem entries as 2: one tar, plus the empty root their "+ + "paths are reported relative to") + + // and the entries really are all there, so this is not passing because nothing was extracted + assert.Equal(t, 2, countTarEntries(t, smallOverflow.TarPath)) + assert.Equal(t, 5000, countTarEntries(t, largeOverflow.TarPath)) + // what one archive costs on disk, exactly: a header block and a padded data block per entry, plus + // the two-block end-of-archive marker. This is the figure the disk limit is charged, which is why + // a limit expressed in entry bytes now under-counts. + assert.Equal(t, int64((5000*2+2)*tarBlockSize), large.Result.BytesWritten) + assert.Equal(t, int64((2*2+2)*tarBlockSize), small.Result.BytesWritten) +} + +func TestOverflow_cleanupRemovesOneFileAndDoesNotWalkPerEntry(t *testing.T) { + // cleanup was proportional to content too: releasing an archive meant removing one filesystem + // entry per archive entry. It is now bounded by the same constant as the extraction, so the count + // of things removed is asserted rather than just the fact that the directory is gone. + extracted, overflow, workDir := extractedOverflow(t, manyEntryZip(t, 5000), ExtractionLimits{}) + + before := filesystemEntries(t, workDir) + require.Len(t, before, 2, "the release has two things to remove whatever the archive held") + + extracted.Cleanup() + + _, err := os.Stat(overflow.TarPath) + assert.True(t, os.IsNotExist(err), "the one file holding every entry must be gone") + _, err = os.Stat(workDir) + assert.True(t, os.IsNotExist(err), "and so must the work directory") + + // safe to call more than once, since the cataloger defers it past the sub-pipeline and past + // recursion + extracted.Cleanup() +} + +func TestOverflow_anEntryAtTheEndIsReadBySeeking(t *testing.T) { + // "overflow content MUST remain directly readable without re-reading or re-decompressing what + // precedes it". A design that met the entry-count requirement by re-streaming the whole archive + // per read would not satisfy it, so what is asserted here is the mechanism: the index records + // where the last entry's content begins, near the end of a tar whose earlier entries are large, + // and the reader it hands back is a bounded window onto the file at that offset. + const filler = 64 * 1024 + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for i := range 8 { + w, err := zw.Create(fmt.Sprintf("filler%d.bin", i)) + require.NoError(t, err) + _, err = w.Write(bytes.Repeat([]byte{byte(i)}, filler)) + require.NoError(t, err) + } + last, err := zw.Create("zz-last.txt") + require.NoError(t, err) + wanted := strings.Repeat("last", 64) + _, err = last.Write([]byte(wanted)) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + _, overflow, _ := extractedOverflow(t, &buf, ExtractionLimits{}) + + info, err := os.Stat(overflow.TarPath) + require.NoError(t, err) + total := info.Size() + require.Greater(t, total, int64(8*filler), "the fixture must put real bulk ahead of the last entry") + + // the last entry's content sits near the end of the tar, which is the only reason a seek to it is + // worth anything: everything before it is what a re-streaming design would have had to read + offset := int64(bytes.Index(readFile(t, overflow.TarPath), []byte(wanted))) + require.Positive(t, offset) + assert.Greater(t, offset, total*9/10, "the last entry must really be at the end of the tar") + + entry := tarEntryNamed(t, overflow.TarPath, "zz-last.txt") + + contents := entry.Open() + t.Cleanup(func() { require.NoError(t, contents.Close()) }) + + random, ok := contents.(interface { + io.ReaderAt + io.Seeker + }) + require.True(t, ok, "the entry reader must be random access, or a read cannot be a seek") + + // the reader is bounded to this entry rather than being a view of the whole tar: its end is the + // entry's own size, so reading it cannot have read what precedes it + end, err := random.Seek(0, io.SeekEnd) + require.NoError(t, err) + assert.Equal(t, int64(len(wanted)), end) + + // and offset zero of the reader is the last entry's first byte, not the tar's. A reader over the + // whole file would answer this with the first filler entry's content. + head := make([]byte, 8) + _, err = random.ReadAt(head, 0) + require.NoError(t, err) + assert.Equal(t, wanted[:8], string(head)) +} + +func readFile(t *testing.T, path string) []byte { + t.Helper() + body, err := os.ReadFile(path) + require.NoError(t, err) + return body +} + +// countTarEntries is how many entries a tar on disk holds, read through the same index the resolver +// builds its filesystem from. +func countTarEntries(t *testing.T, tarPath string) int { + t.Helper() + count := 0 + _, err := stereoscopeFile.NewTarIndex(tarPath, func(stereoscopeFile.TarIndexEntry) error { + count++ + return nil + }) + require.NoError(t, err) + return count +} + +// tarEntryNamed is the index record for one entry of a tar, which is what carries its seek offset. +func tarEntryNamed(t *testing.T, tarPath, name string) stereoscopeFile.TarIndexEntry { + t.Helper() + var found *stereoscopeFile.TarIndexEntry + _, err := stereoscopeFile.NewTarIndex(tarPath, func(entry stereoscopeFile.TarIndexEntry) error { + if entry.ToTarFileEntry().Header.Name == name { + e := entry + found = &e + } + return nil + }) + require.NoError(t, err) + require.NotNil(t, found, "no entry %q in %q", name, tarPath) + return *found +} diff --git a/internal/archive/path_guards_test.go b/internal/archive/path_guards_test.go new file mode 100644 index 000000000..3cf52ff86 --- /dev/null +++ b/internal/archive/path_guards_test.go @@ -0,0 +1,176 @@ +package archive + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The path-safety guards used to be exercised through extraction, because extraction created a file +// or a link per archive entry and every one of them went through them. An entry is a tar header now, +// so those tests assert something else and these guards need their own. +// +// They are covered here rather than retired because their situations differ: +// +// - resolvesInsideRoot, resolveLink and isWithin guard the one filesystem write left in this +// package - an archive's own bytes overflowing into its work directory, under a name derived from +// where the archive was found, which inside another archive is text the archive supplied. +// - writeSafeSymlink has no caller at all. A symlink entry is a header write, which creates +// nothing, and where the link points is decided inside the archive's own filetree. It is kept +// with its reasoning intact, and whether it retires is a judgement about every path that could +// write a link rather than about one call site. + +func TestResolvesInsideRoot_multiStepSymlinkChainIsRefused(t *testing.T) { + // the escape the guard exists for: two cooperating links each pass a lexical check and together + // resolve above the root. "d" -> "." lands on the root, so "d/up" -> ".." is created as + // "/up" -> ".." and points above it, and a write through "d/up/x" lands outside. A lexical + // check sees /d/../x and allows it; only resolving against the real filesystem catches it. + base := t.TempDir() + root := filepath.Join(base, "contents") + require.NoError(t, os.MkdirAll(root, 0o755)) + + require.NoError(t, os.Symlink(".", filepath.Join(root, "d"))) + require.NoError(t, os.Symlink("..", filepath.Join(root, "up"))) + + inside, err := resolvesInsideRoot(root, filepath.Join(root, "d", "up", "PWNED")) + require.NoError(t, err) + assert.False(t, inside, "a write through a link chain that leaves the root must be refused") +} + +func TestResolvesInsideRoot_legitimatePathsAreAllowed(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "contents") + require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755)) + + for _, path := range []string{ + filepath.Join(root, "top.txt"), + filepath.Join(root, "lib", "deep", "file.txt"), + } { + inside, err := resolvesInsideRoot(root, path) + require.NoError(t, err) + assert.True(t, inside, "%s is inside the root and must be allowed", path) + } +} + +func TestResolvesInsideRoot_siblingSharingThePrefixIsRefused(t *testing.T) { + // the string-prefix hole: "contents-evil" shares "contents" as a prefix and is not inside it + base := t.TempDir() + root := filepath.Join(base, "contents") + require.NoError(t, os.MkdirAll(root, 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(base, "contents-evil"), 0o755)) + + inside, err := resolvesInsideRoot(root, filepath.Join(base, "contents-evil", "x.txt")) + require.NoError(t, err) + assert.False(t, inside) +} + +func TestWriteSafeSymlink(t *testing.T) { + // asserted with no production caller, deliberately: see the note at the top of this file. The + // reasoning in these cases is what makes the function worth keeping, so it is kept executable + // rather than left as prose about code nothing runs. + newRoot := func(t *testing.T) string { + t.Helper() + base := t.TempDir() + root := filepath.Join(base, "contents") + require.NoError(t, os.MkdirAll(filepath.Join(root, "bin"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "lib", "real.txt"), []byte("real"), 0o600)) + return root + } + + t.Run("a relative target inside the root is written", func(t *testing.T) { + root := newRoot(t) + dest := filepath.Join(root, "bin", "link.txt") + require.NoError(t, writeSafeSymlink("../lib/real.txt", dest, root)) + + body, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "real", string(body)) + }) + + for _, tc := range []struct { + name string + target string + }{ + {name: "an empty target", target: ""}, + { + // os.Symlink writes the literal string, so reading the link resolves on the host whatever + // was checked when it was created + name: "an absolute target", + target: "/etc/passwd", + }, + {name: "a target climbing out of the root", target: "../../../../etc/passwd"}, + } { + t.Run(tc.name+" is refused", func(t *testing.T) { + root := newRoot(t) + dest := filepath.Join(root, "bin", "link.txt") + require.Error(t, writeSafeSymlink(tc.target, dest, root)) + + _, err := os.Lstat(dest) + assert.True(t, os.IsNotExist(err), "nothing may be created for a refused link") + }) + } + + t.Run("a target that resolves out of the root through an earlier link is refused", func(t *testing.T) { + // the target itself can run through a link an earlier entry left behind + root := newRoot(t) + base := filepath.Dir(root) + require.NoError(t, os.Symlink("..", filepath.Join(root, "up"))) + require.NoError(t, os.WriteFile(filepath.Join(base, "PWNED"), []byte("x"), 0o600)) + + dest := filepath.Join(root, "bin", "link.txt") + require.Error(t, writeSafeSymlink("../up/PWNED", dest, root)) + + _, err := os.Lstat(dest) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("KNOWN LIMIT: a target whose own path runs out of the root is not caught by either guard", func(t *testing.T) { + // Recorded as a finding rather than a passing assertion of safety, because it is one. + // + // Three entries: "up" -> "..", then "bin/link.txt" -> "../up/PWNED", then a write to + // "bin/link.txt/through". writeSafeSymlink computes /up/PWNED, which is lexically inside + // the root, and its last check - does the target already resolve outside? - does not fire, + // because /up/PWNED does not exist yet. So the link is created. resolvesInsideRoot then + // walks the write path, meets bin/link.txt, and resolves its target the same lexical way: the + // target's own path is not walked, so the fact that /up is itself a link out of the root + // is never seen, and the write is allowed. On the real filesystem it lands above the root. + // + // It is not a live exposure. The only caller of resolvesInsideRoot writes one flat name into a + // work directory it just created, where no link exists and none can be planted, and + // writeSafeSymlink has no caller at all - a symlink entry is a header write now. It matters + // because it is what the retirement question actually hangs on: these two are not a working + // defence being kept in reserve, they are a defence with a hole in it and no exposure, and + // anything that gives them a caller again has to close this first. + root := newRoot(t) + require.NoError(t, os.Symlink("..", filepath.Join(root, "up"))) + + dest := filepath.Join(root, "bin", "link.txt") + require.NoError(t, writeSafeSymlink("../up/PWNED", dest, root), + "the link is created: its target does not resolve outside the root YET") + + inside, err := resolvesInsideRoot(root, filepath.Join(root, "bin", "link.txt", "through")) + require.NoError(t, err) + assert.True(t, inside, + "the guard allows this write, and the real filesystem puts it above the root - the hole this "+ + "case exists to record") + }) +} + +func TestArchiveFileName_refusesNamesThatWouldLeaveTheWorkDirectory(t *testing.T) { + // the name an archive's own bytes are overflowed under is the basename of where the archive was + // found, and Base returns ".." for an access path ending in it - which joined to the work + // directory would name the directory above. overflowContent checks its destination as well, so this + // is the earlier of two refusals rather than the only one. + for _, accessPath := range []string{"", ".", "..", "a/..", string(filepath.Separator)} { + assert.Equal(t, "archive", archiveFileName(accessPath), + "%q must not become a name joined to the work directory", accessPath) + } + + // and a real name is preserved, compound extension included, so format detection has the best + // chance of identifying the archive + assert.Equal(t, "bundle.tar.gz", archiveFileName("some/path/bundle.tar.gz")) +} diff --git a/internal/archive/resolver.go b/internal/archive/resolver.go new file mode 100644 index 000000000..a39634b80 --- /dev/null +++ b/internal/archive/resolver.go @@ -0,0 +1,412 @@ +package archive + +import ( + "context" + "crypto" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + intFile "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/tmpdir" + "github.com/anchore/syft/syft/file" +) + +// ResolverFactory builds an indexed resolver over one archive's overflow tar. It is injected (rather +// than calling syft/internal/fileresolver directly) so this module-root package does not depend on +// the syft-subtree internal fileresolver package. +// +// It reports what indexing found as well as the resolver, because a tar-family archive is copied +// wholesale and its entries are first counted where the tar is indexed. +type ResolverFactory func(Overflow) (file.Resolver, IndexResult, error) + +// StoreResolverFactory builds a resolver over an archive's entries rather than over a tar of them. +// +// Where a ResolverFactory can only index content already written to a file - its index IS that file's +// offsets - this one indexes the entries themselves, so an archive small enough to stay in memory +// never reaches the disk and one that outgrows memory moves its bytes without the index changing. +// Injected the same way and for the same reason: this package must not depend on the resolver +// packages it hands work to. +type StoreResolverFactory func(*EntryStore, Overflow) (file.Resolver, IndexResult, error) + +// Overflow names what one archive's entries were written into, and what the resolver built over them +// must know. +type Overflow struct { + // RootDir is the archive's logical root: an empty directory that every entry's path is reported + // relative to. It exists on disk, and stays empty - nothing is written into it - because the + // resolver relativizes an entry's path against a real root the way a directory scan does, and + // that is what keeps a path in the SBOM relative to its own archive rather than naming the + // scan's scratch space. + RootDir string + + // TarPath is the one file holding every entry of this archive, indexed by seek offset. + TarPath string + + // FileSystemID is the identifier of the filesystem the archive file itself lives on (a layer + // digest, or blank), inherited unchanged down the nesting chain, stamped onto every Location. + FileSystemID string + + // ArchivePath is the colon-delimited chain of archive paths from the scan root to this archive, + // stamped onto every Location. It is what keeps identically-named files in different archives + // from colliding in the coordinate-keyed SBOM tables. + ArchivePath string +} + +// IndexResult reports what indexing one archive's overflow tar produced. +type IndexResult struct { + // Records is how many entries the archive's filesystem holds. + Records int + + // Truncated reports that indexing stopped before the end of the tar, which now means only one + // thing: the tar itself ends part way through an entry because the disk limit stopped the write. + // What was indexed is complete and usable; there is simply less of it. + Truncated bool +} + +// archiveDigestHashes are the digests taken of an archive file as a whole. SHA-1 alone, matching +// what the java archive cataloger has always reported for a jar, because these are the digests it +// reads when it can no longer open the archive itself. +var archiveDigestHashes = []crypto.Hash{crypto.SHA1} + +// digestsOf takes the archive's own digests from the content already acquired. One pass over a +// seekable source - memory or an overflow file - and it replaces a read the java cataloger used to +// do for itself, so it is not new work so much as work moved to where the bytes already are. +// +// A failure is logged and returns nothing: a digest is metadata about a package, and an archive +// whose contents cataloged fine should not be discarded because its hash could not be taken. +func digestsOf(ctx context.Context, content Content, accessPath string) []file.Digest { + if _, err := content.Reader.Seek(0, io.SeekStart); err != nil { + log.WithFields("archive", accessPath, "error", err).Trace("unable to rewind archive content for digests") + return nil + } + digests, err := intFile.NewDigestsFromFile(ctx, io.NopCloser(content.Reader), archiveDigestHashes) + if err != nil { + log.WithFields("archive", accessPath, "error", err).Trace("unable to take archive digests") + return nil + } + return digests +} + +// ExtractedArchive is a standalone, fully-indexed filesystem built from an extracted archive. +type ExtractedArchive struct { + // Resolver is an indexed resolver over the extracted contents; its Locations are stamped with FileSystemID. + Resolver file.Resolver + + // Digests are the digests of the archive file itself, taken while its bytes were in hand. + Digests []file.Digest + // FileSystemID is the identifier of the filesystem the archive file itself lives on (inherited unchanged down the nesting chain). + FileSystemID string + // ArchivePath is the colon-delimited chain of archive paths from the scan root to this archive. + ArchivePath string + // Result reports how much was extracted and whether a configured bound truncated the + // extraction. A truncated archive still yields a usable Resolver over whatever was written + // before the bound; see ExtractionResult. + Result ExtractionResult + + // content is this archive's own bytes, held in memory or overflowed to disk. It is kept here rather + // than dropped after extraction for two reasons: content held in memory must stay reachable for + // as long as the memory limit accounts for it as held, otherwise the limiter measures something + // the garbage collector has already taken back; and openers over the extracted tree are heading + // towards reading lazily, which cannot work if the backing bytes are gone. + content Content + + cleanup func() +} + +// Cleanup removes the temp directory backing this archive's filesystem and releases what this +// archive was holding against the scan's limiter, so both limits fall by exactly what it took. Safe +// to call more than once. +func (e *ExtractedArchive) Cleanup() { + if e == nil || e.cleanup == nil { + return + } + // close the resolver before the work directory is removed, so the entry store's overflow blob + // handle is released while the file it points at still exists. A resolver that holds nothing open + // - the tar path opens its file per read - does not implement io.Closer and is skipped. + if closer, ok := e.Resolver.(io.Closer); ok { + if err := closer.Close(); err != nil { + log.WithFields("fsid", e.FileSystemID, "error", err).Trace("unable to close archive resolver") + } + } + e.cleanup() + e.cleanup = nil + // drop the last reference to content held in memory, so the limit falling and the bytes becoming + // collectable happen together + e.content = Content{} +} + +// ExtractToResolver holds the given archive content - in memory while the memory limit admits it, on +// disk once it does not - extracts it, and returns a standalone indexed resolver over its contents +// whose Locations are stamped with the given fileSystemID (the filesystem the archive file lives on) +// and archivePath (the colon-delimited archive nesting chain from the scan root to this archive). It +// returns (nil, nil) when the content is not a supported/extractable archive. The caller owns the +// returned value and must call Cleanup on it. +// +// Everything this archive holds is charged to one handle against the scan's limiter and released by +// Cleanup, which processArchive already defers past the sub-pipeline and past recursion - so the +// limits fall exactly where the resources are freed, and nothing new has to remember to do it. +// +// Reaching a limit never waits for capacity. The walk descends before it unwinds, so a containing +// archive is still holding its content while its children are cataloged and nothing will be released +// while this archive waits: waiting would be a deadlock dressed as backpressure. Content that cannot +// be admitted yields ErrDiskLimitReached and the caller skips the archive. +// +// Reaching a bound part way through is not an error either: the returned ExtractedArchive is indexed +// over the partial contents and Result.Truncated() reports it, so the caller can still catalog what +// was extracted. Any other non-nil error means nothing usable was produced. +func ExtractToResolver(ctx context.Context, content io.Reader, archiveAccessPath, fileSystemID, archivePath string, extractors []Extractor, limiter *Limiter, limits ExtractionLimits, newResolver ResolverFactory, newStoreResolver StoreResolverFactory, notify Notify) (*ExtractedArchive, error) { + workDir, removeWorkDir, err := newWorkDir(ctx) + if err != nil { + return nil, err + } + + charge := limiter.Charge() + limits.Charge = charge + + var held Content + cleanup := func() { + if closeErr := held.Close(); closeErr != nil { + log.WithFields("archive", archiveAccessPath, "error", closeErr).Trace("unable to close archive content") + } + held = Content{} + removeWorkDir() + charge.Release() + } + + held, err = acquireContent(content, workDir, archiveFileName(archiveAccessPath), charge, notify) + if err != nil { + cleanup() + if notify != nil && errors.Is(err, ErrDiskLimitReached) { + notify(Skipped{Archive: archiveAccessPath, Reason: "content would exceed the disk limit"}) + } + // ErrDiskLimitReached travels unwrapped so the caller can attribute the skip to this archive + return nil, err + } + + extractor := FindExtractor(ctx, extractors, held) + if extractor == nil { + // not an archive we can extract (e.g. an executable that happened to match a broad mime filter) + cleanup() + return nil, nil + } + + resolver, result, err := overflowAndIndex(ctx, extractor, held, workDir, fileSystemID, archivePath, archiveAccessPath, limits, newResolver, newStoreResolver, notify) + if err != nil { + cleanup() + return nil, err + } + + return &ExtractedArchive{ + Resolver: resolver, + Digests: digestsOf(ctx, held, archiveAccessPath), + FileSystemID: fileSystemID, + ArchivePath: archivePath, + Result: result, + content: held, + cleanup: cleanup, + }, nil +} + +// workDirName is the prefix given to the directory holding one archive's content and entries. Named +// so a scan's leftovers are recognizable, and matched by the tests that sample what is on disk. +const workDirName = "syft-archive" + +// newWorkDir creates the directory one archive's content and entries are written into, under the +// scan's own temp root when the context carries one. +// +// The root is where every other cataloger that spills to disk already writes (see internal/tmpdir), +// which is what makes a scan's temp files land where the caller configured them and get removed +// together if an archive's own cleanup is ever missed. A context with no root - a caller reaching +// this package directly - still gets a directory, from the same place it would have come from +// before. +func newWorkDir(ctx context.Context) (string, func(), error) { + if td := tmpdir.FromContext(ctx); td != nil { + dir, cleanup, err := td.NewChild(workDirName) //nolint:gocritic // cleanup is returned to the caller, not deferred here + if err != nil { + return "", nil, fmt.Errorf("unable to create temp dir for archive extraction: %w", err) + } + return dir, cleanup, nil + } + + dir, err := os.MkdirTemp("", workDirName+"-") + if err != nil { + return "", nil, fmt.Errorf("unable to create temp dir for archive extraction: %w", err) + } + return dir, func() { + if rmErr := os.RemoveAll(dir); rmErr != nil { + log.WithFields("dir", dir, "error", rmErr).Trace("unable to remove archive temp dir") + } + }, nil +} + +// overflowAndIndex writes one archive's entries into the tar in workDir and builds the resolver over it. +// +// Errors travel unwrapped where the caller has to tell them apart: ErrDiskLimitReached attributes a +// skip to this archive, and everything else is a failure of this archive alone. +func overflowAndIndex(ctx context.Context, extractor Extractor, held Content, workDir, fsID, archivePath, accessPath string, limits ExtractionLimits, newResolver ResolverFactory, newStoreResolver StoreResolverFactory, notify Notify) (file.Resolver, ExtractionResult, error) { + if newStoreResolver != nil { + return storeAndIndex(ctx, extractor, held, workDir, fsID, archivePath, accessPath, limits, newStoreResolver, notify) + } + var result ExtractionResult + + // the archive's logical root, which stays empty: entries live in the tar beside it, and this is + // only what their paths are reported relative to + contentsDir := filepath.Join(workDir, "contents") + if err := os.MkdirAll(contentsDir, 0o755); err != nil { + return nil, result, fmt.Errorf("unable to create contents dir for archive %q: %w", fsID, err) + } + + overflow, err := newOverflowTar(filepath.Join(workDir, overflowTarName)) + if err != nil { + return nil, result, err + } + + result, err = extractor.Extract(ctx, held, overflow, limits) + if closeErr := overflow.Close(); closeErr != nil && err == nil { + err = closeErr + } + if err != nil { + return nil, result, fmt.Errorf("unable to extract archive %q: %w", fsID, err) + } + + // nothing of this archive could be placed: the disk limit refused its first byte, so there is + // nowhere for its content to go and the archive is skipped rather than cataloged as empty. This is + // where the terminal reading of the disk limit lands now that a nested archive is read where it + // lies: its own bytes are never copied, so they are never the thing refused, and what is refused + // instead is the file its entries are written into. An archive that placed SOME of its entries is + // a truncation, not a skip - see ExtractionResult. + if result.Truncation == TruncatedByDiskLimit && result.BytesWritten == 0 { + return nil, result, ErrDiskLimitReached + } + + resolver, indexed, err := newResolver(Overflow{ + RootDir: contentsDir, + TarPath: overflow.Path(), + FileSystemID: fsID, + ArchivePath: archivePath, + }) + if err != nil { + return nil, result, fmt.Errorf("unable to index extracted archive %q: %w", fsID, err) + } + + // the count of entries the archive's filesystem holds is the index's to report, not the writer's: + // a tar-family archive is copied wholesale and never walked, and an exclusion pattern drops + // entries after they were written + result.FilesExtracted = indexed.Records + if indexed.Truncated && !result.Truncated() { + // the tar ends part way through an entry and nothing recorded why. The disk limit is the only + // bound that can end a write early and it names itself, so reaching here means a ragged tar + // with no attributed cause - reported as a truncation without claiming which bound did it. + result.Truncation = TruncatedByLimit + } + + return resolver, result, nil +} + +// inPlaceContent is content that is already random-access on a file this scan wrote and is already +// accounted for by the archive that wrote it. +// +// A nested archive arrives as a reader over one entry of its parent's overflow tar, and that reader is +// Read, Seek AND ReadAt - which is everything an archive format needs, a zip's central directory +// included. So it is read in place rather than copied into memory or overflowed again: the payoff for +// choosing tar as the storage format, and the reason an inner zip needs no special handling. +// +// Declared as a marker method rather than by asserting on the reader's type, because the type belongs +// to the resolver package that this one deliberately does not import. Nothing else in the process +// answers to it, so nothing else is read in place by accident. +type inPlaceContent interface { + io.Reader + io.ReaderAt + io.Seeker + + // OverflowArchiveEntry marks a reader over one entry of an archive's overflow tar. + OverflowArchiveEntry() +} + +// acquireContent gets one archive's bytes ready to be read at random, which is what every archive +// format needs: it reads content already sitting in a file this scan wrote in place, and otherwise +// routes the bytes through the limiter - held in memory while the memory limit admits them, overflowed +// to disk once it does not. +// +// Content read in place is charged nothing, because nothing new is held: the bytes are part of the +// parent archive's tar, which the parent already charged for and releases on its own Cleanup. Its +// handle is the caller's to close, as it was before this ever reached here. +func acquireContent(r io.Reader, workDir, name string, charge *Charge, notify Notify) (Content, error) { + if entry, ok := r.(inPlaceContent); ok { + return Content{Name: name, Reader: entry}, nil + } + return holdContent(r, workDir, name, charge, notify) +} + +// archiveFileName derives a safe basename for the saved archive, preserving compound extensions +// (e.g. ".tar.gz") so format detection has the best chance to identify the archive. +// +// ".." is refused along with the empty and root cases: Base returns it for an access path ending in +// it, and joining it to the work directory would name the directory above. overflowContent checks its +// destination as well, so this is the earlier of two refusals rather than the only one. +func archiveFileName(accessPath string) string { + name := filepath.Base(accessPath) + if name == "" || name == "." || name == ".." || name == string(filepath.Separator) { + return "archive" + } + return name +} + +// storeAndIndex is the entry-store path: the archive's entries go into an EntryStore, which holds +// them in memory while the memory limit admits them, and the resolver is built over the entries. +// +// Compared to the tar path this writes nothing for an archive that fits in memory - no tar to encode, +// no file to index - and where the archive does not fit, the content moves without the index being +// rebuilt. +func storeAndIndex(ctx context.Context, extractor Extractor, held Content, workDir, fsID, archivePath, accessPath string, limits ExtractionLimits, newStoreResolver StoreResolverFactory, notify Notify) (file.Resolver, ExtractionResult, error) { + var result ExtractionResult + + // the archive's logical root, which stays empty: entries live in the store, and this is only what + // their paths are reported relative to + contentsDir := filepath.Join(workDir, "contents") + if err := os.MkdirAll(contentsDir, 0o755); err != nil { + return nil, result, fmt.Errorf("unable to create contents dir for archive %q: %w", fsID, err) + } + + store := NewEntryStore(workDir, accessPath, notify) + + result, err := extractor.Extract(ctx, held, store, limits) + if err != nil { + if closeErr := store.Close(); closeErr != nil { + log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store") + } + return nil, result, fmt.Errorf("unable to extract archive %q: %w", fsID, err) + } + + // nothing of this archive could be placed: the disk limit refused its first byte, so the entries + // it holds are content-less husks and the archive is skipped rather than cataloged as empty. The + // same terminal reading of the disk limit the tar path takes, measured the way this path stores + // its content - an archive that placed SOME of its entries, in memory or on disk, is a truncation. + if result.Truncation == TruncatedByDiskLimit && store.OnDisk() == 0 { + if closeErr := store.Close(); closeErr != nil { + log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store") + } + return nil, result, ErrDiskLimitReached + } + + resolver, indexed, err := newStoreResolver(store, Overflow{ + RootDir: contentsDir, + FileSystemID: fsID, + ArchivePath: archivePath, + }) + if err != nil { + if closeErr := store.Close(); closeErr != nil { + log.WithFields("archive", fsID, "error", closeErr).Trace("unable to close archive entry store") + } + return nil, result, fmt.Errorf("unable to index extracted archive %q: %w", fsID, err) + } + + result.FilesExtracted = indexed.Records + if notify != nil && result.Truncated() { + notify(Truncated{Archive: accessPath, Reason: result.Truncation}) + } + return resolver, result, nil +} diff --git a/internal/archive/resolver_test.go b/internal/archive/resolver_test.go new file mode 100644 index 000000000..854a5547c --- /dev/null +++ b/internal/archive/resolver_test.go @@ -0,0 +1,145 @@ +package archive + +import ( + "archive/tar" + "archive/zip" + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/tmpdir" + "github.com/anchore/syft/syft/file" +) + +func TestExtractToResolver_writesUnderTheScansTempRoot(t *testing.T) { + // where an archive's scratch space lands is the scan's decision, not this package's: the root on + // the context is what a caller configured and what gets cleaned up if an archive's own cleanup is + // ever missed. Reaching for os.MkdirTemp instead put archive work outside all of that. + root := t.TempDir() + ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(root)) + + var got Overflow + factory := func(overflow Overflow) (file.Resolver, IndexResult, error) { + got = overflow + return nil, IndexResult{Records: 1}, nil + } + + extracted, err := ExtractToResolver( + ctx, newTestZip(t, map[string]string{"hello.txt": "hi"}), "app.zip", "", "app.zip", + DefaultExtractors(), nil, ExtractionLimits{}, factory, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, extracted) + t.Cleanup(extracted.Cleanup) + + assert.True(t, strings.HasPrefix(got.TarPath, root+string(os.PathSeparator)), + "the archive's entries must be written under the scan's temp root, got %q", got.TarPath) + + // and the archive still cleans up after itself, so the root is a safety net rather than the only + // thing reclaiming the space + extracted.Cleanup() + _, statErr := os.Stat(got.RootDir) + assert.True(t, os.IsNotExist(statErr), "expected the archive work directory to be removed by Cleanup") +} + +func TestExtractToResolver(t *testing.T) { + content := newTestZip(t, map[string]string{"dir/hello.txt": "hello world"}) + + var got Overflow + factory := func(overflow Overflow) (file.Resolver, IndexResult, error) { + got = overflow + return nil, IndexResult{Records: 1}, nil + } + + extracted, err := ExtractToResolver( + context.Background(), content, "some/path/app.zip", "parentFS", "app.war:some/path/app.zip", + DefaultExtractors(), nil, ExtractionLimits{}, factory, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, extracted) + t.Cleanup(extracted.Cleanup) + + // the filesystem the archive lives on is passed through unchanged; the nesting chain is carried + // separately as the archive path, and both are handed to the resolver factory + assert.Equal(t, "parentFS", extracted.FileSystemID) + assert.Equal(t, "parentFS", got.FileSystemID) + assert.Equal(t, "app.war:some/path/app.zip", extracted.ArchivePath) + assert.Equal(t, "app.war:some/path/app.zip", got.ArchivePath) + + // the archive's entries are in the one tar handed to the factory, and its logical root is an + // empty directory beside it: an entry's path is reported relative to that root, and nothing is + // written into it + entries, err := os.ReadDir(got.RootDir) + require.NoError(t, err) + assert.Empty(t, entries, "the archive root holds no files: the entries are in the tar") + + body := readTarEntry(t, got.TarPath, "dir/hello.txt") + assert.Equal(t, "hello world", body) + + // the count of entries this archive contributes is the index's answer, not the extractor's + assert.Equal(t, 1, extracted.Result.FilesExtracted) + + // Cleanup removes the temp tree + extracted.Cleanup() + _, statErr := os.Stat(got.TarPath) + assert.True(t, os.IsNotExist(statErr), "expected the overflow tar to be removed by Cleanup") + _, statErr = os.Stat(got.RootDir) + assert.True(t, os.IsNotExist(statErr), "expected temp dir to be removed by Cleanup") +} + +// readTarEntry returns the content of one entry of a tar on disk. +func readTarEntry(t *testing.T, tarPath, name string) string { + t.Helper() + f, err := os.Open(tarPath) + require.NoError(t, err) + defer f.Close() + + tr := tar.NewReader(f) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if hdr.Name != name { + continue + } + body, err := io.ReadAll(tr) + require.NoError(t, err) + return string(body) + } + t.Fatalf("no entry %q in %q", name, tarPath) + return "" +} + +func TestExtractToResolver_notAnArchive(t *testing.T) { + factory := func(Overflow) (file.Resolver, IndexResult, error) { return nil, IndexResult{}, nil } + + extracted, err := ExtractToResolver( + context.Background(), strings.NewReader("this is not an archive"), "notes.txt", "", "notes.txt", + DefaultExtractors(), nil, ExtractionLimits{}, factory, nil, nil, + ) + require.NoError(t, err) + assert.Nil(t, extracted, "non-archive content should yield a nil ExtractedArchive") +} + +func newTestZip(t *testing.T, files map[string]string) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, body := range files { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(body)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return &buf +} diff --git a/internal/archive/traversal.go b/internal/archive/traversal.go new file mode 100644 index 000000000..2db0d0542 --- /dev/null +++ b/internal/archive/traversal.go @@ -0,0 +1,85 @@ +package archive + +import ( + "context" + "strings" + + "github.com/anchore/syft/syft/file" +) + +// Traversal describes one level of archive nesting during recursive archive cataloging. The archive +// cataloger task places one on the context before running the cataloger sub-pipeline against an +// archive's contents, so ecosystem catalogers (e.g. java) can reconstruct nesting-aware identity +// such as colon-delimited virtual paths (e.g. "app.war:WEB-INF/lib/dep.jar") without owning the +// recursion themselves. +type Traversal struct { + // Location is the archive file's location within its parent filesystem. + Location file.Location + + // VirtualPath is the colon-delimited chain of archive paths from the scan root to this archive, + // e.g. "app.war:WEB-INF/lib/dep.jar". + VirtualPath string + + // FileSystemID is the identifier of the filesystem the archive file itself lives on (a container + // image layer digest, or empty for a directory source), inherited unchanged down the nesting + // chain. The nesting chain is carried by VirtualPath, not by this field. + FileSystemID string + + // Depth is 1 for an archive found directly in the scan source, incrementing per nesting level. + Depth int + + // Digests are the digests of the archive file itself. They are carried here because they are a + // property of the file and not of its contents: a cataloger running against an archive's extracted + // filesystem cannot compute them, since the archive is not inside itself. Taken once by the + // extraction, which already has the bytes in hand. + Digests []file.Digest + + // Parent is the traversal for the containing archive; nil at depth 1. + Parent *Traversal +} + +// VirtualPathOf returns the colon-delimited virtual path for an entry within this archive. It is +// nil-safe: with no traversal the entry path is returned unchanged, matching non-nested behavior. +// The entry path's leading slash is trimmed so chains match the historical java cataloger format, +// which joins slash-less zip entry names onto the containing archive's path. +func (t *Traversal) VirtualPathOf(entryPath string) string { + if t == nil { + return entryPath + } + entryPath = strings.ReplaceAll(entryPath, ":", "%3A") + if t.VirtualPath == "" { + return entryPath + } + return t.VirtualPath + ":" + strings.TrimPrefix(entryPath, "/") +} + +type limiterCtxKey struct{} + +// WithLimiter returns a context carrying the scan's archive limiter, so a cataloger running within +// the recursive walk can read the scan's live draw on the memory and disk budgets (Limiter.InUse) - +// the same totals the limits are enforced against. Carried on the context rather than copied onto +// each Traversal so there is one source of truth for the charge, not a snapshot per level. +func WithLimiter(ctx context.Context, l *Limiter) context.Context { + return context.WithValue(ctx, limiterCtxKey{}, l) +} + +// LimiterFromContext returns the scan's archive limiter, or nil when the current pass is not running +// within the recursive archive walk. +func LimiterFromContext(ctx context.Context) *Limiter { + l, _ := ctx.Value(limiterCtxKey{}).(*Limiter) + return l +} + +type traversalCtxKey struct{} + +// WithTraversal returns a context carrying the given archive traversal. +func WithTraversal(ctx context.Context, t *Traversal) context.Context { + return context.WithValue(ctx, traversalCtxKey{}, t) +} + +// TraversalFromContext returns the archive traversal on the context, or nil when the current +// cataloging pass is not running within an archive. +func TraversalFromContext(ctx context.Context) *Traversal { + t, _ := ctx.Value(traversalCtxKey{}).(*Traversal) + return t +} diff --git a/internal/archive/traversal_test.go b/internal/archive/traversal_test.go new file mode 100644 index 000000000..5619308b6 --- /dev/null +++ b/internal/archive/traversal_test.go @@ -0,0 +1,197 @@ +package archive + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" +) + +func TestTraversalContextRoundTrip(t *testing.T) { + assert.Nil(t, TraversalFromContext(context.Background())) + + trav := &Traversal{ + Location: file.NewLocation("app.war"), + VirtualPath: "app.war", + FileSystemID: "", + Depth: 1, + } + ctx := WithTraversal(context.Background(), trav) + require.Same(t, trav, TraversalFromContext(ctx)) + + child := &Traversal{ + Location: file.NewLocation("/WEB-INF/lib/dep.jar"), + VirtualPath: trav.VirtualPathOf("/WEB-INF/lib/dep.jar"), + FileSystemID: "", + Depth: 2, + Parent: trav, + } + ctx = WithTraversal(ctx, child) + require.Same(t, child, TraversalFromContext(ctx)) + assert.Same(t, trav, TraversalFromContext(ctx).Parent) +} + +func TestTraversal_VirtualPathOf(t *testing.T) { + tests := []struct { + name string + traversal *Traversal + entryPath string + expected string + }{ + { + name: "nil traversal returns entry path unchanged", + traversal: nil, + entryPath: "/WEB-INF/lib/dep.jar", + expected: "/WEB-INF/lib/dep.jar", + }, + { + name: "nil traversal preserves relative path", + traversal: nil, + entryPath: "some/dir/example.jar", + expected: "some/dir/example.jar", + }, + { + name: "single level trims leading slash", + traversal: &Traversal{VirtualPath: "app.war"}, + entryPath: "/WEB-INF/lib/dep.jar", + expected: "app.war:WEB-INF/lib/dep.jar", + }, + { + name: "single level with slash-less entry", + traversal: &Traversal{VirtualPath: "app.war"}, + entryPath: "WEB-INF/lib/dep.jar", + expected: "app.war:WEB-INF/lib/dep.jar", + }, + { + name: "multi-level chain", + traversal: &Traversal{VirtualPath: "app.war:WEB-INF/lib/dep.jar"}, + entryPath: "/inner/most.jar", + expected: "app.war:WEB-INF/lib/dep.jar:inner/most.jar", + }, + { + // a colon in the entry name itself must not be mistaken for a chain delimiter - + // see decisions.md#colon-in-an-entry-path-is-escaped + name: "colon in entry path is escaped", + traversal: &Traversal{VirtualPath: "app.war"}, + entryPath: "WEB-INF/lib/dep:special.jar", + expected: "app.war:WEB-INF/lib/dep%3Aspecial.jar", + }, + { + name: "every colon in the entry path is escaped", + traversal: &Traversal{VirtualPath: "app.war"}, + entryPath: "lib/a:b:c.jar", + expected: "app.war:lib/a%3Ab%3Ac.jar", + }, + { + // a traversal carrying no virtual path (as distinct from a nil traversal) still + // yields the entry path alone, never a bare leading colon + name: "empty virtual path yields the entry path alone, no leading colon", + traversal: &Traversal{VirtualPath: ""}, + entryPath: "WEB-INF/lib/dep.jar", + expected: "WEB-INF/lib/dep.jar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.traversal.VirtualPathOf(tt.entryPath)) + }) + } +} + +// TestTraversal_VirtualPathOf_noColonIsByteIdenticalToPreEscaping is the assertion that carries +// the compatibility guarantee behind java-virtual-path-identity-preserved +// (decisions.md#colon-in-an-entry-path-is-escaped): escaping a colon in an entry path must not +// alter the result for any entry path that does not contain one. Every case below has no colon, +// and every expected value is exactly what VirtualPathOf produced before escaping existed - do +// not "fix" a failure here by updating the expected string, since that is precisely the +// regression this test exists to catch. +func TestTraversal_VirtualPathOf_noColonIsByteIdenticalToPreEscaping(t *testing.T) { + tests := []struct { + name string + traversal *Traversal + entryPath string + expected string + }{ + { + name: "nil traversal, absolute entry path", + traversal: nil, + entryPath: "/WEB-INF/lib/dep.jar", + expected: "/WEB-INF/lib/dep.jar", + }, + { + name: "nil traversal, relative entry path", + traversal: nil, + entryPath: "some/dir/example.jar", + expected: "some/dir/example.jar", + }, + { + name: "single level, leading slash trimmed", + traversal: &Traversal{VirtualPath: "app.war"}, + entryPath: "/WEB-INF/lib/dep.jar", + expected: "app.war:WEB-INF/lib/dep.jar", + }, + { + name: "single level, no leading slash", + traversal: &Traversal{VirtualPath: "app.war"}, + entryPath: "WEB-INF/lib/dep.jar", + expected: "app.war:WEB-INF/lib/dep.jar", + }, + { + name: "multi-level chain", + traversal: &Traversal{VirtualPath: "app.war:WEB-INF/lib/dep.jar"}, + entryPath: "/inner/most.jar", + expected: "app.war:WEB-INF/lib/dep.jar:inner/most.jar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.traversal.VirtualPathOf(tt.entryPath)) + }) + } +} + +// TestTraversal_VirtualPathOf_escapingComposesAcrossNestingLevels asserts that escaping is +// applied at every level of a chain, not only the first: a colon in an entry name at a deeper +// nesting level is just as ambiguous as one at the outermost level, so it gets the same +// treatment. After composing, splitting the result on ':' must yield exactly one segment per +// real archive boundary - none of the escaped colons survive as a literal ':' byte, so they +// cannot be mistaken for delimiters by a naive splitter. +func TestTraversal_VirtualPathOf_theEscapeCharacterIsNotItselfEscaped(t *testing.T) { + // a known and accepted limit, not a defect: an entry path holding the literal text %3A is + // indistinguishable from one holding a colon. Escaping % as %25 would close it and is + // deliberately not done, because % is far more common in real paths than : and closing this + // would move a large number of well-defined identities to resolve a collision between two + // inputs both already assumed absent. Asserted so the limit is a decision on the record + // rather than something a later reader discovers and "fixes". + t.Parallel() + + outer := &Traversal{VirtualPath: "outer.zip"} + + fromColon := outer.VirtualPathOf("a:b.jar") + fromLiteral := outer.VirtualPathOf("a%3Ab.jar") + + assert.Equal(t, "outer.zip:a%3Ab.jar", fromColon) + assert.Equal(t, "outer.zip:a%3Ab.jar", fromLiteral) + assert.Equal(t, fromColon, fromLiteral, + "the collision is accepted; if this ever differs, the escape character started being escaped") +} + +func TestTraversal_VirtualPathOf_escapingComposesAcrossNestingLevels(t *testing.T) { + level1 := &Traversal{VirtualPath: "outer.zip"} + level2VirtualPath := level1.VirtualPathOf("lib/mid:dle.war") + require.Equal(t, "outer.zip:lib/mid%3Adle.war", level2VirtualPath) + + level2 := &Traversal{VirtualPath: level2VirtualPath} + final := level2.VirtualPathOf("WEB-INF/lib/inn:er.jar") + require.Equal(t, "outer.zip:lib/mid%3Adle.war:WEB-INF/lib/inn%3Aer.jar", final) + + assert.Equal(t, + []string{"outer.zip", "lib/mid%3Adle.war", "WEB-INF/lib/inn%3Aer.jar"}, + strings.Split(final, ":"), + "splitting on ':' must recover exactly the three real archive boundaries", + ) +} diff --git a/internal/bus/helpers.go b/internal/bus/helpers.go index f13364c2e..24a925efa 100644 --- a/internal/bus/helpers.go +++ b/internal/bus/helpers.go @@ -1,6 +1,8 @@ package bus import ( + "context" + "github.com/wagoodman/go-partybus" "github.com/wagoodman/go-progress" @@ -36,7 +38,39 @@ func Notify(message string) { }) } -func StartCatalogerTask(info monitor.GenericTask, size int64, initialStage string) *monitor.TaskProgress { +type catalogerTaskProgressKey struct{} + +// WithCatalogerTaskProgress returns a context whose cataloger tasks report into prog rather than +// starting a row of their own. +// +// Nested cataloging runs under such a context: re-running the cataloger pipeline against the +// contents of an archive is work that belongs to the progress of the task driving the recursion, not +// a second start of every cataloger. Starting rows again per archive floods a consumer's display, +// and where the new row carries an ID the consumer has already seen (every package cataloger does - +// the ID is the cataloger name) it replaces the live row for that ID. The replaced row is then never +// rendered again, its completion is never observed, and a UI that waits on every row it started - as +// the syft CLI does - blocks forever on teardown. +func WithCatalogerTaskProgress(ctx context.Context, prog *monitor.TaskProgress) context.Context { + return context.WithValue(ctx, catalogerTaskProgressKey{}, prog) +} + +// catalogerTaskProgressFromContext returns the progress that cataloger tasks started under this +// context report into, or nil when they should start one of their own. +func catalogerTaskProgressFromContext(ctx context.Context) *monitor.TaskProgress { + prog, _ := ctx.Value(catalogerTaskProgressKey{}).(*monitor.TaskProgress) + return prog +} + +func StartCatalogerTask(ctx context.Context, info monitor.GenericTask, size int64, initialStage string) *monitor.TaskProgress { + if parent := catalogerTaskProgressFromContext(ctx); parent != nil { + // the stage is shared, so what this cataloger is working on shows up on the row already on + // screen; the count is not, so this task completing does not complete the row it reports into + return &monitor.TaskProgress{ + AtomicStage: parent.AtomicStage, + Manual: progress.NewManual(size), + } + } + t := &monitor.TaskProgress{ AtomicStage: progress.NewAtomicStage(initialStage), Manual: progress.NewManual(size), diff --git a/internal/constants.go b/internal/constants.go index c00065718..02d382d49 100644 --- a/internal/constants.go +++ b/internal/constants.go @@ -3,7 +3,7 @@ package internal const ( // JSONSchemaVersion is the current schema version output by the JSON encoder // This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment. - JSONSchemaVersion = "16.1.4" + JSONSchemaVersion = "16.1.11" // Changelog // 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field). @@ -11,5 +11,11 @@ const ( // 16.1.2 - placeholder for 16.1.2 changelog // 16.1.3 - add GGUFFileParts to GGUFFileHeader metadata // 16.1.4 - add BunLockEntry metadata type for bun.lock support - + // 16.1.5 - add DenoLockEntry and DenoRemoteLockEntry metadata types for deno.lock support + // 16.1.6 - add Dependencies to ElixirMixLockEntry metadata + // 16.1.7 - add AppleAppBundleEntry metadata type for the apple app bundle cataloger + // 16.1.8 - add VcpkgManifest metadata type for vcpkg manifest support + // 16.1.9 - add Symbols (grouped by owning package import path) to GolangBinaryBuildinfoEntry metadata + // 16.1.10 - add SafeTensorsModelInfo metadata type for the safetensors AI model cataloger + // 16.1.11 - add archivePath to file coordinates, naming the nested archive chain a file was found in ) diff --git a/internal/file/copy.go b/internal/file/copy.go index 058fe27eb..c02c264bb 100644 --- a/internal/file/copy.go +++ b/internal/file/copy.go @@ -8,9 +8,9 @@ import ( const perFileReadLimit = 2 * GB -// safeCopy limits the copy from the reader. This is useful when extracting files from archives to +// SafeCopy limits the copy from the reader. This is useful when extracting files from archives to // protect against decompression bomb attacks. -func safeCopy(writer io.Writer, reader io.Reader) error { +func SafeCopy(writer io.Writer, reader io.Reader) error { numBytes, err := io.Copy(writer, io.LimitReader(reader, perFileReadLimit)) if numBytes >= perFileReadLimit { return fmt.Errorf("zip read limit hit (potential decompression bomb attack)") diff --git a/internal/file/copy_test.go b/internal/file/copy_test.go index 0c83d266f..f5d42a720 100644 --- a/internal/file/copy_test.go +++ b/internal/file/copy_test.go @@ -29,7 +29,7 @@ func (r *errReader) Read(p []byte) (int, error) { func TestSafeCopy(t *testing.T) { t.Run("clean copy returns nil", func(t *testing.T) { var buf bytes.Buffer - if err := safeCopy(&buf, strings.NewReader("hello")); err != nil { + if err := SafeCopy(&buf, strings.NewReader("hello")); err != nil { t.Fatalf("unexpected error: %v", err) } if got := buf.String(); got != "hello" { @@ -38,12 +38,12 @@ func TestSafeCopy(t *testing.T) { }) t.Run("propagates decompression error", func(t *testing.T) { - // #4806: safeCopy used to drop non-EOF errors, so the caller + // #4806: SafeCopy used to drop non-EOF errors, so the caller // would persist a partial buffer as a successful extract and // downstream catalogers silently read empty manifests. sentinel := errors.New("flate: corrupt input before offset 42") var buf bytes.Buffer - err := safeCopy(&buf, &errReader{data: []byte("partial"), err: sentinel}) + err := SafeCopy(&buf, &errReader{data: []byte("partial"), err: sentinel}) if err == nil { t.Fatalf("expected error to be returned, got nil") } @@ -56,7 +56,7 @@ func TestSafeCopy(t *testing.T) { // The old code had a dead io.EOF branch that labelled clean // reads as decompression bombs; keep the happy path clean. var buf bytes.Buffer - err := safeCopy(&buf, io.LimitReader(strings.NewReader("abc"), 3)) + err := SafeCopy(&buf, io.LimitReader(strings.NewReader("abc"), 3)) if err != nil { t.Fatalf("unexpected error on clean copy: %v", err) } diff --git a/internal/file/tar_file_traversal.go b/internal/file/tar_file_traversal.go index d3c9656a7..d4d496548 100644 --- a/internal/file/tar_file_traversal.go +++ b/internal/file/tar_file_traversal.go @@ -70,7 +70,7 @@ func ExtractGlobsFromTarToUniqueTempFile(ctx context.Context, archivePath, dir s } defer internal.CloseAndLogError(packedFile, archivePath) - if err := safeCopy(tempFile, packedFile); err != nil { + if err := SafeCopy(tempFile, packedFile); err != nil { return fmt.Errorf("unable to copy source=%q for tar=%q: %w", file.Name(), archivePath, err) } diff --git a/internal/file/zip_file_traversal.go b/internal/file/zip_file_traversal.go index 5fc26a220..c3ea0c8e2 100644 --- a/internal/file/zip_file_traversal.go +++ b/internal/file/zip_file_traversal.go @@ -101,7 +101,7 @@ func ExtractFromZipToUniqueTempFile(ctx context.Context, archivePath, dir string return fmt.Errorf("unable to extract directories, only files: %s", file.NameInArchive) } - if err := safeCopy(tempFile, zippedFile); err != nil { + if err := SafeCopy(tempFile, zippedFile); err != nil { return fmt.Errorf("unable to copy source=%q for zip=%q: %w", file.NameInArchive, archivePath, err) } @@ -138,7 +138,7 @@ func ContentsFromZip(ctx context.Context, archivePath string, paths ...string) ( } var buffer bytes.Buffer - if err := safeCopy(&buffer, zippedFile); err != nil { + if err := SafeCopy(&buffer, zippedFile); err != nil { return fmt.Errorf("unable to copy source=%q for zip=%q: %w", file.NameInArchive, archivePath, err) } @@ -165,10 +165,22 @@ func UnzipToDir(ctx context.Context, archivePath, targetDir string) error { } // SafeJoin ensures that any destinations do not resolve to a path above the prefix path. +// +// The comparison is on a path boundary rather than on the string prefix. A sibling directory that +// happens to share the prefix as a string is not inside it: under "/tmp/x/contents" the destination +// "../contents-evil/y" cleans to "/tmp/x/contents-evil/y", which starts with the prefix and is +// outside it. func SafeJoin(prefix string, dest ...string) (string, error) { joinResult := filepath.Join(append([]string{prefix}, dest...)...) + cleanPrefix := filepath.Clean(prefix) cleanJoinResult := filepath.Clean(joinResult) - if !strings.HasPrefix(cleanJoinResult, filepath.Clean(prefix)) { + + boundary := cleanPrefix + if !strings.HasSuffix(boundary, string(filepath.Separator)) { + boundary += string(filepath.Separator) + } + + if cleanJoinResult != cleanPrefix && !strings.HasPrefix(cleanJoinResult, boundary) { return "", &errZipSlipDetected{ Prefix: prefix, JoinArgs: dest, @@ -210,7 +222,7 @@ func extractSingleFile(file archives.FileInfo, expandedFilePath, archivePath str } }() - if err := safeCopy(outputFile, zippedFile); err != nil { + if err := SafeCopy(outputFile, zippedFile); err != nil { return fmt.Errorf("unable to copy source=%q to dest=%q for zip=%q: %w", file.NameInArchive, outputFile.Name(), archivePath, err) } } diff --git a/internal/file/zip_file_traversal_test.go b/internal/file/zip_file_traversal_test.go index 4fe960b66..5b7f73538 100644 --- a/internal/file/zip_file_traversal_test.go +++ b/internal/file/zip_file_traversal_test.go @@ -305,6 +305,45 @@ func TestSafeJoin(t *testing.T) { expected: "", errAssertion: assertErrorAs(&errZipSlipDetected{}), }, + // a legitimate nested path, several components deep + { + prefix: "/a/place", + args: []string{ + "lib", + "sub", + "file.txt", + }, + expected: "/a/place/lib/sub/file.txt", + errAssertion: assert.NoError, + }, + // the prefix itself is inside the prefix + { + prefix: "/a/place", + args: []string{ + ".", + }, + expected: "/a/place", + errAssertion: assert.NoError, + }, + // siblings that share the prefix as a STRING are not inside it. A string-prefix comparison + // accepted these, so an archive entry could write into a directory next to the one it was + // being extracted into. + { + prefix: "/a/place", + args: []string{ + "../place-evil/x", + }, + expected: "", + errAssertion: assertErrorAs(&errZipSlipDetected{}), + }, + { + prefix: "/tmp/syft-archive-abc/contents", + args: []string{ + "../contents-evil/x", + }, + expected: "", + errAssertion: assertErrorAs(&errZipSlipDetected{}), + }, } for _, test := range tests { diff --git a/internal/index/key_split.go b/internal/index/key_split.go new file mode 100644 index 000000000..f631f2a0c --- /dev/null +++ b/internal/index/key_split.go @@ -0,0 +1,328 @@ +// Package index provides a concurrent key-split (radix) index and a forward/reverse pair over it. +// +// Ported from a prototype dir scan package whose search strategy this +// exists to serve: a path index that answers "which entries end in .jar" and "which entries live +// under this directory" without walking a tree, so a cataloger's `**/*.ext` glob is a lookup keyed +// on the extension rather than a scan of every path. +// +// Kept close to the original so the two can be diffed. The lock type is local (see lock.go) rather +// than pulled in from the prototype's sync/atomic package, which carries unrelated helpers. +package index + +import ( + "bytes" + "encoding/json" + "strings" +) + +// ----------------------- KeySplitIndex ----------------------- + +type KeySplitIndex[T any] struct { + Node[T] +} + +var _ interface { + json.Marshaler + json.Unmarshaler +} = (*KeySplitIndex[int])(nil) + +func (n *KeySplitIndex[T]) MarshalJSON() ([]byte, error) { + values := map[string]T{} + collectValues(values, "", &n.Node) + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(values) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func (n *KeySplitIndex[T]) UnmarshalJSON(bytes []byte) error { + values := map[string]T{} + err := json.Unmarshal(bytes, &values) + if err != nil { + return err + } + for k, v := range values { + n.Set(k, v) + } + return nil +} + +func collectValues[T any](values map[string]T, key string, node *Node[T]) { + if node.set { + values[key] = node.value + } + for segment, child := range node.keyMap { + collectValues(values, key+segment, child) + } +} + +// ----------------------- Node ----------------------- + +type NodeMap[T any] map[string]*Node[T] + +type Node[T any] struct { + lockable + set bool + value T + keyMap NodeMap[T] + keyByChar map[rune]string +} + +type NodeUpdateFunc[T any] func(current *Node[T]) (newValue T) + +func (n *Node[T]) Value() (v T) { + unlock := n.RLock() + v = n.value + unlock() + return +} + +func (n *Node[T]) Contains(s string) bool { + _, equal := n._find(s) + return equal +} + +func (n *Node[T]) Get(s string) (out T) { + v, equal := n._find(s) + if !equal || v == nil { + return + } + out = v.value + return +} + +func (n *Node[T]) Set(name string, value T) { + node := n._makeNodeP(nil, name, nil) + node.SetValue(value) +} + +func (n *Node[T]) ByPrefix(s string) []T { + v, _ := n._find(s) + if v == nil { + return nil + } + return v.Collect() +} + +func (n *Node[T]) Update(name string, f NodeUpdateFunc[T]) { + unlock := n.Lock() + node := n._makeNodeP(&unlock, name, nil) + v := f(node) + node.SetValue(v) + unlock() +} + +func (n *Node[T]) SetValue(value T) { + unlock := n.Lock() + n.value = value + n.set = true + unlock() +} + +func (n *Node[T]) UnsetValue() { + unlock := n.Lock() + var zero T + n.value = zero + n.set = false + unlock() +} + +func (n *Node[T]) Collect() (values []T) { + n._collect(&values) + return values +} + +func (n *Node[T]) _collect(values *[]T) { + unlock := n.RLock() + if n.set { + *values = append(*values, n.value) + } + + for _, v := range n.keyMap { + v._collect(values) + } + + unlock() +} + +// _startsWith returns the node starting with the given string +func (n *Node[T]) _find(s string) (node *Node[T], equal bool) { + defer n.RLock()() + + if s == "" { + return n, true + } + + ch := rune(s[0]) + key := n.keyByChar[ch] + if key == "" { + return nil, false + } + + offset := len(key) + + // check our part of the key matches + if offset > 1 { + for i := 1; i < len(s) && i < offset; i++ { + if s[i] != key[i] { + return nil, false + } + } + } + + next := n.keyMap[key] + + // our key matched, it's equal -- just return the node + if offset == len(s) { + return next, true + } + + // our key matched, it's longer -- just return the node as a non-equality match + if offset >= len(s) { + return next, false + } + + // our key matched, but it's shorter -- return what we find for the next portion + return next._find(s[offset:]) +} + +//nolint:funlen,gocognit +func (n *Node[T]) _makeNodeP(unlock *func(), name string, nodeIfEmpty *Node[T]) *Node[T] { + if name == "" { + return n + } + + if unlock == nil { + rUnlock := n.RLock() + n = n._makeNodeP(&rUnlock, name, nodeIfEmpty) + rUnlock() + return n + } + + ch := rune(name[0]) + + if n.keyByChar == nil || n.keyMap == nil { + n._exclusiveLock(unlock) + + if n.keyByChar == nil { + n.keyByChar = map[rune]string{} + } + if n.keyMap == nil { + n.keyMap = NodeMap[T]{} + } + } + + key, ok := n.keyByChar[ch] + + // no entry for the given character + if !ok { + // check again with an exclusive lock before creating a new entry + n._exclusiveLock(unlock) + + key, ok = n.keyByChar[ch] + if !ok { + // no entry for the given character, create one; this is all we have to do + newNode := nodeIfEmpty + if newNode == nil { + newNode = &Node[T]{} + } + n.keyMap[name] = newNode + n.keyByChar[ch] = name + + return newNode + } + } + + switch { + case key == name: + existingNode := n.keyMap[key] + if existingNode == nil { + // try this again, something changed between read checks and exclusive lock + return n._makeNodeP(unlock, name, nodeIfEmpty) + } + + return existingNode + case strings.HasPrefix(key, name): + // existing key is longer than my key, we can just use the existing and make a new sub-entry for the longer key + n._exclusiveLock(unlock) + + existingNode := n.keyMap[key] + if existingNode == nil { + // try this again, something changed between read checks and exclusive lock + return n._makeNodeP(unlock, name, nodeIfEmpty) + } + delete(n.keyMap, key) + + newNode := nodeIfEmpty + if newNode == nil { + newNode = &Node[T]{} + } + n.keyMap[name] = newNode + n.keyByChar[ch] = name + + next := key[len(name):] + _ = newNode._makeNodeP(nil, next, existingNode) + + return newNode + case strings.HasPrefix(name, key): + // existing key is shorter than my key, we can just take the substring to remove the + // existing string prefix and set the new node as a child of the existing node + n._exclusiveLock(unlock) + + existingNode := n.keyMap[key] + if existingNode == nil { + // try this again, something changed between read checks and exclusive lock + return n._makeNodeP(unlock, name, nodeIfEmpty) + } + + next := name[len(key):] + return existingNode._makeNodeP(nil, next, nodeIfEmpty) + default: + // neither the existing key nor my key contains a prefix of the other, so we find + // the longest common prefix and split BOTH entries as children of a new entry with this common prefix + n._exclusiveLock(unlock) + + commonLength := 1 // the first character already matches + minLength := len(key) + if len(name) < minLength { + minLength = len(name) + } + for ; commonLength < minLength; commonLength++ { + if key[commonLength] != name[commonLength] { + break + } + } + + existingNode := n.keyMap[key] + if existingNode == nil { + // try this again, something changed between read checks and exclusive lock + return n._makeNodeP(unlock, name, nodeIfEmpty) + } + delete(n.keyMap, key) + + newNode := nodeIfEmpty + if newNode == nil { + newNode = &Node[T]{} + } + parentNode := &Node[T]{} + parentNode._makeNodeP(nil, key[commonLength:], existingNode) + parentNode._makeNodeP(nil, name[commonLength:], newNode) + + common := key[:commonLength] + n.keyMap[common] = parentNode + n.keyByChar[ch] = common + + return newNode + } +} + +func (n *Node[T]) _exclusiveLock(unlocker *func()) { + if !n.isExclusiveLock(*unlocker) { + (*unlocker)() + *unlocker = n.Lock() + } +} diff --git a/internal/index/key_split_test.go b/internal/index/key_split_test.go new file mode 100644 index 000000000..321d4d4f6 --- /dev/null +++ b/internal/index/key_split_test.go @@ -0,0 +1,175 @@ +package index + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_KeySplitIndex_Create(t *testing.T) { + fi := KeySplitIndex[int]{} + + fi.Set("one", 1) + + requireEqualValues(t, &fi, m{ + "one": 1, + }) + + fi.Set("onesie", 101) + + requireEqualValues(t, &fi, m{ + "one": m{ // there is a value here + "sie": 101, + }, + }) + + fi.Set("apple", 99) + + requireEqualValues(t, &fi, m{ + "one": m{ + "sie": 101, + }, + "apple": 99, + }) + + fi.Set("alice", 999) + + requireEqualValues(t, &fi, m{ + "one": m{ + "sie": 101, + }, + "a": m{ + "pple": 99, + "lice": 999, + }, + }) +} + +type m = map[string]any + +func requireEqualValues[T comparable](t *testing.T, value *KeySplitIndex[T], expected m) { + _requireNodeValues(t, &value.Node, "", expected) +} + +func _requireNodeValues[T comparable](t *testing.T, value *Node[T], path string, expected any) { + switch expected := expected.(type) { + case T: + if value.value != expected { + t.Fatalf("values not equal: %v != %v", value.value, value) + } + case m: + if len(value.keyMap) != len(expected) { + t.Fatalf("number of children not equal: %v != %v", len(value.keyMap), len(expected)) + } + for k, v := range expected { + v2, ok := value.keyMap[k] + if !ok { + t.Fatalf("missing key: %s", k) + } else { + _requireNodeValues(t, v2, path, v) + } + } + default: + panic(fmt.Errorf("invalid type: %#v", expected)) + } +} + +func Test_KeySplitIndex_Get(t *testing.T) { + fi := KeySplitIndex[int]{} + + const ( + one = 1 + two = 2 + once = 11 + onesie = 11 + ) + + fi.Set("one", one) + require.Equal(t, one, fi.Get("one")) + + fi.Set("two", two) + require.Equal(t, one, fi.Get("one")) + require.Equal(t, two, fi.Get("two")) + + fi.Set("once", once) + require.Equal(t, one, fi.Get("one")) + require.Equal(t, two, fi.Get("two")) + require.Equal(t, once, fi.Get("once")) + + fi.Set("onesie", onesie) + require.Equal(t, one, fi.Get("one")) + require.Equal(t, two, fi.Get("two")) + require.Equal(t, once, fi.Get("once")) + require.Equal(t, onesie, fi.Get("onesie")) + + fi.Set("apple", 1000) + require.Equal(t, one, fi.Get("one")) + require.Equal(t, two, fi.Get("two")) + require.Equal(t, once, fi.Get("once")) + require.Equal(t, onesie, fi.Get("onesie")) + + fi.Set("alice", 1001) + require.Equal(t, one, fi.Get("one")) + require.Equal(t, two, fi.Get("two")) + require.Equal(t, once, fi.Get("once")) + require.Equal(t, onesie, fi.Get("onesie")) + + requireHasAll(t, []int{one, once, onesie}, fi.ByPrefix("o")...) + requireHasAll(t, []int{one, once, onesie}, fi.ByPrefix("on")...) + requireHasAll(t, []int{one, onesie}, fi.ByPrefix("one")...) + requireHasAll(t, []int{onesie}, fi.ByPrefix("ones")...) + requireHasAll(t, []int{onesie}, fi.ByPrefix("onesi")...) + requireHasAll(t, []int{onesie}, fi.ByPrefix("onesie")...) + requireHasAll(t, nil, fi.ByPrefix("onesies")...) + requireHasAll(t, []int{two}, fi.ByPrefix("t")...) + requireHasAll(t, []int{two}, fi.ByPrefix("tw")...) + requireHasAll(t, []int{two}, fi.ByPrefix("two")...) + requireHasAll(t, []int{}, fi.ByPrefix("twos")...) + + require.Equal(t, 0, fi.Get("invalid")) // returns the zero value +} + +func Test_KeySplitIndex_serialization(t *testing.T) { + index1 := KeySplitIndex[int]{} + + const ( + one = 1 + two = 2 + once = 11 + onesie = 111 + ) + + index1.Set("one", one) + index1.Set("two", two) + index1.Set("once", once) + index1.Set("onesie", onesie) + + serialized, err := json.Marshal(&index1) + require.NoError(t, err) + + require.JSONEq(t, `{"one":1,"two":2,"once":11,"onesie":111}`, string(serialized)) + + var index2 KeySplitIndex[int] + err = json.Unmarshal(serialized, &index2) + require.NoError(t, err) + + require.Equal(t, &index1, &index2) +} + +// requireHasAll asserts the values are exactly the required ones, in any order. Carried over with the +// index's own tests so they run here unchanged. +func requireHasAll[T comparable](t *testing.T, values []T, required ...T) { + t.Helper() + require.Len(t, values, len(required)) +nextFound: + for _, ex := range required { + for _, existing := range values { + if existing == ex { + continue nextFound + } + } + require.Fail(t, fmt.Sprintf("value %v isn't in slice %v", ex, values)) + } +} diff --git a/internal/index/lock.go b/internal/index/lock.go new file mode 100644 index 000000000..515821cd2 --- /dev/null +++ b/internal/index/lock.go @@ -0,0 +1,32 @@ +package index + +import ( + "reflect" + "sync" +) + +// lockable is a read-write lock that hands back its own unlock function. +// +// Returning the unlocker rather than exposing Unlock is what makes the index's lock upgrades safe to +// write: a caller holds an opaque `unlock`, and the node it is walking can tell whether that unlock +// belongs to the write lock or the read lock and upgrade only when it has to. Carried over from the +// prototype this index came from, where the pattern is used throughout. +type lockable struct { + lock sync.RWMutex +} + +func (l *lockable) Lock() (unlock func()) { + l.lock.Lock() + return l.lock.Unlock +} + +func (l *lockable) RLock() (unlock func()) { + l.lock.RLock() + return l.lock.RUnlock +} + +// isExclusiveLock reports whether the given unlocker is the write lock's rather than the read +// lock's. Compared by function pointer because that is the only handle the caller was given. +func (l *lockable) isExclusiveLock(fn func()) bool { + return reflect.ValueOf(l.lock.Unlock).Pointer() == reflect.ValueOf(fn).Pointer() +} diff --git a/internal/index/prefix_suffix.go b/internal/index/prefix_suffix.go new file mode 100644 index 000000000..7d4c453eb --- /dev/null +++ b/internal/index/prefix_suffix.go @@ -0,0 +1,37 @@ +package index + +// PrefixSuffix index combines two KeySplitIndexes to simplify indexing and searching by prefix and suffix +type PrefixSuffix[T any] struct { + forwardIndex KeySplitIndex[T] + reverseIndex KeySplitIndex[T] +} + +func (f *PrefixSuffix[T]) Set(key string, value T) { + f.forwardIndex.Set(key, value) + f.reverseIndex.Set(reverse(key), value) +} + +func (f *PrefixSuffix[T]) Update(key string, fn NodeUpdateFunc[T]) { + f.forwardIndex.Update(key, fn) + f.reverseIndex.Update(reverse(key), fn) +} + +func (f *PrefixSuffix[T]) Get(key string) T { + return f.forwardIndex.Get(key) +} + +func (f *PrefixSuffix[T]) ByPrefix(prefix string) []T { + return f.forwardIndex.ByPrefix(prefix) +} + +func (f *PrefixSuffix[T]) BySuffix(suffix string) []T { + return f.reverseIndex.ByPrefix(reverse(suffix)) +} + +func reverse(s string) string { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} diff --git a/internal/index/prefix_suffix_test.go b/internal/index/prefix_suffix_test.go new file mode 100644 index 000000000..2c5d12e56 --- /dev/null +++ b/internal/index/prefix_suffix_test.go @@ -0,0 +1,29 @@ +package index + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_PrefixSuffix(t *testing.T) { + i := PrefixSuffix[int]{} + i.Set("one", 1) + i.Set("once", 11) + i.Set("onesie", 111) + i.Set("two", 2) + i.Set("done", 99) + require.Equal(t, 1, i.Get("one")) + require.Equal(t, 11, i.Get("once")) + require.Equal(t, 111, i.Get("onesie")) + require.Equal(t, 2, i.Get("two")) + requireHasAll(t, i.ByPrefix("on"), 1, 11, 111) + requireHasAll(t, i.BySuffix("e"), 1, 11, 111, 99) + requireHasAll(t, i.BySuffix(""), 1, 2, 11, 111, 99) + requireHasAll(t, i.BySuffix("one"), 1, 99) + requireHasAll(t, i.BySuffix("sie"), 111) +} + +func Test_reverse(t *testing.T) { + require.Equal(t, "case", reverse("esac")) +} diff --git a/internal/task/archive_tasks.go b/internal/task/archive_tasks.go new file mode 100644 index 000000000..c8fddfa6a --- /dev/null +++ b/internal/task/archive_tasks.go @@ -0,0 +1,535 @@ +package task + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/dustin/go-humanize" + + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/mimetype" + "github.com/anchore/syft/internal/sbomsync" + "github.com/anchore/syft/internal/unknown" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/cataloging" + "github.com/anchore/syft/syft/cataloging/pkgcataloging" + "github.com/anchore/syft/syft/event/monitor" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/sbom" +) + +const ArchiveCatalogerTaskName = "archive-cataloger" + +// NewArchiveCatalogerTask returns a task that recursively extracts archives (including JAR-family +// archives, whose cataloger skips its own unarchiving when this task is enabled), treats each as +// its own standalone indexed filesystem, runs the given cataloger sub-pipeline against each, and +// records file-level CONTAINS relationships from the archive to the packages and files discovered +// inside it. Returns nil when archive cataloging is disabled (MaxDepth <= 0), so a disabled feature +// registers no task at all rather than a task that runs and does nothing. +// +// subPipeline is the set of package/file cataloger tasks to run against each extracted archive; it +// must NOT include this task, so recursion is driven only by the explicit depth-limited walk here. +// newResolver builds an FSID-stamped indexed resolver over an extracted archive directory (injected +// so this package does not depend on the syft-subtree internal fileresolver package). +func NewArchiveCatalogerTask(cfg cataloging.ArchiveSearchConfig, subPipeline []Task, newResolver archive.ResolverFactory, newStoreResolver archive.StoreResolverFactory, notify archive.Notify) Task { + if cfg.MaxDepth == 0 || newResolver == nil { + return nil + } + + // this task drives recursion itself via an explicit depth-bounded walk, so it must never appear + // in its own sub-pipeline: each nesting level would then be processed once by the walk and + // again by the nested copy. Enforced rather than left to a comment, since the caller assembles + // the sub-pipeline from whatever package and file tasks are selected. + subPipeline = withoutArchiveCataloger(subPipeline) + if len(subPipeline) == 0 { + return nil + } + fn := func(ctx context.Context, resolver file.Resolver, builder sbomsync.Builder) error { + // one progress row for the whole recursive walk, updated as each archive is entered. The + // sub-pipeline underneath it reports into this same row rather than starting rows of its own: + // those catalogers have already started (and finished) a row for the scan root, and starting + // them again per archive is what both floods the display and strands the rows it replaces + // (see bus.WithCatalogerTaskProgress). + prog := bus.StartCatalogerTask(ctx, archiveCatalogerProgressInfo(), -1, "") + c := &archiveCataloger{ + cfg: cfg, + subPipeline: subPipeline, + extractors: archive.DefaultExtractors(), + limits: archive.DefaultExtractionLimits(cfg), + limiter: archive.NewLimiter(archive.DefaultLimits(cfg)), + newResolver: newResolver, + newStoreResolver: newStoreResolver, + notify: notify, + prog: prog, + } + err := c.catalog(archive.WithLimiter(bus.WithCatalogerTaskProgress(ctx, prog), c.limiter), resolver, nil, 0, builder) + c.left() + c.logStats() + prog.SetCompleted() + return err + } + return NewTask(ArchiveCatalogerTaskName, fn, pkgcataloging.PackageTag, "archive") +} + +func archiveCatalogerProgressInfo() monitor.GenericTask { + return monitor.GenericTask{ + Title: monitor.Title{ + Default: "Archives", + WhileRunning: "Cataloging archives", + OnSuccess: "Cataloged archives", + }, + ID: ArchiveCatalogerTaskName, + ParentID: monitor.TopLevelCatalogingTaskID, + } +} + +// withoutArchiveCataloger returns the given tasks with any archive-cataloger task removed. +func withoutArchiveCataloger(tasks []Task) []Task { + out := make([]Task, 0, len(tasks)) + for _, t := range tasks { + if t == nil { + continue + } + if t.Name() == ArchiveCatalogerTaskName { + log.WithFields("task", t.Name()). + Debug("dropping archive cataloger task from its own sub-pipeline") + continue + } + out = append(out, t) + } + return out +} + +type archiveCataloger struct { + cfg cataloging.ArchiveSearchConfig + subPipeline []Task + extractors []archive.Extractor + limits archive.ExtractionLimits + newResolver archive.ResolverFactory + newStoreResolver archive.StoreResolverFactory + + // notify carries structured extraction events - which archive overflowed to disk and why, which was + // skipped - to whoever asked for them. Nil is the no-op, and nil is what a scan that wants nothing + // instrumented passes. + notify archive.Notify + + // limiter measures the archive content this scan is holding right now, falling as each archive is + // released. One instance per task run, because one task run is one scan and that is the only + // scoping under which "held at once" means anything. It replaces a monotonic byte counter, which + // bounded a number matching no state that ever existed: every archive's extraction directory is + // removed on the way out while the counter only ever rose. + limiter *archive.Limiter + + // prog is the single progress row for the whole walk: its stage names the archive currently being + // cataloged and its count is the number of archives entered so far. The recursion reports through + // this one row rather than letting the sub-pipeline start rows of its own at every level. + prog *monitor.TaskProgress + + // slowest is the archive that spent the most time on its own extraction and cataloging, named by + // its full virtual path. Guarded for the same reason the limiter is: the walk is sequential today + // and lives in a concurrent neighbourhood. + statsMu sync.Mutex + slowest time.Duration + slowestPath string + archivesSeen int64 +} + +// recordArchiveTime keeps the longest self time seen and the archive that spent it. Self time +// excludes the archives nested inside this one, which are timed against themselves - inclusive time +// would name the outermost archive every run and say nothing. +func (c *archiveCataloger) recordArchiveTime(virtualPath string, took time.Duration) { + c.statsMu.Lock() + defer c.statsMu.Unlock() + c.archivesSeen++ + if took > c.slowest { + c.slowest = took + c.slowestPath = virtualPath + } +} + +// logStats reports what the walk cost, once, when it is done. The two peaks are the high-water marks +// of the gauges the limits are enforced against, so they are directly comparable to the configured +// bounds - they are not process memory and not filesystem usage. Reported at info rather than debug +// because it is one line per scan and it is what someone tuning the limits is looking for; the +// per-archive skip and truncation lines around it stay at debug. +func (c *archiveCataloger) logStats() { + c.statsMu.Lock() + slowest, slowestPath, seen := c.slowest, c.slowestPath, c.archivesSeen + c.statsMu.Unlock() + + peakMemory, peakDisk := c.limiter.Peak() + fields := []any{ + "archives", seen, + "peak-memory", humanize.Bytes(uint64(peakMemory)), + "peak-disk", humanize.Bytes(uint64(peakDisk)), + } + if slowestPath != "" { + fields = append(fields, "slowest", slowestPath, "slowest-took", slowest.Round(time.Millisecond)) + } + log.WithFields(fields...).Info("nested archive cataloging complete") +} + +// catalog discovers archives in the given resolver and processes each, recursing up to MaxDepth. +// parent is the traversal for the archive whose contents are being searched (nil at the scan root). +func (c *archiveCataloger) catalog(ctx context.Context, resolver file.Resolver, parent *archive.Traversal, depth int, builder sbomsync.Builder) error { + if c.cfg.MaxDepth >= 0 && depth >= c.cfg.MaxDepth { + return nil + } + + var errs error + for _, candidate := range c.discoverArchives(resolver) { + // there is no between-archives check: a limit falls when an archive is released, so an + // archive that does not fit says nothing about the next one, and reaching a bound skips only + // the archive that reached it. + if err := c.processArchive(ctx, resolver, candidate, parent, depth, builder); err != nil { + errs = unknown.Append(errs, candidate.location, err) + } + } + return errs +} + +func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver file.Resolver, candidate archiveCandidate, parent *archive.Traversal, depth int, builder sbomsync.Builder) error { + archiveLoc := candidate.location + content, err := parentResolver.FileContentsByLocation(archiveLoc) + if err != nil { + return err + } + defer internal.CloseAndLogError(content, archiveLoc.AccessPath) + + // a candidate that is only a candidate is checked cheaply before its bytes are read in full: an + // archive wearing a stub carries an entry signature near the front, and almost nothing else does. + // Reading every executable in a scan to its end to ask the authoritative question instead doubled + // a stock ruby image. + var archiveContent io.Reader = content + if !candidate.sniffedAsArchive { + var mayHide bool + archiveContent, mayHide = archive.MayHideAnAppendedArchive(content) + if !mayHide { + return nil + } + } + + // self time for this archive: its extraction, its sub-pipeline and its merge, stopped before the + // recursive descent so a containing archive is not charged for what it contains + started := time.Now() + + // the extracted files inherit the filesystem id of the location the archive was found at, + // uniformly at every level: for an image source that is the layer digest, for a directory source + // it is empty, and inside another archive the enclosing resolver stamped it there already. The + // nesting chain is carried separately as the archive path (the colon-delimited chain of archive + // access paths from the scan root). Together these keep identically-named files in different + // archives - and the same archive path in different layers - apart in the coordinate-keyed tables. + archivePath := parent.VirtualPathOf(accessPath(archiveLoc)) + extracted, err := archive.ExtractToResolver(ctx, archiveContent, archiveLoc.AccessPath, archiveLoc.FileSystemID, archivePath, c.extractors, c.limiter, c.limits, c.newResolver, c.newStoreResolver, c.notify) + if errors.Is(err, archive.ErrDiskLimitReached) { + // the disk limit is terminal and nothing will be released while this archive waits, since + // the walk descends before it unwinds - so the archive is skipped and the scan continues. + // Attributed to the archive, because a log line carrying only the bound says nothing about + // what was lost. + log.WithFields("archive", accessPath(archiveLoc), "limit", c.cfg.MaxDiskBytes). + Debug("skipping archive whose content would exceed the disk limit") + return nil + } + if err != nil { + return err + } + if extracted == nil { + // nothing could open it: a format with no extractor (an ar archive, a bare gzip that is not a + // tar), or a candidate whose head could have been hiding an archive and was not. Logged, not + // returned as an error. + // + // Returning one was measured at 3x the scan on a stock ruby image, whose 3,679 gzip files are + // mostly man pages with no extractor: every one produced an error, and appending thousands of + // them into one chain that is then walked for coordinate errors is quadratic. A file syft + // cannot open is also not the failure `#archive-failure-is-not-fatal` is about - that is a + // corrupt archive, which fails during extraction and still returns an error from there. + log.WithFields("archive", accessPath(archiveLoc), "sniffed-as-archive", candidate.sniffedAsArchive). + Trace("no extractor could read this candidate; skipping") + return nil + } + // releases the extraction directory and gives back everything this archive charged to the + // limiter, after the sub-pipeline and after recursion + defer extracted.Cleanup() + + if extracted.Result.Truncated() { + // a bound stopped the extraction early: catalog what was written rather than discarding the + // archive, but say so, since the packages found here are not the full set. Recorded in the + // SBOM's unknowns and not only in the logs, because "syft saw part of this archive" is a fact + // about the SBOM's completeness that outlives the run that produced it - which is exactly what + // unknowns are for. + log.WithFields("archive", accessPath(archiveLoc), "limit", string(extracted.Result.Truncation)). + Debug("archive extraction truncated by a configured limit; cataloging partial contents") + + appendUnknowns(builder, ArchiveCatalogerTaskName, []unknown.CoordinateError{{ + Coordinates: archiveLoc.Coordinates, + Reason: fmt.Errorf("archive cataloged from part of its contents: extraction stopped at the %s", + string(extracted.Result.Truncation)), + }}) + } + + // expose the nesting chain to catalogers in the sub-pipeline (and deeper recursion) so they can + // reconstruct nesting-aware identity (e.g. the java cataloger's colon-delimited virtual paths) + trav := &archive.Traversal{ + Location: archiveLoc, + VirtualPath: archivePath, + FileSystemID: extracted.FileSystemID, + Depth: depth + 1, + Parent: parent, + Digests: extracted.Digests, + } + subCtx := archive.WithTraversal(ctx, trav) + + c.entering(trav.VirtualPath) + + // run the package/file cataloger sub-pipeline against the archive's standalone filesystem + scratch := c.runSubPipeline(subCtx, extracted.Resolver, archiveLoc.Coordinates) + + // merge results into the shared SBOM and record file-level CONTAINS edges from this archive + mergeArchiveResults(archiveLoc.Coordinates, scratch, builder) + + c.left() + c.recordArchiveTime(trav.VirtualPath, time.Since(started)) + + // recurse into archives nested within this one + return c.catalog(subCtx, extracted.Resolver, trav, depth+1, builder) +} + +// entering records on the walk's single progress row that this archive is now being cataloged, and +// left takes that row's stage back once it is done. Both are nil-safe, so a cataloger assembled +// without a progress row simply reports nothing. +// +// The row is taken back because the sub-pipeline shares its stage - that is what makes the nested +// catalogers' progress visible - and each of them signs off with a summary of its own ("0 +// executables"). Left to stand, that summary is what the row reads while the next archive is being +// extracted, which is both wrong and unchanging. The count of archives entered is the one thing the +// row can say that is always true of the walk as a whole. +func (c *archiveCataloger) entering(virtualPath string) { + if c.prog == nil { + return + } + c.prog.Increment() + c.prog.AtomicStage.Set(virtualPath) +} + +func (c *archiveCataloger) left() { + if c.prog == nil { + return + } + c.prog.AtomicStage.Set(fmt.Sprintf("%s archives", humanize.Comma(c.prog.Current()))) +} + +// accessPath returns the location's access path, falling back to the real path when unset. +func accessPath(loc file.Location) string { + if loc.AccessPath != "" { + return loc.AccessPath + } + return loc.RealPath +} + +// runSubPipeline runs every cataloger task against the given resolver into a throwaway SBOM so the +// results can be attributed to the containing archive before being merged into the shared SBOM. +// +// A task's failure is recorded the same way RunTask records one at the top level (executor.go), so a +// cataloger that fails inside an archive is visible in the SBOM's unknowns rather than only in the +// logs: errors carrying their own coordinates keep them, and anything left has no location of its +// own, so it is attributed to archiveCoord - the archive that was being cataloged. A failure here +// never fails the archive or the scan. +func (c *archiveCataloger) runSubPipeline(ctx context.Context, resolver file.Resolver, archiveCoord file.Coordinates) *sbom.SBOM { + scratch := &sbom.SBOM{ + Artifacts: sbom.Artifacts{ + Packages: pkg.NewCollection(), + FileMetadata: map[file.Coordinates]file.Metadata{}, + FileDigests: map[file.Coordinates][]file.Digest{}, + FileContents: map[file.Coordinates]string{}, + FileLicenses: map[file.Coordinates][]file.License{}, + Executables: map[file.Coordinates]file.Executable{}, + Unknowns: map[file.Coordinates][]string{}, + }, + } + scratchBuilder := sbomsync.NewBuilder(scratch) + for _, t := range c.subPipeline { + err := runTaskSafely(ctx, t, resolver, scratchBuilder) + if err == nil { + continue + } + log.WithFields("task", t.Name(), "error", err).Trace("archive sub-cataloger task reported an error") + + unknowns, remaining := unknown.ExtractCoordinateErrors(err) + if remaining != nil { + unknowns = append(unknowns, unknown.CoordinateError{Coordinates: archiveCoord, Reason: remaining}) + } + appendUnknowns(scratchBuilder, t.Name(), unknowns) + } + return scratch +} + +// archiveCandidate is one file the walk will try to open, and how it came to be a candidate. +type archiveCandidate struct { + location file.Location + + // sniffedAsArchive is true when the file's own content types it as an archive. Such a file that + // then cannot be extracted is a broken archive and is reported; a candidate that got here only + // because its head could be hiding one is simply not an archive, and is passed over in silence. + sniffedAsArchive bool +} + +// zipArchiveGlobs find the files worth a second look: those that claim to be zip archives by name +// and whose content did not say so. A self-extracting archive is one - a launcher stub with a zip +// concatenated onto it, typed by the stub - and the canonical case is a Spring Boot executable jar, +// which sniffs as a shell script. +// +// This is not a detection rule. Whether a candidate is an archive is settled by the +// end-of-central-directory record and by nothing else, so a file named `.jar` that holds prose is +// refused exactly as it is today. The name decides only what gets looked at. +// +// It is a glob rather than a MIME lookup because the MIME lookup was the whole cost. Asking the +// resolver for every file that sniffs as executable, script, or unrecognized returns 3,274 locations +// on a stock ruby image - all of which this then discarded, since none of them were named like +// archives - and that one call took the scan from 15 seconds to 45. Asking for the names directly +// returns the handful that matter. +// +// The limit this accepts is that a self-extracting archive with no telling extension is not found. +// That is deliberate, and it is the one place here where a name decides whether syft looks. +// +// One pattern per extension, not one pattern with brace alternation: the alternation form matched +// nothing, and the cost of the extra patterns is not measurable - the whole lookup is about a +// millisecond and returns nothing on an image with no self-extracting archives in it. +var zipArchiveGlobs = []string{ + "**/*.jar", "**/*.war", "**/*.ear", "**/*.par", "**/*.sar", "**/*.nar", "**/*.kar", + "**/*.hpi", "**/*.jpi", "**/*.far", "**/*.rar", "**/*.zip", "**/*.apk", "**/*.aar", + "**/*.egg", "**/*.whl", "**/*.lpkg", "**/*.zap", "**/*.exe", +} + +// discoverArchives returns every location worth opening, in two groups. +// +// There is no exclusion filter here on purpose. The scan's exclusion patterns are applied where each +// filesystem is indexed - the source's own index for the scan root, and the extraction directory's +// index for an archive's contents - so an excluded archive is not in the resolver to be returned in +// the first place. Filtering again here would be a second mechanism answering the same question. +// +// The archive MIME types are archives by their own content. The appended set are files whose head +// says script or executable and whose tail may still be a whole zip: a self-extracting archive is +// typed by its stub, so nothing sniffed at offset zero can see it. Membership in the second set +// decides only that a file is worth opening - whether it is an archive is decided by the extractor, +// which refuses anything with no end-of-central-directory record. +func (c *archiveCataloger) discoverArchives(resolver file.Resolver) []archiveCandidate { + archives, err := resolver.FilesByMIMEType(mimetype.ArchiveMIMETypeSet.List()...) + if err != nil { + log.WithFields("error", err).Debug("unable to list archive files for recursive cataloging") + return nil + } + + candidates := make([]archiveCandidate, 0, len(archives)) + for _, loc := range archives { + candidates = append(candidates, archiveCandidate{location: loc, sniffedAsArchive: true}) + } + + appended, err := resolver.FilesByGlob(zipArchiveGlobs...) + if err != nil { + // the archives found above are still worth walking + log.WithFields("error", err).Debug("unable to list files that may carry an appended archive") + return candidates + } + seen := make(map[file.Coordinates]struct{}, len(candidates)) + for _, c := range candidates { + seen[c.location.Coordinates] = struct{}{} + } + for _, loc := range appended { + // a location reached by both lookups is one file and must be opened once. The two sets are + // disjoint today, so this guards the invariant rather than a known overlap - and processing + // an archive twice would double every package it holds, which is the failure this whole + // design exists to prevent. + if _, dup := seen[loc.Coordinates]; dup { + continue + } + seen[loc.Coordinates] = struct{}{} + candidates = append(candidates, archiveCandidate{location: loc}) + } + return candidates +} + +// mergeArchiveResults copies the throwaway SBOM's packages, files, and relationships into the shared +// SBOM, and adds a file-level CONTAINS relationship from the archive's coordinates to every package +// and file discovered inside it. +func mergeArchiveResults(archiveCoord file.Coordinates, scratch *sbom.SBOM, builder sbomsync.Builder) { + pkgs := scratch.Artifacts.Packages.Sorted() + if len(pkgs) > 0 { + builder.AddPackages(pkgs...) + } + + accessor, _ := builder.(sbomsync.Accessor) + if accessor != nil { + accessor.WriteToSBOM(func(s *sbom.SBOM) { + mergeFileArtifacts(s, scratch) + }) + } + + var rels []artifact.Relationship + for _, p := range pkgs { + rels = append(rels, artifact.Relationship{ + From: archiveCoord, + To: p, + Type: artifact.ContainsRelationship, + }) + } + for _, coord := range scratch.AllCoordinates() { + rels = append(rels, artifact.Relationship{ + From: archiveCoord, + To: coord, + Type: artifact.ContainsRelationship, + }) + } + // preserve relationships discovered within the archive (e.g. package evident-by file) + rels = append(rels, scratch.Relationships...) + + if len(rels) > 0 { + builder.AddRelationships(rels...) + } +} + +func mergeFileArtifacts(dst, src *sbom.SBOM) { + if dst.Artifacts.FileMetadata == nil { + dst.Artifacts.FileMetadata = map[file.Coordinates]file.Metadata{} + } + for k, v := range src.Artifacts.FileMetadata { + dst.Artifacts.FileMetadata[k] = v + } + if dst.Artifacts.FileDigests == nil { + dst.Artifacts.FileDigests = map[file.Coordinates][]file.Digest{} + } + for k, v := range src.Artifacts.FileDigests { + dst.Artifacts.FileDigests[k] = v + } + if dst.Artifacts.FileContents == nil { + dst.Artifacts.FileContents = map[file.Coordinates]string{} + } + for k, v := range src.Artifacts.FileContents { + dst.Artifacts.FileContents[k] = v + } + if dst.Artifacts.FileLicenses == nil { + dst.Artifacts.FileLicenses = map[file.Coordinates][]file.License{} + } + for k, v := range src.Artifacts.FileLicenses { + dst.Artifacts.FileLicenses[k] = v + } + if dst.Artifacts.Executables == nil { + dst.Artifacts.Executables = map[file.Coordinates]file.Executable{} + } + for k, v := range src.Artifacts.Executables { + dst.Artifacts.Executables[k] = v + } + if dst.Artifacts.Unknowns == nil { + dst.Artifacts.Unknowns = map[file.Coordinates][]string{} + } + for k, v := range src.Artifacts.Unknowns { + dst.Artifacts.Unknowns[k] = append(dst.Artifacts.Unknowns[k], v...) + } +} diff --git a/internal/task/archive_tasks_test.go b/internal/task/archive_tasks_test.go new file mode 100644 index 000000000..34b97ac7c --- /dev/null +++ b/internal/task/archive_tasks_test.go @@ -0,0 +1,1365 @@ +package task + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/bmatcuk/doublestar/v4" + "github.com/scylladb/go-set/strset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + stereoscopeFile "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/mimetype" + "github.com/anchore/syft/internal/sbomsync" + "github.com/anchore/syft/internal/unknown" + "github.com/anchore/syft/syft/cataloging" + "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/monitor" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/sbom" +) + +// dirTestResolver is a minimal file.Resolver over a directory for exercising the archive cataloger +// task: FilesByMIMEType reports any zip-family file (ignoring the actual MIME type arguments) and +// FileContentsByLocation reads from disk. All other resolver methods are unimplemented. +type dirTestResolver struct { + file.Resolver + dir string + fsid string +} + +func (d dirTestResolver) FilesByMIMEType(types ...string) ([]file.Location, error) { + return filesByMIMEType(d.dir, d.fsid, types...) +} + +// filesByMIMEType is what a real resolver does: sniff each file's content at index time and return +// the ones whose type was asked for. The test resolvers used to ignore the argument and return +// everything, which made every caller look like it was being handed archives - and hid the fact +// that the task now asks two different questions with two different consequences. +func filesByMIMEType(dir, fsid string, types ...string) ([]file.Location, error) { + wanted := strset.New(types...) + var locs []file.Location + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + mimeType := stereoscopeFile.MIMEType(f) + if closeErr := f.Close(); closeErr != nil { + return closeErr + } + if !wanted.Has(mimeType) { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + coords := file.Coordinates{RealPath: "/" + filepath.ToSlash(rel), FileSystemID: fsid} + locs = append(locs, file.NewLocationFromCoordinates(coords)) + return nil + }) + return locs, err +} + +func (d dirTestResolver) FileContentsByLocation(loc file.Location) (io.ReadCloser, error) { + return os.Open(filepath.Join(d.dir, filepath.FromSlash(loc.RealPath))) +} + +// tarTestResolver is a minimal file.Resolver over one archive's overflow tar, standing in for the real +// tar-backed resolver: the archive cataloger's own tests should not need the syft-subtree +// fileresolver package, but they do need something shaped like what it produces. +// +// FilesByMIMEType reports any zip-family entry (ignoring the MIME arguments) and +// FileContentsByLocation opens that entry at its offset in the tar. The reader it returns carries the +// marker the extraction path looks for, so a nested archive is exercised through the read-in-place +// route it takes in a real scan rather than through a copy. +type tarTestResolver struct { + file.Resolver + entries map[string]stereoscopeFile.TarIndexEntry + fsid string + archivePath string +} + +// newTarTestResolver is an archive.ResolverFactory over the overflow tar. +func newTarTestResolver(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + r := &tarTestResolver{entries: map[string]stereoscopeFile.TarIndexEntry{}, fsid: overflow.FileSystemID, archivePath: overflow.ArchivePath} + if _, err := stereoscopeFile.NewTarIndex(overflow.TarPath, func(entry stereoscopeFile.TarIndexEntry) error { + name := entry.ToTarFileEntry().Header.Name + r.entries[path.Clean("/"+name)] = entry + return nil + }); err != nil { + return nil, archive.IndexResult{}, err + } + return r, archive.IndexResult{Records: len(r.entries)}, nil +} + +func (r *tarTestResolver) sortedNames() []string { + names := make([]string, 0, len(r.entries)) + for name := range r.entries { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func (r *tarTestResolver) location(name string) file.Location { + return file.NewLocationFromCoordinates(file.Coordinates{RealPath: name, FileSystemID: r.fsid, ArchivePath: r.archivePath}) +} + +func (r *tarTestResolver) FilesByMIMEType(_ ...string) ([]file.Location, error) { + var locs []file.Location + for _, name := range r.sortedNames() { + switch strings.ToLower(filepath.Ext(name)) { + case ".zip", ".jar", ".war": + locs = append(locs, r.location(name)) + } + } + return locs, nil +} + +func (r *tarTestResolver) FilesByGlob(patterns ...string) ([]file.Location, error) { + var locs []file.Location + for _, name := range r.sortedNames() { + for _, pattern := range patterns { + if matched, err := doublestar.Match(pattern, strings.TrimPrefix(name, "/")); err == nil && matched { + locs = append(locs, r.location(name)) + break + } + } + } + return locs, nil +} + +func (r *tarTestResolver) FilesByPath(paths ...string) ([]file.Location, error) { + var locs []file.Location + for _, p := range paths { + name := path.Clean("/" + p) + if _, ok := r.entries[name]; ok { + locs = append(locs, r.location(name)) + } + } + return locs, nil +} + +func (r *tarTestResolver) FileContentsByLocation(loc file.Location) (io.ReadCloser, error) { + entry, ok := r.entries[loc.RealPath] + if !ok { + return nil, fmt.Errorf("no entry %q", loc.RealPath) + } + contents := entry.Open() + seekable, ok := contents.(randomAccessReadCloser) + if !ok { + return contents, nil + } + return testOverflowEntry{seekable}, nil +} + +type randomAccessReadCloser interface { + io.ReadCloser + io.ReaderAt + io.Seeker +} + +// testOverflowEntry carries the marker method that says "already random access on a file this scan +// wrote", which is how a nested archive gets read where it lies. +type testOverflowEntry struct { + randomAccessReadCloser +} + +func (testOverflowEntry) OverflowArchiveEntry() {} + +// makeZip builds a zip in sorted entry-name order. The order matters and must not be the map's: +// extraction limits are enforced as the walk proceeds, so which entries land before a truncation is +// a function of entry order, and a randomized order makes any limit assertion flaky. +func makeZip(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, name := range names { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write(entries[name]) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +// matchesAnyGlob reports whether the path matches one of the patterns, against the resolver-relative +// path with a leading slash, which is the form a real resolver globs over. No patterns means +// everything matches. +func matchesAnyGlob(root, path string, patterns []string) bool { + if len(patterns) == 0 { + return true + } + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + rel = "/" + filepath.ToSlash(rel) + for _, pattern := range patterns { + if ok, err := doublestar.Match(pattern, rel); err == nil && ok { + return true + } + } + return false +} + +// FilesByGlob for the resolvers that only ever stand in for a MIME lookup: the archive walk asks +// them for archive-named files too, and an unimplemented method on an embedded nil resolver panics. +func (a allFilesResolver) FilesByGlob(patterns ...string) ([]file.Location, error) { + return dirTestResolver{dir: a.dir}.FilesByGlob(patterns...) +} + +func (r twoLayerResolver) FilesByGlob(_ ...string) ([]file.Location, error) { + return nil, nil +} + +func Test_archiveCataloger_traversalThreading(t *testing.T) { + manifest := []byte("Manifest-Version: 1.0\nImplementation-Title: example\nImplementation-Version: 1.0.0\n") + innerJar := makeZip(t, map[string][]byte{"META-INF/MANIFEST.MF": manifest}) + innerZip := makeZip(t, map[string][]byte{"README.txt": []byte("hi")}) + outerZip := makeZip(t, map[string][]byte{ + "lib/inner.jar": innerJar, + "nested/inner.zip": innerZip, + }) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + var captured []*archive.Traversal + captureTask := NewTask("capture-traversal", func(ctx context.Context, _ file.Resolver, _ sbomsync.Builder) error { + captured = append(captured, archive.TraversalFromContext(ctx)) + return nil + }) + + cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3) + + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cfg, []Task{captureTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + err := tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)) + require.NoError(t, err) + + var chains []string + for _, trav := range captured { + require.NotNil(t, trav) + chains = append(chains, trav.VirtualPath) + } + // java archives are traversed like any other archive when the task is enabled + assert.ElementsMatch(t, []string{ + "/outer.zip", + "/outer.zip:lib/inner.jar", + "/outer.zip:nested/inner.zip", + }, chains) + + // verify parent linkage and depth + for _, trav := range captured { + if trav.Depth == 1 { + assert.Nil(t, trav.Parent) + continue + } + require.NotNil(t, trav.Parent) + assert.Equal(t, trav.Parent.Depth+1, trav.Depth) + assert.True(t, strings.HasPrefix(trav.VirtualPath, trav.Parent.VirtualPath+":")) + } +} + +func Test_archiveCataloger_truncationStillCatalogs(t *testing.T) { + // a limit is a truncation, not a failure: the sub-pipeline must still run over whatever was + // written, so packages found before the limit survive. Discarding the whole archive was the + // original behavior and it silently lost every package in an oversized archive. + big := bytes.Repeat([]byte("x"), 4096) + outerZip := makeZip(t, map[string][]byte{ + "a/small.txt": []byte("small"), + "b/big.txt": big, + }) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + // small.txt costs a header block and a padded data block; big.txt's header does not fit after + // them. The archive's own bytes are held in memory at the default limit, so what is charged to + // disk here is only the tar its entries are written into. + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(1). + WithMaxDiskBytes(1500) + + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + err := tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)) + + require.NoError(t, err, "a truncated extraction must not fail the scan") + assert.Equal(t, 1, ran, "the sub-pipeline must run over the partial contents") + + // and the SBOM says the archive was read in part, at the archive's own coordinates: a scan that + // finished cleanly but saw only some of an archive is not the same SBOM as one that saw all of + // it, and a debug log does not survive the run that wrote it + reasons, ok := s.Artifacts.Unknowns[file.Coordinates{RealPath: "/outer.zip"}] + require.True(t, ok, "the truncation must be recorded against the archive; got %v", s.Artifacts.Unknowns) + require.Len(t, reasons, 1) + assert.Contains(t, reasons[0], "archive cataloged from part of its contents") + assert.Contains(t, reasons[0], ArchiveCatalogerTaskName, "the unknown names the task that recorded it") +} + +func Test_archiveCataloger_failureIsRecordedAndSkipsSubPipeline(t *testing.T) { + // a genuine failure is not a truncation: nothing usable was produced, so the sub-pipeline must + // not run over an empty tree, and the failure must be visible in the SBOM rather than only in + // the logs + rootDir := t.TempDir() + // a zip whose entry data has been overwritten: its header still says zip and its + // end-of-central-directory record is intact, so it is detected as an archive and then fails when + // the entry is read. Prose named `.zip` would not do - that is not detected as an archive at all, + // which is a different requirement and is covered by + // Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError. + corrupt := makeZip(t, map[string][]byte{"data.bin": incompressible(4096)}) + require.Greater(t, len(corrupt), 1024) + for i := 100; i < 600; i++ { + corrupt[i] ^= 0xff + } + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "corrupt.zip"), corrupt, 0o600)) + + // a second, healthy archive proves the failure does not stop the walk + goodZip := makeZip(t, map[string][]byte{"ok.txt": []byte("ok")}) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "good.zip"), goodZip, 0o600)) + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1) + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + err := tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)) + + // the failure must be a CoordinateError, because that is what executor.go's + // unknown.ExtractCoordinateErrors pulls out and records in sbom.Artifacts.Unknowns. A plain + // error would propagate as a scan failure instead of being reported against the archive + require.Error(t, err) + coordErrs, remaining := unknown.ExtractCoordinateErrors(err) + assert.NoError(t, remaining, "nothing may escape as a non-coordinate error") + require.Len(t, coordErrs, 1) + assert.Equal(t, "/corrupt.zip", coordErrs[0].Coordinates.RealPath) + + // and the healthy archive was still cataloged: one bad archive must not stop the walk + assert.Equal(t, 1, ran, "the sub-pipeline must still run for the archive that extracted cleanly") +} + +func Test_NewArchiveCatalogerTask_gating(t *testing.T) { + newResolver := newTarTestResolver + someTask := NewTask("noop", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { return nil }) + + tests := []struct { + name string + cfg cataloging.ArchiveSearchConfig + subPipeline []Task + resolver archive.ResolverFactory + wantTask bool + }{ + { + name: "disabled at depth 0", + cfg: cataloging.DefaultArchiveSearchConfig(), + subPipeline: []Task{someTask}, + resolver: newResolver, + }, + { + name: "no sub-pipeline", + cfg: cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2), + subPipeline: nil, + resolver: newResolver, + }, + { + name: "no resolver factory", + cfg: cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2), + subPipeline: []Task{someTask}, + resolver: nil, + }, + { + name: "enabled", + cfg: cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2), + subPipeline: []Task{someTask}, + resolver: newResolver, + wantTask: true, + }, + { + name: "enabled at negative depth", + cfg: cataloging.DefaultArchiveSearchConfig().WithMaxDepth(-1), + subPipeline: []Task{someTask}, + resolver: newResolver, + wantTask: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewArchiveCatalogerTask(tt.cfg, tt.subPipeline, tt.resolver, nil, nil) + if tt.wantTask { + require.NotNil(t, got) + assert.Equal(t, ArchiveCatalogerTaskName, got.Name()) + return + } + assert.Nil(t, got) + }) + } +} + +func Test_archiveCataloger_depthBoundIsExact(t *testing.T) { + // an archive in the scan source is depth 1, so MaxDepth 1 must catalog it and not descend + innerZip := makeZip(t, map[string][]byte{"deep.txt": []byte("deep")}) + outerZip := makeZip(t, map[string][]byte{"nested/inner.zip": innerZip}) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + newResolver := newTarTestResolver + + run := func(t *testing.T, depth int) []string { + t.Helper() + var seen []string + captureTask := NewTask("capture", func(ctx context.Context, _ file.Resolver, _ sbomsync.Builder) error { + trav := archive.TraversalFromContext(ctx) + require.NotNil(t, trav) + seen = append(seen, trav.VirtualPath) + return nil + }) + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth), []Task{captureTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)), + "reaching the depth bound must not be an error") + return seen + } + + assert.Equal(t, []string{"/outer.zip"}, run(t, 1), "depth 1 catalogs the top-level archive and does not descend") + assert.ElementsMatch(t, []string{"/outer.zip", "/outer.zip:nested/inner.zip"}, run(t, 2), "depth 2 descends one level") +} + +func Test_NewArchiveCatalogerTask_dropsItselfFromSubPipeline(t *testing.T) { + // the task drives recursion with its own depth-bounded walk, so a copy of itself in the + // sub-pipeline would process every nesting level twice. The depth bound still terminates the + // recursion, so the symptom is duplicated packages rather than a hang - which is exactly the + // kind of failure nobody notices in a large SBOM. + newResolver := newTarTestResolver + cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2) + + innerZip := makeZip(t, map[string][]byte{"leaf.txt": []byte("leaf")}) + outerZip := makeZip(t, map[string][]byte{"nested/inner.zip": innerZip}) + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + // a sub-pipeline that (incorrectly) carries an archive cataloger task alongside real work + poisoned := []Task{countTask, NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil)} + require.NotNil(t, poisoned[1]) + + tsk := NewArchiveCatalogerTask(cfg, poisoned, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s))) + + // two archives, one sub-pipeline task each: the nested archive task was dropped, so no level is + // processed twice + assert.Equal(t, 2, ran) + + t.Run("a sub-pipeline of nothing but itself yields no task", func(t *testing.T) { + only := []Task{NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil)} + assert.Nil(t, NewArchiveCatalogerTask(cfg, only, newResolver, nil, nil)) + }) +} + +// FilesByGlob lists the files under the resolver's directory that match, stamped with its +// filesystem id, so a sub-pipeline task can record file artifacts the way a real file cataloger +// does. With no patterns it lists everything, which is what the sub-pipeline tasks here want. +func (d dirTestResolver) FilesByGlob(patterns ...string) ([]file.Location, error) { + var locs []file.Location + err := filepath.WalkDir(d.dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + if !matchesAnyGlob(d.dir, path, patterns) { + return nil + } + rel, err := filepath.Rel(d.dir, path) + if err != nil { + return err + } + coords := file.Coordinates{RealPath: "/" + filepath.ToSlash(rel), FileSystemID: d.fsid} + locs = append(locs, file.NewLocationFromCoordinates(coords)) + return nil + }) + return locs, err +} + +func Test_archiveCataloger_chainStartsAtTheArchivesOwnFileSystemID(t *testing.T) { + // covers syft/archive-content-identity#nested-filesystem-id-chain, scenario "image source chain + // extends the layer digest": an image-source archive's extracted files keep the layer digest as + // their FileSystemID (inherited unchanged down the nesting chain), while the archive nesting chain + // is carried separately as the traversal's VirtualPath / the coordinate ArchivePath + innerZip := makeZip(t, map[string][]byte{"leaf.txt": []byte("leaf")}) + outerZip := makeZip(t, map[string][]byte{"nested/inner.zip": innerZip}) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + const layerDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + + var chains []string + var fsids []string + captureTask := NewTask("capture-fsid", func(ctx context.Context, _ file.Resolver, _ sbomsync.Builder) error { + trav := archive.TraversalFromContext(ctx) + require.NotNil(t, trav) + chains = append(chains, trav.VirtualPath) + fsids = append(fsids, trav.FileSystemID) + return nil + }) + + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2), []Task{captureTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir, fsid: layerDigest}, sbomsync.NewBuilder(s))) + + // the layer digest is preserved unchanged at every level as the filesystem the archive lives on + assert.Equal(t, []string{layerDigest, layerDigest}, fsids) + + // exact strings: the archive path is serialized into the SBOM, so a changed shape is a changed + // output format, not an internal detail + assert.ElementsMatch(t, []string{ + "/outer.zip", + "/outer.zip:nested/inner.zip", + }, chains) +} + +// twoLayerResolver reports the same archive path twice under two different filesystem ids, each +// holding different content — what an all-layers image scan yields for an archive rewritten in a +// later layer. FilesByMIMEType dedupes on the file reference rather than the path, so both survive. +type twoLayerResolver struct { + file.Resolver + path string + contentBy map[string][]byte +} + +func (r twoLayerResolver) FilesByMIMEType(_ ...string) ([]file.Location, error) { + var locs []file.Location + for _, fsid := range []string{"layer-one", "layer-two"} { + locs = append(locs, file.NewLocationFromCoordinates(file.Coordinates{RealPath: r.path, FileSystemID: fsid})) + } + return locs, nil +} + +func (r twoLayerResolver) FileContentsByLocation(loc file.Location) (io.ReadCloser, error) { + body, ok := r.contentBy[loc.FileSystemID] + if !ok { + return nil, os.ErrNotExist + } + return io.NopCloser(bytes.NewReader(body)), nil +} + +func Test_archiveCataloger_sameArchivePathInTwoLayersDoesNotCollide(t *testing.T) { + // the collision the chain exists to prevent: two layers holding /app/bundle.zip at the same path + // composed the same identifier while the chain was seeded from the parent traversal, so the two + // extractions' contents overwrote each other in the coordinate-keyed file tables + resolver := twoLayerResolver{ + path: "/app/bundle.zip", + contentBy: map[string][]byte{ + "layer-one": makeZip(t, map[string][]byte{"lib/config.json": []byte(`{"v":1}`)}), + "layer-two": makeZip(t, map[string][]byte{"lib/config.json": []byte(`{"v":2}`)}), + }, + } + + var chains []string + // record a file artifact per location the way a file cataloger does, so the merge into the shared + // SBOM exercises the coordinate-keyed tables rather than only the traversal + fileTask := NewTask("record-files", func(ctx context.Context, r file.Resolver, builder sbomsync.Builder) error { + trav := archive.TraversalFromContext(ctx) + require.NotNil(t, trav) + chains = append(chains, trav.FileSystemID) + + locs, err := r.FilesByGlob("**") + require.NoError(t, err) + accessor := builder.(sbomsync.Accessor) + accessor.WriteToSBOM(func(s *sbom.SBOM) { + for _, loc := range locs { + s.Artifacts.FileMetadata[loc.Coordinates] = file.Metadata{Path: loc.RealPath} + } + }) + return nil + }) + + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), []Task{fileTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{ + Packages: pkg.NewCollection(), + FileMetadata: map[file.Coordinates]file.Metadata{}, + }} + require.NoError(t, tsk.Execute(context.Background(), resolver, sbomsync.NewBuilder(s))) + + assert.ElementsMatch(t, []string{"layer-one", "layer-two"}, chains, + "the two archives keep the distinct filesystem ids of the layers they were found in") + + // two file-table entries, asserted as a count: the tables are keyed by Coordinates, so a + // collision is a silently missing entry rather than any kind of error. The same archive path in + // two layers stays distinct because the coordinates differ by FileSystemID (the layer), not by + // ArchivePath (which is identical here) + assert.Len(t, s.Artifacts.FileMetadata, 2) +} + +func Test_archiveCataloger_subPipelineFailureIsRecordedAsAnUnknown(t *testing.T) { + // syft/nested-archive-cataloging#archive-failure-is-not-fatal wants a catalog failure inside an + // archive "visible in output rather than only in logs". runSubPipeline bypasses RunTask, which is + // where the coordinate-error to unknown conversion lives, so the error used to be trace-logged + // and dropped. + outerZip := makeZip(t, map[string][]byte{"lib/broken.json": []byte("{")}) + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + newResolver := newTarTestResolver + cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1) + + t.Run("a coordinate error keeps the coordinates the cataloger named", func(t *testing.T) { + // the cataloger attributed its failure to a file it was reading, which is the normal case and + // is more precise than the archive: it must not be flattened onto the archive's coordinates + failing := NewTask("failing-cataloger", func(_ context.Context, r file.Resolver, _ sbomsync.Builder) error { + locs, err := r.FilesByGlob("**") + require.NoError(t, err) + require.NotEmpty(t, locs) + var errs error + for _, loc := range locs { + errs = unknown.Append(errs, loc, errors.New("unable to parse")) + } + return errs + }) + + tsk := NewArchiveCatalogerTask(cfg, []Task{failing}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)), + "a cataloger failing inside an archive must not fail the archive or the scan") + + require.Len(t, s.Artifacts.Unknowns, 1) + for coords, reasons := range s.Artifacts.Unknowns { + assert.Equal(t, "/lib/broken.json", coords.RealPath, "the inner coordinates must survive the merge") + assert.Equal(t, "/outer.zip", coords.ArchivePath, "and must be addressable per archive") + assert.Equal(t, []string{"failing-cataloger: unable to parse"}, reasons) + } + }) + + t.Run("an error with no coordinates is attributed to the archive", func(t *testing.T) { + failing := NewTask("bare-error-cataloger", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + return errors.New("cataloger blew up") + }) + + tsk := NewArchiveCatalogerTask(cfg, []Task{failing}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s))) + + require.Len(t, s.Artifacts.Unknowns, 1) + reasons, ok := s.Artifacts.Unknowns[file.Coordinates{RealPath: "/outer.zip"}] + require.True(t, ok, "an error with no location of its own belongs to the containing archive") + assert.Equal(t, []string{"bare-error-cataloger: cataloger blew up"}, reasons) + }) + + t.Run("a panicking cataloger is recovered and recorded", func(t *testing.T) { + // runTaskSafely converts the panic to an error, which now has somewhere to go + panicking := NewTask("panicking-cataloger", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + panic("nope") + }) + + tsk := NewArchiveCatalogerTask(cfg, []Task{panicking}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)), + "a panic inside an archive must not fail the scan") + + reasons, ok := s.Artifacts.Unknowns[file.Coordinates{RealPath: "/outer.zip"}] + require.True(t, ok) + require.Len(t, reasons, 1) + assert.Contains(t, reasons[0], "panicking-cataloger: nope") + }) +} + +func Test_archiveCataloger_subPipelineUnknownsAreAddedNotReplaced(t *testing.T) { + // mergeFileArtifacts appends to the shared SBOM's unknowns, so the ones a sub-cataloger wrote + // into the scratch SBOM itself must survive alongside the one the failure produced + outerZip := makeZip(t, map[string][]byte{"lib/thing.json": []byte("{}")}) + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + preexisting := file.Coordinates{RealPath: "/lib/thing.json", ArchivePath: "outer.zip"} + writer := NewTask("scratch-writer", func(_ context.Context, _ file.Resolver, builder sbomsync.Builder) error { + builder.(sbomsync.Accessor).WriteToSBOM(func(s *sbom.SBOM) { + s.Artifacts.Unknowns[preexisting] = append(s.Artifacts.Unknowns[preexisting], "recorded by the cataloger itself") + }) + return nil + }) + failing := NewTask("failing-cataloger", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + return errors.New("boom") + }) + + newResolver := newTarTestResolver + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), []Task{writer, failing}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s))) + + assert.Equal(t, []string{"recorded by the cataloger itself"}, s.Artifacts.Unknowns[preexisting]) + assert.Equal(t, []string{"failing-cataloger: boom"}, s.Artifacts.Unknowns[file.Coordinates{RealPath: "/outer.zip"}]) +} + +func Test_archiveCataloger_manySmallArchivesAreEachCataloged(t *testing.T) { + // the case the monotonic counter wrongly rejected: every archive is released before the next + // begins, so a scan whose archives sum well past the disk limit never holds more than one of + // them at a time and every one is cataloged + entry := bytes.Repeat([]byte("z"), 400) + entries := map[string][]byte{} + for i := range 5 { + entries[fmt.Sprintf("e%d.bin", i)] = entry + } + + rootDir := t.TempDir() + names := []string{"one.zip", "two.zip", "three.zip", "four.zip", "five.zip"} + for _, name := range names { + require.NoError(t, os.WriteFile(filepath.Join(rootDir, name), makeZip(t, entries), 0o600)) + } + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + // one archive extracts 2000 bytes, so the limit holds one comfortably and the five together + // would be four times over a counter that never fell + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(1). + WithMaxDiskBytes(4000) + + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s))) + + assert.Equal(t, len(names), ran, "every archive must be cataloged: no two were ever held at once") +} + +func Test_archiveCataloger_limitsFallWhenAnArchiveIsReleased(t *testing.T) { + // a second archive the same size as the first is cataloged in full, which a counter would have + // refused, and both limits are back at zero once the walk unwinds + entry := bytes.Repeat([]byte("z"), 400) + entries := map[string][]byte{} + for i := range 5 { + entries[fmt.Sprintf("e%d.bin", i)] = entry + } + + rootDir := t.TempDir() + for _, name := range []string{"one.zip", "two.zip"} { + require.NoError(t, os.WriteFile(filepath.Join(rootDir, name), makeZip(t, entries), 0o600)) + } + + // each entry costs a header block plus a padded data block, and the tar carries an + // end-of-archive marker: five entries of 400 bytes is 11 blocks. Room for one archive's tar and + // not for two at once. + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(1). + WithMaxDiskBytes(15 * 512) + + limiter := archive.NewLimiter(archive.DefaultLimits(cfg)) + + // measured while the archive is still held: the resolver is built straight after extraction and + // before anything is cleaned up + var extracted []int + var peakMemory int64 + newResolver := func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + resolver, indexed, err := newTarTestResolver(overflow) + extracted = append(extracted, indexed.Records) + if mem, _ := limiter.InUse(); mem > peakMemory { + peakMemory = mem + } + return resolver, indexed, err + } + + c := &archiveCataloger{ + cfg: cfg, + subPipeline: []Task{NewTask("noop", func(context.Context, file.Resolver, sbomsync.Builder) error { return nil })}, + extractors: archive.DefaultExtractors(), + limits: archive.DefaultExtractionLimits(cfg), + limiter: limiter, + newResolver: newResolver, + } + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, c.catalog(context.Background(), dirTestResolver{dir: rootDir}, nil, 0, sbomsync.NewBuilder(s))) + + assert.Equal(t, []int{5, 5}, extracted, + "both archives must extract in full: the first is released before the second is admitted") + assert.Positive(t, peakMemory, + "an archive within the memory limit is held in memory, so memory in use must rise") + + mem, disk := limiter.InUse() + assert.Zero(t, mem, "the memory limit must fall back to nothing once the walk unwinds") + assert.Zero(t, disk, "and so must the disk limit") +} + +func Test_archiveCataloger_archiveExceedingTheDiskLimitIsSkipped(t *testing.T) { + // the disk limit is terminal, so an archive whose own content will not fit is skipped rather + // than waited for, and a sibling that does fit is cataloged in full + oversized := makeZip(t, map[string][]byte{"payload.bin": incompressible(8000)}) + small := makeZip(t, map[string][]byte{"ok.txt": []byte("ok")}) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "oversized.zip"), oversized, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "small.zip"), small, 0o600)) + + // room for the small archive's own bytes and the tar its one entry is written into - a header + // block, a padded data block and an end-of-archive marker - and nowhere near the oversized one + diskLimit := int64(len(small)) + 8*512 + require.Greater(t, int64(len(oversized)), diskLimit, "the fixture must actually exceed the limit") + + var sawArchivePaths []string + newResolver := func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + sawArchivePaths = append(sawArchivePaths, overflow.ArchivePath) + return newTarTestResolver(overflow) + } + + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(1). + WithMaxMemoryBytes(0). // every archive overflows, so its own bytes are charged to disk + WithMaxDiskBytes(diskLimit) + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + tsk := NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)), + "reaching a limit must not fail the scan") + + assert.Equal(t, []string{"/small.zip"}, sawArchivePaths, + "the oversized archive is skipped and the sibling is still cataloged") + assert.Equal(t, 1, ran) +} + +func Test_archiveCataloger_zeroDiskLimitAdmitsNoArchiveContent(t *testing.T) { + // This case used to say "a zero disk limit skips what will not fit in memory": an archive's own + // bytes were held in memory while the memory limit admitted them, and only what did not fit had + // nowhere to go. It now says something stronger, because an archive's ENTRIES live in a file + // whatever happened to the archive's own bytes: with no disk to write it, that file cannot be + // created, so no archive's content can be admitted and every archive is skipped - the memory limit + // notwithstanding. + // + // The scan still succeeds, and every skip is attributed to the archive that could not be placed, + // which is the part that has not changed. + oversized := makeZip(t, map[string][]byte{"payload.bin": incompressible(8000)}) + small := makeZip(t, map[string][]byte{"ok.txt": []byte("ok")}) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "oversized.zip"), oversized, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "small.zip"), small, 0o600)) + + // generous: even the small archive, which fits in memory with room to spare, has nowhere to put + // its entries + memoryLimit := int64(len(oversized) + len(small) + 1000) + + var sawArchivePaths []string + newResolver := func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + sawArchivePaths = append(sawArchivePaths, overflow.ArchivePath) + return newTarTestResolver(overflow) + } + + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(1). + WithMaxMemoryBytes(memoryLimit). + WithMaxDiskBytes(0) // nowhere to write the file an archive's entries live in + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + tsk := NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)), + "reaching the disk limit must not fail the scan") + + assert.Empty(t, sawArchivePaths, + "with no disk there is nowhere for an archive's entries to go, so no archive is descended into") + assert.Zero(t, ran) +} + +func Test_archiveCataloger_bothLimitsZeroSkipsEveryArchiveAndSucceeds(t *testing.T) { + // the degenerate configuration where nothing is admitted anywhere: every archive is skipped, and + // that is a well-defined success rather than a failure + entries := map[string][]byte{"ok.txt": []byte("ok")} + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "one.zip"), makeZip(t, entries), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "two.zip"), makeZip(t, entries), 0o600)) + + var sawArchivePaths []string + newResolver := func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + sawArchivePaths = append(sawArchivePaths, overflow.ArchivePath) + return newTarTestResolver(overflow) + } + + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(1). + WithMaxMemoryBytes(0). + WithMaxDiskBytes(0) + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + tsk := NewArchiveCatalogerTask(cfg, []Task{countTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)), + "both limits at zero is a real configuration, not a failure") + + assert.Empty(t, sawArchivePaths, "no archive content can be admitted anywhere, so every archive is skipped") + assert.Zero(t, ran, "the sub-pipeline never runs since nothing was extracted") +} + +func Test_archiveCataloger_nestedArchiveExceedingALimitIsSkippedNotBlocked(t *testing.T) { + // the walk descends before it unwinds, so a parent is still holding its content while its + // children are cataloged and nothing will be released while a child waits. Waiting for capacity + // would be a deadlock, so a child that does not fit is skipped and the scan finishes. + inner := makeZip(t, map[string][]byte{"payload.bin": incompressible(3000)}) + outer := makeZip(t, map[string][]byte{"inner.zip": inner}) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outer, 0o600)) + + // enough for the outer archive's own bytes plus the inner.zip it extracts, and not enough for + // the inner archive's bytes on top of them + diskLimit := int64(len(outer)+len(inner)) + 500 + require.Greater(t, int64(len(inner)), int64(500), "the inner archive must not fit in the slack") + + var sawArchivePaths []string + newResolver := func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + sawArchivePaths = append(sawArchivePaths, overflow.ArchivePath) + return newTarTestResolver(overflow) + } + + cfg := cataloging.DefaultArchiveSearchConfig(). + WithMaxDepth(2). + WithMaxMemoryBytes(0). // every archive overflows, so its own bytes are charged to disk + WithMaxDiskBytes(diskLimit) + + noop := NewTask("noop", func(context.Context, file.Resolver, sbomsync.Builder) error { return nil }) + tsk := NewArchiveCatalogerTask(cfg, []Task{noop}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s))) + + assert.Equal(t, []string{"/outer.zip"}, sawArchivePaths, + "the parent is cataloged and the child it cannot afford is skipped rather than blocking") +} + +// incompressible returns n bytes deflate cannot shrink, so an archive built from them has a size on +// disk predictable enough to size a limit against. +func incompressible(n int) []byte { + b := make([]byte, n) + x := uint32(12345) + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } + return b +} + +// makeTarGz builds a gzipped tar in memory, in sorted entry order for the same reason makeZip does. +func makeTarGz(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + require.NoError(t, writeTar(t, gw, entries)) + require.NoError(t, gw.Close()) + return buf.Bytes() +} + +// makeTar builds an uncompressed tar in memory. +func makeTar(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, writeTar(t, &buf, entries)) + return buf.Bytes() +} + +func writeTar(t *testing.T, w io.Writer, entries map[string][]byte) error { + t.Helper() + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + + tw := tar.NewWriter(w) + for _, name := range names { + body := entries[name] + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}); err != nil { + return err + } + if _, err := tw.Write(body); err != nil { + return err + } + } + return tw.Close() +} + +// allFilesResolver is dirTestResolver without a filesystem id. Both sniff content the way a real +// resolver does, so which files reach the task is decided by what they are rather than by what the +// test wishes they were. +type allFilesResolver struct { + file.Resolver + dir string +} + +func (a allFilesResolver) FilesByMIMEType(types ...string) ([]file.Location, error) { + return filesByMIMEType(a.dir, "", types...) +} + +func (a allFilesResolver) FileContentsByLocation(loc file.Location) (io.ReadCloser, error) { + return os.Open(filepath.Join(a.dir, filepath.FromSlash(loc.RealPath))) +} + +func Test_archiveCataloger_discoverArchivesSelectsTarFamily(t *testing.T) { + // every other task-level fixture is a zip, so the tar branch of FindExtractor had never been + // reached through the task's own discovery + marker := map[string][]byte{"nested/marker.txt": []byte("hello")} + + scanDir := t.TempDir() + for name, body := range map[string][]byte{ + "bundle.tar.gz": makeTarGz(t, marker), + "bundle.tgz": makeTarGz(t, marker), + "bundle.tar": makeTar(t, marker), + "bundle.zip": makeZip(t, marker), + "notes.txt": []byte("not an archive at all"), + } { + require.NoError(t, os.WriteFile(filepath.Join(scanDir, name), body, 0o600)) + } + + var seen []string + captureTask := NewTask("capture", func(ctx context.Context, r file.Resolver, _ sbomsync.Builder) error { + trav := archive.TraversalFromContext(ctx) + require.NotNil(t, trav) + + // the contents are actually there, so the extraction really happened rather than the + // traversal being built over an empty tree + locs, err := r.FilesByGlob("**") + require.NoError(t, err) + assert.NotEmpty(t, locs, "expected contents inside %s", trav.VirtualPath) + + seen = append(seen, trav.VirtualPath) + return nil + }) + + newResolver := newTarTestResolver + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), []Task{captureTask}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), allFilesResolver{dir: scanDir}, sbomsync.NewBuilder(s))) + + assert.ElementsMatch(t, []string{"/bundle.tar.gz", "/bundle.tgz", "/bundle.tar", "/bundle.zip"}, seen, + "every tar-family archive must be reached, and the plain text file must not be") + + t.Run("the MIME types the tar family is sniffed as are in the set discovery asks for", func(t *testing.T) { + // discoverArchives passes mimetype.ArchiveMIMETypeSet to FilesByMIMEType, so a tar type + // missing from it makes the whole tar path unreachable no matter what the extractor can do + for _, mt := range []string{"application/x-tar", "application/gzip", "application/x-gtar", "application/zip"} { + assert.True(t, mimetype.ArchiveMIMETypeSet.Has(mt), "%s must be a candidate archive type", mt) + } + }) +} + +// recordingPublisher captures every event published to the bus during a test. +type recordingPublisher struct { + events []partybus.Event +} + +func (p *recordingPublisher) Publish(e partybus.Event) { + p.events = append(p.events, e) +} + +func Test_archiveCatalogerTask_publishesOneProgressRowForTheWholeWalk(t *testing.T) { + // the sub-pipeline re-runs catalogers that start a progress row of their own, keyed by cataloger + // name. Publishing those again per archive floods a consumer's UI with rows, and a consumer that + // keys rows by ID (as the syft CLI does) replaces the live row for that ID, never renders the + // replaced one again, and so never observes its completion - which hangs UI teardown. The whole + // walk therefore reports through the single row this task starts for itself. + inner := makeZip(t, map[string][]byte{"inner/file.txt": []byte("hello")}) + scanDir := t.TempDir() + for _, name := range []string{"one.zip", "two.zip"} { + require.NoError(t, os.WriteFile(filepath.Join(scanDir, name), inner, 0o600)) + } + + publisher := &recordingPublisher{} + bus.Set(publisher) + t.Cleanup(func() { bus.Set(nil) }) + + // a task that starts a progress row the same way every package cataloger does + var reported []string + noisy := NewTask("noisy", func(ctx context.Context, _ file.Resolver, _ sbomsync.Builder) error { + p := bus.StartCatalogerTask(ctx, monitor.GenericTask{ID: "noisy"}, -1, "") + p.AtomicStage.Set("working on something") + + // what the nested cataloger is doing reaches the row that is already on screen + row := publisher.events[0].Value.(progress.StagedProgressable) + reported = append(reported, row.Stage()) + + // and the nested task finishing does not finish the row it reports into + p.SetCompleted() + assert.NotErrorIs(t, row.Error(), progress.ErrCompleted, + "a sub-task completing must not complete the row it reports into") + return nil + }) + + // the row's stage as each archive is about to be extracted: by then the previous archive's + // sub-pipeline has finished, so the row must read the walk's own count rather than whatever the + // last nested cataloger signed off with + var stageAtExtraction []string + newResolver := func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + stageAtExtraction = append(stageAtExtraction, publisher.events[0].Value.(progress.StagedProgressable).Stage()) + return newTarTestResolver(overflow) + } + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), []Task{noisy}, newResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: scanDir}, sbomsync.NewBuilder(s))) + + var started []string + for _, e := range publisher.events { + if e.Type != event.CatalogerTaskStarted { + continue + } + info, ok := e.Source.(monitor.GenericTask) + require.True(t, ok, "expected a generic task as the event source") + started = append(started, info.ID) + } + + assert.Equal(t, []string{ArchiveCatalogerTaskName}, started, + "only the archive cataloger's own row may be published, however many archives the walk enters") + + require.Len(t, publisher.events, 1) + prog, ok := publisher.events[0].Value.(progress.StagedProgressable) + require.True(t, ok) + assert.ErrorIs(t, prog.Error(), progress.ErrCompleted, "the row must be completed, or a waiting consumer never finishes") + assert.Equal(t, int64(2), prog.Current(), "the row counts every archive the walk entered") + assert.Equal(t, "2 archives", prog.Stage(), "and says so once the walk is done") + + assert.Equal(t, []string{"working on something", "working on something"}, reported, + "every nested run must report through the existing row") + + assert.Equal(t, []string{"", "1 archives"}, stageAtExtraction, + "the row must take its stage back from the sub-pipeline once an archive is done") +} + +func Test_archiveCataloger_reportsPeakUsageAndTheSlowestArchive(t *testing.T) { + // the slowest archive is the one that spent the time itself. Inclusive time would name the outer + // archive on every scan, since it contains everything below it, so the outer's own sub-pipeline + // is made fast here and the inner's slow: the report must name the inner. + innerJar := makeZip(t, map[string][]byte{"README.txt": []byte("inner")}) + outerZip := makeZip(t, map[string][]byte{"lib/inner.jar": innerJar}) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), outerZip, 0o600)) + + const innerWork = 40 * time.Millisecond + slow := NewTask("slow-inside-the-inner-archive", func(ctx context.Context, _ file.Resolver, _ sbomsync.Builder) error { + if trav := archive.TraversalFromContext(ctx); trav != nil && strings.HasSuffix(trav.VirtualPath, "inner.jar") { + time.Sleep(innerWork) + } + return nil + }) + + cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3) + limiter := archive.NewLimiter(archive.DefaultLimits(cfg)) + c := &archiveCataloger{ + cfg: cfg, + subPipeline: []Task{slow}, + extractors: archive.DefaultExtractors(), + limits: archive.DefaultExtractionLimits(cfg), + limiter: limiter, + newResolver: newTarTestResolver, + } + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, c.catalog(context.Background(), dirTestResolver{dir: rootDir}, nil, 0, sbomsync.NewBuilder(s))) + + assert.Equal(t, "/outer.zip:lib/inner.jar", c.slowestPath, + "the slowest archive is named by its full chain, not by its base name, and self time must exclude the archives nested inside") + assert.GreaterOrEqual(t, c.slowest, innerWork) + assert.Equal(t, int64(2), c.archivesSeen) + + // the peaks outlive the walk that set them, so they are still readable once everything is released + peakMemory, _ := limiter.Peak() + assert.Positive(t, peakMemory, "content held in memory must show on the peak") + memory, disk := limiter.InUse() + assert.Zero(t, memory) + assert.Zero(t, disk) +} + +func Test_archiveCataloger_slowestIsUnsetWhenNoArchiveIsCataloged(t *testing.T) { + // a directory with nothing extractable in it: there is no archive to name, and the report must + // not invent one + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "notes.txt"), []byte("not an archive"), 0o600)) + + cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2) + c := &archiveCataloger{ + cfg: cfg, + subPipeline: []Task{NewTask("noop", func(context.Context, file.Resolver, sbomsync.Builder) error { return nil })}, + extractors: archive.DefaultExtractors(), + limits: archive.DefaultExtractionLimits(cfg), + limiter: archive.NewLimiter(archive.DefaultLimits(cfg)), + newResolver: newTarTestResolver, + } + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, c.catalog(context.Background(), dirTestResolver{dir: rootDir}, nil, 0, sbomsync.NewBuilder(s))) + + assert.Empty(t, c.slowestPath) + assert.Zero(t, c.archivesSeen) + c.logStats() // must not panic with nothing to report +} + +func Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError(t *testing.T) { + // `syft/nested-archive-cataloging#content-based-archive-detection`, scenario "misnamed + // non-archive is not extracted": the name says archive, the content does not, and the scan says + // nothing about it. It never becomes a candidate, because its content types it as text. + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "notes.zip"), + []byte("this is prose, not an archive, whatever the extension claims\n"), 0o600)) + + var ran int + countTask := NewTask("count-runs", func(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error { + ran++ + return nil + }) + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(1), + []Task{countTask}, newTarTestResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + err := tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)) + + assert.NoError(t, err, "a file that is not an archive is not a failure to report") + assert.Zero(t, ran, "and nothing is cataloged inside it") +} + +func Test_archiveCataloger_catalogsAZipBehindALauncherScript(t *testing.T) { + // end to end for the Spring Boot executable jar shape: a launcher script with a zip concatenated + // onto it. Content sniffing types it text/x-shellscript, so it reaches the walk only through the + // appended-archive candidate set, and it is an archive only because it ends in an + // end-of-central-directory record. + inner := makeZip(t, map[string][]byte{"nested/marker.txt": []byte("found me")}) + prefixed := append([]byte("#!/bin/bash\nexec java -jar \"$0\" \"$@\"\nexit 0\n"), + makeZip(t, map[string][]byte{"BOOT-INF/lib/inner.jar": inner})...) + + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "app.jar"), prefixed, 0o600)) + + var seen []string + capture := NewTask("capture", func(ctx context.Context, _ file.Resolver, _ sbomsync.Builder) error { + if trav := archive.TraversalFromContext(ctx); trav != nil { + seen = append(seen, trav.VirtualPath) + } + return nil + }) + + tsk := NewArchiveCatalogerTask(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(3), + []Task{capture}, newTarTestResolver, nil, nil) + require.NotNil(t, tsk) + + s := &sbom.SBOM{Artifacts: sbom.Artifacts{Packages: pkg.NewCollection()}} + require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s))) + + assert.Equal(t, []string{"/app.jar", "/app.jar:BOOT-INF/lib/inner.jar"}, seen, + "the stub must not hide the archive, and the walk must carry on into what it holds") +} diff --git a/internal/task/package_task_factory.go b/internal/task/package_task_factory.go index 0e83bf2ca..a3995ce6a 100644 --- a/internal/task/package_task_factory.go +++ b/internal/task/package_task_factory.go @@ -48,7 +48,7 @@ func NewPackageTask(cfg CatalogingFactoryConfig, c pkg.Cataloger, tags ...string HideOnSuccess: true, } - t := bus.StartCatalogerTask(info, -1, "") + t := bus.StartCatalogerTask(ctx, info, -1, "") pkgs, relationships, err := c.Catalog(ctx, resolver) diff --git a/internal/task/package_tasks.go b/internal/task/package_tasks.go index 3347c50dd..bb368e8f2 100644 --- a/internal/task/package_tasks.go +++ b/internal/task/package_tasks.go @@ -140,6 +140,13 @@ func DefaultPackageTaskFactories() Factories { ), newPackageTaskFactory( func(cfg CatalogingFactoryConfig) pkg.Cataloger { + // once the archive cataloger task owns extraction, java archives arrive as extracted + // filesystems rather than as files, and the cataloger that reads them is a different + // one. The archive-reading cataloger is what runs when nothing else extracts, which is + // the default; it is deprecated in the sense that it has no role in the enabled path. + if cfg.PackagesConfig.JavaArchive.NestedArchivesHandledExternally { + return java.NewArchiveContentsCataloger(cfg.PackagesConfig.JavaArchive) + } return java.NewArchiveCataloger(cfg.PackagesConfig.JavaArchive) }, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, pkgcataloging.LanguageTag, Java, Maven, diff --git a/schema/json/schema-16.1.10.json b/schema/json/schema-16.1.10.json new file mode 100644 index 000000000..0f30dfac9 --- /dev/null +++ b/schema/json/schema-16.1.10.json @@ -0,0 +1,4575 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "anchore.io/schema/syft/json/16.1.10/document", + "$ref": "#/$defs/Document", + "$defs": { + "AlpmDbEntry": { + "properties": { + "basepackage": { + "type": "string", + "description": "BasePackage is the base package name this package was built from (source package in Arch build system)" + }, + "package": { + "type": "string", + "description": "Package is the package name as found in the desc file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the desc file" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture as defined in Arch architecture spec (e.g. x86_64, aarch64, or \"any\" for arch-independent packages)" + }, + "size": { + "type": "integer", + "description": "Size is the installed size in bytes" + }, + "packager": { + "type": "string", + "description": "Packager is the name and email of the person who packaged this (RFC822 format)" + }, + "url": { + "type": "string", + "description": "URL is the upstream project URL" + }, + "validation": { + "type": "string", + "description": "Validation is the validation method used for package integrity (e.g. pgp signature, sha256 checksum)" + }, + "reason": { + "type": "integer", + "description": "Reason is the installation reason tracked by pacman (0=explicitly installed by user, 1=installed as dependency)" + }, + "files": { + "items": { + "$ref": "#/$defs/AlpmFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + }, + "backup": { + "items": { + "$ref": "#/$defs/AlpmFileRecord" + }, + "type": "array", + "description": "Backup is the list of configuration files that pacman backs up before upgrades" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are virtual packages provided by this package (allows other packages to depend on capabilities rather than specific packages)" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the runtime dependencies required by this package" + } + }, + "type": "object", + "required": [ + "basepackage", + "package", + "version", + "description", + "architecture", + "size", + "packager", + "url", + "validation", + "reason", + "files", + "backup" + ], + "description": "AlpmDBEntry is a struct that represents the package data stored in the pacman flat-file stores for arch linux." + }, + "AlpmFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "type": { + "type": "string", + "description": "Type is the file type (e.g. regular file, directory, symlink)" + }, + "uid": { + "type": "string", + "description": "UID is the file owner user ID as recorded by pacman" + }, + "gid": { + "type": "string", + "description": "GID is the file owner group ID as recorded by pacman" + }, + "time": { + "type": "string", + "format": "date-time", + "description": "Time is the file modification timestamp" + }, + "size": { + "type": "string", + "description": "Size is the file size in bytes" + }, + "link": { + "type": "string", + "description": "Link is the symlink target path if this is a symlink" + }, + "digest": { + "items": { + "$ref": "#/$defs/Digest" + }, + "type": "array", + "description": "Digests contains file content hashes for integrity verification" + } + }, + "type": "object", + "description": "AlpmFileRecord represents a single file entry within an Arch Linux package with its associated metadata tracked by pacman." + }, + "ApkDbEntry": { + "properties": { + "package": { + "type": "string", + "description": "Package is the package name as found in the installed file" + }, + "originPackage": { + "type": "string", + "description": "OriginPackage is the original source package name this binary was built from (used to track which aport/source built this)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is the package maintainer name and email" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the installed file" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture" + }, + "url": { + "type": "string", + "description": "URL is the upstream project URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "size": { + "type": "integer", + "description": "Size is the package archive size in bytes (.apk file size)" + }, + "installedSize": { + "type": "integer", + "description": "InstalledSize is the total size of installed files in bytes" + }, + "pullDependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the runtime dependencies required by this package" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are virtual packages provided by this package (for capability-based dependencies)" + }, + "pullChecksum": { + "type": "string", + "description": "Checksum is the package content checksum for integrity verification" + }, + "gitCommitOfApkPort": { + "type": "string", + "description": "GitCommit is the git commit hash of the APK port definition in Alpine's aports repository" + }, + "files": { + "items": { + "$ref": "#/$defs/ApkFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + } + }, + "type": "object", + "required": [ + "package", + "originPackage", + "maintainer", + "version", + "architecture", + "url", + "description", + "size", + "installedSize", + "pullDependencies", + "provides", + "pullChecksum", + "gitCommitOfApkPort", + "files" + ], + "description": "ApkDBEntry represents all captured data for the alpine linux package manager flat-file store." + }, + "ApkFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "ownerUid": { + "type": "string", + "description": "OwnerUID is the file owner user ID" + }, + "ownerGid": { + "type": "string", + "description": "OwnerGID is the file owner group ID" + }, + "permissions": { + "type": "string", + "description": "Permissions is the file permission mode string (e.g. \"0755\", \"0644\")" + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest is the file content hash for integrity verification" + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records)." + }, + "AppleAppBundleEntry": { + "properties": { + "bundleIdentifier": { + "type": "string", + "description": "BundleIdentifier is the unique identifier for the bundle (e.g. \"com.apple.Safari\")" + }, + "name": { + "type": "string", + "description": "Name is the short name of the bundle" + }, + "displayName": { + "type": "string", + "description": "DisplayName is the user-facing name of the bundle" + }, + "executable": { + "type": "string", + "description": "Executable is the name of the executable within the bundle" + }, + "shortVersion": { + "type": "string", + "description": "ShortVersion is the release (marketing) version of the bundle" + }, + "version": { + "type": "string", + "description": "Version is the build version of the bundle, which often differs from the short version" + }, + "packageType": { + "type": "string", + "description": "PackageType is the four-letter type code (e.g. \"APPL\" for apps, \"FMWK\" for frameworks, \"BNDL\" for bundles)" + }, + "supportedPlatforms": { + "items": { + "type": "string" + }, + "type": "array", + "description": "SupportedPlatforms lists the platforms the bundle targets (e.g. \"MacOSX\", \"iPhoneOS\")" + }, + "minimumSystemVersion": { + "type": "string", + "description": "MinimumSystemVersion is the minimum macOS version required to run the bundle" + }, + "minimumOSVersion": { + "type": "string", + "description": "MinimumOSVersion is the minimum OS version required for non-macOS platforms (e.g. iOS)" + }, + "copyright": { + "type": "string", + "description": "Copyright is the human-readable copyright notice for the bundle" + }, + "platformName": { + "type": "string", + "description": "PlatformName is the platform name of the SDK used to build the bundle (e.g. \"macosx\")" + }, + "sdkName": { + "type": "string", + "description": "SDKName is the name of the SDK used to build the bundle (e.g. \"macosx14.0\")" + } + }, + "type": "object", + "description": "AppleAppBundleEntry represents metadata about an Apple application bundle (CFBundle) extracted from Info.plist files." + }, + "BinarySignature": { + "properties": { + "matches": { + "items": { + "$ref": "#/$defs/ClassifierMatch" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "matches" + ], + "description": "BinarySignature represents a set of matched values within a binary file." + }, + "BitnamiSbomEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the Bitnami SPDX file" + }, + "arch": { + "type": "string", + "description": "Architecture is the target CPU architecture (amd64 or arm64 in Bitnami images)" + }, + "distro": { + "type": "string", + "description": "Distro is the distribution name this package is for (base OS like debian, ubuntu, etc.)" + }, + "revision": { + "type": "string", + "description": "Revision is the Bitnami-specific package revision number (incremented for Bitnami rebuilds of same upstream version)" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the Bitnami SPDX file" + }, + "path": { + "type": "string", + "description": "Path is the installation path in the filesystem where the package is located" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files are the file paths owned by this package (tracked via SPDX relationships)" + } + }, + "type": "object", + "required": [ + "name", + "arch", + "distro", + "revision", + "version", + "path", + "files" + ], + "description": "BitnamiSBOMEntry represents all captured data from Bitnami packages described in Bitnami' SPDX files." + }, + "CConanFileEntry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConanfileEntry represents a single \"Requires\" entry from a conanfile.txt." + }, + "CConanInfoEntry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + }, + "package_id": { + "type": "string", + "description": "PackageID is a unique package variant identifier" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConaninfoEntry represents a single \"full_requires\" entry from a conaninfo.txt." + }, + "CConanLockEntry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + }, + "package_id": { + "type": "string", + "description": "PackageID is a unique package variant identifier computed from settings/options (static hash in Conan 1.x, can have collisions with complex dependency graphs)" + }, + "prev": { + "type": "string", + "description": "Prev is the previous lock entry reference for versioning" + }, + "requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Requires are the runtime package dependencies" + }, + "build_requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "BuildRequires are the build-time dependencies (e.g. cmake, compilers)" + }, + "py_requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "PythonRequires are the Python dependencies needed for Conan recipes" + }, + "options": { + "$ref": "#/$defs/KeyValues", + "description": "Options are package configuration options as key-value pairs (e.g. shared=True, fPIC=True)" + }, + "path": { + "type": "string", + "description": "Path is the filesystem path to the package in Conan cache" + }, + "context": { + "type": "string", + "description": "Context is the build context information" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConanV1LockEntry represents a single \"node\" entry from a conan.lock V1 file." + }, + "CConanLockV2Entry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + }, + "packageID": { + "type": "string", + "description": "PackageID is a unique package variant identifier (dynamic in Conan 2.0, more accurate than V1)" + }, + "username": { + "type": "string", + "description": "Username is the Conan user/organization name" + }, + "channel": { + "type": "string", + "description": "Channel is the Conan channel name indicating stability/purpose (e.g. stable, testing, experimental)" + }, + "recipeRevision": { + "type": "string", + "description": "RecipeRevision is a git-like revision hash (RREV) of the recipe" + }, + "packageRevision": { + "type": "string", + "description": "PackageRevision is a git-like revision hash of the built binary package" + }, + "timestamp": { + "type": "string", + "description": "TimeStamp is when this package was built/locked" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConanV2LockEntry represents a single \"node\" entry from a conan.lock V2 file." + }, + "CPE": { + "properties": { + "cpe": { + "type": "string", + "description": "Value is the CPE string identifier." + }, + "source": { + "type": "string", + "description": "Source is the source where this CPE was obtained or generated from." + } + }, + "type": "object", + "required": [ + "cpe" + ], + "description": "CPE represents a Common Platform Enumeration identifier used for matching packages to known vulnerabilities in security databases." + }, + "ClassifierMatch": { + "properties": { + "classifier": { + "type": "string" + }, + "location": { + "$ref": "#/$defs/Location" + } + }, + "type": "object", + "required": [ + "classifier", + "location" + ], + "description": "ClassifierMatch represents a single matched value within a binary file and the \"class\" name the search pattern represents." + }, + "CocoaPodfileLockEntry": { + "properties": { + "checksum": { + "type": "string", + "description": "Checksum is the SHA-1 hash of the podspec file for integrity verification (generated via `pod ipc spec ... | openssl sha1`), ensuring all team members use the same pod specification version" + } + }, + "type": "object", + "required": [ + "checksum" + ], + "description": "CocoaPodfileLockEntry represents a single entry from the \"Pods\" section of a Podfile.lock file." + }, + "CondaLink": { + "properties": { + "source": { + "type": "string", + "description": "Source is the original path where the package was extracted from cache." + }, + "type": { + "type": "integer", + "description": "Type indicates the link type (1 for hard link, 2 for soft link, 3 for copy)." + } + }, + "type": "object", + "required": [ + "source", + "type" + ], + "description": "CondaLink represents link metadata from a Conda package's link.json file describing package installation source." + }, + "CondaMetadataEntry": { + "properties": { + "arch": { + "type": "string", + "description": "Arch is the target CPU architecture for the package (e.g., \"arm64\", \"x86_64\")." + }, + "name": { + "type": "string", + "description": "Name is the package name as found in the conda-meta JSON file." + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the conda-meta JSON file." + }, + "build": { + "type": "string", + "description": "Build is the build string identifier (e.g., \"h90dfc92_1014\")." + }, + "build_number": { + "type": "integer", + "description": "BuildNumber is the sequential build number for this version." + }, + "channel": { + "type": "string", + "description": "Channel is the Conda channel URL where the package was retrieved from." + }, + "subdir": { + "type": "string", + "description": "Subdir is the subdirectory within the channel (e.g., \"osx-arm64\", \"linux-64\")." + }, + "noarch": { + "type": "string", + "description": "Noarch indicates if the package is platform-independent (e.g., \"python\", \"generic\")." + }, + "license": { + "type": "string", + "description": "License is the package license identifier." + }, + "license_family": { + "type": "string", + "description": "LicenseFamily is the general license category (e.g., \"MIT\", \"Apache\", \"GPL\")." + }, + "md5": { + "type": "string", + "description": "MD5 is the MD5 hash of the package archive." + }, + "sha256": { + "type": "string", + "description": "SHA256 is the SHA-256 hash of the package archive." + }, + "size": { + "type": "integer", + "description": "Size is the package archive size in bytes." + }, + "timestamp": { + "type": "integer", + "description": "Timestamp is the Unix timestamp when the package was built." + }, + "fn": { + "type": "string", + "description": "Filename is the original package archive filename (e.g., \"zlib-1.2.11-h90dfc92_1014.tar.bz2\")." + }, + "url": { + "type": "string", + "description": "URL is the full download URL for the package archive." + }, + "extracted_package_dir": { + "type": "string", + "description": "ExtractedPackageDir is the local cache directory where the package was extracted." + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends is the list of runtime dependencies with version constraints." + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files is the list of files installed by this package." + }, + "paths_data": { + "$ref": "#/$defs/CondaPathsData", + "description": "PathsData contains detailed file metadata from the paths.json file." + }, + "link": { + "$ref": "#/$defs/CondaLink", + "description": "Link contains installation source metadata from the link.json file." + } + }, + "type": "object", + "required": [ + "name", + "version", + "build", + "build_number" + ], + "description": "CondaMetaPackage represents metadata for a Conda package extracted from the conda-meta/*.json files." + }, + "CondaPathData": { + "properties": { + "_path": { + "type": "string", + "description": "Path is the file path relative to the Conda environment root." + }, + "path_type": { + "type": "string", + "description": "PathType indicates the link type for the file (e.g., \"hardlink\", \"softlink\", \"directory\")." + }, + "sha256": { + "type": "string", + "description": "SHA256 is the SHA-256 hash of the file contents." + }, + "sha256_in_prefix": { + "type": "string", + "description": "SHA256InPrefix is the SHA-256 hash of the file after prefix replacement during installation." + }, + "size_in_bytes": { + "type": "integer", + "description": "SizeInBytes is the file size in bytes." + } + }, + "type": "object", + "required": [ + "_path", + "path_type", + "sha256", + "sha256_in_prefix", + "size_in_bytes" + ], + "description": "CondaPathData represents metadata for a single file within a Conda package from the paths.json file." + }, + "CondaPathsData": { + "properties": { + "paths_version": { + "type": "integer", + "description": "PathsVersion is the schema version of the paths data format." + }, + "paths": { + "items": { + "$ref": "#/$defs/CondaPathData" + }, + "type": "array", + "description": "Paths is the list of file metadata entries for all files in the package." + } + }, + "type": "object", + "required": [ + "paths_version", + "paths" + ], + "description": "CondaPathsData represents the paths.json file structure from a Conda package containing file metadata." + }, + "Coordinates": { + "properties": { + "path": { + "type": "string", + "description": "RealPath is the canonical absolute form of the path accessed (all symbolic links have been followed and relative path components like '.' and '..' have been removed)." + }, + "layerID": { + "type": "string", + "description": "FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. When nested archive cataloging is enabled, a file found inside an archive carries the chain naming that archive's filesystem instead: the ID of the filesystem the archive itself was found in (a layer digest, or blank) joined with the path of each containing archive, e.g. \"sha256:1a2b.../app.war/WEB-INF/lib/dep.jar\". It is a path there rather than a digest, and it is what keeps identically-named files in different archives apart." + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "Coordinates contains the minimal information needed to describe how to find a file within any possible source object (e.g." + }, + "DartPubspec": { + "properties": { + "homepage": { + "type": "string", + "description": "Homepage is the package homepage URL" + }, + "repository": { + "type": "string", + "description": "Repository is the source code repository URL" + }, + "documentation": { + "type": "string", + "description": "Documentation is the documentation site URL" + }, + "publish_to": { + "type": "string", + "description": "PublishTo is the package repository to publish to, or \"none\" to prevent accidental publishing" + }, + "environment": { + "$ref": "#/$defs/DartPubspecEnvironment", + "description": "Environment is SDK version constraints for Dart and Flutter" + }, + "platforms": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Platforms are the supported platforms (Android, iOS, web, etc.)" + }, + "ignored_advisories": { + "items": { + "type": "string" + }, + "type": "array", + "description": "IgnoredAdvisories are the security advisories to explicitly ignore for this package" + } + }, + "type": "object", + "description": "DartPubspec is a struct that represents a package described in a pubspec.yaml file" + }, + "DartPubspecEnvironment": { + "properties": { + "sdk": { + "type": "string", + "description": "SDK is the Dart SDK version constraint (e.g. \"\u003e=2.12.0 \u003c3.0.0\")" + }, + "flutter": { + "type": "string", + "description": "Flutter is the Flutter SDK version constraint if this is a Flutter package" + } + }, + "type": "object", + "description": "DartPubspecEnvironment represents SDK version constraints from the environment section of pubspec.yaml." + }, + "DartPubspecLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the pubspec.lock file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the pubspec.lock file" + }, + "hosted_url": { + "type": "string", + "description": "HostedURL is the URL of the package repository for hosted packages (typically pub.dev, but can be custom repository identified by hosted-url). When PUB_HOSTED_URL environment variable changes, lockfile tracks the source." + }, + "vcs_url": { + "type": "string", + "description": "VcsURL is the URL of the VCS repository for git/path dependencies (for packages fetched from version control systems like Git)" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "DartPubspecLockEntry is a struct that represents a single entry found in the \"packages\" section in a Dart pubspec.lock file." + }, + "DenoLockEntry": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is the crpto hash of the package content for verification" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies is the list of package specifiers that this package depends on" + } + }, + "type": "object", + "required": [ + "integrity", + "dependencies" + ], + "description": "DenoLockEntry is a struct that rep a single entry found in the \"packages\" section of a Deno deno.lock file" + }, + "DenoRemoteLockEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the remote URL from which the module fetcef" + }, + "integrity": { + "type": "string", + "description": "Integrity is the crpto hash of the package content for verification" + } + }, + "type": "object", + "required": [ + "url", + "integrity" + ], + "description": "DenoRemoteLockEntry is a struct that rep a single entry found in the \"remote\" section of a Deno deno.lock file" + }, + "Descriptor": { + "properties": { + "name": { + "type": "string", + "description": "Name is the name of the tool that generated this SBOM (e.g., \"syft\")." + }, + "version": { + "type": "string", + "description": "Version is the version of the tool that generated this SBOM." + }, + "configuration": { + "description": "Configuration contains the tool configuration used during SBOM generation." + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "Descriptor identifies the tool that generated this SBOM document, including its name, version, and configuration used during catalog generation." + }, + "Digest": { + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm specifies the hash algorithm used (e.g., \"sha256\", \"md5\")." + }, + "value": { + "type": "string", + "description": "Value is the hexadecimal string representation of the hash." + } + }, + "type": "object", + "required": [ + "algorithm", + "value" + ], + "description": "Digest represents a cryptographic hash of file contents." + }, + "Document": { + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/Package" + }, + "type": "array", + "description": "Artifacts is the list of packages discovered and placed into the catalog" + }, + "artifactRelationships": { + "items": { + "$ref": "#/$defs/Relationship" + }, + "type": "array" + }, + "files": { + "items": { + "$ref": "#/$defs/File" + }, + "type": "array", + "description": "note: must have omitempty" + }, + "source": { + "$ref": "#/$defs/Source", + "description": "Source represents the original object that was cataloged" + }, + "distro": { + "$ref": "#/$defs/LinuxRelease", + "description": "Distro represents the Linux distribution that was detected from the source" + }, + "descriptor": { + "$ref": "#/$defs/Descriptor", + "description": "Descriptor is a block containing self-describing information about syft" + }, + "schema": { + "$ref": "#/$defs/Schema", + "description": "Schema is a block reserved for defining the version for the shape of this JSON document and where to find the schema document to validate the shape" + } + }, + "type": "object", + "required": [ + "artifacts", + "artifactRelationships", + "source", + "distro", + "descriptor", + "schema" + ], + "description": "Document represents the syft cataloging findings as a JSON document" + }, + "DotnetDepsEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the deps.json file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the deps.json file" + }, + "path": { + "type": "string", + "description": "Path is the relative path to the package within the deps structure (e.g. \"app.metrics/3.0.0\")" + }, + "sha512": { + "type": "string", + "description": "Sha512 is the SHA-512 hash of the NuGet package content WITHOUT the signed content for verification (won't match hash from NuGet API or manual calculation of .nupkg file)" + }, + "hashPath": { + "type": "string", + "description": "HashPath is the relative path to the .nupkg.sha512 hash file (e.g. \"app.metrics.3.0.0.nupkg.sha512\")" + }, + "type": { + "type": "string", + "description": "Type is type of entry could be package or project for internal refs" + }, + "executables": { + "additionalProperties": { + "$ref": "#/$defs/DotnetPortableExecutableEntry" + }, + "type": "object", + "description": "Executables are the map of .NET Portable Executable files within this package with their version resources" + } + }, + "type": "object", + "required": [ + "name", + "version", + "path", + "sha512", + "hashPath" + ], + "description": "DotnetDepsEntry is a struct that represents a single entry found in the \"libraries\" section in a .NET [*.]deps.json file." + }, + "DotnetPackagesLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the packages.lock.json file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the packages.lock.json file" + }, + "contentHash": { + "type": "string", + "description": "ContentHash is the hash of the package content for verification" + }, + "type": { + "type": "string", + "description": "Type is the dependency type indicating how this dependency was added (Direct=explicit in project file, Transitive=pulled in by another package, Project=project reference)" + } + }, + "type": "object", + "required": [ + "name", + "version", + "contentHash", + "type" + ], + "description": "DotnetPackagesLockEntry is a struct that represents a single entry found in the \"dependencies\" section in a .NET packages.lock.json file." + }, + "DotnetPortableExecutableEntry": { + "properties": { + "assemblyVersion": { + "type": "string", + "description": "AssemblyVersion is the .NET assembly version number (strong-named version)" + }, + "legalCopyright": { + "type": "string", + "description": "LegalCopyright is the copyright notice string" + }, + "comments": { + "type": "string", + "description": "Comments are additional comments or description embedded in PE resources" + }, + "internalName": { + "type": "string", + "description": "InternalName is the internal name of the file" + }, + "companyName": { + "type": "string", + "description": "CompanyName is the company that produced the file" + }, + "productName": { + "type": "string", + "description": "ProductName is the name of the product this file is part of" + }, + "productVersion": { + "type": "string", + "description": "ProductVersion is the version of the product (may differ from AssemblyVersion)" + } + }, + "type": "object", + "required": [ + "assemblyVersion", + "legalCopyright", + "companyName", + "productName", + "productVersion" + ], + "description": "DotnetPortableExecutableEntry is a struct that represents a single entry found within \"VersionResources\" section of a .NET Portable Executable binary file." + }, + "DpkgArchiveEntry": { + "properties": { + "package": { + "type": "string", + "description": "Package is the package name as found in the status file" + }, + "source": { + "type": "string", + "description": "Source is the source package name this binary was built from (one source can produce multiple binary packages)" + }, + "version": { + "type": "string", + "description": "Version is the binary package version as found in the status file" + }, + "sourceVersion": { + "type": "string", + "description": "SourceVersion is the source package version (may differ from binary version when binNMU rebuilds occur)" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target architecture per Debian spec (specific arch like amd64/arm64, wildcard like any, architecture-independent \"all\", or \"source\" for source packages)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is the package maintainer's name and email in RFC822 format (name must come first, then email in angle brackets)" + }, + "installedSize": { + "type": "integer", + "description": "InstalledSize is the total size of installed files in kilobytes" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are the virtual packages provided by this package (allows other packages to depend on capabilities. Can include versioned provides like \"libdigest-md5-perl (= 2.55.01)\")" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the packages required for this package to function (will not be installed unless these requirements are met, creates strict ordering constraint)" + }, + "preDepends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "PreDepends are the packages that must be installed and configured BEFORE even starting installation of this package (stronger than Depends, discouraged unless absolutely necessary as it adds strict constraints for apt)" + }, + "files": { + "items": { + "$ref": "#/$defs/DpkgFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + } + }, + "type": "object", + "required": [ + "package", + "source", + "version", + "sourceVersion", + "architecture", + "maintainer", + "installedSize", + "files" + ], + "description": "DpkgArchiveEntry represents package metadata extracted from a .deb archive file." + }, + "DpkgDbEntry": { + "properties": { + "package": { + "type": "string", + "description": "Package is the package name as found in the status file" + }, + "source": { + "type": "string", + "description": "Source is the source package name this binary was built from (one source can produce multiple binary packages)" + }, + "version": { + "type": "string", + "description": "Version is the binary package version as found in the status file" + }, + "sourceVersion": { + "type": "string", + "description": "SourceVersion is the source package version (may differ from binary version when binNMU rebuilds occur)" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target architecture per Debian spec (specific arch like amd64/arm64, wildcard like any, architecture-independent \"all\", or \"source\" for source packages)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is the package maintainer's name and email in RFC822 format (name must come first, then email in angle brackets)" + }, + "installedSize": { + "type": "integer", + "description": "InstalledSize is the total size of installed files in kilobytes" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are the virtual packages provided by this package (allows other packages to depend on capabilities. Can include versioned provides like \"libdigest-md5-perl (= 2.55.01)\")" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the packages required for this package to function (will not be installed unless these requirements are met, creates strict ordering constraint)" + }, + "preDepends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "PreDepends are the packages that must be installed and configured BEFORE even starting installation of this package (stronger than Depends, discouraged unless absolutely necessary as it adds strict constraints for apt)" + }, + "files": { + "items": { + "$ref": "#/$defs/DpkgFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + } + }, + "type": "object", + "required": [ + "package", + "source", + "version", + "sourceVersion", + "architecture", + "maintainer", + "installedSize", + "files" + ], + "description": "DpkgDBEntry represents all captured data for a Debian package DB entry; available fields are described at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section." + }, + "DpkgFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest is the file content hash (typically MD5 for dpkg compatibility with legacy systems)" + }, + "isConfigFile": { + "type": "boolean", + "description": "IsConfigFile is whether this file is marked as a configuration file (dpkg will preserve user modifications during upgrades)" + } + }, + "type": "object", + "required": [ + "path", + "isConfigFile" + ], + "description": "DpkgFileRecord represents a single file attributed to a debian package." + }, + "ELFSecurityFeatures": { + "properties": { + "symbolTableStripped": { + "type": "boolean", + "description": "SymbolTableStripped indicates whether debugging symbols have been removed." + }, + "stackCanary": { + "type": "boolean", + "description": "StackCanary indicates whether stack smashing protection is enabled." + }, + "nx": { + "type": "boolean", + "description": "NoExecutable indicates whether NX (no-execute) protection is enabled for the stack." + }, + "relRO": { + "type": "string", + "description": "RelocationReadOnly indicates the RELRO protection level." + }, + "pie": { + "type": "boolean", + "description": "PositionIndependentExecutable indicates whether the binary is compiled as PIE." + }, + "dso": { + "type": "boolean", + "description": "DynamicSharedObject indicates whether the binary is a shared library." + }, + "safeStack": { + "type": "boolean", + "description": "LlvmSafeStack represents a compiler-based security mechanism that separates the stack into a safe stack for storing return addresses and other critical data, and an unsafe stack for everything else, to mitigate stack-based memory corruption errors\nsee https://clang.llvm.org/docs/SafeStack.html" + }, + "cfi": { + "type": "boolean", + "description": "ControlFlowIntegrity represents runtime checks to ensure a program's control flow adheres to the legal paths determined at compile time, thus protecting against various types of control-flow hijacking attacks\nsee https://clang.llvm.org/docs/ControlFlowIntegrity.html" + }, + "fortify": { + "type": "boolean", + "description": "ClangFortifySource is a broad suite of extensions to libc aimed at catching misuses of common library functions\nsee https://android.googlesource.com/platform//bionic/+/d192dbecf0b2a371eb127c0871f77a9caf81c4d2/docs/clang_fortify_anatomy.md" + } + }, + "type": "object", + "required": [ + "symbolTableStripped", + "nx", + "relRO", + "pie", + "dso" + ], + "description": "ELFSecurityFeatures captures security hardening and protection mechanisms in ELF binaries." + }, + "ElfBinaryPackageNoteJsonPayload": { + "properties": { + "type": { + "type": "string", + "description": "Type is the type of the package (e.g. \"rpm\", \"deb\", \"apk\", etc.)" + }, + "architecture": { + "type": "string", + "description": "Architecture of the binary package (e.g. \"amd64\", \"arm\", etc.)" + }, + "osCPE": { + "type": "string", + "description": "OSCPE is a CPE name for the OS, typically corresponding to CPE_NAME in os-release (e.g. cpe:/o:fedoraproject:fedora:33)\n\nDeprecated: in Syft 2.0 the struct tag will be corrected to `osCpe` to match the systemd spec casing." + }, + "appCpe": { + "type": "string", + "description": "AppCpe is a CPE name for the upstream Application, as found in NVD CPE search (e.g. cpe:2.3:a:gnu:coreutils:5.0)" + }, + "os": { + "type": "string", + "description": "OS is the OS name, typically corresponding to ID in os-release (e.g. \"fedora\")" + }, + "osVersion": { + "type": "string", + "description": "osVersion is the version of the OS, typically corresponding to VERSION_ID in os-release (e.g. \"33\")" + }, + "system": { + "type": "string", + "description": "System is a context-specific name for the system that the binary package is intended to run on or a part of" + }, + "vendor": { + "type": "string", + "description": "Vendor is the individual or organization that produced the source code for the binary" + }, + "sourceRepo": { + "type": "string", + "description": "SourceRepo is the URL to the source repository for which the binary was built from" + }, + "commit": { + "type": "string", + "description": "Commit is the commit hash of the source repository for which the binary was built from" + } + }, + "type": "object", + "description": "ELFBinaryPackageNoteJSONPayload Represents metadata captured from the .note.package section of an ELF-formatted binary" + }, + "ElixirMixLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the mix.lock file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the mix.lock file" + }, + "pkgHash": { + "type": "string", + "description": "PkgHash is the outer checksum (SHA-256) of the entire Hex package tarball for integrity verification (preferred method, replaces deprecated inner checksum)" + }, + "pkgHashExt": { + "type": "string", + "description": "PkgHashExt is the extended package hash format (inner checksum is deprecated - SHA-256 of concatenated file contents excluding CHECKSUM file, now replaced by outer checksum)" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the names of the packages this entry depends on, as\ndeclared in the entry's dependency list within mix.lock. Used to derive\ndependency-of relationships between locked packages." + } + }, + "type": "object", + "required": [ + "name", + "version", + "pkgHash", + "pkgHashExt" + ], + "description": "ElixirMixLockEntry is a struct that represents a single entry in a mix.lock file" + }, + "ErlangRebarLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the rebar.lock file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the rebar.lock file" + }, + "pkgHash": { + "type": "string", + "description": "PkgHash is the outer checksum (SHA-256) of the entire Hex package tarball for integrity verification (preferred method over deprecated inner checksum)" + }, + "pkgHashExt": { + "type": "string", + "description": "PkgHashExt is the extended package hash format (inner checksum deprecated - was SHA-256 of concatenated file contents)" + } + }, + "type": "object", + "required": [ + "name", + "version", + "pkgHash", + "pkgHashExt" + ], + "description": "ErlangRebarLockEntry represents a single package entry from the \"deps\" section within an Erlang rebar.lock file." + }, + "Executable": { + "properties": { + "format": { + "type": "string", + "description": "Format denotes either ELF, Mach-O, or PE" + }, + "hasExports": { + "type": "boolean", + "description": "HasExports indicates whether the binary exports symbols." + }, + "hasEntrypoint": { + "type": "boolean", + "description": "HasEntrypoint indicates whether the binary has an entry point function." + }, + "importedLibraries": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ImportedLibraries lists the shared libraries required by this executable." + }, + "elfSecurityFeatures": { + "$ref": "#/$defs/ELFSecurityFeatures", + "description": "ELFSecurityFeatures contains ELF-specific security hardening information when Format is ELF." + } + }, + "type": "object", + "required": [ + "format", + "hasExports", + "hasEntrypoint", + "importedLibraries" + ], + "description": "Executable contains metadata about binary files and their security features." + }, + "File": { + "properties": { + "id": { + "type": "string", + "description": "ID is a unique identifier for this file within the SBOM." + }, + "location": { + "$ref": "#/$defs/Coordinates", + "description": "Location is the file path and layer information where this file was found." + }, + "metadata": { + "$ref": "#/$defs/FileMetadataEntry", + "description": "Metadata contains filesystem metadata such as permissions, ownership, and file type." + }, + "contents": { + "type": "string", + "description": "Contents is the file contents for small files." + }, + "digests": { + "items": { + "$ref": "#/$defs/Digest" + }, + "type": "array", + "description": "Digests contains cryptographic hashes of the file contents." + }, + "licenses": { + "items": { + "$ref": "#/$defs/FileLicense" + }, + "type": "array", + "description": "Licenses contains license information discovered within this file." + }, + "executable": { + "$ref": "#/$defs/Executable", + "description": "Executable contains executable metadata if this file is a binary." + }, + "unknowns": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Unknowns contains unknown fields for forward compatibility." + } + }, + "type": "object", + "required": [ + "id", + "location" + ], + "description": "File represents a file discovered during cataloging with its metadata, content digests, licenses, and relationships to packages." + }, + "FileLicense": { + "properties": { + "value": { + "type": "string", + "description": "Value is the raw license identifier or text as found in the file." + }, + "spdxExpression": { + "type": "string", + "description": "SPDXExpression is the parsed SPDX license expression." + }, + "type": { + "type": "string", + "description": "Type is the license type classification (e.g., declared, concluded, discovered)." + }, + "evidence": { + "$ref": "#/$defs/FileLicenseEvidence", + "description": "Evidence contains supporting evidence for this license detection." + } + }, + "type": "object", + "required": [ + "value", + "spdxExpression", + "type" + ], + "description": "FileLicense represents license information discovered within a file's contents or metadata, including the matched license text and SPDX expression." + }, + "FileLicenseEvidence": { + "properties": { + "confidence": { + "type": "integer", + "description": "Confidence is the confidence score for this license detection (0-100)." + }, + "offset": { + "type": "integer", + "description": "Offset is the byte offset where the license text starts in the file." + }, + "extent": { + "type": "integer", + "description": "Extent is the length of the license text in bytes." + } + }, + "type": "object", + "required": [ + "confidence", + "offset", + "extent" + ], + "description": "FileLicenseEvidence contains supporting evidence for a license detection in a file, including the byte offset, extent, and confidence level." + }, + "FileMetadataEntry": { + "properties": { + "mode": { + "type": "integer", + "description": "Mode is the Unix file permission mode in octal format." + }, + "type": { + "type": "string", + "description": "Type is the file type (e.g., \"RegularFile\", \"Directory\", \"SymbolicLink\")." + }, + "linkDestination": { + "type": "string", + "description": "LinkDestination is the target path for symbolic links." + }, + "userID": { + "type": "integer", + "description": "UserID is the file owner user ID." + }, + "groupID": { + "type": "integer", + "description": "GroupID is the file owner group ID." + }, + "mimeType": { + "type": "string", + "description": "MIMEType is the MIME type of the file contents." + }, + "size": { + "type": "integer", + "description": "Size is the file size in bytes." + } + }, + "type": "object", + "required": [ + "mode", + "type", + "userID", + "groupID", + "mimeType", + "size" + ], + "description": "FileMetadataEntry contains filesystem-level metadata attributes such as permissions, ownership, type, and size for a cataloged file." + }, + "GgufFileHeader": { + "properties": { + "ggufVersion": { + "type": "integer", + "description": "GGUFVersion is the GGUF format version (e.g., 3)" + }, + "fileSize": { + "type": "integer", + "description": "FileSize is the size of the GGUF file in bytes (best-effort if available from resolver)" + }, + "architecture": { + "type": "string", + "description": "Architecture is the model architecture (from general.architecture, e.g., \"qwen3moe\", \"llama\")" + }, + "quantization": { + "type": "string", + "description": "Quantization is the quantization type (e.g., \"IQ4_NL\", \"Q4_K_M\")" + }, + "parameters": { + "type": "integer", + "description": "Parameters is the number of model parameters (if present in header)" + }, + "tensorCount": { + "type": "integer", + "description": "TensorCount is the number of tensors in the model" + }, + "header": { + "type": "object", + "description": "RemainingKeyValues contains the remaining key-value pairs from the GGUF header that are not already\nrepresented as typed fields above. This preserves additional metadata fields for reference\n(namespaced with general.*, llama.*, etc.) while avoiding duplication." + }, + "metadataHash": { + "type": "string", + "description": "MetadataKeyValuesHash is a xx64 hash of all key-value pairs from the GGUF header metadata.\nThis hash is computed over the complete header metadata (including the fields extracted\ninto typed fields above) and provides a stable identifier for the model configuration\nacross different file locations or remotes. It allows matching identical models even\nwhen stored in different repositories or with different filenames." + }, + "parts": { + "items": { + "$ref": "#/$defs/GgufFileHeader" + }, + "type": "array", + "description": "Parts contains headers from additional GGUF files that were merged\ninto this package during post-processing (e.g., from OCI layers without model names)." + } + }, + "type": "object", + "required": [ + "ggufVersion", + "tensorCount" + ], + "description": "GGUFFileHeader represents metadata extracted from a GGUF (GPT-Generated Unified Format) model file." + }, + "GithubActionsUseStatement": { + "properties": { + "value": { + "type": "string", + "description": "Value is the action reference (e.g. \"actions/checkout@v3\")" + }, + "comment": { + "type": "string", + "description": "Comment is the inline comment associated with this uses statement" + } + }, + "type": "object", + "required": [ + "value" + ], + "description": "GitHubActionsUseStatement represents a single 'uses' statement in a GitHub Actions workflow file referencing an action or reusable workflow." + }, + "GoModuleBuildinfoEntry": { + "properties": { + "goBuildSettings": { + "$ref": "#/$defs/KeyValues", + "description": "BuildSettings contains the Go build settings and flags used to compile the binary (e.g., GOARCH, GOOS, CGO_ENABLED)." + }, + "goCompiledVersion": { + "type": "string", + "description": "GoCompiledVersion is the version of Go used to compile the binary." + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture for the binary (extracted from GOARCH build setting)." + }, + "h1Digest": { + "type": "string", + "description": "H1Digest is the Go module hash in h1: format for the main module from go.sum." + }, + "mainModule": { + "type": "string", + "description": "MainModule is the main module path for the binary (e.g., \"github.com/anchore/syft\")." + }, + "goCryptoSettings": { + "items": { + "type": "string" + }, + "type": "array", + "description": "GoCryptoSettings contains FIPS and cryptographic configuration settings if present." + }, + "goExperiments": { + "items": { + "type": "string" + }, + "type": "array", + "description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")." + }, + "symbols": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object", + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-modules glob patterns populate\nany additional modules they match." + } + }, + "type": "object", + "required": [ + "goCompiledVersion", + "architecture" + ], + "description": "GolangBinaryBuildinfoEntry represents all captured data for a Golang binary" + }, + "GoModuleEntry": { + "properties": { + "h1Digest": { + "type": "string", + "description": "H1Digest is the Go module hash in h1: format from go.sum for verifying module contents." + } + }, + "type": "object", + "description": "GolangModuleEntry represents all captured data for a Golang source scan with go.mod/go.sum" + }, + "GoSourceEntry": { + "properties": { + "h1Digest": { + "type": "string", + "description": "H1Digest is the Go module hash in h1: format from go.sum for verifying module contents." + }, + "os": { + "type": "string", + "description": "OperatingSystem is the target OS for build constraints (e.g., \"linux\", \"darwin\", \"windows\")." + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture for build constraints (e.g., \"amd64\", \"arm64\")." + }, + "buildTags": { + "type": "string", + "description": "BuildTags are the build tags used to conditionally compile code (e.g., \"integration,debug\")." + }, + "cgoEnabled": { + "type": "boolean", + "description": "CgoEnabled indicates whether CGO was enabled for this package." + } + }, + "type": "object", + "required": [ + "cgoEnabled" + ], + "description": "GolangSourceEntry represents all captured data for a Golang package found through source analysis" + }, + "HaskellHackageStackEntry": { + "properties": { + "pkgHash": { + "type": "string", + "description": "PkgHash is the package content hash for verification" + } + }, + "type": "object", + "description": "HackageStackYamlEntry represents a single entry from the \"extra-deps\" section of a stack.yaml file." + }, + "HaskellHackageStackLockEntry": { + "properties": { + "pkgHash": { + "type": "string", + "description": "PkgHash is the package content hash for verification" + }, + "snapshotURL": { + "type": "string", + "description": "SnapshotURL is the URL to the Stack snapshot this package came from" + } + }, + "type": "object", + "description": "HackageStackYamlLockEntry represents a single entry from the \"packages\" section of a stack.yaml.lock file." + }, + "HomebrewFormula": { + "properties": { + "tap": { + "type": "string", + "description": "Tap is Homebrew tap this formula belongs to (e.g. \"homebrew/core\")" + }, + "homepage": { + "type": "string", + "description": "Homepage is the upstream project homepage URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable formula description" + } + }, + "type": "object", + "description": "HomebrewFormula represents metadata about a Homebrew formula package extracted from formula JSON files." + }, + "IDLikes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "IDLikes represents a list of distribution IDs that this Linux distribution is similar to or derived from, as defined in os-release ID_LIKE field." + }, + "JavaArchive": { + "properties": { + "virtualPath": { + "type": "string", + "description": "VirtualPath is path within the archive hierarchy, where nested entries are delimited with ':' (for nested JARs)" + }, + "manifest": { + "$ref": "#/$defs/JavaManifest", + "description": "Manifest is parsed META-INF/MANIFEST.MF contents" + }, + "pomProperties": { + "$ref": "#/$defs/JavaPomProperties", + "description": "PomProperties is parsed pom.properties file contents" + }, + "pomProject": { + "$ref": "#/$defs/JavaPomProject", + "description": "PomProject is parsed pom.xml file contents" + }, + "digest": { + "items": { + "$ref": "#/$defs/Digest" + }, + "type": "array", + "description": "ArchiveDigests is cryptographic hashes of the archive file" + } + }, + "type": "object", + "required": [ + "virtualPath" + ], + "description": "JavaArchive encapsulates all Java ecosystem metadata for a package as well as an (optional) parent relationship." + }, + "JavaJvmInstallation": { + "properties": { + "release": { + "$ref": "#/$defs/JavaVMRelease", + "description": "Release is JVM release information and version details" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files are the list of files that are part of this JVM installation" + } + }, + "type": "object", + "required": [ + "release", + "files" + ], + "description": "JavaVMInstallation represents a Java Virtual Machine installation discovered on the system with its release information and file list." + }, + "JavaManifest": { + "properties": { + "main": { + "$ref": "#/$defs/KeyValues", + "description": "Main is main manifest attributes as key-value pairs" + }, + "sections": { + "items": { + "$ref": "#/$defs/KeyValues" + }, + "type": "array", + "description": "Sections are the named sections from the manifest (e.g. per-entry attributes)" + } + }, + "type": "object", + "description": "JavaManifest represents the fields of interest extracted from a Java archive's META-INF/MANIFEST.MF file." + }, + "JavaPomParent": { + "properties": { + "groupId": { + "type": "string", + "description": "GroupID is the parent Maven group identifier" + }, + "artifactId": { + "type": "string", + "description": "ArtifactID is the parent Maven artifact identifier" + }, + "version": { + "type": "string", + "description": "Version is the parent version (child inherits configuration from this specific version of parent POM)" + } + }, + "type": "object", + "required": [ + "groupId", + "artifactId", + "version" + ], + "description": "JavaPomParent contains the fields within the \u003cparent\u003e tag in a pom.xml file" + }, + "JavaPomProject": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the pom.xml file within the archive" + }, + "parent": { + "$ref": "#/$defs/JavaPomParent", + "description": "Parent is the parent POM reference for inheritance (child POMs inherit configuration from parent)" + }, + "groupId": { + "type": "string", + "description": "GroupID is Maven group identifier (reversed domain name like org.apache.maven)" + }, + "artifactId": { + "type": "string", + "description": "ArtifactID is Maven artifact identifier (project name)" + }, + "version": { + "type": "string", + "description": "Version is project version (together with groupId and artifactId forms Maven coordinates groupId:artifactId:version)" + }, + "name": { + "type": "string", + "description": "Name is a human-readable project name (displayed in Maven-generated documentation)" + }, + "description": { + "type": "string", + "description": "Description is detailed project description" + }, + "url": { + "type": "string", + "description": "URL is the project URL (typically project website or repository)" + } + }, + "type": "object", + "required": [ + "path", + "groupId", + "artifactId", + "version", + "name" + ], + "description": "JavaPomProject represents fields of interest extracted from a Java archive's pom.xml file." + }, + "JavaPomProperties": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the pom.properties file within the archive" + }, + "name": { + "type": "string", + "description": "Name is the project name" + }, + "groupId": { + "type": "string", + "description": "GroupID is Maven group identifier uniquely identifying the project across all projects (follows reversed domain name convention like com.company.project)" + }, + "artifactId": { + "type": "string", + "description": "ArtifactID is Maven artifact identifier, the name of the jar/artifact (unique within the groupId scope)" + }, + "version": { + "type": "string", + "description": "Version is artifact version" + }, + "scope": { + "type": "string", + "description": "Scope is dependency scope determining when dependency is available (compile=default all phases, test=test compilation/execution only, runtime=runtime and test not compile, provided=expected from JDK or container)" + }, + "extraFields": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Extra is additional custom properties not in standard Maven coordinates" + } + }, + "type": "object", + "required": [ + "path", + "name", + "groupId", + "artifactId", + "version" + ], + "description": "JavaPomProperties represents the fields of interest extracted from a Java archive's pom.properties file." + }, + "JavaVMRelease": { + "properties": { + "implementor": { + "type": "string", + "description": "Implementor is extracted with the `java.vendor` JVM property" + }, + "implementorVersion": { + "type": "string", + "description": "ImplementorVersion is extracted with the `java.vendor.version` JVM property" + }, + "javaRuntimeVersion": { + "type": "string", + "description": "JavaRuntimeVersion is extracted from the 'java.runtime.version' JVM property" + }, + "javaVersion": { + "type": "string", + "description": "JavaVersion matches that from `java -version` command output" + }, + "javaVersionDate": { + "type": "string", + "description": "JavaVersionDate is extracted from the 'java.version.date' JVM property" + }, + "libc": { + "type": "string", + "description": "Libc can either be 'glibc' or 'musl'" + }, + "modules": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Modules is a list of JVM modules that are packaged" + }, + "osArch": { + "type": "string", + "description": "OsArch is the target CPU architecture" + }, + "osName": { + "type": "string", + "description": "OsName is the name of the target runtime operating system environment" + }, + "osVersion": { + "type": "string", + "description": "OsVersion is the version of the target runtime operating system environment" + }, + "source": { + "type": "string", + "description": "Source refers to the origin repository of OpenJDK source" + }, + "buildSource": { + "type": "string", + "description": "BuildSource Git SHA of the build repository" + }, + "buildSourceRepo": { + "type": "string", + "description": "BuildSourceRepo refers to rhe repository URL for the build source" + }, + "sourceRepo": { + "type": "string", + "description": "SourceRepo refers to the OpenJDK repository URL" + }, + "fullVersion": { + "type": "string", + "description": "FullVersion is extracted from the 'java.runtime.version' JVM property" + }, + "semanticVersion": { + "type": "string", + "description": "SemanticVersion is derived from the OpenJDK version" + }, + "buildInfo": { + "type": "string", + "description": "BuildInfo contains additional build information" + }, + "jvmVariant": { + "type": "string", + "description": "JvmVariant specifies the JVM variant (e.g., Hotspot or OpenJ9)" + }, + "jvmVersion": { + "type": "string", + "description": "JvmVersion is extracted from the 'java.vm.version' JVM property" + }, + "imageType": { + "type": "string", + "description": "ImageType can be 'JDK' or 'JRE'" + }, + "buildType": { + "type": "string", + "description": "BuildType can be 'commercial' (used in some older oracle JDK distributions)" + } + }, + "type": "object", + "description": "JavaVMRelease represents JVM version and build information extracted from the release file in a Java installation." + }, + "JavascriptBunLockEntry": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification (SRI format)" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of runtime dependencies and their version specifiers" + }, + "optionalDependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "OptionalDependencies is a map of optional dependencies and their version specifiers" + }, + "peerDependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "PeerDependencies is a map of peer dependencies and their version specifiers" + }, + "bin": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Bin is a map of binary names to the paths they are installed to" + }, + "os": { + "type": "string", + "description": "OS is the operating system constraint for the package (e.g. \"darwin\")" + }, + "cpu": { + "type": "string", + "description": "CPU is the CPU architecture constraint for the package (e.g. \"arm64\")" + } + }, + "type": "object", + "required": [ + "integrity", + "dependencies", + "optionalDependencies", + "peerDependencies", + "bin", + "os", + "cpu" + ], + "description": "BunLockEntry represents a single entry in the \"packages\" section of a bun.lock file" + }, + "JavascriptNpmPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in package.json" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in package.json" + }, + "author": { + "type": "string", + "description": "Author is package author name" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "url": { + "type": "string", + "description": "URL is repository or project URL" + }, + "private": { + "type": "boolean", + "description": "Private is whether this is a private package" + } + }, + "type": "object", + "required": [ + "name", + "version", + "author", + "homepage", + "description", + "url", + "private" + ], + "description": "NpmPackage represents the contents of a javascript package.json file." + }, + "JavascriptNpmPackageLockEntry": { + "properties": { + "resolved": { + "type": "string", + "description": "Resolved is URL where this package was downloaded from (registry source)" + }, + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification using standard SRI format (sha512-... or sha1-...). npm changed from SHA-1 to SHA-512 in newer versions. For registry sources this is the integrity from registry, for remote tarballs it's SHA-512 of the file. npm verifies tarball matches this hash before unpacking, throwing EINTEGRITY error if mismatch detected." + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of dependencies and their version markers, i.e. \"lodash\": \"^1.0.0\"" + } + }, + "type": "object", + "required": [ + "resolved", + "integrity", + "dependencies" + ], + "description": "NpmPackageLockEntry represents a single entry within the \"packages\" section of a package-lock.json file." + }, + "JavascriptPnpmLockEntry": { + "properties": { + "resolution": { + "$ref": "#/$defs/PnpmLockResolution", + "description": "Resolution is the resolution information for the package" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of dependencies and their versions" + } + }, + "type": "object", + "required": [ + "resolution", + "dependencies" + ], + "description": "PnpmLockEntry represents a single entry in the \"packages\" section of a pnpm-lock.yaml file." + }, + "JavascriptYarnLockEntry": { + "properties": { + "resolved": { + "type": "string", + "description": "Resolved is URL where this package was downloaded from" + }, + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification (SRI format)" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of dependencies and their versions" + } + }, + "type": "object", + "required": [ + "resolved", + "integrity", + "dependencies" + ], + "description": "YarnLockEntry represents a single entry section of a yarn.lock file." + }, + "KeyValue": { + "properties": { + "key": { + "type": "string", + "description": "Key is the key name" + }, + "value": { + "type": "string", + "description": "Value is the value associated with the key" + } + }, + "type": "object", + "required": [ + "key", + "value" + ], + "description": "KeyValue represents a single key-value pair." + }, + "KeyValues": { + "items": { + "$ref": "#/$defs/KeyValue" + }, + "type": "array", + "description": "KeyValues represents an ordered collection of key-value pairs that preserves insertion order." + }, + "License": { + "properties": { + "value": { + "type": "string", + "description": "Value is the raw license identifier or expression as found." + }, + "spdxExpression": { + "type": "string", + "description": "SPDXExpression is the parsed SPDX license expression." + }, + "type": { + "type": "string", + "description": "Type is the license type classification (e.g., declared, concluded, discovered)." + }, + "urls": { + "items": { + "type": "string" + }, + "type": "array", + "description": "URLs are URLs where license text or information can be found." + }, + "locations": { + "items": { + "$ref": "#/$defs/Location" + }, + "type": "array", + "description": "Locations are file locations where this license was discovered." + }, + "contents": { + "type": "string", + "description": "Contents is the full license text content." + } + }, + "type": "object", + "required": [ + "value", + "spdxExpression", + "type", + "urls", + "locations" + ], + "description": "License represents software license information discovered for a package, including SPDX expressions and supporting evidence locations." + }, + "LinuxKernelArchive": { + "properties": { + "name": { + "type": "string", + "description": "Name is kernel name (typically \"Linux\")" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture" + }, + "version": { + "type": "string", + "description": "Version is kernel version string" + }, + "extendedVersion": { + "type": "string", + "description": "ExtendedVersion is additional version information" + }, + "buildTime": { + "type": "string", + "description": "BuildTime is when the kernel was built" + }, + "author": { + "type": "string", + "description": "Author is who built the kernel" + }, + "format": { + "type": "string", + "description": "Format is kernel image format (e.g. bzImage, zImage)" + }, + "rwRootFS": { + "type": "boolean", + "description": "RWRootFS is whether root filesystem is mounted read-write" + }, + "swapDevice": { + "type": "integer", + "description": "SwapDevice is swap device number" + }, + "rootDevice": { + "type": "integer", + "description": "RootDevice is root device number" + }, + "videoMode": { + "type": "string", + "description": "VideoMode is default video mode setting" + } + }, + "type": "object", + "required": [ + "name", + "architecture", + "version" + ], + "description": "LinuxKernel represents all captured data for a Linux kernel" + }, + "LinuxKernelModule": { + "properties": { + "name": { + "type": "string", + "description": "Name is module name" + }, + "version": { + "type": "string", + "description": "Version is module version string" + }, + "sourceVersion": { + "type": "string", + "description": "SourceVersion is the source code version identifier" + }, + "path": { + "type": "string", + "description": "Path is the filesystem path to the .ko kernel object file (absolute path)" + }, + "description": { + "type": "string", + "description": "Description is a human-readable module description" + }, + "author": { + "type": "string", + "description": "Author is module author name and email" + }, + "license": { + "type": "string", + "description": "License is module license (e.g. GPL, BSD) which must be compatible with kernel" + }, + "kernelVersion": { + "type": "string", + "description": "KernelVersion is kernel version this module was built for" + }, + "versionMagic": { + "type": "string", + "description": "VersionMagic is version magic string for compatibility checking (includes kernel version, SMP status, module loading capabilities like \"3.17.4-302.fc21.x86_64 SMP mod_unload modversions\"). Module will NOT load if vermagic doesn't match running kernel." + }, + "parameters": { + "additionalProperties": { + "$ref": "#/$defs/LinuxKernelModuleParameter" + }, + "type": "object", + "description": "Parameters are the module parameters that can be configured at load time (user-settable values like module options)" + } + }, + "type": "object", + "description": "LinuxKernelModule represents a loadable kernel module (.ko file) with its metadata, parameters, and dependencies." + }, + "LinuxKernelModuleParameter": { + "properties": { + "type": { + "type": "string", + "description": "Type is parameter data type (e.g. int, string, bool, array types)" + }, + "description": { + "type": "string", + "description": "Description is a human-readable parameter description explaining what the parameter controls" + } + }, + "type": "object", + "description": "LinuxKernelModuleParameter represents a configurable parameter for a kernel module with its type and description." + }, + "LinuxRelease": { + "properties": { + "prettyName": { + "type": "string", + "description": "PrettyName is a human-readable operating system name with version." + }, + "name": { + "type": "string", + "description": "Name is the operating system name without version information." + }, + "id": { + "type": "string", + "description": "ID is the lower-case operating system identifier (e.g., \"ubuntu\", \"rhel\")." + }, + "idLike": { + "$ref": "#/$defs/IDLikes", + "description": "IDLike is a list of operating system IDs this distribution is similar to or derived from." + }, + "version": { + "type": "string", + "description": "Version is the operating system version including codename if available." + }, + "versionID": { + "type": "string", + "description": "VersionID is the operating system version number or identifier." + }, + "versionCodename": { + "type": "string", + "description": "VersionCodename is the operating system release codename (e.g., \"jammy\", \"bullseye\")." + }, + "buildID": { + "type": "string", + "description": "BuildID is a build identifier for the operating system." + }, + "imageID": { + "type": "string", + "description": "ImageID is an identifier for container or cloud images." + }, + "imageVersion": { + "type": "string", + "description": "ImageVersion is the version for container or cloud images." + }, + "variant": { + "type": "string", + "description": "Variant is the operating system variant name (e.g., \"Server\", \"Workstation\")." + }, + "variantID": { + "type": "string", + "description": "VariantID is the lower-case operating system variant identifier." + }, + "homeURL": { + "type": "string", + "description": "HomeURL is the homepage URL for the operating system." + }, + "supportURL": { + "type": "string", + "description": "SupportURL is the support or help URL for the operating system." + }, + "bugReportURL": { + "type": "string", + "description": "BugReportURL is the bug reporting URL for the operating system." + }, + "privacyPolicyURL": { + "type": "string", + "description": "PrivacyPolicyURL is the privacy policy URL for the operating system." + }, + "cpeName": { + "type": "string", + "description": "CPEName is the Common Platform Enumeration name for the operating system." + }, + "supportEnd": { + "type": "string", + "description": "SupportEnd is the end of support date or version identifier." + }, + "extendedSupport": { + "type": "boolean", + "description": "ExtendedSupport indicates whether extended security or support is available." + } + }, + "type": "object", + "description": "LinuxRelease contains Linux distribution identification and version information extracted from /etc/os-release or similar system files." + }, + "Location": { + "properties": { + "path": { + "type": "string", + "description": "RealPath is the canonical absolute form of the path accessed (all symbolic links have been followed and relative path components like '.' and '..' have been removed)." + }, + "layerID": { + "type": "string", + "description": "FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. When nested archive cataloging is enabled, a file found inside an archive carries the chain naming that archive's filesystem instead: the ID of the filesystem the archive itself was found in (a layer digest, or blank) joined with the path of each containing archive, e.g. \"sha256:1a2b.../app.war/WEB-INF/lib/dep.jar\". It is a path there rather than a digest, and it is what keeps identically-named files in different archives apart." + }, + "accessPath": { + "type": "string", + "description": "AccessPath is the path used to retrieve file contents (which may or may not have hardlinks / symlinks in the path)" + }, + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Arbitrary key-value pairs that can be used to annotate a location" + } + }, + "type": "object", + "required": [ + "path", + "accessPath" + ], + "description": "Location represents a path relative to a particular filesystem resolved to a specific file.Reference." + }, + "LuarocksPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the .rockspec file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the .rockspec file" + }, + "license": { + "type": "string", + "description": "License is license identifier" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "url": { + "type": "string", + "description": "URL is the source download URL" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies are the map of dependency names to version constraints" + } + }, + "type": "object", + "required": [ + "name", + "version", + "license", + "homepage", + "description", + "url", + "dependencies" + ], + "description": "LuaRocksPackage represents a Lua package managed by the LuaRocks package manager with metadata from .rockspec files." + }, + "MicrosoftKbPatch": { + "properties": { + "product_id": { + "type": "string", + "description": "ProductID is MSRC Product ID (e.g. \"Windows 10 Version 1703 for 32-bit Systems\")" + }, + "kb": { + "type": "string", + "description": "Kb is Knowledge Base article number (e.g. \"5001028\")" + } + }, + "type": "object", + "required": [ + "product_id", + "kb" + ], + "description": "MicrosoftKbPatch represents a Windows Knowledge Base patch identifier associated with a specific Microsoft product from the MSRC (Microsoft Security Response Center)." + }, + "NixDerivation": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the .drv file in Nix store" + }, + "system": { + "type": "string", + "description": "System is target system string indicating where derivation can be built (e.g. \"x86_64-linux\", \"aarch64-darwin\"). Must match current system for local builds." + }, + "inputDerivations": { + "items": { + "$ref": "#/$defs/NixDerivationReference" + }, + "type": "array", + "description": "InputDerivations are the list of other derivations that were inputs to this build (dependencies)" + }, + "inputSources": { + "items": { + "type": "string" + }, + "type": "array", + "description": "InputSources are the list of source file paths that were inputs to this build" + } + }, + "type": "object", + "description": "NixDerivation represents a Nix .drv file that describes how to build a package including inputs, outputs, and build instructions." + }, + "NixDerivationReference": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the referenced .drv file" + }, + "outputs": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Outputs are which outputs of the referenced derivation were used (e.g. [\"out\"], [\"bin\", \"dev\"])" + } + }, + "type": "object", + "description": "NixDerivationReference represents a reference to another derivation used as a build input or runtime dependency." + }, + "NixStoreEntry": { + "properties": { + "path": { + "type": "string", + "description": "Path is full store path for this output (e.g. /nix/store/abc123...-package-1.0)" + }, + "output": { + "type": "string", + "description": "Output is the specific output name for multi-output packages (empty string for default \"out\" output, can be \"bin\", \"dev\", \"doc\", etc.)" + }, + "outputHash": { + "type": "string", + "description": "OutputHash is hash prefix of the store path basename (first part before the dash)" + }, + "derivation": { + "$ref": "#/$defs/NixDerivation", + "description": "Derivation is information about the .drv file that describes how this package was built" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files are the list of files under the nix/store path for this package" + } + }, + "type": "object", + "required": [ + "outputHash" + ], + "description": "NixStoreEntry represents a package in the Nix store (/nix/store) with its derivation information and metadata." + }, + "OpamPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the .opam file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the .opam file" + }, + "licenses": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Licenses are the list of applicable licenses" + }, + "url": { + "type": "string", + "description": "URL is download URL for the package source" + }, + "checksum": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Checksums are the list of checksums for verification" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the list of required dependencies" + } + }, + "type": "object", + "required": [ + "name", + "version", + "licenses", + "url", + "checksum", + "homepage", + "dependencies" + ], + "description": "OpamPackage represents an OCaml package managed by the OPAM package manager with metadata from .opam files." + }, + "Package": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "type": { + "type": "string" + }, + "foundBy": { + "type": "string" + }, + "locations": { + "items": { + "$ref": "#/$defs/Location" + }, + "type": "array" + }, + "licenses": { + "$ref": "#/$defs/licenses" + }, + "language": { + "type": "string" + }, + "cpes": { + "$ref": "#/$defs/cpes" + }, + "purl": { + "type": "string" + }, + "metadataType": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AlpmDbEntry" + }, + { + "$ref": "#/$defs/ApkDbEntry" + }, + { + "$ref": "#/$defs/AppleAppBundleEntry" + }, + { + "$ref": "#/$defs/BinarySignature" + }, + { + "$ref": "#/$defs/BitnamiSbomEntry" + }, + { + "$ref": "#/$defs/CConanFileEntry" + }, + { + "$ref": "#/$defs/CConanInfoEntry" + }, + { + "$ref": "#/$defs/CConanLockEntry" + }, + { + "$ref": "#/$defs/CConanLockV2Entry" + }, + { + "$ref": "#/$defs/CocoaPodfileLockEntry" + }, + { + "$ref": "#/$defs/CondaMetadataEntry" + }, + { + "$ref": "#/$defs/DartPubspec" + }, + { + "$ref": "#/$defs/DartPubspecLockEntry" + }, + { + "$ref": "#/$defs/DenoLockEntry" + }, + { + "$ref": "#/$defs/DenoRemoteLockEntry" + }, + { + "$ref": "#/$defs/DotnetDepsEntry" + }, + { + "$ref": "#/$defs/DotnetPackagesLockEntry" + }, + { + "$ref": "#/$defs/DotnetPortableExecutableEntry" + }, + { + "$ref": "#/$defs/DpkgArchiveEntry" + }, + { + "$ref": "#/$defs/DpkgDbEntry" + }, + { + "$ref": "#/$defs/ElfBinaryPackageNoteJsonPayload" + }, + { + "$ref": "#/$defs/ElixirMixLockEntry" + }, + { + "$ref": "#/$defs/ErlangRebarLockEntry" + }, + { + "$ref": "#/$defs/GgufFileHeader" + }, + { + "$ref": "#/$defs/GithubActionsUseStatement" + }, + { + "$ref": "#/$defs/GoModuleBuildinfoEntry" + }, + { + "$ref": "#/$defs/GoModuleEntry" + }, + { + "$ref": "#/$defs/GoSourceEntry" + }, + { + "$ref": "#/$defs/HaskellHackageStackEntry" + }, + { + "$ref": "#/$defs/HaskellHackageStackLockEntry" + }, + { + "$ref": "#/$defs/HomebrewFormula" + }, + { + "$ref": "#/$defs/JavaArchive" + }, + { + "$ref": "#/$defs/JavaJvmInstallation" + }, + { + "$ref": "#/$defs/JavascriptBunLockEntry" + }, + { + "$ref": "#/$defs/JavascriptNpmPackage" + }, + { + "$ref": "#/$defs/JavascriptNpmPackageLockEntry" + }, + { + "$ref": "#/$defs/JavascriptPnpmLockEntry" + }, + { + "$ref": "#/$defs/JavascriptYarnLockEntry" + }, + { + "$ref": "#/$defs/LinuxKernelArchive" + }, + { + "$ref": "#/$defs/LinuxKernelModule" + }, + { + "$ref": "#/$defs/LuarocksPackage" + }, + { + "$ref": "#/$defs/MicrosoftKbPatch" + }, + { + "$ref": "#/$defs/NixStoreEntry" + }, + { + "$ref": "#/$defs/OpamPackage" + }, + { + "$ref": "#/$defs/PeBinary" + }, + { + "$ref": "#/$defs/PhpComposerInstalledEntry" + }, + { + "$ref": "#/$defs/PhpComposerLockEntry" + }, + { + "$ref": "#/$defs/PhpPearEntry" + }, + { + "$ref": "#/$defs/PhpPeclEntry" + }, + { + "$ref": "#/$defs/PortageDbEntry" + }, + { + "$ref": "#/$defs/PythonPackage" + }, + { + "$ref": "#/$defs/PythonPdmLockEntry" + }, + { + "$ref": "#/$defs/PythonPipRequirementsEntry" + }, + { + "$ref": "#/$defs/PythonPipfileLockEntry" + }, + { + "$ref": "#/$defs/PythonPoetryLockEntry" + }, + { + "$ref": "#/$defs/PythonUvLockEntry" + }, + { + "$ref": "#/$defs/RDescription" + }, + { + "$ref": "#/$defs/RpmArchive" + }, + { + "$ref": "#/$defs/RpmDbEntry" + }, + { + "$ref": "#/$defs/RubyGemspec" + }, + { + "$ref": "#/$defs/RustCargoAuditEntry" + }, + { + "$ref": "#/$defs/RustCargoLockEntry" + }, + { + "$ref": "#/$defs/SafetensorsModelInfo" + }, + { + "$ref": "#/$defs/SnapEntry" + }, + { + "$ref": "#/$defs/SwiftPackageManagerLockEntry" + }, + { + "$ref": "#/$defs/SwiplpackPackage" + }, + { + "$ref": "#/$defs/TerraformLockProviderEntry" + }, + { + "$ref": "#/$defs/VcpkgManifest" + }, + { + "$ref": "#/$defs/WordpressPluginEntry" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "name", + "version", + "type", + "foundBy", + "locations", + "licenses", + "language", + "cpes", + "purl" + ], + "description": "Package represents a pkg.Package object specialized for JSON marshaling and unmarshalling." + }, + "PeBinary": { + "properties": { + "VersionResources": { + "$ref": "#/$defs/KeyValues", + "description": "VersionResources contains key-value pairs extracted from the PE file's version resource section (e.g., FileVersion, ProductName, CompanyName)." + } + }, + "type": "object", + "required": [ + "VersionResources" + ], + "description": "PEBinary represents metadata captured from a Portable Executable formatted binary (dll, exe, etc.)" + }, + "PhpComposerAuthors": { + "properties": { + "name": { + "type": "string", + "description": "Name is author's full name" + }, + "email": { + "type": "string", + "description": "Email is author's email address" + }, + "homepage": { + "type": "string", + "description": "Homepage is author's personal or company website" + } + }, + "type": "object", + "required": [ + "name" + ], + "description": "PhpComposerAuthors represents author information for a PHP Composer package from the authors field in composer.json." + }, + "PhpComposerExternalReference": { + "properties": { + "type": { + "type": "string", + "description": "Type is reference type (git for source VCS, zip/tar for dist archives)" + }, + "url": { + "type": "string", + "description": "URL is the URL to the resource (git repository URL or archive download URL)" + }, + "reference": { + "type": "string", + "description": "Reference is git commit hash or version tag for source, or archive version for dist" + }, + "shasum": { + "type": "string", + "description": "Shasum is SHA hash of the archive file for integrity verification (dist only)" + } + }, + "type": "object", + "required": [ + "type", + "url", + "reference" + ], + "description": "PhpComposerExternalReference represents source or distribution information for a PHP package, indicating where the package code is retrieved from." + }, + "PhpComposerInstalledEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is package name in vendor/package format (e.g. symfony/console)" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "source": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Source is the source repository information for development (typically git repo, used when passing --prefer-source). Originates from source code repository." + }, + "dist": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Dist is distribution archive information for production (typically zip/tar, default install method). Packaged version of released code." + }, + "require": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Require is runtime dependencies with version constraints (package will not install unless these requirements can be met)" + }, + "provide": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Provide is virtual packages/functionality provided by this package (allows other packages to depend on capabilities)" + }, + "require-dev": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "RequireDev is development-only dependencies (not installed in production, only when developing this package or running tests)" + }, + "suggest": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Suggest is optional but recommended dependencies (suggestions for packages that would extend functionality)" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of license identifiers (SPDX format)" + }, + "type": { + "type": "string", + "description": "Type is package type indicating purpose (library=reusable code, project=application, metapackage=aggregates dependencies, etc.)" + }, + "notification-url": { + "type": "string", + "description": "NotificationURL is the URL to notify when package is installed (for tracking/statistics)" + }, + "bin": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Bin is the list of binary/executable files that should be added to PATH" + }, + "authors": { + "items": { + "$ref": "#/$defs/PhpComposerAuthors" + }, + "type": "array", + "description": "Authors are the list of package authors with name/email/homepage" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "keywords": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Keywords are the list of keywords for package discovery/search" + }, + "time": { + "type": "string", + "description": "Time is timestamp when this package version was released" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source", + "dist" + ], + "description": "PhpComposerInstalledEntry represents a single package entry from a composer v1/v2 \"installed.json\" files (very similar to composer.lock files)." + }, + "PhpComposerLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is package name in vendor/package format (e.g. symfony/console)" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "source": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Source is the source repository information for development (typically git repo, used when passing --prefer-source). Originates from source code repository." + }, + "dist": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Dist is distribution archive information for production (typically zip/tar, default install method). Packaged version of released code." + }, + "require": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Require is runtime dependencies with version constraints (package will not install unless these requirements can be met)" + }, + "provide": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Provide is virtual packages/functionality provided by this package (allows other packages to depend on capabilities)" + }, + "require-dev": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "RequireDev is development-only dependencies (not installed in production, only when developing this package or running tests)" + }, + "suggest": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Suggest is optional but recommended dependencies (suggestions for packages that would extend functionality)" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of license identifiers (SPDX format)" + }, + "type": { + "type": "string", + "description": "Type is package type indicating purpose (library=reusable code, project=application, metapackage=aggregates dependencies, etc.)" + }, + "notification-url": { + "type": "string", + "description": "NotificationURL is the URL to notify when package is installed (for tracking/statistics)" + }, + "bin": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Bin is the list of binary/executable files that should be added to PATH" + }, + "authors": { + "items": { + "$ref": "#/$defs/PhpComposerAuthors" + }, + "type": "array", + "description": "Authors are the list of package authors with name/email/homepage" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "keywords": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Keywords are the list of keywords for package discovery/search" + }, + "time": { + "type": "string", + "description": "Time is timestamp when this package version was released" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source", + "dist" + ], + "description": "PhpComposerLockEntry represents a single package entry found from a composer.lock file." + }, + "PhpPearEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name" + }, + "channel": { + "type": "string", + "description": "Channel is PEAR channel this package is from" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of applicable licenses" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "PhpPearEntry represents a single package entry found within php pear metadata files." + }, + "PhpPeclEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name" + }, + "channel": { + "type": "string", + "description": "Channel is PEAR channel this package is from" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of applicable licenses" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "PhpPeclEntry represents a single package entry found within php pecl metadata files." + }, + "PnpmLockResolution": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification (SRI format)" + } + }, + "type": "object", + "required": [ + "integrity" + ], + "description": "PnpmLockResolution contains package resolution metadata from pnpm lockfiles, including the integrity hash used for verification." + }, + "PortageDbEntry": { + "properties": { + "installedSize": { + "type": "integer", + "description": "InstalledSize is total size of installed files in bytes" + }, + "licenses": { + "type": "string", + "description": "Licenses is license string which may be an expression (e.g. \"GPL-2 OR Apache-2.0\")" + }, + "files": { + "items": { + "$ref": "#/$defs/PortageFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package (tracked in CONTENTS file)" + } + }, + "type": "object", + "required": [ + "installedSize", + "files" + ], + "description": "PortageEntry represents a single package entry in the portage DB flat-file store." + }, + "PortageFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest is file content hash (MD5 for regular files in CONTENTS format: \"obj filename md5hash mtime\")" + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "PortageFileRecord represents a single file attributed to a portage package." + }, + "PythonDirectURLOriginInfo": { + "properties": { + "url": { + "type": "string", + "description": "URL is the source URL from which the package was installed." + }, + "commitId": { + "type": "string", + "description": "CommitID is the VCS commit hash if installed from version control." + }, + "vcs": { + "type": "string", + "description": "VCS is the version control system type (e.g., \"git\", \"hg\")." + } + }, + "type": "object", + "required": [ + "url" + ], + "description": "PythonDirectURLOriginInfo represents installation source metadata from direct_url.json for packages installed from VCS or direct URLs." + }, + "PythonFileDigest": { + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm is the hash algorithm used (e.g., \"sha256\")." + }, + "value": { + "type": "string", + "description": "Value is the hex-encoded hash digest value." + } + }, + "type": "object", + "required": [ + "algorithm", + "value" + ], + "description": "PythonFileDigest represents the file metadata for a single file attributed to a python package." + }, + "PythonFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the installed file path from the RECORD file." + }, + "digest": { + "$ref": "#/$defs/PythonFileDigest", + "description": "Digest contains the hash algorithm and value for file integrity verification." + }, + "size": { + "type": "string", + "description": "Size is the file size in bytes as a string." + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "PythonFileRecord represents a single entry within a RECORD file for a python wheel or egg package" + }, + "PythonPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name from the Name field in PKG-INFO or METADATA." + }, + "version": { + "type": "string", + "description": "Version is the package version from the Version field in PKG-INFO or METADATA." + }, + "author": { + "type": "string", + "description": "Author is the package author name from the Author field." + }, + "authorEmail": { + "type": "string", + "description": "AuthorEmail is the package author's email address from the Author-Email field." + }, + "platform": { + "type": "string", + "description": "Platform indicates the target platform for the package (e.g., \"any\", \"linux\", \"win32\")." + }, + "files": { + "items": { + "$ref": "#/$defs/PythonFileRecord" + }, + "type": "array", + "description": "Files are the installed files listed in the RECORD file for wheels or installed-files.txt for eggs." + }, + "sitePackagesRootPath": { + "type": "string", + "description": "SitePackagesRootPath is the root directory path containing the package (e.g., \"/usr/lib/python3.9/site-packages\")." + }, + "topLevelPackages": { + "items": { + "type": "string" + }, + "type": "array", + "description": "TopLevelPackages are the top-level Python module names from top_level.txt file." + }, + "directUrlOrigin": { + "$ref": "#/$defs/PythonDirectURLOriginInfo", + "description": "DirectURLOrigin contains VCS or direct URL installation information from direct_url.json." + }, + "requiresPython": { + "type": "string", + "description": "RequiresPython specifies the Python version requirement (e.g., \"\u003e=3.6\")." + }, + "requiresDist": { + "items": { + "type": "string" + }, + "type": "array", + "description": "RequiresDist lists the package dependencies with version specifiers from Requires-Dist fields." + }, + "providesExtra": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ProvidesExtra lists optional feature names that can be installed via extras (e.g., \"dev\", \"test\")." + } + }, + "type": "object", + "required": [ + "name", + "version", + "author", + "authorEmail", + "platform", + "sitePackagesRootPath" + ], + "description": "PythonPackage represents all captured data for a python egg or wheel package (specifically as outlined in the PyPA core metadata specification https://packaging.python.org/en/latest/specifications/core-metadata/)." + }, + "PythonPdmFileEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the file download URL" + }, + "digest": { + "$ref": "#/$defs/PythonFileDigest", + "description": "Digest is the hash digest of the file hosted at the URL" + } + }, + "type": "object", + "required": [ + "url", + "digest" + ] + }, + "PythonPdmLockEntry": { + "properties": { + "summary": { + "type": "string", + "description": "Summary provides a description of the package" + }, + "files": { + "items": { + "$ref": "#/$defs/PythonPdmFileEntry" + }, + "type": "array", + "description": "Files are the package files with their paths and hash digests (for the base package without extras)" + }, + "marker": { + "type": "string", + "description": "Marker is the \"environment\" --conditional expressions that determine whether a package should be installed based on the runtime environment" + }, + "requiresPython": { + "type": "string", + "description": "RequiresPython specifies the Python version requirement (e.g., \"\u003e=3.6\")." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the dependency specifications for the base package (without extras)" + }, + "extras": { + "items": { + "$ref": "#/$defs/PythonPdmLockExtraVariant" + }, + "type": "array", + "description": "Extras contains variants for different extras combinations (PDM may have multiple entries per package)" + } + }, + "type": "object", + "required": [ + "summary", + "files" + ], + "description": "PythonPdmLockEntry represents a single package entry within a pdm.lock file." + }, + "PythonPdmLockExtraVariant": { + "properties": { + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional extras enabled for this variant (e.g., [\"toml\"], [\"dev\"], or [\"toml\", \"dev\"])" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the dependencies specific to this extras variant" + }, + "files": { + "items": { + "$ref": "#/$defs/PythonPdmFileEntry" + }, + "type": "array", + "description": "Files are the package files specific to this variant (only populated if different from base)" + }, + "marker": { + "type": "string", + "description": "Marker is the environment conditional expression for this variant (e.g., \"python_version \u003c \\\"3.11\\\"\")" + } + }, + "type": "object", + "required": [ + "extras" + ], + "description": "PythonPdmLockExtraVariant represents a specific extras combination variant within a PDM lock file." + }, + "PythonPipRequirementsEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name from the requirements file." + }, + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional features to install from the package (e.g., package[dev,test])." + }, + "versionConstraint": { + "type": "string", + "description": "VersionConstraint specifies version requirements (e.g., \"\u003e=1.0,\u003c2.0\")." + }, + "url": { + "type": "string", + "description": "URL is the direct download URL or VCS URL if specified instead of a PyPI package." + }, + "markers": { + "type": "string", + "description": "Markers are environment marker expressions for conditional installation (e.g., \"python_version \u003e= '3.8'\")." + } + }, + "type": "object", + "required": [ + "name", + "versionConstraint" + ], + "description": "PythonRequirementsEntry represents a single entry within a [*-]requirements.txt file." + }, + "PythonPipfileLockEntry": { + "properties": { + "hashes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Hashes are the package file hash values in the format \"algorithm:digest\" for integrity verification." + }, + "index": { + "type": "string", + "description": "Index is the PyPI index name where the package should be fetched from." + } + }, + "type": "object", + "required": [ + "hashes", + "index" + ], + "description": "PythonPipfileLockEntry represents a single package entry within a Pipfile.lock file." + }, + "PythonPoetryLockDependencyEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the dependency package name." + }, + "version": { + "type": "string", + "description": "Version is the locked version or version constraint for the dependency." + }, + "optional": { + "type": "boolean", + "description": "Optional indicates whether this dependency is optional (only needed for certain extras)." + }, + "markers": { + "type": "string", + "description": "Markers are environment marker expressions that conditionally enable the dependency (e.g., \"python_version \u003e= '3.8'\")." + }, + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional feature names from the dependency that should be installed." + } + }, + "type": "object", + "required": [ + "name", + "version", + "optional" + ], + "description": "PythonPoetryLockDependencyEntry represents a single dependency entry within a Poetry lock file." + }, + "PythonPoetryLockEntry": { + "properties": { + "index": { + "type": "string", + "description": "Index is the package repository name where the package should be fetched from." + }, + "dependencies": { + "items": { + "$ref": "#/$defs/PythonPoetryLockDependencyEntry" + }, + "type": "array", + "description": "Dependencies are the package's runtime dependencies with version constraints." + }, + "extras": { + "items": { + "$ref": "#/$defs/PythonPoetryLockExtraEntry" + }, + "type": "array", + "description": "Extras are optional feature groups that include additional dependencies." + } + }, + "type": "object", + "required": [ + "index", + "dependencies" + ], + "description": "PythonPoetryLockEntry represents a single package entry within a Pipfile.lock file." + }, + "PythonPoetryLockExtraEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the optional feature name (e.g., \"dev\", \"test\")." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the package names required when this extra is installed." + } + }, + "type": "object", + "required": [ + "name", + "dependencies" + ], + "description": "PythonPoetryLockExtraEntry represents an optional feature group in a Poetry lock file." + }, + "PythonUvLockDependencyEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the dependency package name." + }, + "optional": { + "type": "boolean", + "description": "Optional indicates whether this dependency is optional (only needed for certain extras)." + }, + "markers": { + "type": "string", + "description": "Markers are environment marker expressions that conditionally enable the dependency (e.g., \"python_version \u003e= '3.8'\")." + }, + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional feature names from the dependency that should be installed." + } + }, + "type": "object", + "required": [ + "name", + "optional" + ], + "description": "PythonUvLockDependencyEntry represents a single dependency entry within a uv lock file." + }, + "PythonUvLockEntry": { + "properties": { + "index": { + "type": "string", + "description": "Index is the package repository name where the package should be fetched from." + }, + "dependencies": { + "items": { + "$ref": "#/$defs/PythonUvLockDependencyEntry" + }, + "type": "array", + "description": "Dependencies are the package's runtime dependencies with version constraints." + }, + "extras": { + "items": { + "$ref": "#/$defs/PythonUvLockExtraEntry" + }, + "type": "array", + "description": "Extras are optional feature groups that include additional dependencies." + } + }, + "type": "object", + "required": [ + "index", + "dependencies" + ], + "description": "PythonUvLockEntry represents a single package entry within a uv.lock file." + }, + "PythonUvLockExtraEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the optional feature name (e.g., \"dev\", \"test\")." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the package names required when this extra is installed." + } + }, + "type": "object", + "required": [ + "name", + "dependencies" + ], + "description": "PythonUvLockExtraEntry represents an optional feature group in a uv lock file." + }, + "RDescription": { + "properties": { + "title": { + "type": "string", + "description": "Title is short one-line package title" + }, + "description": { + "type": "string", + "description": "Description is detailed package description" + }, + "author": { + "type": "string", + "description": "Author is package author(s)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is current package maintainer" + }, + "url": { + "items": { + "type": "string" + }, + "type": "array", + "description": "URL is the list of related URLs" + }, + "repository": { + "type": "string", + "description": "Repository is CRAN or other repository name" + }, + "built": { + "type": "string", + "description": "Built is R version and platform this was built with" + }, + "needsCompilation": { + "type": "boolean", + "description": "NeedsCompilation is whether this package requires compilation" + }, + "imports": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Imports are the packages imported in the NAMESPACE" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the packages this package depends on" + }, + "suggests": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Suggests are the optional packages that extend functionality" + } + }, + "type": "object", + "description": "RDescription represents metadata from an R package DESCRIPTION file containing package information, dependencies, and author details." + }, + "Relationship": { + "properties": { + "parent": { + "type": "string", + "description": "Parent is the ID of the parent artifact in this relationship." + }, + "child": { + "type": "string", + "description": "Child is the ID of the child artifact in this relationship." + }, + "type": { + "type": "string", + "description": "Type is the relationship type (e.g., \"contains\", \"dependency-of\", \"ancestor-of\")." + }, + "metadata": { + "description": "Metadata contains additional relationship-specific metadata." + } + }, + "type": "object", + "required": [ + "parent", + "child", + "type" + ], + "description": "Relationship represents a directed relationship between two artifacts in the SBOM, such as package-contains-file or package-depends-on-package." + }, + "RpmArchive": { + "properties": { + "name": { + "type": "string", + "description": "Name is the RPM package name as found in the RPM database." + }, + "version": { + "type": "string", + "description": "Version is the upstream version of the package." + }, + "epoch": { + "oneOf": [ + { + "type": "integer", + "description": "Epoch is the version epoch used to force upgrade ordering (null if not set)." + }, + { + "type": "null" + } + ] + }, + "architecture": { + "type": "string", + "description": "Arch is the target CPU architecture (e.g., \"x86_64\", \"aarch64\", \"noarch\")." + }, + "release": { + "type": "string", + "description": "Release is the package release number or distribution-specific version suffix." + }, + "sourceRpm": { + "type": "string", + "description": "SourceRpm is the source RPM filename that was used to build this package." + }, + "signatures": { + "items": { + "$ref": "#/$defs/RpmSignature" + }, + "type": "array", + "description": "Signatures contains GPG signature metadata for package verification." + }, + "size": { + "type": "integer", + "description": "Size is the total installed size of the package in bytes." + }, + "vendor": { + "type": "string", + "description": "Vendor is the organization that packaged the software." + }, + "modularityLabel": { + "type": "string", + "description": "ModularityLabel identifies the module stream for modular RPM packages (e.g., \"nodejs:12:20200101\")." + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides lists the virtual packages and capabilities this package provides." + }, + "requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Requires lists the dependencies required by this package." + }, + "files": { + "items": { + "$ref": "#/$defs/RpmFileRecord" + }, + "type": "array", + "description": "Files are the file records for all files owned by this package." + } + }, + "type": "object", + "required": [ + "name", + "version", + "epoch", + "architecture", + "release", + "sourceRpm", + "size", + "vendor", + "files" + ], + "description": "RpmArchive represents package metadata extracted directly from a .rpm archive file, containing the same information as an RPM database entry." + }, + "RpmDbEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the RPM package name as found in the RPM database." + }, + "version": { + "type": "string", + "description": "Version is the upstream version of the package." + }, + "epoch": { + "oneOf": [ + { + "type": "integer", + "description": "Epoch is the version epoch used to force upgrade ordering (null if not set)." + }, + { + "type": "null" + } + ] + }, + "architecture": { + "type": "string", + "description": "Arch is the target CPU architecture (e.g., \"x86_64\", \"aarch64\", \"noarch\")." + }, + "release": { + "type": "string", + "description": "Release is the package release number or distribution-specific version suffix." + }, + "sourceRpm": { + "type": "string", + "description": "SourceRpm is the source RPM filename that was used to build this package." + }, + "signatures": { + "items": { + "$ref": "#/$defs/RpmSignature" + }, + "type": "array", + "description": "Signatures contains GPG signature metadata for package verification." + }, + "size": { + "type": "integer", + "description": "Size is the total installed size of the package in bytes." + }, + "vendor": { + "type": "string", + "description": "Vendor is the organization that packaged the software." + }, + "modularityLabel": { + "type": "string", + "description": "ModularityLabel identifies the module stream for modular RPM packages (e.g., \"nodejs:12:20200101\")." + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides lists the virtual packages and capabilities this package provides." + }, + "requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Requires lists the dependencies required by this package." + }, + "files": { + "items": { + "$ref": "#/$defs/RpmFileRecord" + }, + "type": "array", + "description": "Files are the file records for all files owned by this package." + } + }, + "type": "object", + "required": [ + "name", + "version", + "epoch", + "architecture", + "release", + "sourceRpm", + "size", + "vendor", + "files" + ], + "description": "RpmDBEntry represents all captured data from a RPM DB package entry." + }, + "RpmFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the absolute file path where the file is installed." + }, + "mode": { + "type": "integer", + "description": "Mode is the file permission mode bits following Unix stat.h conventions." + }, + "size": { + "type": "integer", + "description": "Size is the file size in bytes." + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest contains the hash algorithm and value for file integrity verification." + }, + "userName": { + "type": "string", + "description": "UserName is the owner username for the file." + }, + "groupName": { + "type": "string", + "description": "GroupName is the group name for the file." + }, + "flags": { + "type": "string", + "description": "Flags indicates the file type (e.g., \"%config\", \"%doc\", \"%ghost\")." + } + }, + "type": "object", + "required": [ + "path", + "mode", + "size", + "digest", + "userName", + "groupName", + "flags" + ], + "description": "RpmFileRecord represents the file metadata for a single file attributed to a RPM package." + }, + "RpmSignature": { + "properties": { + "algo": { + "type": "string", + "description": "PublicKeyAlgorithm is the public key algorithm used for signing (e.g., \"RSA\")." + }, + "hash": { + "type": "string", + "description": "HashAlgorithm is the hash algorithm used for the signature (e.g., \"SHA256\")." + }, + "created": { + "type": "string", + "description": "Created is the timestamp when the signature was created." + }, + "issuer": { + "type": "string", + "description": "IssuerKeyID is the GPG key ID that created the signature." + } + }, + "type": "object", + "required": [ + "algo", + "hash", + "created", + "issuer" + ], + "description": "RpmSignature represents a GPG signature for an RPM package used for authenticity verification." + }, + "RubyGemspec": { + "properties": { + "name": { + "type": "string", + "description": "Name is gem name as specified in the gemspec" + }, + "version": { + "type": "string", + "description": "Version is gem version as specified in the gemspec" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files is logical list of files in the gem (NOT directly usable as filesystem paths. Example: bundler gem lists \"lib/bundler/vendor/uri/lib/uri/ldap.rb\" but actual path is \"/usr/local/lib/ruby/3.2.0/bundler/vendor/uri/lib/uri/ldap.rb\". Would need gem installation path, ruby version, and env vars like GEM_HOME to resolve actual paths.)" + }, + "authors": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Authors are the list of gem authors (stored as array regardless of using `author` or `authors` method in gemspec)" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "RubyGemspec represents all metadata parsed from the *.gemspec file" + }, + "RustCargoAuditEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is crate name as specified in audit section of the build binary" + }, + "version": { + "type": "string", + "description": "Version is crate version as specified in audit section of the build binary" + }, + "source": { + "type": "string", + "description": "Source is the source registry or repository where this crate came from" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source" + ], + "description": "RustBinaryAuditEntry represents Rust crate metadata extracted from a compiled binary using cargo-auditable format." + }, + "RustCargoLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is crate name as specified in Cargo.toml" + }, + "version": { + "type": "string", + "description": "Version is crate version as specified in Cargo.toml" + }, + "source": { + "type": "string", + "description": "Source is the source registry or repository URL in format \"registry+https://github.com/rust-lang/crates.io-index\" for registry packages" + }, + "checksum": { + "type": "string", + "description": "Checksum is content checksum for registry packages only (hexadecimal string). Cargo doesn't require or include checksums for git dependencies. Used to detect MITM attacks by verifying downloaded crate matches lockfile checksum." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the list of dependencies with version constraints" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source", + "checksum", + "dependencies" + ], + "description": "RustCargoLockEntry represents a locked dependency from a Cargo.lock file with precise version and checksum information." + }, + "SafetensorsModelInfo": { + "properties": { + "format": { + "type": "string", + "description": "Format is the source format label (always \"safetensors\" for this metadata type).\nPresent because the Docker AI model config blob carries an explicit format field" + }, + "architecture": { + "type": "string", + "description": "Architecture is the model architecture (e.g., \"LlamaForCausalLM\",\n\"Qwen3MoeForConditionalGeneration\"). It is not present in the SafeTensors\nheader itself; it is enriched from the companion config.json\n\"architectures\" array when one is found alongside the model." + }, + "quantization": { + "type": "string", + "description": "Quantization describes tensor precision (e.g., \"BF16\", \"F16\", \"F32\", \"INT8\")." + }, + "parameters": { + "type": "integer", + "description": "Parameters is the total number of model parameters, computed from the tensor\nshapes in the SafeTensors header(s). For a sharded model it is the sum across\nevery shard." + }, + "tensorCount": { + "type": "integer", + "description": "TensorCount is the number of tensor entries in the file header." + }, + "totalSize": { + "type": "string", + "description": "TotalSize is the total byte size of tensor data across all shards when known\n(from the Docker AI model config \"size\" field)." + }, + "shardCount": { + "type": "integer", + "description": "ShardCount is the number of .safetensors shards for a sharded model (1 for a\nsingle-file model)." + }, + "userMetadata": { + "$ref": "#/$defs/KeyValues", + "description": "UserMetadata is the optional \"__metadata__\" map from a .safetensors file header\n(string-to-string key/values set by the producer)." + }, + "metadataHash": { + "type": "string", + "description": "MetadataHash is an xxhash over the on-disk SafeTensors header (sorted tensor\nentries + __metadata__). It is derived ONLY from the safetensors file bytes." + }, + "parts": { + "items": { + "$ref": "#/$defs/SafetensorsModelInfo" + }, + "type": "array", + "description": "Parts contains metadata from additional SafeTensors shards or OCI layers that\nwere merged into this package during post-processing." + } + }, + "type": "object", + "description": "SafeTensorsModelInfo holds the model details extracted from SafeTensors content." + }, + "Schema": { + "properties": { + "version": { + "type": "string", + "description": "Version is the JSON schema version for this document format." + }, + "url": { + "type": "string", + "description": "URL is the URL to the JSON schema definition document." + } + }, + "type": "object", + "required": [ + "version", + "url" + ], + "description": "Schema specifies the JSON schema version and URL reference that defines the structure and validation rules for this document format." + }, + "SnapEntry": { + "properties": { + "snapType": { + "type": "string", + "description": "SnapType indicates the snap type (base, kernel, app, gadget, or snapd)." + }, + "base": { + "type": "string", + "description": "Base is the base snap name that this snap depends on (e.g., \"core20\", \"core22\")." + }, + "snapName": { + "type": "string", + "description": "SnapName is the snap package name." + }, + "snapVersion": { + "type": "string", + "description": "SnapVersion is the snap package version." + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture (e.g., \"amd64\", \"arm64\")." + } + }, + "type": "object", + "required": [ + "snapType", + "base", + "snapName", + "snapVersion", + "architecture" + ], + "description": "SnapEntry represents metadata for a Snap package extracted from snap.yaml or snapcraft.yaml files." + }, + "Source": { + "properties": { + "id": { + "type": "string", + "description": "ID is a unique identifier for the analyzed source artifact." + }, + "name": { + "type": "string", + "description": "Name is the name of the analyzed artifact (e.g., image name, directory path)." + }, + "version": { + "type": "string", + "description": "Version is the version of the analyzed artifact (e.g., image tag)." + }, + "supplier": { + "type": "string", + "description": "Supplier is supplier information, which can be user-provided for NTIA minimum elements compliance." + }, + "type": { + "type": "string", + "description": "Type is the source type (e.g., \"image\", \"directory\", \"file\")." + }, + "metadata": { + "description": "Metadata contains additional source-specific metadata." + } + }, + "type": "object", + "required": [ + "id", + "name", + "version", + "type", + "metadata" + ], + "description": "Source represents the artifact that was analyzed to generate this SBOM, such as a container image, directory, or file archive." + }, + "SwiftPackageManagerLockEntry": { + "properties": { + "revision": { + "type": "string", + "description": "Revision is git commit hash of the resolved package" + } + }, + "type": "object", + "required": [ + "revision" + ], + "description": "SwiftPackageManagerResolvedEntry represents a resolved dependency from a Package.resolved file with its locked version and source location." + }, + "SwiplpackPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the .toml file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the .toml file" + }, + "author": { + "type": "string", + "description": "Author is author name" + }, + "authorEmail": { + "type": "string", + "description": "AuthorEmail is author email address" + }, + "packager": { + "type": "string", + "description": "Packager is packager name (if different from author)" + }, + "packagerEmail": { + "type": "string", + "description": "PackagerEmail is packager email address" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the list of required dependencies" + } + }, + "type": "object", + "required": [ + "name", + "version", + "author", + "authorEmail", + "packager", + "packagerEmail", + "homepage", + "dependencies" + ], + "description": "SwiplPackEntry represents a SWI-Prolog package from the pack system with metadata about the package and its dependencies." + }, + "TerraformLockProviderEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the provider source address (e.g., \"registry.terraform.io/hashicorp/aws\")." + }, + "constraints": { + "type": "string", + "description": "Constraints specifies the version constraints for the provider (e.g., \"~\u003e 4.0\")." + }, + "version": { + "type": "string", + "description": "Version is the locked provider version selected during terraform init." + }, + "hashes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Hashes are cryptographic checksums for the provider plugin archives across different platforms." + } + }, + "type": "object", + "required": [ + "url", + "constraints", + "version", + "hashes" + ], + "description": "TerraformLockProviderEntry represents a single provider entry in a Terraform dependency lock file (.terraform.lock.hcl)." + }, + "VcpkgManifest": { + "properties": { + "description": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Description is the human-readable description of the package (may be a single string or a list of paragraphs in the manifest)." + }, + "documentation": { + "type": "string", + "description": "Documentation is the URL to the package's documentation." + }, + "full-version": { + "type": "string", + "description": "FullVersion is the complete version string including the port-version suffix (e.g. \"1.2.3#2\")." + }, + "version": { + "type": "string", + "description": "Version is the upstream package version without the port-version suffix (e.g. \"1.2.3\")." + }, + "port-version": { + "type": "integer", + "description": "PortVersion is the vcpkg-specific packaging revision for a given upstream version." + }, + "maintainers": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Maintainers are the people responsible for maintaining the vcpkg port." + }, + "name": { + "type": "string", + "description": "Name is the package name as declared in the manifest." + }, + "supports": { + "type": "string", + "description": "Supports is the platform expression describing which triplets the package can be built for (e.g. \"!windows\")." + }, + "registry": { + "$ref": "#/$defs/VcpkgRegistryEntry", + "description": "Registry indicates where the package definition came from." + }, + "triplet": { + "type": "string", + "description": "Triplet is the build target discovered from the build folder (e.g. \"x64-linux\")." + } + }, + "type": "object", + "required": [ + "full-version", + "version", + "port-version", + "name" + ], + "description": "VcpkgManifest summarizes the data found in a vcpkg manifest (vcpkg.json) relevant to a single vcpkg package." + }, + "VcpkgRegistryEntry": { + "properties": { + "baseline": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "packages": { + "items": { + "type": "string" + }, + "type": "array" + }, + "path": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "repository": { + "type": "string" + } + }, + "type": "object", + "required": [ + "kind" + ], + "description": "Matches definition of Vcpkg \"Registry\"." + }, + "WordpressPluginEntry": { + "properties": { + "pluginInstallDirectory": { + "type": "string", + "description": "PluginInstallDirectory is directory name where the plugin is installed" + }, + "author": { + "type": "string", + "description": "Author is plugin author name" + }, + "authorUri": { + "type": "string", + "description": "AuthorURI is author's website URL" + } + }, + "type": "object", + "required": [ + "pluginInstallDirectory" + ], + "description": "WordpressPluginEntry represents all metadata parsed from the wordpress plugin file" + }, + "cpes": { + "items": { + "$ref": "#/$defs/CPE" + }, + "type": "array" + }, + "licenses": { + "items": { + "$ref": "#/$defs/License" + }, + "type": "array" + } + } +} diff --git a/schema/json/schema-16.1.11.json b/schema/json/schema-16.1.11.json new file mode 100644 index 000000000..74021283f --- /dev/null +++ b/schema/json/schema-16.1.11.json @@ -0,0 +1,4583 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "anchore.io/schema/syft/json/16.1.11/document", + "$ref": "#/$defs/Document", + "$defs": { + "AlpmDbEntry": { + "properties": { + "basepackage": { + "type": "string", + "description": "BasePackage is the base package name this package was built from (source package in Arch build system)" + }, + "package": { + "type": "string", + "description": "Package is the package name as found in the desc file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the desc file" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture as defined in Arch architecture spec (e.g. x86_64, aarch64, or \"any\" for arch-independent packages)" + }, + "size": { + "type": "integer", + "description": "Size is the installed size in bytes" + }, + "packager": { + "type": "string", + "description": "Packager is the name and email of the person who packaged this (RFC822 format)" + }, + "url": { + "type": "string", + "description": "URL is the upstream project URL" + }, + "validation": { + "type": "string", + "description": "Validation is the validation method used for package integrity (e.g. pgp signature, sha256 checksum)" + }, + "reason": { + "type": "integer", + "description": "Reason is the installation reason tracked by pacman (0=explicitly installed by user, 1=installed as dependency)" + }, + "files": { + "items": { + "$ref": "#/$defs/AlpmFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + }, + "backup": { + "items": { + "$ref": "#/$defs/AlpmFileRecord" + }, + "type": "array", + "description": "Backup is the list of configuration files that pacman backs up before upgrades" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are virtual packages provided by this package (allows other packages to depend on capabilities rather than specific packages)" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the runtime dependencies required by this package" + } + }, + "type": "object", + "required": [ + "basepackage", + "package", + "version", + "description", + "architecture", + "size", + "packager", + "url", + "validation", + "reason", + "files", + "backup" + ], + "description": "AlpmDBEntry is a struct that represents the package data stored in the pacman flat-file stores for arch linux." + }, + "AlpmFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "type": { + "type": "string", + "description": "Type is the file type (e.g. regular file, directory, symlink)" + }, + "uid": { + "type": "string", + "description": "UID is the file owner user ID as recorded by pacman" + }, + "gid": { + "type": "string", + "description": "GID is the file owner group ID as recorded by pacman" + }, + "time": { + "type": "string", + "format": "date-time", + "description": "Time is the file modification timestamp" + }, + "size": { + "type": "string", + "description": "Size is the file size in bytes" + }, + "link": { + "type": "string", + "description": "Link is the symlink target path if this is a symlink" + }, + "digest": { + "items": { + "$ref": "#/$defs/Digest" + }, + "type": "array", + "description": "Digests contains file content hashes for integrity verification" + } + }, + "type": "object", + "description": "AlpmFileRecord represents a single file entry within an Arch Linux package with its associated metadata tracked by pacman." + }, + "ApkDbEntry": { + "properties": { + "package": { + "type": "string", + "description": "Package is the package name as found in the installed file" + }, + "originPackage": { + "type": "string", + "description": "OriginPackage is the original source package name this binary was built from (used to track which aport/source built this)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is the package maintainer name and email" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the installed file" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture" + }, + "url": { + "type": "string", + "description": "URL is the upstream project URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "size": { + "type": "integer", + "description": "Size is the package archive size in bytes (.apk file size)" + }, + "installedSize": { + "type": "integer", + "description": "InstalledSize is the total size of installed files in bytes" + }, + "pullDependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the runtime dependencies required by this package" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are virtual packages provided by this package (for capability-based dependencies)" + }, + "pullChecksum": { + "type": "string", + "description": "Checksum is the package content checksum for integrity verification" + }, + "gitCommitOfApkPort": { + "type": "string", + "description": "GitCommit is the git commit hash of the APK port definition in Alpine's aports repository" + }, + "files": { + "items": { + "$ref": "#/$defs/ApkFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + } + }, + "type": "object", + "required": [ + "package", + "originPackage", + "maintainer", + "version", + "architecture", + "url", + "description", + "size", + "installedSize", + "pullDependencies", + "provides", + "pullChecksum", + "gitCommitOfApkPort", + "files" + ], + "description": "ApkDBEntry represents all captured data for the alpine linux package manager flat-file store." + }, + "ApkFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "ownerUid": { + "type": "string", + "description": "OwnerUID is the file owner user ID" + }, + "ownerGid": { + "type": "string", + "description": "OwnerGID is the file owner group ID" + }, + "permissions": { + "type": "string", + "description": "Permissions is the file permission mode string (e.g. \"0755\", \"0644\")" + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest is the file content hash for integrity verification" + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records)." + }, + "AppleAppBundleEntry": { + "properties": { + "bundleIdentifier": { + "type": "string", + "description": "BundleIdentifier is the unique identifier for the bundle (e.g. \"com.apple.Safari\")" + }, + "name": { + "type": "string", + "description": "Name is the short name of the bundle" + }, + "displayName": { + "type": "string", + "description": "DisplayName is the user-facing name of the bundle" + }, + "executable": { + "type": "string", + "description": "Executable is the name of the executable within the bundle" + }, + "shortVersion": { + "type": "string", + "description": "ShortVersion is the release (marketing) version of the bundle" + }, + "version": { + "type": "string", + "description": "Version is the build version of the bundle, which often differs from the short version" + }, + "packageType": { + "type": "string", + "description": "PackageType is the four-letter type code (e.g. \"APPL\" for apps, \"FMWK\" for frameworks, \"BNDL\" for bundles)" + }, + "supportedPlatforms": { + "items": { + "type": "string" + }, + "type": "array", + "description": "SupportedPlatforms lists the platforms the bundle targets (e.g. \"MacOSX\", \"iPhoneOS\")" + }, + "minimumSystemVersion": { + "type": "string", + "description": "MinimumSystemVersion is the minimum macOS version required to run the bundle" + }, + "minimumOSVersion": { + "type": "string", + "description": "MinimumOSVersion is the minimum OS version required for non-macOS platforms (e.g. iOS)" + }, + "copyright": { + "type": "string", + "description": "Copyright is the human-readable copyright notice for the bundle" + }, + "platformName": { + "type": "string", + "description": "PlatformName is the platform name of the SDK used to build the bundle (e.g. \"macosx\")" + }, + "sdkName": { + "type": "string", + "description": "SDKName is the name of the SDK used to build the bundle (e.g. \"macosx14.0\")" + } + }, + "type": "object", + "description": "AppleAppBundleEntry represents metadata about an Apple application bundle (CFBundle) extracted from Info.plist files." + }, + "BinarySignature": { + "properties": { + "matches": { + "items": { + "$ref": "#/$defs/ClassifierMatch" + }, + "type": "array" + } + }, + "type": "object", + "required": [ + "matches" + ], + "description": "BinarySignature represents a set of matched values within a binary file." + }, + "BitnamiSbomEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the Bitnami SPDX file" + }, + "arch": { + "type": "string", + "description": "Architecture is the target CPU architecture (amd64 or arm64 in Bitnami images)" + }, + "distro": { + "type": "string", + "description": "Distro is the distribution name this package is for (base OS like debian, ubuntu, etc.)" + }, + "revision": { + "type": "string", + "description": "Revision is the Bitnami-specific package revision number (incremented for Bitnami rebuilds of same upstream version)" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the Bitnami SPDX file" + }, + "path": { + "type": "string", + "description": "Path is the installation path in the filesystem where the package is located" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files are the file paths owned by this package (tracked via SPDX relationships)" + } + }, + "type": "object", + "required": [ + "name", + "arch", + "distro", + "revision", + "version", + "path", + "files" + ], + "description": "BitnamiSBOMEntry represents all captured data from Bitnami packages described in Bitnami' SPDX files." + }, + "CConanFileEntry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConanfileEntry represents a single \"Requires\" entry from a conanfile.txt." + }, + "CConanInfoEntry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + }, + "package_id": { + "type": "string", + "description": "PackageID is a unique package variant identifier" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConaninfoEntry represents a single \"full_requires\" entry from a conaninfo.txt." + }, + "CConanLockEntry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + }, + "package_id": { + "type": "string", + "description": "PackageID is a unique package variant identifier computed from settings/options (static hash in Conan 1.x, can have collisions with complex dependency graphs)" + }, + "prev": { + "type": "string", + "description": "Prev is the previous lock entry reference for versioning" + }, + "requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Requires are the runtime package dependencies" + }, + "build_requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "BuildRequires are the build-time dependencies (e.g. cmake, compilers)" + }, + "py_requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "PythonRequires are the Python dependencies needed for Conan recipes" + }, + "options": { + "$ref": "#/$defs/KeyValues", + "description": "Options are package configuration options as key-value pairs (e.g. shared=True, fPIC=True)" + }, + "path": { + "type": "string", + "description": "Path is the filesystem path to the package in Conan cache" + }, + "context": { + "type": "string", + "description": "Context is the build context information" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConanV1LockEntry represents a single \"node\" entry from a conan.lock V1 file." + }, + "CConanLockV2Entry": { + "properties": { + "ref": { + "type": "string", + "description": "Ref is the package reference string in format name/version@user/channel" + }, + "packageID": { + "type": "string", + "description": "PackageID is a unique package variant identifier (dynamic in Conan 2.0, more accurate than V1)" + }, + "username": { + "type": "string", + "description": "Username is the Conan user/organization name" + }, + "channel": { + "type": "string", + "description": "Channel is the Conan channel name indicating stability/purpose (e.g. stable, testing, experimental)" + }, + "recipeRevision": { + "type": "string", + "description": "RecipeRevision is a git-like revision hash (RREV) of the recipe" + }, + "packageRevision": { + "type": "string", + "description": "PackageRevision is a git-like revision hash of the built binary package" + }, + "timestamp": { + "type": "string", + "description": "TimeStamp is when this package was built/locked" + } + }, + "type": "object", + "required": [ + "ref" + ], + "description": "ConanV2LockEntry represents a single \"node\" entry from a conan.lock V2 file." + }, + "CPE": { + "properties": { + "cpe": { + "type": "string", + "description": "Value is the CPE string identifier." + }, + "source": { + "type": "string", + "description": "Source is the source where this CPE was obtained or generated from." + } + }, + "type": "object", + "required": [ + "cpe" + ], + "description": "CPE represents a Common Platform Enumeration identifier used for matching packages to known vulnerabilities in security databases." + }, + "ClassifierMatch": { + "properties": { + "classifier": { + "type": "string" + }, + "location": { + "$ref": "#/$defs/Location" + } + }, + "type": "object", + "required": [ + "classifier", + "location" + ], + "description": "ClassifierMatch represents a single matched value within a binary file and the \"class\" name the search pattern represents." + }, + "CocoaPodfileLockEntry": { + "properties": { + "checksum": { + "type": "string", + "description": "Checksum is the SHA-1 hash of the podspec file for integrity verification (generated via `pod ipc spec ... | openssl sha1`), ensuring all team members use the same pod specification version" + } + }, + "type": "object", + "required": [ + "checksum" + ], + "description": "CocoaPodfileLockEntry represents a single entry from the \"Pods\" section of a Podfile.lock file." + }, + "CondaLink": { + "properties": { + "source": { + "type": "string", + "description": "Source is the original path where the package was extracted from cache." + }, + "type": { + "type": "integer", + "description": "Type indicates the link type (1 for hard link, 2 for soft link, 3 for copy)." + } + }, + "type": "object", + "required": [ + "source", + "type" + ], + "description": "CondaLink represents link metadata from a Conda package's link.json file describing package installation source." + }, + "CondaMetadataEntry": { + "properties": { + "arch": { + "type": "string", + "description": "Arch is the target CPU architecture for the package (e.g., \"arm64\", \"x86_64\")." + }, + "name": { + "type": "string", + "description": "Name is the package name as found in the conda-meta JSON file." + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the conda-meta JSON file." + }, + "build": { + "type": "string", + "description": "Build is the build string identifier (e.g., \"h90dfc92_1014\")." + }, + "build_number": { + "type": "integer", + "description": "BuildNumber is the sequential build number for this version." + }, + "channel": { + "type": "string", + "description": "Channel is the Conda channel URL where the package was retrieved from." + }, + "subdir": { + "type": "string", + "description": "Subdir is the subdirectory within the channel (e.g., \"osx-arm64\", \"linux-64\")." + }, + "noarch": { + "type": "string", + "description": "Noarch indicates if the package is platform-independent (e.g., \"python\", \"generic\")." + }, + "license": { + "type": "string", + "description": "License is the package license identifier." + }, + "license_family": { + "type": "string", + "description": "LicenseFamily is the general license category (e.g., \"MIT\", \"Apache\", \"GPL\")." + }, + "md5": { + "type": "string", + "description": "MD5 is the MD5 hash of the package archive." + }, + "sha256": { + "type": "string", + "description": "SHA256 is the SHA-256 hash of the package archive." + }, + "size": { + "type": "integer", + "description": "Size is the package archive size in bytes." + }, + "timestamp": { + "type": "integer", + "description": "Timestamp is the Unix timestamp when the package was built." + }, + "fn": { + "type": "string", + "description": "Filename is the original package archive filename (e.g., \"zlib-1.2.11-h90dfc92_1014.tar.bz2\")." + }, + "url": { + "type": "string", + "description": "URL is the full download URL for the package archive." + }, + "extracted_package_dir": { + "type": "string", + "description": "ExtractedPackageDir is the local cache directory where the package was extracted." + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends is the list of runtime dependencies with version constraints." + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files is the list of files installed by this package." + }, + "paths_data": { + "$ref": "#/$defs/CondaPathsData", + "description": "PathsData contains detailed file metadata from the paths.json file." + }, + "link": { + "$ref": "#/$defs/CondaLink", + "description": "Link contains installation source metadata from the link.json file." + } + }, + "type": "object", + "required": [ + "name", + "version", + "build", + "build_number" + ], + "description": "CondaMetaPackage represents metadata for a Conda package extracted from the conda-meta/*.json files." + }, + "CondaPathData": { + "properties": { + "_path": { + "type": "string", + "description": "Path is the file path relative to the Conda environment root." + }, + "path_type": { + "type": "string", + "description": "PathType indicates the link type for the file (e.g., \"hardlink\", \"softlink\", \"directory\")." + }, + "sha256": { + "type": "string", + "description": "SHA256 is the SHA-256 hash of the file contents." + }, + "sha256_in_prefix": { + "type": "string", + "description": "SHA256InPrefix is the SHA-256 hash of the file after prefix replacement during installation." + }, + "size_in_bytes": { + "type": "integer", + "description": "SizeInBytes is the file size in bytes." + } + }, + "type": "object", + "required": [ + "_path", + "path_type", + "sha256", + "sha256_in_prefix", + "size_in_bytes" + ], + "description": "CondaPathData represents metadata for a single file within a Conda package from the paths.json file." + }, + "CondaPathsData": { + "properties": { + "paths_version": { + "type": "integer", + "description": "PathsVersion is the schema version of the paths data format." + }, + "paths": { + "items": { + "$ref": "#/$defs/CondaPathData" + }, + "type": "array", + "description": "Paths is the list of file metadata entries for all files in the package." + } + }, + "type": "object", + "required": [ + "paths_version", + "paths" + ], + "description": "CondaPathsData represents the paths.json file structure from a Conda package containing file metadata." + }, + "Coordinates": { + "properties": { + "path": { + "type": "string", + "description": "RealPath is the canonical absolute form of the path accessed (all symbolic links have been followed and relative path components like '.' and '..' have been removed)." + }, + "layerID": { + "type": "string", + "description": "FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. A file found inside an archive carries the FileSystemID of the filesystem the archive itself was found in (a layer digest, or blank), inherited unchanged down the nesting chain; the nesting chain is carried by ArchivePath instead." + }, + "archivePath": { + "type": "string", + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, of every archive traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). It is blank for files not found within an archive. It disambiguates identically-named files residing in different archives on the same filesystem." + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "Coordinates contains the minimal information needed to describe how to find a file within any possible source object (e.g." + }, + "DartPubspec": { + "properties": { + "homepage": { + "type": "string", + "description": "Homepage is the package homepage URL" + }, + "repository": { + "type": "string", + "description": "Repository is the source code repository URL" + }, + "documentation": { + "type": "string", + "description": "Documentation is the documentation site URL" + }, + "publish_to": { + "type": "string", + "description": "PublishTo is the package repository to publish to, or \"none\" to prevent accidental publishing" + }, + "environment": { + "$ref": "#/$defs/DartPubspecEnvironment", + "description": "Environment is SDK version constraints for Dart and Flutter" + }, + "platforms": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Platforms are the supported platforms (Android, iOS, web, etc.)" + }, + "ignored_advisories": { + "items": { + "type": "string" + }, + "type": "array", + "description": "IgnoredAdvisories are the security advisories to explicitly ignore for this package" + } + }, + "type": "object", + "description": "DartPubspec is a struct that represents a package described in a pubspec.yaml file" + }, + "DartPubspecEnvironment": { + "properties": { + "sdk": { + "type": "string", + "description": "SDK is the Dart SDK version constraint (e.g. \"\u003e=2.12.0 \u003c3.0.0\")" + }, + "flutter": { + "type": "string", + "description": "Flutter is the Flutter SDK version constraint if this is a Flutter package" + } + }, + "type": "object", + "description": "DartPubspecEnvironment represents SDK version constraints from the environment section of pubspec.yaml." + }, + "DartPubspecLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the pubspec.lock file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the pubspec.lock file" + }, + "hosted_url": { + "type": "string", + "description": "HostedURL is the URL of the package repository for hosted packages (typically pub.dev, but can be custom repository identified by hosted-url). When PUB_HOSTED_URL environment variable changes, lockfile tracks the source." + }, + "vcs_url": { + "type": "string", + "description": "VcsURL is the URL of the VCS repository for git/path dependencies (for packages fetched from version control systems like Git)" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "DartPubspecLockEntry is a struct that represents a single entry found in the \"packages\" section in a Dart pubspec.lock file." + }, + "DenoLockEntry": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is the crpto hash of the package content for verification" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies is the list of package specifiers that this package depends on" + } + }, + "type": "object", + "required": [ + "integrity", + "dependencies" + ], + "description": "DenoLockEntry is a struct that rep a single entry found in the \"packages\" section of a Deno deno.lock file" + }, + "DenoRemoteLockEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the remote URL from which the module fetcef" + }, + "integrity": { + "type": "string", + "description": "Integrity is the crpto hash of the package content for verification" + } + }, + "type": "object", + "required": [ + "url", + "integrity" + ], + "description": "DenoRemoteLockEntry is a struct that rep a single entry found in the \"remote\" section of a Deno deno.lock file" + }, + "Descriptor": { + "properties": { + "name": { + "type": "string", + "description": "Name is the name of the tool that generated this SBOM (e.g., \"syft\")." + }, + "version": { + "type": "string", + "description": "Version is the version of the tool that generated this SBOM." + }, + "configuration": { + "description": "Configuration contains the tool configuration used during SBOM generation." + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "Descriptor identifies the tool that generated this SBOM document, including its name, version, and configuration used during catalog generation." + }, + "Digest": { + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm specifies the hash algorithm used (e.g., \"sha256\", \"md5\")." + }, + "value": { + "type": "string", + "description": "Value is the hexadecimal string representation of the hash." + } + }, + "type": "object", + "required": [ + "algorithm", + "value" + ], + "description": "Digest represents a cryptographic hash of file contents." + }, + "Document": { + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/Package" + }, + "type": "array", + "description": "Artifacts is the list of packages discovered and placed into the catalog" + }, + "artifactRelationships": { + "items": { + "$ref": "#/$defs/Relationship" + }, + "type": "array" + }, + "files": { + "items": { + "$ref": "#/$defs/File" + }, + "type": "array", + "description": "note: must have omitempty" + }, + "source": { + "$ref": "#/$defs/Source", + "description": "Source represents the original object that was cataloged" + }, + "distro": { + "$ref": "#/$defs/LinuxRelease", + "description": "Distro represents the Linux distribution that was detected from the source" + }, + "descriptor": { + "$ref": "#/$defs/Descriptor", + "description": "Descriptor is a block containing self-describing information about syft" + }, + "schema": { + "$ref": "#/$defs/Schema", + "description": "Schema is a block reserved for defining the version for the shape of this JSON document and where to find the schema document to validate the shape" + } + }, + "type": "object", + "required": [ + "artifacts", + "artifactRelationships", + "source", + "distro", + "descriptor", + "schema" + ], + "description": "Document represents the syft cataloging findings as a JSON document" + }, + "DotnetDepsEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the deps.json file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the deps.json file" + }, + "path": { + "type": "string", + "description": "Path is the relative path to the package within the deps structure (e.g. \"app.metrics/3.0.0\")" + }, + "sha512": { + "type": "string", + "description": "Sha512 is the SHA-512 hash of the NuGet package content WITHOUT the signed content for verification (won't match hash from NuGet API or manual calculation of .nupkg file)" + }, + "hashPath": { + "type": "string", + "description": "HashPath is the relative path to the .nupkg.sha512 hash file (e.g. \"app.metrics.3.0.0.nupkg.sha512\")" + }, + "type": { + "type": "string", + "description": "Type is type of entry could be package or project for internal refs" + }, + "executables": { + "additionalProperties": { + "$ref": "#/$defs/DotnetPortableExecutableEntry" + }, + "type": "object", + "description": "Executables are the map of .NET Portable Executable files within this package with their version resources" + } + }, + "type": "object", + "required": [ + "name", + "version", + "path", + "sha512", + "hashPath" + ], + "description": "DotnetDepsEntry is a struct that represents a single entry found in the \"libraries\" section in a .NET [*.]deps.json file." + }, + "DotnetPackagesLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the packages.lock.json file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the packages.lock.json file" + }, + "contentHash": { + "type": "string", + "description": "ContentHash is the hash of the package content for verification" + }, + "type": { + "type": "string", + "description": "Type is the dependency type indicating how this dependency was added (Direct=explicit in project file, Transitive=pulled in by another package, Project=project reference)" + } + }, + "type": "object", + "required": [ + "name", + "version", + "contentHash", + "type" + ], + "description": "DotnetPackagesLockEntry is a struct that represents a single entry found in the \"dependencies\" section in a .NET packages.lock.json file." + }, + "DotnetPortableExecutableEntry": { + "properties": { + "assemblyVersion": { + "type": "string", + "description": "AssemblyVersion is the .NET assembly version number (strong-named version)" + }, + "legalCopyright": { + "type": "string", + "description": "LegalCopyright is the copyright notice string" + }, + "comments": { + "type": "string", + "description": "Comments are additional comments or description embedded in PE resources" + }, + "internalName": { + "type": "string", + "description": "InternalName is the internal name of the file" + }, + "companyName": { + "type": "string", + "description": "CompanyName is the company that produced the file" + }, + "productName": { + "type": "string", + "description": "ProductName is the name of the product this file is part of" + }, + "productVersion": { + "type": "string", + "description": "ProductVersion is the version of the product (may differ from AssemblyVersion)" + } + }, + "type": "object", + "required": [ + "assemblyVersion", + "legalCopyright", + "companyName", + "productName", + "productVersion" + ], + "description": "DotnetPortableExecutableEntry is a struct that represents a single entry found within \"VersionResources\" section of a .NET Portable Executable binary file." + }, + "DpkgArchiveEntry": { + "properties": { + "package": { + "type": "string", + "description": "Package is the package name as found in the status file" + }, + "source": { + "type": "string", + "description": "Source is the source package name this binary was built from (one source can produce multiple binary packages)" + }, + "version": { + "type": "string", + "description": "Version is the binary package version as found in the status file" + }, + "sourceVersion": { + "type": "string", + "description": "SourceVersion is the source package version (may differ from binary version when binNMU rebuilds occur)" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target architecture per Debian spec (specific arch like amd64/arm64, wildcard like any, architecture-independent \"all\", or \"source\" for source packages)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is the package maintainer's name and email in RFC822 format (name must come first, then email in angle brackets)" + }, + "installedSize": { + "type": "integer", + "description": "InstalledSize is the total size of installed files in kilobytes" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are the virtual packages provided by this package (allows other packages to depend on capabilities. Can include versioned provides like \"libdigest-md5-perl (= 2.55.01)\")" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the packages required for this package to function (will not be installed unless these requirements are met, creates strict ordering constraint)" + }, + "preDepends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "PreDepends are the packages that must be installed and configured BEFORE even starting installation of this package (stronger than Depends, discouraged unless absolutely necessary as it adds strict constraints for apt)" + }, + "files": { + "items": { + "$ref": "#/$defs/DpkgFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + } + }, + "type": "object", + "required": [ + "package", + "source", + "version", + "sourceVersion", + "architecture", + "maintainer", + "installedSize", + "files" + ], + "description": "DpkgArchiveEntry represents package metadata extracted from a .deb archive file." + }, + "DpkgDbEntry": { + "properties": { + "package": { + "type": "string", + "description": "Package is the package name as found in the status file" + }, + "source": { + "type": "string", + "description": "Source is the source package name this binary was built from (one source can produce multiple binary packages)" + }, + "version": { + "type": "string", + "description": "Version is the binary package version as found in the status file" + }, + "sourceVersion": { + "type": "string", + "description": "SourceVersion is the source package version (may differ from binary version when binNMU rebuilds occur)" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target architecture per Debian spec (specific arch like amd64/arm64, wildcard like any, architecture-independent \"all\", or \"source\" for source packages)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is the package maintainer's name and email in RFC822 format (name must come first, then email in angle brackets)" + }, + "installedSize": { + "type": "integer", + "description": "InstalledSize is the total size of installed files in kilobytes" + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides are the virtual packages provided by this package (allows other packages to depend on capabilities. Can include versioned provides like \"libdigest-md5-perl (= 2.55.01)\")" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the packages required for this package to function (will not be installed unless these requirements are met, creates strict ordering constraint)" + }, + "preDepends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "PreDepends are the packages that must be installed and configured BEFORE even starting installation of this package (stronger than Depends, discouraged unless absolutely necessary as it adds strict constraints for apt)" + }, + "files": { + "items": { + "$ref": "#/$defs/DpkgFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package" + } + }, + "type": "object", + "required": [ + "package", + "source", + "version", + "sourceVersion", + "architecture", + "maintainer", + "installedSize", + "files" + ], + "description": "DpkgDBEntry represents all captured data for a Debian package DB entry; available fields are described at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section." + }, + "DpkgFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest is the file content hash (typically MD5 for dpkg compatibility with legacy systems)" + }, + "isConfigFile": { + "type": "boolean", + "description": "IsConfigFile is whether this file is marked as a configuration file (dpkg will preserve user modifications during upgrades)" + } + }, + "type": "object", + "required": [ + "path", + "isConfigFile" + ], + "description": "DpkgFileRecord represents a single file attributed to a debian package." + }, + "ELFSecurityFeatures": { + "properties": { + "symbolTableStripped": { + "type": "boolean", + "description": "SymbolTableStripped indicates whether debugging symbols have been removed." + }, + "stackCanary": { + "type": "boolean", + "description": "StackCanary indicates whether stack smashing protection is enabled." + }, + "nx": { + "type": "boolean", + "description": "NoExecutable indicates whether NX (no-execute) protection is enabled for the stack." + }, + "relRO": { + "type": "string", + "description": "RelocationReadOnly indicates the RELRO protection level." + }, + "pie": { + "type": "boolean", + "description": "PositionIndependentExecutable indicates whether the binary is compiled as PIE." + }, + "dso": { + "type": "boolean", + "description": "DynamicSharedObject indicates whether the binary is a shared library." + }, + "safeStack": { + "type": "boolean", + "description": "LlvmSafeStack represents a compiler-based security mechanism that separates the stack into a safe stack for storing return addresses and other critical data, and an unsafe stack for everything else, to mitigate stack-based memory corruption errors\nsee https://clang.llvm.org/docs/SafeStack.html" + }, + "cfi": { + "type": "boolean", + "description": "ControlFlowIntegrity represents runtime checks to ensure a program's control flow adheres to the legal paths determined at compile time, thus protecting against various types of control-flow hijacking attacks\nsee https://clang.llvm.org/docs/ControlFlowIntegrity.html" + }, + "fortify": { + "type": "boolean", + "description": "ClangFortifySource is a broad suite of extensions to libc aimed at catching misuses of common library functions\nsee https://android.googlesource.com/platform//bionic/+/d192dbecf0b2a371eb127c0871f77a9caf81c4d2/docs/clang_fortify_anatomy.md" + } + }, + "type": "object", + "required": [ + "symbolTableStripped", + "nx", + "relRO", + "pie", + "dso" + ], + "description": "ELFSecurityFeatures captures security hardening and protection mechanisms in ELF binaries." + }, + "ElfBinaryPackageNoteJsonPayload": { + "properties": { + "type": { + "type": "string", + "description": "Type is the type of the package (e.g. \"rpm\", \"deb\", \"apk\", etc.)" + }, + "architecture": { + "type": "string", + "description": "Architecture of the binary package (e.g. \"amd64\", \"arm\", etc.)" + }, + "osCPE": { + "type": "string", + "description": "OSCPE is a CPE name for the OS, typically corresponding to CPE_NAME in os-release (e.g. cpe:/o:fedoraproject:fedora:33)\n\nDeprecated: in Syft 2.0 the struct tag will be corrected to `osCpe` to match the systemd spec casing." + }, + "appCpe": { + "type": "string", + "description": "AppCpe is a CPE name for the upstream Application, as found in NVD CPE search (e.g. cpe:2.3:a:gnu:coreutils:5.0)" + }, + "os": { + "type": "string", + "description": "OS is the OS name, typically corresponding to ID in os-release (e.g. \"fedora\")" + }, + "osVersion": { + "type": "string", + "description": "osVersion is the version of the OS, typically corresponding to VERSION_ID in os-release (e.g. \"33\")" + }, + "system": { + "type": "string", + "description": "System is a context-specific name for the system that the binary package is intended to run on or a part of" + }, + "vendor": { + "type": "string", + "description": "Vendor is the individual or organization that produced the source code for the binary" + }, + "sourceRepo": { + "type": "string", + "description": "SourceRepo is the URL to the source repository for which the binary was built from" + }, + "commit": { + "type": "string", + "description": "Commit is the commit hash of the source repository for which the binary was built from" + } + }, + "type": "object", + "description": "ELFBinaryPackageNoteJSONPayload Represents metadata captured from the .note.package section of an ELF-formatted binary" + }, + "ElixirMixLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the mix.lock file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the mix.lock file" + }, + "pkgHash": { + "type": "string", + "description": "PkgHash is the outer checksum (SHA-256) of the entire Hex package tarball for integrity verification (preferred method, replaces deprecated inner checksum)" + }, + "pkgHashExt": { + "type": "string", + "description": "PkgHashExt is the extended package hash format (inner checksum is deprecated - SHA-256 of concatenated file contents excluding CHECKSUM file, now replaced by outer checksum)" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the names of the packages this entry depends on, as\ndeclared in the entry's dependency list within mix.lock. Used to derive\ndependency-of relationships between locked packages." + } + }, + "type": "object", + "required": [ + "name", + "version", + "pkgHash", + "pkgHashExt" + ], + "description": "ElixirMixLockEntry is a struct that represents a single entry in a mix.lock file" + }, + "ErlangRebarLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the rebar.lock file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the rebar.lock file" + }, + "pkgHash": { + "type": "string", + "description": "PkgHash is the outer checksum (SHA-256) of the entire Hex package tarball for integrity verification (preferred method over deprecated inner checksum)" + }, + "pkgHashExt": { + "type": "string", + "description": "PkgHashExt is the extended package hash format (inner checksum deprecated - was SHA-256 of concatenated file contents)" + } + }, + "type": "object", + "required": [ + "name", + "version", + "pkgHash", + "pkgHashExt" + ], + "description": "ErlangRebarLockEntry represents a single package entry from the \"deps\" section within an Erlang rebar.lock file." + }, + "Executable": { + "properties": { + "format": { + "type": "string", + "description": "Format denotes either ELF, Mach-O, or PE" + }, + "hasExports": { + "type": "boolean", + "description": "HasExports indicates whether the binary exports symbols." + }, + "hasEntrypoint": { + "type": "boolean", + "description": "HasEntrypoint indicates whether the binary has an entry point function." + }, + "importedLibraries": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ImportedLibraries lists the shared libraries required by this executable." + }, + "elfSecurityFeatures": { + "$ref": "#/$defs/ELFSecurityFeatures", + "description": "ELFSecurityFeatures contains ELF-specific security hardening information when Format is ELF." + } + }, + "type": "object", + "required": [ + "format", + "hasExports", + "hasEntrypoint", + "importedLibraries" + ], + "description": "Executable contains metadata about binary files and their security features." + }, + "File": { + "properties": { + "id": { + "type": "string", + "description": "ID is a unique identifier for this file within the SBOM." + }, + "location": { + "$ref": "#/$defs/Coordinates", + "description": "Location is the file path and layer information where this file was found." + }, + "metadata": { + "$ref": "#/$defs/FileMetadataEntry", + "description": "Metadata contains filesystem metadata such as permissions, ownership, and file type." + }, + "contents": { + "type": "string", + "description": "Contents is the file contents for small files." + }, + "digests": { + "items": { + "$ref": "#/$defs/Digest" + }, + "type": "array", + "description": "Digests contains cryptographic hashes of the file contents." + }, + "licenses": { + "items": { + "$ref": "#/$defs/FileLicense" + }, + "type": "array", + "description": "Licenses contains license information discovered within this file." + }, + "executable": { + "$ref": "#/$defs/Executable", + "description": "Executable contains executable metadata if this file is a binary." + }, + "unknowns": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Unknowns contains unknown fields for forward compatibility." + } + }, + "type": "object", + "required": [ + "id", + "location" + ], + "description": "File represents a file discovered during cataloging with its metadata, content digests, licenses, and relationships to packages." + }, + "FileLicense": { + "properties": { + "value": { + "type": "string", + "description": "Value is the raw license identifier or text as found in the file." + }, + "spdxExpression": { + "type": "string", + "description": "SPDXExpression is the parsed SPDX license expression." + }, + "type": { + "type": "string", + "description": "Type is the license type classification (e.g., declared, concluded, discovered)." + }, + "evidence": { + "$ref": "#/$defs/FileLicenseEvidence", + "description": "Evidence contains supporting evidence for this license detection." + } + }, + "type": "object", + "required": [ + "value", + "spdxExpression", + "type" + ], + "description": "FileLicense represents license information discovered within a file's contents or metadata, including the matched license text and SPDX expression." + }, + "FileLicenseEvidence": { + "properties": { + "confidence": { + "type": "integer", + "description": "Confidence is the confidence score for this license detection (0-100)." + }, + "offset": { + "type": "integer", + "description": "Offset is the byte offset where the license text starts in the file." + }, + "extent": { + "type": "integer", + "description": "Extent is the length of the license text in bytes." + } + }, + "type": "object", + "required": [ + "confidence", + "offset", + "extent" + ], + "description": "FileLicenseEvidence contains supporting evidence for a license detection in a file, including the byte offset, extent, and confidence level." + }, + "FileMetadataEntry": { + "properties": { + "mode": { + "type": "integer", + "description": "Mode is the Unix file permission mode in octal format." + }, + "type": { + "type": "string", + "description": "Type is the file type (e.g., \"RegularFile\", \"Directory\", \"SymbolicLink\")." + }, + "linkDestination": { + "type": "string", + "description": "LinkDestination is the target path for symbolic links." + }, + "userID": { + "type": "integer", + "description": "UserID is the file owner user ID." + }, + "groupID": { + "type": "integer", + "description": "GroupID is the file owner group ID." + }, + "mimeType": { + "type": "string", + "description": "MIMEType is the MIME type of the file contents." + }, + "size": { + "type": "integer", + "description": "Size is the file size in bytes." + } + }, + "type": "object", + "required": [ + "mode", + "type", + "userID", + "groupID", + "mimeType", + "size" + ], + "description": "FileMetadataEntry contains filesystem-level metadata attributes such as permissions, ownership, type, and size for a cataloged file." + }, + "GgufFileHeader": { + "properties": { + "ggufVersion": { + "type": "integer", + "description": "GGUFVersion is the GGUF format version (e.g., 3)" + }, + "fileSize": { + "type": "integer", + "description": "FileSize is the size of the GGUF file in bytes (best-effort if available from resolver)" + }, + "architecture": { + "type": "string", + "description": "Architecture is the model architecture (from general.architecture, e.g., \"qwen3moe\", \"llama\")" + }, + "quantization": { + "type": "string", + "description": "Quantization is the quantization type (e.g., \"IQ4_NL\", \"Q4_K_M\")" + }, + "parameters": { + "type": "integer", + "description": "Parameters is the number of model parameters (if present in header)" + }, + "tensorCount": { + "type": "integer", + "description": "TensorCount is the number of tensors in the model" + }, + "header": { + "type": "object", + "description": "RemainingKeyValues contains the remaining key-value pairs from the GGUF header that are not already\nrepresented as typed fields above. This preserves additional metadata fields for reference\n(namespaced with general.*, llama.*, etc.) while avoiding duplication." + }, + "metadataHash": { + "type": "string", + "description": "MetadataKeyValuesHash is a xx64 hash of all key-value pairs from the GGUF header metadata.\nThis hash is computed over the complete header metadata (including the fields extracted\ninto typed fields above) and provides a stable identifier for the model configuration\nacross different file locations or remotes. It allows matching identical models even\nwhen stored in different repositories or with different filenames." + }, + "parts": { + "items": { + "$ref": "#/$defs/GgufFileHeader" + }, + "type": "array", + "description": "Parts contains headers from additional GGUF files that were merged\ninto this package during post-processing (e.g., from OCI layers without model names)." + } + }, + "type": "object", + "required": [ + "ggufVersion", + "tensorCount" + ], + "description": "GGUFFileHeader represents metadata extracted from a GGUF (GPT-Generated Unified Format) model file." + }, + "GithubActionsUseStatement": { + "properties": { + "value": { + "type": "string", + "description": "Value is the action reference (e.g. \"actions/checkout@v3\")" + }, + "comment": { + "type": "string", + "description": "Comment is the inline comment associated with this uses statement" + } + }, + "type": "object", + "required": [ + "value" + ], + "description": "GitHubActionsUseStatement represents a single 'uses' statement in a GitHub Actions workflow file referencing an action or reusable workflow." + }, + "GoModuleBuildinfoEntry": { + "properties": { + "goBuildSettings": { + "$ref": "#/$defs/KeyValues", + "description": "BuildSettings contains the Go build settings and flags used to compile the binary (e.g., GOARCH, GOOS, CGO_ENABLED)." + }, + "goCompiledVersion": { + "type": "string", + "description": "GoCompiledVersion is the version of Go used to compile the binary." + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture for the binary (extracted from GOARCH build setting)." + }, + "h1Digest": { + "type": "string", + "description": "H1Digest is the Go module hash in h1: format for the main module from go.sum." + }, + "mainModule": { + "type": "string", + "description": "MainModule is the main module path for the binary (e.g., \"github.com/anchore/syft\")." + }, + "goCryptoSettings": { + "items": { + "type": "string" + }, + "type": "array", + "description": "GoCryptoSettings contains FIPS and cryptographic configuration settings if present." + }, + "goExperiments": { + "items": { + "type": "string" + }, + "type": "array", + "description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")." + }, + "symbols": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object", + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-modules glob patterns populate\nany additional modules they match." + } + }, + "type": "object", + "required": [ + "goCompiledVersion", + "architecture" + ], + "description": "GolangBinaryBuildinfoEntry represents all captured data for a Golang binary" + }, + "GoModuleEntry": { + "properties": { + "h1Digest": { + "type": "string", + "description": "H1Digest is the Go module hash in h1: format from go.sum for verifying module contents." + } + }, + "type": "object", + "description": "GolangModuleEntry represents all captured data for a Golang source scan with go.mod/go.sum" + }, + "GoSourceEntry": { + "properties": { + "h1Digest": { + "type": "string", + "description": "H1Digest is the Go module hash in h1: format from go.sum for verifying module contents." + }, + "os": { + "type": "string", + "description": "OperatingSystem is the target OS for build constraints (e.g., \"linux\", \"darwin\", \"windows\")." + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture for build constraints (e.g., \"amd64\", \"arm64\")." + }, + "buildTags": { + "type": "string", + "description": "BuildTags are the build tags used to conditionally compile code (e.g., \"integration,debug\")." + }, + "cgoEnabled": { + "type": "boolean", + "description": "CgoEnabled indicates whether CGO was enabled for this package." + } + }, + "type": "object", + "required": [ + "cgoEnabled" + ], + "description": "GolangSourceEntry represents all captured data for a Golang package found through source analysis" + }, + "HaskellHackageStackEntry": { + "properties": { + "pkgHash": { + "type": "string", + "description": "PkgHash is the package content hash for verification" + } + }, + "type": "object", + "description": "HackageStackYamlEntry represents a single entry from the \"extra-deps\" section of a stack.yaml file." + }, + "HaskellHackageStackLockEntry": { + "properties": { + "pkgHash": { + "type": "string", + "description": "PkgHash is the package content hash for verification" + }, + "snapshotURL": { + "type": "string", + "description": "SnapshotURL is the URL to the Stack snapshot this package came from" + } + }, + "type": "object", + "description": "HackageStackYamlLockEntry represents a single entry from the \"packages\" section of a stack.yaml.lock file." + }, + "HomebrewFormula": { + "properties": { + "tap": { + "type": "string", + "description": "Tap is Homebrew tap this formula belongs to (e.g. \"homebrew/core\")" + }, + "homepage": { + "type": "string", + "description": "Homepage is the upstream project homepage URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable formula description" + } + }, + "type": "object", + "description": "HomebrewFormula represents metadata about a Homebrew formula package extracted from formula JSON files." + }, + "IDLikes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "IDLikes represents a list of distribution IDs that this Linux distribution is similar to or derived from, as defined in os-release ID_LIKE field." + }, + "JavaArchive": { + "properties": { + "virtualPath": { + "type": "string", + "description": "VirtualPath is path within the archive hierarchy, where nested entries are delimited with ':' (for nested JARs)" + }, + "manifest": { + "$ref": "#/$defs/JavaManifest", + "description": "Manifest is parsed META-INF/MANIFEST.MF contents" + }, + "pomProperties": { + "$ref": "#/$defs/JavaPomProperties", + "description": "PomProperties is parsed pom.properties file contents" + }, + "pomProject": { + "$ref": "#/$defs/JavaPomProject", + "description": "PomProject is parsed pom.xml file contents" + }, + "digest": { + "items": { + "$ref": "#/$defs/Digest" + }, + "type": "array", + "description": "ArchiveDigests is cryptographic hashes of the archive file" + } + }, + "type": "object", + "required": [ + "virtualPath" + ], + "description": "JavaArchive encapsulates all Java ecosystem metadata for a package as well as an (optional) parent relationship." + }, + "JavaJvmInstallation": { + "properties": { + "release": { + "$ref": "#/$defs/JavaVMRelease", + "description": "Release is JVM release information and version details" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files are the list of files that are part of this JVM installation" + } + }, + "type": "object", + "required": [ + "release", + "files" + ], + "description": "JavaVMInstallation represents a Java Virtual Machine installation discovered on the system with its release information and file list." + }, + "JavaManifest": { + "properties": { + "main": { + "$ref": "#/$defs/KeyValues", + "description": "Main is main manifest attributes as key-value pairs" + }, + "sections": { + "items": { + "$ref": "#/$defs/KeyValues" + }, + "type": "array", + "description": "Sections are the named sections from the manifest (e.g. per-entry attributes)" + } + }, + "type": "object", + "description": "JavaManifest represents the fields of interest extracted from a Java archive's META-INF/MANIFEST.MF file." + }, + "JavaPomParent": { + "properties": { + "groupId": { + "type": "string", + "description": "GroupID is the parent Maven group identifier" + }, + "artifactId": { + "type": "string", + "description": "ArtifactID is the parent Maven artifact identifier" + }, + "version": { + "type": "string", + "description": "Version is the parent version (child inherits configuration from this specific version of parent POM)" + } + }, + "type": "object", + "required": [ + "groupId", + "artifactId", + "version" + ], + "description": "JavaPomParent contains the fields within the \u003cparent\u003e tag in a pom.xml file" + }, + "JavaPomProject": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the pom.xml file within the archive" + }, + "parent": { + "$ref": "#/$defs/JavaPomParent", + "description": "Parent is the parent POM reference for inheritance (child POMs inherit configuration from parent)" + }, + "groupId": { + "type": "string", + "description": "GroupID is Maven group identifier (reversed domain name like org.apache.maven)" + }, + "artifactId": { + "type": "string", + "description": "ArtifactID is Maven artifact identifier (project name)" + }, + "version": { + "type": "string", + "description": "Version is project version (together with groupId and artifactId forms Maven coordinates groupId:artifactId:version)" + }, + "name": { + "type": "string", + "description": "Name is a human-readable project name (displayed in Maven-generated documentation)" + }, + "description": { + "type": "string", + "description": "Description is detailed project description" + }, + "url": { + "type": "string", + "description": "URL is the project URL (typically project website or repository)" + } + }, + "type": "object", + "required": [ + "path", + "groupId", + "artifactId", + "version", + "name" + ], + "description": "JavaPomProject represents fields of interest extracted from a Java archive's pom.xml file." + }, + "JavaPomProperties": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the pom.properties file within the archive" + }, + "name": { + "type": "string", + "description": "Name is the project name" + }, + "groupId": { + "type": "string", + "description": "GroupID is Maven group identifier uniquely identifying the project across all projects (follows reversed domain name convention like com.company.project)" + }, + "artifactId": { + "type": "string", + "description": "ArtifactID is Maven artifact identifier, the name of the jar/artifact (unique within the groupId scope)" + }, + "version": { + "type": "string", + "description": "Version is artifact version" + }, + "scope": { + "type": "string", + "description": "Scope is dependency scope determining when dependency is available (compile=default all phases, test=test compilation/execution only, runtime=runtime and test not compile, provided=expected from JDK or container)" + }, + "extraFields": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Extra is additional custom properties not in standard Maven coordinates" + } + }, + "type": "object", + "required": [ + "path", + "name", + "groupId", + "artifactId", + "version" + ], + "description": "JavaPomProperties represents the fields of interest extracted from a Java archive's pom.properties file." + }, + "JavaVMRelease": { + "properties": { + "implementor": { + "type": "string", + "description": "Implementor is extracted with the `java.vendor` JVM property" + }, + "implementorVersion": { + "type": "string", + "description": "ImplementorVersion is extracted with the `java.vendor.version` JVM property" + }, + "javaRuntimeVersion": { + "type": "string", + "description": "JavaRuntimeVersion is extracted from the 'java.runtime.version' JVM property" + }, + "javaVersion": { + "type": "string", + "description": "JavaVersion matches that from `java -version` command output" + }, + "javaVersionDate": { + "type": "string", + "description": "JavaVersionDate is extracted from the 'java.version.date' JVM property" + }, + "libc": { + "type": "string", + "description": "Libc can either be 'glibc' or 'musl'" + }, + "modules": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Modules is a list of JVM modules that are packaged" + }, + "osArch": { + "type": "string", + "description": "OsArch is the target CPU architecture" + }, + "osName": { + "type": "string", + "description": "OsName is the name of the target runtime operating system environment" + }, + "osVersion": { + "type": "string", + "description": "OsVersion is the version of the target runtime operating system environment" + }, + "source": { + "type": "string", + "description": "Source refers to the origin repository of OpenJDK source" + }, + "buildSource": { + "type": "string", + "description": "BuildSource Git SHA of the build repository" + }, + "buildSourceRepo": { + "type": "string", + "description": "BuildSourceRepo refers to rhe repository URL for the build source" + }, + "sourceRepo": { + "type": "string", + "description": "SourceRepo refers to the OpenJDK repository URL" + }, + "fullVersion": { + "type": "string", + "description": "FullVersion is extracted from the 'java.runtime.version' JVM property" + }, + "semanticVersion": { + "type": "string", + "description": "SemanticVersion is derived from the OpenJDK version" + }, + "buildInfo": { + "type": "string", + "description": "BuildInfo contains additional build information" + }, + "jvmVariant": { + "type": "string", + "description": "JvmVariant specifies the JVM variant (e.g., Hotspot or OpenJ9)" + }, + "jvmVersion": { + "type": "string", + "description": "JvmVersion is extracted from the 'java.vm.version' JVM property" + }, + "imageType": { + "type": "string", + "description": "ImageType can be 'JDK' or 'JRE'" + }, + "buildType": { + "type": "string", + "description": "BuildType can be 'commercial' (used in some older oracle JDK distributions)" + } + }, + "type": "object", + "description": "JavaVMRelease represents JVM version and build information extracted from the release file in a Java installation." + }, + "JavascriptBunLockEntry": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification (SRI format)" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of runtime dependencies and their version specifiers" + }, + "optionalDependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "OptionalDependencies is a map of optional dependencies and their version specifiers" + }, + "peerDependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "PeerDependencies is a map of peer dependencies and their version specifiers" + }, + "bin": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Bin is a map of binary names to the paths they are installed to" + }, + "os": { + "type": "string", + "description": "OS is the operating system constraint for the package (e.g. \"darwin\")" + }, + "cpu": { + "type": "string", + "description": "CPU is the CPU architecture constraint for the package (e.g. \"arm64\")" + } + }, + "type": "object", + "required": [ + "integrity", + "dependencies", + "optionalDependencies", + "peerDependencies", + "bin", + "os", + "cpu" + ], + "description": "BunLockEntry represents a single entry in the \"packages\" section of a bun.lock file" + }, + "JavascriptNpmPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in package.json" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in package.json" + }, + "author": { + "type": "string", + "description": "Author is package author name" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "url": { + "type": "string", + "description": "URL is repository or project URL" + }, + "private": { + "type": "boolean", + "description": "Private is whether this is a private package" + } + }, + "type": "object", + "required": [ + "name", + "version", + "author", + "homepage", + "description", + "url", + "private" + ], + "description": "NpmPackage represents the contents of a javascript package.json file." + }, + "JavascriptNpmPackageLockEntry": { + "properties": { + "resolved": { + "type": "string", + "description": "Resolved is URL where this package was downloaded from (registry source)" + }, + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification using standard SRI format (sha512-... or sha1-...). npm changed from SHA-1 to SHA-512 in newer versions. For registry sources this is the integrity from registry, for remote tarballs it's SHA-512 of the file. npm verifies tarball matches this hash before unpacking, throwing EINTEGRITY error if mismatch detected." + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of dependencies and their version markers, i.e. \"lodash\": \"^1.0.0\"" + } + }, + "type": "object", + "required": [ + "resolved", + "integrity", + "dependencies" + ], + "description": "NpmPackageLockEntry represents a single entry within the \"packages\" section of a package-lock.json file." + }, + "JavascriptPnpmLockEntry": { + "properties": { + "resolution": { + "$ref": "#/$defs/PnpmLockResolution", + "description": "Resolution is the resolution information for the package" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of dependencies and their versions" + } + }, + "type": "object", + "required": [ + "resolution", + "dependencies" + ], + "description": "PnpmLockEntry represents a single entry in the \"packages\" section of a pnpm-lock.yaml file." + }, + "JavascriptYarnLockEntry": { + "properties": { + "resolved": { + "type": "string", + "description": "Resolved is URL where this package was downloaded from" + }, + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification (SRI format)" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies is a map of dependencies and their versions" + } + }, + "type": "object", + "required": [ + "resolved", + "integrity", + "dependencies" + ], + "description": "YarnLockEntry represents a single entry section of a yarn.lock file." + }, + "KeyValue": { + "properties": { + "key": { + "type": "string", + "description": "Key is the key name" + }, + "value": { + "type": "string", + "description": "Value is the value associated with the key" + } + }, + "type": "object", + "required": [ + "key", + "value" + ], + "description": "KeyValue represents a single key-value pair." + }, + "KeyValues": { + "items": { + "$ref": "#/$defs/KeyValue" + }, + "type": "array", + "description": "KeyValues represents an ordered collection of key-value pairs that preserves insertion order." + }, + "License": { + "properties": { + "value": { + "type": "string", + "description": "Value is the raw license identifier or expression as found." + }, + "spdxExpression": { + "type": "string", + "description": "SPDXExpression is the parsed SPDX license expression." + }, + "type": { + "type": "string", + "description": "Type is the license type classification (e.g., declared, concluded, discovered)." + }, + "urls": { + "items": { + "type": "string" + }, + "type": "array", + "description": "URLs are URLs where license text or information can be found." + }, + "locations": { + "items": { + "$ref": "#/$defs/Location" + }, + "type": "array", + "description": "Locations are file locations where this license was discovered." + }, + "contents": { + "type": "string", + "description": "Contents is the full license text content." + } + }, + "type": "object", + "required": [ + "value", + "spdxExpression", + "type", + "urls", + "locations" + ], + "description": "License represents software license information discovered for a package, including SPDX expressions and supporting evidence locations." + }, + "LinuxKernelArchive": { + "properties": { + "name": { + "type": "string", + "description": "Name is kernel name (typically \"Linux\")" + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture" + }, + "version": { + "type": "string", + "description": "Version is kernel version string" + }, + "extendedVersion": { + "type": "string", + "description": "ExtendedVersion is additional version information" + }, + "buildTime": { + "type": "string", + "description": "BuildTime is when the kernel was built" + }, + "author": { + "type": "string", + "description": "Author is who built the kernel" + }, + "format": { + "type": "string", + "description": "Format is kernel image format (e.g. bzImage, zImage)" + }, + "rwRootFS": { + "type": "boolean", + "description": "RWRootFS is whether root filesystem is mounted read-write" + }, + "swapDevice": { + "type": "integer", + "description": "SwapDevice is swap device number" + }, + "rootDevice": { + "type": "integer", + "description": "RootDevice is root device number" + }, + "videoMode": { + "type": "string", + "description": "VideoMode is default video mode setting" + } + }, + "type": "object", + "required": [ + "name", + "architecture", + "version" + ], + "description": "LinuxKernel represents all captured data for a Linux kernel" + }, + "LinuxKernelModule": { + "properties": { + "name": { + "type": "string", + "description": "Name is module name" + }, + "version": { + "type": "string", + "description": "Version is module version string" + }, + "sourceVersion": { + "type": "string", + "description": "SourceVersion is the source code version identifier" + }, + "path": { + "type": "string", + "description": "Path is the filesystem path to the .ko kernel object file (absolute path)" + }, + "description": { + "type": "string", + "description": "Description is a human-readable module description" + }, + "author": { + "type": "string", + "description": "Author is module author name and email" + }, + "license": { + "type": "string", + "description": "License is module license (e.g. GPL, BSD) which must be compatible with kernel" + }, + "kernelVersion": { + "type": "string", + "description": "KernelVersion is kernel version this module was built for" + }, + "versionMagic": { + "type": "string", + "description": "VersionMagic is version magic string for compatibility checking (includes kernel version, SMP status, module loading capabilities like \"3.17.4-302.fc21.x86_64 SMP mod_unload modversions\"). Module will NOT load if vermagic doesn't match running kernel." + }, + "parameters": { + "additionalProperties": { + "$ref": "#/$defs/LinuxKernelModuleParameter" + }, + "type": "object", + "description": "Parameters are the module parameters that can be configured at load time (user-settable values like module options)" + } + }, + "type": "object", + "description": "LinuxKernelModule represents a loadable kernel module (.ko file) with its metadata, parameters, and dependencies." + }, + "LinuxKernelModuleParameter": { + "properties": { + "type": { + "type": "string", + "description": "Type is parameter data type (e.g. int, string, bool, array types)" + }, + "description": { + "type": "string", + "description": "Description is a human-readable parameter description explaining what the parameter controls" + } + }, + "type": "object", + "description": "LinuxKernelModuleParameter represents a configurable parameter for a kernel module with its type and description." + }, + "LinuxRelease": { + "properties": { + "prettyName": { + "type": "string", + "description": "PrettyName is a human-readable operating system name with version." + }, + "name": { + "type": "string", + "description": "Name is the operating system name without version information." + }, + "id": { + "type": "string", + "description": "ID is the lower-case operating system identifier (e.g., \"ubuntu\", \"rhel\")." + }, + "idLike": { + "$ref": "#/$defs/IDLikes", + "description": "IDLike is a list of operating system IDs this distribution is similar to or derived from." + }, + "version": { + "type": "string", + "description": "Version is the operating system version including codename if available." + }, + "versionID": { + "type": "string", + "description": "VersionID is the operating system version number or identifier." + }, + "versionCodename": { + "type": "string", + "description": "VersionCodename is the operating system release codename (e.g., \"jammy\", \"bullseye\")." + }, + "buildID": { + "type": "string", + "description": "BuildID is a build identifier for the operating system." + }, + "imageID": { + "type": "string", + "description": "ImageID is an identifier for container or cloud images." + }, + "imageVersion": { + "type": "string", + "description": "ImageVersion is the version for container or cloud images." + }, + "variant": { + "type": "string", + "description": "Variant is the operating system variant name (e.g., \"Server\", \"Workstation\")." + }, + "variantID": { + "type": "string", + "description": "VariantID is the lower-case operating system variant identifier." + }, + "homeURL": { + "type": "string", + "description": "HomeURL is the homepage URL for the operating system." + }, + "supportURL": { + "type": "string", + "description": "SupportURL is the support or help URL for the operating system." + }, + "bugReportURL": { + "type": "string", + "description": "BugReportURL is the bug reporting URL for the operating system." + }, + "privacyPolicyURL": { + "type": "string", + "description": "PrivacyPolicyURL is the privacy policy URL for the operating system." + }, + "cpeName": { + "type": "string", + "description": "CPEName is the Common Platform Enumeration name for the operating system." + }, + "supportEnd": { + "type": "string", + "description": "SupportEnd is the end of support date or version identifier." + }, + "extendedSupport": { + "type": "boolean", + "description": "ExtendedSupport indicates whether extended security or support is available." + } + }, + "type": "object", + "description": "LinuxRelease contains Linux distribution identification and version information extracted from /etc/os-release or similar system files." + }, + "Location": { + "properties": { + "path": { + "type": "string", + "description": "RealPath is the canonical absolute form of the path accessed (all symbolic links have been followed and relative path components like '.' and '..' have been removed)." + }, + "layerID": { + "type": "string", + "description": "FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. A file found inside an archive carries the FileSystemID of the filesystem the archive itself was found in (a layer digest, or blank), inherited unchanged down the nesting chain; the nesting chain is carried by ArchivePath instead." + }, + "archivePath": { + "type": "string", + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, of every archive traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). It is blank for files not found within an archive. It disambiguates identically-named files residing in different archives on the same filesystem." + }, + "accessPath": { + "type": "string", + "description": "AccessPath is the path used to retrieve file contents (which may or may not have hardlinks / symlinks in the path)" + }, + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Arbitrary key-value pairs that can be used to annotate a location" + } + }, + "type": "object", + "required": [ + "path", + "accessPath" + ], + "description": "Location represents a path relative to a particular filesystem resolved to a specific file.Reference." + }, + "LuarocksPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the .rockspec file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the .rockspec file" + }, + "license": { + "type": "string", + "description": "License is license identifier" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "url": { + "type": "string", + "description": "URL is the source download URL" + }, + "dependencies": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Dependencies are the map of dependency names to version constraints" + } + }, + "type": "object", + "required": [ + "name", + "version", + "license", + "homepage", + "description", + "url", + "dependencies" + ], + "description": "LuaRocksPackage represents a Lua package managed by the LuaRocks package manager with metadata from .rockspec files." + }, + "MicrosoftKbPatch": { + "properties": { + "product_id": { + "type": "string", + "description": "ProductID is MSRC Product ID (e.g. \"Windows 10 Version 1703 for 32-bit Systems\")" + }, + "kb": { + "type": "string", + "description": "Kb is Knowledge Base article number (e.g. \"5001028\")" + } + }, + "type": "object", + "required": [ + "product_id", + "kb" + ], + "description": "MicrosoftKbPatch represents a Windows Knowledge Base patch identifier associated with a specific Microsoft product from the MSRC (Microsoft Security Response Center)." + }, + "NixDerivation": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the .drv file in Nix store" + }, + "system": { + "type": "string", + "description": "System is target system string indicating where derivation can be built (e.g. \"x86_64-linux\", \"aarch64-darwin\"). Must match current system for local builds." + }, + "inputDerivations": { + "items": { + "$ref": "#/$defs/NixDerivationReference" + }, + "type": "array", + "description": "InputDerivations are the list of other derivations that were inputs to this build (dependencies)" + }, + "inputSources": { + "items": { + "type": "string" + }, + "type": "array", + "description": "InputSources are the list of source file paths that were inputs to this build" + } + }, + "type": "object", + "description": "NixDerivation represents a Nix .drv file that describes how to build a package including inputs, outputs, and build instructions." + }, + "NixDerivationReference": { + "properties": { + "path": { + "type": "string", + "description": "Path is path to the referenced .drv file" + }, + "outputs": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Outputs are which outputs of the referenced derivation were used (e.g. [\"out\"], [\"bin\", \"dev\"])" + } + }, + "type": "object", + "description": "NixDerivationReference represents a reference to another derivation used as a build input or runtime dependency." + }, + "NixStoreEntry": { + "properties": { + "path": { + "type": "string", + "description": "Path is full store path for this output (e.g. /nix/store/abc123...-package-1.0)" + }, + "output": { + "type": "string", + "description": "Output is the specific output name for multi-output packages (empty string for default \"out\" output, can be \"bin\", \"dev\", \"doc\", etc.)" + }, + "outputHash": { + "type": "string", + "description": "OutputHash is hash prefix of the store path basename (first part before the dash)" + }, + "derivation": { + "$ref": "#/$defs/NixDerivation", + "description": "Derivation is information about the .drv file that describes how this package was built" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files are the list of files under the nix/store path for this package" + } + }, + "type": "object", + "required": [ + "outputHash" + ], + "description": "NixStoreEntry represents a package in the Nix store (/nix/store) with its derivation information and metadata." + }, + "OpamPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the .opam file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the .opam file" + }, + "licenses": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Licenses are the list of applicable licenses" + }, + "url": { + "type": "string", + "description": "URL is download URL for the package source" + }, + "checksum": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Checksums are the list of checksums for verification" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the list of required dependencies" + } + }, + "type": "object", + "required": [ + "name", + "version", + "licenses", + "url", + "checksum", + "homepage", + "dependencies" + ], + "description": "OpamPackage represents an OCaml package managed by the OPAM package manager with metadata from .opam files." + }, + "Package": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "type": { + "type": "string" + }, + "foundBy": { + "type": "string" + }, + "locations": { + "items": { + "$ref": "#/$defs/Location" + }, + "type": "array" + }, + "licenses": { + "$ref": "#/$defs/licenses" + }, + "language": { + "type": "string" + }, + "cpes": { + "$ref": "#/$defs/cpes" + }, + "purl": { + "type": "string" + }, + "metadataType": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AlpmDbEntry" + }, + { + "$ref": "#/$defs/ApkDbEntry" + }, + { + "$ref": "#/$defs/AppleAppBundleEntry" + }, + { + "$ref": "#/$defs/BinarySignature" + }, + { + "$ref": "#/$defs/BitnamiSbomEntry" + }, + { + "$ref": "#/$defs/CConanFileEntry" + }, + { + "$ref": "#/$defs/CConanInfoEntry" + }, + { + "$ref": "#/$defs/CConanLockEntry" + }, + { + "$ref": "#/$defs/CConanLockV2Entry" + }, + { + "$ref": "#/$defs/CocoaPodfileLockEntry" + }, + { + "$ref": "#/$defs/CondaMetadataEntry" + }, + { + "$ref": "#/$defs/DartPubspec" + }, + { + "$ref": "#/$defs/DartPubspecLockEntry" + }, + { + "$ref": "#/$defs/DenoLockEntry" + }, + { + "$ref": "#/$defs/DenoRemoteLockEntry" + }, + { + "$ref": "#/$defs/DotnetDepsEntry" + }, + { + "$ref": "#/$defs/DotnetPackagesLockEntry" + }, + { + "$ref": "#/$defs/DotnetPortableExecutableEntry" + }, + { + "$ref": "#/$defs/DpkgArchiveEntry" + }, + { + "$ref": "#/$defs/DpkgDbEntry" + }, + { + "$ref": "#/$defs/ElfBinaryPackageNoteJsonPayload" + }, + { + "$ref": "#/$defs/ElixirMixLockEntry" + }, + { + "$ref": "#/$defs/ErlangRebarLockEntry" + }, + { + "$ref": "#/$defs/GgufFileHeader" + }, + { + "$ref": "#/$defs/GithubActionsUseStatement" + }, + { + "$ref": "#/$defs/GoModuleBuildinfoEntry" + }, + { + "$ref": "#/$defs/GoModuleEntry" + }, + { + "$ref": "#/$defs/GoSourceEntry" + }, + { + "$ref": "#/$defs/HaskellHackageStackEntry" + }, + { + "$ref": "#/$defs/HaskellHackageStackLockEntry" + }, + { + "$ref": "#/$defs/HomebrewFormula" + }, + { + "$ref": "#/$defs/JavaArchive" + }, + { + "$ref": "#/$defs/JavaJvmInstallation" + }, + { + "$ref": "#/$defs/JavascriptBunLockEntry" + }, + { + "$ref": "#/$defs/JavascriptNpmPackage" + }, + { + "$ref": "#/$defs/JavascriptNpmPackageLockEntry" + }, + { + "$ref": "#/$defs/JavascriptPnpmLockEntry" + }, + { + "$ref": "#/$defs/JavascriptYarnLockEntry" + }, + { + "$ref": "#/$defs/LinuxKernelArchive" + }, + { + "$ref": "#/$defs/LinuxKernelModule" + }, + { + "$ref": "#/$defs/LuarocksPackage" + }, + { + "$ref": "#/$defs/MicrosoftKbPatch" + }, + { + "$ref": "#/$defs/NixStoreEntry" + }, + { + "$ref": "#/$defs/OpamPackage" + }, + { + "$ref": "#/$defs/PeBinary" + }, + { + "$ref": "#/$defs/PhpComposerInstalledEntry" + }, + { + "$ref": "#/$defs/PhpComposerLockEntry" + }, + { + "$ref": "#/$defs/PhpPearEntry" + }, + { + "$ref": "#/$defs/PhpPeclEntry" + }, + { + "$ref": "#/$defs/PortageDbEntry" + }, + { + "$ref": "#/$defs/PythonPackage" + }, + { + "$ref": "#/$defs/PythonPdmLockEntry" + }, + { + "$ref": "#/$defs/PythonPipRequirementsEntry" + }, + { + "$ref": "#/$defs/PythonPipfileLockEntry" + }, + { + "$ref": "#/$defs/PythonPoetryLockEntry" + }, + { + "$ref": "#/$defs/PythonUvLockEntry" + }, + { + "$ref": "#/$defs/RDescription" + }, + { + "$ref": "#/$defs/RpmArchive" + }, + { + "$ref": "#/$defs/RpmDbEntry" + }, + { + "$ref": "#/$defs/RubyGemspec" + }, + { + "$ref": "#/$defs/RustCargoAuditEntry" + }, + { + "$ref": "#/$defs/RustCargoLockEntry" + }, + { + "$ref": "#/$defs/SafetensorsModelInfo" + }, + { + "$ref": "#/$defs/SnapEntry" + }, + { + "$ref": "#/$defs/SwiftPackageManagerLockEntry" + }, + { + "$ref": "#/$defs/SwiplpackPackage" + }, + { + "$ref": "#/$defs/TerraformLockProviderEntry" + }, + { + "$ref": "#/$defs/VcpkgManifest" + }, + { + "$ref": "#/$defs/WordpressPluginEntry" + } + ] + } + }, + "type": "object", + "required": [ + "id", + "name", + "version", + "type", + "foundBy", + "locations", + "licenses", + "language", + "cpes", + "purl" + ], + "description": "Package represents a pkg.Package object specialized for JSON marshaling and unmarshalling." + }, + "PeBinary": { + "properties": { + "VersionResources": { + "$ref": "#/$defs/KeyValues", + "description": "VersionResources contains key-value pairs extracted from the PE file's version resource section (e.g., FileVersion, ProductName, CompanyName)." + } + }, + "type": "object", + "required": [ + "VersionResources" + ], + "description": "PEBinary represents metadata captured from a Portable Executable formatted binary (dll, exe, etc.)" + }, + "PhpComposerAuthors": { + "properties": { + "name": { + "type": "string", + "description": "Name is author's full name" + }, + "email": { + "type": "string", + "description": "Email is author's email address" + }, + "homepage": { + "type": "string", + "description": "Homepage is author's personal or company website" + } + }, + "type": "object", + "required": [ + "name" + ], + "description": "PhpComposerAuthors represents author information for a PHP Composer package from the authors field in composer.json." + }, + "PhpComposerExternalReference": { + "properties": { + "type": { + "type": "string", + "description": "Type is reference type (git for source VCS, zip/tar for dist archives)" + }, + "url": { + "type": "string", + "description": "URL is the URL to the resource (git repository URL or archive download URL)" + }, + "reference": { + "type": "string", + "description": "Reference is git commit hash or version tag for source, or archive version for dist" + }, + "shasum": { + "type": "string", + "description": "Shasum is SHA hash of the archive file for integrity verification (dist only)" + } + }, + "type": "object", + "required": [ + "type", + "url", + "reference" + ], + "description": "PhpComposerExternalReference represents source or distribution information for a PHP package, indicating where the package code is retrieved from." + }, + "PhpComposerInstalledEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is package name in vendor/package format (e.g. symfony/console)" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "source": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Source is the source repository information for development (typically git repo, used when passing --prefer-source). Originates from source code repository." + }, + "dist": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Dist is distribution archive information for production (typically zip/tar, default install method). Packaged version of released code." + }, + "require": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Require is runtime dependencies with version constraints (package will not install unless these requirements can be met)" + }, + "provide": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Provide is virtual packages/functionality provided by this package (allows other packages to depend on capabilities)" + }, + "require-dev": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "RequireDev is development-only dependencies (not installed in production, only when developing this package or running tests)" + }, + "suggest": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Suggest is optional but recommended dependencies (suggestions for packages that would extend functionality)" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of license identifiers (SPDX format)" + }, + "type": { + "type": "string", + "description": "Type is package type indicating purpose (library=reusable code, project=application, metapackage=aggregates dependencies, etc.)" + }, + "notification-url": { + "type": "string", + "description": "NotificationURL is the URL to notify when package is installed (for tracking/statistics)" + }, + "bin": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Bin is the list of binary/executable files that should be added to PATH" + }, + "authors": { + "items": { + "$ref": "#/$defs/PhpComposerAuthors" + }, + "type": "array", + "description": "Authors are the list of package authors with name/email/homepage" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "keywords": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Keywords are the list of keywords for package discovery/search" + }, + "time": { + "type": "string", + "description": "Time is timestamp when this package version was released" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source", + "dist" + ], + "description": "PhpComposerInstalledEntry represents a single package entry from a composer v1/v2 \"installed.json\" files (very similar to composer.lock files)." + }, + "PhpComposerLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is package name in vendor/package format (e.g. symfony/console)" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "source": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Source is the source repository information for development (typically git repo, used when passing --prefer-source). Originates from source code repository." + }, + "dist": { + "$ref": "#/$defs/PhpComposerExternalReference", + "description": "Dist is distribution archive information for production (typically zip/tar, default install method). Packaged version of released code." + }, + "require": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Require is runtime dependencies with version constraints (package will not install unless these requirements can be met)" + }, + "provide": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Provide is virtual packages/functionality provided by this package (allows other packages to depend on capabilities)" + }, + "require-dev": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "RequireDev is development-only dependencies (not installed in production, only when developing this package or running tests)" + }, + "suggest": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Suggest is optional but recommended dependencies (suggestions for packages that would extend functionality)" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of license identifiers (SPDX format)" + }, + "type": { + "type": "string", + "description": "Type is package type indicating purpose (library=reusable code, project=application, metapackage=aggregates dependencies, etc.)" + }, + "notification-url": { + "type": "string", + "description": "NotificationURL is the URL to notify when package is installed (for tracking/statistics)" + }, + "bin": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Bin is the list of binary/executable files that should be added to PATH" + }, + "authors": { + "items": { + "$ref": "#/$defs/PhpComposerAuthors" + }, + "type": "array", + "description": "Authors are the list of package authors with name/email/homepage" + }, + "description": { + "type": "string", + "description": "Description is a human-readable package description" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "keywords": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Keywords are the list of keywords for package discovery/search" + }, + "time": { + "type": "string", + "description": "Time is timestamp when this package version was released" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source", + "dist" + ], + "description": "PhpComposerLockEntry represents a single package entry found from a composer.lock file." + }, + "PhpPearEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name" + }, + "channel": { + "type": "string", + "description": "Channel is PEAR channel this package is from" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of applicable licenses" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "PhpPearEntry represents a single package entry found within php pear metadata files." + }, + "PhpPeclEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name" + }, + "channel": { + "type": "string", + "description": "Channel is PEAR channel this package is from" + }, + "version": { + "type": "string", + "description": "Version is the package version" + }, + "license": { + "items": { + "type": "string" + }, + "type": "array", + "description": "License is the list of applicable licenses" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "PhpPeclEntry represents a single package entry found within php pecl metadata files." + }, + "PnpmLockResolution": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is Subresource Integrity hash for verification (SRI format)" + } + }, + "type": "object", + "required": [ + "integrity" + ], + "description": "PnpmLockResolution contains package resolution metadata from pnpm lockfiles, including the integrity hash used for verification." + }, + "PortageDbEntry": { + "properties": { + "installedSize": { + "type": "integer", + "description": "InstalledSize is total size of installed files in bytes" + }, + "licenses": { + "type": "string", + "description": "Licenses is license string which may be an expression (e.g. \"GPL-2 OR Apache-2.0\")" + }, + "files": { + "items": { + "$ref": "#/$defs/PortageFileRecord" + }, + "type": "array", + "description": "Files are the files installed by this package (tracked in CONTENTS file)" + } + }, + "type": "object", + "required": [ + "installedSize", + "files" + ], + "description": "PortageEntry represents a single package entry in the portage DB flat-file store." + }, + "PortageFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the file path relative to the filesystem root" + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest is file content hash (MD5 for regular files in CONTENTS format: \"obj filename md5hash mtime\")" + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "PortageFileRecord represents a single file attributed to a portage package." + }, + "PythonDirectURLOriginInfo": { + "properties": { + "url": { + "type": "string", + "description": "URL is the source URL from which the package was installed." + }, + "commitId": { + "type": "string", + "description": "CommitID is the VCS commit hash if installed from version control." + }, + "vcs": { + "type": "string", + "description": "VCS is the version control system type (e.g., \"git\", \"hg\")." + } + }, + "type": "object", + "required": [ + "url" + ], + "description": "PythonDirectURLOriginInfo represents installation source metadata from direct_url.json for packages installed from VCS or direct URLs." + }, + "PythonFileDigest": { + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm is the hash algorithm used (e.g., \"sha256\")." + }, + "value": { + "type": "string", + "description": "Value is the hex-encoded hash digest value." + } + }, + "type": "object", + "required": [ + "algorithm", + "value" + ], + "description": "PythonFileDigest represents the file metadata for a single file attributed to a python package." + }, + "PythonFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the installed file path from the RECORD file." + }, + "digest": { + "$ref": "#/$defs/PythonFileDigest", + "description": "Digest contains the hash algorithm and value for file integrity verification." + }, + "size": { + "type": "string", + "description": "Size is the file size in bytes as a string." + } + }, + "type": "object", + "required": [ + "path" + ], + "description": "PythonFileRecord represents a single entry within a RECORD file for a python wheel or egg package" + }, + "PythonPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name from the Name field in PKG-INFO or METADATA." + }, + "version": { + "type": "string", + "description": "Version is the package version from the Version field in PKG-INFO or METADATA." + }, + "author": { + "type": "string", + "description": "Author is the package author name from the Author field." + }, + "authorEmail": { + "type": "string", + "description": "AuthorEmail is the package author's email address from the Author-Email field." + }, + "platform": { + "type": "string", + "description": "Platform indicates the target platform for the package (e.g., \"any\", \"linux\", \"win32\")." + }, + "files": { + "items": { + "$ref": "#/$defs/PythonFileRecord" + }, + "type": "array", + "description": "Files are the installed files listed in the RECORD file for wheels or installed-files.txt for eggs." + }, + "sitePackagesRootPath": { + "type": "string", + "description": "SitePackagesRootPath is the root directory path containing the package (e.g., \"/usr/lib/python3.9/site-packages\")." + }, + "topLevelPackages": { + "items": { + "type": "string" + }, + "type": "array", + "description": "TopLevelPackages are the top-level Python module names from top_level.txt file." + }, + "directUrlOrigin": { + "$ref": "#/$defs/PythonDirectURLOriginInfo", + "description": "DirectURLOrigin contains VCS or direct URL installation information from direct_url.json." + }, + "requiresPython": { + "type": "string", + "description": "RequiresPython specifies the Python version requirement (e.g., \"\u003e=3.6\")." + }, + "requiresDist": { + "items": { + "type": "string" + }, + "type": "array", + "description": "RequiresDist lists the package dependencies with version specifiers from Requires-Dist fields." + }, + "providesExtra": { + "items": { + "type": "string" + }, + "type": "array", + "description": "ProvidesExtra lists optional feature names that can be installed via extras (e.g., \"dev\", \"test\")." + } + }, + "type": "object", + "required": [ + "name", + "version", + "author", + "authorEmail", + "platform", + "sitePackagesRootPath" + ], + "description": "PythonPackage represents all captured data for a python egg or wheel package (specifically as outlined in the PyPA core metadata specification https://packaging.python.org/en/latest/specifications/core-metadata/)." + }, + "PythonPdmFileEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the file download URL" + }, + "digest": { + "$ref": "#/$defs/PythonFileDigest", + "description": "Digest is the hash digest of the file hosted at the URL" + } + }, + "type": "object", + "required": [ + "url", + "digest" + ] + }, + "PythonPdmLockEntry": { + "properties": { + "summary": { + "type": "string", + "description": "Summary provides a description of the package" + }, + "files": { + "items": { + "$ref": "#/$defs/PythonPdmFileEntry" + }, + "type": "array", + "description": "Files are the package files with their paths and hash digests (for the base package without extras)" + }, + "marker": { + "type": "string", + "description": "Marker is the \"environment\" --conditional expressions that determine whether a package should be installed based on the runtime environment" + }, + "requiresPython": { + "type": "string", + "description": "RequiresPython specifies the Python version requirement (e.g., \"\u003e=3.6\")." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the dependency specifications for the base package (without extras)" + }, + "extras": { + "items": { + "$ref": "#/$defs/PythonPdmLockExtraVariant" + }, + "type": "array", + "description": "Extras contains variants for different extras combinations (PDM may have multiple entries per package)" + } + }, + "type": "object", + "required": [ + "summary", + "files" + ], + "description": "PythonPdmLockEntry represents a single package entry within a pdm.lock file." + }, + "PythonPdmLockExtraVariant": { + "properties": { + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional extras enabled for this variant (e.g., [\"toml\"], [\"dev\"], or [\"toml\", \"dev\"])" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the dependencies specific to this extras variant" + }, + "files": { + "items": { + "$ref": "#/$defs/PythonPdmFileEntry" + }, + "type": "array", + "description": "Files are the package files specific to this variant (only populated if different from base)" + }, + "marker": { + "type": "string", + "description": "Marker is the environment conditional expression for this variant (e.g., \"python_version \u003c \\\"3.11\\\"\")" + } + }, + "type": "object", + "required": [ + "extras" + ], + "description": "PythonPdmLockExtraVariant represents a specific extras combination variant within a PDM lock file." + }, + "PythonPipRequirementsEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name from the requirements file." + }, + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional features to install from the package (e.g., package[dev,test])." + }, + "versionConstraint": { + "type": "string", + "description": "VersionConstraint specifies version requirements (e.g., \"\u003e=1.0,\u003c2.0\")." + }, + "url": { + "type": "string", + "description": "URL is the direct download URL or VCS URL if specified instead of a PyPI package." + }, + "markers": { + "type": "string", + "description": "Markers are environment marker expressions for conditional installation (e.g., \"python_version \u003e= '3.8'\")." + } + }, + "type": "object", + "required": [ + "name", + "versionConstraint" + ], + "description": "PythonRequirementsEntry represents a single entry within a [*-]requirements.txt file." + }, + "PythonPipfileLockEntry": { + "properties": { + "hashes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Hashes are the package file hash values in the format \"algorithm:digest\" for integrity verification." + }, + "index": { + "type": "string", + "description": "Index is the PyPI index name where the package should be fetched from." + } + }, + "type": "object", + "required": [ + "hashes", + "index" + ], + "description": "PythonPipfileLockEntry represents a single package entry within a Pipfile.lock file." + }, + "PythonPoetryLockDependencyEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the dependency package name." + }, + "version": { + "type": "string", + "description": "Version is the locked version or version constraint for the dependency." + }, + "optional": { + "type": "boolean", + "description": "Optional indicates whether this dependency is optional (only needed for certain extras)." + }, + "markers": { + "type": "string", + "description": "Markers are environment marker expressions that conditionally enable the dependency (e.g., \"python_version \u003e= '3.8'\")." + }, + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional feature names from the dependency that should be installed." + } + }, + "type": "object", + "required": [ + "name", + "version", + "optional" + ], + "description": "PythonPoetryLockDependencyEntry represents a single dependency entry within a Poetry lock file." + }, + "PythonPoetryLockEntry": { + "properties": { + "index": { + "type": "string", + "description": "Index is the package repository name where the package should be fetched from." + }, + "dependencies": { + "items": { + "$ref": "#/$defs/PythonPoetryLockDependencyEntry" + }, + "type": "array", + "description": "Dependencies are the package's runtime dependencies with version constraints." + }, + "extras": { + "items": { + "$ref": "#/$defs/PythonPoetryLockExtraEntry" + }, + "type": "array", + "description": "Extras are optional feature groups that include additional dependencies." + } + }, + "type": "object", + "required": [ + "index", + "dependencies" + ], + "description": "PythonPoetryLockEntry represents a single package entry within a Pipfile.lock file." + }, + "PythonPoetryLockExtraEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the optional feature name (e.g., \"dev\", \"test\")." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the package names required when this extra is installed." + } + }, + "type": "object", + "required": [ + "name", + "dependencies" + ], + "description": "PythonPoetryLockExtraEntry represents an optional feature group in a Poetry lock file." + }, + "PythonUvLockDependencyEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the dependency package name." + }, + "optional": { + "type": "boolean", + "description": "Optional indicates whether this dependency is optional (only needed for certain extras)." + }, + "markers": { + "type": "string", + "description": "Markers are environment marker expressions that conditionally enable the dependency (e.g., \"python_version \u003e= '3.8'\")." + }, + "extras": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Extras are the optional feature names from the dependency that should be installed." + } + }, + "type": "object", + "required": [ + "name", + "optional" + ], + "description": "PythonUvLockDependencyEntry represents a single dependency entry within a uv lock file." + }, + "PythonUvLockEntry": { + "properties": { + "index": { + "type": "string", + "description": "Index is the package repository name where the package should be fetched from." + }, + "dependencies": { + "items": { + "$ref": "#/$defs/PythonUvLockDependencyEntry" + }, + "type": "array", + "description": "Dependencies are the package's runtime dependencies with version constraints." + }, + "extras": { + "items": { + "$ref": "#/$defs/PythonUvLockExtraEntry" + }, + "type": "array", + "description": "Extras are optional feature groups that include additional dependencies." + } + }, + "type": "object", + "required": [ + "index", + "dependencies" + ], + "description": "PythonUvLockEntry represents a single package entry within a uv.lock file." + }, + "PythonUvLockExtraEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the optional feature name (e.g., \"dev\", \"test\")." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the package names required when this extra is installed." + } + }, + "type": "object", + "required": [ + "name", + "dependencies" + ], + "description": "PythonUvLockExtraEntry represents an optional feature group in a uv lock file." + }, + "RDescription": { + "properties": { + "title": { + "type": "string", + "description": "Title is short one-line package title" + }, + "description": { + "type": "string", + "description": "Description is detailed package description" + }, + "author": { + "type": "string", + "description": "Author is package author(s)" + }, + "maintainer": { + "type": "string", + "description": "Maintainer is current package maintainer" + }, + "url": { + "items": { + "type": "string" + }, + "type": "array", + "description": "URL is the list of related URLs" + }, + "repository": { + "type": "string", + "description": "Repository is CRAN or other repository name" + }, + "built": { + "type": "string", + "description": "Built is R version and platform this was built with" + }, + "needsCompilation": { + "type": "boolean", + "description": "NeedsCompilation is whether this package requires compilation" + }, + "imports": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Imports are the packages imported in the NAMESPACE" + }, + "depends": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Depends are the packages this package depends on" + }, + "suggests": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Suggests are the optional packages that extend functionality" + } + }, + "type": "object", + "description": "RDescription represents metadata from an R package DESCRIPTION file containing package information, dependencies, and author details." + }, + "Relationship": { + "properties": { + "parent": { + "type": "string", + "description": "Parent is the ID of the parent artifact in this relationship." + }, + "child": { + "type": "string", + "description": "Child is the ID of the child artifact in this relationship." + }, + "type": { + "type": "string", + "description": "Type is the relationship type (e.g., \"contains\", \"dependency-of\", \"ancestor-of\")." + }, + "metadata": { + "description": "Metadata contains additional relationship-specific metadata." + } + }, + "type": "object", + "required": [ + "parent", + "child", + "type" + ], + "description": "Relationship represents a directed relationship between two artifacts in the SBOM, such as package-contains-file or package-depends-on-package." + }, + "RpmArchive": { + "properties": { + "name": { + "type": "string", + "description": "Name is the RPM package name as found in the RPM database." + }, + "version": { + "type": "string", + "description": "Version is the upstream version of the package." + }, + "epoch": { + "oneOf": [ + { + "type": "integer", + "description": "Epoch is the version epoch used to force upgrade ordering (null if not set)." + }, + { + "type": "null" + } + ] + }, + "architecture": { + "type": "string", + "description": "Arch is the target CPU architecture (e.g., \"x86_64\", \"aarch64\", \"noarch\")." + }, + "release": { + "type": "string", + "description": "Release is the package release number or distribution-specific version suffix." + }, + "sourceRpm": { + "type": "string", + "description": "SourceRpm is the source RPM filename that was used to build this package." + }, + "signatures": { + "items": { + "$ref": "#/$defs/RpmSignature" + }, + "type": "array", + "description": "Signatures contains GPG signature metadata for package verification." + }, + "size": { + "type": "integer", + "description": "Size is the total installed size of the package in bytes." + }, + "vendor": { + "type": "string", + "description": "Vendor is the organization that packaged the software." + }, + "modularityLabel": { + "type": "string", + "description": "ModularityLabel identifies the module stream for modular RPM packages (e.g., \"nodejs:12:20200101\")." + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides lists the virtual packages and capabilities this package provides." + }, + "requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Requires lists the dependencies required by this package." + }, + "files": { + "items": { + "$ref": "#/$defs/RpmFileRecord" + }, + "type": "array", + "description": "Files are the file records for all files owned by this package." + } + }, + "type": "object", + "required": [ + "name", + "version", + "epoch", + "architecture", + "release", + "sourceRpm", + "size", + "vendor", + "files" + ], + "description": "RpmArchive represents package metadata extracted directly from a .rpm archive file, containing the same information as an RPM database entry." + }, + "RpmDbEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is the RPM package name as found in the RPM database." + }, + "version": { + "type": "string", + "description": "Version is the upstream version of the package." + }, + "epoch": { + "oneOf": [ + { + "type": "integer", + "description": "Epoch is the version epoch used to force upgrade ordering (null if not set)." + }, + { + "type": "null" + } + ] + }, + "architecture": { + "type": "string", + "description": "Arch is the target CPU architecture (e.g., \"x86_64\", \"aarch64\", \"noarch\")." + }, + "release": { + "type": "string", + "description": "Release is the package release number or distribution-specific version suffix." + }, + "sourceRpm": { + "type": "string", + "description": "SourceRpm is the source RPM filename that was used to build this package." + }, + "signatures": { + "items": { + "$ref": "#/$defs/RpmSignature" + }, + "type": "array", + "description": "Signatures contains GPG signature metadata for package verification." + }, + "size": { + "type": "integer", + "description": "Size is the total installed size of the package in bytes." + }, + "vendor": { + "type": "string", + "description": "Vendor is the organization that packaged the software." + }, + "modularityLabel": { + "type": "string", + "description": "ModularityLabel identifies the module stream for modular RPM packages (e.g., \"nodejs:12:20200101\")." + }, + "provides": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Provides lists the virtual packages and capabilities this package provides." + }, + "requires": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Requires lists the dependencies required by this package." + }, + "files": { + "items": { + "$ref": "#/$defs/RpmFileRecord" + }, + "type": "array", + "description": "Files are the file records for all files owned by this package." + } + }, + "type": "object", + "required": [ + "name", + "version", + "epoch", + "architecture", + "release", + "sourceRpm", + "size", + "vendor", + "files" + ], + "description": "RpmDBEntry represents all captured data from a RPM DB package entry." + }, + "RpmFileRecord": { + "properties": { + "path": { + "type": "string", + "description": "Path is the absolute file path where the file is installed." + }, + "mode": { + "type": "integer", + "description": "Mode is the file permission mode bits following Unix stat.h conventions." + }, + "size": { + "type": "integer", + "description": "Size is the file size in bytes." + }, + "digest": { + "$ref": "#/$defs/Digest", + "description": "Digest contains the hash algorithm and value for file integrity verification." + }, + "userName": { + "type": "string", + "description": "UserName is the owner username for the file." + }, + "groupName": { + "type": "string", + "description": "GroupName is the group name for the file." + }, + "flags": { + "type": "string", + "description": "Flags indicates the file type (e.g., \"%config\", \"%doc\", \"%ghost\")." + } + }, + "type": "object", + "required": [ + "path", + "mode", + "size", + "digest", + "userName", + "groupName", + "flags" + ], + "description": "RpmFileRecord represents the file metadata for a single file attributed to a RPM package." + }, + "RpmSignature": { + "properties": { + "algo": { + "type": "string", + "description": "PublicKeyAlgorithm is the public key algorithm used for signing (e.g., \"RSA\")." + }, + "hash": { + "type": "string", + "description": "HashAlgorithm is the hash algorithm used for the signature (e.g., \"SHA256\")." + }, + "created": { + "type": "string", + "description": "Created is the timestamp when the signature was created." + }, + "issuer": { + "type": "string", + "description": "IssuerKeyID is the GPG key ID that created the signature." + } + }, + "type": "object", + "required": [ + "algo", + "hash", + "created", + "issuer" + ], + "description": "RpmSignature represents a GPG signature for an RPM package used for authenticity verification." + }, + "RubyGemspec": { + "properties": { + "name": { + "type": "string", + "description": "Name is gem name as specified in the gemspec" + }, + "version": { + "type": "string", + "description": "Version is gem version as specified in the gemspec" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Files is logical list of files in the gem (NOT directly usable as filesystem paths. Example: bundler gem lists \"lib/bundler/vendor/uri/lib/uri/ldap.rb\" but actual path is \"/usr/local/lib/ruby/3.2.0/bundler/vendor/uri/lib/uri/ldap.rb\". Would need gem installation path, ruby version, and env vars like GEM_HOME to resolve actual paths.)" + }, + "authors": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Authors are the list of gem authors (stored as array regardless of using `author` or `authors` method in gemspec)" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "RubyGemspec represents all metadata parsed from the *.gemspec file" + }, + "RustCargoAuditEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is crate name as specified in audit section of the build binary" + }, + "version": { + "type": "string", + "description": "Version is crate version as specified in audit section of the build binary" + }, + "source": { + "type": "string", + "description": "Source is the source registry or repository where this crate came from" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source" + ], + "description": "RustBinaryAuditEntry represents Rust crate metadata extracted from a compiled binary using cargo-auditable format." + }, + "RustCargoLockEntry": { + "properties": { + "name": { + "type": "string", + "description": "Name is crate name as specified in Cargo.toml" + }, + "version": { + "type": "string", + "description": "Version is crate version as specified in Cargo.toml" + }, + "source": { + "type": "string", + "description": "Source is the source registry or repository URL in format \"registry+https://github.com/rust-lang/crates.io-index\" for registry packages" + }, + "checksum": { + "type": "string", + "description": "Checksum is content checksum for registry packages only (hexadecimal string). Cargo doesn't require or include checksums for git dependencies. Used to detect MITM attacks by verifying downloaded crate matches lockfile checksum." + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the list of dependencies with version constraints" + } + }, + "type": "object", + "required": [ + "name", + "version", + "source", + "checksum", + "dependencies" + ], + "description": "RustCargoLockEntry represents a locked dependency from a Cargo.lock file with precise version and checksum information." + }, + "SafetensorsModelInfo": { + "properties": { + "format": { + "type": "string", + "description": "Format is the source format label (always \"safetensors\" for this metadata type).\nPresent because the Docker AI model config blob carries an explicit format field" + }, + "architecture": { + "type": "string", + "description": "Architecture is the model architecture (e.g., \"LlamaForCausalLM\",\n\"Qwen3MoeForConditionalGeneration\"). It is not present in the SafeTensors\nheader itself; it is enriched from the companion config.json\n\"architectures\" array when one is found alongside the model." + }, + "quantization": { + "type": "string", + "description": "Quantization describes tensor precision (e.g., \"BF16\", \"F16\", \"F32\", \"INT8\")." + }, + "parameters": { + "type": "integer", + "description": "Parameters is the total number of model parameters, computed from the tensor\nshapes in the SafeTensors header(s). For a sharded model it is the sum across\nevery shard." + }, + "tensorCount": { + "type": "integer", + "description": "TensorCount is the number of tensor entries in the file header." + }, + "totalSize": { + "type": "string", + "description": "TotalSize is the total byte size of tensor data across all shards when known\n(from the Docker AI model config \"size\" field)." + }, + "shardCount": { + "type": "integer", + "description": "ShardCount is the number of .safetensors shards for a sharded model (1 for a\nsingle-file model)." + }, + "userMetadata": { + "$ref": "#/$defs/KeyValues", + "description": "UserMetadata is the optional \"__metadata__\" map from a .safetensors file header\n(string-to-string key/values set by the producer)." + }, + "metadataHash": { + "type": "string", + "description": "MetadataHash is an xxhash over the on-disk SafeTensors header (sorted tensor\nentries + __metadata__). It is derived ONLY from the safetensors file bytes." + }, + "parts": { + "items": { + "$ref": "#/$defs/SafetensorsModelInfo" + }, + "type": "array", + "description": "Parts contains metadata from additional SafeTensors shards or OCI layers that\nwere merged into this package during post-processing." + } + }, + "type": "object", + "description": "SafeTensorsModelInfo holds the model details extracted from SafeTensors content." + }, + "Schema": { + "properties": { + "version": { + "type": "string", + "description": "Version is the JSON schema version for this document format." + }, + "url": { + "type": "string", + "description": "URL is the URL to the JSON schema definition document." + } + }, + "type": "object", + "required": [ + "version", + "url" + ], + "description": "Schema specifies the JSON schema version and URL reference that defines the structure and validation rules for this document format." + }, + "SnapEntry": { + "properties": { + "snapType": { + "type": "string", + "description": "SnapType indicates the snap type (base, kernel, app, gadget, or snapd)." + }, + "base": { + "type": "string", + "description": "Base is the base snap name that this snap depends on (e.g., \"core20\", \"core22\")." + }, + "snapName": { + "type": "string", + "description": "SnapName is the snap package name." + }, + "snapVersion": { + "type": "string", + "description": "SnapVersion is the snap package version." + }, + "architecture": { + "type": "string", + "description": "Architecture is the target CPU architecture (e.g., \"amd64\", \"arm64\")." + } + }, + "type": "object", + "required": [ + "snapType", + "base", + "snapName", + "snapVersion", + "architecture" + ], + "description": "SnapEntry represents metadata for a Snap package extracted from snap.yaml or snapcraft.yaml files." + }, + "Source": { + "properties": { + "id": { + "type": "string", + "description": "ID is a unique identifier for the analyzed source artifact." + }, + "name": { + "type": "string", + "description": "Name is the name of the analyzed artifact (e.g., image name, directory path)." + }, + "version": { + "type": "string", + "description": "Version is the version of the analyzed artifact (e.g., image tag)." + }, + "supplier": { + "type": "string", + "description": "Supplier is supplier information, which can be user-provided for NTIA minimum elements compliance." + }, + "type": { + "type": "string", + "description": "Type is the source type (e.g., \"image\", \"directory\", \"file\")." + }, + "metadata": { + "description": "Metadata contains additional source-specific metadata." + } + }, + "type": "object", + "required": [ + "id", + "name", + "version", + "type", + "metadata" + ], + "description": "Source represents the artifact that was analyzed to generate this SBOM, such as a container image, directory, or file archive." + }, + "SwiftPackageManagerLockEntry": { + "properties": { + "revision": { + "type": "string", + "description": "Revision is git commit hash of the resolved package" + } + }, + "type": "object", + "required": [ + "revision" + ], + "description": "SwiftPackageManagerResolvedEntry represents a resolved dependency from a Package.resolved file with its locked version and source location." + }, + "SwiplpackPackage": { + "properties": { + "name": { + "type": "string", + "description": "Name is the package name as found in the .toml file" + }, + "version": { + "type": "string", + "description": "Version is the package version as found in the .toml file" + }, + "author": { + "type": "string", + "description": "Author is author name" + }, + "authorEmail": { + "type": "string", + "description": "AuthorEmail is author email address" + }, + "packager": { + "type": "string", + "description": "Packager is packager name (if different from author)" + }, + "packagerEmail": { + "type": "string", + "description": "PackagerEmail is packager email address" + }, + "homepage": { + "type": "string", + "description": "Homepage is project homepage URL" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the list of required dependencies" + } + }, + "type": "object", + "required": [ + "name", + "version", + "author", + "authorEmail", + "packager", + "packagerEmail", + "homepage", + "dependencies" + ], + "description": "SwiplPackEntry represents a SWI-Prolog package from the pack system with metadata about the package and its dependencies." + }, + "TerraformLockProviderEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the provider source address (e.g., \"registry.terraform.io/hashicorp/aws\")." + }, + "constraints": { + "type": "string", + "description": "Constraints specifies the version constraints for the provider (e.g., \"~\u003e 4.0\")." + }, + "version": { + "type": "string", + "description": "Version is the locked provider version selected during terraform init." + }, + "hashes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Hashes are cryptographic checksums for the provider plugin archives across different platforms." + } + }, + "type": "object", + "required": [ + "url", + "constraints", + "version", + "hashes" + ], + "description": "TerraformLockProviderEntry represents a single provider entry in a Terraform dependency lock file (.terraform.lock.hcl)." + }, + "VcpkgManifest": { + "properties": { + "description": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Description is the human-readable description of the package (may be a single string or a list of paragraphs in the manifest)." + }, + "documentation": { + "type": "string", + "description": "Documentation is the URL to the package's documentation." + }, + "full-version": { + "type": "string", + "description": "FullVersion is the complete version string including the port-version suffix (e.g. \"1.2.3#2\")." + }, + "version": { + "type": "string", + "description": "Version is the upstream package version without the port-version suffix (e.g. \"1.2.3\")." + }, + "port-version": { + "type": "integer", + "description": "PortVersion is the vcpkg-specific packaging revision for a given upstream version." + }, + "maintainers": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Maintainers are the people responsible for maintaining the vcpkg port." + }, + "name": { + "type": "string", + "description": "Name is the package name as declared in the manifest." + }, + "supports": { + "type": "string", + "description": "Supports is the platform expression describing which triplets the package can be built for (e.g. \"!windows\")." + }, + "registry": { + "$ref": "#/$defs/VcpkgRegistryEntry", + "description": "Registry indicates where the package definition came from." + }, + "triplet": { + "type": "string", + "description": "Triplet is the build target discovered from the build folder (e.g. \"x64-linux\")." + } + }, + "type": "object", + "required": [ + "full-version", + "version", + "port-version", + "name" + ], + "description": "VcpkgManifest summarizes the data found in a vcpkg manifest (vcpkg.json) relevant to a single vcpkg package." + }, + "VcpkgRegistryEntry": { + "properties": { + "baseline": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "packages": { + "items": { + "type": "string" + }, + "type": "array" + }, + "path": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "repository": { + "type": "string" + } + }, + "type": "object", + "required": [ + "kind" + ], + "description": "Matches definition of Vcpkg \"Registry\"." + }, + "WordpressPluginEntry": { + "properties": { + "pluginInstallDirectory": { + "type": "string", + "description": "PluginInstallDirectory is directory name where the plugin is installed" + }, + "author": { + "type": "string", + "description": "Author is plugin author name" + }, + "authorUri": { + "type": "string", + "description": "AuthorURI is author's website URL" + } + }, + "type": "object", + "required": [ + "pluginInstallDirectory" + ], + "description": "WordpressPluginEntry represents all metadata parsed from the wordpress plugin file" + }, + "cpes": { + "items": { + "$ref": "#/$defs/CPE" + }, + "type": "array" + }, + "licenses": { + "items": { + "$ref": "#/$defs/License" + }, + "type": "array" + } + } +} diff --git a/schema/json/schema-latest.json b/schema/json/schema-latest.json index 4589abee2..74021283f 100644 --- a/schema/json/schema-latest.json +++ b/schema/json/schema-latest.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "anchore.io/schema/syft/json/16.1.4/document", + "$id": "anchore.io/schema/syft/json/16.1.11/document", "$ref": "#/$defs/Document", "$defs": { "AlpmDbEntry": { @@ -249,6 +249,67 @@ ], "description": "ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records)." }, + "AppleAppBundleEntry": { + "properties": { + "bundleIdentifier": { + "type": "string", + "description": "BundleIdentifier is the unique identifier for the bundle (e.g. \"com.apple.Safari\")" + }, + "name": { + "type": "string", + "description": "Name is the short name of the bundle" + }, + "displayName": { + "type": "string", + "description": "DisplayName is the user-facing name of the bundle" + }, + "executable": { + "type": "string", + "description": "Executable is the name of the executable within the bundle" + }, + "shortVersion": { + "type": "string", + "description": "ShortVersion is the release (marketing) version of the bundle" + }, + "version": { + "type": "string", + "description": "Version is the build version of the bundle, which often differs from the short version" + }, + "packageType": { + "type": "string", + "description": "PackageType is the four-letter type code (e.g. \"APPL\" for apps, \"FMWK\" for frameworks, \"BNDL\" for bundles)" + }, + "supportedPlatforms": { + "items": { + "type": "string" + }, + "type": "array", + "description": "SupportedPlatforms lists the platforms the bundle targets (e.g. \"MacOSX\", \"iPhoneOS\")" + }, + "minimumSystemVersion": { + "type": "string", + "description": "MinimumSystemVersion is the minimum macOS version required to run the bundle" + }, + "minimumOSVersion": { + "type": "string", + "description": "MinimumOSVersion is the minimum OS version required for non-macOS platforms (e.g. iOS)" + }, + "copyright": { + "type": "string", + "description": "Copyright is the human-readable copyright notice for the bundle" + }, + "platformName": { + "type": "string", + "description": "PlatformName is the platform name of the SDK used to build the bundle (e.g. \"macosx\")" + }, + "sdkName": { + "type": "string", + "description": "SDKName is the name of the SDK used to build the bundle (e.g. \"macosx14.0\")" + } + }, + "type": "object", + "description": "AppleAppBundleEntry represents metadata about an Apple application bundle (CFBundle) extracted from Info.plist files." + }, "BinarySignature": { "properties": { "matches": { @@ -659,7 +720,11 @@ }, "layerID": { "type": "string", - "description": "FileSystemID is an ID representing and entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank." + "description": "FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. A file found inside an archive carries the FileSystemID of the filesystem the archive itself was found in (a layer digest, or blank), inherited unchanged down the nesting chain; the nesting chain is carried by ArchivePath instead." + }, + "archivePath": { + "type": "string", + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, of every archive traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). It is blank for files not found within an archive. It disambiguates identically-named files residing in different archives on the same filesystem." } }, "type": "object", @@ -748,6 +813,45 @@ ], "description": "DartPubspecLockEntry is a struct that represents a single entry found in the \"packages\" section in a Dart pubspec.lock file." }, + "DenoLockEntry": { + "properties": { + "integrity": { + "type": "string", + "description": "Integrity is the crpto hash of the package content for verification" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies is the list of package specifiers that this package depends on" + } + }, + "type": "object", + "required": [ + "integrity", + "dependencies" + ], + "description": "DenoLockEntry is a struct that rep a single entry found in the \"packages\" section of a Deno deno.lock file" + }, + "DenoRemoteLockEntry": { + "properties": { + "url": { + "type": "string", + "description": "URL is the remote URL from which the module fetcef" + }, + "integrity": { + "type": "string", + "description": "Integrity is the crpto hash of the package content for verification" + } + }, + "type": "object", + "required": [ + "url", + "integrity" + ], + "description": "DenoRemoteLockEntry is a struct that rep a single entry found in the \"remote\" section of a Deno deno.lock file" + }, "Descriptor": { "properties": { "name": { @@ -1228,6 +1332,13 @@ "pkgHashExt": { "type": "string", "description": "PkgHashExt is the extended package hash format (inner checksum is deprecated - SHA-256 of concatenated file contents excluding CHECKSUM file, now replaced by outer checksum)" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Dependencies are the names of the packages this entry depends on, as\ndeclared in the entry's dependency list within mix.lock. Used to derive\ndependency-of relationships between locked packages." } }, "type": "object", @@ -1546,6 +1657,16 @@ }, "type": "array", "description": "GoExperiments lists experimental Go features enabled during compilation (e.g., \"arenas\", \"cgocheck2\")." + }, + "symbols": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object", + "description": "Symbols are the function symbols from this module that are compiled into the binary, extracted from\nthe binary symbol table (pclntab) and grouped by the import path of the package that owns them. Each\nvalue is the sorted, deduplicated list of symbol names local to that package, i.e. with the import\npath prefix stripped (e.g. import path \"github.com/foo/bar\" -\u003e \"(*Type).Method\"). The fully qualified\nname is the import path, a \".\", and the local name. One exception: the binary's main package appears\nunder the key \"main\" (the name the linker assigns), not its original source import path, which is not\nrecoverable from the binary. Populated only when the golang cataloger's capture-symbols scope covers\nthis package: the \"all\" scope populates every module package plus the synthetic stdlib package, the\n\"extended-stdlib\" scope populates the stdlib package plus every module under golang.org/x/, and the\n\"stdlib\" scope populates only the stdlib package. The capture-symbols-modules glob patterns populate\nany additional modules they match." } }, "type": "object", @@ -2365,7 +2486,11 @@ }, "layerID": { "type": "string", - "description": "FileSystemID is an ID representing and entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank." + "description": "FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. A file found inside an archive carries the FileSystemID of the filesystem the archive itself was found in (a layer digest, or blank), inherited unchanged down the nesting chain; the nesting chain is carried by ArchivePath instead." + }, + "archivePath": { + "type": "string", + "description": "ArchivePath is the colon-delimited chain of archive paths, from the scan root, of every archive traversed to reach this file (e.g. \"app.war:WEB-INF/lib/dep.jar\"). It is blank for files not found within an archive. It disambiguates identically-named files residing in different archives on the same filesystem." }, "accessPath": { "type": "string", @@ -2628,6 +2753,9 @@ { "$ref": "#/$defs/ApkDbEntry" }, + { + "$ref": "#/$defs/AppleAppBundleEntry" + }, { "$ref": "#/$defs/BinarySignature" }, @@ -2658,6 +2786,12 @@ { "$ref": "#/$defs/DartPubspecLockEntry" }, + { + "$ref": "#/$defs/DenoLockEntry" + }, + { + "$ref": "#/$defs/DenoRemoteLockEntry" + }, { "$ref": "#/$defs/DotnetDepsEntry" }, @@ -2799,6 +2933,9 @@ { "$ref": "#/$defs/RustCargoLockEntry" }, + { + "$ref": "#/$defs/SafetensorsModelInfo" + }, { "$ref": "#/$defs/SnapEntry" }, @@ -2811,6 +2948,9 @@ { "$ref": "#/$defs/TerraformLockProviderEntry" }, + { + "$ref": "#/$defs/VcpkgManifest" + }, { "$ref": "#/$defs/WordpressPluginEntry" } @@ -4087,6 +4227,55 @@ ], "description": "RustCargoLockEntry represents a locked dependency from a Cargo.lock file with precise version and checksum information." }, + "SafetensorsModelInfo": { + "properties": { + "format": { + "type": "string", + "description": "Format is the source format label (always \"safetensors\" for this metadata type).\nPresent because the Docker AI model config blob carries an explicit format field" + }, + "architecture": { + "type": "string", + "description": "Architecture is the model architecture (e.g., \"LlamaForCausalLM\",\n\"Qwen3MoeForConditionalGeneration\"). It is not present in the SafeTensors\nheader itself; it is enriched from the companion config.json\n\"architectures\" array when one is found alongside the model." + }, + "quantization": { + "type": "string", + "description": "Quantization describes tensor precision (e.g., \"BF16\", \"F16\", \"F32\", \"INT8\")." + }, + "parameters": { + "type": "integer", + "description": "Parameters is the total number of model parameters, computed from the tensor\nshapes in the SafeTensors header(s). For a sharded model it is the sum across\nevery shard." + }, + "tensorCount": { + "type": "integer", + "description": "TensorCount is the number of tensor entries in the file header." + }, + "totalSize": { + "type": "string", + "description": "TotalSize is the total byte size of tensor data across all shards when known\n(from the Docker AI model config \"size\" field)." + }, + "shardCount": { + "type": "integer", + "description": "ShardCount is the number of .safetensors shards for a sharded model (1 for a\nsingle-file model)." + }, + "userMetadata": { + "$ref": "#/$defs/KeyValues", + "description": "UserMetadata is the optional \"__metadata__\" map from a .safetensors file header\n(string-to-string key/values set by the producer)." + }, + "metadataHash": { + "type": "string", + "description": "MetadataHash is an xxhash over the on-disk SafeTensors header (sorted tensor\nentries + __metadata__). It is derived ONLY from the safetensors file bytes." + }, + "parts": { + "items": { + "$ref": "#/$defs/SafetensorsModelInfo" + }, + "type": "array", + "description": "Parts contains metadata from additional SafeTensors shards or OCI layers that\nwere merged into this package during post-processing." + } + }, + "type": "object", + "description": "SafeTensorsModelInfo holds the model details extracted from SafeTensors content." + }, "Schema": { "properties": { "version": { @@ -4269,6 +4458,94 @@ ], "description": "TerraformLockProviderEntry represents a single provider entry in a Terraform dependency lock file (.terraform.lock.hcl)." }, + "VcpkgManifest": { + "properties": { + "description": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Description is the human-readable description of the package (may be a single string or a list of paragraphs in the manifest)." + }, + "documentation": { + "type": "string", + "description": "Documentation is the URL to the package's documentation." + }, + "full-version": { + "type": "string", + "description": "FullVersion is the complete version string including the port-version suffix (e.g. \"1.2.3#2\")." + }, + "version": { + "type": "string", + "description": "Version is the upstream package version without the port-version suffix (e.g. \"1.2.3\")." + }, + "port-version": { + "type": "integer", + "description": "PortVersion is the vcpkg-specific packaging revision for a given upstream version." + }, + "maintainers": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Maintainers are the people responsible for maintaining the vcpkg port." + }, + "name": { + "type": "string", + "description": "Name is the package name as declared in the manifest." + }, + "supports": { + "type": "string", + "description": "Supports is the platform expression describing which triplets the package can be built for (e.g. \"!windows\")." + }, + "registry": { + "$ref": "#/$defs/VcpkgRegistryEntry", + "description": "Registry indicates where the package definition came from." + }, + "triplet": { + "type": "string", + "description": "Triplet is the build target discovered from the build folder (e.g. \"x64-linux\")." + } + }, + "type": "object", + "required": [ + "full-version", + "version", + "port-version", + "name" + ], + "description": "VcpkgManifest summarizes the data found in a vcpkg manifest (vcpkg.json) relevant to a single vcpkg package." + }, + "VcpkgRegistryEntry": { + "properties": { + "baseline": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "packages": { + "items": { + "type": "string" + }, + "type": "array" + }, + "path": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "repository": { + "type": "string" + } + }, + "type": "object", + "required": [ + "kind" + ], + "description": "Matches definition of Vcpkg \"Registry\"." + }, "WordpressPluginEntry": { "properties": { "pluginInstallDirectory": { diff --git a/syft/archive_cataloger_test.go b/syft/archive_cataloger_test.go new file mode 100644 index 000000000..dc5074824 --- /dev/null +++ b/syft/archive_cataloger_test.go @@ -0,0 +1,998 @@ +package syft + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "unicode/utf8" + + "github.com/scylladb/go-set/strset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/cataloging" + "github.com/anchore/syft/syft/cataloging/filecataloging" + "github.com/anchore/syft/syft/cataloging/pkgcataloging" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/java" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" + "github.com/anchore/syft/syft/source/directorysource" +) + +// markerCataloger is a stub package cataloger that emits one package for every "marker.txt" file it +// finds. It lets the archive-cataloger end-to-end test assert behavior without depending on the +// fixture format of any real language cataloger. +type markerCataloger struct{} + +func (markerCataloger) Name() string { return "marker-cataloger" } + +func (markerCataloger) Catalog(_ context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) { + locations, err := resolver.FilesByGlob("**/marker.txt") + if err != nil { + return nil, nil, err + } + var pkgs []pkg.Package + for _, loc := range locations { + p := pkg.Package{ + Name: "marker-pkg", + Version: "1.0.0", + Type: pkg.BinaryPkg, + Locations: file.NewLocationSet(loc), + } + p.SetID() + pkgs = append(pkgs, p) + } + return pkgs, nil, nil +} + +// TestArchiveCataloger_endToEnd scans a directory containing a zip that holds a marker file, with +// recursive archive cataloging enabled, and asserts that: (1) the package discovered inside the +// archive is stamped with the archive's nesting-chain FileSystemID, and (2) a file-level CONTAINS +// relationship links the archive to the discovered package. +func TestArchiveCataloger_endToEnd(t *testing.T) { + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "app.zip"), map[string]string{"nested/marker.txt": "hello"}) + + src, err := directorysource.New(directorysource.Config{Path: scanDir}) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + cfg := DefaultCreateSBOMConfig(). + WithoutCatalogers(). + WithCatalogers(pkgcataloging.NewCatalogerReference(markerCataloger{}, []string{"directory"})) + cfg.Archive.MaxDepth = 1 + + s, err := cfg.Create(context.Background(), src) + require.NoError(t, err) + + pkgs := s.Artifacts.Packages.Sorted() + require.Len(t, pkgs, 1, "expected exactly one package, discovered inside the archive") + found := pkgs[0] + assert.Equal(t, "marker-pkg", found.Name) + + locs := found.Locations.ToSlice() + require.NotEmpty(t, locs) + // root directory FSID is empty, so the chain starts with the relative archive path + assert.Equal(t, "app.zip", locs[0].ArchivePath, "package location should carry the archive's FileSystemID") + + // a file-level CONTAINS relationship should link the archive file to the discovered package + var hasContains bool + for _, rel := range s.Relationships { + if rel.Type != artifact.ContainsRelationship || rel.To.ID() != found.ID() { + continue + } + coord, ok := rel.From.(file.Coordinates) + if ok && strings.HasSuffix(coord.RealPath, "app.zip") { + hasContains = true + } + } + assert.True(t, hasContains, "expected a CONTAINS relationship from app.zip to the discovered package") +} + +// TestArchiveCataloger_nestedRecursion validates depth-limited recursion and the FileSystemID +// nesting chain: a marker inside inner.zip inside outer.zip should be stamped with the full chain. +func TestArchiveCataloger_nestedRecursion(t *testing.T) { + innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"}) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{ + "deeper/inner.zip": innerZip, + }) + + src, err := directorysource.New(directorysource.Config{Path: scanDir}) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + cfg := DefaultCreateSBOMConfig(). + WithoutCatalogers(). + WithCatalogers(pkgcataloging.NewCatalogerReference(markerCataloger{}, []string{"directory"})) + cfg.Archive.MaxDepth = 2 + + s, err := cfg.Create(context.Background(), src) + require.NoError(t, err) + + pkgs := s.Artifacts.Packages.Sorted() + require.Len(t, pkgs, 1) + locs := pkgs[0].Locations.ToSlice() + require.NotEmpty(t, locs) + assert.Equal(t, "outer.zip:deeper/inner.zip", locs[0].ArchivePath, + "nested package location should carry the full archive nesting chain as its FileSystemID") +} + +func writeTestZip(t *testing.T, path string, files map[string]string) { + t.Helper() + raw := make(map[string][]byte, len(files)) + for k, v := range files { + raw[k] = []byte(v) + } + writeTestZipRaw(t, path, raw) +} + +func writeTestZipRaw(t *testing.T, path string, files map[string][]byte) { + t.Helper() + require.NoError(t, os.WriteFile(path, buildZipBytesRaw(t, files), 0o644)) +} + +func buildZipBytes(t *testing.T, files map[string]string) []byte { + t.Helper() + raw := make(map[string][]byte, len(files)) + for k, v := range files { + raw[k] = []byte(v) + } + return buildZipBytesRaw(t, raw) +} + +// buildZipBytesRaw writes entries in sorted name order. Extraction limits are enforced as the walk +// proceeds, so which entries land before a truncation depends on entry order; taking the order from +// the map would make any limit assertion flaky. +func buildZipBytesRaw(t *testing.T, files map[string][]byte) []byte { + t.Helper() + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, name := range names { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write(files[name]) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +// archiveScanConfig builds a CreateSBOMConfig limited to the given catalogers, with nested archive +// cataloging at the given depth (0 disables it). +func archiveScanConfig(depth int, catalogers ...pkg.Cataloger) *CreateSBOMConfig { + refs := make([]pkgcataloging.CatalogerReference, 0, len(catalogers)) + for _, c := range catalogers { + refs = append(refs, pkgcataloging.NewCatalogerReference(c, []string{"directory"})) + } + cfg := DefaultCreateSBOMConfig().WithoutCatalogers().WithCatalogers(refs...) + cfg.Archive.MaxDepth = depth + return cfg +} + +// javaScanConfig builds a config whose java cataloger comes from the cataloging FACTORY rather than +// being handed in pre-built. That distinction matters: the yield flag that hands recursion to the +// archive task is derived onto the factory's PackagesConfig, so a pre-built cataloger passed via +// WithCatalogers keeps whatever config it was constructed with and never learns to yield. +// +// It selects only the java-tagged catalogers rather than taking the whole default set, because the +// default set includes the RPM cataloger, which needs a sqlite driver this test binary does not +// register. +func javaScanConfig(depth int) *CreateSBOMConfig { + cfg := DefaultCreateSBOMConfig(). + WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")) + if depth > 0 { + cfg = cfg.WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth)) + } + return cfg +} + +func scanDirWith(t *testing.T, dir string, cfg *CreateSBOMConfig) *sbom.SBOM { + t.Helper() + src, err := directorysource.New(directorysource.Config{Path: dir}) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + s, err := cfg.Create(context.Background(), src) + require.NoError(t, err) + return s +} + +// scanDirWithExclusions scans with the given exclusion patterns configured on the SOURCE, which is +// where exclusions live: this capability has no setting of its own, so this is the only way to +// configure them and the same call a real consumer makes. +func scanDirWithExclusions(t *testing.T, dir string, cfg *CreateSBOMConfig, exclude ...string) *sbom.SBOM { + t.Helper() + src, err := directorysource.New(directorysource.Config{ + Path: dir, + Exclude: source.ExcludeConfig{Paths: exclude}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + s, err := cfg.Create(context.Background(), src) + require.NoError(t, err) + return s +} + +func jarBytes(t *testing.T, title, version string, extra map[string][]byte) []byte { + t.Helper() + entries := map[string][]byte{ + "META-INF/MANIFEST.MF": []byte( + "Manifest-Version: 1.0\nImplementation-Title: " + title + "\nImplementation-Version: " + version + "\n", + ), + } + for k, v := range extra { + entries[k] = v + } + return buildZipBytesRaw(t, entries) +} + +func TestArchiveCataloger_jarInsideArchiveCatalogedOnce(t *testing.T) { + // the failure this guards against is a DUPLICATE, not an error: before the archive task took + // ownership of recursion, a jar inside a zip was reached both by the task's walk and by the java + // cataloger's own unarchiving. Count, do not assert presence. + dep := jarBytes(t, "nested-lib", "2.0", nil) + + tests := []struct { + name string + container string + entry string + }{ + {name: "jar inside a zip", container: "bundle.zip", entry: "lib/nested-lib-2.0.jar"}, + {name: "jar inside a war", container: "app.war", entry: "WEB-INF/lib/nested-lib-2.0.jar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, tt.container), map[string][]byte{tt.entry: dep}) + + s := scanDirWith(t, scanDir, javaScanConfig(3)) + + var count int + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Name == "nested-lib" { + count++ + } + } + assert.Equal(t, 1, count, "the nested jar's package must be cataloged exactly once") + }) + } +} + +func TestArchiveCataloger_javaVirtualPathUnchangedByMechanism(t *testing.T) { + // these colon-delimited strings appear in already-published SBOMs, so the package must be + // reported under the same virtual path whichever mechanism reached it + dep := jarBytes(t, "nested-lib", "2.0", nil) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "app.war"), map[string][]byte{ + "WEB-INF/lib/nested-lib-2.0.jar": dep, + }) + + virtualPaths := func(t *testing.T, depth int) map[string]string { + t.Helper() + s := scanDirWith(t, scanDir, javaScanConfig(depth)) + + out := map[string]string{} + for _, p := range s.Artifacts.Packages.Sorted() { + if metadata, ok := p.Metadata.(pkg.JavaArchive); ok { + out[p.Name] = metadata.VirtualPath + } + } + return out + } + + off := virtualPaths(t, 0) + on := virtualPaths(t, 3) + + require.Contains(t, off, "nested-lib", "the java cataloger's own recursion must find it when the feature is off") + require.Contains(t, on, "nested-lib", "the archive cataloger must find it when the feature is on") + // this equality IS the compatibility guarantee: the string is in already-published SBOMs, so a + // package reached through the archive cataloger must not be reported under a different virtual + // path than the java cataloger's own recursion reported for it + assert.Equal(t, off["nested-lib"], on["nested-lib"], + "the nested jar's virtual path must be byte-identical between the two mechanisms") + + // and the format itself is the historical colon-delimited one. For a directory source the + // archive's access path is relative, so there is no leading separator on the chain. + assert.Equal(t, "app.war:WEB-INF/lib/nested-lib-2.0.jar", on["nested-lib"]) +} + +func TestArchiveCataloger_archiveContentsDoNotLeakIntoParentTree(t *testing.T) { + scanDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(scanDir, "app"), 0o755)) + writeTestZip(t, filepath.Join(scanDir, "app", "data.zip"), map[string]string{"nested/marker.txt": "hello"}) + + s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{})) + + for _, p := range s.Artifacts.Packages.Sorted() { + for _, loc := range p.Locations.ToSlice() { + // the nested file's path is relative to the archive, and the FileSystemID says which + // archive. A joined or flattened path would mean the archive's contents were smeared + // into the parent's tree, which is what the rejected resolver-recursion approach did. + assert.NotContains(t, loc.RealPath, "data.zip/") + assert.NotEqual(t, "/app/nested/marker.txt", loc.RealPath) + assert.Equal(t, "app/data.zip", loc.ArchivePath) + } + } +} + +func TestArchiveCataloger_siblingArchivesDoNotCollide(t *testing.T) { + // two archives containing the same internal path: the file tables are map[file.Coordinates]..., + // so without a distinct FileSystemID the second write silently overwrites the first and one + // package disappears with no error anywhere + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "one.zip"), map[string]string{"nested/marker.txt": "from one"}) + writeTestZip(t, filepath.Join(scanDir, "two.zip"), map[string]string{"nested/marker.txt": "from two"}) + + s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{})) + + // note: the two markers are the same package by syft's identity rules (same name, version and + // type), so they merge into one package carrying two locations. That merge is pre-existing + // behavior and is not what this test is about - what matters is that the two LOCATIONS survive + // as distinct coordinates instead of one overwriting the other. + fsIDs := strset.New() + var locCount int + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Name != "marker-pkg" { + continue + } + for _, loc := range p.Locations.ToSlice() { + locCount++ + fsIDs.Add(loc.ArchivePath) + assert.Equal(t, "nested/marker.txt", loc.RealPath, + "the paths are identical, which is exactly why the FileSystemID has to differ") + } + } + + assert.Equal(t, 2, locCount, "both archives' markers must survive as distinct locations") + assert.ElementsMatch(t, []string{"one.zip", "two.zip"}, fsIDs.List(), + "each archive must produce a distinct FileSystemID") +} + +func TestArchiveCataloger_containsEdgesChainAcrossLevels(t *testing.T) { + innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"}) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{"deeper/inner.zip": innerZip}) + + // file cataloging is on deliberately: the archive-to-file edges are recorded for the files the + // FILE catalogers found inside an archive, so without them there is no coordinate for inner.zip + // to hang an edge on and only the archive-to-package edges exist + cfg := archiveScanConfig(2, markerCataloger{}). + WithFilesConfig(filecataloging.Config{Selection: file.AllFilesSelection}) + s := scanDirWith(t, scanDir, cfg) + + // an edge from outer.zip to inner.zip (file-to-file), and one from inner.zip to the package + var outerToInner, innerToPkg bool + for _, rel := range s.Relationships { + if rel.Type != artifact.ContainsRelationship { + continue + } + from, ok := rel.From.(file.Coordinates) + if !ok { + continue + } + if to, ok := rel.To.(file.Coordinates); ok { + if strings.HasSuffix(from.RealPath, "outer.zip") && strings.HasSuffix(to.RealPath, "inner.zip") { + outerToInner = true + } + continue + } + if p, ok := rel.To.(pkg.Package); ok && p.Name == "marker-pkg" { + if strings.HasSuffix(from.RealPath, "inner.zip") { + innerToPkg = true + } + } + } + + assert.True(t, outerToInner, "expected a CONTAINS edge from outer.zip to inner.zip") + assert.True(t, innerToPkg, "expected a CONTAINS edge from inner.zip to the nested package") +} + +func TestArchiveCataloger_truncationRetainsWhatWasFound(t *testing.T) { + // a bound must truncate, not discard: the marker extracted before the disk limit filled stays in + // the SBOM, and a small sibling archive is cataloged in full because the first archive was + // released before it started + big := bytes.Repeat([]byte("x"), 8192) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "oversized.zip"), map[string][]byte{ + "a/marker.txt": []byte("small enough"), + "b/payload.bin": big, + }) + writeTestZip(t, filepath.Join(scanDir, "small.zip"), map[string]string{"nested/marker.txt": "fine"}) + + cfg := archiveScanConfig(1, markerCataloger{}) + cfg.Archive = cfg.Archive.WithMaxDiskBytes(1024) + + s := scanDirWith(t, scanDir, cfg) + + fsIDs := strset.New() + for _, p := range s.Artifacts.Packages.Sorted() { + for _, loc := range p.Locations.ToSlice() { + fsIDs.Add(loc.ArchivePath) + } + } + + assert.True(t, fsIDs.Has("oversized.zip"), + "the marker extracted before the limit must survive rather than the archive being discarded") + assert.True(t, fsIDs.Has("small.zip"), + "one archive hitting its limit must not affect another") +} + +func TestArchiveCataloger_detectionIsContentBased(t *testing.T) { + marker := map[string]string{"nested/marker.txt": "hello"} + + t.Run("extensionless and renamed archives are extracted", func(t *testing.T) { + scanDir := t.TempDir() + // no extension at all, and an extension that says something else entirely + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "bundle"), buildZipBytes(t, marker), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "data.bin"), buildZipBytes(t, marker), 0o644)) + + s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{})) + + fsIDs := strset.New() + for _, p := range s.Artifacts.Packages.Sorted() { + for _, loc := range p.Locations.ToSlice() { + fsIDs.Add(loc.ArchivePath) + } + } + assert.True(t, fsIDs.Has("bundle"), "an extensionless zip must be detected by content") + assert.True(t, fsIDs.Has("data.bin"), "a renamed zip must be detected by content") + }) + + t.Run("a misnamed non-archive is not extracted and is not an error", func(t *testing.T) { + scanDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "notes.zip"), []byte("just some text"), 0o644)) + + s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{})) + assert.Empty(t, s.Artifacts.Packages.Sorted()) + }) +} + +// TestArchiveCataloger_exclusionsAreTheScansOwn covers the whole of +// exclusions-apply-by-pattern-scope from the outside: there is no archive-specific exclusion +// setting, and the scan's own patterns decide, by their shape, whether they reach inside an archive. +func TestArchiveCataloger_exclusionsAreTheScansOwn(t *testing.T) { + marker := map[string]string{"nested/marker.txt": "hello"} + + t.Run("a recursive pattern reaches inside archives", func(t *testing.T) { + // the marker is INSIDE the archive, so nothing but an exclusion applied to the archive's own + // index can remove it: a filter over the scan root never sees this path at all + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker) + + s := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/marker.txt") + assert.Empty(t, s.Artifacts.Packages.Sorted(), + "a pattern matching at any depth must exclude a file inside an archive") + }) + + t.Run("a root-anchored pattern does not reach inside archives", func(t *testing.T) { + // the same relative path exists twice: once at the scan root, once inside the archive. An + // anchored pattern names the first and says nothing about the second + scanDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(scanDir, "nested"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "nested", "marker.txt"), []byte("at the root"), 0o644)) + writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker) + + s := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "./nested/marker.txt") + + var fsIDs []string + for _, p := range s.Artifacts.Packages.Sorted() { + for _, loc := range p.Locations.ToSlice() { + fsIDs = append(fsIDs, loc.ArchivePath) + } + } + assert.Equal(t, []string{"app.zip"}, fsIDs, + "the file at the scan root must be excluded and the one inside the archive kept") + }) + + t.Run("excluding an archive's own extension stops it being descended into", func(t *testing.T) { + // this is what replaces the removed extension list: the archive is not extracted, it is still + // an ordinary file, and nothing about it is an error + scanDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "pkg.rpm"), buildZipBytes(t, marker), 0o644)) + + s := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/*.rpm") + assert.Empty(t, s.Artifacts.Packages.Sorted(), + "an archive whose own path is excluded must not be extracted") + }) + + t.Run("a nested archive is excluded at its own level", func(t *testing.T) { + // outer.zip is not excluded and is cataloged; inner.zip is, so it is absent from outer's own + // filesystem, which is what makes it neither extracted nor recordable as a file. Asserted + // through the resolver rather than through the file catalogers: absence from the index is + // upstream of every consumer, so it settles both halves at once + innerZip := buildZipBytes(t, marker) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{ + "deeper/inner.zip": innerZip, + "outer/marker.txt": []byte("hello"), + }) + + var seen []string + probe := resolverProbeCataloger{ + glob: "**/*.zip", + record: func(paths []string) { + seen = append(seen, paths...) + }, + } + s := scanDirWithExclusions(t, scanDir, archiveScanConfig(2, markerCataloger{}, probe), "**/inner.zip") + + var fsIDs []string + for _, p := range s.Artifacts.Packages.Sorted() { + for _, loc := range p.Locations.ToSlice() { + fsIDs = append(fsIDs, loc.ArchivePath) + } + } + assert.Equal(t, []string{"outer.zip"}, fsIDs, + "the outer archive must still be cataloged and the inner one must contribute nothing") + + for _, path := range seen { + assert.NotContains(t, path, "inner.zip", + "the excluded inner archive must be absent from the outer archive's filesystem") + } + assert.NotEmpty(t, seen, "the probe must have seen the outer archive at the scan root") + }) + + t.Run("matching follows the scan's own case sensitivity", func(t *testing.T) { + // worth pinning because it differs from the removed extension list, which lower-cased both + // sides. An exclusion now behaves the same inside an archive as it does anywhere else in a + // scan, which is the point of using the scan's own patterns; a caller who wants both cases + // says so in the pattern, the way they would for any other exclusion + scanDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "PKG.RPM"), buildZipBytes(t, marker), 0o644)) + + kept := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/*.rpm") + assert.NotEmpty(t, kept.Artifacts.Packages.Sorted(), + "a lower-case pattern must not match an upper-case name") + + excluded := scanDirWithExclusions(t, scanDir, archiveScanConfig(1, markerCataloger{}), "**/*.[rR][pP][mM]") + assert.Empty(t, excluded.Artifacts.Packages.Sorted(), + "the case-insensitive intent is expressible as an ordinary pattern") + }) + + t.Run("no exclusions means nothing is excluded", func(t *testing.T) { + scanDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "pkg.rpm"), buildZipBytes(t, marker), 0o644)) + + s := scanDirWith(t, scanDir, archiveScanConfig(1, markerCataloger{})) + assert.NotEmpty(t, s.Artifacts.Packages.Sorted(), + "with no patterns configured every detected archive stays eligible") + }) + + t.Run("an excluded file is absent from the filesystem, not present and skipped", func(t *testing.T) { + // the distinction the whole mechanism rests on: an excluded entry is not in the archive's + // index, so nothing downstream can resolve it by path or by glob + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "app.zip"), map[string]string{ + "nested/marker.txt": "hello", + "nested/keep.txt": "hello", + }) + + var seen []string + probe := resolverProbeCataloger{ + glob: "**/*.txt", + record: func(paths []string) { + seen = append(seen, paths...) + }, + } + scanDirWithExclusions(t, scanDir, archiveScanConfig(1, probe), "**/marker.txt") + + assert.Contains(t, seen, "nested/keep.txt") + assert.NotContains(t, seen, "nested/marker.txt", + "an excluded path must not be resolvable from the archive's filesystem at all") + }) +} + +// TestArchiveCataloger_exclusionPatternPrecedence covers every-boundary-populates-the-patterns, which +// superseded the single-writer clause of patterns-ride-the-archive-config: +// cataloging.ArchiveSearchConfig.ExclusionPatterns is still a derived field (no yaml/json/mapstructure +// tag - there is still no key to supply a second, competing list through), but it is no longer written +// only by CreateSBOM. cmd/syft/internal/options.Catalog.ToArchiveConfig is the other boundary, filling +// it from the CLI's --exclude flag, and precedence between the two is defined rather than left to +// ordering: a value already present is never overwritten, and CreateSBOM consults the source only to +// fill an empty field. +func TestArchiveCataloger_exclusionPatternPrecedence(t *testing.T) { + marker := map[string]string{"nested/marker.txt": "hello"} + + t.Run("a value already present is not overwritten by the source", func(t *testing.T) { + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker) + + cfg := archiveScanConfig(1, markerCataloger{}) + cfg.Archive.ExclusionPatterns = []string{"**/marker.txt"} + + // the source publishes a different, non-matching pattern - if the source won, this would + // have no effect on the caller-supplied value + s := scanDirWithExclusions(t, scanDir, cfg, "**/does-not-exist.txt") + assert.Empty(t, s.Artifacts.Packages.Sorted(), + "a value already present on the config must be what is applied, not overwritten by what the source publishes") + }) + + t.Run("an empty field is filled from the source", func(t *testing.T) { + // the configure-only-your-source path: a consumer who never touches ExclusionPatterns + // directly still gets the source's own patterns applied inside archives + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker) + + cfg := archiveScanConfig(1, markerCataloger{}) + + s := scanDirWithExclusions(t, scanDir, cfg, "**/marker.txt") + assert.Empty(t, s.Artifacts.Packages.Sorted(), + "an empty field must be filled from the source's own published patterns") + }) + + t.Run("an empty field with no source exclusions excludes nothing", func(t *testing.T) { + scanDir := t.TempDir() + writeTestZip(t, filepath.Join(scanDir, "app.zip"), marker) + + cfg := archiveScanConfig(1, markerCataloger{}) + + s := scanDirWith(t, scanDir, cfg) + assert.NotEmpty(t, s.Artifacts.Packages.Sorted(), + "with nothing configured on either boundary, no exclusion should apply") + }) +} + +// resolverProbeCataloger reports what the resolver it was handed can actually see, which is how the +// difference between "absent from the index" and "present and skipped" is observed from a scan. +type resolverProbeCataloger struct { + glob string + record func([]string) +} + +func (resolverProbeCataloger) Name() string { return "resolver-probe-cataloger" } + +func (c resolverProbeCataloger) Catalog(_ context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) { + locations, err := resolver.FilesByGlob(c.glob) + if err != nil { + return nil, nil, err + } + var paths []string + for _, loc := range locations { + paths = append(paths, loc.RealPath) + } + c.record(paths) + return nil, nil, nil +} + +func TestArchiveCataloger_leavesNothingBehind(t *testing.T) { + // extracted content is written outside the scanned source and must not survive the scan, so a + // long-running process scanning many archives does not accumulate temp trees + innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"}) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{"deeper/inner.zip": innerZip}) + // a corrupt archive so the failure path's cleanup is exercised too + require.NoError(t, os.WriteFile(filepath.Join(scanDir, "corrupt.zip"), []byte("PK\x03\x04 truncated"), 0o644)) + + before := archiveTempDirs(t) + + scanDirWith(t, scanDir, archiveScanConfig(2, markerCataloger{})) + + assert.ElementsMatch(t, before, archiveTempDirs(t), + "no syft-archive-* temp dir may survive the scan, including for the archive that failed") + + // and nothing may be written into the directory under scan + var found []string + require.NoError(t, filepath.WalkDir(scanDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + rel, relErr := filepath.Rel(scanDir, path) + if relErr != nil { + return relErr + } + found = append(found, rel) + return nil + })) + assert.ElementsMatch(t, []string{"outer.zip", "corrupt.zip"}, found, + "nothing new may appear under the scanned directory") +} + +func archiveTempDirs(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(os.TempDir()) + require.NoError(t, err) + + var out []string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "syft-archive-") { + out = append(out, entry.Name()) + } + } + return out +} + +func TestArchiveCataloger_unicodeEntryNamesSurviveToOutput(t *testing.T) { + // entry names reach the FileSystemID and then the encoded SBOM; a mangled one is a corrupt + // identifier rather than a visible failure + innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"}) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{ + "日本語/café-🎉/inner.zip": innerZip, + }) + + s := scanDirWith(t, scanDir, archiveScanConfig(2, markerCataloger{})) + + pkgs := s.Artifacts.Packages.Sorted() + require.Len(t, pkgs, 1) + locs := pkgs[0].Locations.ToSlice() + require.NotEmpty(t, locs) + assert.Equal(t, "outer.zip:日本語/café-🎉/inner.zip", locs[0].ArchivePath) + assert.True(t, utf8.ValidString(locs[0].ArchivePath)) +} + +func TestArchiveCataloger_libraryParity(t *testing.T) { + // the library entry point must give a Go consumer the same behavior as a CLI user, and must not + // silently replace anything the consumer set on the package cataloger config + dep := jarBytes(t, "nested-lib", "2.0", nil) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "bundle.zip"), map[string][]byte{"lib/nested-lib-2.0.jar": dep}) + + countNestedLib := func(t *testing.T, s *sbom.SBOM) int { + t.Helper() + var n int + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Name == "nested-lib" { + n++ + } + } + return n + } + + t.Run("WithArchiveConfig enables the feature and hands java its yield", func(t *testing.T) { + s := scanDirWith(t, scanDir, javaScanConfig(3)) + assert.Equal(t, 1, countNestedLib(t, s), "reached exactly once, via the archive cataloger") + }) + + t.Run("setting only java's own depth neither enables the feature nor breaks java", func(t *testing.T) { + // java's squash-inlined MaxDepth is a config field a consumer can set; it must have no + // effect on recursion ownership, and must not be overwritten either + supplied := java.DefaultArchiveCatalogerConfig() + supplied.ArchiveSearchConfig = supplied.ArchiveSearchConfig.WithMaxDepth(3) + + cfg := javaScanConfig(0).WithPackagesConfig(pkgcataloging.Config{JavaArchive: supplied}) + + s := scanDirWith(t, scanDir, cfg) + // the archive task never ran, so java's own recursion is what found it - once + assert.Equal(t, 1, countNestedLib(t, s), "java must keep recursing itself") + assert.Equal(t, 3, cfg.Packages.JavaArchive.MaxDepth, "the consumer's value must survive") + }) +} + +func TestArchiveCataloger_noExtractionPathReachesTheSBOM(t *testing.T) { + // covers syft/archive-content-identity#nested-paths-are-archive-relative. AllLocations emitted + // the resolver's own root unrelativized, and filemetadata.Cataloger records every location it is + // handed, so a coordinate whose RealPath was the extraction temp dir reached FileMetadata, was + // merged into the shared SBOM, and gained a CONTAINS edge. File cataloging must be on: that is + // the only path that enumerates all locations rather than searching for them. + innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"}) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{"deeper/inner.zip": innerZip}) + + // the resolver normalizes its root with EvalSymlinks, so the scan root it reports is the + // resolved path. On macOS t.TempDir() sits under /var, a symlink to /private/var, so comparing + // against the unresolved value fails there while passing on linux. + resolvedScanDir, err := filepath.EvalSymlinks(scanDir) + require.NoError(t, err) + + cfg := archiveScanConfig(2, markerCataloger{}). + WithFilesConfig(filecataloging.Config{Selection: file.AllFilesSelection}) + s := scanDirWith(t, scanDir, cfg) + + // every path syft can report about this scan, from every table and both ends of every edge + var paths []string + for coords := range s.Artifacts.FileMetadata { + paths = append(paths, coords.RealPath) + } + for _, p := range s.Artifacts.Packages.Sorted() { + for _, loc := range p.Locations.ToSlice() { + paths = append(paths, loc.RealPath, loc.AccessPath) + } + } + for _, rel := range s.Relationships { + for _, end := range []artifact.Identifiable{rel.From, rel.To} { + if coords, ok := end.(file.Coordinates); ok { + paths = append(paths, coords.RealPath) + } + } + } + require.NotEmpty(t, paths) + + for _, p := range paths { + assert.NotContains(t, p, "syft-archive-", + "an extraction directory reached the SBOM at %q", p) + // the storage shape is an implementation detail of the scan, so the file an archive's entries + // were overflowed into must not name itself either - in a path, or as a segment of one + assert.NotContains(t, p, "contents.tar", + "the file holding an archive's entries reached the SBOM at %q", p) + if filepath.IsAbs(p) { + // the scan root itself is reported absolute by the directory resolver, and has been + // since long before this feature: it is the path the user asked for. Anything else + // absolute is a filesystem this feature invented leaking its own root. + assert.Equal(t, resolvedScanDir, p, "only the scanned directory itself may be reported absolute") + } + } +} + +func TestArchiveCataloger_repeatedScansAgreeOnNestedPaths(t *testing.T) { + // the reproducibility property the requirement exists for: extraction directories are created + // fresh per run, so anything of theirs that reaches output makes two scans of identical input + // disagree + innerZip := buildZipBytes(t, map[string]string{"nested/marker.txt": "hello"}) + scanDir := t.TempDir() + writeTestZipRaw(t, filepath.Join(scanDir, "outer.zip"), map[string][]byte{"deeper/inner.zip": innerZip}) + + scan := func(t *testing.T) []string { + t.Helper() + cfg := archiveScanConfig(2, markerCataloger{}). + WithFilesConfig(filecataloging.Config{Selection: file.AllFilesSelection}) + s := scanDirWith(t, scanDir, cfg) + + var out []string + for coords := range s.Artifacts.FileMetadata { + out = append(out, coords.ArchivePath+"|"+coords.RealPath) + } + sort.Strings(out) + return out + } + + first := scan(t) + require.NotEmpty(t, first) + assert.Equal(t, first, scan(t)) +} + +// buildTarGzBytes builds a gzipped tar in memory, in sorted entry order. +func buildTarGzBytes(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + writeTarEntries(t, gw, files) + require.NoError(t, gw.Close()) + return buf.Bytes() +} + +// buildTarBytes builds an uncompressed tar in memory. +func buildTarBytes(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + writeTarEntries(t, &buf, files) + return buf.Bytes() +} + +func writeTarEntries(t *testing.T, w io.Writer, files map[string][]byte) { + t.Helper() + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + + tw := tar.NewWriter(w) + for _, name := range names { + body := files[name] + require.NoError(t, tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))})) + _, err := tw.Write(body) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) +} + +func TestArchiveCataloger_tarFamilyIsCatalogedEndToEnd(t *testing.T) { + // nothing asserted the tar path end to end: the extensionless-archive case builds a ZIP named + // "bundle", so TarExtractor was only ever reached by its own unit tests. The compound extension + // is the interesting one - the archive is saved to a temp file before detection and the basename + // is preserved specifically so ".tar.gz" survives that round trip. + dep := jarBytes(t, "tar-nested-lib", "3.1", nil) + entry := map[string][]byte{"lib/tar-nested-lib-3.1.jar": dep} + + for name, body := range map[string][]byte{ + "bundle.tar.gz": buildTarGzBytes(t, entry), + "bundle.tgz": buildTarGzBytes(t, entry), + "bundle.tar": buildTarBytes(t, entry), + } { + t.Run(name, func(t *testing.T) { + scanDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(scanDir, name), body, 0o644)) + + s := scanDirWith(t, scanDir, javaScanConfig(2)) + + var count int + var virtualPath string + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Name != "tar-nested-lib" { + continue + } + count++ + if metadata, ok := p.Metadata.(pkg.JavaArchive); ok { + virtualPath = metadata.VirtualPath + } + } + require.Equal(t, 1, count, "the jar inside the tar must be cataloged exactly once") + assert.Equal(t, name+":lib/tar-nested-lib-3.1.jar", virtualPath) + }) + } +} + +func TestArchiveCataloger_legacySearchSettingsAreSuperseded(t *testing.T) { + // syft/nested-archive-cataloging#legacy-archive-search-settings-are-superseded. The row that + // matters is nested-on with both settings false: it looks like a bug and is not, so a future + // change that "fixes" it is changing a requirement. + dep := jarBytes(t, "legacy-lib", "1.2", nil) + entry := map[string][]byte{"lib/legacy-lib-1.2.jar": dep} + + containers := map[string][]byte{ + "bundle.zip": buildZipBytesRaw(t, entry), + "bundle.tar.gz": buildTarGzBytes(t, entry), + } + + // with the feature off, java's own wrapped-archive parsers are what reach the jar, and those are + // gated by these two settings: indexed covers the zip, unindexed covers the tar family + found := func(t *testing.T, container string, depth int, searchSettings *bool) bool { + t.Helper() + scanDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(scanDir, container), containers[container], 0o644)) + + cfg := javaScanConfig(depth) + if searchSettings != nil { + // these two settings only ever reach the java cataloger's wrapped-archive parser + // registrations, which is where the CLI's search-indexed-archives and + // search-unindexed-archives land as well + packages := cfg.Packages + packages.JavaArchive.IncludeIndexedArchives = *searchSettings + packages.JavaArchive.IncludeUnindexedArchives = *searchSettings + cfg = cfg.WithPackagesConfig(packages) + } + + for _, p := range scanDirWith(t, scanDir, cfg).Artifacts.Packages.Sorted() { + if p.Name == "legacy-lib" { + return true + } + } + return false + } + + yes, no := true, false + + for _, tt := range []struct { + name string + depth int + searchSettings *bool + wantZip bool + wantTarGz bool + }{ + {name: "off, both settings true", depth: 0, searchSettings: &yes, wantZip: true, wantTarGz: true}, + {name: "off, both settings false", depth: 0, searchSettings: &no, wantZip: false, wantTarGz: false}, + {name: "on, both settings true", depth: 2, searchSettings: &yes, wantZip: true, wantTarGz: true}, + // the load-bearing cell + {name: "on, both settings false", depth: 2, searchSettings: &no, wantZip: true, wantTarGz: true}, + // and what real users hit: unindexed defaults to false, so the tar.gz is cataloged with a + // setting whose documented meaning says it should not be + {name: "on, settings untouched", depth: 2, searchSettings: nil, wantZip: true, wantTarGz: true}, + {name: "off, settings untouched", depth: 0, searchSettings: nil, wantZip: true, wantTarGz: false}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantZip, found(t, "bundle.zip", tt.depth, tt.searchSettings), "zip") + assert.Equal(t, tt.wantTarGz, found(t, "bundle.tar.gz", tt.depth, tt.searchSettings), "tar.gz") + }) + } +} diff --git a/syft/archive_exclusions.go b/syft/archive_exclusions.go new file mode 100644 index 000000000..d8e706e66 --- /dev/null +++ b/syft/archive_exclusions.go @@ -0,0 +1,85 @@ +package syft + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/anchore/syft/syft/internal/fileresolver" + "github.com/anchore/syft/syft/source" +) + +// newArchiveExclusionVisitor builds an index visitor that drops the given patterns from the tree +// rooted at dir, which is one archive's extraction directory. Returns a nil visitor when there is +// nothing to exclude, so the resolver is built exactly as it was before when no pattern applies. +// +// Matching is done against each entry's path RELATIVE to the extraction directory. The directory +// source instead rewrites every pattern to be absolute against the scan root, and reusing that here +// would be wrong twice over: the root would be the wrong one, and an absolute pattern is matched +// against the absolute path of the entry, so a segment of the temp directory holding the extraction +// could satisfy a pattern the archive's own contents never do. Relative matching also keeps the +// patterns intact, which is what lets `**/x` match x at the archive's own root as well as below it. +func newArchiveExclusionVisitor(dir string, patterns []string) (fileresolver.PathIndexVisitor, error) { + if len(patterns) == 0 { + return nil, nil + } + + // the indexer reports an absolute, symlink-resolved path to every visitor: the resolver + // normalizes its root with EvalSymlinks (fileresolver.NormalizeRootDirectory, via the chroot + // context) and the walk then takes filepath.Abs of it. The prefix to strip has to be derived the + // same way or it strips nothing - on macOS an extraction directory lives under /var, a symlink + // to /private/var, and every pattern would quietly match nothing. + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, fmt.Errorf("unable to resolve archive extraction directory %q: %w", dir, err) + } + absolute, err := filepath.Abs(resolved) + if err != nil { + return nil, fmt.Errorf("unable to make archive extraction directory %q absolute: %w", dir, err) + } + + // slash-separated, to match doublestar's syntax on Windows too + root := filepath.ToSlash(absolute) + prefix := strings.TrimSuffix(root, "/") + "/" + + return func(_, path string, info os.FileInfo, _ error) error { + slashed := filepath.ToSlash(path) + if slashed == root || slashed == prefix { + // the extraction directory itself is not a candidate: it is the archive, not something in it + return nil + } + relative := strings.TrimPrefix(slashed, prefix) + + for _, pattern := range patterns { + matches, err := doublestar.Match(pattern, relative) + if err != nil { + // a malformed pattern excludes nothing rather than failing the archive; the source + // rejected anything it does not accept long before the scan reached here + continue + } + if !matches { + continue + } + if info != nil && info.IsDir() { + return filepath.SkipDir + } + return fileresolver.ErrSkipPath + } + return nil + }, nil +} + +// sourceExclusions returns the exclusion patterns the given source was configured with, or nil when +// it publishes none. Exclusions are the source's own configuration and appear nowhere in +// CreateSBOMConfig, so this is the only way the cataloging side learns them; a source that does not +// implement source.PathExcluder excludes nothing anywhere, archives included. +func sourceExclusions(src source.Source) []string { + excluder, ok := src.(source.PathExcluder) + if !ok { + return nil + } + return excluder.ExcludedPaths() +} diff --git a/syft/archive_exclusions_test.go b/syft/archive_exclusions_test.go new file mode 100644 index 000000000..23a2ac99b --- /dev/null +++ b/syft/archive_exclusions_test.go @@ -0,0 +1,143 @@ +package syft + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/internal/fileresolver" + "github.com/anchore/syft/syft/source" + "github.com/anchore/syft/syft/source/directorysource" +) + +func Test_newArchiveExclusionVisitor(t *testing.T) { + t.Run("no patterns means no visitor", func(t *testing.T) { + // so the archive resolver is built exactly as it was before this existed + visitor, err := newArchiveExclusionVisitor(t.TempDir(), nil) + require.NoError(t, err) + assert.Nil(t, visitor) + }) + + t.Run("matching is relative to the extraction directory", func(t *testing.T) { + // and NOT against a pattern rewritten to be absolute, which is what the directory source does + // with the scan root. The two are only distinguishable when the temp directory holding the + // extraction has a segment the pattern would match, which is why the check below builds one. + root := filepath.Join(t.TempDir(), "vendor") + require.NoError(t, os.MkdirAll(filepath.Join(root, "keep"), 0o755)) + + // the paths handed to a visitor are the symlink-resolved ones the indexer walks, so the test + // has to speak in those too: on macOS t.TempDir sits under /var, a symlink to /private/var, + // and an unresolved path would strip no prefix and match the temp directory's own segments + root, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + + visitor, err := newArchiveExclusionVisitor(root, []string{"**/vendor"}) + require.NoError(t, err) + require.NotNil(t, visitor) + + assert.NoError(t, visitor("", root, dirInfo(t, root), nil), + "the extraction directory itself is the archive, not something in it") + assert.NoError(t, visitor("", filepath.Join(root, "keep"), dirInfo(t, filepath.Join(root, "keep")), nil), + "a path under a matching temp directory must not inherit the match") + }) + + t.Run("a matching file is skipped and a matching directory is pruned", func(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "a", "vendor") + require.NoError(t, os.MkdirAll(dir, 0o755)) + file := filepath.Join(root, "a", "pkg.rpm") + require.NoError(t, os.WriteFile(file, []byte("x"), 0o644)) + + visitor, err := newArchiveExclusionVisitor(root, []string{"**/vendor", "**/*.rpm"}) + require.NoError(t, err) + require.NotNil(t, visitor) + + assert.ErrorIs(t, visitor("", file, fileInfo(t, file), nil), fileresolver.ErrSkipPath) + assert.ErrorIs(t, visitor("", dir, dirInfo(t, dir), nil), filepath.SkipDir) + }) + + t.Run("an any-depth pattern reaches the archive's own root", func(t *testing.T) { + // `**/x` matches x at zero depth as well as below it, which is what makes "exclude .rpm + // everywhere" stop an .rpm sitting directly inside another archive + root := t.TempDir() + file := filepath.Join(root, "pkg.rpm") + require.NoError(t, os.WriteFile(file, []byte("x"), 0o644)) + + visitor, err := newArchiveExclusionVisitor(root, []string{"**/*.rpm"}) + require.NoError(t, err) + + assert.ErrorIs(t, visitor("", file, fileInfo(t, file), nil), fileresolver.ErrSkipPath) + }) + + t.Run("a non-matching path is kept", func(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "keep.txt") + require.NoError(t, os.WriteFile(file, []byte("x"), 0o644)) + + visitor, err := newArchiveExclusionVisitor(root, []string{"**/*.rpm"}) + require.NoError(t, err) + + assert.NoError(t, visitor("", file, fileInfo(t, file), nil)) + }) + + t.Run("an unresolvable directory is an error rather than a silent no-match", func(t *testing.T) { + // the failure mode this guards against is invisible: a prefix that cannot be resolved strips + // nothing, every pattern then matches nothing, and the scan looks like it had no exclusions + _, err := newArchiveExclusionVisitor(filepath.Join(t.TempDir(), "does-not-exist"), []string{"**/x"}) + assert.Error(t, err) + }) +} + +func Test_sourceExclusions(t *testing.T) { + t.Run("a source publishes what it was configured with", func(t *testing.T) { + src, err := directorysource.New(directorysource.Config{ + Path: t.TempDir(), + Exclude: source.ExcludeConfig{Paths: []string{"**/*.rpm", "./x"}}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + assert.Equal(t, []string{"**/*.rpm", "./x"}, sourceExclusions(src)) + }) + + t.Run("building the source's own resolver does not rewrite them", func(t *testing.T) { + // the source rewrites exclusions to be absolute against the scan root while building its + // resolver. If that rewriting reached the configured patterns, none would begin "**/" any + // more and an archive's exclusions would degrade to "none configured" - which reads as a + // feature that was never switched on rather than as a bug + src, err := directorysource.New(directorysource.Config{ + Path: t.TempDir(), + Exclude: source.ExcludeConfig{Paths: []string{"**/*.rpm"}}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + _, err = src.FileResolver(source.SquashedScope) + require.NoError(t, err) + + assert.Equal(t, []string{"**/*.rpm"}, sourceExclusions(src)) + }) + + t.Run("a source that publishes nothing excludes nothing", func(t *testing.T) { + assert.Nil(t, sourceExclusions(source.FromDescription(source.Description{}))) + }) +} + +func dirInfo(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + require.True(t, info.IsDir()) + return info +} + +func fileInfo(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + require.False(t, info.IsDir()) + return info +} diff --git a/syft/archive_nesting_test.go b/syft/archive_nesting_test.go new file mode 100644 index 000000000..e82350736 --- /dev/null +++ b/syft/archive_nesting_test.go @@ -0,0 +1,1085 @@ +package syft + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/scylladb/go-set/strset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/go-logger" + "github.com/anchore/go-logger/adapter/discard" + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/cataloging" + "github.com/anchore/syft/syft/cataloging/filecataloging" + "github.com/anchore/syft/syft/cataloging/pkgcataloging" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source/directorysource" +) + +// This file covers nesting ACROSS archive families. Every other fixture in the nested-archive tests +// is a zip, so a mechanism that happens to be zip-specific would pass all of them; the requirement +// is that an archive of one family inside another behaves exactly as it would inside its own family, +// with the same packages, the same filesystem-id chain, the same CONTAINS edges and the same depth +// accounting. +// +// The chains are built in the test and nothing is committed as a binary. A committed nested archive +// is opaque in review, cannot be varied per case, and hides which family sits at which level - which +// is the one thing every case here turns on. + +// archiveFamily is one archive format buildNestedArchive can produce. +// +// ".jar" is its own family rather than a flavour of zip: the java cataloger meets it as a jar +// wherever it sits, while the generic archive task descends into it like any other zip. That is the +// single-owner coupling, and it behaves differently in the middle of a chain than at the leaf, which +// is why the matrix below has a jar as a container as well as a payload. +type archiveFamily string + +const ( + familyZip archiveFamily = ".zip" + familyJar archiveFamily = ".jar" + familyTar archiveFamily = ".tar" + familyTarGz archiveFamily = ".tar.gz" + familyTgz archiveFamily = ".tgz" +) + +// pack builds one archive of this family holding the given entries. +func (f archiveFamily) pack(t *testing.T, base string, entries map[string][]byte) []byte { + t.Helper() + switch f { + case familyZip: + return buildZipBytesRaw(t, entries) + case familyJar: + // a jar carries a manifest, so it is a real jar to the java cataloger at any level. The + // package java reports for it is named after the file, not the manifest title. + return jarBytes(t, base, "1.0", entries) + case familyTar: + return buildTarBytes(t, entries) + case familyTarGz, familyTgz: + return buildTarGzBytes(t, entries) + } + t.Fatalf("unknown archive family %q", f) + return nil +} + +// nestPlan describes one chain to build: a family per level (outermost first), the entries to put +// inside the innermost archive, and any extra entries to add beside the nested archive at a given +// level. The extras are what let one helper serve the limit cases too: grading the levels' sizes or +// their entry counts is the only way to put a bound between two levels of a chain. +type nestPlan struct { + families []archiveFamily + leaf map[string][]byte + extra map[int]map[string][]byte +} + +// nestedArchive is one built chain: the outermost archive's bytes, and the addresses that chain +// composes to at each level. +type nestedArchive struct { + // name is the outermost archive's file name, as written into the scanned directory. + name string + // bytes is the outermost archive. + bytes []byte + // sizes is each archive's size in bytes, outermost first. A container is never smaller than what + // it holds, so these decrease, and a bound placed between two of them is what the limit cases + // need. + sizes []int + // paths is each archive's path within its parent, outermost first; paths[0] is name. + paths []string + // fileSystemIDs is the composed chain each archive's own contents carry, outermost first. + fileSystemIDs []string + // virtualPaths is the same chain in java's colon-joined form, outermost first. + virtualPaths []string +} + +// leafFileSystemID is the chain the innermost archive's contents carry: every archive in the chain, +// outer to inner. It is asserted as an exact string throughout - a partially composed chain passes +// a substring check, which is how a real bug in this work went unnoticed for a while. +func (n nestedArchive) leafFileSystemID() string { + return n.fileSystemIDs[len(n.fileSystemIDs)-1] +} + +// buildNestedArchive builds one archive per family given, each holding the next, with leaf inside +// the innermost. A case is then one line rather than one fixture. +func buildNestedArchive(t *testing.T, plan nestPlan) nestedArchive { + t.Helper() + require.NotEmpty(t, plan.families, "a chain needs at least one level") + + out := nestedArchive{ + sizes: make([]int, len(plan.families)), + paths: make([]string, len(plan.families)), + fileSystemIDs: make([]string, len(plan.families)), + virtualPaths: make([]string, len(plan.families)), + } + + // the level index is in every name and every path segment, so a failed assertion says which + // level it was, and no two levels share a path - a chain assertion cannot pass by accident on a + // value composed from the wrong level + for i, family := range plan.families { + base := fmt.Sprintf("level%d", i) + if i == 0 { + out.paths[i] = base + string(family) + continue + } + out.paths[i] = fmt.Sprintf("nest%d/%s%s", i, base, family) + } + + out.name = out.paths[0] + for i := range plan.families { + if i == 0 { + out.fileSystemIDs[i] = out.paths[0] + out.virtualPaths[i] = out.paths[0] + continue + } + out.fileSystemIDs[i] = out.fileSystemIDs[i-1] + ":" + out.paths[i] + out.virtualPaths[i] = out.virtualPaths[i-1] + ":" + out.paths[i] + } + + // built innermost first, because each level's bytes are an entry of the level above it + var packed []byte + for i := len(plan.families) - 1; i >= 0; i-- { + entries := map[string][]byte{} + if i == len(plan.families)-1 { + for name, body := range plan.leaf { + entries[name] = body + } + } else { + entries[out.paths[i+1]] = packed + } + for name, body := range plan.extra[i] { + entries[name] = body + } + packed = plan.families[i].pack(t, fmt.Sprintf("level%d", i), entries) + out.sizes[i] = len(packed) + } + out.bytes = packed + + return out +} + +// writeNestedArchive writes the chain's outermost archive into dir and returns the chain. +func writeNestedArchive(t *testing.T, dir string, plan nestPlan) nestedArchive { + t.Helper() + nested := buildNestedArchive(t, plan) + require.NoError(t, os.WriteFile(filepath.Join(dir, nested.name), nested.bytes, 0o644)) + return nested +} + +// markerLeaf is the family-agnostic leaf payload: one file no archive format has any opinion about, +// found by markerCataloger. Using it rather than an ecosystem fixture is what makes the rows +// comparable - the same package must come out whichever families the chain is made of. +func markerLeaf() map[string][]byte { + return map[string][]byte{"nested/marker.txt": []byte("leaf")} +} + +// incompressibleBytes returns n bytes deflate and gzip cannot shrink, so a chain built from them has +// sizes per level predictable enough to place a bound between two of them. +func incompressibleBytes(n int) []byte { + b := make([]byte, n) + x := uint32(0x9e3779b9) + for i := range b { + x = x*1664525 + 1013904223 + b[i] = byte(x >> 24) + } + return b +} + +// nestingRow is one line of the depth-3 matrix. +type nestingRow struct { + name string + families []archiveFamily + why string +} + +// nestingRows is the depth-3 matrix. Each row exists for a reason the others do not cover, so a row +// that stops being interesting should be removed rather than quietly kept. +func nestingRows() []nestingRow { + return []nestingRow{ + { + name: "tar.gz/zip/jar", + families: []archiveFamily{familyTarGz, familyZip, familyJar}, + why: "the headline case: compressed tar outermost, jar innermost", + }, + { + name: "zip/tar.gz/jar", + families: []archiveFamily{familyZip, familyTarGz, familyJar}, + why: "compressed tar in the middle, where the stream-only format is not the entry point", + }, + { + name: "jar/tar/zip", + families: []archiveFamily{familyJar, familyTar, familyZip}, + why: "jar as a container rather than a payload", + }, + { + name: "tgz/tar/jar", + families: []archiveFamily{familyTgz, familyTar, familyJar}, + why: "the .tgz alias resolving at a non-outermost boundary", + }, + { + name: "tar/jar/tar.gz", + families: []archiveFamily{familyTar, familyJar, familyTarGz}, + why: "compressed tar as the innermost, which nothing else covers", + }, + } +} + +// nestingVisit is one filesystem the archive cataloger's sub-pipeline was run against. +type nestingVisit struct { + virtualPath string + depth int + // overflow names the archives whose content was on disk at this moment, sampled only when the + // probe was given a temp dir to look in. + overflowed []string + // diskBytes is the scan's total draw on the disk budget at this moment, read from the limiter on + // the context (Limiter.InUse): the archives' own overflow bytes plus the blob each archive's + // entries were written into plus the index records that overflowed to disk. It is what the disk + // limit is measured against, which is more than the bytes in the work directories once an index + // record is charged to the disk budget. + diskBytes int64 +} + +// nestingProbe is a stub cataloger that records one visit per filesystem the archive cataloger runs +// its sub-pipeline against, reading the chain off the traversal on the context. It is how a case +// asserts WHICH archives were cataloged, rather than inferring it from what the SBOM happens to +// contain. +type nestingProbe struct { + // tempDir, when set, is where extraction work directories live, so each visit can record which + // archives were overflowed to disk while they were all still held. + tempDir string + + mu sync.Mutex + visits []nestingVisit +} + +func (p *nestingProbe) Name() string { return "nesting-probe" } + +func (p *nestingProbe) Catalog(ctx context.Context, _ file.Resolver) ([]pkg.Package, []artifact.Relationship, error) { + trav := archive.TraversalFromContext(ctx) + visit := nestingVisit{} + if trav != nil { + visit.virtualPath = trav.VirtualPath + visit.depth = trav.Depth + } + if lim := archive.LimiterFromContext(ctx); lim != nil { + _, visit.diskBytes = lim.InUse() + } + if p.tempDir != "" { + visit.overflowed = overflowArchives(p.tempDir) + } + + p.mu.Lock() + defer p.mu.Unlock() + p.visits = append(p.visits, visit) + return nil, nil, nil +} + +// archiveFileSystemIDs is the chain of every archive whose contents were cataloged, in visit order, +// with the scan root itself dropped. +func (p *nestingProbe) archiveFileSystemIDs() []string { + p.mu.Lock() + defer p.mu.Unlock() + var out []string + for _, v := range p.visits { + if v.virtualPath == "" { + continue + } + out = append(out, v.virtualPath) + } + return out +} + +// deepestVisit is the visit at the innermost level reached, which is the only moment at which every +// level of a chain is still held and its routing therefore observable. It is returned whole rather +// than just its overflow sample so a case can assert it got as deep as it meant to - an empty overflow +// sample from a visit that never left the scan root would otherwise read as "nothing overflowed". +// visitAtDepth is the visit at the given nesting depth, for a case that wants to measure what a run +// held while a particular level was being cataloged. +func (p *nestingProbe) visitAtDepth(depth int) nestingVisit { + p.mu.Lock() + defer p.mu.Unlock() + for _, v := range p.visits { + if v.depth == depth { + return v + } + } + return nestingVisit{} +} + +func (p *nestingProbe) deepestVisit() nestingVisit { + p.mu.Lock() + defer p.mu.Unlock() + var best nestingVisit + for _, v := range p.visits { + if v.depth > best.depth { + best = v + } + } + return best +} + +// overflowArchives names the archive files sitting in the live extraction work directories under +// tempDir. Content held in memory writes nothing there; content overflowed to disk is written beside +// the archive's logical root under the archive's own name. That makes the routing observable from +// outside without a hook into the limiter, and it is only meaningful while the archives are still +// held - so it is sampled from inside the sub-pipeline rather than after the scan. +// +// Whatever file the resolver in use stores an archive's entries in is skipped: it says nothing about +// where an archive's OWN bytes were routed, which is what this reports. Its bytes are counted by +// overflowBytes instead, which is about how much is on disk rather than about which archive is where. +func overflowArchives(tempDir string) []string { + var out []string + forEachHeldFile(tempDir, func(name string, _ os.FileInfo) { + if isEntryStorageFile(name) { + return + } + out = append(out, name) + }) + sort.Strings(out) + return out +} + +// entryStorageFiles are the basenames internal/archive gives the file one archive's entries are stored +// in - a tar where the resolver indexes by seek offset, a blob where it indexes the entries +// themselves. Named here rather than reached for, since the probe reads a directory the implementation +// owns, and listed rather than hard-coded to one because which of them appears is a property of the +// resolver under test and not of the routing this probe is about. +var entryStorageFiles = []string{"contents.tar", "contents.blob"} + +func isEntryStorageFile(name string) bool { + for _, storage := range entryStorageFiles { + if name == storage { + return true + } + } + return false +} + +// forEachHeldFile visits every file an archive work directory is holding, wherever under tempDir +// those directories were created. The tree is walked rather than read one level deep because an +// archive's work directory is a child of the scan's own temp root (internal/tmpdir), which is itself +// a directory of a name this probe does not choose. +func forEachHeldFile(tempDir string, visit func(name string, info os.FileInfo)) { + _ = filepath.WalkDir(tempDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() || !strings.HasPrefix(d.Name(), "syft-archive-") { + return nil //nolint:nilerr // an unreadable directory holds nothing this probe can report + } + held, err := os.ReadDir(path) + if err != nil { + return filepath.SkipDir + } + for _, entry := range held { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + visit(entry.Name(), info) + } + return filepath.SkipDir + }) +} + +// isolatedTempDir points archive extraction at a directory of this test's own, so an overflow sample +// sees only this scan's work directories and not those of any other package's tests running beside +// it. +func isolatedTempDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + require.Equal(t, dir, os.TempDir(), "extraction must be writing where the probe is looking") + return dir +} + +// nestingScanConfig selects the java catalogers - so a jar is met as a jar wherever it sits in the +// chain - and always runs the given catalogers, whatever the selection. The java selection is by tag +// rather than the whole default set because the default set includes the RPM cataloger, which needs +// a sqlite driver this test binary does not register. +func nestingScanConfig(depth int, catalogers ...pkg.Cataloger) *CreateSBOMConfig { + cfg := DefaultCreateSBOMConfig(). + WithCatalogerSelection(cataloging.NewSelectionRequest().WithDefaults("java")) + for _, c := range catalogers { + cfg = cfg.WithCatalogers(pkgcataloging.NewAlwaysEnabledCatalogerReference(c)) + } + return cfg.WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth)) +} + +// withFileCataloging turns on file cataloging, which the CONTAINS chain needs: the archive-to-file +// edges key on the coordinates the FILE catalogers recorded, so without them an inner archive has no +// coordinate to hang an edge on and the chain has a hole. +func withFileCataloging(cfg *CreateSBOMConfig) *CreateSBOMConfig { + return cfg.WithFilesConfig(filecataloging.Config{Selection: file.AllFilesSelection}) +} + +// leafLocations returns the locations of the leaf marker package. +func leafLocations(s *sbom.SBOM) []file.Location { + var out []file.Location + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Name != "marker-pkg" { + continue + } + out = append(out, p.Locations.ToSlice()...) + } + return out +} + +// javaVirtualPaths maps each java package's name to its virtual path. +func javaVirtualPaths(s *sbom.SBOM) map[string]string { + out := map[string]string{} + for _, p := range s.Artifacts.Packages.Sorted() { + if metadata, ok := p.Metadata.(pkg.JavaArchive); ok { + out[p.Name] = metadata.VirtualPath + } + } + return out +} + +func packageCount(s *sbom.SBOM, name string) int { + var n int + for _, p := range s.Artifacts.Packages.Sorted() { + if p.Name == name { + n++ + } + } + return n +} + +// scanWithinTimeout runs the scan on its own goroutine and fails if it has not finished in time. +// +// It exists for the depth-3 limit cases. The walk descends before it unwinds, so the outer and +// middle archives are still holding their content when the inner one is refused: an implementation +// that waited for capacity would deadlock there rather than fail, and a test asserting only that the +// result is right would HANG instead of reporting. The timeout is what turns that into a failure. +func scanWithinTimeout(t *testing.T, dir string, cfg *CreateSBOMConfig, timeout time.Duration) *sbom.SBOM { + t.Helper() + src, err := directorysource.New(directorysource.Config{Path: dir}) + require.NoError(t, err) + t.Cleanup(func() { _ = src.Close() }) + + type outcome struct { + s *sbom.SBOM + err error + } + done := make(chan outcome, 1) + go func() { + s, err := cfg.Create(context.Background(), src) + done <- outcome{s: s, err: err} + }() + + select { + case got := <-done: + require.NoError(t, got.err, "reaching a bound must not fail the scan") + return got.s + case <-time.After(timeout): + t.Fatalf("the scan did not finish within %s: reaching a limit must skip the archive that "+ + "reached it, never wait for capacity that nothing in the chain will release", timeout) + return nil + } +} + +func Test_mixedFamilyNesting_leafIsCatalogedOnceWithTheFullChain(t *testing.T) { + // the requirement is that family order does not change the result: the same package, addressed + // by a chain naming its own containers in its own order + for _, row := range nestingRows() { + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + probe := &nestingProbe{} + s := scanDirWith(t, scanDir, nestingScanConfig(3, markerCataloger{}, probe)) + + assert.Equal(t, 1, packageCount(s, "marker-pkg"), "%s: the leaf package must be cataloged exactly once", row.why) + + locs := leafLocations(s) + require.Len(t, locs, 1, "the leaf must be reached by exactly one path") + // exact, never a substring: a partially composed chain passes a substring check + assert.Equal(t, nested.leafFileSystemID(), locs[0].ArchivePath, + "the chain must name all three archives outer-to-inner") + assert.Equal(t, "nested/marker.txt", locs[0].RealPath, + "the path stays relative to the archive the file came from") + + assert.Equal(t, nested.fileSystemIDs, probe.archiveFileSystemIDs(), + "every level must be cataloged, outer to inner, whatever families they are") + }) + } +} + +func Test_mixedFamilyNesting_containsEdgesChainAtEveryLevel(t *testing.T) { + // the edges are what makes the chain navigable in the SBOM, and they key on coordinates the file + // catalogers recorded, so file cataloging has to be on for the inner archives to have any + for _, row := range nestingRows() { + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + cfg := withFileCataloging(nestingScanConfig(3, markerCataloger{})) + s := scanDirWith(t, scanDir, cfg) + + // the coordinates of each archive AS SEEN IN ITS PARENT: the outermost sits in the + // scanned directory, so it carries no filesystem id of its own + archiveCoords := make([]file.Coordinates, len(row.families)) + for i := range row.families { + var parentFSID string + if i > 0 { + parentFSID = nested.fileSystemIDs[i-1] + } + archiveCoords[i] = file.Coordinates{RealPath: nested.paths[i], ArchivePath: parentFSID} + } + + fileEdges := strset.New() + pkgEdges := strset.New() + for _, rel := range s.Relationships { + if rel.Type != artifact.ContainsRelationship { + continue + } + from, ok := rel.From.(file.Coordinates) + if !ok { + continue + } + switch to := rel.To.(type) { + case file.Coordinates: + fileEdges.Add(coordKey(from) + " -> " + coordKey(to)) + case pkg.Package: + pkgEdges.Add(coordKey(from) + " -> " + to.Name) + } + } + + for i := 0; i < len(archiveCoords)-1; i++ { + assert.True(t, fileEdges.Has(coordKey(archiveCoords[i])+" -> "+coordKey(archiveCoords[i+1])), + "expected a CONTAINS edge from %s to %s", nested.paths[i], nested.paths[i+1]) + } + + innermost := archiveCoords[len(archiveCoords)-1] + assert.True(t, pkgEdges.Has(coordKey(innermost)+" -> marker-pkg"), + "expected a CONTAINS edge from %s to the leaf package", nested.paths[len(nested.paths)-1]) + assert.True(t, fileEdges.Has(coordKey(innermost)+" -> "+coordKey(file.Coordinates{ + RealPath: "nested/marker.txt", + ArchivePath: nested.leafFileSystemID(), + })), "expected a CONTAINS edge from the innermost archive to the leaf file") + }) + } +} + +func coordKey(c file.Coordinates) string { + return c.ArchivePath + "|" + c.RealPath +} + +func Test_mixedFamilyNesting_javaLeafVirtualPathIsTheColonJoinedChain(t *testing.T) { + // these colon-delimited strings are in already-published SBOMs, so a jar reached through three + // levels of mixed families must still be reported under the chain of all three + for _, row := range nestingRows() { + if row.families[len(row.families)-1] != familyJar { + continue + } + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + s := scanDirWith(t, scanDir, nestingScanConfig(3, markerCataloger{})) + + // the innermost archive IS the jar, so its own package is the java leaf and the chain of + // all three archives is exactly its virtual path + leaf := fmt.Sprintf("level%d", len(row.families)-1) + assert.Equal(t, 1, packageCount(s, leaf), "the leaf jar must be cataloged exactly once") + assert.Equal(t, nested.virtualPaths[len(row.families)-1], javaVirtualPaths(s)[leaf]) + }) + } +} + +func Test_mixedFamilyNesting_jarIsExercisedAsAContainer(t *testing.T) { + // the row that separates the two mechanisms that meet a jar: the java cataloger catalogs it as a + // package, and the generic archive task descends into it. A jar in the middle of a chain has to + // do both, exactly once each - the failure mode is a duplicate, not an error. + for _, row := range nestingRows() { + var containerLevels []int + for i, family := range row.families { + if family == familyJar && i < len(row.families)-1 { + containerLevels = append(containerLevels, i) + } + } + if len(containerLevels) == 0 { + continue + } + + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + probe := &nestingProbe{} + s := scanDirWith(t, scanDir, nestingScanConfig(3, markerCataloger{}, probe)) + + for _, level := range containerLevels { + name := fmt.Sprintf("level%d", level) + assert.Equal(t, 1, packageCount(s, name), + "the container jar's own package must be cataloged exactly once") + assert.Equal(t, nested.virtualPaths[level], javaVirtualPaths(s)[name], + "the container jar's virtual path must be its own place in the chain") + } + + // and the archive task still descended through it, so the chain is complete below it + assert.Equal(t, nested.fileSystemIDs, probe.archiveFileSystemIDs()) + locs := leafLocations(s) + require.Len(t, locs, 1) + assert.Equal(t, nested.leafFileSystemID(), locs[0].ArchivePath) + }) + } +} + +func Test_mixedFamilyNesting_depthCountsLevelsNotFamilies(t *testing.T) { + // two rows with different families at every level, so the bound is shown not to turn on which + // families are involved + for _, row := range nestingRows()[:2] { + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + probe := &nestingProbe{} + cfg := withFileCataloging(nestingScanConfig(2, markerCataloger{}, probe)) + s := scanDirWith(t, scanDir, cfg) + + assert.Equal(t, nested.fileSystemIDs[:2], probe.archiveFileSystemIDs(), + "at depth 2 the outer and middle archives are cataloged and the innermost is not") + assert.Zero(t, packageCount(s, "marker-pkg"), + "the leaf is one level past the bound, so its package must be absent") + + // the innermost archive is still cataloged as a FILE, inside the middle archive + innermost := file.Coordinates{ + RealPath: nested.paths[2], + ArchivePath: nested.fileSystemIDs[1], + } + _, ok := s.Artifacts.FileMetadata[innermost] + assert.True(t, ok, "the undescended archive must still be recorded as a file at %s", coordKey(innermost)) + }) + } +} + +func Test_mixedFamilyNesting_defaultBoundsCatalogEveryRow(t *testing.T) { + // what a real user gets. The bounds are measured against a corpus rather than argued, so this + // also pins that a three-level chain of any family mix sits well inside them. + bounds := cataloging.DefaultArchiveSearchConfig() + require.Equal(t, int64(2*1024*1024*1024), bounds.MaxMemoryBytes) + require.Equal(t, int64(100*1024*1024*1024), bounds.MaxDiskBytes) + + for _, row := range nestingRows() { + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + probe := &nestingProbe{} + s := scanDirWith(t, scanDir, nestingScanConfig(3, markerCataloger{}, probe)) + + assert.Equal(t, nested.fileSystemIDs, probe.archiveFileSystemIDs(), + "every level of the chain must be cataloged at the default bounds") + locs := leafLocations(s) + require.Len(t, locs, 1) + assert.Equal(t, nested.leafFileSystemID(), locs[0].ArchivePath) + }) + } +} + +// gradedChain is the headline row with each level padded so the three archives have clearly +// different sizes: a container is never smaller than what it holds, so a bound can only be placed +// between two levels if the levels are graded on purpose. +func gradedChain() nestPlan { + return nestPlan{ + families: []archiveFamily{familyTarGz, familyZip, familyJar}, + leaf: markerLeaf(), + extra: map[int]map[string][]byte{ + 0: {"pad0.bin": incompressibleBytes(24 * 1024)}, + 1: {"pad1.bin": incompressibleBytes(24 * 1024)}, + 2: {"pad2.bin": incompressibleBytes(8 * 1024)}, + }, + } +} + +func Test_mixedFamilyNesting_memoryPressureOverflowsRatherThanFailing(t *testing.T) { + // memory pressure degrades to disk usage: there is somewhere further to put the bytes, so an + // archive that will not fit in memory overflows instead of being refused. Where the boundary falls + // is decided entirely by the memory limit - there is no separate configured size at which content + // overflows. + scanDir := t.TempDir() + tempDir := isolatedTempDir(t) + nested := writeNestedArchive(t, scanDir, gradedChain()) + + t.Run("with a generous memory limit nothing overflows", func(t *testing.T) { + probe := &nestingProbe{tempDir: tempDir} + cfg := nestingScanConfig(3, markerCataloger{}, probe) + // all three levels are held at once at the deepest point, so the limit must admit their sum + generous := int64(nested.sizes[0]+nested.sizes[1]+nested.sizes[2]) + 1 + cfg = cfg.WithArchiveConfig(cfg.Archive.WithMaxMemoryBytes(generous)) + scanDirWith(t, scanDir, cfg) + + deepest := probe.deepestVisit() + require.Equal(t, 3, deepest.depth, "the sample must be taken with all three levels still held") + assert.Empty(t, deepest.overflowed, "with room in memory for every level, nothing is written to disk") + }) + + t.Run("a memory limit the outermost archive does not fit in", func(t *testing.T) { + // only the outermost archive is routed by the memory limit at all: it is the one whose bytes + // have to be copied out of the scanned filesystem. Every archive below it is already a region + // of its parent's tar, on disk and charged to that parent, so it is read where it lies - it + // neither takes memory nor writes a second copy. What this case pins is the half that is still + // the memory limit's: an archive too large for it overflows instead of failing, and the outcome + // is identical to a run with room for it. + probe := &nestingProbe{tempDir: tempDir} + cfg := nestingScanConfig(3, markerCataloger{}, probe) + cfg = cfg.WithArchiveConfig(cfg.Archive. + WithMaxMemoryBytes(int64(nested.sizes[2])). + WithMaxDiskBytes(-1)) // unbounded: the disk limit must not be what decides anything here + s := scanDirWith(t, scanDir, cfg) + + deepest := probe.deepestVisit() + require.Equal(t, 3, deepest.depth, "the sample must be taken with all three levels still held") + require.Less(t, int64(nested.sizes[2]), int64(nested.sizes[0]), + "the outermost archive must be the one that does not fit, or this case asserts nothing") + assert.Equal(t, []string{"level0.tar.gz"}, deepest.overflowed, + "the archive that does not fit in memory overflows rather than failing, and the levels inside "+ + "it are read where they lie rather than being copied anywhere") + + // and the outcome is identical to the default-bounds run + assert.Equal(t, nested.fileSystemIDs, probe.archiveFileSystemIDs()) + locs := leafLocations(s) + require.Len(t, locs, 1) + assert.Equal(t, nested.leafFileSystemID(), locs[0].ArchivePath) + }) +} + +func Test_mixedFamilyNesting_aNestedArchiveIsReadWhereItLies(t *testing.T) { + // This case replaces one that asserted the first-come consequence of having no per-archive memory + // cap: once the outermost archive had taken the whole memory limit, the archives nested inside it + // overflowed even though each was small enough to have fit had it arrived first. That property is + // gone, and not because the limit changed - because there is no longer anything to route. A nested + // archive is a region of its parent's tar, already on disk and already charged to the parent, and + // the tar entry reader is Read, Seek AND ReadAt - everything a zip's central directory needs. So it + // is handed to the archive format where it lies. Nothing is copied, so nothing competes for the + // memory limit, and the first-come question no longer has two archives to ask it about. + // + // What is asserted instead is that consequence directly, at both ends of the memory limit: whether + // the limit is generous or zero, the nested levels neither hold memory nor put a second copy of + // themselves on disk, and the whole chain is cataloged either way. + scanDir := t.TempDir() + tempDir := isolatedTempDir(t) + nested := writeNestedArchive(t, scanDir, gradedChain()) + + for _, tc := range []struct { + name string + memoryBytes int64 + outerAlsoOverflows bool + }{ + { + name: "with room in memory for the outermost archive", + memoryBytes: int64(nested.sizes[0]), + }, + { + name: "with a memory limit of zero, so even the outermost overflows", + memoryBytes: 0, + outerAlsoOverflows: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + probe := &nestingProbe{tempDir: tempDir} + cfg := nestingScanConfig(3, markerCataloger{}, probe) + cfg = cfg.WithArchiveConfig(cfg.Archive. + WithMaxMemoryBytes(tc.memoryBytes). + WithMaxDiskBytes(-1)) // unbounded: only the memory limit is under test + + s := scanDirWith(t, scanDir, cfg) + + deepest := probe.deepestVisit() + require.Equal(t, 3, deepest.depth, "the sample must be taken with all three levels still held") + + var want []string + if tc.outerAlsoOverflows { + want = []string{"level0.tar.gz"} + } + assert.Equal(t, want, deepest.overflowed, + "no copy of a nested archive is written anywhere: level1.zip and level2.jar are read "+ + "out of the tar of the archive holding them") + + assert.Equal(t, nested.fileSystemIDs, probe.archiveFileSystemIDs(), + "and every level is still cataloged") + locs := leafLocations(s) + require.Len(t, locs, 1) + assert.Equal(t, nested.leafFileSystemID(), locs[0].ArchivePath) + }) + } +} + +// deepChain is the headline row with a single large, incompressible payload at the leaf and no +// padding anywhere else. A container is never smaller than what it holds, so with the payload +// dominating every level the three archives are all about the same size - which is what makes the +// disk limit arithmetic below robust: the slack is half an archive rather than a few hundred bytes. +func deepChain() nestPlan { + return nestPlan{ + families: []archiveFamily{familyTarGz, familyZip, familyJar}, + leaf: map[string][]byte{ + "nested/marker.txt": []byte("leaf"), + "payload.bin": incompressibleBytes(64 * 1024), + }, + } +} + +func Test_mixedFamilyNesting_diskLimitSkipsTheLevelItCannotAdmit(t *testing.T) { + // the disk limit is terminal, and the walk descends before it unwinds: when the innermost level + // cannot be admitted, the outer and middle archives are still holding everything they took. So + // the level that would exceed it is skipped, the shallower levels keep what they found, and the + // scan finishes. An implementation that waited for capacity would deadlock instead - which is why + // every scan here is run under a timeout rather than merely checked for the right answer. + scanDir := t.TempDir() + tempDir := isolatedTempDir(t) + nested := writeNestedArchive(t, scanDir, deepChain()) + + // a sibling of a different family, to show that reaching the bound is per archive: it is + // processed once the chain has been released, so it is cataloged in full + require.NoError(t, os.WriteFile( + filepath.Join(scanDir, "sibling.tgz"), + buildTarGzBytes(t, map[string][]byte{"nested/marker.txt": []byte("sibling")}), + 0o644, + )) + + // The limit is MEASURED rather than computed: what the chain holds on disk at a given level is the + // outermost archive's own bytes plus every entry the levels above it wrote out, which is a fact + // about this corpus rather than arithmetic anyone should reproduce. So the chain is run once with + // no disk ceiling and the probe reports exactly what was on disk while two levels were held. + // + // The real run then gets that figure with NO slack, which is what makes this a skip rather than a + // truncation. Entries are stored without framing, so any slack at all is enough to admit some of + // the innermost level's content - and an archive that placed some of its entries is truncated, a + // different outcome tested elsewhere. With none, the first byte of the first entry is refused and + // nothing of that level is placed at all. + measuring := &nestingProbe{tempDir: tempDir} + measureCfg := nestingScanConfig(3, markerCataloger{}, measuring) + measureCfg = measureCfg.WithArchiveConfig(measureCfg.Archive.WithMaxMemoryBytes(0).WithMaxDiskBytes(-1)) + scanDirWith(t, scanDir, measureCfg) + + twoLevelsHeld := measuring.visitAtDepth(2).diskBytes + require.Positive(t, twoLevelsHeld, "the measuring run must have reached the second level") + diskLimit := twoLevelsHeld + + probe := &nestingProbe{} + cfg := nestingScanConfig(3, markerCataloger{}, probe) + cfg = cfg.WithArchiveConfig(cfg.Archive. + WithMaxMemoryBytes(0). // every archive overflows, so its own bytes are charged to disk + WithMaxDiskBytes(diskLimit)) + + logs := captureLogs(t) + s := scanWithinTimeout(t, scanDir, cfg, 60*time.Second) + + want := append(append([]string{}, nested.fileSystemIDs[:2]...), "sibling.tgz") + assert.ElementsMatch(t, want, probe.archiveFileSystemIDs(), + "the level that would exceed the limit is skipped, the shallower ones are still cataloged, "+ + "and a sibling of another family is unaffected") + + // the skip is attributed to the archive that triggered it: a log line naming only the bound says + // nothing about what was lost + assert.True(t, logs.sawFields(map[string]any{ + "archive": nested.paths[2], + "limit": diskLimit, + }), "the skip must be attributed to the archive that reached the limit; saw %v", logs.eventsWith("archive")) + + fsIDs := strset.New() + for _, loc := range leafLocations(s) { + fsIDs.Add(loc.ArchivePath) + } + assert.False(t, fsIDs.Has(nested.leafFileSystemID()), "the skipped level's contents must be absent") + assert.True(t, fsIDs.Has("sibling.tgz"), "the sibling of a different family must be cataloged fully") +} + +func Test_mixedFamilyNesting_unboundedLimitsEnforceNothing(t *testing.T) { + // a caller can opt out of one bound without opting out of the other, and opting out of both must + // still find the deepest leaf. Memory and disk opt out with a negative value, since zero means + // "none of that resource". + for _, row := range nestingRows() { + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + nested := writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + probe := &nestingProbe{} + cfg := nestingScanConfig(3, markerCataloger{}, probe) + cfg = cfg.WithArchiveConfig(cfg.Archive. + WithMaxMemoryBytes(-1). + WithMaxDiskBytes(-1)) + + s := scanWithinTimeout(t, scanDir, cfg, 60*time.Second) + + assert.Equal(t, nested.fileSystemIDs, probe.archiveFileSystemIDs()) + locs := leafLocations(s) + require.Len(t, locs, 1) + assert.Equal(t, nested.leafFileSystemID(), locs[0].ArchivePath) + }) + } +} + +func Test_mixedFamilyNesting_bothLimitsZeroSkipsEveryArchive(t *testing.T) { + // the degenerate but well-defined configuration where nothing is admitted anywhere: unlike + // negative (unbounded), zero on both in-use limits skips every archive, and the scan still + // succeeds rather than failing or finding anything inside the archives + for _, row := range nestingRows() { + t.Run(row.name, func(t *testing.T) { + scanDir := t.TempDir() + writeNestedArchive(t, scanDir, nestPlan{families: row.families, leaf: markerLeaf()}) + + probe := &nestingProbe{} + cfg := nestingScanConfig(3, markerCataloger{}, probe) + cfg = cfg.WithArchiveConfig(cfg.Archive. + WithMaxMemoryBytes(0). + WithMaxDiskBytes(0)) + + s := scanWithinTimeout(t, scanDir, cfg, 60*time.Second) + + assert.Empty(t, probe.archiveFileSystemIDs(), "no archive content can be admitted anywhere") + assert.Empty(t, leafLocations(s), "the leaf is never reached since every archive is skipped") + }) + } +} + +// The attribution in a skip is a log line and nothing else: reaching a bound must not fail the scan, +// so there is no return value that says which archive was refused. Asserting it therefore means +// capturing what was logged. cmd/syft/internal/ui/log_writer_test.go does the same thing with the +// same log.Set/log.Get pair; this adds the field-carrying half, which is where the attribution lives. + +// logCapture collects the field-carrying log events emitted while it is installed. +type logCapture struct { + mu sync.Mutex + collected []map[string]any +} + +// captureLogs installs a capturing logger for the duration of the test and restores the previous one +// afterwards. +func captureLogs(t *testing.T) *logCapture { + t.Helper() + capture := &logCapture{} + previous := log.Get() + t.Cleanup(func() { log.Set(previous) }) + log.Set(&capturingLogger{Logger: discard.New(), capture: capture}) + return capture +} + +func (c *logCapture) record(event map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + c.collected = append(c.collected, event) +} + +func (c *logCapture) events() []map[string]any { + c.mu.Lock() + defer c.mu.Unlock() + return append([]map[string]any(nil), c.collected...) +} + +// eventsWith is the captured events carrying the given field, which is what a failure wants to show: +// a scan logs thousands of lines and the whole set is unreadable in a test failure. +func (c *logCapture) eventsWith(field string) []map[string]any { + var out []map[string]any + for _, event := range c.events() { + if _, ok := event[field]; ok { + out = append(out, event) + } + } + return out +} + +// sawFields reports whether any captured event carried every one of the given fields with exactly +// the given values. +func (c *logCapture) sawFields(want map[string]any) bool { + for _, got := range c.events() { + matched := true + for key, value := range want { + if !reflect.DeepEqual(got[key], value) { + matched = false + break + } + } + if matched { + return true + } + } + return false +} + +// capturingLogger discards every message but records the fields attached to the ones that carry any. +// Nested is deliberately left to the embedded discard logger: nothing under test uses it, and +// merging nested fields into an event would be untested machinery in a test helper. +type capturingLogger struct { + logger.Logger + capture *logCapture +} + +func (l *capturingLogger) WithFields(fields ...any) logger.MessageLogger { + return &capturingMessageLogger{ + MessageLogger: l.Logger.WithFields(fields...), + capture: l.capture, + fields: fieldMap(fields), + } +} + +type capturingMessageLogger struct { + logger.MessageLogger + capture *logCapture + fields map[string]any +} + +func (l *capturingMessageLogger) emit(message string) { + event := map[string]any{"message": message} + for key, value := range l.fields { + event[key] = value + } + l.capture.record(event) +} + +func (l *capturingMessageLogger) Error(args ...any) { l.emit(fmt.Sprint(args...)) } +func (l *capturingMessageLogger) Warn(args ...any) { l.emit(fmt.Sprint(args...)) } +func (l *capturingMessageLogger) Info(args ...any) { l.emit(fmt.Sprint(args...)) } +func (l *capturingMessageLogger) Debug(args ...any) { l.emit(fmt.Sprint(args...)) } +func (l *capturingMessageLogger) Trace(args ...any) { l.emit(fmt.Sprint(args...)) } + +func (l *capturingMessageLogger) Errorf(format string, args ...any) { + l.emit(fmt.Sprintf(format, args...)) +} +func (l *capturingMessageLogger) Warnf(format string, args ...any) { + l.emit(fmt.Sprintf(format, args...)) +} +func (l *capturingMessageLogger) Infof(format string, args ...any) { + l.emit(fmt.Sprintf(format, args...)) +} +func (l *capturingMessageLogger) Debugf(format string, args ...any) { + l.emit(fmt.Sprintf(format, args...)) +} +func (l *capturingMessageLogger) Tracef(format string, args ...any) { + l.emit(fmt.Sprintf(format, args...)) +} + +// fieldMap turns the alternating key/value form log.WithFields takes into a map, and folds in any +// logger.Fields passed as a whole. +func fieldMap(fields []any) map[string]any { + out := map[string]any{} + for i := 0; i < len(fields); i++ { + if asMap, ok := fields[i].(logger.Fields); ok { + for key, value := range asMap { + out[key] = value + } + continue + } + key, ok := fields[i].(string) + if !ok || i+1 >= len(fields) { + continue + } + out[key] = fields[i+1] + i++ + } + return out +} diff --git a/syft/cataloging/archive_exclusions.go b/syft/cataloging/archive_exclusions.go new file mode 100644 index 000000000..83e0e71be --- /dev/null +++ b/syft/cataloging/archive_exclusions.go @@ -0,0 +1,41 @@ +package cataloging + +import "strings" + +// anyDepthPrefix marks the one pattern shape that reaches inside an archive. +// +// A directory source accepts exactly three shapes and rejects everything else outright +// (directorysource.GetDirectoryExclusionFunctions), and only this one is depth-independent: +// +// ./x names a path at the scan root +// */x names a path exactly one level below the scan root - `*` matches one segment and no +// separator, so it matches a/x but neither x nor a/b/x +// **/x names x at any depth, the scan root included +// +// The first two are measured from the scan root and describe a layout that exists only there. The +// third means the same thing wherever it is rooted, and that is what makes re-rooting it at an +// archive's extraction directory a faithful reading of it rather than a coincidence: a user who +// wrote `*/vendor` was pointing at a directory they can see, while one who wrote `**/*.rpm` was +// saying something about every tree. +// +// Image sources do not validate their patterns against these three shapes at all - that check lives +// only in the directory-source helper - but the rule needs no adjustment for them, since a pattern +// either commits to a depth or does not. +const anyDepthPrefix = "**/" + +// ArchiveExclusionPatterns returns the subset of the scan's exclusion patterns that reach inside an +// archive, normalized for matching. A pattern's own shape decides: see anyDepthPrefix. +// +// The trailing slash is trimmed for the same reason the directory source trims it - it signals a +// directory to a reader but is discarded by doublestar.Match, so a pattern keeping it silently +// matches nothing (issue #4839). +func ArchiveExclusionPatterns(exclusions []string) []string { + var out []string + for _, exclusion := range exclusions { + if !strings.HasPrefix(exclusion, anyDepthPrefix) { + continue + } + out = append(out, strings.TrimSuffix(exclusion, "/")) + } + return out +} diff --git a/syft/cataloging/archive_exclusions_test.go b/syft/cataloging/archive_exclusions_test.go new file mode 100644 index 000000000..b23dcba80 --- /dev/null +++ b/syft/cataloging/archive_exclusions_test.go @@ -0,0 +1,59 @@ +package cataloging + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ArchiveExclusionPatterns(t *testing.T) { + // the pattern's own shape is the whole rule, so every accepted shape is stated here rather than + // only the two obvious ones. `*/x` is the interesting one: it names a definite depth measured + // from the scan root, so it describes a layout that exists only there and is anchored, exactly + // like `./x`. Only `**/x` means the same thing wherever it is rooted, which is what makes + // re-rooting it at an extraction directory a reading of it rather than a coincidence. + tests := []struct { + name string + exclusions []string + want []string + }{ + { + name: "nothing configured", + exclusions: nil, + want: nil, + }, + { + name: "root-anchored does not reach inside", + exclusions: []string{"./nested/marker.txt"}, + want: nil, + }, + { + name: "one-level-down is anchored too and does not reach inside", + exclusions: []string{"*/vendor"}, + want: nil, + }, + { + name: "any-depth reaches inside", + exclusions: []string{"**/*.rpm"}, + want: []string{"**/*.rpm"}, + }, + { + name: "mixed keeps only the any-depth ones, in order", + exclusions: []string{"./a", "**/b", "*/c", "**/d"}, + want: []string{"**/b", "**/d"}, + }, + { + name: "a trailing slash is trimmed", + // it reads as "a directory" but doublestar.Match discards it, so a pattern keeping it + // matches nothing at all (issue #4839) + exclusions: []string{"**/vendor/"}, + want: []string{"**/vendor"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ArchiveExclusionPatterns(tt.exclusions)) + }) + } +} diff --git a/syft/cataloging/archive_search.go b/syft/cataloging/archive_search.go index 464913909..bd07d4ee5 100644 --- a/syft/cataloging/archive_search.go +++ b/syft/cataloging/archive_search.go @@ -1,10 +1,33 @@ package cataloging +import "github.com/anchore/stereoscope/pkg/file" + const ( - DefaultArchiveMaxDepth = 0 // disabled by default for backwards compatibility - DefaultArchiveMaxExtractionSizeBytes = 500 * 1024 * 1024 // 500 MB per archive - DefaultArchiveMaxFileCount = 10000 - DefaultArchiveMaxTotalExtractionBytes = 2 * 1024 * 1024 * 1024 // 2 GB total + DefaultArchiveMaxDepth = 0 // disabled by default for backwards compatibility + + // DefaultArchiveMaxMemoryBytes bounds the archive content held in memory at once. + // + // Both bounds are in-use gauges rather than cumulative totals: they measure what the scan is + // holding right now and fall as each archive is released, so what they cap is a scan's peak. + // That is what lets them be set well above the largest archive anyone is likely to meet - the + // number that matters is how much is held at the deepest point of a nesting chain, not the sum + // of everything the scan ever opened. + // + // Set generously on purpose. An archive's entries are held here entry by entry and moved out to + // the overflow blob the first time this bound says no, so the bound does not decide whether an + // archive can be cataloged - only whether it costs a write. Making room for the common case is + // therefore a straight speed win with a bounded cost, and the cost is bounded because what is + // held is released as soon as the archive is. + DefaultArchiveMaxMemoryBytes = 2 * file.GB + + // DefaultArchiveMaxDiskBytes bounds the archive content on disk at once. + // + // Unlike the memory bound this one is terminal: there is nowhere further to overflow, so an + // archive whose first byte it refuses is skipped and its packages are absent from the SBOM. It + // is set high for that reason - it is a backstop against a pathological archive filling the + // disk, not a working budget - and, being an in-use gauge, it is released along with each + // archive rather than accumulating over a scan. + DefaultArchiveMaxDiskBytes = 100 * file.GB ) type ArchiveSearchConfig struct { @@ -17,17 +40,34 @@ type ArchiveSearchConfig struct { // MaxDepth is the maximum depth of recursive archive extraction (0 = disabled, no recursive extraction). MaxDepth int `yaml:"max-depth" json:"max-depth" mapstructure:"max-depth"` - // MaxExtractionSizeBytes is the maximum total bytes to extract from a single archive before stopping. - MaxExtractionSizeBytes int64 `yaml:"max-extraction-size-bytes" json:"max-extraction-size-bytes" mapstructure:"max-extraction-size-bytes"` + // MaxMemoryBytes is the maximum bytes of archive content held in memory at once. It is an in-use + // limit: it measures what is held right now and falls when an archive is released, so what it + // bounds is a scan's peak rather than its cumulative total. Content is held in memory while this + // limit admits it and overflows to disk when it does not - there is no separate configured size at + // which overflowing begins. Zero means content is never held in memory and everything overflows; a + // negative value means memory is not bounded at all. + MaxMemoryBytes int64 `yaml:"max-memory-bytes" json:"max-memory-bytes" mapstructure:"max-memory-bytes"` - // MaxFileCount is the maximum number of files to extract from a single archive before stopping. - MaxFileCount int `yaml:"max-file-count" json:"max-file-count" mapstructure:"max-file-count"` + // MaxDiskBytes is the maximum bytes of archive content on disk at once - content overflowed there + // plus the entries extracted from it. Also an in-use limit, and terminal: there is nowhere + // further to overflow, so an archive that would exceed it is skipped. Zero admits no archive + // content at all, since an archive's entries are stored whatever happened to its own bytes; a + // negative value means disk is not bounded. + MaxDiskBytes int64 `yaml:"max-disk-bytes" json:"max-disk-bytes" mapstructure:"max-disk-bytes"` - // MaxTotalExtractionBytes is the maximum total bytes to extract across all archives before stopping. - MaxTotalExtractionBytes int64 `yaml:"max-total-extraction-bytes" json:"max-total-extraction-bytes" mapstructure:"max-total-extraction-bytes"` - - // ExcludeExtensions is a list of archive extensions to skip during recursive extraction (e.g., [".rpm"]). - ExcludeExtensions []string `yaml:"exclude-extensions" json:"exclude-extensions" mapstructure:"exclude-extensions"` + // ExclusionPatterns carries the scan's own file exclusion patterns so the code that indexes an + // archive's contents does not have to interrogate the source for them. This capability has no + // exclusion setting of its own: it is deliberately not a yaml/json/mapstructure key, so no config + // file, flag, or environment variable reaches it, and `syft config` never names it. + // + // Each boundary that already holds exclusion patterns populates this field from its own: the CLI + // fills it in cmd/syft/internal/options.Catalog.ToArchiveConfig from the --exclude flag, and + // syft.CreateSBOM fills it from the source's own published exclusions (source.PathExcluder). A + // value already present is never overwritten - CreateSBOM consults the source only to fill an + // empty field - so the boundary closest to the user wins and a library consumer who configures + // only their source still gets those patterns applied inside archives. Callers building this + // config directly should leave it alone; the zero value means nothing has populated it yet. + ExclusionPatterns []string `yaml:"-" json:"-" mapstructure:"-"` } func DefaultArchiveSearchConfig() ArchiveSearchConfig { @@ -35,9 +75,8 @@ func DefaultArchiveSearchConfig() ArchiveSearchConfig { IncludeIndexedArchives: true, IncludeUnindexedArchives: false, MaxDepth: DefaultArchiveMaxDepth, - MaxExtractionSizeBytes: DefaultArchiveMaxExtractionSizeBytes, - MaxFileCount: DefaultArchiveMaxFileCount, - MaxTotalExtractionBytes: DefaultArchiveMaxTotalExtractionBytes, + MaxMemoryBytes: DefaultArchiveMaxMemoryBytes, + MaxDiskBytes: DefaultArchiveMaxDiskBytes, } } @@ -56,22 +95,25 @@ func (c ArchiveSearchConfig) WithMaxDepth(depth int) ArchiveSearchConfig { return c } -func (c ArchiveSearchConfig) WithMaxExtractionSizeBytes(size int64) ArchiveSearchConfig { - c.MaxExtractionSizeBytes = size +func (c ArchiveSearchConfig) WithMaxMemoryBytes(size int64) ArchiveSearchConfig { + c.MaxMemoryBytes = size return c } -func (c ArchiveSearchConfig) WithMaxFileCount(count int) ArchiveSearchConfig { - c.MaxFileCount = count +func (c ArchiveSearchConfig) WithMaxDiskBytes(size int64) ArchiveSearchConfig { + c.MaxDiskBytes = size return c } -func (c ArchiveSearchConfig) WithMaxTotalExtractionBytes(size int64) ArchiveSearchConfig { - c.MaxTotalExtractionBytes = size - return c -} - -func (c ArchiveSearchConfig) WithExcludeExtensions(extensions []string) ArchiveSearchConfig { - c.ExcludeExtensions = extensions +// WithExclusionPatterns sets the scan's exclusion patterns for the code that indexes an archive's +// contents. Only patterns whose shape reaches inside an archive belong here; ArchiveExclusionPatterns +// selects them. +// +// This is how the two boundaries that populate the field do it, and it is not a way to configure a +// second exclusion list: the field carries no yaml, json or mapstructure tag, so nothing a user +// writes reaches it. A caller who sets it here is the boundary closest to the user, and +// syft.CreateSBOM will not overwrite a value that is already present. +func (c ArchiveSearchConfig) WithExclusionPatterns(patterns []string) ArchiveSearchConfig { + c.ExclusionPatterns = patterns return c } diff --git a/syft/cataloging/archive_search_test.go b/syft/cataloging/archive_search_test.go index f15e76b34..6182876b7 100644 --- a/syft/cataloging/archive_search_test.go +++ b/syft/cataloging/archive_search_test.go @@ -1,6 +1,7 @@ package cataloging import ( + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -12,10 +13,39 @@ func TestDefaultArchiveSearchConfig(t *testing.T) { assert.True(t, cfg.IncludeIndexedArchives) assert.False(t, cfg.IncludeUnindexedArchives) assert.Equal(t, DefaultArchiveMaxDepth, cfg.MaxDepth) - assert.Equal(t, int64(DefaultArchiveMaxExtractionSizeBytes), cfg.MaxExtractionSizeBytes) - assert.Equal(t, DefaultArchiveMaxFileCount, cfg.MaxFileCount) - assert.Equal(t, int64(DefaultArchiveMaxTotalExtractionBytes), cfg.MaxTotalExtractionBytes) - assert.Nil(t, cfg.ExcludeExtensions) + assert.Equal(t, int64(DefaultArchiveMaxMemoryBytes), cfg.MaxMemoryBytes) + assert.Equal(t, int64(DefaultArchiveMaxDiskBytes), cfg.MaxDiskBytes) +} + +func TestArchiveSearchConfig_namesNoSeparateOverflowSize(t *testing.T) { + // two things are asserted by one list. The boundary between memory and disk is decided by the + // memory limit alone, so the surface must never grow a field naming a separate size at which + // content overflows. And nested archive cataloging honors the scan's own exclusion patterns, so the + // surface must name no exclusion setting of its own - an archive extension list here would be a + // second mechanism for a question the scan already answers. + // ExclusionPatterns is on the struct but is NOT config surface: it is derived, written only by + // CreateSBOM from what the source published, and reachable from no file, flag or variable. So it + // is listed here and separately asserted to carry no tags - which is what keeps this test able to + // catch a SETTABLE addition rather than merely any addition. + var settable, derived []string + typ := reflect.TypeOf(ArchiveSearchConfig{}) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.Tag.Get("yaml") == "-" && f.Tag.Get("json") == "-" && f.Tag.Get("mapstructure") == "-" { + derived = append(derived, f.Name) + continue + } + settable = append(settable, f.Name) + } + assert.Equal(t, []string{ + "IncludeIndexedArchives", + "IncludeUnindexedArchives", + "MaxDepth", + "MaxMemoryBytes", + "MaxDiskBytes", + }, settable, "a new SETTABLE field means a new way to configure this capability") + assert.Equal(t, []string{"ExclusionPatterns"}, derived, + "a derived field must stay untagged, or it becomes config surface without anyone deciding it should") } func TestArchiveSearchConfig_WithMethods(t *testing.T) { @@ -24,17 +54,11 @@ func TestArchiveSearchConfig_WithMethods(t *testing.T) { cfg = cfg.WithMaxDepth(5) assert.Equal(t, 5, cfg.MaxDepth) - cfg = cfg.WithMaxExtractionSizeBytes(1024) - assert.Equal(t, int64(1024), cfg.MaxExtractionSizeBytes) + cfg = cfg.WithMaxMemoryBytes(1024) + assert.Equal(t, int64(1024), cfg.MaxMemoryBytes) - cfg = cfg.WithMaxFileCount(100) - assert.Equal(t, 100, cfg.MaxFileCount) - - cfg = cfg.WithMaxTotalExtractionBytes(2048) - assert.Equal(t, int64(2048), cfg.MaxTotalExtractionBytes) - - cfg = cfg.WithExcludeExtensions([]string{".rpm", ".deb"}) - assert.Equal(t, []string{".rpm", ".deb"}, cfg.ExcludeExtensions) + cfg = cfg.WithMaxDiskBytes(2048) + assert.Equal(t, int64(2048), cfg.MaxDiskBytes) cfg = cfg.WithIncludeIndexedArchives(false) assert.False(t, cfg.IncludeIndexedArchives) diff --git a/syft/create_sbom.go b/syft/create_sbom.go index 817e698c6..a06b65a59 100644 --- a/syft/create_sbom.go +++ b/syft/create_sbom.go @@ -38,7 +38,21 @@ func CreateSBOM(ctx context.Context, src source.Source, cfg *CreateSBOMConfig) ( srcMetadata := src.Describe() - taskGroups, audit, err := cfg.makeTaskGroups(srcMetadata) + // read before the source builds its own resolver: exclusions belong to the source, and the + // archive cataloger needs them to index each archive it finds the way the scan indexes itself. + // This is the one type assertion (source.PathExcluder) left in the whole path. A local copy, so + // the CreateSBOMConfig the caller supplied is never mutated by deriving it. + // + // A value already present is never overwritten: cmd/syft/internal/options.ToArchiveConfig is the + // other boundary that populates this field, from the CLI's --exclude flag, and its value wins + // when set. The source is consulted only to fill an empty field, which is what makes + // "configure only your source" keep working for a library consumer who never touches this field. + archiveCfg := cfg.Archive + if len(archiveCfg.ExclusionPatterns) == 0 { + archiveCfg = archiveCfg.WithExclusionPatterns(sourceExclusions(src)) + } + + taskGroups, audit, err := cfg.makeTaskGroups(srcMetadata, archiveCfg) if err != nil { return nil, err } @@ -88,8 +102,8 @@ func CreateSBOM(ctx context.Context, src source.Source, cfg *CreateSBOMConfig) ( } } - catalogingProgress := monitorCatalogingTask(src.ID(), taskGroups) - packageCatalogingProgress := monitorPackageCatalogingTask() + catalogingProgress := monitorCatalogingTask(ctx, src.ID(), taskGroups) + packageCatalogingProgress := monitorPackageCatalogingTask(ctx) builder := sbomsync.NewBuilder(&s, monitorPackageCount(packageCatalogingProgress)) for i := range taskGroups { @@ -173,7 +187,7 @@ func monitorPackageCount(prog *monitor.TaskProgress) func(s *sbom.SBOM) { } } -func monitorPackageCatalogingTask() *monitor.TaskProgress { +func monitorPackageCatalogingTask(ctx context.Context) *monitor.TaskProgress { info := monitor.GenericTask{ Title: monitor.Title{ Default: "Packages", @@ -183,10 +197,10 @@ func monitorPackageCatalogingTask() *monitor.TaskProgress { ParentID: monitor.TopLevelCatalogingTaskID, } - return bus.StartCatalogerTask(info, -1, "") + return bus.StartCatalogerTask(ctx, info, -1, "") } -func monitorCatalogingTask(srcID artifact.ID, tasks [][]task.Task) *monitor.TaskProgress { +func monitorCatalogingTask(ctx context.Context, srcID artifact.ID, tasks [][]task.Task) *monitor.TaskProgress { info := monitor.GenericTask{ Title: monitor.Title{ Default: "Catalog contents", @@ -203,7 +217,7 @@ func monitorCatalogingTask(srcID artifact.ID, tasks [][]task.Task) *monitor.Task length += int64(len(tg)) } - return bus.StartCatalogerTask(info, length, "") + return bus.StartCatalogerTask(ctx, info, length, "") } func formatTaskNames(tasks []task.Task) []string { diff --git a/syft/create_sbom_config.go b/syft/create_sbom_config.go index 1a84c14b6..8b97f0327 100644 --- a/syft/create_sbom_config.go +++ b/syft/create_sbom_config.go @@ -7,16 +7,79 @@ import ( "runtime/debug" "strings" + "github.com/anchore/syft/internal/archive" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/task" "github.com/anchore/syft/syft/cataloging" "github.com/anchore/syft/syft/cataloging/filecataloging" "github.com/anchore/syft/syft/cataloging/pkgcataloging" "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/internal/fileresolver" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) +// archiveResolverFactory returns a factory that builds an indexed resolver over an extracted-archive +// directory whose Locations are stamped with the given (nesting-chain) fileSystemID. It is passed to +// the archive cataloger task so the internal/task and internal/archive packages do not need to +// import the syft-subtree internal fileresolver package directly. +// +// exclusions are the scan's own exclusion patterns, carried here on cataloging.ArchiveSearchConfig's +// derived ExclusionPatterns field rather than passed in from the source directly; this capability has +// no exclusion setting of its own. Those whose shape reaches inside an archive are applied as index +// visitors, which is why an excluded file ends up absent from the archive's filesystem rather than +// present and skipped: an excluded archive is then never found to extract in the first place. +func archiveResolverFactory(exclusions []string) archive.ResolverFactory { + patterns := cataloging.ArchiveExclusionPatterns(exclusions) + + return func(overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + visitor, err := newArchiveExclusionVisitor(overflow.RootDir, patterns) + if err != nil { + return nil, archive.IndexResult{}, err + } + + var filters []fileresolver.PathIndexVisitor + if visitor != nil { + filters = append(filters, visitor) + } + + resolver, err := fileresolver.NewFromArchiveTar(overflow.RootDir, overflow.TarPath, overflow.FileSystemID, overflow.ArchivePath, filters...) + if err != nil { + return nil, archive.IndexResult{}, err + } + return resolver, archive.IndexResult{Records: resolver.Records(), Truncated: resolver.IndexTruncated()}, nil + } +} + +// archiveEntryResolverFactory returns a factory that builds a resolver over an archive's entries +// rather than over a tar of them. +// +// This is the store-backed path: an archive small enough to stay in memory never reaches the disk, and +// one that outgrows memory moves its bytes without the index being rebuilt. It stands beside the +// tar-backed factory rather than replacing it so the two can be measured against each other on the +// same corpus. +func archiveEntryResolverFactory(exclusions []string) archive.StoreResolverFactory { + patterns := cataloging.ArchiveExclusionPatterns(exclusions) + + return func(store *archive.EntryStore, overflow archive.Overflow) (file.Resolver, archive.IndexResult, error) { + visitor, err := newArchiveExclusionVisitor(overflow.RootDir, patterns) + if err != nil { + return nil, archive.IndexResult{}, err + } + + var filters []fileresolver.PathIndexVisitor + if visitor != nil { + filters = append(filters, visitor) + } + + resolver, err := fileresolver.NewFromArchiveEntries(overflow.RootDir, overflow.FileSystemID, overflow.ArchivePath, store, false, filters...) + if err != nil { + return nil, archive.IndexResult{}, err + } + return resolver, archive.IndexResult{Records: resolver.Records(), Truncated: resolver.IndexTruncated()}, nil + } +} + // CreateSBOMConfig specifies all parameters needed for creating an SBOM. type CreateSBOMConfig struct { // required configuration input to specify how cataloging should be performed @@ -28,6 +91,7 @@ type CreateSBOMConfig struct { Packages pkgcataloging.Config Licenses cataloging.LicenseConfig Files filecataloging.Config + Archive cataloging.ArchiveSearchConfig Parallelism int CatalogerSelection cataloging.SelectionRequest @@ -49,6 +113,7 @@ func DefaultCreateSBOMConfig() *CreateSBOMConfig { Packages: pkgcataloging.DefaultConfig(), Licenses: cataloging.DefaultLicenseConfig(), Files: filecataloging.DefaultConfig(), + Archive: cataloging.DefaultArchiveSearchConfig(), Parallelism: 0, // use default: run in parallel based on number of CPUs packageTaskFactories: task.DefaultPackageTaskFactories(), @@ -144,6 +209,14 @@ func (c *CreateSBOMConfig) WithFilesConfig(cfg filecataloging.Config) *CreateSBO return c } +// WithArchiveConfig allows for defining recursive archive cataloging parameters (e.g. max depth and +// extraction limits). When the configured MaxDepth is 0 (the default), recursive archive cataloging +// is disabled. +func (c *CreateSBOMConfig) WithArchiveConfig(cfg cataloging.ArchiveSearchConfig) *CreateSBOMConfig { + c.Archive = cfg + return c +} + // WithoutFiles allows for disabling file cataloging altogether. func (c *CreateSBOMConfig) WithoutFiles() *CreateSBOMConfig { c.Files = filecataloging.Config{ @@ -183,7 +256,12 @@ func (c *CreateSBOMConfig) WithCatalogers(catalogerRefs ...pkgcataloging.Catalog // groups, where each task in a group can be run concurrently, while tasks in different groups must be run serially. // The final set of task groups is returned along with a cataloger manifest that describes the catalogers that were // selected and the tokens that were sensitive to this selection (both for adding and removing from the final set). -func (c *CreateSBOMConfig) makeTaskGroups(src source.Description) ([][]task.Task, *catalogerManifest, error) { +// +// archiveCfg is c.Archive with ExclusionPatterns derived and populated by the caller (CreateSBOM, +// the only place that holds the source and can read source.PathExcluder off it). It is passed in +// rather than read off c so that deriving the field never mutates the CreateSBOMConfig the library +// consumer supplied. +func (c *CreateSBOMConfig) makeTaskGroups(src source.Description, archiveCfg cataloging.ArchiveSearchConfig) ([][]task.Task, *catalogerManifest, error) { var taskGroups [][]task.Task // generate package and file tasks based on the configuration @@ -206,6 +284,16 @@ func (c *CreateSBOMConfig) makeTaskGroups(src source.Description) ([][]task.Task taskGroups = append(taskGroups, append(pkgTasks, fileTasks...)) } + // recursively catalog archives as standalone indexed filesystems (opt-in via Archive.MaxDepth). + // the sub-pipeline is the package/file cataloger set so the same catalogers run against archive contents; + // it must run after the base scan group and before relationship/unknowns post-processing. + var subPipeline []task.Task + subPipeline = append(subPipeline, pkgTasks...) + subPipeline = append(subPipeline, fileTasks...) + if archiveTask := task.NewArchiveCatalogerTask(archiveCfg, subPipeline, archiveResolverFactory(archiveCfg.ExclusionPatterns), archiveEntryResolverFactory(archiveCfg.ExclusionPatterns), archive.LogNotify); archiveTask != nil { + taskGroups = append(taskGroups, []task.Task{archiveTask}) + } + // all scope work must be done after all nodes (files and packages) have been cataloged and before the relationship if len(scopeTasks) > 0 { taskGroups = append(taskGroups, scopeTasks) @@ -266,6 +354,13 @@ func (c *CreateSBOMConfig) selectTasks(src source.Description) ([]task.Task, []t FilesConfig: c.Files, } + // nested archive cataloging is controlled by a single knob (Archive.MaxDepth): when enabled, the + // archive cataloger task recurses into all archives (including JAR-family), and the java + // cataloger must not unarchive nested archives itself. This is the ONE place the two sides are + // coupled, and it writes a field that exists only for this purpose, so no value a caller + // supplied through WithPackagesConfig is overwritten. + cfg.PackagesConfig.JavaArchive.NestedArchivesHandledExternally = c.Archive.MaxDepth > 0 + persistentPkgTasks, selectablePkgTasks, err := c.allPackageTasks(cfg) if err != nil { return nil, nil, nil, fmt.Errorf("unable to create package cataloger tasks: %w", err) diff --git a/syft/create_sbom_config_test.go b/syft/create_sbom_config_test.go index 0c12922f7..14c339048 100644 --- a/syft/create_sbom_config_test.go +++ b/syft/create_sbom_config_test.go @@ -18,6 +18,7 @@ import ( "github.com/anchore/syft/syft/cataloging/pkgcataloging" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/java" "github.com/anchore/syft/syft/source" ) @@ -329,7 +330,7 @@ func TestCreateSBOMConfig_makeTaskGroups(t *testing.T) { require.NotEmpty(t, tt.wantTaskNames) // test the subject - gotTasks, gotManifest, err := tt.cfg.makeTaskGroups(tt.src) + gotTasks, gotManifest, err := tt.cfg.makeTaskGroups(tt.src, tt.cfg.Archive) tt.wantErr(t, err) if err != nil { return @@ -564,3 +565,65 @@ func TestCreateSBOMConfig_validate(t *testing.T) { }) } } + +func TestCreateSBOMConfig_archiveTaskGroup(t *testing.T) { + src := source.Description{Metadata: source.DirectoryMetadata{}} + + archiveTaskIn := func(groups [][]task.Task) bool { + for _, group := range groups { + for _, tsk := range group { + if tsk.Name() == task.ArchiveCatalogerTaskName { + return true + } + } + } + return false + } + + t.Run("no archive task by default", func(t *testing.T) { + // the feature is off by default, and off must mean no task at all rather than a registered + // task that does nothing: a registered task is reported as a selected cataloger + groups, _, err := DefaultCreateSBOMConfig().makeTaskGroups(src, DefaultCreateSBOMConfig().Archive) + require.NoError(t, err) + assert.False(t, archiveTaskIn(groups)) + }) + + t.Run("archive task is present at negative depth", func(t *testing.T) { + cfg := DefaultCreateSBOMConfig(). + WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(-1)) + groups, _, err := cfg.makeTaskGroups(src, cfg.Archive) + require.NoError(t, err) + assert.True(t, archiveTaskIn(groups)) + }) + + t.Run("archive task is present when enabled", func(t *testing.T) { + cfg := DefaultCreateSBOMConfig(). + WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2)) + groups, _, err := cfg.makeTaskGroups(src, cfg.Archive) + require.NoError(t, err) + assert.True(t, archiveTaskIn(groups)) + }) + + t.Run("enabling the feature does not mutate the caller's package config", func(t *testing.T) { + // the java side's yield flag is derived from the archive depth, but it must be derived into + // the cataloging factory's own copy: a consumer who handed us a package config gets it back + // exactly as they supplied it. Nothing user-settable is overwritten, and nothing the + // framework sets leaks back out. + supplied := java.DefaultArchiveCatalogerConfig().WithUseNetwork(true) + require.False(t, supplied.NestedArchivesHandledExternally) + + cfg := DefaultCreateSBOMConfig(). + WithArchiveConfig(cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2)). + WithPackagesConfig(pkgcataloging.Config{JavaArchive: supplied}) + + _, _, err := cfg.makeTaskGroups(src, cfg.Archive) + require.NoError(t, err) + + assert.Equal(t, supplied, cfg.Packages.JavaArchive, + "the caller's java config must come back byte-identical") + assert.True(t, cfg.Packages.JavaArchive.UseNetwork, + "a caller-supplied field must survive untouched") + assert.Zero(t, cfg.Packages.JavaArchive.MaxDepth, + "java's own squash-inlined depth must not be written to either") + }) +} diff --git a/syft/file/cataloger/executable/cataloger.go b/syft/file/cataloger/executable/cataloger.go index f6cdb5e65..0fc71f862 100644 --- a/syft/file/cataloger/executable/cataloger.go +++ b/syft/file/cataloger/executable/cataloger.go @@ -63,7 +63,7 @@ func (i *Cataloger) CatalogCtx(ctx context.Context, resolver file.Resolver) (map return nil, err } - prog := catalogingProgress(int64(len(locs))) + prog := catalogingProgress(ctx, int64(len(locs))) results := make(map[file.Coordinates]file.Executable) errs := sync.Collect(&ctx, cataloging.ExecutorFile, sync.ToSeq(locs), func(loc file.Location) (*file.Executable, error) { @@ -106,7 +106,7 @@ func processExecutableLocation(loc file.Location, resolver file.Resolver) (*file return processExecutable(loc, uReader) } -func catalogingProgress(locations int64) *monitor.TaskProgress { +func catalogingProgress(ctx context.Context, locations int64) *monitor.TaskProgress { info := monitor.GenericTask{ Title: monitor.Title{ Default: "Executables", @@ -114,7 +114,7 @@ func catalogingProgress(locations int64) *monitor.TaskProgress { ParentID: monitor.TopLevelCatalogingTaskID, } - return bus.StartCatalogerTask(info, locations, "") + return bus.StartCatalogerTask(ctx, info, locations, "") } func filterByGlobs(locs []file.Location, globs []string) ([]file.Location, error) { diff --git a/syft/file/cataloger/filecontent/cataloger.go b/syft/file/cataloger/filecontent/cataloger.go index 5e57a94e8..2e3417d4f 100644 --- a/syft/file/cataloger/filecontent/cataloger.go +++ b/syft/file/cataloger/filecontent/cataloger.go @@ -44,7 +44,7 @@ func NewCataloger(cfg Config) *Cataloger { } } -func (i *Cataloger) Catalog(_ context.Context, resolver file.Resolver) (map[file.Coordinates]string, error) { +func (i *Cataloger) Catalog(ctx context.Context, resolver file.Resolver) (map[file.Coordinates]string, error) { results := make(map[file.Coordinates]string) var locations []file.Location var errs error @@ -54,7 +54,7 @@ func (i *Cataloger) Catalog(_ context.Context, resolver file.Resolver) (map[file return nil, err } - prog := catalogingProgress(int64(len(locations))) + prog := catalogingProgress(ctx, int64(len(locations))) for _, location := range locations { prog.AtomicStage.Set(location.Path()) @@ -113,7 +113,7 @@ func (i *Cataloger) catalogLocation(resolver file.Resolver, location file.Locati return buf.String(), nil } -func catalogingProgress(locations int64) *monitor.TaskProgress { +func catalogingProgress(ctx context.Context, locations int64) *monitor.TaskProgress { info := monitor.GenericTask{ Title: monitor.Title{ Default: "File contents", @@ -121,5 +121,5 @@ func catalogingProgress(locations int64) *monitor.TaskProgress { ParentID: monitor.TopLevelCatalogingTaskID, } - return bus.StartCatalogerTask(info, locations, "") + return bus.StartCatalogerTask(ctx, info, locations, "") } diff --git a/syft/file/cataloger/filedigest/cataloger.go b/syft/file/cataloger/filedigest/cataloger.go index 9ca6e7867..bc0ec534f 100644 --- a/syft/file/cataloger/filedigest/cataloger.go +++ b/syft/file/cataloger/filedigest/cataloger.go @@ -49,7 +49,7 @@ func (i *Cataloger) Catalog(ctx context.Context, resolver file.Resolver, coordin } } - prog := catalogingProgress(int64(len(locations))) + prog := catalogingProgress(ctx, int64(len(locations))) err := sync.Collect(&ctx, cataloging.ExecutorFile, sync.ToSeq(locations), func(location file.Location) ([]file.Digest, error) { result, err := i.catalogLocation(ctx, resolver, location) @@ -112,7 +112,7 @@ func (i *Cataloger) catalogLocation(ctx context.Context, resolver file.Resolver, return digests, nil } -func catalogingProgress(locations int64) *monitor.TaskProgress { +func catalogingProgress(ctx context.Context, locations int64) *monitor.TaskProgress { info := monitor.GenericTask{ Title: monitor.Title{ Default: "File digests", @@ -120,5 +120,5 @@ func catalogingProgress(locations int64) *monitor.TaskProgress { ParentID: monitor.TopLevelCatalogingTaskID, } - return bus.StartCatalogerTask(info, locations, "") + return bus.StartCatalogerTask(ctx, info, locations, "") } diff --git a/syft/file/cataloger/filemetadata/cataloger.go b/syft/file/cataloger/filemetadata/cataloger.go index f22383a6e..b8e7d64a2 100644 --- a/syft/file/cataloger/filemetadata/cataloger.go +++ b/syft/file/cataloger/filemetadata/cataloger.go @@ -53,7 +53,7 @@ func (i *Cataloger) Catalog(ctx context.Context, resolver file.Resolver, coordin }() } - prog := catalogingProgress(-1) + prog := catalogingProgress(ctx, -1) for location := range locations { prog.AtomicStage.Set(location.Path()) @@ -76,7 +76,7 @@ func (i *Cataloger) Catalog(ctx context.Context, resolver file.Resolver, coordin return results, errs } -func catalogingProgress(locations int64) *monitor.TaskProgress { +func catalogingProgress(ctx context.Context, locations int64) *monitor.TaskProgress { info := monitor.GenericTask{ Title: monitor.Title{ Default: "File metadata", @@ -84,5 +84,5 @@ func catalogingProgress(locations int64) *monitor.TaskProgress { ParentID: monitor.TopLevelCatalogingTaskID, } - return bus.StartCatalogerTask(info, locations, "") + return bus.StartCatalogerTask(ctx, info, locations, "") } diff --git a/syft/file/coordinates.go b/syft/file/coordinates.go index afeda5b13..a5ba50be4 100644 --- a/syft/file/coordinates.go +++ b/syft/file/coordinates.go @@ -12,8 +12,11 @@ type Coordinates struct { // RealPath is the canonical absolute form of the path accessed (all symbolic links have been followed and relative path components like '.' and '..' have been removed). RealPath string `json:"path" cyclonedx:"path"` - // FileSystemID is an ID representing and entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. + // FileSystemID is an ID representing an entire filesystem. For container images, this is a layer digest. For directories or a root filesystem, this is blank. A file found inside an archive carries the FileSystemID of the filesystem the archive itself was found in (a layer digest, or blank), inherited unchanged down the nesting chain; the nesting chain is carried by ArchivePath instead. FileSystemID string `json:"layerID,omitempty" cyclonedx:"layerID"` + + // ArchivePath is the colon-delimited chain of archive paths, from the scan root, of every archive traversed to reach this file (e.g. "app.war:WEB-INF/lib/dep.jar"). It is blank for files not found within an archive. It disambiguates identically-named files residing in different archives on the same filesystem. + ArchivePath string `json:"archivePath,omitempty" cyclonedx:"archivePath"` } func NewCoordinates(realPath, fsID string) Coordinates { @@ -40,9 +43,24 @@ func (c Coordinates) String() string { if c.FileSystemID != "" { str += fmt.Sprintf(" Layer=%q", c.FileSystemID) } + if c.ArchivePath != "" { + str += fmt.Sprintf(" Archive=%q", c.ArchivePath) + } return fmt.Sprintf("Location<%s>", str) } func (c Coordinates) GetCoordinates() Coordinates { return c } + +// HashInclude controls which fields participate in the artifact ID hash (see artifact.IDByHash, +// which uses hashstructure). ArchivePath is excluded from the hash when empty so that ordinary +// (non-archive) coordinates keep the same identity they had before ArchivePath existed; a non-empty +// ArchivePath is included, which is what keeps identically-named files in different archives from +// sharing an ID. +func (c Coordinates) HashInclude(field string, _ any) (bool, error) { + if field == "ArchivePath" && c.ArchivePath == "" { + return false, nil + } + return true, nil +} diff --git a/syft/internal/fileresolver/archive_index.go b/syft/internal/fileresolver/archive_index.go new file mode 100644 index 000000000..99b93f494 --- /dev/null +++ b/syft/internal/fileresolver/archive_index.go @@ -0,0 +1,386 @@ +package fileresolver + +import ( + "context" + "fmt" + "io" + "path" + "sort" + "strings" + + stereoscopeFile "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/syft/internal/archive" + syftindex "github.com/anchore/syft/internal/index" + "github.com/anchore/syft/syft/file" +) + +// ArchiveIndex is a resolver over the entries of one extracted archive, indexed by the entries +// themselves rather than by offsets into a file. +// +// It exists because the resolver it stands beside, ArchiveTar, can only index content that is already +// on disk: its index IS the tar's seek offsets, so every archive paid for a tar write and a tar walk +// before a cataloger could read a byte. Here the index is over archive.Entry values whose bytes may be +// in memory or in an overflow blob, and which may move between the two without the index noticing. +// +// Lookups go through a key-split index rather than a walk. A cataloger's globs are overwhelmingly +// `**/*.ext` and `**/name`, and both are answered by a lookup keyed on the base name - by suffix for +// an extension, exactly for a name - instead of by matching every path in the archive. +type ArchiveIndex struct { + fileSystemID string + archivePath string + root *indexNode + + // byPath is every node by its full archive-relative path, which is what a path lookup wants and + // what HasPath answers from. + byPath map[string]*indexNode + + // fileNames and dirNames are the global base-name indexes the glob fast paths use. The values are + // slices because a name repeats across directories. + fileNames syftindex.PrefixSuffix[[]*indexNode] + dirNames syftindex.PrefixSuffix[[]*indexNode] + + store *archive.EntryStore + records int + truncated bool + + // sorted is every file in a stable order, built once. See ordered. + sorted []*indexNode +} + +// indexNode is one file or directory in an archive's filesystem. +// +// A directory may be synthesized: archives routinely name `a/b/c.txt` without ever naming `a/` or +// `a/b/`, and a resolver that only knew the named entries could not answer a glob whose middle +// segment is a directory. A synthesized node has no entry and no content. +type indexNode struct { + name string + path string + isDir bool + entry *archive.Entry + metadata file.Metadata + parent *indexNode + + children syftindex.KeySplitIndex[*indexNode] + childList []*indexNode +} + +// NewFromArchiveEntries builds a resolver over the entries of one archive. +// +// rootPath is what every entry's path is reported relative to, matching what the tar-backed resolver +// reports, so a location's path is archive-relative rather than naming the scan's scratch space. +// pathFilters are applied per entry exactly as they are for a directory walk, including the +// prune-a-subtree answer. +func NewFromArchiveEntries(rootDir, fileSystemID, archivePath string, store *archive.EntryStore, truncated bool, pathFilters ...PathIndexVisitor) (*ArchiveIndex, error) { + if store == nil { + return nil, fmt.Errorf("no entry store for archive %q", fileSystemID) + } + + // Two frames, and they are not interchangeable. Nodes are keyed and reported by their + // archive-relative path, because that is what belongs in an SBOM - a location naming the scan's + // scratch directory would leak a temp path into published output, and the filesystem id chain is + // composed from these paths. Path filters, on the other hand, are built against the real + // directory, so they are handed the absolute path. + rootPath := treeRoot(rootDir) + r := &ArchiveIndex{ + fileSystemID: fileSystemID, + archivePath: archivePath, + root: &indexNode{name: "", path: "/", isDir: true}, + byPath: map[string]*indexNode{}, + store: store, + truncated: truncated, + } + r.byPath["/"] = r.root + + var pruned []string + for _, entry := range store.Entries() { + name, ok := sanitizeEntryName(entry.Header.Name) + if !ok { + continue + } + absPath := path.Join(rootPath, name) + + if isPruned(absPath, pruned) { + continue + } + switch runPathFilters(pathFilters, rootDir, absPath, entry.Header.FileInfo()) { + case skipEntry: + continue + case skipTree: + pruned = append(pruned, absPath) + continue + case keepEntry: + } + + r.add(entry, "/"+name) + } + + return r, nil +} + +// add puts one entry into the tree and the indexes, creating whatever directories its path implies. +func (r *ArchiveIndex) add(entry *archive.Entry, entryPath string) { + isDir := entry.Header.FileInfo().IsDir() + node := r.node(entryPath, isDir) + if node == nil { + return + } + + if node.entry != nil { + // a crafted archive naming the same path twice: the first entry is the one this filesystem + // has, matching the tar-backed resolver, which cannot add a second node at one path either + return + } + + node.entry = entry + node.metadata = r.metadataOf(entry, entryPath) + r.records++ + + if isDir { + r.dirNames.Update(node.name, appendNode(node)) + return + } + r.fileNames.Update(node.name, appendNode(node)) +} + +func appendNode(node *indexNode) syftindex.NodeUpdateFunc[[]*indexNode] { + return func(current *syftindex.Node[[]*indexNode]) []*indexNode { + return append(current.Value(), node) + } +} + +// node returns the node at the given path, creating it and any missing parent directories. +func (r *ArchiveIndex) node(entryPath string, isDir bool) *indexNode { + if existing, ok := r.byPath[entryPath]; ok { + return existing + } + + parentPath := path.Dir(entryPath) + if parentPath == entryPath { + // cannot climb any further without leaving the archive + return nil + } + parent := r.node(parentPath, true) + if parent == nil { + return nil + } + + node := &indexNode{ + name: path.Base(entryPath), + path: entryPath, + isDir: isDir, + parent: parent, + } + parent.children.Set(node.name, node) + parent.childList = append(parent.childList, node) + r.byPath[entryPath] = node + + if isDir && node.entry == nil { + // a directory the archive never named still has to be findable by name, since a glob's middle + // segment may be exactly it + r.dirNames.Update(node.name, appendNode(node)) + } + return node +} + +// metadataOf describes one entry as a file in the archive's filesystem, including the MIME type +// sniffed from its content - which is what FilesByMIMEType answers from, and what archive detection +// one level down depends on. +func (r *ArchiveIndex) metadataOf(entry *archive.Entry, entryPath string) file.Metadata { + var contents io.Reader + if opened, err := r.store.Open(entry); err == nil { + contents = opened + } + + metadata := stereoscopeFile.NewMetadata(entry.Header, contents) + metadata.Path = reportedPath(entryPath) + + if entry.Header.Linkname != "" { + relative := path.Clean(entry.Header.Linkname) + if !path.IsAbs(relative) { + relative = path.Join("/", path.Dir(entryPath), entry.Header.Linkname) + } + // resolved in archive-relative space, where path.Join clamps a climb at the root, so a target + // naming the host filesystem lands somewhere inside this archive or nowhere at all + metadata.LinkDestination = reportedPath(relative) + } + + return metadata +} + +// Records reports how many of the archive's entries this filesystem holds. +func (r *ArchiveIndex) Records() int { return r.records } + +// IndexTruncated reports that the archive's content ended before the archive did. +func (r *ArchiveIndex) IndexTruncated() bool { return r.truncated } + +// Close releases the entry store this index was built over, and with it the overflow blob's file +// handle. Readers handed out by this resolver borrow the store, so the index must not be closed until +// the extracted archive is torn down - which is exactly when ExtractedArchive.Cleanup calls it, after +// the sub-pipeline and after recursion. Safe to call more than once. +func (r *ArchiveIndex) Close() error { + if r == nil || r.store == nil { + return nil + } + // the store reference is kept, not cleared: EntryStore.Close is itself idempotent, and a read that + // arrives after Close then fails gracefully through the store rather than dereferencing a nil one. + return r.store.Close() +} + +func (r *ArchiveIndex) location(node *indexNode) file.Location { + return file.NewLocationFromCoordinates(file.Coordinates{ + RealPath: reportedPath(node.path), + FileSystemID: r.fileSystemID, + ArchivePath: r.archivePath, + }) +} + +// reportedPath is how a path leaves this resolver: archive-relative and with no leading slash, which +// is the form the tar-backed resolver reports and therefore the form already in published SBOMs and in +// every filesystem-id chain composed from one. Nodes are keyed with the slash, because that is what +// path.Dir and path.Join want. +func reportedPath(nodePath string) string { + return strings.TrimPrefix(nodePath, "/") +} + +// FileContentsByLocation returns a reader over the entry's content, from memory or from the overflow +// blob as it currently stands. The reader is Read, Seek and ReadAt either way, so a nested archive is +// read where it lies. +func (r *ArchiveIndex) FileContentsByLocation(location file.Location) (io.ReadCloser, error) { + node, ok := r.byPath[r.requestPath(location.RealPath)] + if !ok || node.entry == nil { + return nil, fmt.Errorf("no archive entry for path=%q", location.RealPath) + } + reader, err := r.store.Open(node.entry) + if err != nil { + return nil, err + } + return overflowEntryReader{nopCloserAt{reader}}, nil +} + +// FileMetadataByLocation returns what was recorded for the entry when it was indexed. +func (r *ArchiveIndex) FileMetadataByLocation(location file.Location) (file.Metadata, error) { + node, ok := r.byPath[r.requestPath(location.RealPath)] + if !ok { + return file.Metadata{}, fmt.Errorf("no archive entry for path=%q", location.RealPath) + } + return node.metadata, nil +} + +// HasPath reports whether the archive holds anything at that path, file or directory. +func (r *ArchiveIndex) HasPath(userPath string) bool { + _, ok := r.byPath[r.requestPath(userPath)] + return ok +} + +// FilesByPath returns the files at the given paths. Directories are not files, matching every other +// resolver. +func (r *ArchiveIndex) FilesByPath(paths ...string) ([]file.Location, error) { + var out []file.Location + seen := map[string]struct{}{} + for _, userPath := range paths { + node, ok := r.byPath[r.requestPath(userPath)] + if !ok || node.isDir || node.entry == nil { + continue + } + if _, dup := seen[node.path]; dup { + continue + } + seen[node.path] = struct{}{} + out = append(out, r.location(node)) + } + return out, nil +} + +// FilesByMIMEType returns the files whose sniffed content type is one of the given types. +func (r *ArchiveIndex) FilesByMIMEType(types ...string) ([]file.Location, error) { + wanted := map[string]struct{}{} + for _, t := range types { + wanted[t] = struct{}{} + } + + var out []file.Location + for _, node := range r.ordered() { + if node.entry == nil { + continue + } + if _, ok := wanted[node.metadata.MIMEType]; !ok { + continue + } + out = append(out, r.location(node)) + } + return out, nil +} + +// ordered returns the archive's files in a stable order. +// +// Every answer this resolver gives has to be ordered, because a resolver that returns the same set in +// a different order each run is not the same resolver twice. The archive walk enters archives in the +// order they are handed to it, and what it finds inside them is merged into one shared SBOM - so the +// order decides which locations end up grouped into which package. Measured on trinodb/trino, whose +// plugin jars are hard links to one another: unordered, one libzstd with 24 locations became three +// libzstd packages holding 6, 9 and 9 of the same 24. +// +// Files are kept rather than every node, since every caller of this wants files. +func (r *ArchiveIndex) ordered() []*indexNode { + if r.sorted != nil { + return r.sorted + } + out := make([]*indexNode, 0, len(r.byPath)) + for _, node := range r.byPath { + if node.isDir { + continue + } + out = append(out, node) + } + sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path }) + r.sorted = out + return out +} + +// RelativeFileByPath resolves a path relative to the archive's root, which is the only frame an +// archive has. +func (r *ArchiveIndex) RelativeFileByPath(_ file.Location, userPath string) *file.Location { + locations, err := r.FilesByPath(userPath) + if err != nil || len(locations) == 0 { + return nil + } + return &locations[0] +} + +// AllLocations returns every file in the archive. +func (r *ArchiveIndex) AllLocations(ctx context.Context) <-chan file.Location { + out := make(chan file.Location) + go func() { + defer close(out) + for _, node := range r.ordered() { + if node.entry == nil { + continue + } + select { + case out <- r.location(node): + case <-ctx.Done(): + return + } + } + }() + return out +} + +// requestPath puts a caller's path into the archive's frame: a path may arrive absolute (the way a +// cataloger writes it) or already rooted, and both name the same entry. +func (r *ArchiveIndex) requestPath(userPath string) string { + if userPath == "" { + return "/" + } + return path.Clean("/" + strings.TrimPrefix(userPath, "/")) +} + +var _ file.Resolver = (*ArchiveIndex)(nil) + +// nopCloserAt gives an entry reader the Close the resolver interface asks for. The store owns the +// bytes and the overflow file, so closing a reader over them must not close anything. +type nopCloserAt struct { + archive.ReaderAtSeeker +} + +func (nopCloserAt) Close() error { return nil } diff --git a/syft/internal/fileresolver/archive_index_glob.go b/syft/internal/fileresolver/archive_index_glob.go new file mode 100644 index 000000000..58073f460 --- /dev/null +++ b/syft/internal/fileresolver/archive_index_glob.go @@ -0,0 +1,203 @@ +package fileresolver + +import ( + "sort" + "strings" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/anchore/syft/syft/file" +) + +const ( + anyDir = "**" + wildcard = '*' +) + +// FilesByGlob returns the files matching any of the patterns. +// +// The walk is segment by segment, with one deliberate shortcut: a pattern that begins `**/` and whose +// remainder is a single segment is answered from the global base-name index instead of by descending +// the tree. That covers `**/*.jar`, `**/pom.properties` and `**/*pom.xml` - which is most of what +// syft's catalogers ask for - and turns "match every path in this archive" into a lookup keyed on the +// name. +// +// Patterns are archive-relative. A leading slash is not a filesystem root here, it is the archive's +// own root, so `/META-INF/*` and `META-INF/*` name the same entries - and `/*` names the entries at +// the top of the archive, which is what the tar-backed resolver could not match at all. +func (r *ArchiveIndex) FilesByGlob(patterns ...string) ([]file.Location, error) { + seen := map[string]struct{}{} + var out []file.Location + + keep := func(node *indexNode) { + if node.isDir || node.entry == nil { + return + } + if _, dup := seen[node.path]; dup { + return + } + seen[node.path] = struct{}{} + out = append(out, r.location(node)) + } + + for _, pattern := range patterns { + segments := splitPattern(pattern) + if len(segments) == 0 { + continue + } + if nodes, ok := r.byNameIndex(segments); ok { + for _, node := range nodes { + keep(node) + } + continue + } + r.match(r.root, segments, keep) + } + + // ordered for the same reason every other answer here is: what a cataloger is handed in what order + // decides how its findings merge, and the index's own traversal order is a map's + sort.Slice(out, func(i, j int) bool { return out[i].RealPath < out[j].RealPath }) + return out, nil +} + +// splitPattern turns a glob into its segments, dropping the leading and trailing separators that +// distinguish "rooted at the archive" from "relative to it" - inside one archive there is no +// difference. +func splitPattern(pattern string) []string { + trimmed := strings.Trim(pattern, "/") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "/") +} + +// byNameIndex answers the `**/` shapes from the global file-name index, and reports false +// for anything else so the caller falls back to the walk. +// +// Only the forms the index can answer exactly are taken: a suffix match (`*foo`), a prefix match +// (`foo*`), and an exact name. A segment with a wildcard in the middle would need every name anyway, +// so it goes to the walk rather than pretending. +func (r *ArchiveIndex) byNameIndex(segments []string) ([]*indexNode, bool) { + if len(segments) != 2 || segments[0] != anyDir { + return nil, false + } + name := segments[1] + if strings.Contains(name, anyDir) { + return nil, false + } + + first := strings.IndexRune(name, wildcard) + last := strings.LastIndexByte(name, byte(wildcard)) + + switch { + case first < 0: + // an exact name, e.g. **/pom.properties + return flattenNodes(r.fileNames.Get(name)), true + case first == 0 && last == 0 && len(name) > 1: + // *something, e.g. **/*.jar - the reverse index is keyed for exactly this + return flattenNodes(r.fileNames.BySuffix(name[1:])...), true + case last == len(name)-1 && first == last && len(name) > 1: + // something*, e.g. **/pom* + return flattenNodes(r.fileNames.ByPrefix(name[:len(name)-1])...), true + } + return nil, false +} + +func flattenNodes(groups ...[]*indexNode) []*indexNode { + var out []*indexNode + for _, group := range groups { + out = append(out, group...) + } + return out +} + +// match walks the pattern's segments from a node, calling keep for every file the pattern reaches. +func (r *ArchiveIndex) match(from *indexNode, segments []string, keep func(*indexNode)) { + if len(segments) == 0 { + keep(from) + return + } + + segment := segments[0] + rest := segments[1:] + + if segment == anyDir { + // ** matches this directory and every directory beneath it + if len(rest) == 0 { + r.collect(from, keep) + return + } + r.match(from, rest, keep) + for _, dir := range r.dirsBeneath(from) { + r.match(dir, rest, keep) + } + return + } + + for _, child := range r.childrenMatching(from, segment) { + if len(rest) == 0 { + keep(child) + continue + } + if child.isDir { + r.match(child, rest, keep) + } + } +} + +// childrenMatching returns the children of a node whose names match one glob segment, using the +// directory's own name index where the segment allows it. +func (r *ArchiveIndex) childrenMatching(node *indexNode, segment string) []*indexNode { + first := strings.IndexRune(segment, wildcard) + + switch { + case first < 0: + // an exact name: one lookup in this directory's index + if child := node.children.Get(segment); child != nil { + return []*indexNode{child} + } + return nil + case segment == string(wildcard): + return node.childList + case first == len(segment)-1: + // name*, which the directory's index answers by prefix + return node.children.ByPrefix(segment[:len(segment)-1]) + } + + var out []*indexNode + for _, child := range node.childList { + if ok, err := doublestar.Match(segment, child.name); err == nil && ok { + out = append(out, child) + } + } + return out +} + +// dirsBeneath returns every directory below a node, which is what `**` has to consider when more +// pattern follows it. +func (r *ArchiveIndex) dirsBeneath(node *indexNode) []*indexNode { + var out []*indexNode + var walk func(*indexNode) + walk = func(n *indexNode) { + for _, child := range n.childList { + if !child.isDir { + continue + } + out = append(out, child) + walk(child) + } + } + walk(node) + return out +} + +// collect reports every file at or below a node. +func (r *ArchiveIndex) collect(node *indexNode, keep func(*indexNode)) { + for _, child := range node.childList { + if child.isDir { + r.collect(child, keep) + continue + } + keep(child) + } +} diff --git a/syft/internal/fileresolver/archive_index_test.go b/syft/internal/fileresolver/archive_index_test.go new file mode 100644 index 000000000..eaba8ec82 --- /dev/null +++ b/syft/internal/fileresolver/archive_index_test.go @@ -0,0 +1,293 @@ +package fileresolver + +import ( + "archive/tar" + "bytes" + "context" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/syft/file" +) + +// storeWith builds an entry store holding the given entries, in name order so a test that depends on +// order is not depending on a map. +func storeWith(t *testing.T, maxInMemory int64, entries map[string]string) *archive.EntryStore { + t.Helper() + store := archive.NewEntryStore(t.TempDir(), "test.jar", nil) + // how much stays in memory is the limiter's answer, so a test that wants content on disk sets a + // memory bound and leaves disk unbounded + charge := archive.NewLimiter(archive.Limits{MaxMemoryBytes: maxInMemory, MaxDiskBytes: -1}).Charge() + t.Cleanup(func() { require.NoError(t, store.Close()) }) + + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + body := entries[name] + hdr := tar.Header{Name: name, Size: int64(len(body)), Mode: 0o600, Typeflag: tar.TypeReg} + if body == "" && name[len(name)-1] == '/' { + hdr = tar.Header{Name: name, Mode: 0o755, Typeflag: tar.TypeDir} + _, err := store.Add(hdr, nil, charge) + require.NoError(t, err) + continue + } + _, err := store.Add(hdr, bytes.NewReader([]byte(body)), charge) + require.NoError(t, err) + } + return store +} + +func indexOver(t *testing.T, maxInMemory int64, entries map[string]string, filters ...PathIndexVisitor) *ArchiveIndex { + t.Helper() + root := t.TempDir() + r, err := NewFromArchiveEntries(root, "", "outer.jar", storeWith(t, maxInMemory, entries), false, filters...) + require.NoError(t, err) + return r +} + +func relPaths(t *testing.T, r *ArchiveIndex, locations []file.Location) []string { + t.Helper() + var out []string + for _, loc := range locations { + out = append(out, "/"+loc.RealPath) + } + sort.Strings(out) + return out +} + +var jarEntriesFixture = map[string]string{ + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "META-INF/maven/com.example/lib/pom.properties": "groupId=com.example\n", + "META-INF/maven/com.example/lib/pom.xml": "", + "LICENSE": "Apache License 2.0", + "BOOT-INF/lib/inner.jar": "PK\x03\x04 not really", + "com/example/Thing.class": "bytecode", +} + +func TestArchiveIndex_globByExtensionUsesTheNameIndex(t *testing.T) { + r := indexOver(t, 1<<20, jarEntriesFixture) + + locations, err := r.FilesByGlob("**/*.jar") + require.NoError(t, err) + assert.Equal(t, []string{"/BOOT-INF/lib/inner.jar"}, relPaths(t, r, locations)) +} + +func TestArchiveIndex_globByExactNameAtAnyDepth(t *testing.T) { + r := indexOver(t, 1<<20, jarEntriesFixture) + + locations, err := r.FilesByGlob("**/pom.properties") + require.NoError(t, err) + assert.Equal(t, []string{"/META-INF/maven/com.example/lib/pom.properties"}, relPaths(t, r, locations)) +} + +func TestArchiveIndex_globFindsRootLevelFiles(t *testing.T) { + // what the tar-backed resolver could not do: `/*` matched nothing there, so a jar carrying its + // license at the root - kafka-clients does - lost it + r := indexOver(t, 1<<20, jarEntriesFixture) + + locations, err := r.FilesByGlob("/*") + require.NoError(t, err) + assert.Equal(t, []string{"/LICENSE"}, relPaths(t, r, locations)) +} + +func TestArchiveIndex_globOneDirectoryDeep(t *testing.T) { + r := indexOver(t, 1<<20, jarEntriesFixture) + + locations, err := r.FilesByGlob("/META-INF/*") + require.NoError(t, err) + assert.Equal(t, []string{"/META-INF/MANIFEST.MF"}, relPaths(t, r, locations), + "one level only: the maven files are deeper") +} + +func TestArchiveIndex_globAcrossSyntheticDirectories(t *testing.T) { + // the archive never names META-INF/ or META-INF/maven/, so the directories a middle segment needs + // have to be synthesized from the paths of the entries that were named + r := indexOver(t, 1<<20, jarEntriesFixture) + + locations, err := r.FilesByGlob("**/maven/*/*/pom.xml") + require.NoError(t, err) + assert.Equal(t, []string{"/META-INF/maven/com.example/lib/pom.xml"}, relPaths(t, r, locations)) +} + +func TestArchiveIndex_pathsAndContents(t *testing.T) { + r := indexOver(t, 1<<20, jarEntriesFixture) + + locations, err := r.FilesByPath("/META-INF/MANIFEST.MF") + require.NoError(t, err) + require.Len(t, locations, 1) + + assert.True(t, r.HasPath("/META-INF/MANIFEST.MF")) + assert.True(t, r.HasPath("META-INF/MANIFEST.MF"), "a path may arrive with or without the leading slash") + assert.False(t, r.HasPath("/nope")) + + reader, err := r.FileContentsByLocation(locations[0]) + require.NoError(t, err) + body, err := io.ReadAll(reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + assert.Equal(t, "Manifest-Version: 1.0\n", string(body)) +} + +func TestArchiveIndex_contentsSurviveTheMoveToDisk(t *testing.T) { + // the index is built over entries, not offsets, so content moving out to the overflow blob must be + // invisible to every lookup + r := indexOver(t, 8, jarEntriesFixture) + + locations, err := r.FilesByGlob("**/*.jar") + require.NoError(t, err) + require.Len(t, locations, 1) + + reader, err := r.FileContentsByLocation(locations[0]) + require.NoError(t, err) + body, err := io.ReadAll(reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + assert.Equal(t, "PK\x03\x04 not really", string(body)) +} + +func TestArchiveIndex_mimeTypesAreSniffedFromContent(t *testing.T) { + r := indexOver(t, 1<<20, map[string]string{ + "notes.txt": "just some text", + "run.sh": "#!/bin/bash\necho hello\n", + }) + + locations, err := r.FilesByMIMEType("text/x-shellscript") + require.NoError(t, err) + assert.Equal(t, []string{"/run.sh"}, relPaths(t, r, locations)) +} + +func TestArchiveIndex_exclusionsPruneWholeSubtrees(t *testing.T) { + // a filter answering SkipDir removes the directory and everything under it, the way it would in a + // directory walk. A tar is a sequence rather than a walk, so this is checked per entry against + // what was pruned. + skipMetaInf := func(_, entryPath string, info os.FileInfo, _ error) error { + if strings.HasSuffix(entryPath, "/META-INF") || strings.HasSuffix(entryPath, "/META-INF/") { + return filepath.SkipDir + } + _ = info + return nil + } + + // the archive names the directory itself, so the filter has something to prune on + entries := map[string]string{} + for k, v := range jarEntriesFixture { + entries[k] = v + } + entries["META-INF/"] = "" + + r := indexOver(t, 1<<20, entries, skipMetaInf) + + locations, err := r.FilesByGlob("**/*") + require.NoError(t, err) + got := relPaths(t, r, locations) + assert.NotContains(t, got, "/META-INF/MANIFEST.MF") + assert.NotContains(t, got, "/META-INF/maven/com.example/lib/pom.xml") + assert.Contains(t, got, "/LICENSE", "everything outside the pruned subtree is still there") +} + +func TestArchiveIndex_allLocationsCoversEveryFile(t *testing.T) { + r := indexOver(t, 1<<20, jarEntriesFixture) + + var got []string + for loc := range r.AllLocations(context.Background()) { + got = append(got, "/"+loc.RealPath) + } + sort.Strings(got) + + assert.Equal(t, []string{ + "/BOOT-INF/lib/inner.jar", + "/LICENSE", + "/META-INF/MANIFEST.MF", + "/META-INF/maven/com.example/lib/pom.properties", + "/META-INF/maven/com.example/lib/pom.xml", + "/com/example/Thing.class", + }, got) +} + +func TestArchiveIndex_recordsCountsWhatTheFilesystemHolds(t *testing.T) { + r := indexOver(t, 1<<20, jarEntriesFixture) + assert.Equal(t, len(jarEntriesFixture), r.Records()) + assert.False(t, r.IndexTruncated()) +} + +func TestArchiveIndex_answersAreOrdered(t *testing.T) { + // every answer has to come back in the same order every time. The archive walk enters archives in + // the order it is handed them and merges what it finds into one shared SBOM, so the order decides + // which locations group into which package - and an index built over a Go map has no order at all. + // + // Measured on trinodb/trino, whose plugin jars are hard links to one another: unordered, one + // libzstd package holding 24 locations came back as three packages holding 6, 9 and 9 of the same + // 24, and the image reported 7508 packages instead of 7506. + entries := map[string]string{} + for _, name := range []string{ + "b/z.jar", "a/y.jar", "c/x.jar", "a/b/c/w.jar", "z.jar", "a.jar", + } { + entries[name] = "PK\x03\x04 not really" + } + r := indexOver(t, 1<<20, entries) + + first, err := r.FilesByGlob("**/*.jar") + require.NoError(t, err) + require.Len(t, first, len(entries)) + + byMIME, err := r.FilesByMIMEType("application/octet-stream") + require.NoError(t, err) + + for range 8 { + again, err := r.FilesByGlob("**/*.jar") + require.NoError(t, err) + assert.Equal(t, first, again, "the same glob must return the same order every time") + + mimeAgain, err := r.FilesByMIMEType("application/octet-stream") + require.NoError(t, err) + assert.Equal(t, byMIME, mimeAgain, "and so must a MIME lookup") + + var all []string + for loc := range r.AllLocations(context.Background()) { + all = append(all, loc.RealPath) + } + assert.True(t, sort.StringsAreSorted(all), "AllLocations must be ordered, not merely repeatable") + } + + var paths []string + for _, loc := range first { + paths = append(paths, loc.RealPath) + } + assert.True(t, sort.StringsAreSorted(paths), "and the order is by path, so it is predictable rather than merely stable") +} + +func TestArchiveIndex_CloseReleasesStore(t *testing.T) { + // forcing content to disk (memory 0) so the store holds an open overflow blob, then Close must + // release it; reads after Close no longer resolve, and a second Close is a no-op. + r := indexOver(t, 0, map[string]string{"a.txt": "hello"}) + + locs, err := r.FilesByPath("a.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + + rc, err := r.FileContentsByLocation(locs[0]) + require.NoError(t, err) + b, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + assert.Equal(t, "hello", string(b)) + + require.NoError(t, r.Close()) + + _, err = r.FileContentsByLocation(locs[0]) + assert.Error(t, err, "the overflow blob is closed, so its content can no longer be read") + + assert.NoError(t, r.Close(), "Close is idempotent") +} diff --git a/syft/internal/fileresolver/archive_tar.go b/syft/internal/fileresolver/archive_tar.go new file mode 100644 index 000000000..e9c0bd2df --- /dev/null +++ b/syft/internal/fileresolver/archive_tar.go @@ -0,0 +1,323 @@ +package fileresolver + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + stereoscopeFile "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/stereoscope/pkg/filetree" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/internal/windows" +) + +var _ file.Resolver = (*ArchiveTar)(nil) + +// ArchiveTar implements path and content access for one archive whose entries were overflowed into a +// single tar. +// +// It is the same FiletreeResolver a directory scan uses, differing only in where the tree comes from +// and how content is opened. That separation already existed: FiletreeResolver holds Tree, Index and +// an Opener, and nativeOSFileOpener - which opens a path on the real filesystem - is one +// implementation of that Opener among several. Reading an entry out of a tar by seek offset is +// another, so no resolver interface changes to accommodate it. +// +// Paths are reported relative to the archive's own root, exactly as they were when the archive was +// expanded into a directory: the tree is rooted at an empty directory standing in for the archive +// root, and the chroot context relativizes against it. Nothing about the storage - the tar, its +// offsets, the scratch directory holding it - is reachable from a Location. +type ArchiveTar struct { + FiletreeResolver + tarPath string + + // records is how many entries of the tar were indexed. + records int + + // truncated reports that indexing stopped before the end of the tar. + truncated bool +} + +// NewFromArchiveTar builds a resolver over the archive entries held in the tar at tarPath, whose +// Locations are reported relative to rootDir and stamped with fileSystemID and archivePath. +// +// rootDir must exist and is expected to be empty: it is the archive's logical root and nothing is +// ever written into it. Every entry the tar holds is indexed: what bounds a crafted archive is the +// disk limit charged as its entries were written, which is the resource the entries actually consume. +// +// pathFilters are applied to each entry the way they are applied to each path of a directory walk, so +// an exclusion pattern reaches inside an archive by exactly the mechanism it did before: the entry it +// matches is absent from the archive's filesystem rather than present and skipped. +func NewFromArchiveTar(rootDir, tarPath, fileSystemID, archivePath string, pathFilters ...PathIndexVisitor) (*ArchiveTar, error) { + chroot, err := NewChrootContextFromCWD(rootDir, "") + if err != nil { + return nil, fmt.Errorf("unable to interpret chroot context for archive root %q: %w", rootDir, err) + } + + r := &ArchiveTar{ + tarPath: tarPath, + FiletreeResolver: FiletreeResolver{ + Chroot: *chroot, + FileSystemID: fileSystemID, + ArchivePath: archivePath, + }, + } + + if err := r.buildIndex(chroot.Root(), pathFilters); err != nil { + return nil, err + } + + return r, nil +} + +// Records is how many entries this archive's filesystem holds. +func (r *ArchiveTar) Records() int { + return r.records +} + +// IndexTruncated reports that indexing stopped before the end of the tar, so the filesystem holds +// only part of what the archive contained. It is not an error: what was indexed is complete and +// usable, there is simply less of it. +func (r *ArchiveTar) IndexTruncated() bool { + return r.truncated +} + +func (r *ArchiveTar) String() string { + return fmt.Sprintf("archive:%s", r.tarPath) +} + +// buildIndex walks the overflow tar once, adding a node to the filetree and a record to the index for +// each entry, and keeping the entry itself so content can later be opened at its offset. +// +// A visitor error is never fatal to the archive. A crafted tar can hold two entries whose names +// disagree about what kind of node a path is, and letting that cost the whole archive would hand an +// attacker a way to hide everything else in it. +func (r *ArchiveTar) buildIndex(root string, pathFilters []PathIndexVisitor) error { + tree := filetree.New() + index := filetree.NewIndex() + builder := filetree.NewBuilder(tree, index) + entries := make(map[stereoscopeFile.ID]stereoscopeFile.TarIndexEntry) + + rootPath := treeRoot(root) + var pruned []string + + add := func(entry stereoscopeFile.TarIndexEntry) { + if ref, ok := r.addEntry(builder, entry, root, rootPath, pathFilters, &pruned); ok { + entries[ref] = entry + r.records++ + } + } + + // One entry behind: an entry is added only once the header after it has been read, so an entry + // whose data the tar stops part way through is never in the filesystem. It matters because a tar + // CAN end mid-entry - that is what the disk limit refusing a chunk of a copy leaves behind - and a + // half-present jar or pom that a cataloger parses anyway is worse than an absent one. The lag is + // what makes "the entries before the cut" mean the entries whose content is all there. + var pending *stereoscopeFile.TarIndexEntry + + visitor := func(entry stereoscopeFile.TarIndexEntry) error { + if pending != nil { + add(*pending) + pending = nil + } + pending = &entry + return nil + } + + if _, err := stereoscopeFile.NewTarIndex(r.tarPath, visitor); err != nil { + if r.records == 0 { + return fmt.Errorf("unable to index archive contents %q: %w", r.tarPath, err) + } + // the tar ends part way through an entry. Everything complete before that point is intact, so + // it is kept and reported as a truncation rather than discarding an archive that was cataloged + // perfectly well up to the cut. + log.WithFields("archive", r.tarPath, "error", err). + Debug("archive contents end part way through an entry; cataloging what was read") + r.truncated = true + } else if pending != nil { + // the tar ended cleanly, so the last entry read is whole + add(*pending) + } + + r.Tree = tree + r.Index = index + r.SearchContext = filetree.NewSearchContext(tree, index) + r.Opener = tarEntryOpener(entries) + + return nil +} + +// addEntry puts one tar entry into the filetree and the index, reporting the reference it was given, +// or false when the entry is not part of this archive's filesystem: a name that names nothing, a path +// under a directory a filter pruned, a path a filter excluded, or a path a crafted archive has +// already claimed as a different kind of node. +func (r *ArchiveTar) addEntry(builder *filetree.Builder, entry stereoscopeFile.TarIndexEntry, root, rootPath string, pathFilters []PathIndexVisitor, pruned *[]string) (stereoscopeFile.ID, bool) { + hdr := entry.ToTarFileEntry().Header + + name, ok := sanitizeEntryName(hdr.Name) + if !ok { + return 0, false + } + entryPath := path.Join(rootPath, name) + + if isPruned(entryPath, *pruned) { + return 0, false + } + + switch runPathFilters(pathFilters, root, entryPath, hdr.FileInfo()) { + case skipEntry: + return 0, false + case skipTree: + *pruned = append(*pruned, entryPath) + return 0, false + } + + ref, err := builder.Add(entryMetadata(entry, hdr, rootPath, entryPath)) + if err != nil { + log.WithFields("entry", hdr.Name, "error", err). + Trace("unable to add archive entry to its filesystem, skipping it") + return 0, false + } + return ref.ID(), true +} + +// entryMetadata describes one tar entry as a file in the archive's filesystem. +// +// The path and the link destination are both rewritten into the archive's own root. A link is +// resolved in archive-relative space first, where path.Join clamps a climb at the root, so a target +// naming the host filesystem - "/etc/passwd", or a chain of ".." - lands somewhere inside this +// archive or nowhere at all. That is stronger than the check it replaces: the previous code refused +// to create such a link, and here there is nothing to create in the first place. +func entryMetadata(entry stereoscopeFile.TarIndexEntry, hdr tar.Header, rootPath, entryPath string) file.Metadata { + contents := entry.Open() + metadata := stereoscopeFile.NewMetadata(hdr, contents) + if err := contents.Close(); err != nil { + log.WithFields("entry", hdr.Name, "error", err).Trace("unable to close archive entry after reading its type") + } + + metadata.Path = entryPath + + if hdr.Linkname != "" { + relative := path.Clean(hdr.Linkname) + if !path.IsAbs(relative) { + relative = path.Join("/", path.Dir(strings.TrimPrefix(entryPath, rootPath)), hdr.Linkname) + } + metadata.LinkDestination = path.Join(rootPath, relative) + } + + return metadata +} + +// tarEntryOpener opens an entry's content by seeking to its offset in the tar. +// +// The reader is bounded to the entry, and it is Read, Seek and ReadAt - so reading the last entry of +// a large archive costs a seek rather than a pass over everything before it, and a nested archive can +// be handed straight to an archive format that needs random access instead of being copied out first. +func tarEntryOpener(entries map[stereoscopeFile.ID]stereoscopeFile.TarIndexEntry) func(stereoscopeFile.Reference) (io.ReadCloser, error) { + return func(ref stereoscopeFile.Reference) (io.ReadCloser, error) { + entry, ok := entries[ref.ID()] + if !ok { + return nil, fmt.Errorf("no archive entry for path=%q", ref.RealPath) + } + contents := entry.Open() + seekable, ok := contents.(entryReadCloser) + if !ok { + // still readable, just not readable at an offset: whatever wants random access will copy + // it out first, which is what happened to every nested archive before this + return contents, nil + } + return overflowEntryReader{seekable}, nil + } +} + +// overflowEntryReader is a reader over one entry of an archive's overflow tar. +// +// The marker method is what lets the archive cataloger recognize a nested archive as content it can +// read where it lies - already random access, already charged to the archive holding it - without +// either package importing the other. +type overflowEntryReader struct { + entryReadCloser +} + +// OverflowArchiveEntry marks this reader as content that needs no copy to be read at random. +func (overflowEntryReader) OverflowArchiveEntry() {} + +// entryReadCloser is what a tar index entry hands back: a read closer that also seeks and reads at an +// offset, because it is a bounded view onto the tar file. +type entryReadCloser interface { + io.ReadCloser + io.ReaderAt + io.Seeker +} + +type filterOutcome int + +const ( + keepEntry filterOutcome = iota + skipEntry + skipTree +) + +// runPathFilters asks each filter about one entry, translating the answers a directory walk uses - +// skip this path, skip this whole directory - into what an unordered pass over tar entries can act on. +func runPathFilters(filters []PathIndexVisitor, root, entryPath string, info os.FileInfo) filterOutcome { + for _, filter := range filters { + if filter == nil { + continue + } + switch err := filter(root, entryPath, info, nil); err { + case nil: + continue + case filepath.SkipDir, filepath.SkipAll: + return skipTree + default: + return skipEntry + } + } + return keepEntry +} + +// isPruned reports whether an entry sits inside a directory a filter already pruned. A tar is a +// sequence, not a walk, so a pruned directory cannot simply end a subtree the way returning SkipDir +// from a walk does: each later entry is checked against what was pruned. +func isPruned(entryPath string, pruned []string) bool { + for _, dir := range pruned { + if entryPath == dir || strings.HasPrefix(entryPath, dir+"/") { + return true + } + } + return false +} + +// sanitizeEntryName turns the name an archive gave an entry into a path inside that archive, and +// reports false for a name that names nothing. +// +// Nothing is written through an entry's name any more - it is a field in a tar header - so this is +// not about arbitrary writes. It is about the path the SBOM reports: a name like "../../etc/passwd" +// left alone would put a node above the archive's own root, which renders as a path outside the +// archive and reads as a claim about the host filesystem. Cleaning it against the root clamps the +// climb, so the worst an attacker-controlled name produces is a wrong path inside the right archive. +func sanitizeEntryName(name string) (string, bool) { + if strings.ContainsRune(name, 0) { + return "", false + } + cleaned := strings.TrimPrefix(path.Clean("/"+name), "/") + if cleaned == "" || cleaned == "." { + // the archive's own root, which is not an entry in it + return "", false + } + return cleaned, true +} + +// treeRoot is the archive root as a filetree path: posix, absolute, and on Windows with the volume +// encoded the way every other resolver encodes it. +func treeRoot(root string) string { + if windows.HostRunningOnWindows() { + return windows.ToPosix(root) + } + return path.Clean(filepath.ToSlash(root)) +} diff --git a/syft/internal/fileresolver/archive_tar_test.go b/syft/internal/fileresolver/archive_tar_test.go new file mode 100644 index 000000000..69b07babc --- /dev/null +++ b/syft/internal/fileresolver/archive_tar_test.go @@ -0,0 +1,287 @@ +package fileresolver + +import ( + "archive/tar" + "context" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" +) + +// tarEntry describes one entry to write into a test tar: a symlink when link is set, a directory when +// the name ends in "/", a regular file otherwise. +type tarEntry struct { + name string + body string + link string +} + +// writeTestTar builds a plain tar on disk, which is what an archive's entries are overflowed into. +func writeTestTar(t *testing.T, dir string, entries ...tarEntry) string { + t.Helper() + path := filepath.Join(dir, "contents.tar") + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + tw := tar.NewWriter(f) + for _, e := range entries { + hdr := &tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.body))} + switch { + case e.link != "": + hdr.Typeflag = tar.TypeSymlink + hdr.Linkname = e.link + hdr.Size = 0 + case strings.HasSuffix(e.name, "/"): + hdr.Typeflag = tar.TypeDir + default: + hdr.Typeflag = tar.TypeReg + } + require.NoError(t, tw.WriteHeader(hdr)) + if hdr.Typeflag == tar.TypeReg { + _, err := tw.Write([]byte(e.body)) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + return path +} + +// archiveRoot is the empty directory an archive's entries are reported relative to. Nothing is +// written into it; it exists so a path in the SBOM is relative to its own archive. +func archiveRoot(t *testing.T, dir string) string { + t.Helper() + root := filepath.Join(dir, "contents") + require.NoError(t, os.MkdirAll(root, 0o755)) + + // resolved, because that is the form the resolver reports paths in: it normalizes its own root + // with EvalSymlinks, and a test comparing against the unresolved form would be comparing against + // a path the resolver never produces - on macOS t.TempDir sits under /var, a symlink to + // /private/var, so every prefix check here would fail on that alone + resolved, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + return resolved +} + +// locationOf finds the location the resolver reports for one path, going through AllLocations rather +// than FilesByPath: a link whose target is not in the archive resolves to nothing, and a dead link is +// exactly what some of these cases are about. +func locationOf(t *testing.T, r file.Resolver, path string) file.Location { + t.Helper() + // the channel is drained to the end rather than broken out of early: AllLocations runs a goroutine + // that fills it, and abandoning it leaks that goroutine into whatever else the package tests check + var found *file.Location + for loc := range r.AllLocations(context.Background()) { + if loc.RealPath == path { + match := loc + found = &match + } + } + if found == nil { + t.Fatalf("no location reported for %q", path) + } + return *found +} + +// resolvedPaths is every path the resolver reports, sorted. +func resolvedPaths(t *testing.T, r file.Resolver) []string { + t.Helper() + var out []string + for loc := range r.AllLocations(context.Background()) { + out = append(out, loc.RealPath) + } + sort.Strings(out) + return out +} + +func TestArchiveTar_pathsAreRelativeToTheArchiveRoot(t *testing.T) { + // the storage is an implementation detail of the scan: nothing about the tar, the offsets or the + // scratch directory holding them may be reachable from a Location, so an SBOM produced from a + // overflow archive says the same thing it said when the archive was expanded into a directory + dir := t.TempDir() + tarPath := writeTestTar(t, dir, + tarEntry{name: "nested/marker.txt", body: "hello"}, + tarEntry{name: "top.txt", body: "top"}, + ) + + r, err := NewFromArchiveTar(archiveRoot(t, dir), tarPath, "", "outer.zip") + require.NoError(t, err) + + assert.Equal(t, []string{"nested/marker.txt", "top.txt"}, resolvedPaths(t, r)) + assert.Equal(t, 2, r.Records()) + assert.False(t, r.IndexTruncated()) + + for _, path := range resolvedPaths(t, r) { + assert.NotContains(t, path, dir, "no part of the scratch directory may reach a path") + assert.NotContains(t, path, "contents.tar", "the storage file must not name itself in a path") + assert.False(t, filepath.IsAbs(path), "a path inside an archive is relative to that archive") + } + + // and the filesystem id is stamped on every location, which is what keeps identically-named files + // in different archives apart + locs, err := r.FilesByPath("nested/marker.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + assert.Equal(t, "outer.zip", locs[0].ArchivePath) + + contents, err := r.FileContentsByLocation(locs[0]) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, contents.Close()) }) + body, err := io.ReadAll(contents) + require.NoError(t, err) + assert.Equal(t, "hello", string(body)) +} + +func TestArchiveTar_entryNamesAreSanitized(t *testing.T) { + // nothing is written through an entry's name any more - it is a field in a tar header - so this is + // not about arbitrary writes. It is about the path the SBOM reports: a name climbing out of the + // archive would put a node above the archive's own root, which renders as a path outside the + // archive and reads as a claim about the host filesystem. + dir := t.TempDir() + tarPath := writeTestTar(t, dir, + tarEntry{name: "../../etc/passwd", body: "climbing"}, + tarEntry{name: "/etc/hosts", body: "absolute"}, + tarEntry{name: "a/./b/../c.txt", body: "noisy"}, + tarEntry{name: "./", body: ""}, + tarEntry{name: "ok.txt", body: "plain"}, + ) + + r, err := NewFromArchiveTar(archiveRoot(t, dir), tarPath, "", "outer.zip") + require.NoError(t, err) + + // every name lands inside the archive, and the archive's own root is not an entry in it + assert.Equal(t, []string{"a/c.txt", "etc/hosts", "etc/passwd", "ok.txt"}, resolvedPaths(t, r)) + + // the two hostile names collapse onto the same reported path, which is the residual: a misleading + // path inside the right archive rather than an arbitrary write or a claim about the host + byPath, err := r.FilesByPath("etc/passwd") + require.NoError(t, err) + require.Len(t, byPath, 1) + + contents, err := r.FileContentsByLocation(byPath[0]) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, contents.Close()) }) + body, err := io.ReadAll(contents) + require.NoError(t, err) + assert.Equal(t, "climbing", string(body), "the entry is still readable under its sanitized name") +} + +func TestArchiveTar_linkTargetsAreResolvedInsideTheArchive(t *testing.T) { + // a link is resolved in archive-relative space, where a climb clamps at the root, so a target + // naming the host filesystem lands somewhere inside this archive or nowhere at all. That is + // stronger than the check it replaces: extraction refused to CREATE such a link, and here there is + // nothing to create. + dir := t.TempDir() + root := archiveRoot(t, dir) + tarPath := writeTestTar(t, dir, + tarEntry{name: "lib/real.txt", body: "real content"}, + tarEntry{name: "bin/link.txt", link: "../lib/real.txt"}, + tarEntry{name: "escape.txt", link: "../../../../etc/passwd"}, + tarEntry{name: "absolute.txt", link: "/etc/shadow"}, + ) + + r, err := NewFromArchiveTar(root, tarPath, "", "outer.zip") + require.NoError(t, err) + + t.Run("a legitimate relative link still resolves", func(t *testing.T) { + locs, err := r.FilesByPath("bin/link.txt") + require.NoError(t, err) + require.Len(t, locs, 1) + + contents, err := r.FileContentsByLocation(locs[0]) + require.NoError(t, err) + defer func() { require.NoError(t, contents.Close()) }() + body, err := io.ReadAll(contents) + require.NoError(t, err) + assert.Equal(t, "real content", string(body), "the link must resolve to its target's content") + }) + + for _, name := range []string{"escape.txt", "absolute.txt"} { + t.Run("a link out of the archive names nothing on the host: "+name, func(t *testing.T) { + // it is still a file of the archive, and it resolves to nothing rather than to the host: + // FilesByPath follows the link and finds no target + followed, err := r.FilesByPath(name) + require.NoError(t, err) + assert.Empty(t, followed, "the link must resolve to nothing, not to a path on the host") + + metadata, err := r.FileMetadataByLocation(locationOf(t, r, name)) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(metadata.LinkDestination, root+"/"), + "the target must be re-rooted inside the archive, got %q", metadata.LinkDestination) + // the climb is clamped rather than followed: what is left of the target after the archive's + // own root is a path inside the archive, not a path on the host + inside := strings.TrimPrefix(metadata.LinkDestination, root+"/") + assert.NotContains(t, inside, "..", "a climb out of the archive must be clamped, got %q", inside) + }) + } +} + +func TestArchiveTar_aTarEndingPartWayThroughAnEntryIsUsable(t *testing.T) { + // what the disk limit stopping a copy leaves behind. Everything read before that point is intact, + // so it is kept and the tail is reported as a truncation - discarding an archive that was + // cataloged perfectly well up to the cut would lose more than it protects. + dir := t.TempDir() + tarPath := writeTestTar(t, dir, + tarEntry{name: "first.txt", body: "first"}, + tarEntry{name: "second.txt", body: strings.Repeat("s", 2048)}, + ) + + full, err := os.ReadFile(tarPath) + require.NoError(t, err) + cut := filepath.Join(dir, "cut.tar") + // through the first entry and into the second's data + require.NoError(t, os.WriteFile(cut, full[:3*512+16], 0o600)) + + r, err := NewFromArchiveTar(archiveRoot(t, dir), cut, "", "outer.tar") + require.NoError(t, err) + assert.Equal(t, []string{"first.txt"}, resolvedPaths(t, r)) + assert.True(t, r.IndexTruncated()) + + t.Run("a tar that holds nothing readable is an error, not an empty filesystem", func(t *testing.T) { + garbage := filepath.Join(dir, "garbage.tar") + require.NoError(t, os.WriteFile(garbage, []byte("this is not a tar at all"), 0o600)) + + _, err := NewFromArchiveTar(archiveRoot(t, t.TempDir()), garbage, "", "outer.tar") + require.Error(t, err) + }) +} + +func TestArchiveTar_pathFiltersDropEntriesAndPruneDirectories(t *testing.T) { + // an exclusion pattern reaches inside an archive by the same mechanism as before - a visitor per + // entry as the filesystem is indexed - so an excluded entry is ABSENT from the archive's filesystem + // rather than present and skipped. A tar is a sequence rather than a walk, so a pruned directory + // has to be remembered and later entries checked against it. + dir := t.TempDir() + root := archiveRoot(t, dir) + tarPath := writeTestTar(t, dir, + tarEntry{name: "keep.txt", body: "keep"}, + tarEntry{name: "drop.txt", body: "drop"}, + tarEntry{name: "vendor/", body: ""}, + tarEntry{name: "vendor/deep/inner.txt", body: "inner"}, + ) + + filter := func(_, path string, info os.FileInfo, _ error) error { + switch { + case strings.HasSuffix(path, "/drop.txt"): + return ErrSkipPath + case info != nil && info.IsDir() && strings.HasSuffix(path, "/vendor"): + return filepath.SkipDir + } + return nil + } + + r, err := NewFromArchiveTar(root, tarPath, "", "outer.zip", filter) + require.NoError(t, err) + + assert.Equal(t, []string{"keep.txt"}, resolvedPaths(t, r), + "the dropped file and everything under the pruned directory must be absent") + assert.Equal(t, 1, r.Records(), "an excluded entry is not a record either") +} diff --git a/syft/internal/fileresolver/directory.go b/syft/internal/fileresolver/directory.go index 750cfffd9..2f079041b 100644 --- a/syft/internal/fileresolver/directory.go +++ b/syft/internal/fileresolver/directory.go @@ -20,7 +20,15 @@ type Directory struct { } func NewFromDirectory(root, base string, pathFilters ...PathIndexVisitor) (*Directory, error) { - resolver, err := newFromDirectoryWithoutIndex(root, base, pathFilters...) + return NewFromDirectoryWithFS(root, base, "", pathFilters...) +} + +// NewFromDirectoryWithFS builds an indexed directory resolver whose Locations are stamped with the +// given fileSystemID. Pass a non-empty id when the directory is an archive that has been extracted +// to a temp dir and is being treated as its own standalone filesystem (see the archive cataloger); +// pass "" for an ordinary directory scan. +func NewFromDirectoryWithFS(root, base, fileSystemID string, pathFilters ...PathIndexVisitor) (*Directory, error) { + resolver, err := newFromDirectoryWithoutIndex(root, base, fileSystemID, pathFilters...) if err != nil { return nil, err } @@ -28,7 +36,7 @@ func NewFromDirectory(root, base string, pathFilters ...PathIndexVisitor) (*Dire return resolver, resolver.buildIndex() } -func newFromDirectoryWithoutIndex(root, base string, pathFilters ...PathIndexVisitor) (*Directory, error) { +func newFromDirectoryWithoutIndex(root, base, fileSystemID string, pathFilters ...PathIndexVisitor) (*Directory, error) { chroot, err := NewChrootContextFromCWD(root, base) if err != nil { return nil, fmt.Errorf("unable to interpret chroot context: %w", err) @@ -40,10 +48,11 @@ func newFromDirectoryWithoutIndex(root, base string, pathFilters ...PathIndexVis return &Directory{ path: cleanRoot, FiletreeResolver: FiletreeResolver{ - Chroot: *chroot, - Tree: filetree.New(), - Index: filetree.NewIndex(), - Opener: nativeOSFileOpener, + Chroot: *chroot, + Tree: filetree.New(), + Index: filetree.NewIndex(), + Opener: nativeOSFileOpener, + FileSystemID: fileSystemID, }, indexer: newDirectoryIndexer(cleanRoot, cleanBase, pathFilters...), }, nil diff --git a/syft/internal/fileresolver/directory_fsid_test.go b/syft/internal/fileresolver/directory_fsid_test.go new file mode 100644 index 000000000..8a5e24535 --- /dev/null +++ b/syft/internal/fileresolver/directory_fsid_test.go @@ -0,0 +1,161 @@ +//go:build !windows + +package fileresolver + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" +) + +// Test_DirectoryResolver_stampsFileSystemID verifies that a resolver built with a non-empty +// fileSystemID stamps it onto the Coordinates of every Location returned across all access paths. +// This is the identity mechanism that keeps identically-named files in different extracted +// archives from colliding in the coordinate-keyed SBOM tables. +func Test_DirectoryResolver_stampsFileSystemID(t *testing.T) { + const fsID = "some/path/to.zip/some/nested/path.tar.gz" + + resolver, err := NewFromDirectoryWithFS("./testdata/image-symlinks", "", fsID) + require.NoError(t, err) + + t.Run("FilesByPath", func(t *testing.T) { + locations, err := resolver.FilesByPath("/file-1.txt") + require.NoError(t, err) + require.NotEmpty(t, locations) + for _, l := range locations { + assert.Equal(t, fsID, l.FileSystemID, "FilesByPath did not stamp FileSystemID") + } + }) + + t.Run("FilesByGlob", func(t *testing.T) { + locations, err := resolver.FilesByGlob("**/*.txt") + require.NoError(t, err) + require.NotEmpty(t, locations) + for _, l := range locations { + assert.Equal(t, fsID, l.FileSystemID, "FilesByGlob did not stamp FileSystemID") + } + }) + + t.Run("FilesByMIMEType", func(t *testing.T) { + locations, err := resolver.FilesByMIMEType("text/plain") + require.NoError(t, err) + require.NotEmpty(t, locations) + for _, l := range locations { + assert.Equal(t, fsID, l.FileSystemID, "FilesByMIMEType did not stamp FileSystemID") + } + }) + + t.Run("AllLocations", func(t *testing.T) { + var count int + for l := range resolver.AllLocations(context.Background()) { + assert.Equal(t, fsID, l.FileSystemID, "AllLocations did not stamp FileSystemID") + count++ + } + require.NotZero(t, count) + }) +} + +// Test_DirectoryResolver_emptyFileSystemID confirms that an ordinary directory scan (via the +// unchanged NewFromDirectory) continues to produce empty FileSystemIDs. +func Test_DirectoryResolver_emptyFileSystemID(t *testing.T) { + resolver, err := NewFromDirectory("./testdata/image-symlinks", "") + require.NoError(t, err) + + locations, err := resolver.FilesByGlob("**/*.txt") + require.NoError(t, err) + require.NotEmpty(t, locations) + for _, l := range locations { + assert.Empty(t, l.FileSystemID, "ordinary directory scan should not stamp a FileSystemID") + } +} + +// Test_DirectoryResolver_archiveRootIsNotALocation covers +// syft/archive-content-identity#nested-paths-are-archive-relative, scenario "enumerating all +// locations yields nothing outside the archive tree". ToChrootPath trims the prefix root + "/", +// which cannot match the root path itself, so AllLocations used to emit the resolver's own root +// with an absolute path. For an extracted archive that path is a temp directory created fresh per +// run: it reached FileMetadata, was copied into the shared SBOM, and gained a CONTAINS edge, so +// identical input produced different SBOMs. +func Test_DirectoryResolver_archiveRootIsNotALocation(t *testing.T) { + // a directory standing in for an extracted archive, with a nested file so there is something + // legitimate to enumerate alongside the root + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "lib", "dep.txt"), []byte("dep"), 0o644)) + + t.Run("an archive filesystem does not report its own root", func(t *testing.T) { + resolver, err := NewFromDirectoryWithFS(root, "", "app/bundle.zip") + require.NoError(t, err) + + var paths []string + for l := range resolver.AllLocations(context.Background()) { + assert.False(t, filepath.IsAbs(l.RealPath), + "no location may carry an absolute path: %q", l.RealPath) + assert.NotContains(t, l.RealPath, root, + "the extraction directory must not appear in any location") + paths = append(paths, l.RealPath) + } + assert.Contains(t, paths, "lib/dep.txt", "the archive's own contents must still be enumerated") + }) + + t.Run("a directory scan still reports its root", func(t *testing.T) { + // the suppression is gated on a non-empty FileSystemID precisely so that released behavior + // for directory and image scans is untouched: there the root is the path the user asked for + resolver, err := NewFromDirectory(root, "") + require.NoError(t, err) + + var sawAbsolute bool + for l := range resolver.AllLocations(context.Background()) { + if filepath.IsAbs(l.RealPath) { + sawAbsolute = true + } + } + assert.True(t, sawAbsolute, "a plain directory scan reports its root as it always has") + }) +} + +// Test_DirectoryResolver_archivePathsAreRelative covers the other half of +// #nested-paths-are-archive-relative: the path, glob and MIME code paths report archive-relative +// paths. Nothing asserted this - the FileSystemID test above checks the identifier and never the +// path. +func Test_DirectoryResolver_archivePathsAreRelative(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "lib", "dep.txt"), []byte("dep"), 0o644)) + + resolver, err := NewFromDirectoryWithFS(root, "", "app/bundle.zip") + require.NoError(t, err) + + assertRelative := func(t *testing.T, locations []file.Location) { + t.Helper() + require.NotEmpty(t, locations) + for _, l := range locations { + assert.Equal(t, "lib/dep.txt", l.RealPath) + assert.NotContains(t, l.AccessPath, root) + } + } + + t.Run("FilesByPath", func(t *testing.T) { + locations, err := resolver.FilesByPath("/lib/dep.txt") + require.NoError(t, err) + assertRelative(t, locations) + }) + + t.Run("FilesByGlob", func(t *testing.T) { + locations, err := resolver.FilesByGlob("**/*.txt") + require.NoError(t, err) + assertRelative(t, locations) + }) + + t.Run("FilesByMIMEType", func(t *testing.T) { + locations, err := resolver.FilesByMIMEType("text/plain") + require.NoError(t, err) + assertRelative(t, locations) + }) +} diff --git a/syft/internal/fileresolver/filetree_resolver.go b/syft/internal/fileresolver/filetree_resolver.go index 68b3b8395..2a688314e 100644 --- a/syft/internal/fileresolver/filetree_resolver.go +++ b/syft/internal/fileresolver/filetree_resolver.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "path/filepath" stereoscopeFile "github.com/anchore/stereoscope/pkg/file" "github.com/anchore/stereoscope/pkg/filetree" @@ -21,6 +22,30 @@ type FiletreeResolver struct { Index filetree.IndexReader SearchContext filetree.Searcher Opener func(stereoscopeFile.Reference) (io.ReadCloser, error) + + // FileSystemID, when non-empty, is stamped onto the Coordinates of every Location this + // resolver returns. For container images this is a layer digest; for an extracted archive + // treated as its own standalone filesystem it is the FileSystemID of the archive file's own + // filesystem (inherited unchanged down the nesting chain); for a plain root directory scan it + // is empty. + FileSystemID string + + // ArchivePath, when non-empty, is stamped onto the Coordinates of every Location this resolver + // returns. For an archive extracted and indexed as its own standalone filesystem it is the + // colon-delimited chain of archive paths from the scan root to that archive (e.g. + // "app.war:WEB-INF/lib/dep.jar"); for a plain root directory scan it is empty. This keeps + // identically-named files in different archives from colliding in the coordinate-keyed SBOM + // tables. + ArchivePath string +} + +// newVirtualLocation builds a directory location with a distinct access path, stamped with this +// resolver's FileSystemID and ArchivePath. +func (r FiletreeResolver) newVirtualLocation(responsePath, responseAccessPath string, ref stereoscopeFile.Reference) file.Location { + loc := file.NewVirtualLocationFromDirectory(responsePath, responseAccessPath, ref) + loc.FileSystemID = r.FileSystemID + loc.ArchivePath = r.ArchivePath + return loc } func nativeOSFileOpener(ref stereoscopeFile.Reference) (io.ReadCloser, error) { @@ -91,7 +116,7 @@ func (r FiletreeResolver) FilesByPath(userPaths ...string) ([]file.Location, err if ref.HasReference() { references = append(references, - file.NewVirtualLocationFromDirectory( + r.newVirtualLocation( r.responsePath(string(ref.RealPath)), // the actual path relative to the resolver root r.responsePath(userStrPath), // the path used to access this file, relative to the resolver root *ref.Reference, @@ -135,7 +160,7 @@ func (r FiletreeResolver) FilesByGlob(patterns ...string) ([]file.Location, erro continue } - loc := file.NewVirtualLocationFromDirectory( + loc := r.newVirtualLocation( r.responsePath(string(refVia.RealPath)), // the actual path relative to the resolver root r.responsePath(string(refVia.RequestPath)), // the path used to access this file, relative to the resolver root *refVia.Reference, @@ -187,10 +212,24 @@ func (r *FiletreeResolver) AllLocations(ctx context.Context) <-chan file.Locatio go func() { defer close(results) for _, ref := range r.Tree.AllFiles(stereoscopeFile.AllTypes()...) { + responsePath := r.responsePath(string(ref.RealPath)) + if r.FileSystemID != "" && filepath.IsAbs(responsePath) { + // ToChrootPath relativizes by trimming the prefix root + "/", which cannot match + // the root path itself, so the resolver's own root arrives here still absolute. + // For a directory scan that is the path the user asked for and is left alone. For a + // filesystem this capability invented - an archive extracted into a directory + // created fresh per run - it is a path that must not reach a coordinate: it would + // make two scans of identical input produce different output, and it names a + // directory that no longer exists by the time the scan finishes. Gated on + // FileSystemID so directory and image scans behave exactly as before. + continue + } + loc := file.NewLocationFromDirectory(responsePath, r.FileSystemID, ref) + loc.ArchivePath = r.ArchivePath select { case <-ctx.Done(): return - case results <- file.NewLocationFromDirectory(r.responsePath(string(ref.RealPath)), "", ref): + case results <- loc: continue } } @@ -222,7 +261,7 @@ func (r *FiletreeResolver) FilesByMIMEType(types ...string) ([]file.Location, er if uniqueFileIDs.Contains(*refVia.Reference) { continue } - location := file.NewVirtualLocationFromDirectory( + location := r.newVirtualLocation( r.responsePath(string(refVia.RealPath)), r.responsePath(string(refVia.RequestPath)), *refVia.Reference, diff --git a/syft/pkg/cataloger/internal/cpegenerate/dictionary/data/cpe-index.json b/syft/pkg/cataloger/internal/cpegenerate/dictionary/data/cpe-index.json index 22f6e3ef1..8e09644d1 100644 --- a/syft/pkg/cataloger/internal/cpegenerate/dictionary/data/cpe-index.json +++ b/syft/pkg/cataloger/internal/cpegenerate/dictionary/data/cpe-index.json @@ -7,6 +7,9 @@ "chainguard.dev/apko": [ "cpe:2.3:a:chainguard:apko:*:*:*:*:*:go:*:*" ], + "github.com/Azure/go-ntlmssp": [ + "cpe:2.3:a:microsoft:go-ntlmssp:*:*:*:*:*:go:*:*" + ], "github.com/ClickHouse/ch-go": [ "cpe:2.3:a:clickhouse:ch:*:*:*:*:*:go:*:*" ], @@ -37,6 +40,12 @@ "github.com/charmbracelet/soft-serve": [ "cpe:2.3:a:charm:soft_serve:*:*:*:*:*:go:*:*" ], + "github.com/cli/go-gh": [ + "cpe:2.3:a:cli:go-gh:*:*:*:*:*:go:*:*" + ], + "github.com/cloudflare/circl": [ + "cpe:2.3:a:cloudflare:circl:*:*:*:*:*:go:*:*" + ], "github.com/containers/podman": [ "cpe:2.3:a:podman_project:podman:*:*:*:*:*:*:*:*" ], @@ -61,9 +70,18 @@ "github.com/free5gc/amf": [ "cpe:2.3:a:free5gc:amf:*:*:*:*:*:go:*:*" ], + "github.com/free5gc/bsf": [ + "cpe:2.3:a:free5gc:bsf:*:*:*:*:*:go:*:*" + ], "github.com/free5gc/go-upf": [ "cpe:2.3:a:free5gc:go-upf:*:*:*:*:*:go:*:*" ], + "github.com/free5gc/nef": [ + "cpe:2.3:a:free5gc:nef:*:*:*:*:*:go:*:*" + ], + "github.com/free5gc/nrf": [ + "cpe:2.3:a:free5gc:nrf:*:*:*:*:*:go:*:*" + ], "github.com/free5gc/smf": [ "cpe:2.3:a:free5gc:smf:*:*:*:*:*:go:*:*" ], @@ -97,6 +115,9 @@ "github.com/gookit/goutil": [ "cpe:2.3:a:go_util_project:go_util:*:*:*:*:*:go:*:*" ], + "github.com/gopacket/gopacket": [ + "cpe:2.3:a:gopacket:gopacket:*:*:*:*:*:go:*:*" + ], "github.com/hamba/avro/v2": [ "cpe:2.3:a:avro_project:avro:*:*:*:*:*:go:*:*" ], @@ -106,6 +127,12 @@ "github.com/hashicorp/go-slug": [ "cpe:2.3:a:hashicorp:go-slug:*:*:*:*:*:*:*:*" ], + "github.com/jackc/pgx": [ + "cpe:2.3:a:jackc:pgx:*:*:*:*:*:go:*:*" + ], + "github.com/jackc/pgx/v5": [ + "cpe:2.3:a:jackc:pgx:*:*:*:*:*:go:*:*" + ], "github.com/jumpserver/koko/pkg/koko": [ "cpe:2.3:a:fit2cloud:koko:*:*:*:*:*:go:*:*" ], @@ -145,6 +172,12 @@ "github.com/ntbosscher/gobase": [ "cpe:2.3:a:gobase_project:gobase:*:*:*:*:*:go:*:*" ], + "github.com/open-telemetry/opentelemetry-collector-contrib": [ + "cpe:2.3:a:opentelemetry:opentelemetry_collector_contrib:*:*:*:*:*:go:*:*" + ], + "github.com/pocketbase/pocketbase": [ + "cpe:2.3:a:pocketbase:pocketbase:*:*:*:*:*:go:*:*" + ], "github.com/proglottis/gpgme": [ "cpe:2.3:a:gpgme_project:gpgme:*:*:*:*:*:go:*:*" ], @@ -163,6 +196,9 @@ "github.com/satori/go.uuid": [ "cpe:2.3:a:satori:uuid:*:*:*:*:*:go:*:*" ], + "github.com/shamaton/msgpack/v3": [ + "cpe:2.3:a:shamaton:msgpack:*:*:*:*:*:go:*:*" + ], "github.com/sigstore/gitsign": [ "cpe:2.3:a:sigstore:gitsign:*:*:*:*:*:go:*:*" ], @@ -193,14 +229,24 @@ "github.com/whilp/git-urls": [ "cpe:2.3:a:git-urls_project:git-urls:*:*:*:*:*:go:*:*" ], + "github.com/xddxdd/bird-lg-go": [ + "cpe:2.3:a:xddxdd:bird-lg-go:*:*:*:*:*:go:*:*" + ], + "github.com/xuri/excelize/v2": [ + "cpe:2.3:a:excelize:excelize:*:*:*:*:*:go:*:*" + ], "github.com/yuin/goldmark": [ "cpe:2.3:a:yuin:goldmark:*:*:*:*:*:go:*:*" ], "go.opentelemetry.io/otel": [ "cpe:2.3:a:opentelemetry:opentelemetry:*:*:*:*:*:go:*:*" ], + "go.opentelemetry.io/otel/schema": [ + "cpe:2.3:a:opentelemetry:telemetry_schema_files:*:*:*:*:*:go:*:*" + ], "golang.org/x/crypto": [ - "cpe:2.3:a:go:ssh:*:*:*:*:*:go:*:*" + "cpe:2.3:a:go:ssh:*:*:*:*:*:go:*:*", + "cpe:2.3:a:golang:crypto:*:*:*:*:*:go:*:*" ], "golang.org/x/crypto/ssh": [ "cpe:2.3:a:golang:package_ssh:*:*:*:*:*:*:*:*", @@ -217,6 +263,7 @@ "cpe:2.3:a:golang:tiff:*:*:*:*:*:go:*:*" ], "golang.org/x/net": [ + "cpe:2.3:a:golang:net:*:*:*:*:*:go:*:*", "cpe:2.3:a:golang:networking:*:*:*:*:*:go:*:*" ], "golang.org/x/net/html": [ @@ -261,6 +308,9 @@ "Parameterized-Remote-Trigger": [ "cpe:2.3:a:jenkins:parameterized_remote_trigger:*:*:*:*:*:jenkins:*:*" ], + "PrioritySorter": [ + "cpe:2.3:a:jenkins:priority_sorter:*:*:*:*:*:jenkins:*:*" + ], "TestComplete": [ "cpe:2.3:a:jenkins:testcomplete_support:*:*:*:*:*:jenkins:*:*" ], @@ -673,6 +723,9 @@ "covcomplplot": [ "cpe:2.3:a:jenkins:coverage\\/complexity_scatter_plot:*:*:*:*:*:jenkins:*:*" ], + "coverage": [ + "cpe:2.3:a:jenkins:coverage:*:*:*:*:*:jenkins:*:*" + ], "coverity": [ "cpe:2.3:a:jenkins:coverity:*:*:*:*:*:jenkins:*:*" ], @@ -851,6 +904,9 @@ "ec2-deployment-dashboard": [ "cpe:2.3:a:jenkins:deployment_dashboard:*:*:*:*:*:jenkins:*:*" ], + "ec2-fleet": [ + "cpe:2.3:a:jenkins:ec2_fleet:*:*:*:*:*:jenkins:*:*" + ], "echarts-api": [ "cpe:2.3:a:jenkins:echarts_api:*:*:*:*:*:jenkins:*:*" ], @@ -863,6 +919,9 @@ "eggplant": [ "cpe:2.3:a:jenkins:eggplant:*:*:*:*:*:jenkins:*:*" ], + "eggplant-runner": [ + "cpe:2.3:a:jenkins:eggplant_runner:*:*:*:*:*:jenkins:*:*" + ], "elastest": [ "cpe:2.3:a:jenkins:elastest:*:*:*:*:*:jenkins:*:*" ], @@ -903,6 +962,9 @@ "extended-choice-parameter": [ "cpe:2.3:a:jenkins:extended_choice_parameter:*:*:*:*:*:jenkins:*:*" ], + "extensible-choice-parameter": [ + "cpe:2.3:a:jenkins:extensible_choice_parameter:*:*:*:*:*:jenkins:*:*" + ], "extensivetesting": [ "cpe:2.3:a:jenkins:extensive_testing:*:*:*:*:*:jenkins:*:*" ], @@ -1032,6 +1094,9 @@ "github-pr-coverage-status": [ "cpe:2.3:a:jenkins:github_pull_request_coverage_status:*:*:*:*:*:jenkins:*:*" ], + "github-pullrequest": [ + "cpe:2.3:a:kostyasha:github_integration:*:*:*:*:*:jenkins:*:*" + ], "gitlab": [ "cpe:2.3:a:jenkins:gitlab:*:*:*:*:*:jenkins:*:*" ], @@ -1184,6 +1249,9 @@ "jenkins-cloudformation": [ "cpe:2.3:a:jenkins:jenkins-cloudformation-plugin:*:*:*:*:*:jenkins:*:*" ], + "jenkins-multijob": [ + "cpe:2.3:a:jenkins:multijob:*:*:*:*:*:jenkins:*:*" + ], "jenkins-multijob-plugin": [ "cpe:2.3:a:jenkins:multijob:*:*:*:*:*:jenkins:*:*" ], @@ -1285,6 +1353,9 @@ "lambdatest-automation": [ "cpe:2.3:a:jenkins:lambdatest-automation:*:*:*:*:*:jenkins:*:*" ], + "ldap": [ + "cpe:2.3:a:jenkins:ldap:*:*:*:*:*:jenkins:*:*" + ], "libvirt-slave": [ "cpe:2.3:a:jenkins:libvirt_agents:*:*:*:*:*:jenkins:*:*", "cpe:2.3:a:jenkins:libvirt_slaves:*:*:*:*:*:jenkins:*:*" @@ -1385,6 +1456,9 @@ "maven-release-cascade": [ "cpe:2.3:a:barchart:maven_cascade_release:*:*:*:*:*:jenkins:*:*" ], + "mcp-server": [ + "cpe:2.3:a:jenkins:mcp_server:*:*:*:*:*:jenkins:*:*" + ], "meliora-testlab": [ "cpe:2.3:a:jenkins:meliora_testlab:*:*:*:*:*:jenkins:*:*", "cpe:2.3:a:melioratestlab:melioratestlab:*:*:*:*:*:jenkins:*:*" @@ -1558,6 +1632,9 @@ "pipeline-githubnotify-step": [ "cpe:2.3:a:jenkins:pipeline_github_notify_step:*:*:*:*:*:jenkins:*:*" ], + "pipeline-groovy-lib": [ + "cpe:2.3:a:jenkins:pipeline\\:_groovy_libraries:*:*:*:*:*:*:*:*" + ], "pipeline-input-step": [ "cpe:2.3:a:jenkins:pipeline\\:_input_step:*:*:*:*:*:jenkins:*:*", "cpe:2.3:a:jenkins:pipeline\\:input_step:*:*:*:*:*:jenkins:*:*" @@ -1568,6 +1645,9 @@ "pipeline-model-definition": [ "cpe:2.3:a:jenkins:pipeline\\:_declarative:*:*:*:*:*:jenkins:*:*" ], + "pipeline-reporter-by-redpen": [ + "cpe:2.3:a:jenkins:redpen_-_pipeline_reporter_for_jira:*:*:*:*:*:jenkins:*:*" + ], "pipeline-restful-api": [ "cpe:2.3:a:jenkins:pipeline_restful_api:*:*:*:*:*:jenkins:*:*" ], @@ -2214,6 +2294,9 @@ "zap-pipeline": [ "cpe:2.3:a:jenkins:zap_pipeline:*:*:*:*:*:jenkins:*:*" ], + "zdevops": [ + "cpe:2.3:a:jenkins:zowe_zdevops:*:*:*:*:*:jenkins:*:*" + ], "zephyr-enterprise-test-management": [ "cpe:2.3:a:jenkins:zephyr_enterprise_test_management:*:*:*:*:*:jenkins:*:*" ], @@ -2265,6 +2348,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:*:*:*:*:*:*:*:*" @@ -2287,6 +2373,9 @@ "@apollosproject/data-connector-rock": [ "cpe:2.3:a:apollosapp:data-connector-rock:*:*:*:*:*:node.js:*:*" ], + "@appium/base-driver": [ + "cpe:2.3:a:appium:appium\\/base-driver:*:*:*:*:*:node.js:*:*" + ], "@appium/support": [ "cpe:2.3:a:appium:appium\\/support:*:*:*:*:*:node.js:*:*" ], @@ -2335,9 +2424,15 @@ "@backstage/techdocs-common": [ "cpe:2.3:a:linuxfoundation:\\@backstage\\/techdocs-common:*:*:*:*:*:node.js:*:*" ], + "@beproduct/nestjs-auth": [ + "cpe:2.3:a:beproduct:beproduct\\/nestjs-auth:*:*:*:*:*:node.js:*:*" + ], "@better-auth/oauth-provider": [ "cpe:2.3:a:better-auth:better-auth\\/oauth-provider:*:*:*:*:*:node.js:*:*" ], + "@better-auth/sso": [ + "cpe:2.3:a:better-auth:better-auth\\/sso:*:*:*:*:*:node.js:*:*" + ], "@braintree/sanitize-url": [ "cpe:2.3:a:paypal:braintree\\/sanitize-url:*:*:*:*:*:node.js:*:*" ], @@ -2350,6 +2445,9 @@ "@chainsafe/libp2p-noise": [ "cpe:2.3:a:chainsafe:js-libp2p-noise:*:*:*:*:*:node.js:*:*" ], + "@chargeover/redoc": [ + "cpe:2.3:a:chargeover:chargeover\\/redoc:*:*:*:*:*:node.js:*:*" + ], "@ckeditor/ckeditor5-engine": [ "cpe:2.3:a:ckeditor:ckeditor5-engine:*:*:*:*:*:node.js:*:*" ], @@ -2374,12 +2472,18 @@ "@ckeditor/ckeditor5-widget": [ "cpe:2.3:a:ckeditor:ckeditor5-widget:*:*:*:*:*:node.js:*:*" ], + "@clerk/backend": [ + "cpe:2.3:a:clerk:clerk\\/backend:*:*:*:*:*:node.js:*:*" + ], "@coding-solo/godot-mcp": [ "cpe:2.3:a:coding-solo:godot_mcp:*:*:*:*:*:*:*:*" ], "@contentauth/c2pa-web": [ "cpe:2.3:a:adobe:c2pa-web:*:*:*:*:*:node.js:*:*" ], + "@continuedev/cli": [ + "cpe:2.3:a:continue:continue:*:*:*:*:*:node.js:*:*" + ], "@cookiex/deep": [ "cpe:2.3:a:cookiex-deep_project:cookiex-deep:*:*:*:*:*:node.js:*:*" ], @@ -2392,15 +2496,43 @@ "@diez/generation": [ "cpe:2.3:a:haikuforteams:diez:*:*:*:*:*:node.js:*:*" ], + "@draftlab/auth": [ + "cpe:2.3:a:matheuspergoli:draftlab\\/auth:*:*:*:*:*:node.js:*:*" + ], + "@draftlab/db": [ + "cpe:2.3:a:matheuspergoli:draftlab\\/db:*:*:*:*:*:node.js:*:*" + ], "@electron/packager": [ "cpe:2.3:a:electron:packager:*:*:*:*:*:node.js:*:*" ], + "@ensdomains/ens-contracts": [ + "cpe:2.3:a:ens.domains:ethereum_name_service:*:*:*:*:*:node.js:*:*" + ], "@evershop/evershop": [ "cpe:2.3:a:evershop:evershop:*:*:*:*:*:node.js:*:*" ], + "@fastify/accepts-serializer": [ + "cpe:2.3:a:fastify:fastify\\/accepts-serializer:*:*:*:*:*:node.js:*:*" + ], + "@fastify/express": [ + "cpe:2.3:a:fastify:fastify\\/express:*:*:*:*:*:node.js:*:*" + ], + "@fastify/forwarded": [ + "cpe:2.3:a:fastify:fastify\\/forwarded:*:*:*:*:*:node.js:*:*" + ], + "@fastify/http-proxy": [ + "cpe:2.3:a:fastify:fastify\\/http-proxy:*:*:*:*:*:node.js:*:*" + ], "@fastify/middie": [ "cpe:2.3:a:fastify:fastify\\/middie:*:*:*:*:*:node.js:*:*" ], + "@fastify/rate-limit": [ + "cpe:2.3:a:fastify:fastify\\/rate-limit:*:*:*:*:*:node.js:*:*" + ], + "@fastify/reply-from": [ + "cpe:2.3:a:fastify:fastify\\/reply-from:*:*:*:*:*:node.js:*:*", + "cpe:2.3:a:fastify:reply-from:*:*:*:*:*:node.js:*:*" + ], "@fastly/js-compute": [ "cpe:2.3:a:fastly:js-compute:*:*:*:*:*:node.js:*:*" ], @@ -2452,6 +2584,9 @@ "@keystonejs/keystone": [ "cpe:2.3:a:keystonejs:keystone:*:*:*:*:*:node.js:*:*" ], + "@kilocode/cli": [ + "cpe:2.3:a:kilo:kilo_code_cli:*:*:*:*:*:node.js:*:*" + ], "@knight-lab/timelinejs": [ "cpe:2.3:a:northwestern:timelinejs:*:*:*:*:*:*:*:*" ], @@ -2464,6 +2599,18 @@ "@mattkrick/sanitize-svg": [ "cpe:2.3:a:sanitize-svg_project:sanitize-svg:*:*:*:*:*:node.js:*:*" ], + "@mesadev/rest": [ + "cpe:2.3:a:mesa:mesadev\\/rest:*:*:*:*:*:node.js:*:*" + ], + "@mesadev/saguaro": [ + "cpe:2.3:a:mesa:mesadev\\/saguaro:*:*:*:*:*:node.js:*:*" + ], + "@mesadev/sdk": [ + "cpe:2.3:a:mesa:mesadev\\/sdk:*:*:*:*:*:node.js:*:*" + ], + "@microsoft/live-share-canvas": [ + "cpe:2.3:a:microsoft:live_share_canvas:*:*:*:*:*:node.js:*:*" + ], "@misskey-dev/summaly": [ "cpe:2.3:a:misskey:summaly:*:*:*:*:*:node.js:*:*" ], @@ -2476,6 +2623,9 @@ "@nextcloud/dialogs": [ "cpe:2.3:a:nextcloud\\/dialogs_project:nextcloud\\/dialogs:*:*:*:*:*:node.js:*:*" ], + "@node-oauth/oauth2-server": [ + "cpe:2.3:a:node-oauth:node-oauth\\/oauth2-server:*:*:*:*:*:node.js:*:*" + ], "@node-saml/node-saml": [ "cpe:2.3:a:node_saml_project:node_saml:*:*:*:*:*:node.js:*:*" ], @@ -2488,24 +2638,42 @@ "@nuxt/devalue": [ "cpe:2.3:a:nuxtjs:\\@nuxt\\/devalue:*:*:*:*:*:node.js:*:*" ], + "@nuxt/nitro-server": [ + "cpe:2.3:a:nuxt:nuxt\\/nitro-server:*:*:*:*:*:node.js:*:*" + ], + "@nuxt/rspack-builder": [ + "cpe:2.3:a:nuxt:nuxt\\/rspack-builder:*:*:*:*:*:node.js:*:*" + ], + "@nuxt/webpack-builder": [ + "cpe:2.3:a:nuxt:nuxt\\/webpack-builder:*:*:*:*:*:node.js:*:*" + ], "@nyariv/sandboxjs": [ "cpe:2.3:a:nyariv:sandboxjs:*:*:*:*:*:node.js:*:*" ], "@octokit/webhooks": [ "cpe:2.3:a:octokit:webhooks:*:*:*:*:*:node.js:*:*" ], + "@openclaw/feishu": [ + "cpe:2.3:a:openclaw:openclaw\\/feishu:*:*:*:*:*:node.js:*:*" + ], "@openclaw/voice-call": [ "cpe:2.3:a:openclaw:openclaw\\/voice-call:*:*:*:*:*:node.js:*:*" ], "@opennextjs/cloudflare": [ "cpe:2.3:a:opennextjs:opennext_for_cloudflare:*:*:*:*:*:node.js:*:*" ], + "@opensearch-project/opensearch": [ + "cpe:2.3:a:linuxfoundation:opensearch:*:*:*:*:*:node.js:*:*" + ], "@openzeppelin/contracts": [ "cpe:2.3:a:openzeppelin:contracts:*:*:*:*:*:node.js:*:*" ], "@openzeppelin/contracts-upgradeable": [ "cpe:2.3:a:openzeppelin:contracts_upgradeable:*:*:*:*:*:node.js:*:*" ], + "@paperclipai/server": [ + "cpe:2.3:a:paperclip:paperclipai\\/server:*:*:*:*:*:node.js:*:*" + ], "@parse/push-adapter": [ "cpe:2.3:a:parseplatform:parse_server_push_adapter:*:*:*:*:*:node.js:*:*" ], @@ -2522,6 +2690,12 @@ "@progfay/scrapbox-parser": [ "cpe:2.3:a:scrapbox-parser_project:scrapbox-parser:*:*:*:*:*:node.js:*:*" ], + "@rage-against-the-pixel/unity-cli": [ + "cpe:2.3:a:rageagainstthepixel:unity-cli:*:*:*:*:*:node.js:*:*" + ], + "@remix-run/server-runtime": [ + "cpe:2.3:a:shopify:remix-run\\/server-runtime:*:*:*:*:*:node.js:*:*" + ], "@rkesters/gnuplot": [ "cpe:2.3:a:gnuplot_project:gnuplot:*:*:*:*:*:node.js:*:*" ], @@ -2552,6 +2726,12 @@ "@strikeentco/set": [ "cpe:2.3:a:set_project:set:*:*:*:*:*:node.js:*:*" ], + "@supersurkhet/cli": [ + "cpe:2.3:a:abhishake1:supersurkhet\\/cli:*:*:*:*:*:node.js:*:*" + ], + "@supersurkhet/sdk": [ + "cpe:2.3:a:abhishake1:supersurkhet\\/sdk:*:*:*:*:*:node.js:*:*" + ], "@sveltejs/adapter-node": [ "cpe:2.3:a:svelte:adapter-node:*:*:*:*:*:node.js:*:*" ], @@ -2564,6 +2744,9 @@ "@tarojs/taro": [ "cpe:2.3:a:taro:taro:*:*:*:*:*:node.js:*:*" ], + "@taskflow-corp/cli": [ + "cpe:2.3:a:abhishake1:taskflow-corp\\/cli:*:*:*:*:*:node.js:*:*" + ], "@thi.ng/egf": [ "cpe:2.3:a:\\@thi.ng\\/egf_project:\\@thi.ng\\/egf:*:*:*:*:*:node.js:*:*" ], @@ -2576,6 +2759,9 @@ "@tiptap/extension-link": [ "cpe:2.3:a:tiptap:tiptap\\/extension-link:*:*:*:*:*:node.js:*:*" ], + "@tolka/cli": [ + "cpe:2.3:a:christianalares:tolka\\/cli:*:*:*:*:*:node.js:*:*" + ], "@tryghost/portal": [ "cpe:2.3:a:ghost:portal:*:*:*:*:*:node.js:*:*" ], @@ -2800,6 +2986,9 @@ "blamer": [ "cpe:2.3:a:blamer_project:blamer:*:*:*:*:*:node.js:*:*" ], + "bluebird": [ + "cpe:2.3:a:bluebirdjs:bluebird:*:*:*:*:*:node.js:*:*" + ], "bmoor": [ "cpe:2.3:a:bmoor_project:bmoor:*:*:*:*:*:node.js:*:*" ], @@ -2909,6 +3098,9 @@ "chatbyvista": [ "cpe:2.3:a:chatbyvista_project:chatbyvista:*:*:*:*:*:node.js:*:*" ], + "chrome-devtools-mcp": [ + "cpe:2.3:a:google:chrome-devtools-mcp:*:*:*:*:*:node.js:*:*" + ], "chrome-launcher": [ "cpe:2.3:a:google:chrome-launcher:*:*:*:*:*:node.js:*:*" ], @@ -2937,9 +3129,15 @@ "clawdbot": [ "cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*" ], + "clawvet": [ + "cpe:2.3:a:mohibshaikh:clawvet:*:*:*:*:*:node.js:*:*" + ], "cli": [ "cpe:2.3:a:cli_project:cli:*:*:*:*:*:node.js:*:*" ], + "client-certificate-auth": [ + "cpe:2.3:a:tgies:client-certificate-auth:*:*:*:*:*:node.js:*:*" + ], "closure-compiler-stream": [ "cpe:2.3:a:closure-compiler-stream_project:closure-compiler-stream:*:*:*:*:*:node.js:*:*" ], @@ -2949,12 +3147,18 @@ "closurecompiler": [ "cpe:2.3:a:closurecompiler_project:closurecompiler:*:*:*:*:*:node.js:*:*" ], + "cloudinary": [ + "cpe:2.3:a:cloudinary:cloudinary:*:*:*:*:*:node.js:*:*" + ], "cloudpub-redis": [ "cpe:2.3:a:cloudpub-redis_project:cloudpub-redis:*:*:*:*:*:node.js:*:*" ], "cmake": [ "cpe:2.3:a:cmake_project:cmake:*:*:*:*:*:node.js:*:*" ], + "cmux-agent-mcp": [ + "cpe:2.3:a:multiagentcognition:cmux-agent-mcp:*:*:*:*:*:node.js:*:*" + ], "co-cli-installer": [ "cpe:2.3:a:co-cli-installer_project:co-cli-installer:*:*:*:*:*:node.js:*:*" ], @@ -3161,7 +3365,7 @@ "cpe:2.3:a:deep.assign_project:deep.assign:*:*:*:*:*:node.js:*:*" ], "deephas": [ - "cpe:2.3:a:deephas_project:deephas:*:*:*:*:*:node.js:*:*" + "cpe:2.3:a:sharpred:deephas:*:*:*:*:*:node.js:*:*" ], "deeply": [ "cpe:2.3:a:deeply_project:deeply:*:*:*:*:*:node.js:*:*" @@ -3412,6 +3616,9 @@ "external-svg-loader": [ "cpe:2.3:a:shubhamjain:svg_loader:*:*:*:*:*:node.js:*:*" ], + "extract-zip": [ + "cpe:2.3:a:max-mapper:extract-zip:*:*:*:*:*:node.js:*:*" + ], "exxxxxxxxxxx": [ "cpe:2.3:a:exxxxxxxxxxx_project:exxxxxxxxxxx:*:*:*:*:*:node.js:*:*" ], @@ -3595,9 +3802,15 @@ "git-archive": [ "cpe:2.3:a:git-archive_project:git-archive:*:*:*:*:*:node.js:*:*" ], + "git-branch-selector": [ + "cpe:2.3:a:christianalares:git_branch_selector:*:*:*:*:*:node.js:*:*" + ], "git-dummy-commit": [ "cpe:2.3:a:git-dummy-commit_project:git-dummy-commit:*:*:*:*:*:node.js:*:*" ], + "git-git-git": [ + "cpe:2.3:a:christianalares:git-git-git:*:*:*:*:*:node.js:*:*" + ], "git-parse": [ "cpe:2.3:a:wayfair:git-parse:*:*:*:*:*:node.js:*:*" ], @@ -3828,6 +4041,9 @@ "http-proxy": [ "cpe:2.3:a:http-proxy_project:http-proxy:*:*:*:*:*:node.js:*:*" ], + "http-proxy-middleware": [ + "cpe:2.3:a:chimurai:http-proxy-middleware:*:*:*:*:*:*:*:*" + ], "http-proxy.js": [ "cpe:2.3:a:http-proxy.js_project:http-proxy.js:*:*:*:*:*:node.js:*:*" ], @@ -3864,6 +4080,18 @@ "i18n-node-angular": [ "cpe:2.3:a:i18n-node-angular_project:i18n-node-angular:*:*:*:*:*:node.js:*:*" ], + "i18next-fs-backend": [ + "cpe:2.3:a:i18next:i18next-fs-backend:*:*:*:*:*:node.js:*:*" + ], + "i18next-http-backend": [ + "cpe:2.3:a:i18next:i18next-http-backend:*:*:*:*:*:node.js:*:*" + ], + "i18next-http-middleware": [ + "cpe:2.3:a:i18next:i18next-http-middleware:*:*:*:*:*:node.js:*:*" + ], + "i18nextify": [ + "cpe:2.3:a:i18next:i18nextify:*:*:*:*:*:node.js:*:*" + ], "ibapi": [ "cpe:2.3:a:interactivebrokers:ibapi:*:*:*:*:*:node.js:*:*" ], @@ -3927,6 +4155,9 @@ "ip": [ "cpe:2.3:a:fedorindutny:ip:*:*:*:*:*:node.js:*:*" ], + "ip-address": [ + "cpe:2.3:a:beaugunderson:ip-address:*:*:*:*:*:node.js:*:*" + ], "ipip": [ "cpe:2.3:a:ipip_project:ipip:*:*:*:*:*:node.js:*:*" ], @@ -3969,6 +4200,9 @@ "jdf-sass": [ "cpe:2.3:a:jdf-sass_project:jdf-sass:*:*:*:*:*:node.js:*:*" ], + "jest": [ + "cpe:2.3:a:openjsf:jest:*:*:*:*:*:node.js:*:*" + ], "jikes": [ "cpe:2.3:a:jikes_project:jikes:*:*:*:*:*:node.js:*:*" ], @@ -3986,7 +4220,7 @@ "cpe:2.3:a:joplin_project:joplin:*:*:*:*:*:node.js:*:*" ], "jose": [ - "cpe:2.3:a:jose_project:jose:*:*:*:*:*:node.js:*:*" + "cpe:2.3:a:panva:jose:*:*:*:*:*:node.js:*:*" ], "jpeg-js": [ "cpe:2.3:a:jpeg-js_project:jpeg-js:*:*:*:*:*:node.js:*:*" @@ -4022,6 +4256,9 @@ "js-given": [ "cpe:2.3:a:js-given_project:js-given:*:*:*:*:*:node.js:*:*" ], + "js-toml": [ + "cpe:2.3:a:sunnyadn:js-toml:*:*:*:*:*:node.js:*:*" + ], "js-yaml": [ "cpe:2.3:a:nodeca:js-yaml:*:*:*:*:*:node.js:*:*" ], @@ -4055,6 +4292,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:*:*" ], @@ -4068,7 +4308,7 @@ "cpe:2.3:a:jsreport:jsreport:*:*:*:*:*:node.js:*:*" ], "jsrsasign": [ - "cpe:2.3:a:jsrsasign_project:jsrsasign:*:*:*:*:*:node.js:*:*" + "cpe:2.3:a:kjur:jsrsasign:*:*:*:*:*:node.js:*:*" ], "jstestdriver": [ "cpe:2.3:a:jstestdriver_project:jstestdriver:*:*:*:*:*:node.js:*:*" @@ -4154,6 +4394,9 @@ "langchain": [ "cpe:2.3:a:langchain:langchain.js:*:*:*:*:*:*:*:*" ], + "langsmith": [ + "cpe:2.3:a:langchain:langsmith:*:*:*:*:*:node.js:*:*" + ], "larvitbase-api": [ "cpe:2.3:a:larvit:larvitbase:*:*:*:*:*:node.js:*:*" ], @@ -4299,6 +4542,9 @@ "madlib-object-utils": [ "cpe:2.3:a:springtree:madlib-object-utils:*:*:*:*:*:node.js:*:*" ], + "magicmirror": [ + "cpe:2.3:a:magicmirror:magicmirror:*:*:*:*:*:node.js:*:*" + ], "mailparser": [ "cpe:2.3:a:nodemailer:mailparser:*:*:*:*:*:node.js:*:*" ], @@ -4309,6 +4555,7 @@ "cpe:2.3:a:mapbox:mapbox.js:*:*:*:*:*:node.js:*:*" ], "mariadb": [ + "cpe:2.3:a:mariadb:mariadb:*:*:*:*:*:*:*:*", "cpe:2.3:a:mariadb:mariadb:*:*:*:*:*:node.js:*:*" ], "marionette-socket-host": [ @@ -4356,12 +4603,18 @@ "mc-kill-port": [ "cpe:2.3:a:mc-kill-port_project:mc-kill-port:*:*:*:*:*:node.js:*:*" ], + "mcp-from-openapi": [ + "cpe:2.3:a:frontmcp:mcp-from-openapi:*:*:*:*:*:node.js:*:*" + ], "mcp-server-kubernetes": [ "cpe:2.3:a:suyogs:mcp-server-kubernetes:*:*:*:*:*:node.js:*:*" ], "mcstatic": [ "cpe:2.3:a:mcstatic_project:mcstatic:*:*:*:*:*:node.js:*:*" ], + "md-fileserver": [ + "cpe:2.3:a:commenthol:md-fileserver:*:*:*:*:*:node.js:*:*" + ], "md-to-pdf": [ "cpe:2.3:a:markdown_to_pdf_project:markdown_to_pdf:*:*:*:*:*:node.js:*:*" ], @@ -4606,6 +4859,9 @@ "next-auth": [ "cpe:2.3:a:nextauth.js:next-auth:*:*:*:*:*:node.js:*:*" ], + "nextmove-mcp": [ + "cpe:2.3:a:christianalares:nextmove-mcp:*:*:*:*:*:node.js:*:*" + ], "nis-utils": [ "cpe:2.3:a:nis-utils_project:nis-utils:*:*:*:*:*:node.js:*:*" ], @@ -4722,6 +4978,9 @@ "node-stringbuilder": [ "cpe:2.3:a:magiclen:stringbuilder:*:*:*:*:*:node.js:*:*" ], + "node-tesseract-ocr": [ + "cpe:2.3:a:zapolnoch:tesseract_ocr:*:*:*:*:*:node.js:*:*" + ], "node-thulac": [ "cpe:2.3:a:geohey:node-thulac:*:*:*:*:*:node.js:*:*" ], @@ -4908,6 +5167,9 @@ "pandora-doomsday": [ "cpe:2.3:a:pandora-doomsday_project:pandora-doomsday:*:*:*:*:*:node.js:*:*" ], + "paperclipai": [ + "cpe:2.3:a:paperclip:paperclipai:*:*:*:*:*:node.js:*:*" + ], "parse-dashboard": [ "cpe:2.3:a:parseplatform:parse_dashboard:*:*:*:*:*:node.js:*:*" ], @@ -5085,6 +5347,9 @@ "protobufjs": [ "cpe:2.3:a:protobufjs_project:protobufjs:*:*:*:*:*:node.js:*:*" ], + "protobufjs-cli": [ + "cpe:2.3:a:protobufjs_project:protobufjs-cli:*:*:*:*:*:node.js:*:*" + ], "proxy": [ "cpe:2.3:a:proxy_project:proxy:*:*:*:*:*:node.js:*:*" ], @@ -5173,6 +5438,15 @@ "react-router": [ "cpe:2.3:a:shopify:react-router:*:*:*:*:*:node.js:*:*" ], + "react-server-dom-parcel": [ + "cpe:2.3:a:facebook:react-server-dom-parcel:*:*:*:*:*:node.js:*:*" + ], + "react-server-dom-turbopack": [ + "cpe:2.3:a:facebook:react-server-dom-turbopack:*:*:*:*:*:node.js:*:*" + ], + "react-server-dom-webpack": [ + "cpe:2.3:a:facebook:react-server-dom-webpack:*:*:*:*:*:node.js:*:*" + ], "realms-shim": [ "cpe:2.3:a:agoric:realms-shim:*:*:*:*:*:node.js:*:*" ], @@ -5263,6 +5537,9 @@ "s3-uploader": [ "cpe:2.3:a:s3-uploader_project:s3-uploader:*:*:*:*:*:node.js:*:*" ], + "safe-action": [ + "cpe:2.3:a:matheuspergoli:simple_type-safe_actions:*:*:*:*:*:node.js:*:*" + ], "safe-eval": [ "cpe:2.3:a:safe-eval_project:safe-eval:*:*:*:*:*:node.js:*:*" ], @@ -5451,6 +5728,9 @@ "shout": [ "cpe:2.3:a:shout_project:shout:*:*:*:*:*:node.js:*:*" ], + "sigstore": [ + "cpe:2.3:a:sigstore:sigstore:*:*:*:*:*:node.js:*:*" + ], "sillytavern": [ "cpe:2.3:a:sillytavern:sillytavern:*:*:*:*:*:node.js:*:*" ], @@ -5554,6 +5834,9 @@ "ssrf-agent": [ "cpe:2.3:a:ssrf-agent_project:ssrf-agent:*:*:*:*:*:node.js:*:*" ], + "ssrfcheck": [ + "cpe:2.3:a:felippe-regazio:ssrf_check:*:*:*:*:*:node.js:*:*" + ], "ssri": [ "cpe:2.3:a:ssri_project:ssri:*:*:*:*:*:node.js:*:*" ], @@ -5594,7 +5877,6 @@ "cpe:2.3:a:storybook:storybook:*:*:*:*:*:node.js:*:*" ], "strapi": [ - "cpe:2.3:a:strapi:strapi:*:*:*:*:*:*:*:*", "cpe:2.3:a:strapi:strapi:*:*:*:*:*:node.js:*:*" ], "strider-sauce": [ @@ -5651,7 +5933,11 @@ ], "tar": [ "cpe:2.3:a:isaacs:tar:*:*:*:*:*:node.js:*:*", - "cpe:2.3:a:tar_project:tar:*:*:*:*:*:node.js:*:*" + "cpe:2.3:a:tar_project:tar:*:*:*:*:*:node.js:*:*", + "cpe:2.3:a:tar_project:tar:*:*:*:*:*:rust:*:*" + ], + "tarteaucitronjs": [ + "cpe:2.3:a:amauri:tarteaucitronjs:*:*:*:*:*:node.js:*:*" ], "teddy": [ "cpe:2.3:a:teddy_project:teddy:*:*:*:*:*:node.js:*:*" @@ -5674,6 +5960,9 @@ "three": [ "cpe:2.3:a:three_project:three:*:*:*:*:*:node.js:*:*" ], + "thumbler": [ + "cpe:2.3:a:mmahrous:thumbler:*:*:*:*:*:node.js:*:*" + ], "tianma-static": [ "cpe:2.3:a:tianma-static_project:tianma-static:*:*:*:*:*:node.js:*:*" ], @@ -5698,6 +5987,9 @@ "tmock": [ "cpe:2.3:a:tmock_project:tmock:*:*:*:*:*:node.js:*:*" ], + "tmp": [ + "cpe:2.3:a:raszi:tmp:*:*:*:*:*:node.js:*:*" + ], "tmpl": [ "cpe:2.3:a:tmpl_project:tmpl:*:*:*:*:*:node.js:*:*" ], @@ -5755,6 +6047,9 @@ "tsup": [ "cpe:2.3:a:egoist:tsup:*:*:*:*:*:node.js:*:*" ], + "turbo-stream": [ + "cpe:2.3:a:turbo-stream:turbo_stream:*:*:*:*:*:node.js:*:*" + ], "ua-parser": [ "cpe:2.3:a:ua-parser_project:ua-parser:*:*:*:*:*:node.js:*:*" ], @@ -5866,9 +6161,18 @@ "vega-functions": [ "cpe:2.3:a:vega-functions_project:vega-functions:*:*:*:*:*:node.js:*:*" ], + "velocityjs": [ + "cpe:2.3:a:shepherdwind:velocity.js:*:*:*:*:*:node.js:*:*" + ], + "vercel": [ + "cpe:2.3:a:vercel:vercel:*:*:*:*:*:node.js:*:*" + ], "vite": [ "cpe:2.3:a:vitejs:vite:*:*:*:*:*:node.js:*:*" ], + "vitest": [ + "cpe:2.3:a:vitest.dev:vitest:*:*:*:*:*:node.js:*:*" + ], "vm2": [ "cpe:2.3:a:vm2_project:vm2:*:*:*:*:*:node.js:*:*" ], @@ -5903,6 +6207,9 @@ "webdriver-launcher": [ "cpe:2.3:a:webdriver-launcher_project:webdriver-launcher:*:*:*:*:*:node.js:*:*" ], + "webdriverio": [ + "cpe:2.3:a:openjsf:webdriverio:*:*:*:*:*:node.js:*:*" + ], "webdrvr": [ "cpe:2.3:a:webdrvr_project:webdrvr:*:*:*:*:*:node.js:*:*" ], @@ -5942,6 +6249,9 @@ "wifiscanner": [ "cpe:2.3:a:thingssdk:wifiscanner:*:*:*:*:*:node.js:*:*" ], + "willitmerge": [ + "cpe:2.3:a:dontkry:willitmerge:*:*:*:*:*:node.js:*:*" + ], "wincred": [ "cpe:2.3:a:wincred_project:wincred:*:*:*:*:*:*:*:*" ], @@ -6047,6 +6357,9 @@ "joomla/session": [ "cpe:2.3:a:joomla:session:*:*:*:*:*:*:*:*" ], + "mtdowling/jmespath.php": [ + "cpe:2.3:a:jmespath:jmespath:*:*:*:*:*:php:*:*" + ], "mustache/mustache": [ "cpe:2.3:a:mustache_project:mustache:*:*:*:*:*:php:*:*" ], @@ -6059,6 +6372,12 @@ "shopware/shopware": [ "cpe:2.3:a:shopware:shopware:*:*:*:*:*:*:*:*" ], + "symfony/twig-bridge": [ + "cpe:2.3:a:symfony:twig-bridge:*:*:*:*:*:*:*:*" + ], + "symfony/twig-bundle": [ + "cpe:2.3:a:symfony:twig-bundle:*:*:*:*:*:*:*:*" + ], "typo3/html-sanitizer": [ "cpe:2.3:a:typo3:html_sanitizer:*:*:*:*:*:*:*:*" ], @@ -6160,6 +6479,9 @@ "XML2Dict": [ "cpe:2.3:a:xml2dict_project:xml2dict:*:*:*:*:*:python:*:*" ], + "adx-mcp-server": [ + "cpe:2.3:a:pab1it0:azure_data_explorer_mcp_server:*:*:*:*:*:python:*:*" + ], "agixt": [ "cpe:2.3:a:agixt:agixt:*:*:*:*:*:*:*:*" ], @@ -6172,6 +6494,12 @@ "aniso8601": [ "cpe:2.3:a:aniso8601_project:aniso8601:*:*:*:*:*:*:*:*" ], + "apache-airflow-providers-amazon": [ + "cpe:2.3:a:apache:apache-airflow-providers-amazon:*:*:*:*:*:*:*:*" + ], + "apache-airflow-providers-cncf-kubernetes": [ + "cpe:2.3:a:apache:apache-airflow-providers-cncf-kubernetes:*:*:*:*:*:*:*:*" + ], "api-res-py": [ "cpe:2.3:a:api-res-py_project:api-res-py:*:*:*:*:*:python:*:*" ], @@ -6184,6 +6512,15 @@ "autobahn": [ "cpe:2.3:a:crossbar:autobahn:*:*:*:*:*:*:*:*" ], + "awslabs.aws-api-mcp-server": [ + "cpe:2.3:a:amazon:aws_api_mcp_server:*:*:*:*:*:python:*:*" + ], + "awslabs.aws-transform-mcp-server": [ + "cpe:2.3:a:amazon:aws_transform_mcp_server:*:*:*:*:*:python:*:*" + ], + "awslabs.documentdb-mcp-server": [ + "cpe:2.3:a:amazon:documentdb_mcp_server:*:*:*:*:*:python:*:*" + ], "azure-core": [ "cpe:2.3:a:microsoft:azure_core_shared_client_library:*:*:*:*:*:python:*:*" ], @@ -6220,6 +6557,9 @@ "certifi": [ "cpe:2.3:a:certifi:certifi:*:*:*:*:*:python:*:*" ], + "chromadb": [ + "cpe:2.3:a:trychroma:chromadb:*:*:*:*:*:python:*:*" + ], "claude-agent-sdk": [ "cpe:2.3:a:anthropic:claude_agent_sdk:*:*:*:*:*:python:*:*" ], @@ -6269,6 +6609,12 @@ "d8s-uuids": [ "cpe:2.3:a:democritus_uuids_project:democritus_uuids:*:*:*:*:*:python:*:*" ], + "dash-uploader": [ + "cpe:2.3:a:fohrloop:dash-uploader:*:*:*:*:*:python:*:*" + ], + "datamodel-code-generator": [ + "cpe:2.3:a:koxudaxi:datamodel-code-generator:*:*:*:*:*:python:*:*" + ], "datapizza-ai": [ "cpe:2.3:a:datapizza:datapizza_ai:*:*:*:*:*:*:*:*" ], @@ -6284,6 +6630,9 @@ "django-user-sessions": [ "cpe:2.3:a:django-user-sessions_project:django-user-sessions:*:*:*:*:*:*:*:*" ], + "docling": [ + "cpe:2.3:a:docling:docling:*:*:*:*:*:python:*:*" + ], "docling-core": [ "cpe:2.3:a:docling:docling-core:*:*:*:*:*:python:*:*" ], @@ -6350,6 +6699,9 @@ "guarddog": [ "cpe:2.3:a:datadoghq:guarddog:*:*:*:*:*:python:*:*" ], + "guardrails-ai": [ + "cpe:2.3:a:guardrailsai:guardrails_ai:*:*:*:*:*:python:*:*" + ], "hail": [ "cpe:2.3:a:hail:hail:*:*:*:*:*:python:*:*" ], @@ -6372,6 +6724,9 @@ "hyper-bump-it": [ "cpe:2.3:a:plannigan:hyper_bump_it:*:*:*:*:*:python:*:*" ], + "idna": [ + "cpe:2.3:a:kjd:internationalized_domain_names_in_applications:*:*:*:*:*:python:*:*" + ], "invenio-records": [ "cpe:2.3:a:inveniosoftware:invenio-records:*:*:*:*:*:*:*:*" ], @@ -6384,6 +6739,9 @@ "jwcrypto": [ "cpe:2.3:a:latchset:jwcrypto:*:*:*:*:*:*:*:*" ], + "kafka-python": [ + "cpe:2.3:a:dpkp:kafka-python:*:*:*:*:*:python:*:*" + ], "kedro": [ "cpe:2.3:a:linuxfoundation:kedro:*:*:*:*:*:python:*:*" ], @@ -6402,9 +6760,15 @@ "langchain-experimental": [ "cpe:2.3:a:langchain:langchain_experimental:*:*:*:*:*:python:*:*" ], + "langflow": [ + "cpe:2.3:a:langflow:langflow:*:*:*:*:*:*:*:*" + ], "langflow-base": [ "cpe:2.3:a:langflow:langflow-base:*:*:*:*:*:python:*:*" ], + "langgraph-checkpoint": [ + "cpe:2.3:a:langchain:langgraph-checkpoint:*:*:*:*:*:python:*:*" + ], "lerobot": [ "cpe:2.3:a:huggingface:lerobot:*:*:*:*:*:python:*:*" ], @@ -6438,12 +6802,24 @@ "mcp-memory-service": [ "cpe:2.3:a:doobidoo:mcp-memory-service:*:*:*:*:*:*:*:*" ], + "megatron-bridge": [ + "cpe:2.3:a:nvidia:nemo_megatron_bridge:*:*:*:*:*:*:*:*" + ], + "mistralai": [ + "cpe:2.3:a:mistral:mistralai:*:*:*:*:*:python:*:*" + ], "mltable": [ "cpe:2.3:a:microsoft:azure_machine_learning_software_development_kit:*:*:*:*:*:*:*:*" ], "mpxj": [ "cpe:2.3:a:mpxj:mpxj:*:*:*:*:*:python:*:*" ], + "msgpack": [ + "cpe:2.3:a:msgpack:messagepack:*:*:*:*:*:python:*:*" + ], + "nanobot-ai": [ + "cpe:2.3:a:nanobot:nanobot:*:*:*:*:*:python:*:*" + ], "nbconvert": [ "cpe:2.3:a:jupyter:nbconvert:*:*:*:*:*:python:*:*" ], @@ -6453,6 +6829,9 @@ "networkx": [ "cpe:2.3:a:python:networkx:*:*:*:*:*:*:*:*" ], + "nltk": [ + "cpe:2.3:a:nltk:nltk:*:*:*:*:*:*:*:*" + ], "novajoin": [ "cpe:2.3:a:python:novajoin:*:*:*:*:*:*:*:*" ], @@ -6546,6 +6925,9 @@ "pyanyapi": [ "cpe:2.3:a:pyanyapi_project:pyanyapi:*:*:*:*:*:*:*:*" ], + "pyasn1": [ + "cpe:2.3:a:pyasn1:pyasn1:*:*:*:*:*:python:*:*" + ], "pybluemonday": [ "cpe:2.3:a:python:pybluemonday:*:*:*:*:*:*:*:*" ], @@ -6561,6 +6943,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:*:*" ], @@ -6579,6 +6964,9 @@ "pymdown-extensions": [ "cpe:2.3:a:facelessuser:pymdown_extensions:*:*:*:*:*:*:*:*" ], + "pymetasploit3": [ + "cpe:2.3:a:danmcinerney:pymetasploit3:*:*:*:*:*:*:*:*" + ], "pyo": [ "cpe:2.3:a:pyo_project:pyo:*:*:*:*:*:*:*:*" ], @@ -6591,6 +6979,9 @@ "pypolicyd-spf": [ "cpe:2.3:a:pypolicyd-spf_project:pypolicyd-spf:*:*:*:*:*:*:*:*" ], + "pyspector": [ + "cpe:2.3:a:parzivalhack:pyspector:*:*:*:*:*:python:*:*" + ], "python-dotenv": [ "cpe:2.3:a:saurabh-kumar:python-dotenv:*:*:*:*:*:python:*:*" ], @@ -6606,12 +6997,18 @@ "python-libnmap": [ "cpe:2.3:a:python-libnmap_project:python-libnmap:*:*:*:*:*:python:*:*" ], + "python-liquid": [ + "cpe:2.3:a:jg-rp:python_liquid:*:*:*:*:*:python:*:*" + ], "python-multipart": [ "cpe:2.3:a:fastapiexpert:python-multipart:*:*:*:*:*:python:*:*" ], "python-scciclient": [ "cpe:2.3:a:python-scciclient_project:python-scciclient:*:*:*:*:*:python:*:*" ], + "python-statemachine": [ + "cpe:2.3:a:fgmacedo:python_statemachine:*:*:*:*:*:python:*:*" + ], "pytorch-lightning": [ "cpe:2.3:a:lightningai:pytorch_lightning:*:*:*:*:*:python:*:*" ], @@ -6679,7 +7076,7 @@ "cpe:2.3:a:google:slashify:*:*:*:*:*:node.js:*:*" ], "snowflake-connector-python": [ - "cpe:2.3:a:snowflake:snowflake-connector-python:*:*:*:*:*:*:*:*" + "cpe:2.3:a:snowflake:snowflake_connector:*:*:*:*:*:python:*:*" ], "sockeye": [ "cpe:2.3:a:amazon:sockeye:*:*:*:*:*:python:*:*" @@ -6693,12 +7090,18 @@ "sqlparse": [ "cpe:2.3:a:sqlparse_project:sqlparse:*:*:*:*:*:python:*:*" ], + "stanza": [ + "cpe:2.3:a:stanford:stanza:*:*:*:*:*:python:*:*" + ], "starlette": [ "cpe:2.3:a:encode:starlette:*:*:*:*:*:python:*:*" ], "strawberry-graphql": [ "cpe:2.3:a:strawberry:strawberry_graphql:*:*:*:*:*:python:*:*" ], + "streamlink": [ + "cpe:2.3:a:streamlink:streamlink:*:*:*:*:*:python:*:*" + ], "tablib": [ "cpe:2.3:a:python:tablib:*:*:*:*:*:*:*:*" ], @@ -6732,6 +7135,9 @@ "untangle": [ "cpe:2.3:a:untangle_project:untangle:*:*:*:*:*:python:*:*" ], + "upsonic": [ + "cpe:2.3:a:upsonic:upsonic:*:*:*:*:*:python:*:*" + ], "urllib3": [ "cpe:2.3:a:python:urllib3:*:*:*:*:*:*:*:*" ], @@ -6744,6 +7150,9 @@ "vyper": [ "cpe:2.3:a:vyperlang:vyper:*:*:*:*:*:python:*:*" ], + "web3": [ + "cpe:2.3:a:apeworx:web3.py:*:*:*:*:*:python:*:*" + ], "wikifaces": [ "cpe:2.3:a:wikifaces_project:wikifaces:*:*:*:*:*:pypi:*:*" ], @@ -6753,6 +7162,12 @@ "xmpp-http-upload": [ "cpe:2.3:a:xmpp-http-upload_project:xmpp-http-upload:*:*:*:*:*:*:*:*" ], + "zeep": [ + "cpe:2.3:a:python-zeep:zeep:*:*:*:*:*:python:*:*" + ], + "zeroconf": [ + "cpe:2.3:a:paulsm:zeroconf:*:*:*:*:*:python:*:*" + ], "zibalPlatform": [ "cpe:2.3:a:zibal_project:zibal:*:*:*:*:*:pypi:*:*" ] @@ -6836,6 +7251,9 @@ "commonmarker": [ "cpe:2.3:a:gjtorikian:commonmarker:*:*:*:*:*:ruby:*:*" ], + "concurrent-ruby": [ + "cpe:2.3:a:rubyconcurrency:concurrent_ruby:*:*:*:*:*:ruby:*:*" + ], "consul": [ "cpe:2.3:a:makandra:consul:*:*:*:*:*:ruby:*:*" ], @@ -6884,6 +7302,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:*:*" ], @@ -6948,6 +7369,9 @@ "iodine": [ "cpe:2.3:a:boazsegev:iodine:*:*:*:*:*:ruby:*:*" ], + "jmespath": [ + "cpe:2.3:a:jmespath:jmespath:*:*:*:*:*:ruby:*:*" + ], "json": [ "cpe:2.3:a:json_project:json:*:*:*:*:*:ruby:*:*", "cpe:2.3:a:ruby-lang:json:*:*:*:*:*:ruby:*:*" @@ -7010,6 +7434,9 @@ "mpxj": [ "cpe:2.3:a:mpxj:mpxj:*:*:*:*:*:ruby:*:*" ], + "msgpack": [ + "cpe:2.3:a:msgpack:messagepack:*:*:*:*:*:ruby:*:*" + ], "net-imap": [ "cpe:2.3:a:ruby-lang:net\\:\\:imap:*:*:*:*:*:ruby:*:*" ], @@ -7244,8 +7671,14 @@ "webrick": [ "cpe:2.3:a:ruby-lang:webrick:*:*:*:*:*:ruby:*:*" ], + "websocket-driver": [ + "cpe:2.3:a:faye:websocket-driver:*:*:*:*:*:ruby:*:*" + ], "websocket-extensions": [ "cpe:2.3:a:websocket-extensions_project:websocket-extensions:*:*:*:*:*:ruby:*:*" + ], + "zlib": [ + "cpe:2.3:a:ruby-lang:zlib:*:*:*:*:*:ruby:*:*" ] }, "rust_crates": { @@ -7285,6 +7718,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:*:*" ], @@ -7312,6 +7748,9 @@ "asn1_der": [ "cpe:2.3:a:asn1_der_project:asn1_der:*:*:*:*:*:*:*:*" ], + "astral-tokio-tar": [ + "cpe:2.3:a:astral:astral-tokio-tar:*:*:*:*:*:rust:*:*" + ], "async-h1": [ "cpe:2.3:a:rust-lang:async-h1:*:*:*:*:*:rust:*:*" ], @@ -7333,6 +7772,9 @@ "aws-lc-sys": [ "cpe:2.3:a:amazon:aws-lc-sys:*:*:*:*:*:rust:*:*" ], + "aws-smithy-json": [ + "cpe:2.3:a:amazon:aws-smithy-json:*:*:*:*:*:rust:*:*" + ], "axum-core": [ "cpe:2.3:a:axum-core_project:axum-core:*:*:*:*:*:rust:*:*" ], @@ -7384,6 +7826,12 @@ "bzip2": [ "cpe:2.3:a:bzip2_project:bzip2:*:*:*:*:*:rust:*:*" ], + "c2pa": [ + "cpe:2.3:a:adobe:c2pa:*:*:*:*:*:rust:*:*" + ], + "c2patool": [ + "cpe:2.3:a:adobe:c2patool:*:*:*:*:*:*:*:*" + ], "cache": [ "cpe:2.3:a:cache_project:cache:*:*:*:*:*:rust:*:*" ], @@ -7642,6 +8090,15 @@ "obstack": [ "cpe:2.3:a:obstack_project:obstack:*:*:*:*:*:rust:*:*" ], + "one2html": [ + "cpe:2.3:a:msiemens:one2html:*:*:*:*:*:rust:*:*" + ], + "openssl": [ + "cpe:2.3:a:sfackler:openssl:*:*:*:*:*:rust:*:*" + ], + "opentelemetry": [ + "cpe:2.3:a:opentelemetry:opentelemetry:*:*:*:*:*:rust:*:*" + ], "outer_cgi": [ "cpe:2.3:a:outer_cgi_project:outer_cgi:*:*:*:*:*:rust:*:*" ], @@ -7672,6 +8129,9 @@ "postscript": [ "cpe:2.3:a:postscript_project:postscript:*:*:*:*:*:rust:*:*" ], + "quiche": [ + "cpe:2.3:a:cloudflare:quiche:*:*:*:*:*:rust:*:*" + ], "quinn": [ "cpe:2.3:a:quinn_project:quinn:*:*:*:*:*:rust:*:*" ], @@ -7744,6 +8204,9 @@ "smallvec": [ "cpe:2.3:a:servo:smallvec:*:*:*:*:*:rust:*:*" ], + "spiral-rs": [ + "cpe:2.3:a:blyssprivacy:spiral-rs:*:*:*:*:*:rust:*:*" + ], "stack": [ "cpe:2.3:a:stack_project:stack:*:*:*:*:*:rust:*:*" ], @@ -7861,6 +8324,9 @@ "zebra-consensus": [ "cpe:2.3:a:zfnd:zebra-consensus:*:*:*:*:*:rust:*:*" ], + "zebra-network": [ + "cpe:2.3:a:zfnd:zebra-network:*:*:*:*:*:rust:*:*" + ], "zebra-rpc": [ "cpe:2.3:a:zfnd:zebra-rpc:*:*:*:*:*:rust:*:*" ], @@ -8215,6 +8681,9 @@ "add-subtitle": [ "cpe:2.3:a:viitorcloud:add_subtitle:*:*:*:*:*:wordpress:*:*" ], + "add-svg-support-for-media-uploader-inventivo": [ + "cpe:2.3:a:inventivo:inventivo:*:*:*:*:*:wordpress:*:*" + ], "add-to-any": [ "cpe:2.3:a:addtoany:addtoany_share_buttons:*:*:*:*:*:wordpress:*:*" ], @@ -8405,6 +8874,9 @@ "advanced-recent-posts": [ "cpe:2.3:a:advanced_recent_posts_project:advanced_recent_posts:*:*:*:*:*:wordpress:*:*" ], + "advanced-schedule-posts": [ + "cpe:2.3:a:hijiriworld:advanced_schedule_posts:*:*:*:*:*:wordpress:*:*" + ], "advanced-sermons": [ "cpe:2.3:a:wpcodeus:advanced_sermons:*:*:*:*:*:wordpress:*:*" ], @@ -8469,6 +8941,7 @@ "cpe:2.3:a:ai_contact_us_form_project:ai_contact_us_form:*:*:*:*:*:wordpress:*:*" ], "ai-engine": [ + "cpe:2.3:a:jordymeow:ai_engine:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:meowapps:ai_engine:*:*:*:*:*:wordpress:*:*" ], "ai-image-alt-text-generator-for-wp": [ @@ -8528,6 +9001,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:*:*" ], @@ -8753,6 +9229,9 @@ "ap-mega-menu": [ "cpe:2.3:a:accesspressthemes:ap_mega_menu:*:*:*:*:*:wordpress:*:*" ], + "ap-plugin-scripteo": [ + "cpe:2.3:a:scripteo:ads_pro:*:*:*:*:*:wordpress:*:*" + ], "ap-pricing-tables-lite": [ "cpe:2.3:a:accesspressthemes:ap_pricing_tables_lite:*:*:*:*:*:wordpress:*:*" ], @@ -8807,6 +9286,9 @@ "aprils-super-functions-pack": [ "cpe:2.3:a:april\\'s_super_functions_pack_project:april\\'s_super_functions_pack:*:*:*:*:*:wordpress:*:*" ], + "aptivada-for-wp": [ + "cpe:2.3:a:aptivada:aptivada_for_wp:*:*:*:*:*:wordpress:*:*" + ], "apus-framework": [ "cpe:2.3:a:apusthemes:superio:*:*:*:*:*:wordpress:*:*" ], @@ -8857,7 +9339,8 @@ "cpe:2.3:a:article_directory_project:article_directory:*:*:*:*:*:wordpress:*:*" ], "article-directory-redux": [ - "cpe:2.3:a:icontrolwp:article_directory_redux:*:*:*:*:*:wordpress:*:*" + "cpe:2.3:a:icontrolwp:article_directory_redux:*:*:*:*:*:wordpress:*:*", + "cpe:2.3:a:icontrolwp:icontrolwp:*:*:*:*:*:wordpress:*:*" ], "article2pdf": [ "cpe:2.3:a:article2pdf_project:article2pdf:*:*:*:*:*:wordpress:*:*" @@ -8994,6 +9477,9 @@ "autoptimize": [ "cpe:2.3:a:autoptimize:autoptimize:*:*:*:*:*:wordpress:*:*" ], + "autoship-cloud": [ + "cpe:2.3:a:patternsinthecloud:autoship_cloud_for_woocommerce:*:*:*:*:*:wordpress:*:*" + ], "autotitle-for-wordpress": [ "cpe:2.3:a:unalignedcode:autotitle:*:*:*:*:*:wordpress:*:*" ], @@ -9151,6 +9637,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:*:*" ], @@ -9408,6 +9897,9 @@ "bluet-keywords-tooltip-generator": [ "cpe:2.3:a:tooltipy:tooltipy:*:*:*:*:*:wordpress:*:*" ], + "bluetrait-event-viewer": [ + "cpe:2.3:a:bluetrait:blue_trait_event_viewer:*:*:*:*:*:wordpress:*:*" + ], "bmi-bmr-calculator": [ "cpe:2.3:a:bmi_bmr_calculator_project:bmi_bmr_calculator:*:*:*:*:*:wordpress:*:*" ], @@ -9602,6 +10094,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:*:*" ], @@ -10081,6 +10576,9 @@ "christian-science-bible-lesson-subjects": [ "cpe:2.3:a:sharethepractice:christian_science_bible_lesson_subjects:*:*:*:*:*:wordpress:*:*" ], + "christmasify": [ + "cpe:2.3:a:cyberfoxdigital:christmasify\\!:*:*:*:*:*:wordpress:*:*" + ], "chronosly-events-calendar": [ "cpe:2.3:a:chronosly-events-calendar_project:chronosly-events-calendar:*:*:*:*:*:wordpress:*:*" ], @@ -10120,7 +10618,7 @@ "cpe:2.3:a:cm-wp:clearfy:*:*:*:*:*:wordpress:*:*" ], "clerkio": [ - "cpe:2.3:a:clerk:clerk.io:*:*:*:*:*:wordpress:*:*" + "cpe:2.3:a:clerk.io:clerk.io:*:*:*:*:*:wordpress:*:*" ], "clever-fox": [ "cpe:2.3:a:nayrathemes:clever_fox:*:*:*:*:*:wordpress:*:*" @@ -10347,6 +10845,9 @@ "cpe:2.3:a:really-simple-plugins:complianz:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:really-simple-plugins:complianz:*:*:*:*:premium:wordpress:*:*" ], + "compress-then-upload": [ + "cpe:2.3:a:eliehanna:compress_\\\u0026_upload:*:*:*:*:*:wordpress:*:*" + ], "conditional-extra-fees-for-woocommerce": [ "cpe:2.3:a:piwebsolution:conditional_cart_fee_\\/_extra_charge_rule_for_woocommerce_extra_fees:*:*:*:*:*:wordpress:*:*" ], @@ -10458,6 +10959,9 @@ "contact-form-manager": [ "cpe:2.3:a:xyzscripts:contact_form_manager:*:*:*:*:*:wordpress:*:*" ], + "contact-form-master": [ + "cpe:2.3:a:edmonparker:contact_form_master:*:*:*:*:*:wordpress:*:*" + ], "contact-form-multi": [ "cpe:2.3:a:bestwebsoft:contact_form_multi:*:*:*:*:*:wordpress:*:*" ], @@ -10651,6 +11155,9 @@ "corner-ad": [ "cpe:2.3:a:dwbooster:corner_ad:*:*:*:*:*:wordpress:*:*" ], + "coronavirus-covid-19-notice-message": [ + "cpe:2.3:a:gallagherwebsitedesign:coronavirus_\\(covid-19\\)_notice_message:*:*:*:*:*:wordpress:*:*" + ], "correos-express": [ "cpe:2.3:a:correosexpress_project:correosexpress:*:*:*:*:*:wordpress:*:*" ], @@ -10660,6 +11167,9 @@ "cost-calculator-builder": [ "cpe:2.3:a:stylemixthemes:cost_calculator_builder:*:*:*:*:free:wordpress:*:*" ], + "coub": [ + "cpe:2.3:a:chartscss:coub:*:*:*:*:*:wordpress:*:*" + ], "countdown-block": [ "cpe:2.3:a:wpdeveloper:countdown_block:*:*:*:*:*:wordpress:*:*" ], @@ -10904,6 +11414,9 @@ "custom-field-for-wp-job-manager": [ "cpe:2.3:a:custom_field_for_wp_job_manager_project:custom_field_for_wp_job_manager:*:*:*:*:*:wordpress:*:*" ], + "custom-field-manager": [ + "cpe:2.3:a:f1logic:custom_field_manager:*:*:*:*:*:wordpress:*:*" + ], "custom-field-suite": [ "cpe:2.3:a:custom_field_suite_project:custom_field_suite:*:*:*:*:*:wordpress:*:*" ], @@ -11032,6 +11545,9 @@ "cysteme-finder": [ "cpe:2.3:a:cysteme:cysteme-finder:*:*:*:*:*:wordpress:*:*" ], + "cz-loan-management": [ + "cpe:2.3:a:contrive:cz_loan_management:*:*:*:*:*:wordpress:*:*" + ], "daext-autolinks-manager": [ "cpe:2.3:a:daext:autolinks_manager:*:*:*:*:*:wordpress:*:*" ], @@ -11078,6 +11594,9 @@ "database-peek": [ "cpe:2.3:a:database_peek_project:database_peek:*:*:*:*:*:wordpress:*:*" ], + "db-access": [ + "cpe:2.3:a:jimbob1953:db-access:*:*:*:*:*:wordpress:*:*" + ], "dc-woocommerce-multi-vendor": [ "cpe:2.3:a:multivendorx:multivendorx:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:wc-marketplace:multivendor_marketplace_solution_for_woocommerce_-_wc_marketplace:*:*:*:*:*:wordpress:*:*" @@ -11203,6 +11722,9 @@ "directorypress-frontend": [ "cpe:2.3:a:designinvento:directorypress:*:*:*:*:*:wordpress:*:*" ], + "disable-auto-updates": [ + "cpe:2.3:a:exeebit:disable_auto_updates:*:*:*:*:*:wordpress:*:*" + ], "disable-comments": [ "cpe:2.3:a:disable_comments:disable_comments_project:*:*:*:*:*:wordpress:*:*" ], @@ -11242,6 +11764,12 @@ "dk-pricr-responsive-pricing-table": [ "cpe:2.3:a:wpdarko:responsive_pricing_table:*:*:*:*:*:wordpress:*:*" ], + "dl-robotstxt": [ + "cpe:2.3:a:dyadyalesha:dl_robots.txt:*:*:*:*:*:wordpress:*:*" + ], + "dl-verification": [ + "cpe:2.3:a:dyadyalesha:dl_verification:*:*:*:*:*:wordpress:*:*" + ], "dmsguestbook": [ "cpe:2.3:a:dmsguestbook_project:dmsguestbook:*:*:*:*:*:wordpress:*:*" ], @@ -11293,6 +11821,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:*:*" ], @@ -11380,6 +11911,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:*:*" ], @@ -11622,6 +12156,9 @@ "cpe:2.3:a:flowdee:easyazon:*:*:*:*:*:*:*:*", "cpe:2.3:a:getaawp:easyazon:*:*:*:*:*:wordpress:*:*" ], + "easyevent": [ + "cpe:2.3:a:dwalliance:easyevent:*:*:*:*:*:wordpress:*:*" + ], "easyjobs": [ "cpe:2.3:a:easy.jobs:easy.jobs:*:*:*:*:*:wordpress:*:*" ], @@ -11768,9 +12305,12 @@ "cpe:2.3:a:mandsconsulting:email_before_download:*:*:*:*:*:wordpress:*:*" ], "email-encoder-bundle": [ - "cpe:2.3:a:jannisthuemmig:email_encoder:*:*:*:*:*:wordpress:*:*", + "cpe:2.3:a:onlineoptimisation:email_encoder:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:wp-webhooks:email_encoder:*:*:*:*:*:wordpress:*:*" ], + "email-keep": [ + "cpe:2.3:a:intricateweb:email_keep:*:*:*:*:*:wordpress:*:*" + ], "email-log": [ "cpe:2.3:a:email_log_project:email_log:*:*:*:*:*:wordpress:*:*" ], @@ -12362,6 +12902,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:*:*" @@ -12394,6 +12937,9 @@ "cpe:2.3:a:octolize:woocommerce_ups_shipping:*:*:*:*:free:wordpress:*:*", "cpe:2.3:a:octolize:woocommerce_ups_shipping:*:*:*:*:pro:wordpress:*:*" ], + "flickr-gallery": [ + "cpe:2.3:a:dancoulter:flickr_gallery:*:*:*:*:*:wordpress:*:*" + ], "flickr-justified-gallery": [ "cpe:2.3:a:flickr_justified_gallery_project:flickr_justified_gallery:*:*:*:*:*:wordpress:*:*" ], @@ -12540,6 +13086,9 @@ "formality": [ "cpe:2.3:a:giorgi:formality:*:*:*:*:*:wordpress:*:*" ], + "formassembly-web-forms": [ + "cpe:2.3:a:formassembly:wp-formassembly:*:*:*:*:*:wordpress:*:*" + ], "formbuilder": [ "cpe:2.3:a:formbuilder_project:formbuilder:*:*:*:*:*:wordpress:*:*" ], @@ -12601,6 +13150,9 @@ "fotobook": [ "cpe:2.3:a:fotobook_project:fotobook:*:*:*:*:*:wordpress:*:*" ], + "foxypress": [ + "cpe:2.3:a:webmovementllc:foxypress:*:*:*:*:*:wordpress:*:*" + ], "foxyshop": [ "cpe:2.3:a:foxy:foxyshop:*:*:*:*:*:wordpress:*:*" ], @@ -12677,6 +13229,9 @@ "furikake": [ "cpe:2.3:a:furikake_project:furikake:*:*:*:*:*:wordpress:*:*" ], + "fuse-social-floating-sidebar": [ + "cpe:2.3:a:daniyalahmedk:fuse_social_floating_sidebar:*:*:*:*:*:wordpress:*:*" + ], "fusedesk": [ "cpe:2.3:a:jeremyshapiro:fusedesk:*:*:*:*:*:wordpress:*:*" ], @@ -12741,6 +13296,9 @@ "game-server-status": [ "cpe:2.3:a:game-server-status_project:game-server-status:*:*:*:*:*:wordpress:*:*" ], + "game-users-share-buttons": [ + "cpe:2.3:a:gameusers:game_users_share_button:*:*:*:*:*:wordpress:*:*" + ], "gamepress": [ "cpe:2.3:a:gamepress_project:gamepress:*:*:*:*:*:wordpress:*:*" ], @@ -12750,6 +13308,9 @@ "gamipress-link": [ "cpe:2.3:a:gamipress:gamipress_-_link:*:*:*:*:*:wordpress:*:*" ], + "gamipress-reset-user": [ + "cpe:2.3:a:gamipress:gamipress_-_reset_user:*:*:*:*:*:wordpress:*:*" + ], "garden-gnome-package": [ "cpe:2.3:a:ggnome:garden_gnome_package:*:*:*:*:*:wordpress:*:*" ], @@ -12831,6 +13392,12 @@ "gerryworks-post-by-mail": [ "cpe:2.3:a:gerryntabuhashe:gerryworks_post_by_mail:*:*:*:*:*:wordpress:*:*" ], + "gestpay-for-woocommerce": [ + "cpe:2.3:a:fabrick:gestpay_for_woocommerce:*:*:*:*:*:wordpress:*:*" + ], + "get-bookings-wp": [ + "cpe:2.3:a:istmoplugins:get_bookings_wp:*:*:*:*:*:wordpress:*:*" + ], "get-custom-field-values": [ "cpe:2.3:a:get_custom_field_values_project:get_custom_field_values:*:*:*:*:*:wordpress:*:*" ], @@ -12858,9 +13425,24 @@ "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:*:*" ], + "gf-hubspot": [ + "cpe:2.3:a:crmperks:wp_gravity_forms_hubspot:*:*:*:*:*:wordpress:*:*" + ], + "gf-insightly": [ + "cpe:2.3:a:crmperks:wp_gravity_forms_insightly:*:*:*:*:*:wordpress:*:*" + ], + "gf-salesforce-crmperks": [ + "cpe:2.3:a:crmperks:wp_gravity_forms_salesforce:*:*:*:*:*:wordpress:*:*" + ], + "gf-zoho": [ + "cpe:2.3:a:crmperks:wp_gravity_forms_zoho_crm_and_bigin:*:*:*:*:*:wordpress:*:*" + ], "gg-woo-feed": [ "cpe:2.3:a:gutengeek:gg_woo_feed:*:*:*:*:*:wordpress:*:*" ], @@ -13290,6 +13872,9 @@ "heureka": [ "cpe:2.3:a:heureka:heureka:*:*:*:*:*:wordpress:*:*" ], + "hide-links": [ + "cpe:2.3:a:avovkdesign:hide_links:*:*:*:*:*:wordpress:*:*" + ], "hide-my-wp": [ "cpe:2.3:a:wpplugins:hide_my_wp_ghost:*:*:*:*:*:wordpress:*:*" ], @@ -13314,6 +13899,9 @@ "hkdev-maintenance-mode": [ "cpe:2.3:a:helderk:maintenance_mode:*:*:*:*:*:wordpress:*:*" ], + "hmapsprem": [ + "cpe:2.3:a:heroplugins:hero_maps_premium:*:*:*:*:*:wordpress:*:*" + ], "hms-testimonials": [ "cpe:2.3:a:hitmyserver:hms_testimonials:*:*:*:*:*:wordpress:*:*" ], @@ -13346,7 +13934,8 @@ "cpe:2.3:a:kibokolabs:hostel:*:*:*:*:*:wordpress:*:*" ], "hostinger": [ - "cpe:2.3:a:hostinger:hostinger:*:*:*:*:*:wordpress:*:*" + "cpe:2.3:a:hostinger:hostinger:*:*:*:*:*:wordpress:*:*", + "cpe:2.3:a:hostinger:hostinger_tools:*:*:*:*:*:wordpress:*:*" ], "hot-linked-image-cacher": [ "cpe:2.3:a:hot_linked_image_cacher_project:hot_linked_image_cacher:*:*:*:*:*:wordpress:*:*" @@ -13462,6 +14051,9 @@ "ibtana-visual-editor": [ "cpe:2.3:a:vowelweb:ibtana:*:*:*:*:*:wordpress:*:*" ], + "ibuildapp": [ + "cpe:2.3:a:ibuildapp:ibuildapp:*:*:*:*:*:wordpress:*:*" + ], "icegram": [ "cpe:2.3:a:icegram:icegram_engage:*:*:*:*:*:wordpress:*:*" ], @@ -13685,6 +14277,9 @@ "cpe:2.3:a:infusionsoft_project:infusionsoft:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:katz:infusionsoft_gravity_forms:*:*:*:*:*:wordpress:*:*" ], + "infusionsoft-official-opt-in-forms": [ + "cpe:2.3:a:keap:keap_official_opt_in_forms:*:*:*:*:*:wordpress:*:*" + ], "inline-google-spreadsheet-viewer": [ "cpe:2.3:a:inline_google_spreadsheet_viewer_project:inline_google_spreadsheet_viewer:*:*:*:*:*:wordpress:*:*" ], @@ -13706,6 +14301,9 @@ "inpost-gallery": [ "cpe:2.3:a:pluginus:inpost_gallery:*:*:*:*:*:wordpress:*:*" ], + "inprosysmedia-likes-dislikes-post": [ + "cpe:2.3:a:erumfaham:likes_and_dislikes:*:*:*:*:*:wordpress:*:*" + ], "insert-estimated-reading-time": [ "cpe:2.3:a:nigauri:insert_estimated_reading_time:*:*:*:*:*:wordpress:*:*" ], @@ -13782,6 +14380,9 @@ "intelly-related-posts": [ "cpe:2.3:a:data443:inline_related_posts:*:*:*:*:*:wordpress:*:*" ], + "interact-quiz-embed": [ + "cpe:2.3:a:tryinteract:embed_a_quiz_on_your_site:*:*:*:*:*:wordpress:*:*" + ], "interactive-3d-flipbook-powered-physics-engine": [ "cpe:2.3:a:3dflipbook:3d_flipbook:*:*:*:*:*:wordpress:*:*" ], @@ -14018,6 +14619,9 @@ "jvm-rich-text-icons": [ "cpe:2.3:a:jorisvm:jvm_gutenberg_rich_text_icons:*:*:*:*:*:wordpress:*:*" ], + "jwp-a11y": [ + "cpe:2.3:a:jidaikobo:jwp-a11y:*:*:*:*:*:wordpress:*:*" + ], "kadence-blocks": [ "cpe:2.3:a:kadencewp:gutenberg_blocks_with_ai:*:*:*:*:*:wordpress:*:*" ], @@ -14507,6 +15111,9 @@ "logo-slider-wp": [ "cpe:2.3:a:logichunt:logo_slider:*:*:*:*:*:wordpress:*:*" ], + "logs-de-connexion": [ + "cpe:2.3:a:floriansimunek:connexion_logs:*:*:*:*:*:wordpress:*:*" + ], "logwpmail": [ "cpe:2.3:a:premierethemes:log_wp_mail:*:*:*:*:*:wordpress:*:*" ], @@ -14579,6 +15186,9 @@ "cpe:2.3:a:custom_popup_builder_project:custom_popup_builder:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:popup_\\|_custom_popup_builder_project:popup_\\|_custom_popup_builder:*:*:*:*:*:wordpress:*:*" ], + "magayo-lottery-results": [ + "cpe:2.3:a:magayo:magayo_lottery_results:*:*:*:*:*:wordpress:*:*" + ], "magazine-blocks": [ "cpe:2.3:a:themegrill:magazine_blocks:*:*:*:*:*:wordpress:*:*" ], @@ -14597,6 +15207,9 @@ "magical-addons-for-elementor": [ "cpe:2.3:a:wpthemespace:magical_addons_for_elementor:*:*:*:*:*:wordpress:*:*" ], + "magicform": [ + "cpe:2.3:a:dcooperman:magicform:*:*:*:*:*:wordpress:*:*" + ], "mail-control": [ "cpe:2.3:a:instareza:mail_control:*:*:*:*:*:wordpress:*:*" ], @@ -14664,6 +15277,7 @@ "cpe:2.3:a:mainwp:mainwp_child:*:*:*:*:*:wordpress:*:*" ], "mainwp-child-reports": [ + "cpe:2.3:a:mainwp:mainwp_child:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:mainwp:mainwp_child_reports:*:*:*:*:*:wordpress:*:*" ], "majestic-support": [ @@ -14700,6 +15314,9 @@ "manual-image-crop": [ "cpe:2.3:a:manual_image_crop_project:manual_image_crop:*:*:*:*:*:wordpress:*:*" ], + "map-addons-for-elementor-waze-map": [ + "cpe:2.3:a:nervythemes:local_business_addons_for_elementor:*:*:*:*:*:wordpress:*:*" + ], "mapfig-studio": [ "cpe:2.3:a:acugis:mapfig_studio:*:*:*:*:*:wordpress:*:*" ], @@ -14771,11 +15388,15 @@ "cpe:2.3:a:master-addons:master_addons:*:*:*:*:*:wordpress:*:*" ], "master-slider": [ - "cpe:2.3:a:averta:master_slider:*:*:*:*:*:wordpress:*:*" + "cpe:2.3:a:averta:master_slider:*:*:*:*:*:wordpress:*:*", + "cpe:2.3:a:averta:master_slider:*:*:*:*:free:wordpress:*:*" ], "masterstudy-lms-learning-management-system": [ "cpe:2.3:a:stylemixthemes:masterstudy_lms:*:*:*:*:*:wordpress:*:*" ], + "masy-gallery": [ + "cpe:2.3:a:dineshrawat:masy_gallery:*:*:*:*:*:wordpress:*:*" + ], "material-design-for-contact-form-7": [ "cpe:2.3:a:material_design_for_contact_form_7_project:material_design_for_contact_form_7:*:*:*:*:*:wordpress:*:*" ], @@ -14788,6 +15409,9 @@ "materialis-companion": [ "cpe:2.3:a:extendthemes:materialis_companion:*:*:*:*:*:wordpress:*:*" ], + "matomo": [ + "cpe:2.3:a:matomo:matomo:*:*:*:*:*:wordpress:*:*" + ], "matrix-pre-loader": [ "cpe:2.3:a:nkb-bd:preloader_matrix:*:*:*:*:*:wordpress:*:*" ], @@ -14806,6 +15430,9 @@ "mdc-youtube-downloader": [ "cpe:2.3:a:mdc_youtube_downloader_project:mdc_youtube_downloader:*:*:*:*:*:wordpress:*:*" ], + "media-alt-renamer": [ + "cpe:2.3:a:jordymeow:media_alt_renamer:*:*:*:*:*:wordpress:*:*" + ], "media-downloader": [ "cpe:2.3:a:media_downloader_project:media_downloader:*:*:*:*:*:wordpress:*:*" ], @@ -14994,6 +15621,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:*:*" ], @@ -15027,6 +15657,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:*:*" ], @@ -15113,6 +15746,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:*:*" ], @@ -15129,7 +15765,7 @@ "cpe:2.3:a:motor_racing_league_project:motor_racing_league:*:*:*:*:*:wordpress:*:*" ], "motors-car-dealership-classified-listings": [ - "cpe:2.3:a:stylemixthemes:motors_-_car_dealer\\,_classifieds_\\\u0026_listing:*:*:*:*:*:wordpress:*:*" + "cpe:2.3:a:stylemixthemes:motors_-_car_dealer\\,_classifieds_\\\u0026_listing:*:*:*:*:starter:wordpress:*:*" ], "mousewheel-smooth-scroll": [ "cpe:2.3:a:mousewheel_smooth_scroll_project:mousewheel_smooth_scroll:*:*:*:*:*:wordpress:*:*" @@ -15137,6 +15773,9 @@ "move-addons": [ "cpe:2.3:a:moveaddons:move_addons_for_elementor:*:*:*:*:*:wordpress:*:*" ], + "movie-database": [ + "cpe:2.3:a:heimkino-praxis:movie_database:*:*:*:*:*:wordpress:*:*" + ], "movies": [ "cpe:2.3:a:movies_project:movies:*:*:*:*:*:wordpress:*:*" ], @@ -15271,6 +15910,9 @@ "my-geo-posts-free": [ "cpe:2.3:a:mindstien:my_geo_posts_free:*:*:*:*:*:wordpress:*:*" ], + "my-quota": [ + "cpe:2.3:a:dropstr:my_quota:*:*:*:*:*:wordpress:*:*" + ], "my-tickets": [ "cpe:2.3:a:my_tickets_project:my_tickets:*:*:*:*:*:wordpress:*:*" ], @@ -15603,6 +16245,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:*:*" ], @@ -15829,6 +16474,7 @@ "cpe:2.3:a:benhuson:page_parts:*:*:*:*:*:wordpress:*:*" ], "page-scroll-to-id": [ + "cpe:2.3:a:malihu:page_scroll_to_id:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:page_scroll_to_id_project:page_scroll_to_id:*:*:*:*:*:wordpress:*:*" ], "page-views-count": [ @@ -16013,6 +16659,9 @@ "personalize-woocommerce-cart-page": [ "cpe:2.3:a:najeebmedia:personalized_woocommerce_cart_page:*:*:*:*:*:wordpress:*:*" ], + "pet-manager": [ + "cpe:2.3:a:dianakcury:pet_manager:*:*:*:*:*:wordpress:*:*" + ], "peters-collaboration-e-mails": [ "cpe:2.3:a:peter\\'s_collaboration_e-mails_project:peter\\'s_collaboration_e-mails:*:*:*:*:*:wordpress:*:*" ], @@ -16090,6 +16739,9 @@ "phpfreechat": [ "cpe:2.3:a:frumph:phpfreechat:*:*:*:*:*:wordpress:*:*" ], + "phpinfo-wp": [ + "cpe:2.3:a:exeebit:phpinfo-wp:*:*:*:*:free:wordpress:*:*" + ], "pi-woocommerce-order-date-time-and-type": [ "cpe:2.3:a:piwebsolution:pi-woocommerce-order-date-time-and-type:*:*:*:*:*:wordpress:*:*" ], @@ -16142,6 +16794,9 @@ "plexx-elementor-extension": [ "cpe:2.3:a:themeworm:plexx_elementor_extension:*:*:*:*:*:wordpress:*:*" ], + "plugin-groups": [ + "cpe:2.3:a:davidcramer:plugin_groups:*:*:*:*:*:wordpress:*:*" + ], "plugin-logic": [ "cpe:2.3:a:plugin_logic_project:plugin_logic:*:*:*:*:*:wordpress:*:*" ], @@ -16253,6 +16908,9 @@ "popup-zyrex": [ "cpe:2.3:a:zyrex:popup:*:*:*:*:*:wordpress:*:*" ], + "popup4phone": [ + "cpe:2.3:a:ivanweb:popup4phone:*:*:*:*:*:wordpress:*:*" + ], "popupally": [ "cpe:2.3:a:accessally:popupally:*:*:*:*:*:wordpress:*:*" ], @@ -16266,6 +16924,9 @@ "portfolio": [ "cpe:2.3:a:bestwebsoft:portfolio:*:*:*:*:*:wordpress:*:*" ], + "portfolio-and-projects": [ + "cpe:2.3:a:essentialplugin:portfolio_and_projects:*:*:*:*:*:wordpress:*:*" + ], "portfolio-by-lisa-westlund": [ "cpe:2.3:a:portfolio_project:portfolio:*:*:*:*:*:wordpress:*:*" ], @@ -16753,7 +17414,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:*:*", @@ -16940,9 +17604,13 @@ "cpe:2.3:a:inisev:redirection:*:*:*:*:*:wordpress:*:*" ], "redirection": [ + "cpe:2.3:a:johngodley:redirection:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:redirection:redirection:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:redirection_project:redirection:*:*:*:*:*:wordpress:*:*" ], + "redirects": [ + "cpe:2.3:a:declaire:redirects:*:*:*:*:*:wordpress:*:*" + ], "redux-framework": [ "cpe:2.3:a:redux:gutenberg_template_library_\\\u0026_redux_framework:*:*:*:*:*:wordpress:*:*" ], @@ -17050,6 +17718,12 @@ "responsive-css-editor": [ "cpe:2.3:a:wpwox:responsive_css_editor:*:*:*:*:*:wordpress:*:*" ], + "responsive-filterable-portfolio": [ + "cpe:2.3:a:i13websolution:responsive_filterable_portfolio:*:*:*:*:*:wordpress:*:*" + ], + "responsive-food-and-drink-menu": [ + "cpe:2.3:a:corporatezen:responsive_food_and_drink_menu:*:*:*:*:*:wordpress:*:*" + ], "responsive-gallery-grid": [ "cpe:2.3:a:bdwm:responsive_gallery_grid:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:responsive_gallery_grid_project:responsive_gallery_grid:*:*:*:*:*:wordpress:*:*" @@ -17076,6 +17750,12 @@ "responsive-vector-maps": [ "cpe:2.3:a:thinkupthemes:responsive_vector_maps:*:*:*:*:*:wordpress:*:*" ], + "responsive-video-embed": [ + "cpe:2.3:a:lepileppanen:responsive_video_embed:*:*:*:*:*:wordpress:*:*" + ], + "rest-api-to-miniprogram": [ + "cpe:2.3:a:jianbo:rest_api_to_miniprogram:*:*:*:*:*:wordpress:*:*" + ], "restaurant-cafe-addon-for-elementor": [ "cpe:2.3:a:nicheaddons:restaurant_\\\u0026_cafe_addon_for_elementor:*:*:*:*:*:wordpress:*:*" ], @@ -17261,6 +17941,9 @@ "safe-svg": [ "cpe:2.3:a:10up:safe_svg:*:*:*:*:*:wordpress:*:*" ], + "safetymails-forms": [ + "cpe:2.3:a:henriquerodrigues:safetyforms:*:*:*:*:*:wordpress:*:*" + ], "sagepay-server-gateway-for-woocommerce": [ "cpe:2.3:a:patsatech:sagepay_server_gateway_for_woocommerce:*:*:*:*:*:wordpress:*:*" ], @@ -17313,6 +17996,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:*:*" ], @@ -17531,6 +18217,7 @@ "cpe:2.3:a:fabiantodt:private_post_share:*:*:*:*:*:wordpress:*:*" ], "share-buttons": [ + "cpe:2.3:a:artlosk:share_buttons:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:artlosk:social_share_buttons:*:*:*:*:*:wordpress:*:*" ], "share-on-diaspora": [ @@ -17690,9 +18377,15 @@ "simple-al-slider": [ "cpe:2.3:a:alexdtn:simple_al_slider:*:*:*:*:*:wordpress:*:*" ], + "simple-amazon-affiliate": [ + "cpe:2.3:a:duogeek:simple_amazon_affiliate:*:*:*:*:*:wordpress:*:*" + ], "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:*:*" ], @@ -17748,6 +18441,9 @@ "simple-download-monitor": [ "cpe:2.3:a:tipsandtricks-hq:simple_download_monitor:*:*:*:*:*:wordpress:*:*" ], + "simple-downloads-list": [ + "cpe:2.3:a:neofix:simple_downloads_list:*:*:*:*:*:wordpress:*:*" + ], "simple-e-commerce-shopping-cart": [ "cpe:2.3:a:simple-e-commerce-shopping-cart_project:simple-e-commerce-shopping-cart:*:*:*:*:*:wordpress:*:*" ], @@ -17896,6 +18592,9 @@ "simple-side-tab": [ "cpe:2.3:a:rumspeed:simple_side_tab:*:*:*:*:*:wordpress:*:*" ], + "simple-signup-form": [ + "cpe:2.3:a:modalsurvey:simple_signup_form:*:*:*:*:*:wordpress:*:*" + ], "simple-site-verify": [ "cpe:2.3:a:idoweb:simple_site_verify:*:*:*:*:*:wordpress:*:*" ], @@ -18100,6 +18799,9 @@ "slide-anything": [ "cpe:2.3:a:simonpedge:slide_anything:*:*:*:*:*:wordpress:*:*" ], + "slidedeck-lite-for-wordpress": [ + "cpe:2.3:a:dtelepathy:slidedeck_1_lite_content_slider:*:*:*:*:*:wordpress:*:*" + ], "slidedeck2": [ "cpe:2.3:a:hbwsl:slidedeck_2:*:*:*:*:lite:wordpress:*:*" ], @@ -18141,6 +18843,9 @@ "slideshow-se": [ "cpe:2.3:a:slideshow_se_project:slideshow_se:*:*:*:*:*:wordpress:*:*" ], + "slingblocks": [ + "cpe:2.3:a:funnelkit:slingblocks:*:*:*:*:*:wordpress:*:*" + ], "small-package-quotes-fedex-edition": [ "cpe:2.3:a:eniture:small_package_quotes:*:*:*:*:fedex:wordpress:*:*" ], @@ -18169,6 +18874,9 @@ "smart-cookie-kit": [ "cpe:2.3:a:nicolamodugno:smart_cookie_kit:*:*:*:*:*:wordpress:*:*" ], + "smart-docs": [ + "cpe:2.3:a:archalj:smart_docs:*:*:*:*:*:wordpress:*:*" + ], "smart-donations": [ "cpe:2.3:a:rednao:donations_made_easy_-_smart_donations:*:*:*:*:*:wordpress:*:*" ], @@ -18430,6 +19138,9 @@ "sportspress": [ "cpe:2.3:a:themeboy:sportspress:*:*:*:*:*:wordpress:*:*" ], + "spotbot": [ + "cpe:2.3:a:jakehelbig:spotbot:*:*:*:*:*:wordpress:*:*" + ], "spotify-play-button-for-wordpress": [ "cpe:2.3:a:followmedarling:spotify-play-button-for-wordpress:*:*:*:*:*:wordpress:*:*" ], @@ -18460,6 +19171,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:*:*" ], @@ -18488,6 +19202,9 @@ "stars-rating": [ "cpe:2.3:a:stars_rating_project:stars_rating:*:*:*:*:*:wordpress:*:*" ], + "starter-templates": [ + "cpe:2.3:a:fancywp:starter_templates:*:*:*:*:*:wordpress:*:*" + ], "startklar-elmentor-forms-extwidgets": [ "cpe:2.3:a:web-shop-host:startklar_elmentor_addons:*:*:*:*:*:wordpress:*:*" ], @@ -18571,6 +19288,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:*:*" ], @@ -18601,6 +19321,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:*:*" ], @@ -19037,12 +19760,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:*:*" ], @@ -19111,6 +19840,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:*:*" ], @@ -19237,6 +19969,9 @@ "twitter-cards-meta": [ "cpe:2.3:a:wpdeveloper:twitter_cards_meta:*:*:*:*:*:wordpress:*:*" ], + "twitter-follow": [ + "cpe:2.3:a:firecask:twitter_follow_button:*:*:*:*:*:wordpress:*:*" + ], "twitter-friends-widget": [ "cpe:2.3:a:twitter_friends_widget_project:twitter_friends_widget:*:*:*:*:*:wordpress:*:*" ], @@ -19476,6 +20211,9 @@ "upunzipper": [ "cpe:2.3:a:upunzipper_project:upunzipper:*:*:*:*:*:wordpress:*:*" ], + "url-media-uploader": [ + "cpe:2.3:a:apprhyme:url_media_uploader:*:*:*:*:*:wordpress:*:*" + ], "url-shortify": [ "cpe:2.3:a:kaizencoders:url_shortify:*:*:*:*:*:wordpress:*:*" ], @@ -19523,6 +20261,9 @@ "user-login-log": [ "cpe:2.3:a:intechnosoftware:user_login_log:*:*:*:*:*:wordpress:*:*" ], + "user-messages": [ + "cpe:2.3:a:marvinlabs:user_messages:*:*:*:*:*:wordpress:*:*" + ], "user-meta": [ "cpe:2.3:a:user-meta:user_meta_user_profile_builder_and_user_management:*:*:*:*:*:wordpress:*:*" ], @@ -19546,6 +20287,9 @@ "user-role": [ "cpe:2.3:a:bestwebsoft:user_role:*:*:*:*:*:wordpress:*:*" ], + "user-shortcodes-plus": [ + "cpe:2.3:a:kylebjohnson:user_shortcodes_plus:*:*:*:*:*:wordpress:*:*" + ], "user-spam-remover": [ "cpe:2.3:a:joelhardi:user_spam_remover:*:*:*:*:*:wordpress:*:*" ], @@ -19854,6 +20598,9 @@ "wds-multisite-aggregate": [ "cpe:2.3:a:webdevstudios:wds_multisite_aggregate:*:*:*:*:*:wordpress:*:*" ], + "we-testimonial-slider": [ + "cpe:2.3:a:wordpresteem:we_-_testimonial_slide:*:*:*:*:*:wordpress:*:*" + ], "weather-effect": [ "cpe:2.3:a:awplife:weather_effect:*:*:*:*:*:wordpress:*:*" ], @@ -19863,6 +20610,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:*:*" ], @@ -20155,6 +20905,9 @@ "woo-gutenberg-products-block": [ "cpe:2.3:a:automattic:woocommerce_blocks:*:*:*:*:*:wordpress:*:*" ], + "woo-mailerlite": [ + "cpe:2.3:a:mailerlite:mailerlite:*:*:*:*:*:wordpress:*:*" + ], "woo-moneybird": [ "cpe:2.3:a:techastha:integration_of_moneybird_for_woocommerce:*:*:*:*:*:wordpress:*:*" ], @@ -20209,6 +20962,9 @@ "woo-related-products-refresh-on-reload": [ "cpe:2.3:a:peachpay:related_products_for_woocommerce:*:*:*:*:*:wordpress:*:*" ], + "woo-rfq-for-woocommerce": [ + "cpe:2.3:a:neahplugins:np_quote_request_for_woocommerce:*:*:*:*:*:wordpress:*:*" + ], "woo-save-abandoned-carts": [ "cpe:2.3:a:majas-lapu-izstrade:cartbounty:*:*:*:*:*:wordpress:*:*" ], @@ -20389,6 +21145,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:*:*" ], @@ -20504,9 +21263,15 @@ "wordpress-users": [ "cpe:2.3:a:jonathonkemp:wordpress_users:*:*:*:*:*:wordpress:*:*" ], + "work-the-flow-file-upload": [ + "cpe:2.3:a:lyntonreed:work_the_flow_file_upload:*:*:*:*:*:wordpress:*:*" + ], "workreap": [ "cpe:2.3:a:amentotech:workreap:*:*:*:*:*:wordpress:*:*" ], + "wow-carousel-for-divi-lite": [ + "cpe:2.3:a:elegantthemes:divi_carousel:*:*:*:*:free:wordpress:*:*" + ], "wow-moodboard-lite": [ "cpe:2.3:a:wownmedia:wow_moodboard:*:*:*:*:lite:wordpress:*:*" ], @@ -20718,6 +21483,7 @@ "cpe:2.3:a:wow-company:wp_coder:*:*:*:*:*:wordpress:*:*" ], "wp-comment-fields": [ + "cpe:2.3:a:najeebmedia:comments_extra_fields_for_post\\,_pages_and_cpt:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:najeebmedia:wordpress_comments_fields:*:*:*:*:*:wordpress:*:*" ], "wp-commentnavi": [ @@ -20732,6 +21498,9 @@ "wp-consultant": [ "cpe:2.3:a:wp_consultant_project:wp_consultant:*:*:-:*:-:wordpress:*:*" ], + "wp-contact-form": [ + "cpe:2.3:a:ftwr:wp_contact_form:*:*:*:*:*:wordpress:*:*" + ], "wp-contact-slider": [ "cpe:2.3:a:wpexperts:wp_contact_slider:*:*:*:*:*:wordpress:*:*" ], @@ -20764,6 +21533,9 @@ "wp-crm": [ "cpe:2.3:a:usabilitydynamics:wp-crm:*:*:*:*:*:wordpress:*:*" ], + "wp-crm-system": [ + "cpe:2.3:a:wp-crm:wp-crm_system:*:*:*:*:*:wordpress:*:*" + ], "wp-crowdfunding": [ "cpe:2.3:a:themeum:wp_crowdfunding:*:*:*:*:*:wordpress:*:*" ], @@ -20879,6 +21651,9 @@ "wp-easy-contact": [ "cpe:2.3:a:emarketdesign:wp_easy_contact:*:*:*:*:*:wordpress:*:*" ], + "wp-easy-events": [ + "cpe:2.3:a:emarketdesign:event_rsvp_and_simple_event_management:*:*:*:*:*:wordpress:*:*" + ], "wp-easy-gallery": [ "cpe:2.3:a:plugingarden:wp_easy_gallery:*:*:*:*:*:wordpress:*:*" ], @@ -21024,6 +21799,9 @@ "wp-fountain": [ "cpe:2.3:a:wp_fountain_project:wp_fountain:*:*:*:*:*:wordpress:*:*" ], + "wp-front-end-login-and-register": [ + "cpe:2.3:a:hellomohsinkhan:wp_front-end_login_and_register:*:*:*:*:*:wordpress:*:*" + ], "wp-front-end-profile": [ "cpe:2.3:a:wp_front_end_profile_project:wp_front_end_profile:*:*:*:*:*:wordpress:*:*" ], @@ -21184,6 +21962,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:*:*" ], @@ -21243,6 +22024,9 @@ "cpe:2.3:a:3cx:live_chat:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:3cx:wp-live_chat:*:*:*:*:*:wordpress:*:*" ], + "wp-login-control": [ + "cpe:2.3:a:forsyspress:wp_login_control:*:*:*:*:*:wordpress:*:*" + ], "wp-login-security-and-history": [ "cpe:2.3:a:clogica:wp_login_security_and_history:*:*:*:*:*:wordpress:*:*" ], @@ -21391,6 +22175,9 @@ "wp-photo-text-slider-50": [ "cpe:2.3:a:gopiplus:wp_photo_text_slider_50:*:*:*:*:*:wordpress:*:*" ], + "wp-photonav": [ + "cpe:2.3:a:fmos:wp-photonav:*:*:*:*:*:wordpress:*:*" + ], "wp-phpmyadmin-extension": [ "cpe:2.3:a:puvox:wp_phpmyadmin:*:*:*:*:*:wordpress:*:*" ], @@ -21470,6 +22257,9 @@ "wp-product-gallery-lite": [ "cpe:2.3:a:accesspressthemes:wp_product_gallery_lite:*:*:*:*:*:wordpress:*:*" ], + "wp-programmmanager": [ + "cpe:2.3:a:mantus667:wp-pmanager:*:*:*:*:*:wordpress:*:*" + ], "wp-propagator": [ "cpe:2.3:a:widgilabs:plugin_propagator:*:*:*:*:*:wordpress:*:*" ], @@ -21501,7 +22291,7 @@ "cpe:2.3:a:bootstrapped:wp_recipe_maker:*:*:*:*:free:wordpress:*:*" ], "wp-registration": [ - "cpe:2.3:a:najeebmedia:simple_user_registration:*:*:*:*:*:wordpress:*:*" + "cpe:2.3:a:najeebmedia:memberhero:*:*:*:*:*:wordpress:*:*" ], "wp-remote-site-search": [ "cpe:2.3:a:brainstormforce:wp_remote_site_search:*:*:*:*:*:wordpress:*:*" @@ -22038,6 +22828,9 @@ "wpextended": [ "cpe:2.3:a:wpextended:wp_extended:*:*:*:*:*:wordpress:*:*" ], + "wpfavicon": [ + "cpe:2.3:a:nihal:wpfavicon:*:*:*:*:*:wordpress:*:*" + ], "wpforms": [ "cpe:2.3:a:wpforms:wpforms:*:*:*:*:pro:wordpress:*:*" ], @@ -22138,6 +22931,9 @@ "wpschoolpress": [ "cpe:2.3:a:igexsolutions:wpschoolpress:*:*:*:*:*:wordpress:*:*" ], + "wpshop": [ + "cpe:2.3:a:eoxia:wpshop_2:*:*:*:*:*:wordpress:*:*" + ], "wpshopgermany-it-recht-kanzlei": [ "cpe:2.3:a:maennchen1:wpshopgermany_it-recht_kanzlei:*:*:*:*:*:wordpress:*:*" ], @@ -22145,7 +22941,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:*:*" @@ -22291,6 +23088,7 @@ "cpe:2.3:a:ydesignservices:yds_support_ticket_system:*:*:*:*:*:wordpress:*:*" ], "yellow-pencil-visual-theme-customizer": [ + "cpe:2.3:a:waspthemes:yellowpencil:*:*:*:*:*:wordpress:*:*", "cpe:2.3:a:yellowpencil:visual_css_style_editor:*:*:*:*:*:wordpress:*:*" ], "yellow-yard": [ @@ -22524,6 +23322,9 @@ "activello": [ "cpe:2.3:a:colorlib:activello:*:*:*:*:*:wordpress:*:*" ], + "adforest": [ + "cpe:2.3:a:scriptsbundle:adforest:*:*:*:*:*:wordpress:*:*" + ], "affluent": [ "cpe:2.3:a:cpothemes:affluent:*:*:*:*:*:wordpress:*:*" ], @@ -22672,6 +23473,9 @@ "empowerwp": [ "cpe:2.3:a:extendthemes:empowerwp:*:*:*:*:*:wordpress:*:*" ], + "enfold": [ + "cpe:2.3:a:kriesi:enfold:*:*:*:*:*:wordpress:*:*" + ], "enlighten": [ "cpe:2.3:a:accesspressthemes:enlighten:*:*:*:*:*:wordpress:*:*" ], @@ -22722,6 +23526,12 @@ "hello-agency": [ "cpe:2.3:a:cozythemes:hello_agency:*:*:*:*:*:wordpress:*:*" ], + "himalayas": [ + "cpe:2.3:a:themegrill:himalayas:*:*:*:*:*:wordpress:*:*" + ], + "homey": [ + "cpe:2.3:a:favethemes:homey:*:*:*:*:*:wordpress:*:*" + ], "hotel-galaxy": [ "cpe:2.3:a:webdzier:hotel_galaxy:*:*:*:*:*:wordpress:*:*" ], @@ -22913,6 +23723,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:*:*" ], diff --git a/syft/pkg/cataloger/java/archive_contents_cataloger.go b/syft/pkg/cataloger/java/archive_contents_cataloger.go new file mode 100644 index 000000000..71bff3533 --- /dev/null +++ b/syft/pkg/cataloger/java/archive_contents_cataloger.go @@ -0,0 +1,129 @@ +package java + +import ( + "context" + "fmt" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/unknown" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/java/internal/maven" +) + +const ArchiveContentsCatalogerName = "java-archive-contents-cataloger" + +// NewArchiveContentsCataloger returns a java archive cataloger for the world after extraction: it +// reads an archive's already-extracted filesystem rather than opening the archive itself. +// +// It is a second cataloger rather than a mode of the first because the two answer different +// questions. The archive-reading cataloger asks "is this file a jar?" of every file in a scan; this +// one asks "is the filesystem I have been handed a jar?", which is the only form the question can +// take once something else owns extraction. That inversion is also why it cannot be a parameter: +// when the archive cataloger task hands a jar's contents to the sub-pipeline, the jar file is one +// level up and already behind us. +// +// What it does not do is recurse. The task that extracted this archive will meet the archives inside +// it on its own, and one mechanism owning recursion is the whole point - see +// syft/java-archive-nesting#single-owner-of-archive-recursion. +func NewArchiveContentsCataloger(cfg ArchiveCatalogerConfig) pkg.Cataloger { + return archiveContentsCataloger{cfg: cfg} +} + +type archiveContentsCataloger struct { + cfg ArchiveCatalogerConfig +} + +func (c archiveContentsCataloger) Name() string { + return ArchiveContentsCatalogerName +} + +// Catalog reports the java package of the archive whose contents this resolver holds, if it is a java +// archive at all. +// +// Nothing outside an archive is cataloged. Without a traversal on the context there is no archive to +// describe - the identity of a java archive lives half in the file (its name, its digests) and half +// in its contents (the manifest, the poms), and the file half arrives only on the traversal. +func (c archiveContentsCataloger) Catalog(ctx context.Context, resolver file.Resolver) ([]pkg.Package, []artifact.Relationship, error) { + trav := archive.TraversalFromContext(ctx) + if trav == nil { + return nil, nil, nil + } + + if !isJavaArchiveName(trav.Location.Path()) { + // the same rule the archive-reading cataloger applies through its globs: a .zip is an archive + // but it is not a java archive, and its contents are not a jar's contents + return nil, nil, nil + } + + parser, err := newArchiveContentsParser(trav, resolver, c.cfg) + if err != nil { + return nil, nil, err + } + + pkgs, relationships, err := parser.parse(ctx, nil) + if err != nil { + // attributed to the archive rather than returned bare, the way the generic cataloger attributes + // a parser's failure to the file it was parsing. An archive that turns out to hold no java + // package at all reports exactly that, and the scan carries on. + return pkgs, relationships, unknown.New(trav.Location, err) + } + return pkgs, relationships, nil +} + +// newArchiveContentsParser builds the same parser the archive-reading cataloger uses, over the +// archive's extracted filesystem instead of over the archive file. Identity comes from the traversal: +// the location and virtual path it already carries, the archive's filename for the name and version +// fallbacks, and the digests taken when the archive was extracted. +func newArchiveContentsParser(trav *archive.Traversal, resolver file.Resolver, cfg ArchiveCatalogerConfig) (*archiveParser, error) { + if resolver == nil { + return nil, fmt.Errorf("no resolver for the contents of %q", trav.VirtualPath) + } + return &archiveParser{ + entries: newResolverEntries(resolver, trav.Digests), + location: trav.Location, + // the colon-delimited chain the task composed, which is the identity already-published SBOMs + // carry for a nested java package + virtualPath: trav.VirtualPath, + fileInfo: newJavaArchiveFilename(archiveFileNameOf(trav)), + // recursion belongs to the task that extracted this archive + detectNested: false, + cfg: cfg, + maven: maven.NewResolver(nil, cfg.mavenConfig()), + }, nil +} + +// archiveFileNameOf returns the archive's own file name, which is where a jar's name and version come +// from when its manifest does not say. The last element of the virtual path rather than of the real +// path, so a chain and a plain scan agree. +func archiveFileNameOf(trav *archive.Traversal) string { + name := trav.VirtualPath + if name == "" { + name = trav.Location.Path() + } + for i := len(name) - 1; i >= 0; i-- { + if name[i] == ':' || name[i] == '/' { + return name[i+1:] + } + } + return name +} + +// isJavaArchiveName reports whether the path is one of the java archive formats, by the same glob +// list the archive-reading cataloger registers against. +func isJavaArchiveName(path string) bool { + for _, glob := range archiveFormatGlobs { + if ok, err := doublestar.Match(glob, path); err == nil && ok { + return true + } + if ok, err := doublestar.Match(glob, "/"+path); err == nil && ok { + return true + } + } + log.WithFields("path", path).Trace("archive contents are not a java archive") + return false +} diff --git a/syft/pkg/cataloger/java/archive_contents_cataloger_test.go b/syft/pkg/cataloger/java/archive_contents_cataloger_test.go new file mode 100644 index 000000000..1f10db93b --- /dev/null +++ b/syft/pkg/cataloger/java/archive_contents_cataloger_test.go @@ -0,0 +1,260 @@ +package java + +import ( + "archive/zip" + "bytes" + "context" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/internal/tmpdir" + "github.com/anchore/syft/internal/unknown" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/internal/fileresolver" + "github.com/anchore/syft/syft/pkg" +) + +// jarEntries is one java archive's worth of content, used both to build the archive the +// archive-reading cataloger parses and to lay out the filesystem the contents-reading one reads. +// The point of the pair of tests below is that these two must not disagree. +func jarEntries() map[string]string { + return map[string]string{ + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n" + + "Implementation-Title: example-lib\n" + + "Implementation-Version: 1.2.3\n", + "META-INF/maven/com.example/example-lib/pom.properties": "groupId=com.example\n" + + "artifactId=example-lib\nversion=1.2.3\n", + "META-INF/maven/com.example/example-lib/pom.xml": `4.0.0 +com.exampleexample-lib1.2.3 +Apache License 2.0https://www.apache.org/licenses/LICENSE-2.0 +`, + // a bundled dependency's maven metadata, which becomes a package of its own + "META-INF/maven/com.other/bundled-dep/pom.properties": "groupId=com.other\n" + + "artifactId=bundled-dep\nversion=4.5.6\n", + "com/example/Thing.class": "not really bytecode", + } +} + +func writeJar(t *testing.T, dir, name string, entries map[string]string) string { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + names := make([]string, 0, len(entries)) + for n := range entries { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + w, err := zw.Create(n) + require.NoError(t, err) + _, err = w.Write([]byte(entries[n])) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, buf.Bytes(), 0o600)) + return p +} + +// explode lays the same entries out as files, which is what the archive cataloger task hands the +// sub-pipeline once it has extracted an archive. +func explode(t *testing.T, dir string, entries map[string]string) { + t.Helper() + for name, contents := range entries { + p := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(contents), 0o600)) + } +} + +func summarize(pkgs []pkg.Package) []string { + var out []string + for _, p := range pkgs { + meta, _ := p.Metadata.(pkg.JavaArchive) + lics := p.Licenses.ToSlice() + names := make([]string, 0, len(lics)) + for _, l := range lics { + names = append(names, l.Value) + } + sort.Strings(names) + out = append(out, p.Name+"@"+p.Version+" purl="+p.PURL+" vpath="+meta.VirtualPath+" lics="+ + joinSorted(names)) + } + sort.Strings(out) + return out +} + +func joinSorted(in []string) string { + out := "" + for i, s := range in { + if i > 0 { + out += "," + } + out += s + } + return out +} + +func Test_archiveContentsCataloger_agreesWithTheArchiveReadingCataloger(t *testing.T) { + // the whole reason a second cataloger is tolerable: for the same archive it must report the same + // packages, with the same identity, as the one that reads the archive file. Anything else is a + // divergence that will be discovered as a diff in someone's SBOM. + entries := jarEntries() + cfg := DefaultArchiveCatalogerConfig() + + // the archive-reading cataloger, over a directory holding the jar + archiveDir := t.TempDir() + jarPath := writeJar(t, archiveDir, "example-lib-1.2.3.jar", entries) + archiveResolver, err := fileresolver.NewFromDirectory(archiveDir, "") + require.NoError(t, err) + // the archive-reading cataloger copies the archive to a temp dir before it can read it, which is + // the work the contents-reading one does not do + archiveCtx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(t.TempDir())) + fromArchive, _, err := NewArchiveCataloger(cfg).Catalog(archiveCtx, archiveResolver) + require.NoError(t, err) + require.NotEmpty(t, fromArchive) + + // the contents-reading cataloger, over the same entries laid out as a filesystem, with the + // traversal the archive cataloger task would have placed on the context + contentsDir := t.TempDir() + explode(t, contentsDir, entries) + contentsResolver, err := fileresolver.NewFromDirectory(contentsDir, "") + require.NoError(t, err) + + // the traversal names the archive exactly as the resolver did for the archive-reading cataloger, + // so both sides derive identity from the same string and the comparison is about the source of + // the entries rather than about how the test spelled a path + jarLocations, err := archiveResolver.FilesByGlob("**/*.jar") + require.NoError(t, err) + require.Len(t, jarLocations, 1) + archivePath := jarLocations[0].Path() + + digests := digestsOfFile(t, jarPath) + trav := &archive.Traversal{ + Location: file.NewLocation(archivePath), + VirtualPath: archivePath, + FileSystemID: "example-lib-1.2.3.jar", + Depth: 1, + Digests: digests, + } + ctx := archive.WithTraversal(context.Background(), trav) + fromContents, _, err := NewArchiveContentsCataloger(cfg).Catalog(ctx, contentsResolver) + require.NoError(t, err) + + assert.Equal(t, summarize(fromArchive), summarize(fromContents)) +} + +func digestsOfFile(t *testing.T, path string) []file.Digest { + t.Helper() + entries, err := newZipEntries(context.Background(), path) + require.NoError(t, err) + digests, err := entries.digests(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, digests) + return digests +} + +func Test_archiveContentsCataloger_reportsNothingOutsideAnArchive(t *testing.T) { + // no traversal means the scan is not inside an archive, and the identity of a java archive is + // half in the file - so there is nothing this cataloger can say + dir := t.TempDir() + explode(t, dir, jarEntries()) + resolver, err := fileresolver.NewFromDirectory(dir, "") + require.NoError(t, err) + + pkgs, _, err := NewArchiveContentsCataloger(DefaultArchiveCatalogerConfig()). + Catalog(context.Background(), resolver) + require.NoError(t, err) + assert.Empty(t, pkgs) +} + +func Test_archiveContentsCataloger_reportsNothingForAnArchiveThatIsNotJava(t *testing.T) { + // a .zip is an archive, and its contents are not a jar's contents. The archive-reading cataloger + // draws that line with its globs; this one draws it with the same list. + dir := t.TempDir() + explode(t, dir, jarEntries()) + resolver, err := fileresolver.NewFromDirectory(dir, "") + require.NoError(t, err) + + ctx := archive.WithTraversal(context.Background(), &archive.Traversal{ + Location: file.NewLocation("/bundle.zip"), + VirtualPath: "/bundle.zip", + Depth: 1, + }) + pkgs, _, err := NewArchiveContentsCataloger(DefaultArchiveCatalogerConfig()).Catalog(ctx, resolver) + require.NoError(t, err) + assert.Empty(t, pkgs) +} + +func Test_archiveContentsCataloger_noManifestIsNoPackage(t *testing.T) { + // unchanged from the archive-reading cataloger, and worth asserting because it is the whole + // coverage argument: a jar with no META-INF/MANIFEST.MF yields nothing today either, so requiring + // the contents to be traversed takes away less than it appears to. Note the maven metadata here is + // never consulted - without a main package there is nothing to attach it to. + entries := map[string]string{ + "META-INF/maven/com.other/bundled-dep/pom.properties": "groupId=com.other\nartifactId=bundled-dep\nversion=4.5.6\n", + "com/example/Thing.class": "not really bytecode", + } + + dir := t.TempDir() + explode(t, dir, entries) + resolver, err := fileresolver.NewFromDirectory(dir, "") + require.NoError(t, err) + + ctx := archive.WithTraversal(context.Background(), &archive.Traversal{ + Location: file.NewLocation("no-manifest-1.0.0.jar"), + VirtualPath: "no-manifest-1.0.0.jar", + Depth: 1, + }) + pkgs, _, err := NewArchiveContentsCataloger(DefaultArchiveCatalogerConfig()).Catalog(ctx, resolver) + assert.Empty(t, pkgs) + // reported against the archive rather than swallowed, and as an unknown rather than a scan failure + require.Error(t, err) + coordErrs, remaining := unknown.ExtractCoordinateErrors(err) + assert.NoError(t, remaining) + require.Len(t, coordErrs, 1) + assert.Equal(t, "no-manifest-1.0.0.jar", coordErrs[0].Coordinates.RealPath) +} + +func Test_archiveContentsCataloger_readsWhatWasExtractedAndNoMore(t *testing.T) { + // the sharp edge of reading contents rather than the archive: this cataloger sees exactly what the + // extraction produced. An archive truncated by a limit yields a package built from the entries + // that were written, so identity degrades rather than disappearing - the manifest is there and the + // maven metadata is not. + // + // Observed on a real artifact: jenkins-core-2.578.jar holds more than the default 10,000-entry + // bound, so its META-INF/maven pom is never extracted and its group id falls back from + // org.jenkins-ci.main to the artifact id. The archive-reading cataloger never felt this, because it + // read the archive's own central directory with no bound on it. + entries := map[string]string{ + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n" + + "Implementation-Title: example-lib\nImplementation-Version: 1.2.3\n", + // the pom that would have supplied the group id is missing, as truncation would leave it + "com/example/Thing.class": "not really bytecode", + } + + dir := t.TempDir() + explode(t, dir, entries) + resolver, err := fileresolver.NewFromDirectory(dir, "") + require.NoError(t, err) + + ctx := archive.WithTraversal(context.Background(), &archive.Traversal{ + Location: file.NewLocation("example-lib-1.2.3.jar"), + VirtualPath: "example-lib-1.2.3.jar", + Depth: 1, + }) + pkgs, _, err := NewArchiveContentsCataloger(DefaultArchiveCatalogerConfig()).Catalog(ctx, resolver) + require.NoError(t, err) + require.Len(t, pkgs, 1) + assert.Equal(t, "example-lib", pkgs[0].Name) + assert.Equal(t, "1.2.3", pkgs[0].Version) + assert.Equal(t, "pkg:maven/example-lib/example-lib@1.2.3", pkgs[0].PURL, + "with no pom to name the group, the group falls back to the artifact id") +} diff --git a/syft/pkg/cataloger/java/archive_entries.go b/syft/pkg/cataloger/java/archive_entries.go new file mode 100644 index 000000000..636c853ae --- /dev/null +++ b/syft/pkg/cataloger/java/archive_entries.go @@ -0,0 +1,193 @@ +package java + +import ( + "context" + "fmt" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/scylladb/go-set/strset" + + "github.com/anchore/syft/internal" + intFile "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/file" +) + +// archiveEntries is where an archive parser reads a java archive's entries from. +// +// The parser needs three things of an archive and nothing else: which entries match a glob, the +// contents of some of them, and the digest of the archive as a whole. Everything else it does - +// which manifest wins, how pom.properties decides identity, license discovery - is logic over those +// answers. Naming the three makes them replaceable, which is what lets the same logic run over an +// archive file and over an archive that has already been extracted to a filesystem. +// +// Entry names are archive-relative with no leading slash, matching zip entry names, because they +// become map keys and pom paths that identity is derived from. An implementation over a filesystem +// has to strip the root to match. +type archiveEntries interface { + // glob returns the names of the entries matching any of the patterns. + glob(caseInsensitive bool, patterns ...string) []string + + // contents reads the named entries, returning a map keyed by the same names. + contents(ctx context.Context, names ...string) (map[string]string, error) + + // digests returns the digests of the archive as a whole, which is a property of the archive file + // rather than of anything inside it. + digests(ctx context.Context) ([]file.Digest, error) +} + +// zipEntries reads entries out of a java archive on disk. This is the original source, kept for +// scans where nothing has extracted the archive: it copies the archive to a temp file, reads its +// central directory once, and opens it again for each set of entries it is asked for. +type zipEntries struct { + archivePath string + fileManifest intFile.ZipFileManifest +} + +func newZipEntries(ctx context.Context, archivePath string) (*zipEntries, error) { + fileManifest, err := intFile.NewZipFileManifest(ctx, archivePath) + if err != nil { + return nil, fmt.Errorf("unable to read files from java archive: %w", err) + } + return &zipEntries{archivePath: archivePath, fileManifest: fileManifest}, nil +} + +func (z *zipEntries) glob(caseInsensitive bool, patterns ...string) []string { + return z.fileManifest.GlobMatch(caseInsensitive, patterns...) +} + +func (z *zipEntries) contents(ctx context.Context, names ...string) (map[string]string, error) { + return intFile.ContentsFromZip(ctx, z.archivePath, names...) +} + +func (z *zipEntries) digests(ctx context.Context) ([]file.Digest, error) { + return getDigestsFromArchive(ctx, z.archivePath) +} + +// resolverEntries reads entries out of an archive that has already been extracted and indexed - the +// filesystem the archive cataloger task builds for every archive it enters. +// +// Nothing is copied and nothing is re-opened: the entries are files in an indexed resolver, and the +// archive's own digests were taken by whoever extracted it, since the archive file itself is not +// present in its own contents. +type resolverEntries struct { + resolver file.Resolver + archiveDigests []file.Digest + locationsByName map[string]file.Location +} + +func newResolverEntries(resolver file.Resolver, archiveDigests []file.Digest) *resolverEntries { + return &resolverEntries{ + resolver: resolver, + archiveDigests: archiveDigests, + locationsByName: make(map[string]file.Location), + } +} + +func (r *resolverEntries) glob(caseInsensitive bool, patterns ...string) []string { + if caseInsensitive { + return r.globCaseInsensitive(patterns...) + } + + // deduplicated within this call and not across calls: the parser globs the same pattern more than + // once (identity resolution asks for the poms, and so does aux package discovery), and a source + // that remembered what it had already returned would hand back nothing the second time. That + // silently cost every package derived from a bundled pom.properties. + seen := strset.New() + var names []string + for _, pattern := range patterns { + locations, err := r.resolver.FilesByGlob(pattern) + if err != nil { + log.WithFields("pattern", pattern, "error", err).Trace("unable to glob archive contents") + continue + } + for _, location := range locations { + name := strings.TrimPrefix(location.Path(), "/") + r.locationsByName[name] = location + if seen.Has(name) { + continue + } + seen.Add(name) + names = append(names, name) + } + } + return names +} + +// globCaseInsensitive matches the way the zip-backed source does, by lowercasing both sides. The +// resolver's own glob is case sensitive, so this lists the archive's files once and matches them +// here. License discovery is the only caller that asks, and what it costs is one listing of a +// filesystem holding one archive's entries. +func (r *resolverEntries) globCaseInsensitive(patterns ...string) []string { + all, err := r.resolver.FilesByGlob("**/*") + if err != nil { + log.WithFields("error", err).Trace("unable to list archive contents") + return nil + } + seen := strset.New() + var names []string + for _, pattern := range patterns { + lowered := strings.ToLower(pattern) + for _, location := range all { + // matched with a leading slash, the way the zip-backed source normalizes an entry before + // matching, because the patterns are written that way ("/META-INF/*"). An extracted + // archive's resolver reports paths without one - they are entry names, not absolute paths - + // so matching them raw silently matches nothing, which cost the license found in every + // jar that carries one as a file. + name := strings.TrimPrefix(location.Path(), "/") + if ok, matchErr := doublestar.Match(lowered, "/"+strings.ToLower(name)); matchErr != nil || !ok { + continue + } + r.locationsByName[name] = location + if seen.Has(name) { + continue + } + seen.Add(name) + names = append(names, name) + } + } + return names +} + +func (r *resolverEntries) contents(_ context.Context, names ...string) (map[string]string, error) { + out := make(map[string]string, len(names)) + for _, name := range names { + location, ok := r.locationsByName[name] + if !ok { + // only names this source handed out can be read back; a name from anywhere else is a bug + // rather than a missing file + return nil, fmt.Errorf("entry %q was not found by any glob against this archive", name) + } + contents, err := r.read(location) + if err != nil { + return nil, fmt.Errorf("unable to read archive entry %q: %w", name, err) + } + out[name] = contents + } + return out, nil +} + +func (r *resolverEntries) digests(_ context.Context) ([]file.Digest, error) { + return r.archiveDigests, nil +} + +// read reads one entry's content to the end and closes the stream. The parser wants entry contents as +// strings, since every consumer of them parses text. +// +// The copy is bounded the same way the zip-backed source bounds its own: an entry of an extracted +// archive is still attacker-supplied content, and reading it into a string is exactly the allocation +// a decompression bomb is aiming at. +func (r *resolverEntries) read(location file.Location) (string, error) { + reader, err := r.resolver.FileContentsByLocation(location) + if err != nil { + return "", err + } + defer internal.CloseAndLogError(reader, location.RealPath) + + var contents strings.Builder + if err := intFile.SafeCopy(&contents, reader); err != nil { + return "", err + } + return contents.String(), nil +} diff --git a/syft/pkg/cataloger/java/archive_parser.go b/syft/pkg/cataloger/java/archive_parser.go index e54b73696..6cd3e1f32 100644 --- a/syft/pkg/cataloger/java/archive_parser.go +++ b/syft/pkg/cataloger/java/archive_parser.go @@ -16,6 +16,7 @@ import ( "golang.org/x/exp/maps" "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/archive" intFile "github.com/anchore/syft/internal/file" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/tmpdir" @@ -57,8 +58,9 @@ var javaArchiveHashes = []crypto.Hash{ } type archiveParser struct { - fileManifest intFile.ZipFileManifest + entries archiveEntries location file.Location + virtualPath string archivePath string contentPath string fileInfo archiveFilename @@ -82,7 +84,9 @@ func (gap genericArchiveParserAdapter) parseJavaArchive(ctx context.Context, _ f // processJavaArchive processes an archive for java contents, returning all Java libraries and nested archives func (gap genericArchiveParserAdapter) processJavaArchive(ctx context.Context, reader file.LocationReadCloser, parentPkg *pkg.Package) ([]pkg.Package, []artifact.Relationship, error) { - parser, cleanupFn, err := newJavaArchiveParser(ctx, reader, true, gap.cfg) + // when the generic archive cataloger task owns recursion into archives, this parser must not + // also unarchive nested archives, otherwise they would be cataloged twice + parser, cleanupFn, err := newJavaArchiveParser(ctx, reader, !gap.cfg.nestedArchivesHandledExternally(), gap.cfg) // note: even on error, we should always run cleanup functions defer cleanupFn() if err != nil { @@ -102,8 +106,13 @@ func uniquePkgKey(groupID string, p *pkg.Package) string { // newJavaArchiveParser returns a new java archive parser object for the given archive. Can be configured to discover // and parse nested archives or ignore them. func newJavaArchiveParser(ctx context.Context, reader file.LocationReadCloser, detectNested bool, cfg ArchiveCatalogerConfig) (*archiveParser, func(), error) { + // establish the full virtual path of this archive: when the generic archive cataloger task drove + // us here, the context traversal supplies the containing archive chain; otherwise the reader + // path already carries any colon-delimited nesting from this cataloger's own recursion + virtualPath := archive.TraversalFromContext(ctx).VirtualPathOf(reader.Path()) + // fetch the last element of the virtual path - virtualElements := strings.Split(reader.Path(), ":") + virtualElements := strings.Split(virtualPath, ":") currentFilepath := virtualElements[len(virtualElements)-1] td := tmpdir.FromContext(ctx) @@ -115,14 +124,15 @@ func newJavaArchiveParser(ctx context.Context, reader file.LocationReadCloser, d return nil, cleanupFn, fmt.Errorf("unable to process java archive: %w", err) } - fileManifest, err := intFile.NewZipFileManifest(ctx, archivePath) + entries, err := newZipEntries(ctx, archivePath) if err != nil { - return nil, cleanupFn, fmt.Errorf("unable to read files from java archive: %w", err) + return nil, cleanupFn, err } return &archiveParser{ - fileManifest: fileManifest, + entries: entries, location: reader.Location, + virtualPath: virtualPath, archivePath: archivePath, contentPath: contentPath, fileInfo: newJavaArchiveFilename(currentFilepath), @@ -188,9 +198,11 @@ func (j *archiveParser) parse(ctx context.Context, parentPkg *pkg.Package) ([]pk } pkgs = append(pkgs, nestedPkgs...) relationships = append(relationships, nestedRelationships...) - } else { + } else if !j.cfg.nestedArchivesHandledExternally() { + // nested archives will not be cataloged by anything: this parser is not recursing and the + // generic archive cataloger task is disabled // .jar and .war files are present in archives, are others? or generally just consider them top-level? - nestedArchives := j.fileManifest.GlobMatch(true, "**/*.jar", "**/*.war") + nestedArchives := j.entries.glob(true, "**/*.jar", "**/*.war") if len(nestedArchives) > 0 { slices.Sort(nestedArchives) errs = unknown.Appendf(errs, j.location, "nested archives not cataloged: %v", strings.Join(nestedArchives, ", ")) @@ -223,7 +235,7 @@ func finalizePackage(p *pkg.Package) { // discoverMainPackage parses the root Java manifest used as the parent package to all discovered nested packages. func (j *archiveParser) discoverMainPackage(ctx context.Context) (*pkg.Package, error) { // search and parse java manifest files - manifestMatches := j.fileManifest.GlobMatch(false, manifestGlob) + manifestMatches := j.entries.glob(false, manifestGlob) if len(manifestMatches) > 1 { return nil, fmt.Errorf("found multiple manifests in the jar: %+v", manifestMatches) } else if len(manifestMatches) == 0 { @@ -232,7 +244,7 @@ func (j *archiveParser) discoverMainPackage(ctx context.Context) (*pkg.Package, } // fetch the manifest file - contents, err := intFile.ContentsFromZip(ctx, j.archivePath, manifestMatches...) + contents, err := j.entries.contents(ctx, manifestMatches...) if err != nil { return nil, fmt.Errorf("unable to extract java manifests (%s): %w", j.location, err) } @@ -253,7 +265,7 @@ func (j *archiveParser) discoverMainPackage(ctx context.Context) (*pkg.Package, } // grab and assign digest for the entire archive - digests, err := getDigestsFromArchive(ctx, j.archivePath) + digests, err := j.entries.digests(ctx) if err != nil { return nil, err } @@ -275,7 +287,7 @@ func (j *archiveParser) discoverMainPackage(ctx context.Context) (*pkg.Package, ), Type: j.fileInfo.pkgType(), Metadata: pkg.JavaArchive{ - VirtualPath: j.location.Path(), + VirtualPath: j.virtualPath, Manifest: manifest, PomProject: pkgPomProject, ArchiveDigests: digests, @@ -388,8 +400,8 @@ type parsedPomProject struct { // discoverMainPackageFromPomInfo attempts to resolve maven groupId, artifactId, version and other info from found pom information func (j *archiveParser) discoverMainPackageFromPomInfo(ctx context.Context) (group, name, version string, parsedPom *parsedPomProject) { // Find the pom.properties/pom.xml if the names seem like a plausible match - properties, _ := pomPropertiesByParentPath(ctx, j.archivePath, j.location, j.fileManifest.GlobMatch(false, pomPropertiesGlob)) - projects, _ := pomProjectByParentPath(ctx, j.archivePath, j.location, j.fileManifest.GlobMatch(false, pomXMLGlob)) + properties, _ := pomPropertiesByParentPath(ctx, j.entries, j.location, j.entries.glob(false, pomPropertiesGlob)) + projects, _ := pomProjectByParentPath(ctx, j.entries, j.location, j.entries.glob(false, pomXMLGlob)) artifactsMap := j.buildArtifactsMap(properties) pomProperties, parsedPom := j.findBestPomMatch(properties, projects, artifactsMap) @@ -520,13 +532,13 @@ func (j *archiveParser) discoverPkgsFromAllMavenFiles(ctx context.Context, paren var pkgs []pkg.Package // pom.properties - properties, err := pomPropertiesByParentPath(ctx, j.archivePath, j.location, j.fileManifest.GlobMatch(false, pomPropertiesGlob)) + properties, err := pomPropertiesByParentPath(ctx, j.entries, j.location, j.entries.glob(false, pomPropertiesGlob)) if err != nil { return nil, err } // pom.xml - projects, err := pomProjectByParentPath(ctx, j.archivePath, j.location, j.fileManifest.GlobMatch(false, pomXMLGlob)) + projects, err := pomProjectByParentPath(ctx, j.entries, j.location, j.entries.glob(false, pomXMLGlob)) if err != nil { return nil, err } @@ -537,7 +549,7 @@ func (j *archiveParser) discoverPkgsFromAllMavenFiles(ctx context.Context, paren parsedPom = proj } - pkgFromPom := newPackageFromMavenData(ctx, j.maven, propertiesObj, parsedPom, parentPkg, j.location) + pkgFromPom := newPackageFromMavenData(ctx, j.maven, propertiesObj, parsedPom, parentPkg, j.location, j.virtualPath) if pkgFromPom != nil { pkgs = append(pkgs, *pkgFromPom) } @@ -569,14 +581,14 @@ func (j *archiveParser) getLicenseFromFileInArchive(ctx context.Context) []pkg.L for _, glob := range []string{"/META-INF/*", "/*"} { var licenseMatches []string - for _, f := range j.fileManifest.GlobMatch(true, glob) { + for _, f := range j.entries.glob(true, glob) { if licenses.IsLicenseFile(path.Base(f)) { licenseMatches = append(licenseMatches, f) } } if len(licenseMatches) > 0 { - contents, err := intFile.ContentsFromZip(ctx, j.archivePath, licenseMatches...) + contents, err := j.entries.contents(ctx, licenseMatches...) if err != nil { log.Debugf("unable to extract java license (%s): %w", j.location, err) continue @@ -609,9 +621,17 @@ func (j *archiveParser) getLicenseFromFileInArchive(ctx context.Context) []pkg.L return identified } +// discoverPkgsFromNestedArchives recurses into the java archives inside this one. It exists only for +// the zip-backed source: recursion needs an archive file to unzip, and the resolver-backed source has +// no file - by the time it runs, the archive cataloger task has already extracted everything and will +// meet those nested archives itself. func (j *archiveParser) discoverPkgsFromNestedArchives(ctx context.Context, parentPkg *pkg.Package) ([]pkg.Package, []artifact.Relationship, error) { + zipped, ok := j.entries.(*zipEntries) + if !ok { + return nil, nil, nil + } // we know that all java archives are zip formatted files, so we can use the shared zip helper - return discoverPkgsFromZip(ctx, j.location, j.archivePath, j.contentPath, j.fileManifest, parentPkg, j.cfg) + return discoverPkgsFromZip(ctx, j.location, zipped.archivePath, j.contentPath, zipped.fileManifest, parentPkg, j.cfg) } // discoverPkgsFromZip finds Java archives within Java archives, returning all listed Java packages found and @@ -682,8 +702,8 @@ func discoverPkgsFromOpener(ctx context.Context, location file.Location, pathWit return nestedPkgs, nestedRelationships, nil } -func pomPropertiesByParentPath(ctx context.Context, archivePath string, location file.Location, extractPaths []string) (map[string]pkg.JavaPomProperties, error) { - contentsOfMavenPropertiesFiles, err := intFile.ContentsFromZip(ctx, archivePath, extractPaths...) +func pomPropertiesByParentPath(ctx context.Context, entries archiveEntries, location file.Location, extractPaths []string) (map[string]pkg.JavaPomProperties, error) { + contentsOfMavenPropertiesFiles, err := entries.contents(ctx, extractPaths...) if err != nil { return nil, fmt.Errorf("unable to extract maven files: %w", err) } @@ -711,8 +731,8 @@ func pomPropertiesByParentPath(ctx context.Context, archivePath string, location return propertiesByParentPath, nil } -func pomProjectByParentPath(ctx context.Context, archivePath string, location file.Location, extractPaths []string) (map[string]*parsedPomProject, error) { - contentsOfMavenProjectFiles, err := intFile.ContentsFromZip(ctx, archivePath, extractPaths...) +func pomProjectByParentPath(ctx context.Context, entries archiveEntries, location file.Location, extractPaths []string) (map[string]*parsedPomProject, error) { + contentsOfMavenProjectFiles, err := entries.contents(ctx, extractPaths...) if err != nil { return nil, fmt.Errorf("unable to extract maven files: %w", err) } @@ -739,7 +759,7 @@ func pomProjectByParentPath(ctx context.Context, archivePath string, location fi // newPackageFromMavenData processes a single Maven POM properties for a given parent package, returning all listed Java packages found and // associating each discovered package to the given parent package. Note the pom.xml is optional, the pom.properties is not. -func newPackageFromMavenData(ctx context.Context, r *maven.Resolver, pomProperties pkg.JavaPomProperties, parsedPom *parsedPomProject, parentPkg *pkg.Package, location file.Location) *pkg.Package { +func newPackageFromMavenData(ctx context.Context, r *maven.Resolver, pomProperties pkg.JavaPomProperties, parsedPom *parsedPomProject, parentPkg *pkg.Package, location file.Location, virtualPath string) *pkg.Package { // keep the artifact name within the virtual path if this package does not match the parent package vPathSuffix := "" groupID := "" @@ -759,7 +779,7 @@ func newPackageFromMavenData(ctx context.Context, r *maven.Resolver, pomProperti // https://github.com/anchore/syft/issues/1944 vPathSuffix += ":" + pomProperties.GroupID + ":" + pomProperties.ArtifactID } - virtualPath := location.Path() + vPathSuffix + virtualPath += vPathSuffix var pkgPomProject *pkg.JavaPomProject diff --git a/syft/pkg/cataloger/java/archive_parser_test.go b/syft/pkg/cataloger/java/archive_parser_test.go index 84167c5ef..454f868fe 100644 --- a/syft/pkg/cataloger/java/archive_parser_test.go +++ b/syft/pkg/cataloger/java/archive_parser_test.go @@ -1155,7 +1155,7 @@ func Test_newPackageFromMavenData(t *testing.T) { test.expectedParent.Locations = locations r := maven.NewResolver(nil, maven.DefaultConfig()) - actualPackage := newPackageFromMavenData(pkgtest.Context(t), r, test.props, test.project, test.parent, file.NewLocation(virtualPath)) + actualPackage := newPackageFromMavenData(pkgtest.Context(t), r, test.props, test.project, test.parent, file.NewLocation(virtualPath), virtualPath) if test.expectedPackage == nil { require.Nil(t, actualPackage) } else { diff --git a/syft/pkg/cataloger/java/archive_parser_traversal_test.go b/syft/pkg/cataloger/java/archive_parser_traversal_test.go new file mode 100644 index 000000000..ea66c9bef --- /dev/null +++ b/syft/pkg/cataloger/java/archive_parser_traversal_test.go @@ -0,0 +1,327 @@ +package java + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/archive" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" + "github.com/anchore/syft/syft/source" + "github.com/anchore/syft/syft/source/directorysource" +) + +// makeTestZip builds a zip in memory so the parser can be exercised without the +// java-toolchain-built fixtures. +func makeTestZip(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range entries { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write(content) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +func testManifest(title, version string) []byte { + return fmt.Appendf(nil, "Manifest-Version: 1.0\nImplementation-Title: %s\nImplementation-Version: %s\n", title, version) +} + +func Test_virtualPathFromArchiveTraversal(t *testing.T) { + jarBytes := makeTestZip(t, map[string][]byte{ + "META-INF/MANIFEST.MF": testManifest("example-app", "1.0.0"), + }) + + parse := func(t *testing.T, traversal *archive.Traversal, readerPath string) pkg.Package { + ctx := pkgtest.Context(t) + if traversal != nil { + ctx = archive.WithTraversal(ctx, traversal) + } + gap := newGenericArchiveParserAdapter(ArchiveCatalogerConfig{}) + pkgs, _, err := gap.processJavaArchive(ctx, file.LocationReadCloser{ + Location: file.NewLocation(readerPath), + ReadCloser: io.NopCloser(bytes.NewReader(jarBytes)), + }, nil) + require.NoError(t, err) + require.Len(t, pkgs, 1) + return pkgs[0] + } + + t.Run("no traversal keeps reader path as virtual path", func(t *testing.T) { + p := parse(t, nil, "some/dir/example-app-1.0.0.jar") + metadata, ok := p.Metadata.(pkg.JavaArchive) + require.True(t, ok) + assert.Equal(t, "some/dir/example-app-1.0.0.jar", metadata.VirtualPath) + assert.Equal(t, "example-app", p.Name) + assert.Equal(t, "1.0.0", p.Version) + }) + + t.Run("traversal prepends containing archive chain", func(t *testing.T) { + trav := &archive.Traversal{ + Location: file.NewLocation("/app.war"), + VirtualPath: "/app.war", + FileSystemID: "app.war", + Depth: 1, + } + p := parse(t, trav, "/WEB-INF/lib/example-app-1.0.0.jar") + metadata, ok := p.Metadata.(pkg.JavaArchive) + require.True(t, ok) + assert.Equal(t, "/app.war:WEB-INF/lib/example-app-1.0.0.jar", metadata.VirtualPath) + // name/version inference from the last virtual path element is unaffected by the chain + assert.Equal(t, "example-app", p.Name) + assert.Equal(t, "1.0.0", p.Version) + }) + + t.Run("multi-level traversal chains all parents", func(t *testing.T) { + outer := &archive.Traversal{VirtualPath: "/dist.zip", FileSystemID: "dist.zip", Depth: 1} + inner := &archive.Traversal{ + VirtualPath: outer.VirtualPathOf("/app.war"), + FileSystemID: "dist.zip/app.war", + Depth: 2, + Parent: outer, + } + p := parse(t, inner, "/WEB-INF/lib/example-app-1.0.0.jar") + metadata, ok := p.Metadata.(pkg.JavaArchive) + require.True(t, ok) + assert.Equal(t, "/dist.zip:app.war:WEB-INF/lib/example-app-1.0.0.jar", metadata.VirtualPath) + }) +} + +// Test_virtualPathFromArchiveTraversal_colonInEntryPath is the java-cataloger-level counterpart +// of internal/archive's escaping tests: a package reached through an entry whose own path +// contains a colon must carry that colon escaped as %3A end to end in its virtual path +// (decisions.md#colon-in-an-entry-path-is-escaped), and the maven-derived +// ":groupID:artifactID" suffix that distinguishes a pom.properties package from its containing +// archive must still be appended using real, unescaped delimiters. +func Test_virtualPathFromArchiveTraversal_colonInEntryPath(t *testing.T) { + jarBytes := makeTestZip(t, map[string][]byte{ + "META-INF/MANIFEST.MF": testManifest("outer-app", "1.0.0"), + "META-INF/maven/com.example/different-lib/pom.properties": []byte( + "groupId=com.example\nartifactId=different-lib\nversion=9.9.9\n", + ), + }) + + trav := &archive.Traversal{ + Location: file.NewLocation("/outer.zip"), + VirtualPath: "outer.zip", + FileSystemID: "outer.zip", + Depth: 1, + } + ctx := archive.WithTraversal(pkgtest.Context(t), trav) + + gap := newGenericArchiveParserAdapter(ArchiveCatalogerConfig{}) + pkgs, _, err := gap.processJavaArchive(ctx, file.LocationReadCloser{ + Location: file.NewLocation("/lib/dep:special.jar"), + ReadCloser: io.NopCloser(bytes.NewReader(jarBytes)), + }, nil) + require.NoError(t, err) + require.Len(t, pkgs, 2) + + var mainPkg, auxPkg *pkg.Package + for i := range pkgs { + if pkgs[i].Name == "outer-app" { + mainPkg = &pkgs[i] + } else if pkgs[i].Name == "different-lib" { + auxPkg = &pkgs[i] + } + } + require.NotNil(t, mainPkg, "expected a package from the manifest") + require.NotNil(t, auxPkg, "expected a package from pom.properties") + + mainMetadata, ok := mainPkg.Metadata.(pkg.JavaArchive) + require.True(t, ok) + assert.Equal(t, "outer.zip:lib/dep%3Aspecial.jar", mainMetadata.VirtualPath, + "the entry's own colon must be escaped, and it is the only delimiter in play here") + + auxMetadata, ok := auxPkg.Metadata.(pkg.JavaArchive) + require.True(t, ok) + assert.Equal(t, "outer.zip:lib/dep%3Aspecial.jar:com.example:different-lib", auxMetadata.VirtualPath, + "the maven groupID:artifactID suffix is appended with real, unescaped delimiters") +} + +func Test_nestedArchiveOwnershipSwitch(t *testing.T) { + nestedJar := makeTestZip(t, map[string][]byte{ + "META-INF/MANIFEST.MF": testManifest("nested-lib", "2.0"), + }) + outerJar := makeTestZip(t, map[string][]byte{ + "META-INF/MANIFEST.MF": testManifest("example-app", "1.0.0"), + "lib/nested-lib-2.0.jar": nestedJar, + }) + + parse := func(t *testing.T, cfg ArchiveCatalogerConfig) []pkg.Package { + gap := newGenericArchiveParserAdapter(cfg) + pkgs, _, err := gap.processJavaArchive(pkgtest.Context(t), file.LocationReadCloser{ + Location: file.NewLocation("example-app-1.0.0.jar"), + ReadCloser: io.NopCloser(bytes.NewReader(outerJar)), + }, nil) + require.NoError(t, err) + return pkgs + } + + t.Run("nested archive cataloging disabled: this cataloger unarchives nested jars itself", func(t *testing.T) { + pkgs := parse(t, DefaultArchiveCatalogerConfig()) // MaxDepth defaults to 0 (disabled) + require.Len(t, pkgs, 2) + + var names, virtualPaths []string + for _, p := range pkgs { + names = append(names, p.Name) + metadata, ok := p.Metadata.(pkg.JavaArchive) + require.True(t, ok) + virtualPaths = append(virtualPaths, metadata.VirtualPath) + } + assert.ElementsMatch(t, []string{"example-app", "nested-lib"}, names) + assert.ElementsMatch(t, []string{ + "example-app-1.0.0.jar", + "example-app-1.0.0.jar:lib/nested-lib-2.0.jar", + }, virtualPaths) + }) + + t.Run("nested archive cataloging enabled: this cataloger does not unarchive", func(t *testing.T) { + cfg := DefaultArchiveCatalogerConfig() + // only CreateSBOMConfig sets this in production; it is not reachable from user config + cfg.NestedArchivesHandledExternally = true + + // no error expected: the nested jar is deliberately left to the archive cataloger task + pkgs := parse(t, cfg) + require.Len(t, pkgs, 1) + assert.Equal(t, "example-app", pkgs[0].Name) + }) + + t.Run("java's own archive-search depth does not hand off recursion", func(t *testing.T) { + // a library consumer setting java's squash-inlined MaxDepth must neither enable nor + // disable nested archive cataloging: that knob is Archive.MaxDepth on CreateSBOMConfig. + // Getting this wrong silently drops nested jars for every consumer that is not the CLI. + cfg := DefaultArchiveCatalogerConfig() + cfg.ArchiveSearchConfig = cfg.ArchiveSearchConfig.WithMaxDepth(2) + require.False(t, cfg.nestedArchivesHandledExternally()) + + pkgs := parse(t, cfg) + require.Len(t, pkgs, 2, "this cataloger must still recurse itself") + }) +} + +func Test_NewArchiveCataloger_wrappedParserRegistration(t *testing.T) { + // the wrapped-archive parsers exist only because the generic archive cataloger did not. When it + // is enabled it extracts zip and tar containers itself and this cataloger meets the same JARs + // one nesting level down, so registering them would double the work. + // + // asserted through the globs the cataloger actually queries, since that is the observable + // consequence of a parser being registered + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "placeholder.txt"), []byte("x"), 0o600)) + + queriedGlobs := func(t *testing.T, cfg ArchiveCatalogerConfig) *pkgtest.ObservingResolver { + t.Helper() + src, err := directorysource.NewFromPath(dir) + require.NoError(t, err) + resolver, err := src.FileResolver(source.AllLayersScope) + require.NoError(t, err) + + observed := pkgtest.NewObservingResolver(resolver) + _, _, err = NewArchiveCataloger(cfg).Catalog(pkgtest.Context(t), observed) + require.NoError(t, err) + return observed + } + + t.Run("wrapped parsers are not registered when the archive cataloger owns recursion", func(t *testing.T) { + cfg := DefaultArchiveCatalogerConfig() + cfg.IncludeIndexedArchives = true + cfg.IncludeUnindexedArchives = true + cfg.NestedArchivesHandledExternally = true + + observed := queriedGlobs(t, cfg) + assert.False(t, observed.ObservedPathQuery("**/*.zip"), "no generic zip parser may be registered") + assert.False(t, observed.ObservedPathQuery("**/*.tar"), "no generic tar parser may be registered") + + // the JAR-family globs are still registered: this cataloger is still the java parser + assert.True(t, observed.ObservedPathQuery("**/*.jar")) + }) + + t.Run("wrapped parsers stay registered when the feature is off", func(t *testing.T) { + cfg := DefaultArchiveCatalogerConfig() + cfg.IncludeIndexedArchives = true + cfg.IncludeUnindexedArchives = true + + observed := queriedGlobs(t, cfg) + assert.True(t, observed.ObservedPathQuery("**/*.zip"), "existing behavior must be unchanged") + assert.True(t, observed.ObservedPathQuery("**/*.tar"), "existing behavior must be unchanged") + }) + + t.Run("the existing include settings still gate them when the feature is off", func(t *testing.T) { + cfg := DefaultArchiveCatalogerConfig() + cfg.IncludeIndexedArchives = false + cfg.IncludeUnindexedArchives = false + + observed := queriedGlobs(t, cfg) + assert.False(t, observed.ObservedPathQuery("**/*.zip")) + assert.False(t, observed.ObservedPathQuery("**/*.tar")) + }) +} + +func Test_mavenVirtualPathSuffixUnderTraversal(t *testing.T) { + // a package derived from a pom.properties whose artifact does not match the containing archive + // gets a trailing "::" appended to the archive's virtual path. That suffix + // form is in published SBOMs, and once the archive cataloger drives the parser the base it is + // appended to comes from the traversal rather than from the reader path - so this asserts the + // two compose rather than one clobbering the other. + jarBytes := makeTestZip(t, map[string][]byte{ + "META-INF/MANIFEST.MF": testManifest("example-app", "1.0.0"), + "META-INF/maven/com.example.other/other-lib/pom.properties": []byte( + "groupId=com.example.other\nartifactId=other-lib\nversion=2.5.0\n", + ), + }) + + parse := func(t *testing.T, traversal *archive.Traversal, readerPath string) []pkg.Package { + t.Helper() + ctx := pkgtest.Context(t) + if traversal != nil { + ctx = archive.WithTraversal(ctx, traversal) + } + gap := newGenericArchiveParserAdapter(ArchiveCatalogerConfig{}) + pkgs, _, err := gap.processJavaArchive(ctx, file.LocationReadCloser{ + Location: file.NewLocation(readerPath), + ReadCloser: io.NopCloser(bytes.NewReader(jarBytes)), + }, nil) + require.NoError(t, err) + return pkgs + } + + virtualPathOf := func(t *testing.T, pkgs []pkg.Package, name string) string { + t.Helper() + for _, p := range pkgs { + if p.Name != name { + continue + } + metadata, ok := p.Metadata.(pkg.JavaArchive) + require.True(t, ok) + return metadata.VirtualPath + } + t.Fatalf("no package named %q in %v", name, pkgs) + return "" + } + + t.Run("no traversal: suffix hangs off the reader path", func(t *testing.T) { + pkgs := parse(t, nil, "/example-app-1.0.0.jar") + assert.Equal(t, "/example-app-1.0.0.jar:com.example.other:other-lib", + virtualPathOf(t, pkgs, "other-lib")) + }) + + t.Run("under traversal: suffix hangs off the archive chain", func(t *testing.T) { + pkgs := parse(t, &archive.Traversal{VirtualPath: "app.war", Depth: 1}, "/WEB-INF/lib/example-app-1.0.0.jar") + assert.Equal(t, "app.war:WEB-INF/lib/example-app-1.0.0.jar:com.example.other:other-lib", + virtualPathOf(t, pkgs, "other-lib")) + }) +} diff --git a/syft/pkg/cataloger/java/cataloger.go b/syft/pkg/cataloger/java/cataloger.go index 1e48b05e1..44fbbe9fd 100644 --- a/syft/pkg/cataloger/java/cataloger.go +++ b/syft/pkg/cataloger/java/cataloger.go @@ -15,16 +15,21 @@ func NewArchiveCataloger(cfg ArchiveCatalogerConfig) pkg.Cataloger { c := generic.NewCataloger("java-archive-cataloger"). WithParserByGlobs(gap.parseJavaArchive, archiveFormatGlobs...) - if cfg.IncludeIndexedArchives { - // java archives wrapped within zip files - gzp := newGenericZipWrappedJavaArchiveParser(cfg) - c.WithParserByGlobs(gzp.parseZipWrappedJavaArchive, genericZipGlobs...) - } + // when the generic archive cataloger task owns recursion into archives, it extracts zip/tar + // archives generically and this cataloger meets any java archives within them at the next + // nesting level, so the wrapped-archive parsers would only duplicate that work + if !cfg.nestedArchivesHandledExternally() { + if cfg.IncludeIndexedArchives { + // java archives wrapped within zip files + gzp := newGenericZipWrappedJavaArchiveParser(cfg) + c.WithParserByGlobs(gzp.parseZipWrappedJavaArchive, genericZipGlobs...) + } - if cfg.IncludeUnindexedArchives { - // java archives wrapped within tar files - gtp := newGenericTarWrappedJavaArchiveParser(cfg) - c.WithParserByGlobs(gtp.parseTarWrappedJavaArchive, genericTarGlobs...) + if cfg.IncludeUnindexedArchives { + // java archives wrapped within tar files + gtp := newGenericTarWrappedJavaArchiveParser(cfg) + c.WithParserByGlobs(gtp.parseTarWrappedJavaArchive, genericTarGlobs...) + } } return c } diff --git a/syft/pkg/cataloger/java/config.go b/syft/pkg/cataloger/java/config.go index 652ca827b..4ca66b466 100644 --- a/syft/pkg/cataloger/java/config.go +++ b/syft/pkg/cataloger/java/config.go @@ -33,6 +33,15 @@ type ArchiveCatalogerConfig struct { // ResolveTransitiveDependencies enables resolving transitive dependencies for java packages found within archives. // app-config: java.resolve-transitive-dependencies ResolveTransitiveDependencies bool `yaml:"resolve-transitive-dependencies" json:"resolve-transitive-dependencies" mapstructure:"resolve-transitive-dependencies"` + + // NestedArchivesHandledExternally indicates that some other mechanism owns recursion into + // nested archives, so this cataloger must not unarchive them itself. It is deliberately not a + // yaml/json/mapstructure key: no config file, flag, or environment variable reaches it. + // + // It is written ONLY by syft.CreateSBOMConfig, which derives it from the nested-archive + // cataloging depth so the two sides cannot disagree. Callers building this config should leave + // it alone; the zero value is this cataloger's long-standing behavior of recursing itself. + NestedArchivesHandledExternally bool `yaml:"-" json:"-" mapstructure:"-"` } func DefaultArchiveCatalogerConfig() ArchiveCatalogerConfig { @@ -81,6 +90,13 @@ func (j ArchiveCatalogerConfig) WithArchiveTraversal(search cataloging.ArchiveSe return j } +// nestedArchivesHandledExternally indicates another mechanism (the generic archive cataloger task) +// owns recursion into all archives, in which case this cataloger must not unarchive nested archives +// itself. See the field of the same name for why this is not derived from MaxDepth. +func (j ArchiveCatalogerConfig) nestedArchivesHandledExternally() bool { + return j.NestedArchivesHandledExternally +} + func (j ArchiveCatalogerConfig) mavenConfig() maven.Config { return maven.Config{ UseNetwork: j.UseNetwork, diff --git a/syft/pkg/cataloger/java/package_url_test.go b/syft/pkg/cataloger/java/package_url_test.go index 1661f3fdb..5433b1481 100644 --- a/syft/pkg/cataloger/java/package_url_test.go +++ b/syft/pkg/cataloger/java/package_url_test.go @@ -171,6 +171,37 @@ func Test_groupIDFromJavaMetadata(t *testing.T) { metadata: pkg.JavaArchive{}, expect: "org.apache.ant", }, + { + // regression for github.com/anchore/syft/issues/4030: spring-ldap artifacts + // live under org.springframework.ldap, not org.springframework + name: "known package list spring-ldap-core", + pkgName: "spring-ldap-core", + metadata: pkg.JavaArchive{}, + expect: "org.springframework.ldap", + }, + { + name: "known package list spring-ldap", + pkgName: "spring-ldap", + metadata: pkg.JavaArchive{}, + expect: "org.springframework.ldap", + }, + { + // regression for github.com/anchore/syft/issues/4598: legacy Jackson 1.x ("-asl") + // jars built before ~2014 have no embedded pom.properties, so without the known + // package list the group ID falls back to the artifact name itself, producing a + // purl that doesn't match the vulnerability database's namespace (e.g. the correct + // group for jackson-mapper-asl is org.codehaus.jackson, not jackson-mapper-asl). + name: "known package list jackson-mapper-asl", + pkgName: "jackson-mapper-asl", + metadata: pkg.JavaArchive{}, + expect: "org.codehaus.jackson", + }, + { + name: "known package list jackson-core-asl", + pkgName: "jackson-core-asl", + metadata: pkg.JavaArchive{}, + expect: "org.codehaus.jackson", + }, { name: "java manifest", metadata: pkg.JavaArchive{ diff --git a/syft/source/directorysource/directory_source.go b/syft/source/directorysource/directory_source.go index dcbbeaa62..e6bf6a516 100644 --- a/syft/source/directorysource/directory_source.go +++ b/syft/source/directorysource/directory_source.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "sync" @@ -18,7 +19,10 @@ import ( "github.com/anchore/syft/syft/source/internal" ) -var _ source.Source = (*directorySource)(nil) +var ( + _ source.Source = (*directorySource)(nil) + _ source.PathExcluder = (*directorySource)(nil) +) type Config struct { Path string @@ -114,6 +118,13 @@ func (s *directorySource) FileResolver(_ source.Scope) (file.Resolver, error) { return s.resolver, nil } +// ExcludedPaths returns a copy of the exclusion patterns this source was configured with, so a +// consumer indexing content taken from it can honor the same patterns. A copy, because +// GetDirectoryExclusionFunctions rewrites the patterns it is given. +func (s directorySource) ExcludedPaths() []string { + return slices.Clone(s.config.Exclude.Paths) +} + func (s *directorySource) Close() error { s.mutex.Lock() defer s.mutex.Unlock() @@ -127,7 +138,18 @@ func GetDirectoryExclusionFunctions(root string, exclusions []string) ([]fileres return nil, nil } - // this is what directoryResolver.indexTree is doing to get the absolute path: + // the indexer reports an absolute, symlink-resolved path to every visitor: the resolver normalizes + // its root with EvalSymlinks (fileresolver.NormalizeRootDirectory, via the chroot context) and the + // walk then takes filepath.Abs of it. The root these patterns are anchored to has to be derived + // the same way or they anchor to a path the walk never reports - on macOS a scan of a directory + // under /var, a symlink to /private/var, would exclude nothing at all. + // + // A root that cannot be resolved is left as it was rather than failing the scan: EvalSymlinks + // wants the path to exist, and the resolver reports a missing root far better than this can. + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved + } + root, err := filepath.Abs(root) if err != nil { return nil, err @@ -140,15 +162,20 @@ func GetDirectoryExclusionFunctions(root string, exclusions []string) ([]fileres root += "/" } + // the rooted patterns are built into a new slice rather than written back over the caller's: + // the patterns belong to the source's configuration, which is read elsewhere (see + // source.PathExcluder), and rewriting them in place would replace what the user configured with + // scan-root-absolute patterns as a side effect of building the resolver + rooted := make([]string, 0, len(exclusions)) var errors []string - for idx, exclusion := range exclusions { + for _, exclusion := range exclusions { // check exclusions for supported paths, these are all relative to the "scan root" if strings.HasPrefix(exclusion, "./") || strings.HasPrefix(exclusion, "*/") || strings.HasPrefix(exclusion, "**/") { exclusion = strings.TrimPrefix(exclusion, "./") // a trailing slash signals a directory but is otherwise discarded by doublestar.Match, // causing the pattern to silently match nothing (see issue #4839) exclusion = strings.TrimSuffix(exclusion, "/") - exclusions[idx] = root + exclusion + rooted = append(rooted, root+exclusion) } else { errors = append(errors, exclusion) } @@ -160,7 +187,7 @@ func GetDirectoryExclusionFunctions(root string, exclusions []string) ([]fileres return []fileresolver.PathIndexVisitor{ func(_, path string, info os.FileInfo, _ error) error { - for _, exclusion := range exclusions { + for _, exclusion := range rooted { // this is required to handle Windows filepaths path = filepath.ToSlash(path) matches, err := doublestar.Match(exclusion, path) diff --git a/syft/source/exclude.go b/syft/source/exclude.go index f41dc0e31..dbc5a9428 100644 --- a/syft/source/exclude.go +++ b/syft/source/exclude.go @@ -3,3 +3,22 @@ package source type ExcludeConfig struct { Paths []string } + +// PathExcluder is optionally implemented by a source that was configured with path exclusion +// patterns. It publishes those patterns so that a consumer which indexes content taken from the +// source as a filesystem of its own can honor the scan's exclusions rather than carry a second +// setting for the same purpose. The nested archive cataloger is that consumer: every archive it +// finds is extracted to a directory and indexed independently, and a pattern the scan excluded +// would otherwise be read inside an archive after being skipped outside one. +// +// Only patterns whose own shape is depth-independent are meaningful to such a consumer; a pattern +// anchored to the scan root describes a layout that exists only there. Deciding that is the +// consumer's business, so everything the source was given is published here. +// +// Implementations MUST return a copy. Sources that build directory resolvers hand their patterns to +// GetDirectoryExclusionFunctions, which rewrites what it is given to be absolute against the scan +// root, so a caller holding the same slice would find the patterns replaced the moment the source +// built its resolver. +type PathExcluder interface { + ExcludedPaths() []string +} diff --git a/syft/source/filesource/file_source.go b/syft/source/filesource/file_source.go index 1c49e4905..488740fd9 100644 --- a/syft/source/filesource/file_source.go +++ b/syft/source/filesource/file_source.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "slices" "sync" "github.com/mholt/archives" @@ -24,7 +25,10 @@ import ( "github.com/anchore/syft/syft/source/internal" ) -var _ source.Source = (*fileSource)(nil) +var ( + _ source.Source = (*fileSource)(nil) + _ source.PathExcluder = (*fileSource)(nil) +) type Config struct { Path string @@ -172,6 +176,13 @@ func (s fileSource) FileResolver(_ source.Scope) (file.Resolver, error) { return s.resolver, nil } +// ExcludedPaths returns a copy of the exclusion patterns this source was configured with, so a +// consumer indexing content taken from it can honor the same patterns. A copy, because +// directorysource.GetDirectoryExclusionFunctions rewrites the patterns it is given. +func (s fileSource) ExcludedPaths() []string { + return slices.Clone(s.config.Exclude.Paths) +} + func (s *fileSource) Close() error { s.mutex.Lock() defer s.mutex.Unlock() diff --git a/syft/source/stereoscopesource/image_source.go b/syft/source/stereoscopesource/image_source.go index c6dc29390..fa257313e 100644 --- a/syft/source/stereoscopesource/image_source.go +++ b/syft/source/stereoscopesource/image_source.go @@ -2,6 +2,7 @@ package stereoscopesource import ( "fmt" + "slices" "github.com/bmatcuk/doublestar/v4" "github.com/distribution/reference" @@ -15,7 +16,10 @@ import ( "github.com/anchore/syft/syft/source/internal" ) -var _ source.Source = (*stereoscopeImageSource)(nil) +var ( + _ source.Source = (*stereoscopeImageSource)(nil) + _ source.PathExcluder = (*stereoscopeImageSource)(nil) +) type ImageConfig struct { Reference string @@ -122,6 +126,18 @@ func (s stereoscopeImageSource) FileResolver(scope source.Scope) (file.Resolver, return res, nil } +// ExcludedPaths returns a copy of the exclusion patterns this source was configured with, so a +// consumer indexing content taken from it can honor the same patterns. +// +// An image filters its own tree after indexing, with NewExcludingDecorator above rather than an +// index visitor, because the tree is built by stereoscope and not walked here. That is a fact about +// the image's own filesystem: content taken out of it and indexed separately - an archive extracted +// to a directory - is a directory index like any other and is filtered like one. So the patterns +// travel, and the shape of each one still decides whether it reaches inside an archive. +func (s stereoscopeImageSource) ExcludedPaths() []string { + return slices.Clone(s.config.Exclude.Paths) +} + func (s stereoscopeImageSource) Close() error { if s.image == nil { return nil