mirror of
https://github.com/anchore/syft.git
synced 2026-10-23 19:42:23 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e11775c8b | ||
|
|
8ed534c32b | ||
|
|
aff2c247db | ||
|
|
ed499fd2d0 | ||
|
|
ad828d659f | ||
|
|
2805655ab0 | ||
|
|
552ddbfadb | ||
|
|
f6b5d3e736 | ||
|
|
4312699dd6 | ||
|
|
fabd760914 | ||
|
|
e854078fb0 | ||
|
|
987ae7f26a | ||
|
|
76ede661db | ||
|
|
da77ea388a | ||
|
|
3cc3ecb003 | ||
|
|
7745e450fa | ||
|
|
bd0917fe93 | ||
|
|
1dcac54b0e | ||
|
|
3252b6f806 | ||
|
|
c4a0b6f647 | ||
|
|
5b630ec61c | ||
|
|
e8abab2e77 | ||
|
|
002a326c41 | ||
|
|
3e399256b3 | ||
|
|
ebac92a86e | ||
|
|
aebdf4bff4 | ||
|
|
f1457f2554 | ||
|
|
e4f5df936c | ||
|
|
8c0d21e147 | ||
|
|
6f5c83a558 | ||
|
|
ee48c01c06 | ||
|
|
f6bf15bb3c | ||
|
|
312f2532d0 | ||
|
|
f2c9463e76 | ||
|
|
4eb61fc104 | ||
|
|
832d96c1dd | ||
|
|
5a7a72b53e | ||
|
|
df1fc98e7a | ||
|
|
656a4d46d7 | ||
|
|
148fe572bc | ||
|
|
deee79411a | ||
|
|
e7f1a803e7 | ||
|
|
a34f86fba1 | ||
|
|
e388b5249d | ||
|
|
1746e96ad3 | ||
|
|
956858fc11 | ||
|
|
37fee88b5c | ||
|
|
1143c12a97 |
@@ -30,7 +30,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup go + go-make tooling
|
||||
uses: anchore/go-make/.github/actions/setup@39fe5f71112d4dceb3ff0a92a40f272f067fc457 # v0.6.0
|
||||
uses: anchore/go-make/.github/actions/setup@430e2175bb166bfd138ef75466adbcc8ddab875f # v0.8.0
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
cache-key-prefix: ${{ inputs.cache-key-prefix }}
|
||||
@@ -52,7 +52,7 @@ runs:
|
||||
# ORAS cache: restore-only on non-default branches / forks
|
||||
- name: Restore ORAS cache from github actions
|
||||
if: ${{ inputs.download-test-fixture-cache == 'true' && (github.ref != format('refs/heads/{0}', github.event.repository.default_branch) || github.event.repository.fork == true) }}
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp/oras-cache
|
||||
key: ${{ inputs.cache-key-prefix }}-oras-cache
|
||||
@@ -60,7 +60,7 @@ runs:
|
||||
# ORAS cache: restore + save on the default branch of the canonical repo only.
|
||||
- name: Restore and save ORAS cache from github actions
|
||||
if: ${{ inputs.download-test-fixture-cache == 'true' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.repository.fork == false }}
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp/oras-cache
|
||||
key: ${{ inputs.cache-key-prefix }}-oras-cache
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions: {}
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
uses: anchore/workflows/.github/workflows/codeql.yaml@b0c30a80409130d329aaa356fd64a34d8c0b3375 # v0.7.2
|
||||
uses: anchore/workflows/.github/workflows/codeql.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # required for getting the status of specific check names
|
||||
uses: anchore/workflows/.github/workflows/check-gate.yaml@b0c30a80409130d329aaa356fd64a34d8c0b3375 # v0.7.2
|
||||
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.
|
||||
@@ -63,14 +63,19 @@ jobs:
|
||||
# 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@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
@@ -79,13 +84,13 @@ jobs:
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 #v4.4.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 #v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -119,7 +124,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@b0c30a80409130d329aaa356fd64a34d8c0b3375 # v0.7.2
|
||||
uses: anchore/workflows/.github/workflows/release-install-script.yaml@7212994dc8fc3a53fe9c8e766ab5b4ddd16ea3d4 # v0.8.0
|
||||
with:
|
||||
tag: ${{ github.event.inputs.version }}
|
||||
secrets:
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: read
|
||||
security-events: write # for uploading SARIF results
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run zizmor"
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
with:
|
||||
# there is a pass/fail gate as a repo ruleset (if there is no ruleset configured then the action will pass by default)
|
||||
advanced-security: true
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,14 +92,20 @@ jobs:
|
||||
# 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
|
||||
# tmpfs: faster io-intensive workflows
|
||||
# tmpfs intentionally omitted -- a RAM-backed dist/ competes with the goreleaser
|
||||
# build's memory peak (parallel binaries + multi-arch image assembly) and OOMs the
|
||||
# instance; keep dist/ on disk.
|
||||
# note: s3-cache intentionally omitted -- PR runs are untrusted and must not write to the
|
||||
# shared cache backend that the trusted release workflow reads from (cache poisoning).
|
||||
runs-on: "runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=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.
|
||||
runs-on: "runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/volume=120gb:gp3:500mbs:4000iops"
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -129,7 +135,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -173,7 +179,7 @@ jobs:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -207,7 +213,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
+76
-294
@@ -73,306 +73,88 @@ brews:
|
||||
description: *description
|
||||
license: "Apache License 2.0"
|
||||
|
||||
dockers:
|
||||
# production images...
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-amd64
|
||||
goarch: amd64
|
||||
dockers_v2:
|
||||
# production images (scratch base, root)
|
||||
- id: production
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
ids: &docker-ids
|
||||
- linux-build
|
||||
images: &docker-images
|
||||
- anchore/syft
|
||||
- ghcr.io/anchore/syft
|
||||
platforms: &docker-platforms
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/ppc64le
|
||||
- linux/riscv64
|
||||
- linux/s390x
|
||||
labels: &docker-labels
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "syft"
|
||||
"org.opencontainers.image.description": "CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.vendor": "Anchore, Inc."
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.licenses": "Apache-2.0"
|
||||
"io.artifacthub.package.readme-url": "https://raw.githubusercontent.com/anchore/syft/main/README.md"
|
||||
"io.artifacthub.package.logo-url": "https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
|
||||
"io.artifacthub.package.license": "Apache-2.0"
|
||||
tags:
|
||||
- latest
|
||||
- "{{.Tag}}"
|
||||
# DEBIAN_VERSION 13 (trixie) is the first distroless release with a riscv64 base image
|
||||
# and is a superset of debian 12 for the other targeted arches; docker_v2 builds all
|
||||
# platforms in a single buildx invocation, so build_args cannot vary per platform.
|
||||
build_args: &docker-build-args
|
||||
DEBIAN_VERSION: "13"
|
||||
# disable provenance attestations to keep the manifest free of unknown/unknown entries
|
||||
flags: &docker-flags
|
||||
- "--provenance=false"
|
||||
# SBOMs are produced separately for the archives (see the sboms section)
|
||||
sbom: &docker-sbom "false"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
goarch: ppc64le
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/ppc64le"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-riscv64
|
||||
goarch: riscv64
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/riscv64"
|
||||
- "--build-arg=DEBIAN_VERSION=13"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
goarch: s390x
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/s390x"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
# nonroot images...
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
goarch: amd64
|
||||
# nonroot images
|
||||
- id: nonroot
|
||||
dockerfile: Dockerfile.nonroot
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
ids: *docker-ids
|
||||
images: *docker-images
|
||||
platforms: *docker-platforms
|
||||
labels: *docker-labels
|
||||
tags:
|
||||
- nonroot
|
||||
- "{{.Tag}}-nonroot"
|
||||
build_args: *docker-build-args
|
||||
flags: *docker-flags
|
||||
sbom: *docker-sbom
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile.nonroot
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
goarch: ppc64le
|
||||
dockerfile: Dockerfile.nonroot
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/ppc64le"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-riscv64
|
||||
goarch: riscv64
|
||||
dockerfile: Dockerfile.nonroot
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/riscv64"
|
||||
- "--build-arg=DEBIAN_VERSION=13"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
goarch: s390x
|
||||
dockerfile: Dockerfile.nonroot
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/s390x"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
# debug images...
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-amd64
|
||||
goarch: amd64
|
||||
# debug images (root)
|
||||
- id: debug
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
ids: *docker-ids
|
||||
images: *docker-images
|
||||
platforms: *docker-platforms
|
||||
labels: *docker-labels
|
||||
tags:
|
||||
- debug
|
||||
- "{{.Tag}}-debug"
|
||||
build_args: *docker-build-args
|
||||
flags: *docker-flags
|
||||
sbom: *docker-sbom
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
goarch: ppc64le
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/ppc64le"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-riscv64
|
||||
goarch: riscv64
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/riscv64"
|
||||
- "--build-arg=DEBIAN_VERSION=13"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
- image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
goarch: s390x
|
||||
dockerfile: Dockerfile.debug
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/s390x"
|
||||
- "--build-arg=BUILD_DATE={{.Date}}"
|
||||
- "--build-arg=BUILD_VERSION={{.Version}}"
|
||||
- "--build-arg=VCS_REF={{.FullCommit}}"
|
||||
- "--build-arg=VCS_URL={{.GitURL}}"
|
||||
|
||||
docker_manifests:
|
||||
- name_template: anchore/syft:latest
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-amd64
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- anchore/syft:{{.Tag}}-riscv64
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:latest
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-amd64
|
||||
- anchore/syft:{{.Tag}}-arm64v8
|
||||
- anchore/syft:{{.Tag}}-ppc64le
|
||||
- anchore/syft:{{.Tag}}-riscv64
|
||||
- anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-s390x
|
||||
|
||||
# nonroot images...
|
||||
- name_template: anchore/syft:nonroot
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- anchore/syft:{{.Tag}}-nonroot-riscv64
|
||||
- anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:nonroot
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}-nonroot
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- anchore/syft:{{.Tag}}-nonroot-riscv64
|
||||
- anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}-nonroot
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-nonroot-s390x
|
||||
|
||||
# debug images...
|
||||
- name_template: anchore/syft:debug
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-amd64
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- anchore/syft:{{.Tag}}-debug-riscv64
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:debug
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: anchore/syft:{{.Tag}}-debug
|
||||
image_templates:
|
||||
- anchore/syft:{{.Tag}}-debug-amd64
|
||||
- anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- anchore/syft:{{.Tag}}-debug-riscv64
|
||||
- anchore/syft:{{.Tag}}-debug-s390x
|
||||
|
||||
- name_template: ghcr.io/anchore/syft:{{.Tag}}-debug
|
||||
image_templates:
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-amd64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-riscv64
|
||||
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
|
||||
# debug-nonroot images
|
||||
- id: debug-nonroot
|
||||
dockerfile: Dockerfile.debug-nonroot
|
||||
ids: *docker-ids
|
||||
images: *docker-images
|
||||
platforms: *docker-platforms
|
||||
labels: *docker-labels
|
||||
tags:
|
||||
- debug-nonroot
|
||||
- "{{.Tag}}-debug-nonroot"
|
||||
build_args: *docker-build-args
|
||||
flags: *docker-flags
|
||||
sbom: *docker-sbom
|
||||
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
|
||||
+3
-19
@@ -1,4 +1,4 @@
|
||||
ARG DEBIAN_VERSION=12
|
||||
ARG DEBIAN_VERSION=13
|
||||
FROM gcr.io/distroless/static-debian${DEBIAN_VERSION}:latest AS build
|
||||
|
||||
FROM scratch
|
||||
@@ -8,23 +8,7 @@ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifica
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
COPY syft /
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
ARG VCS_REF
|
||||
ARG VCS_URL
|
||||
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||
LABEL org.opencontainers.image.title="syft"
|
||||
LABEL org.opencontainers.image.description="CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
|
||||
LABEL org.opencontainers.image.source=$VCS_URL
|
||||
LABEL org.opencontainers.image.revision=$VCS_REF
|
||||
LABEL org.opencontainers.image.vendor="Anchore, Inc."
|
||||
LABEL org.opencontainers.image.version=$BUILD_VERSION
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
|
||||
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
|
||||
LABEL io.artifacthub.package.license="Apache-2.0"
|
||||
ARG TARGETPLATFORM
|
||||
COPY ${TARGETPLATFORM}/syft /
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
|
||||
+4
-22
@@ -1,28 +1,10 @@
|
||||
ARG DEBIAN_VERSION=12
|
||||
FROM gcr.io/distroless/static-debian${DEBIAN_VERSION}:debug-nonroot
|
||||
ARG DEBIAN_VERSION=13
|
||||
FROM gcr.io/distroless/static-debian${DEBIAN_VERSION}:debug
|
||||
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
COPY syft /
|
||||
|
||||
USER nonroot
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
ARG VCS_REF
|
||||
ARG VCS_URL
|
||||
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||
LABEL org.opencontainers.image.title="syft"
|
||||
LABEL org.opencontainers.image.description="CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
|
||||
LABEL org.opencontainers.image.source=$VCS_URL
|
||||
LABEL org.opencontainers.image.revision=$VCS_REF
|
||||
LABEL org.opencontainers.image.vendor="Anchore, Inc."
|
||||
LABEL org.opencontainers.image.version=$BUILD_VERSION
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
|
||||
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
|
||||
LABEL io.artifacthub.package.license="Apache-2.0"
|
||||
ARG TARGETPLATFORM
|
||||
COPY ${TARGETPLATFORM}/syft /
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
ARG DEBIAN_VERSION=13
|
||||
FROM gcr.io/distroless/static-debian${DEBIAN_VERSION}:debug-nonroot
|
||||
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
COPY ${TARGETPLATFORM}/syft /
|
||||
|
||||
USER nonroot
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
+3
-19
@@ -1,28 +1,12 @@
|
||||
ARG DEBIAN_VERSION=12
|
||||
ARG DEBIAN_VERSION=13
|
||||
FROM gcr.io/distroless/static-debian${DEBIAN_VERSION}:nonroot
|
||||
|
||||
# create the /tmp dir, which is needed for image content cache
|
||||
WORKDIR /tmp
|
||||
|
||||
COPY syft /
|
||||
ARG TARGETPLATFORM
|
||||
COPY ${TARGETPLATFORM}/syft /
|
||||
|
||||
USER nonroot
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
ARG VCS_REF
|
||||
ARG VCS_URL
|
||||
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||
LABEL org.opencontainers.image.title="syft"
|
||||
LABEL org.opencontainers.image.description="CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
|
||||
LABEL org.opencontainers.image.source=$VCS_URL
|
||||
LABEL org.opencontainers.image.revision=$VCS_REF
|
||||
LABEL org.opencontainers.image.vendor="Anchore, Inc."
|
||||
LABEL org.opencontainers.image.version=$BUILD_VERSION
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
|
||||
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
|
||||
LABEL io.artifacthub.package.license="Apache-2.0"
|
||||
|
||||
ENTRYPOINT ["/syft"]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="https://goreportcard.com/report/github.com/anchore/syft" target="_blank"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/anchore/syft"></a>
|
||||
<a href="https://github.com/anchore/syft/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/release/anchore/syft.svg"></a>
|
||||
<a href="https://github.com/anchore/syft" target="_blank"><img alt="GitHub go.mod Go version" src="https://img.shields.io/github/go-mod/go-version/anchore/syft.svg"></a>
|
||||
<a href="" target="_blank"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/License-Apache%202.0-blue.svg"></a>
|
||||
<a href="https://github.com/anchore/syft/blob/main/LICENSE" target="_blank"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/License-Apache%202.0-blue.svg"></a>
|
||||
<a href="https://anchore.com/discourse" target="_blank"><img alt="Join our Discourse" src="https://img.shields.io/badge/Discourse-Join-blue?logo=discourse"/></a>
|
||||
<a rel="me" href="https://fosstodon.org/@syft"><img alt="Follow on Mastodon" src="https://img.shields.io/badge/Mastodon-Follow-blue?logoColor=white&logo=mastodon"/></a>
|
||||
</p>
|
||||
|
||||
+8
-2
@@ -39,6 +39,12 @@ vars:
|
||||
PROJECT_ROOT:
|
||||
sh: echo $PWD
|
||||
|
||||
# docker platform suffix goreleaser appends to snapshot image tags (e.g. latest-amd64).
|
||||
# snapshot builds load per-arch images rather than a multi-arch manifest, so the smoke
|
||||
# test must run the host-native tag to avoid emulation (and red-local/green-CI splits).
|
||||
DOCKER_ARCH:
|
||||
sh: go env GOARCH
|
||||
|
||||
# note: the snapshot dir must be a relative path starting with ./
|
||||
# e.g. when installing snapshot debs from a local path, ./ forces the deb to be installed in the current working directory instead of referencing a package name
|
||||
SNAPSHOT_DIR: ./snapshot
|
||||
@@ -129,8 +135,8 @@ tasks:
|
||||
silent: true
|
||||
- "{{ .SNAPSHOT_BIN }} version"
|
||||
- "{{ .SNAPSHOT_BIN }} scan alpine:latest"
|
||||
- docker run --rm anchore/syft:latest version
|
||||
- docker run --rm anchore/syft:latest scan alpine:latest
|
||||
- docker run --pull=never --rm anchore/syft:latest-{{ .DOCKER_ARCH }} version
|
||||
- docker run --pull=never --rm anchore/syft:latest-{{ .DOCKER_ARCH }} scan alpine:latest
|
||||
|
||||
## Test-fixture-related targets ###########################################
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -171,6 +172,11 @@ func validateArgs(cmd *cobra.Command, args []string, err string) error {
|
||||
}
|
||||
|
||||
func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, userInput string) error {
|
||||
scanStart := time.Now()
|
||||
defer func() {
|
||||
log.Infof("scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
|
||||
}()
|
||||
|
||||
writer, err := opts.SBOMWriter()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/anchore/syft/syft/file/cataloger/executable"
|
||||
"github.com/anchore/syft/syft/file/cataloger/filecontent"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/cpp"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/dotnet"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/golang"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/java"
|
||||
@@ -44,6 +45,7 @@ type Catalog struct {
|
||||
Enrich []string `yaml:"enrich" json:"enrich" mapstructure:"enrich"`
|
||||
|
||||
// ecosystem-specific cataloger configuration
|
||||
Cpp cppConfig `yaml:"cpp" json:"cpp" mapstructure:"cpp"`
|
||||
Dotnet dotnetConfig `yaml:"dotnet" json:"dotnet" mapstructure:"dotnet"`
|
||||
Golang golangConfig `yaml:"golang" json:"golang" mapstructure:"golang"`
|
||||
Java javaConfig `yaml:"java" json:"java" mapstructure:"java"`
|
||||
@@ -80,6 +82,7 @@ func DefaultCatalog() Catalog {
|
||||
JavaScript: defaultJavaScriptConfig(),
|
||||
Python: defaultPythonConfig(),
|
||||
Nix: defaultNixConfig(),
|
||||
Cpp: defaultCppConfig(),
|
||||
Dotnet: defaultDotnetConfig(),
|
||||
Golang: defaultGolangConfig(),
|
||||
Java: defaultJavaConfig(),
|
||||
@@ -172,11 +175,15 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
|
||||
}
|
||||
return pkgcataloging.Config{
|
||||
Binary: binary.DefaultClassifierCatalogerConfig(),
|
||||
Cpp: cpp.DefaultCatalogerConfig().
|
||||
WithVcpkgAllowGitClone(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.Cpp, task.Vcpkg), cfg.Cpp.VcpkgAllowGitClone)),
|
||||
Dotnet: dotnet.DefaultCatalogerConfig().
|
||||
WithDepPackagesMustHaveDLL(cfg.Dotnet.DepPackagesMustHaveDLL).
|
||||
WithDepPackagesMustClaimDLL(cfg.Dotnet.DepPackagesMustClaimDLL).
|
||||
WithPropagateDLLClaimsToParents(cfg.Dotnet.PropagateDLLClaimsToParents).
|
||||
WithRelaxDLLClaimsWhenBundlingDetected(cfg.Dotnet.RelaxDLLClaimsWhenBundlingDetected),
|
||||
WithRelaxDLLClaimsWhenBundlingDetected(cfg.Dotnet.RelaxDLLClaimsWhenBundlingDetected).
|
||||
WithExcludeProjectReferences(cfg.Dotnet.ExcludeProjectReferences),
|
||||
|
||||
Golang: golang.DefaultCatalogerConfig().
|
||||
WithSearchLocalModCacheLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.SearchLocalModCacheLicenses)).
|
||||
WithLocalModCacheDir(cfg.Golang.LocalModCacheDir).
|
||||
@@ -191,7 +198,8 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
|
||||
WithFromBuildSettings(cfg.Golang.MainModuleVersion.FromBuildSettings).
|
||||
WithFromLDFlags(cfg.Golang.MainModuleVersion.FromLDFlags),
|
||||
).
|
||||
WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)),
|
||||
WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)).
|
||||
WithCaptureSymbols(cfg.Golang.CaptureSymbols),
|
||||
JavaScript: javascript.DefaultCatalogerConfig().
|
||||
WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)).
|
||||
WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)).
|
||||
@@ -299,6 +307,7 @@ var publicisedEnrichmentOptions = []string{
|
||||
task.Java,
|
||||
task.JavaScript,
|
||||
task.Python,
|
||||
task.Vcpkg,
|
||||
}
|
||||
|
||||
func enrichmentEnabled(enrichDirectives []string, features ...string) *bool {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/cpp"
|
||||
)
|
||||
|
||||
type cppConfig struct {
|
||||
VcpkgAllowGitClone *bool `yaml:"vcpkg-allow-git-clone" json:"vcpkg-allow-git-clone" mapstructure:"vcpkg-allow-git-clone"`
|
||||
}
|
||||
|
||||
func defaultCppConfig() cppConfig {
|
||||
// reference the cataloger default so capability generation can associate this config with the cpp
|
||||
// ecosystem (it discovers ecosystem configs by their cataloger import). the value itself stays nil:
|
||||
// nil defaults to false (no network), and leaving it unset lets --enrich opt in. cloning requires a
|
||||
// network connection, which must be opt-in.
|
||||
_ = cpp.DefaultCatalogerConfig()
|
||||
return cppConfig{
|
||||
VcpkgAllowGitClone: nil,
|
||||
}
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FieldDescriber
|
||||
} = (*cppConfig)(nil)
|
||||
|
||||
func (o *cppConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
|
||||
descriptions.Add(&o.VcpkgAllowGitClone, `enables Syft to use clone remote repositories for vcpkg custom git registries.
|
||||
(also useful if the builtin vcpkg registry is not cloned locally)`)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/golang"
|
||||
)
|
||||
|
||||
@@ -17,10 +18,12 @@ type golangConfig struct {
|
||||
NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"`
|
||||
MainModuleVersion golangMainModuleVersionConfig `json:"main-module-version" yaml:"main-module-version" mapstructure:"main-module-version"`
|
||||
UsePackagesLib *bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"`
|
||||
CaptureSymbols cataloging.SymbolScope `json:"capture-symbols" yaml:"capture-symbols" mapstructure:"capture-symbols"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FieldDescriber
|
||||
clio.PostLoader
|
||||
} = (*golangConfig)(nil)
|
||||
|
||||
func (o *golangConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
|
||||
@@ -39,12 +42,19 @@ if unset this defaults to $GONOPROXY`)
|
||||
always show (devel) as the version. Use these options to control heuristics to guess
|
||||
a more accurate version from the binary.`)
|
||||
descriptions.Add(&o.UsePackagesLib, `use the golang.org/x/tools/go/packages library, which executes golang tooling found on the path in addition to potential network access to get the most accurate results`)
|
||||
descriptions.Add(&o.CaptureSymbols, `capture function symbols from the binary symbol table (pclntab). valid values are:
|
||||
"none" (disabled), "stdlib" (only the synthetic stdlib package), and "all" (all module packages plus stdlib)`)
|
||||
descriptions.Add(&o.MainModuleVersion.FromLDFlags, `look for LD flags that appear to be setting a version (e.g. -X main.version=1.0.0)`)
|
||||
descriptions.Add(&o.MainModuleVersion.FromBuildSettings, `use the build settings (e.g. vcs.version & vcs.time) to craft a v0 pseudo version
|
||||
descriptions.Add(&o.MainModuleVersion.FromBuildSettings, `use the build settings (e.g. vcs.version & vcs.time) to craft a v0 pseudo version
|
||||
(e.g. v0.0.0-20220308212642-53e6d0aaf6fb) when a more accurate version cannot be found otherwise`)
|
||||
descriptions.Add(&o.MainModuleVersion.FromContents, `search for semver-like strings in the binary contents`)
|
||||
}
|
||||
|
||||
func (o *golangConfig) PostLoad() error {
|
||||
o.CaptureSymbols = o.CaptureSymbols.Parse()
|
||||
return nil
|
||||
}
|
||||
|
||||
type golangMainModuleVersionConfig struct {
|
||||
FromLDFlags bool `json:"from-ld-flags" yaml:"from-ld-flags" mapstructure:"from-ld-flags"`
|
||||
FromContents bool `json:"from-contents" yaml:"from-contents" mapstructure:"from-contents"`
|
||||
@@ -67,5 +77,6 @@ func defaultGolangConfig() golangConfig {
|
||||
FromBuildSettings: def.MainModuleVersion.FromBuildSettings,
|
||||
},
|
||||
UsePackagesLib: nil, // this defaults to true, which is the API default
|
||||
CaptureSymbols: def.CaptureSymbols,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
|
||||
func Test_golangConfig_PostLoad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg golangConfig
|
||||
expected cataloging.SymbolScope
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "normalize all",
|
||||
cfg: golangConfig{CaptureSymbols: "all"},
|
||||
expected: cataloging.SymbolScopeAll,
|
||||
},
|
||||
{
|
||||
name: "normalize stdlib",
|
||||
cfg: golangConfig{CaptureSymbols: "stdlib"},
|
||||
expected: cataloging.SymbolScopeStdlib,
|
||||
},
|
||||
{
|
||||
name: "empty defaults to none",
|
||||
cfg: golangConfig{CaptureSymbols: ""},
|
||||
expected: cataloging.SymbolScopeNone,
|
||||
},
|
||||
{
|
||||
name: "invalid value defaults to none",
|
||||
cfg: golangConfig{CaptureSymbols: "bogus"},
|
||||
expected: cataloging.SymbolScopeNone,
|
||||
},
|
||||
{
|
||||
name: "boolean spellings default to none",
|
||||
cfg: golangConfig{CaptureSymbols: "true"},
|
||||
expected: cataloging.SymbolScopeNone,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = assert.NoError
|
||||
}
|
||||
err := tt.cfg.PostLoad()
|
||||
tt.wantErr(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.expected, tt.cfg.CaptureSymbols)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@ func TestPkgCoverageImage(t *testing.T) {
|
||||
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // we have coverage for pear instead
|
||||
definedPkgs.Remove(string(pkg.CondaPkg))
|
||||
definedPkgs.Remove(string(pkg.ModelPkg))
|
||||
definedPkgs.Remove(string(pkg.VcpkgPkg))
|
||||
definedPkgs.Remove(string(pkg.AppleAppBundlePkg))
|
||||
|
||||
var cases []testCase
|
||||
cases = append(cases, commonTestCases...)
|
||||
@@ -164,6 +166,8 @@ func TestPkgCoverageDirectory(t *testing.T) {
|
||||
definedPkgs.Remove(string(pkg.CondaPkg))
|
||||
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // this is covered as pear packages
|
||||
definedPkgs.Remove(string(pkg.ModelPkg))
|
||||
definedPkgs.Remove(string(pkg.VcpkgPkg))
|
||||
definedPkgs.Remove(string(pkg.AppleAppBundlePkg))
|
||||
|
||||
// for directory scans we should not expect to see any of the following package types
|
||||
definedPkgs.Remove(string(pkg.KbPkg))
|
||||
|
||||
@@ -100,7 +100,7 @@ func (m *UI) Teardown(force bool) error {
|
||||
})
|
||||
|
||||
// it may be tempting to use Kill() however it has been found that this can cause the terminal to be left in
|
||||
// a bad state (where Ctrl+C and other control characters no longer works for future processes in that terminal).
|
||||
// a bad state (where Ctrl+C and other control characters no longer work for future processes in that terminal).
|
||||
m.program.Quit()
|
||||
|
||||
_ = runWithTimeout(250*time.Millisecond, func() error {
|
||||
|
||||
+3
-1
@@ -5,8 +5,10 @@ This directory contains examples of how to use the Syft API.
|
||||
- `create_simple_sbom`: Create a simple SBOM from scratch
|
||||
- `create_custom_sbom`: Create an SBOM using as much custom configuration as possible, including a custom cataloger implementation
|
||||
- `decode_sbom`: Take an existing SBOM file (of arbitrary format) and decode it into a Syft SBOM object
|
||||
- `select_catalogers`: Example demonstrating how to select specific catalogers to run
|
||||
- `source_detection`: Shows how to detect what to catalog automatically from a user string (e.g. container image vs directory)
|
||||
- `source_from_image`: Construct a source from a only a container image
|
||||
- `source_from_image`: Construct a source from only a container image
|
||||
- `source_from_registry`: Construct a source directly from a container registry
|
||||
|
||||
You can run any of these examples from this directory with:
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ require (
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.1
|
||||
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/klauspost/compress v1.19.0
|
||||
github.com/magiconair/properties v1.8.10
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/moby/sys/mountinfo v0.7.2
|
||||
@@ -96,14 +96,15 @@ require (
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.46.0
|
||||
golang.org/x/tools v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1
|
||||
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -323,7 +324,7 @@ require (
|
||||
google.golang.org/grpc v1.80.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.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -626,6 +626,7 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.1 h1:0fkCNhjrX0zPpwkWaDYU5VMrygg41Tu197mWILIJoqQ=
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
@@ -644,8 +645,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/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=
|
||||
@@ -1314,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.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
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=
|
||||
@@ -1489,6 +1490,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -1509,20 +1511,22 @@ 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=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
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=
|
||||
@@ -1531,8 +1535,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
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=
|
||||
|
||||
+8
-7
@@ -35,7 +35,8 @@ is_command() (
|
||||
)
|
||||
|
||||
echo_stderr() (
|
||||
echo "$@" 1>&2
|
||||
IFS=" "
|
||||
printf "%b\n" "$*" 1>&2
|
||||
)
|
||||
|
||||
_logp=2
|
||||
@@ -75,12 +76,12 @@ init_colors
|
||||
|
||||
log_tag() (
|
||||
case $1 in
|
||||
0) echo "${RED}${BOLD}[error]${RESET}" ;;
|
||||
1) echo "${RED}[warn]${RESET}" ;;
|
||||
2) echo "[info]${RESET}" ;;
|
||||
3) echo "${BLUE}[debug]${RESET}" ;;
|
||||
4) echo "${PURPLE}[trace]${RESET}" ;;
|
||||
*) echo "[$1]" ;;
|
||||
0) printf "%b" "${RED}${BOLD}[error]${RESET}" ;;
|
||||
1) printf "%b" "${RED}[warn]${RESET}" ;;
|
||||
2) printf "%b" "[info]${RESET}" ;;
|
||||
3) printf "%b" "${BLUE}[debug]${RESET}" ;;
|
||||
4) printf "%b" "${PURPLE}[trace]${RESET}" ;;
|
||||
*) printf "[%s]" "$1" ;;
|
||||
esac
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# This file is partially auto-generated. Run 'go generate ./internal/capabilities' to regenerate.
|
||||
|
||||
application: # AUTO-GENERATED - application-level config keys
|
||||
- key: cpp.vcpkg-allow-git-clone
|
||||
description: enables Syft to use clone remote repositories for vcpkg custom git registries. (also useful if the builtin vcpkg registry is not cloned locally)
|
||||
- key: dotnet.dep-packages-must-claim-dll
|
||||
description: only keep dep.json packages which have a runtime/resource DLL claimed in the deps.json targets section (but not necessarily found on disk). The package is also included if any child package claims a DLL, even if the package itself does not claim a DLL.
|
||||
- key: dotnet.dep-packages-must-have-dll
|
||||
@@ -12,6 +14,8 @@ application: # AUTO-GENERATED - application-level config keys
|
||||
description: treat DLL claims or on-disk evidence for child packages as DLL claims or on-disk evidence for any parent package
|
||||
- key: dotnet.relax-dll-claims-when-bundling-detected
|
||||
description: show all packages from the deps.json if bundling tooling is present as a dependency (e.g. ILRepack)
|
||||
- key: golang.capture-symbols
|
||||
description: 'capture function symbols from the binary symbol table (pclntab). valid values are: "none" (disabled), "stdlib" (only the synthetic stdlib package), and "all" (all module packages plus stdlib)'
|
||||
- key: golang.local-mod-cache-dir
|
||||
description: specify an explicit go mod cache directory, if unset this defaults to $GOPATH/pkg/mod or $HOME/go/pkg/mod
|
||||
- key: golang.local-vendor-dir
|
||||
|
||||
@@ -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.5"
|
||||
JSONSchemaVersion = "16.1.10"
|
||||
|
||||
// Changelog
|
||||
// 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field).
|
||||
@@ -12,5 +12,9 @@ const (
|
||||
// 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
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ package os
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"github.com/anchore/syft/internal/sbomsync"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/linux"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
@@ -19,8 +22,11 @@ func DetectFeatures(_ context.Context, resolver file.Resolver, builder sbomsync.
|
||||
return
|
||||
}
|
||||
|
||||
err := findRhelFeatures(resolver, s.Artifacts.LinuxDistribution)
|
||||
if err != nil {
|
||||
if err := findRhelFeatures(resolver, s.Artifacts.LinuxDistribution); err != nil {
|
||||
log.WithFields("error", err, "release", s.Artifacts.LinuxDistribution).Trace("error searching for extended support")
|
||||
}
|
||||
|
||||
if err := findUbuntuFeatures(resolver, s.Artifacts.LinuxDistribution, s.Artifacts.Packages); err != nil {
|
||||
log.WithFields("error", err, "release", s.Artifacts.LinuxDistribution).Trace("error searching for extended support")
|
||||
}
|
||||
})
|
||||
@@ -28,6 +34,9 @@ func DetectFeatures(_ context.Context, resolver file.Resolver, builder sbomsync.
|
||||
return nil
|
||||
}
|
||||
|
||||
// rhelEUSPatterns match a RHEL content manifest referencing an extended-update-support (EUS) repo.
|
||||
var rhelEUSPatterns = []*regexp.Regexp{regexp.MustCompile(`baseos-eus`), regexp.MustCompile(`baseos-eus|appstream-eus`)}
|
||||
|
||||
func findRhelFeatures(resolver file.Resolver, release *linux.Release) error {
|
||||
if release == nil || release.ID != "rhel" {
|
||||
return nil
|
||||
@@ -37,9 +46,9 @@ func findRhelFeatures(resolver file.Resolver, release *linux.Release) error {
|
||||
return fmt.Errorf("unable to find content manifests: %w", err)
|
||||
}
|
||||
for _, contentManifestFile := range contentManifestFiles {
|
||||
found, err := hasRhelExtendedSupportInContentManifest(resolver, contentManifestFile)
|
||||
found, err := fileMatchesAny(resolver, contentManifestFile, rhelEUSPatterns...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read content manifest from %s: %w", contentManifestFile.String(), err)
|
||||
return fmt.Errorf("unable to read content manifest from %s: %w", contentManifestFile.RealPath, err)
|
||||
}
|
||||
if found {
|
||||
release.ExtendedSupport = true
|
||||
@@ -49,13 +58,152 @@ func findRhelFeatures(resolver file.Resolver, release *linux.Release) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasRhelExtendedSupportInContentManifest(resolver file.Resolver, contentManifestFile file.Location) (bool, error) {
|
||||
contents, err := resolver.FileContentsByLocation(contentManifestFile)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to read content manifest from %s: %w", contentManifestFile.String(), err)
|
||||
}
|
||||
defer internal.CloseAndLogError(contents, "content-manifest")
|
||||
// Ubuntu Pro (formerly Ubuntu Advantage) is Canonical's subscription that unlocks Expanded Security
|
||||
// Maintenance (ESM): security fixes past a package's standard support window. we detect the two base ESM
|
||||
// streams, esm-infra (the `main` repo) and esm-apps (`universe`), and collapse them into one ExtendedSupport
|
||||
// signal; the infra-vs-apps split is disambiguated by the downstream consumer, not by this boolean.
|
||||
//
|
||||
// the other Pro streams on the same host (esm.ubuntu.com/fips, /fips-updates, /realtime) are deliberately
|
||||
// excluded: they are separate compliance products with their own downstream channels, and a host can run
|
||||
// them with base ESM disabled, so folding them in would be unsound.
|
||||
//
|
||||
// detection is deliberately forgiving: apt/status evidence disappears when ESM is disabled, but an installed
|
||||
// +esmN package is durable proof ESM content is on disk. so ExtendedSupport means "ESM was or is in effect
|
||||
// for this host or its content", not "currently entitled" (mere eligibility, without evidence, is not enough).
|
||||
|
||||
patterns := []*regexp.Regexp{regexp.MustCompile(`baseos-eus`), regexp.MustCompile(`baseos-eus|appstream-eus`)}
|
||||
// esmAptSourcePattern matches an uncommented apt source (classic or DEB822) or auth entry pointing at the
|
||||
// plain ESM streams esm.ubuntu.com/infra or /apps. it deliberately scopes to /(infra|apps) so the other
|
||||
// Pro streams served from the same host (/fips, /fips-updates, /realtime) are never folded into the base
|
||||
// esm channel. the [^#\n]* before the host ensures a leading `#` (comment) can never satisfy the match.
|
||||
var esmAptSourcePattern = regexp.MustCompile(`(?m)^[^#\n]*esm\.ubuntu\.com/(infra|apps)`)
|
||||
|
||||
// esmVersionPattern matches a dpkg version carrying a numbered ESM pocket suffix, e.g. `...+esm1` or
|
||||
// `...~esm2`. the trailing \d scopes this to the ESM pocket and excludes other Pro pockets (e.g. +fips).
|
||||
var esmVersionPattern = regexp.MustCompile(`[~+]esm\d`)
|
||||
|
||||
func findUbuntuFeatures(resolver file.Resolver, release *linux.Release, packages *pkg.Collection) error {
|
||||
if release == nil || release.ID != "ubuntu" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// an uncommented esm.ubuntu.com apt source or an active pro esm service both prove Pro/ESM was enabled.
|
||||
// these are the durable, file-based signals present on attached hosts. a read error in one signal must
|
||||
// not suppress the others, so errors are collected and detection continues to the next signal.
|
||||
var errs error
|
||||
for _, check := range []func() (bool, error){
|
||||
func() (bool, error) { return hasUbuntuESMAptEvidence(resolver) },
|
||||
func() (bool, error) { return hasActiveUbuntuESMService(resolver) },
|
||||
} {
|
||||
found, err := check()
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
release.ExtendedSupport = true
|
||||
return errs
|
||||
}
|
||||
}
|
||||
|
||||
// fallback for images (typically not pro-attached): an installed package whose version carries an
|
||||
// +esm/~esm suffix was pulled from an ESM pocket, so ESM was in effect when it was installed.
|
||||
if hasInstalledESMPackage(packages) {
|
||||
release.ExtendedSupport = true
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// hasUbuntuESMAptEvidence reports whether any apt source (classic .list or DEB822 .sources) or auth entry
|
||||
// references esm.ubuntu.com via an uncommented line.
|
||||
func hasUbuntuESMAptEvidence(resolver file.Resolver) (bool, error) {
|
||||
// apt source files live in a directory (classic .list or DEB822 .sources), the credentials file is a fixed path.
|
||||
sourceLocations, err := resolver.FilesByGlob("/etc/apt/sources.list.d/*")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to find apt esm sources: %w", err)
|
||||
}
|
||||
authLocations, err := resolver.FilesByPath("/etc/apt/auth.conf.d/90ubuntu-advantage")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to find apt esm auth: %w", err)
|
||||
}
|
||||
|
||||
// ponytail: a DEB822 source with `Enabled: no` would still match; the pro client removes/comments the
|
||||
// file on disable rather than toggling that key, so this is an accepted false-positive ceiling. the
|
||||
// auth.conf.d entry is deleted by the pro client on disable, so it is a durable positive-only signal.
|
||||
for _, location := range append(sourceLocations, authLocations...) {
|
||||
match, err := fileMatchesAny(resolver, location, esmAptSourcePattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if match {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasActiveUbuntuESMService reports whether the ubuntu-advantage/pro status cache shows an enabled esm service.
|
||||
func hasActiveUbuntuESMService(resolver file.Resolver) (bool, error) {
|
||||
locations, err := resolver.FilesByPath("/var/lib/ubuntu-advantage/status.json")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to find ubuntu-advantage status: %w", err)
|
||||
}
|
||||
for _, location := range locations {
|
||||
enabled, err := hasEnabledESMServiceInStatus(resolver, location)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if enabled {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func hasEnabledESMServiceInStatus(resolver file.Resolver, location file.Location) (bool, error) {
|
||||
contents, err := resolver.FileContentsByLocation(location)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to read ubuntu-advantage status from %s: %w", location.RealPath, err)
|
||||
}
|
||||
defer internal.CloseAndLogError(contents, location.RealPath)
|
||||
|
||||
var status struct {
|
||||
Services []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"services"`
|
||||
}
|
||||
if err := json.NewDecoder(contents).Decode(&status); err != nil {
|
||||
return false, fmt.Errorf("unable to parse ubuntu-advantage status from %s: %w", location.RealPath, err)
|
||||
}
|
||||
|
||||
// pro status values are enabled/disabled/n-a/warning/—; only "enabled" proves the stream is active.
|
||||
// warning/expired states are intentionally excluded here and left to the apt-source and installed-package signals.
|
||||
for _, svc := range status.Services {
|
||||
if (svc.Name == "esm-infra" || svc.Name == "esm-apps") && svc.Status == "enabled" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasInstalledESMPackage reports whether any installed dpkg package version carries an ESM pocket suffix.
|
||||
func hasInstalledESMPackage(packages *pkg.Collection) bool {
|
||||
if packages == nil {
|
||||
return false
|
||||
}
|
||||
for p := range packages.Enumerate(pkg.DebPkg) {
|
||||
if esmVersionPattern.MatchString(p.Version) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileMatchesAny(resolver file.Resolver, location file.Location, patterns ...*regexp.Regexp) (bool, error) {
|
||||
contents, err := resolver.FileContentsByLocation(location)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to read %s: %w", location.RealPath, err)
|
||||
}
|
||||
defer internal.CloseAndLogError(contents, location.RealPath)
|
||||
return internal.MatchAnyFromReader(contents, patterns...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ package os_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
stdos "os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -11,7 +16,9 @@ import (
|
||||
"github.com/anchore/syft/internal/sbomsync"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/linux"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
@@ -99,3 +106,210 @@ func Test_EnvironmentTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_findUbuntuFeatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
releaseID string
|
||||
packages []pkg.Package
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "esm apt source present",
|
||||
dir: "testdata/ubuntu_esm_apt",
|
||||
releaseID: "ubuntu",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "esm apt source in DEB822 .sources format",
|
||||
dir: "testdata/ubuntu_esm_deb822",
|
||||
releaseID: "ubuntu",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "esm evidence in apt auth.conf.d",
|
||||
dir: "testdata/ubuntu_esm_authconf",
|
||||
releaseID: "ubuntu",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active esm service in ubuntu-advantage status",
|
||||
dir: "testdata/ubuntu_esm_status",
|
||||
releaseID: "ubuntu",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "only esm-infra enabled (esm-apps disabled)",
|
||||
dir: "testdata/ubuntu_esm_infra_only",
|
||||
releaseID: "ubuntu",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "malformed status.json does not panic or match",
|
||||
dir: "testdata/ubuntu_esm_malformed",
|
||||
releaseID: "ubuntu",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "installed +esm package version",
|
||||
dir: "testdata/ubuntu_plain",
|
||||
releaseID: "ubuntu",
|
||||
packages: []pkg.Package{
|
||||
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19+esm1", Type: pkg.DebPkg},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "installed ~esm package version",
|
||||
dir: "testdata/ubuntu_plain",
|
||||
releaseID: "ubuntu",
|
||||
packages: []pkg.Package{
|
||||
{Name: "libcap2", Version: "1:2.32-1ubuntu0.1~esm1", Type: pkg.DebPkg},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "plain ubuntu with no esm evidence",
|
||||
dir: "testdata/ubuntu_plain",
|
||||
releaseID: "ubuntu",
|
||||
packages: []pkg.Package{
|
||||
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19", Type: pkg.DebPkg},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "commented esm source and disabled esm service",
|
||||
dir: "testdata/ubuntu_esm_disabled",
|
||||
releaseID: "ubuntu",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "esm disabled but +esm package remains installed",
|
||||
dir: "testdata/ubuntu_esm_disabled",
|
||||
releaseID: "ubuntu",
|
||||
packages: []pkg.Package{
|
||||
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19+esm1", Type: pkg.DebPkg},
|
||||
},
|
||||
expected: true, // durable proof: ESM content is on disk even though the channel is now off
|
||||
},
|
||||
{
|
||||
name: "fips-only pro host is not plain esm",
|
||||
dir: "testdata/ubuntu_fips_only",
|
||||
releaseID: "ubuntu",
|
||||
expected: false, // esm.ubuntu.com/fips must not be folded into the base esm channel
|
||||
},
|
||||
{
|
||||
name: "non-ubuntu is unaffected by esm evidence",
|
||||
dir: "testdata/ubuntu_esm_apt",
|
||||
releaseID: "debian",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "non-ubuntu is unaffected by esm package version",
|
||||
dir: "testdata/ubuntu_plain",
|
||||
releaseID: "debian",
|
||||
packages: []pkg.Package{
|
||||
{Name: "openssl", Version: "1.1.1f-1ubuntu2.19+esm1", Type: pkg.DebPkg},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resolver := fixtureResolverForDir(t, test.dir)
|
||||
|
||||
s := sbom.SBOM{}
|
||||
s.Artifacts.LinuxDistribution = &linux.Release{ID: test.releaseID}
|
||||
s.Artifacts.Packages = pkg.NewCollection(test.packages...)
|
||||
|
||||
err := os.DetectFeatures(context.Background(), resolver, sbomsync.NewBuilder(&s))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, test.expected, s.Artifacts.LinuxDistribution.ExtendedSupport)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// a read error in the apt-source signal must not suppress the ubuntu-advantage status signal; the three
|
||||
// signals are meant to be independent fallbacks.
|
||||
func Test_findUbuntuFeatures_signalErrorDoesNotSuppressOthers(t *testing.T) {
|
||||
resolver := errOnGlobResolver{
|
||||
fixtureResolver: fixtureResolverForDir(t, "testdata/ubuntu_esm_status").(fixtureResolver),
|
||||
errOnGlob: "/etc/apt/sources.list.d/*",
|
||||
}
|
||||
|
||||
s := sbom.SBOM{}
|
||||
s.Artifacts.LinuxDistribution = &linux.Release{ID: "ubuntu"}
|
||||
s.Artifacts.Packages = pkg.NewCollection()
|
||||
|
||||
require.NoError(t, os.DetectFeatures(context.Background(), resolver, sbomsync.NewBuilder(&s)))
|
||||
|
||||
// the apt glob errors, but the status.json signal (esm-infra enabled) should still be consulted
|
||||
require.True(t, s.Artifacts.LinuxDistribution.ExtendedSupport)
|
||||
}
|
||||
|
||||
// errOnGlobResolver injects a failure for a specific glob to simulate an unreadable apt directory.
|
||||
type errOnGlobResolver struct {
|
||||
fixtureResolver
|
||||
errOnGlob string
|
||||
}
|
||||
|
||||
func (r errOnGlobResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
|
||||
if slices.Contains(patterns, r.errOnGlob) {
|
||||
return nil, stdos.ErrPermission
|
||||
}
|
||||
return r.fixtureResolver.FilesByGlob(patterns...)
|
||||
}
|
||||
|
||||
// fixtureResolver maps in-image logical paths to on-disk fixture files so DetectFeatures can be driven
|
||||
// without building a container image.
|
||||
type fixtureResolver struct {
|
||||
file.Resolver // unused methods; nil is fine since only the three below are called
|
||||
files map[string]string
|
||||
}
|
||||
|
||||
func (r fixtureResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
|
||||
var out []file.Location
|
||||
for logical := range r.files {
|
||||
for _, p := range patterns {
|
||||
if ok, _ := path.Match(p, logical); ok {
|
||||
out = append(out, file.NewLocation(logical))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r fixtureResolver) FilesByPath(paths ...string) ([]file.Location, error) {
|
||||
var out []file.Location
|
||||
for _, p := range paths {
|
||||
if _, ok := r.files[p]; ok {
|
||||
out = append(out, file.NewLocation(p))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r fixtureResolver) FileContentsByLocation(l file.Location) (io.ReadCloser, error) {
|
||||
return stdos.Open(r.files[l.RealPath])
|
||||
}
|
||||
|
||||
func fixtureResolverForDir(t *testing.T, dir string) file.Resolver {
|
||||
t.Helper()
|
||||
files := map[string]string{}
|
||||
require.NoError(t, filepath.WalkDir(dir, func(p string, d stdos.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files["/"+filepath.ToSlash(rel)] = p
|
||||
return nil
|
||||
}))
|
||||
return fixtureResolver{files: files}
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Written by ubuntu-advantage-tools
|
||||
deb https://esm.ubuntu.com/infra/ubuntu focal-infra-security main
|
||||
# deb-src https://esm.ubuntu.com/infra/ubuntu focal-infra-security main
|
||||
|
||||
deb https://esm.ubuntu.com/infra/ubuntu focal-infra-updates main
|
||||
# deb-src https://esm.ubuntu.com/infra/ubuntu focal-infra-updates main
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
machine esm.ubuntu.com/infra/ubuntu/ login bearer password DUMMYTOKENREDACTED # ubuntu-pro-client
|
||||
machine esm.ubuntu.com/apps/ubuntu/ login bearer password DUMMYTOKENREDACTED # ubuntu-pro-client
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Types: deb
|
||||
URIs: https://esm.ubuntu.com/infra/ubuntu
|
||||
Suites: noble-infra-security noble-infra-updates
|
||||
Components: main
|
||||
Signed-By: /usr/share/keyrings/ubuntu-pro-esm-infra-keyring.gpg
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Written by ubuntu-advantage-tools
|
||||
# deb https://esm.ubuntu.com/infra/ubuntu focal-infra-security main
|
||||
# deb-src https://esm.ubuntu.com/infra/ubuntu focal-infra-security main
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_schema_version": "0.1",
|
||||
"attached": true,
|
||||
"services": [
|
||||
{
|
||||
"name": "esm-apps",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "disabled"
|
||||
},
|
||||
{
|
||||
"name": "esm-infra",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"_schema_version": "0.1",
|
||||
"attached": true,
|
||||
"execution_status": "success",
|
||||
"services": [
|
||||
{
|
||||
"name": "esm-apps",
|
||||
"description": "Expanded Security Maintenance for Applications",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "disabled"
|
||||
},
|
||||
{
|
||||
"name": "esm-infra",
|
||||
"description": "Expanded Security Maintenance for Infrastructure",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ this is not valid json
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"_doc": "Content provided in json response is currently considered Experimental and may change",
|
||||
"_schema_version": "0.1",
|
||||
"attached": true,
|
||||
"services": [
|
||||
{
|
||||
"name": "esm-apps",
|
||||
"description": "Expanded Security Maintenance for Applications",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "enabled"
|
||||
},
|
||||
{
|
||||
"name": "esm-infra",
|
||||
"description": "Expanded Security Maintenance for Infrastructure",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "enabled"
|
||||
},
|
||||
{
|
||||
"name": "fips",
|
||||
"description": "NIST-certified FIPS crypto packages",
|
||||
"available": "no",
|
||||
"entitled": "no",
|
||||
"status": "n/a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Written by ubuntu-pro-client
|
||||
deb https://esm.ubuntu.com/fips/ubuntu focal main
|
||||
# deb-src https://esm.ubuntu.com/fips/ubuntu focal main
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"_schema_version": "0.1",
|
||||
"attached": true,
|
||||
"execution_status": "success",
|
||||
"services": [
|
||||
{
|
||||
"name": "esm-apps",
|
||||
"description": "Expanded Security Maintenance for Applications",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "disabled"
|
||||
},
|
||||
{
|
||||
"name": "esm-infra",
|
||||
"description": "Expanded Security Maintenance for Infrastructure",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "disabled"
|
||||
},
|
||||
{
|
||||
"name": "fips",
|
||||
"description": "NIST-certified FIPS crypto packages",
|
||||
"available": "yes",
|
||||
"entitled": "yes",
|
||||
"status": "enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Types: deb
|
||||
URIs: http://archive.ubuntu.com/ubuntu/
|
||||
Suites: noble noble-updates noble-backports
|
||||
Components: main restricted universe multiverse
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
@@ -27,6 +27,7 @@ var knownNonMetadataTypeNames = strset.New(
|
||||
var knownMetadataTypeNames = strset.New(
|
||||
"DotnetPortableExecutableEntry",
|
||||
"GGUFFileHeader",
|
||||
"SafeTensorsModelInfo",
|
||||
)
|
||||
|
||||
func DiscoverTypeNames() ([]string, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ func AllTypes() []any {
|
||||
return []any{
|
||||
pkg.AlpmDBEntry{},
|
||||
pkg.ApkDBEntry{},
|
||||
pkg.AppleAppBundleEntry{},
|
||||
pkg.BinarySignature{},
|
||||
pkg.BitnamiSBOMEntry{},
|
||||
pkg.BunLockEntry{},
|
||||
@@ -67,10 +68,12 @@ func AllTypes() []any {
|
||||
pkg.RubyGemspec{},
|
||||
pkg.RustBinaryAuditEntry{},
|
||||
pkg.RustCargoLockEntry{},
|
||||
pkg.SafeTensorsModelInfo{},
|
||||
pkg.SnapEntry{},
|
||||
pkg.SwiftPackageManagerResolvedEntry{},
|
||||
pkg.SwiplPackEntry{},
|
||||
pkg.TerraformLockProviderEntry{},
|
||||
pkg.VcpkgManifest{},
|
||||
pkg.WordpressPluginEntry{},
|
||||
pkg.YarnLockEntry{},
|
||||
}
|
||||
|
||||
@@ -124,11 +124,14 @@ var jsonTypes = makeJSONTypes(
|
||||
jsonNames(pkg.SnapEntry{}, "snap-entry"),
|
||||
jsonNames(pkg.WordpressPluginEntry{}, "wordpress-plugin-entry", "WordpressMetadata"),
|
||||
jsonNames(pkg.HomebrewFormula{}, "homebrew-formula"),
|
||||
jsonNames(pkg.AppleAppBundleEntry{}, "apple-app-bundle-entry"),
|
||||
jsonNames(pkg.LuaRocksPackage{}, "luarocks-package"),
|
||||
jsonNames(pkg.TerraformLockProviderEntry{}, "terraform-lock-provider-entry"),
|
||||
jsonNames(pkg.DotnetPackagesLockEntry{}, "dotnet-packages-lock-entry"),
|
||||
jsonNames(pkg.CondaMetaPackage{}, "conda-metadata-entry", "CondaPackageMetadata"),
|
||||
jsonNames(pkg.GGUFFileHeader{}, "gguf-file-header"),
|
||||
jsonNames(pkg.SafeTensorsModelInfo{}, "safetensors-model-info"),
|
||||
jsonNames(pkg.VcpkgManifest{}, "vcpkg-manifest"),
|
||||
)
|
||||
|
||||
func expandLegacyNameVariants(names ...string) []string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/ai"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/alpine"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/apple"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/arch"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary"
|
||||
bitnamiSbomCataloger "github.com/anchore/syft/syft/pkg/cataloger/bitnami"
|
||||
@@ -56,6 +57,10 @@ const (
|
||||
|
||||
// Python ecosystem labels
|
||||
Python = "python"
|
||||
|
||||
// C/C++ ecosystem labels
|
||||
Cpp = "cpp"
|
||||
Vcpkg = "vcpkg"
|
||||
)
|
||||
|
||||
//nolint:funlen
|
||||
@@ -82,6 +87,11 @@ func DefaultPackageTaskFactories() Factories {
|
||||
|
||||
// language-specific package declared catalogers ///////////////////////////////////////////////////////////////////////////
|
||||
newSimplePackageTaskFactory(cpp.NewConanCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "cpp", "conan"),
|
||||
newPackageTaskFactory(
|
||||
func(cfg CatalogingFactoryConfig) pkg.Cataloger {
|
||||
return cpp.NewVcpkgManifestCataloger(cfg.PackagesConfig.Cpp)
|
||||
},
|
||||
pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, Cpp, Vcpkg),
|
||||
newSimplePackageTaskFactory(dart.NewPubspecLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "dart"),
|
||||
newSimplePackageTaskFactory(dart.NewPubspecCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "dart"),
|
||||
newSimplePackageTaskFactory(elixir.NewMixLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "elixir"),
|
||||
@@ -177,9 +187,11 @@ func DefaultPackageTaskFactories() Factories {
|
||||
newSimplePackageTaskFactory(wordpress.NewWordpressPluginCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "wordpress"),
|
||||
newSimplePackageTaskFactory(terraform.NewLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, "terraform"),
|
||||
newSimplePackageTaskFactory(homebrew.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "homebrew"),
|
||||
newSimplePackageTaskFactory(apple.NewAppBundleCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "apple"),
|
||||
newSimplePackageTaskFactory(conda.NewCondaMetaCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.PackageTag, "conda"),
|
||||
newSimplePackageTaskFactory(snap.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "snap"),
|
||||
newSimplePackageTaskFactory(ai.NewGGUFCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "gguf", "ml"),
|
||||
newSimplePackageTaskFactory(ai.NewSafeTensorsCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "safetensors", "ml"),
|
||||
|
||||
// deprecated catalogers ////////////////////////////////////////
|
||||
// these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "anchore.io/schema/syft/json/16.1.5/document",
|
||||
"$id": "anchore.io/schema/syft/json/16.1.10/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": {
|
||||
@@ -1267,6 +1328,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",
|
||||
@@ -1585,6 +1653,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, while\nthe \"stdlib\" scope populates only the stdlib package."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -2667,6 +2745,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/ApkDbEntry"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/AppleAppBundleEntry"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/BinarySignature"
|
||||
},
|
||||
@@ -2844,6 +2925,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/RustCargoLockEntry"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SafetensorsModelInfo"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SnapEntry"
|
||||
},
|
||||
@@ -2856,6 +2940,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/TerraformLockProviderEntry"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VcpkgManifest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/WordpressPluginEntry"
|
||||
}
|
||||
@@ -4132,6 +4219,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": {
|
||||
@@ -4314,6 +4450,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": {
|
||||
|
||||
@@ -2,6 +2,7 @@ package pkgcataloging
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/cpp"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/dotnet"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/golang"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/java"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
Binary binary.ClassifierCatalogerConfig `yaml:"binary" json:"binary" mapstructure:"binary"`
|
||||
Cpp cpp.CatalogerConfig `yaml:"cpp" json:"cpp" mapstructure:"cpp"`
|
||||
Dotnet dotnet.CatalogerConfig `yaml:"dotnet" json:"dotnet" mapstructure:"dotnet"`
|
||||
Golang golang.CatalogerConfig `yaml:"golang" json:"golang" mapstructure:"golang"`
|
||||
JavaArchive java.ArchiveCatalogerConfig `yaml:"java-archive" json:"java-archive" mapstructure:"java-archive"`
|
||||
@@ -25,6 +27,7 @@ type Config struct {
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Binary: binary.DefaultClassifierCatalogerConfig(),
|
||||
Cpp: cpp.DefaultCatalogerConfig(),
|
||||
Dotnet: dotnet.DefaultCatalogerConfig(),
|
||||
Golang: golang.DefaultCatalogerConfig(),
|
||||
JavaArchive: java.DefaultArchiveCatalogerConfig(),
|
||||
@@ -44,6 +47,11 @@ func (c Config) WithBinaryConfig(cfg binary.ClassifierCatalogerConfig) Config {
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) WithCppConfig(cfg cpp.CatalogerConfig) Config {
|
||||
c.Cpp = cfg
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) WithDotnetConfig(cfg dotnet.CatalogerConfig) Config {
|
||||
c.Dotnet = cfg
|
||||
return c
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cataloging
|
||||
|
||||
import "strings"
|
||||
|
||||
// SymbolScope controls which packages get function symbols (from a binary's symbol table) attached to their metadata.
|
||||
type SymbolScope string
|
||||
|
||||
const (
|
||||
// SymbolScopeNone disables symbol capture entirely.
|
||||
SymbolScopeNone SymbolScope = "none"
|
||||
|
||||
// SymbolScopeStdlib captures symbols only for the synthetic "stdlib" package, leaving module packages without symbols.
|
||||
SymbolScopeStdlib SymbolScope = "stdlib"
|
||||
|
||||
// SymbolScopeAll captures symbols for all module packages as well as the synthetic "stdlib" package.
|
||||
SymbolScopeAll SymbolScope = "all"
|
||||
)
|
||||
|
||||
// Parse normalizes a SymbolScope, treating empty (unset) and unrecognized values as SymbolScopeNone.
|
||||
func (s SymbolScope) Parse() SymbolScope {
|
||||
switch strings.ToLower(strings.TrimSpace(string(s))) {
|
||||
case string(SymbolScopeAll):
|
||||
return SymbolScopeAll
|
||||
case string(SymbolScopeStdlib):
|
||||
return SymbolScopeStdlib
|
||||
}
|
||||
return SymbolScopeNone
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cataloging
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_SymbolScope_Parse(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected SymbolScope
|
||||
}{
|
||||
{"all", SymbolScopeAll},
|
||||
{"ALL", SymbolScopeAll},
|
||||
{" all ", SymbolScopeAll},
|
||||
{"stdlib", SymbolScopeStdlib},
|
||||
{"Stdlib", SymbolScopeStdlib},
|
||||
{"none", SymbolScopeNone},
|
||||
{"", SymbolScopeNone},
|
||||
{"true", SymbolScopeNone},
|
||||
{"false", SymbolScopeNone},
|
||||
{"bogus", SymbolScopeNone},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, SymbolScope(test.input).Parse())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type MockResolver struct {
|
||||
mediaTypeIndex map[string][]Location
|
||||
extension map[string][]Location
|
||||
basename map[string][]Location
|
||||
ociRef string
|
||||
}
|
||||
|
||||
// NewMockResolverForPaths creates a new MockResolver, where the only resolvable
|
||||
@@ -74,6 +75,16 @@ func NewMockResolverForPathsWithMetadata(metadata map[Coordinates]Metadata) *Moc
|
||||
}
|
||||
}
|
||||
|
||||
// NewMockResolverForOCIArtifact creates a MockResolver that can resolve files
|
||||
// by media type AND surfaces the given OCI ref via the ImageReference method.
|
||||
// Intended for tests that exercise the catalogers' OCI-artifact-aware naming
|
||||
// code paths.
|
||||
func NewMockResolverForOCIArtifact(ref string, mediaTypes map[string][]Location) *MockResolver {
|
||||
r := NewMockResolverForMediaTypes(mediaTypes)
|
||||
r.ociRef = ref
|
||||
return r
|
||||
}
|
||||
|
||||
// NewMockResolverForMediaTypes creates a MockResolver that can resolve files by media type.
|
||||
// The mediaTypes map specifies which locations should be returned for each media type.
|
||||
func NewMockResolverForMediaTypes(mediaTypes map[string][]Location) *MockResolver {
|
||||
@@ -102,6 +113,11 @@ func NewMockResolverForMediaTypes(mediaTypes map[string][]Location) *MockResolve
|
||||
}
|
||||
}
|
||||
|
||||
// ImageReference returns the image reference associated with this mock, if any.
|
||||
func (r MockResolver) ImageReference() string {
|
||||
return r.ociRef
|
||||
}
|
||||
|
||||
// HasPath indicates if the given path exists in the underlying source.
|
||||
func (r MockResolver) HasPath(path string) bool {
|
||||
for _, l := range r.locations {
|
||||
|
||||
@@ -62,7 +62,7 @@ func collectPackages(component *cyclonedx.Component, s *sbom.SBOM, idMap map[str
|
||||
switch component.Type {
|
||||
case cyclonedx.ComponentTypeOS:
|
||||
case cyclonedx.ComponentTypeContainer:
|
||||
case cyclonedx.ComponentTypeApplication, cyclonedx.ComponentTypeFramework, cyclonedx.ComponentTypeLibrary, cyclonedx.ComponentTypeMachineLearningModel:
|
||||
case cyclonedx.ComponentTypeApplication, cyclonedx.ComponentTypeFramework, cyclonedx.ComponentTypeLibrary, cyclonedx.ComponentTypeMachineLearningModel, cyclonedx.ComponentTypeFirmware:
|
||||
p := decodeComponent(component)
|
||||
idMap[component.BOMRef] = p
|
||||
if component.BOMRef != "" {
|
||||
|
||||
@@ -259,6 +259,30 @@ func Test_decode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_decode_includesFirmwareComponents(t *testing.T) {
|
||||
// CycloneDX BOMs that describe firmware (e.g. u-boot) use
|
||||
// component type "firmware". Prior to issue #2537 the decoder skipped
|
||||
// these and downstream tools (e.g. grype) reported zero matches.
|
||||
bom := cyclonedx.BOM{
|
||||
Components: &[]cyclonedx.Component{
|
||||
{
|
||||
BOMRef: "u-boot",
|
||||
Type: cyclonedx.ComponentTypeFirmware,
|
||||
Name: "u-boot",
|
||||
Version: "2024.04",
|
||||
PackageURL: "pkg:generic/u-boot@2024.04",
|
||||
},
|
||||
},
|
||||
}
|
||||
model, err := ToSyftModel(&bom)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkgs := model.Artifacts.Packages.Sorted()
|
||||
require.Len(t, pkgs, 1, "firmware component should be imported as a package")
|
||||
assert.Equal(t, "u-boot", pkgs[0].Name)
|
||||
assert.Equal(t, "2024.04", pkgs[0].Version)
|
||||
}
|
||||
|
||||
func Test_relationshipDirection(t *testing.T) {
|
||||
cyclonedx_bom := cyclonedx.BOM{Metadata: nil,
|
||||
Components: &[]cyclonedx.Component{
|
||||
|
||||
@@ -138,6 +138,11 @@ func Originator(p pkg.Package) (typ string, author string) { //nolint: gocyclo,f
|
||||
|
||||
case pkg.SwiplPackEntry:
|
||||
author = formatPersonOrOrg(metadata.Author, metadata.AuthorEmail)
|
||||
|
||||
case pkg.VcpkgManifest:
|
||||
if len(metadata.Maintainers) > 0 {
|
||||
author = metadata.Maintainers[0]
|
||||
}
|
||||
}
|
||||
|
||||
if typ == "" && author != "" {
|
||||
|
||||
@@ -31,6 +31,7 @@ func Test_OriginatorSupplier(t *testing.T) {
|
||||
pkg.GolangModuleEntry{},
|
||||
pkg.GolangSourceEntry{},
|
||||
pkg.HomebrewFormula{},
|
||||
pkg.AppleAppBundleEntry{},
|
||||
pkg.HackageStackYamlLockEntry{},
|
||||
pkg.HackageStackYamlEntry{},
|
||||
pkg.LinuxKernel{},
|
||||
@@ -57,8 +58,10 @@ func Test_OriginatorSupplier(t *testing.T) {
|
||||
pkg.YarnLockEntry{},
|
||||
pkg.TerraformLockProviderEntry{},
|
||||
pkg.GGUFFileHeader{},
|
||||
pkg.SafeTensorsModelInfo{},
|
||||
pkg.DenoLockEntry{},
|
||||
pkg.DenoRemoteLockEntry{},
|
||||
pkg.VcpkgManifest{},
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -80,10 +80,14 @@ func SourceInfo(p pkg.Package) string {
|
||||
answer = "acquired package info from found wordpress plugin PHP source files"
|
||||
case pkg.HomebrewPkg:
|
||||
answer = "acquired package info from Homebrew formula"
|
||||
case pkg.AppleAppBundlePkg:
|
||||
answer = "acquired package info from Apple application bundle Info.plist"
|
||||
case pkg.TerraformPkg:
|
||||
answer = "acquired package info from Terraform dependency lock file"
|
||||
case pkg.ModelPkg:
|
||||
answer = "acquired package info from AI artifact (e.g. GGUF File)"
|
||||
answer = "acquired package info from AI model artifact"
|
||||
case pkg.VcpkgPkg:
|
||||
answer = "acquired package info from vcpkg manifest file"
|
||||
default:
|
||||
answer = "acquired package info from the following paths"
|
||||
}
|
||||
|
||||
@@ -199,6 +199,14 @@ func Test_SourceInfo(t *testing.T) {
|
||||
"from conda metadata",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: pkg.Package{
|
||||
Type: pkg.VcpkgPkg,
|
||||
},
|
||||
expected: []string{
|
||||
"from vcpkg manifest",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: pkg.Package{
|
||||
Type: pkg.PortagePkg,
|
||||
@@ -343,6 +351,14 @@ func Test_SourceInfo(t *testing.T) {
|
||||
"acquired package info from Homebrew formula",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: pkg.Package{
|
||||
Type: pkg.AppleAppBundlePkg,
|
||||
},
|
||||
expected: []string{
|
||||
"acquired package info from Apple application bundle Info.plist",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: pkg.Package{
|
||||
Type: pkg.TerraformPkg,
|
||||
|
||||
@@ -33,7 +33,16 @@ type encoder struct {
|
||||
func NewFormatEncoder(cfg EncoderConfig) (sbom.FormatEncoder, error) {
|
||||
// TODO: revisit this... should no template file be an error or simply render an empty result? or render the json output?
|
||||
// Note: do not check for the existence of the template file here, as the default encoder cannot provide one.
|
||||
// start from the hermetic (repeatable) sprig map, which omits functions that reach into the
|
||||
// environment or network (env, expandenv, getHostByName), then re-expose just the date/time
|
||||
// helpers requested in issue #2372. Allowlisting here keeps the user-template trust boundary
|
||||
// safe even if sprig adds new non-hermetic functions in the future.
|
||||
f := sprig.HermeticTxtFuncMap()
|
||||
full := sprig.TxtFuncMap()
|
||||
for _, name := range []string{"now", "date", "dateInZone", "dateModify", "date_in_zone", "date_modify", "htmlDate", "htmlDateInZone"} {
|
||||
f[name] = full[name]
|
||||
}
|
||||
|
||||
f["getLastIndex"] = func(collection any) int {
|
||||
if v := reflect.ValueOf(collection); v.Kind() == reflect.Slice {
|
||||
return v.Len() - 1
|
||||
|
||||
@@ -82,6 +82,27 @@ func TestFormatWithOptionAndHasField(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestFuncMap_ExposesDateFunctions_ExcludesEnvAndNetwork(t *testing.T) {
|
||||
enc, err := NewFormatEncoder(DefaultEncoderConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
e, ok := enc.(encoder)
|
||||
require.True(t, ok)
|
||||
|
||||
// date/time functions (the reason for issue #2372) should be available
|
||||
for _, name := range []string{"now", "date", "dateInZone", "dateModify", "unixEpoch"} {
|
||||
_, exists := e.funcMap[name]
|
||||
assert.Truef(t, exists, "expected date function %q to be available", name)
|
||||
}
|
||||
|
||||
// functions that reach into the environment or network must remain excluded.
|
||||
// rand*/uuidv4 are also kept out to preserve hermetic (repeatable) output.
|
||||
for _, name := range []string{"env", "expandenv", "getHostByName", "randAlphaNum", "uuidv4"} {
|
||||
_, exists := e.funcMap[name]
|
||||
assert.Falsef(t, exists, "expected non-hermetic function %q to be excluded", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWithoutOptions(t *testing.T) {
|
||||
f, err := NewFormatEncoder(DefaultEncoderConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -20,16 +20,22 @@ type LayerInfo struct {
|
||||
}
|
||||
|
||||
// ContainerImageModel is a file.Resolver implementation that provides access to
|
||||
// GGUF header data fetched from OCI model artifacts via range-GET requests.
|
||||
// This does not fetch the entire model from the registry, only a sliver of it.
|
||||
// model header and metadata data (GGUF and SafeTensors headers, the model config
|
||||
// blob, and companion layers) fetched from OCI model artifacts via range-GET
|
||||
// requests. This does not fetch the entire model from the registry, only a
|
||||
// sliver of it.
|
||||
type ContainerImageModel struct {
|
||||
tempDir string // temp directory containing all layer files
|
||||
layerFiles map[string]LayerInfo // digest -> layer info (temp path + media type)
|
||||
locations map[string]file.Location // digest -> location
|
||||
ref string // image reference the artifact was fetched with
|
||||
}
|
||||
|
||||
// NewContainerImageModel creates a new resolver with the given temp directory and layer files.
|
||||
func NewContainerImageModel(tempDir string, layerFiles map[string]LayerInfo) *ContainerImageModel {
|
||||
// NewContainerImageModel creates a new resolver with the given temp directory
|
||||
// and layer files. The ref is surfaced through the ImageReference method so
|
||||
// catalogers can derive context-level naming hints from the artifact reference
|
||||
// when the layer contents don't carry one.
|
||||
func NewContainerImageModel(tempDir string, layerFiles map[string]LayerInfo, ref string) *ContainerImageModel {
|
||||
// Create locations for all layer files
|
||||
// Each location has RealPath="/", FileSystemID=digest, AccessPath="/"
|
||||
locations := make(map[string]file.Location, len(layerFiles))
|
||||
@@ -43,9 +49,15 @@ func NewContainerImageModel(tempDir string, layerFiles map[string]LayerInfo) *Co
|
||||
tempDir: tempDir,
|
||||
layerFiles: layerFiles,
|
||||
locations: locations,
|
||||
ref: ref,
|
||||
}
|
||||
}
|
||||
|
||||
// ImageReference returns the image reference the artifact was fetched with.
|
||||
func (r *ContainerImageModel) ImageReference() string {
|
||||
return r.ref
|
||||
}
|
||||
|
||||
// FilesByMediaType returns locations for layers matching the given media type patterns.
|
||||
// Patterns support glob-style matching (e.g., "application/vnd.docker.ai*").
|
||||
func (r *ContainerImageModel) FilesByMediaType(types ...string) ([]file.Location, error) {
|
||||
|
||||
@@ -60,7 +60,8 @@ func TestOCIModelResolver_FilesByMediaType(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resolver := NewContainerImageModel(tempDir, test.layerFiles)
|
||||
ref := ""
|
||||
resolver := NewContainerImageModel(tempDir, test.layerFiles, ref)
|
||||
|
||||
locations, err := resolver.FilesByMediaType(test.patterns...)
|
||||
require.NoError(t, err)
|
||||
@@ -81,7 +82,8 @@ func TestOCIModelResolver_FileContentsByLocation(t *testing.T) {
|
||||
digest: {TempPath: tempFile, MediaType: ggufLayerMediaType},
|
||||
}
|
||||
|
||||
resolver := NewContainerImageModel(tempDir, layerFiles)
|
||||
ref := ""
|
||||
resolver := NewContainerImageModel(tempDir, layerFiles, ref)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -101,6 +101,22 @@ func TestIdentifyRelease(t *testing.T) {
|
||||
BugReportURL: "https://github.com/GoogleContainerTools/distroless/issues/new",
|
||||
},
|
||||
},
|
||||
{
|
||||
// standard Debian: VERSION_ID has major only, debian_version has the point release
|
||||
fixture: "testdata/os/debian/with-point-release",
|
||||
release: &Release{
|
||||
PrettyName: "Debian GNU/Linux 13 (trixie)",
|
||||
Name: "Debian GNU/Linux",
|
||||
ID: "debian",
|
||||
IDLike: nil,
|
||||
Version: "13 (trixie)",
|
||||
VersionID: "13.5",
|
||||
VersionCodename: "trixie",
|
||||
HomeURL: "https://www.debian.org/",
|
||||
SupportURL: "https://www.debian.org/support",
|
||||
BugReportURL: "https://bugs.debian.org/",
|
||||
},
|
||||
},
|
||||
{
|
||||
fixture: "testdata/os/fedora",
|
||||
release: &Release{
|
||||
|
||||
@@ -11,11 +11,10 @@ import (
|
||||
)
|
||||
|
||||
func supplementDebianVersion(resolver file.Resolver, release *Release) {
|
||||
// we're only looking for version information for debian when none is present in /etc/os-release
|
||||
if release.Version != "" || release.VersionID != "" || !strings.EqualFold(release.ID, "debian") {
|
||||
if !strings.EqualFold(release.ID, "debian") {
|
||||
return
|
||||
}
|
||||
// if we have a debian release with no version, look for a debian_version
|
||||
// debian_version has a more specific point release than os-release
|
||||
locations, err := resolver.FilesByGlob("/etc/debian_version")
|
||||
if err != nil {
|
||||
log.Debugf("error reading /etc/debian_version: %v", err)
|
||||
@@ -24,8 +23,10 @@ func supplementDebianVersion(resolver file.Resolver, release *Release) {
|
||||
for _, location := range locations {
|
||||
version := readDebianVersionFile(resolver, location)
|
||||
if version != "" {
|
||||
release.Version = version
|
||||
release.VersionID = version
|
||||
if release.Version == "" {
|
||||
release.Version = version
|
||||
}
|
||||
return // keep the first result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
13.5
|
||||
@@ -0,0 +1,9 @@
|
||||
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
|
||||
NAME="Debian GNU/Linux"
|
||||
VERSION_ID="13"
|
||||
VERSION="13 (trixie)"
|
||||
VERSION_CODENAME=trixie
|
||||
ID=debian
|
||||
HOME_URL="https://www.debian.org/"
|
||||
SUPPORT_URL="https://www.debian.org/support"
|
||||
BUG_REPORT_URL="https://bugs.debian.org/"
|
||||
@@ -0,0 +1,44 @@
|
||||
package pkg
|
||||
|
||||
// AppleAppBundleEntry represents metadata about an Apple application bundle (CFBundle) extracted from Info.plist files.
|
||||
// These bundles share the same plist structure across macOS, iOS, watchOS, visionOS, and others.
|
||||
type AppleAppBundleEntry struct {
|
||||
// BundleIdentifier is the unique identifier for the bundle (e.g. "com.apple.Safari")
|
||||
BundleIdentifier string `json:"bundleIdentifier,omitempty"`
|
||||
|
||||
// Name is the short name of the bundle
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// DisplayName is the user-facing name of the bundle
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
|
||||
// Executable is the name of the executable within the bundle
|
||||
Executable string `json:"executable,omitempty"`
|
||||
|
||||
// ShortVersion is the release (marketing) version of the bundle
|
||||
ShortVersion string `json:"shortVersion,omitempty"`
|
||||
|
||||
// Version is the build version of the bundle, which often differs from the short version
|
||||
Version string `json:"version,omitempty"`
|
||||
|
||||
// PackageType is the four-letter type code (e.g. "APPL" for apps, "FMWK" for frameworks, "BNDL" for bundles)
|
||||
PackageType string `json:"packageType,omitempty"`
|
||||
|
||||
// SupportedPlatforms lists the platforms the bundle targets (e.g. "MacOSX", "iPhoneOS")
|
||||
SupportedPlatforms []string `json:"supportedPlatforms,omitempty"`
|
||||
|
||||
// MinimumSystemVersion is the minimum macOS version required to run the bundle
|
||||
MinimumSystemVersion string `json:"minimumSystemVersion,omitempty"`
|
||||
|
||||
// MinimumOSVersion is the minimum OS version required for non-macOS platforms (e.g. iOS)
|
||||
MinimumOSVersion string `json:"minimumOSVersion,omitempty"`
|
||||
|
||||
// Copyright is the human-readable copyright notice for the bundle
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
|
||||
// PlatformName is the platform name of the SDK used to build the bundle (e.g. "macosx")
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
|
||||
// SDKName is the name of the SDK used to build the bundle (e.g. "macosx14.0")
|
||||
SDKName string `json:"sdkName,omitempty"`
|
||||
}
|
||||
@@ -44,3 +44,46 @@ catalogers:
|
||||
default: true
|
||||
evidence:
|
||||
- GGUFFileHeader.MetadataKeyValuesHash
|
||||
- ecosystem: ai # MANUAL
|
||||
name: safetensors-cataloger # AUTO-GENERATED
|
||||
type: generic # AUTO-GENERATED
|
||||
source: # AUTO-GENERATED
|
||||
file: syft/pkg/cataloger/ai/cataloger.go
|
||||
function: NewSafeTensorsCataloger
|
||||
selectors: # AUTO-GENERATED
|
||||
- ai
|
||||
- directory
|
||||
- image
|
||||
- ml
|
||||
- model
|
||||
- package
|
||||
- safetensors
|
||||
parsers: # AUTO-GENERATED structure
|
||||
- function: parseSafeTensorsFile
|
||||
detector: # AUTO-GENERATED
|
||||
method: glob # AUTO-GENERATED
|
||||
criteria: # AUTO-GENERATED
|
||||
- '**/*.safetensors'
|
||||
metadata_types: # AUTO-GENERATED
|
||||
- pkg.SafeTensorsModelInfo
|
||||
package_types: # AUTO-GENERATED
|
||||
- model
|
||||
json_schema_types: # AUTO-GENERATED
|
||||
- SafetensorsModelInfo
|
||||
capabilities: # MANUAL - preserved across regeneration
|
||||
- name: license
|
||||
default: true
|
||||
- name: dependency.depth
|
||||
default: []
|
||||
- name: dependency.edges
|
||||
default: ""
|
||||
- name: dependency.kinds
|
||||
default: []
|
||||
- name: package_manager.files.listing
|
||||
default: false
|
||||
- name: package_manager.files.digests
|
||||
default: false
|
||||
- name: package_manager.package_integrity_hash
|
||||
default: true
|
||||
evidence:
|
||||
- SafeTensorsModelInfo.MetadataHash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
Package ai provides concrete Cataloger implementations for AI artifacts and machine learning models,
|
||||
including support for GGUF (GPT-Generated Unified Format) model files.
|
||||
including support for GGUF (GPT-Generated Unified Format) and SafeTensors model files.
|
||||
*/
|
||||
package ai
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
catalogerName = "gguf-cataloger"
|
||||
ggufLayerMediaType = "application/vnd.docker.ai*"
|
||||
catalogerName = "gguf-cataloger"
|
||||
ggufLayerMediaType = "application/vnd.docker.ai*"
|
||||
safeTensorsCatalogerName = "safetensors-cataloger"
|
||||
)
|
||||
|
||||
// NewGGUFCataloger returns a new cataloger instance for GGUF model files.
|
||||
@@ -23,3 +24,26 @@ func NewGGUFCataloger() pkg.Cataloger {
|
||||
WithParserByMediaType(parseGGUFModel, ggufLayerMediaType).
|
||||
WithProcessors(ggufMergeProcessor)
|
||||
}
|
||||
|
||||
// NewSafeTensorsCataloger returns a cataloger for SafeTensors model files,
|
||||
// covering three discovery paths:
|
||||
// - **/*.safetensors files (single-file models and individual shards;
|
||||
// header-only parse)
|
||||
// - application/vnd.docker.ai.model.config.v0.1+json / v0.2+json OCI layers
|
||||
// (Docker Model Runner artifacts whose config advertises format=="safetensors")
|
||||
// - application/vnd.docker.ai.safetensors OCI layers (per-shard JSON headers,
|
||||
// fetched as a prefix by the OCI model source; emitted as nameless
|
||||
// packages and merged into the config-derived package as Parts)
|
||||
//
|
||||
// model.safetensors.index.json files are intentionally not parsed today: the
|
||||
// index describes how tensors map to shards but contributes no metadata the
|
||||
// cataloger can't derive from the shard headers themselves. If a model is
|
||||
// distributed as just an index.json with no accompanying shard files, the
|
||||
// cataloger emits nothing for that directory.
|
||||
func NewSafeTensorsCataloger() pkg.Cataloger {
|
||||
return generic.NewCataloger(safeTensorsCatalogerName).
|
||||
WithParserByGlobs(parseSafeTensorsFile, "**/*.safetensors").
|
||||
WithParserByMediaType(parseSafeTensorsOCIConfig, dockerAIModelConfigMediaTypes...).
|
||||
WithParserByMediaType(parseSafeTensorsOCILayer, dockerAISafeTensorsMediaType).
|
||||
WithResolvingProcessors(safeTensorsMergeProcessor)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// ggufMergeProcessor consolidates multiple GGUF packages into a single package
|
||||
// representing the AI model. When scanning OCI images with multiple layers,
|
||||
// each layer may produce a separate package. This processor finds the package
|
||||
// with a name and merges metadata from nameless packages into its GGUFFileParts field.
|
||||
// Only packages with a non-empty name are returned in the final result.
|
||||
func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
if err != nil {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// Separate packages with names from those without
|
||||
var namedPkgs []pkg.Package
|
||||
var namelessHeaders []pkg.GGUFFileHeader
|
||||
|
||||
for _, p := range pkgs {
|
||||
if p.Name != "" {
|
||||
namedPkgs = append(namedPkgs, p)
|
||||
} else {
|
||||
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
// We do not want a kv hash for nameless headers
|
||||
header.MetadataKeyValuesHash = ""
|
||||
namelessHeaders = append(namelessHeaders, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no named packages, return nothing
|
||||
if len(namedPkgs) == 0 {
|
||||
return nil, rels, err
|
||||
}
|
||||
|
||||
// merge nameless headers into a single named package;
|
||||
// if there are multiple named packages, return them without trying to merge headers.
|
||||
// we cannot determine which nameless headers belong to which package
|
||||
// this is because the order we receive the gguf headers in is not guaranteed
|
||||
// to match the layer order in the original oci image
|
||||
if len(namedPkgs) == 1 && len(namelessHeaders) > 0 {
|
||||
winner := &namedPkgs[0]
|
||||
if header, ok := winner.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
header.Parts = namelessHeaders
|
||||
winner.Metadata = header
|
||||
}
|
||||
}
|
||||
|
||||
return namedPkgs, rels, err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// hfConfig is a minimal projection of Hugging Face config.json fields.
|
||||
type hfConfig struct {
|
||||
Architectures []string `json:"architectures"`
|
||||
NameOrPath string `json:"_name_or_path"`
|
||||
}
|
||||
|
||||
func applyHFConfig(md *pkg.SafeTensorsModelInfo, cfg *hfConfig) {
|
||||
if md.Architecture == "" && len(cfg.Architectures) > 0 {
|
||||
md.Architecture = cfg.Architectures[0]
|
||||
}
|
||||
}
|
||||
|
||||
// readmeFrontmatter holds the subset of YAML frontmatter fields we extract.
|
||||
type readmeFrontmatter struct {
|
||||
License string `yaml:"license"`
|
||||
BaseModel []string `yaml:"base_model"`
|
||||
}
|
||||
|
||||
type licenseFrontmatter struct {
|
||||
SPDXID string `yaml:"spdx-id"`
|
||||
}
|
||||
|
||||
// extractFrontmatterBlock returns the YAML bytes between the first and second
|
||||
// "---" delimiters of a file
|
||||
func extractFrontmatterBlock(buf []byte) []byte {
|
||||
trimmed := bytes.TrimLeft(buf, "\xef\xbb\xbf \t\r\n")
|
||||
if !bytes.HasPrefix(trimmed, []byte("---")) {
|
||||
return nil
|
||||
}
|
||||
rest := trimmed[3:]
|
||||
if i := bytes.IndexByte(rest, '\n'); i >= 0 {
|
||||
rest = rest[i+1:]
|
||||
}
|
||||
block, _, found := bytes.Cut(rest, []byte("\n---"))
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
// parseFrontmatter decodes a Hugging Face model card YAML frontmatter block
|
||||
// and returns the license and base_model fields.
|
||||
func parseFrontmatter(buf []byte) *readmeFrontmatter {
|
||||
block := extractFrontmatterBlock(buf)
|
||||
if block == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
License string `yaml:"license"`
|
||||
BaseModel yaml.Node `yaml:"base_model"`
|
||||
}
|
||||
if err := yaml.Unmarshal(block, &raw); err != nil {
|
||||
log.Debugf("failed to parse README frontmatter: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
fm := readmeFrontmatter{License: raw.License}
|
||||
switch raw.BaseModel.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if raw.BaseModel.Value != "" {
|
||||
fm.BaseModel = []string{raw.BaseModel.Value}
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
_ = raw.BaseModel.Decode(&fm.BaseModel)
|
||||
}
|
||||
return &fm
|
||||
}
|
||||
|
||||
// parseLicenseFrontmatter returns the producer-declared SPDX identifier
|
||||
func parseLicenseFrontmatter(buf []byte) string {
|
||||
block := extractFrontmatterBlock(buf)
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
var fm licenseFrontmatter
|
||||
if err := yaml.Unmarshal(block, &fm); err != nil {
|
||||
log.Debugf("failed to parse license frontmatter: %v", err)
|
||||
return ""
|
||||
}
|
||||
return fm.SPDXID
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// resolveSafeTensorsDirIdentity handles the directory-scan case for safe tensors
|
||||
// find config.json beside the model files (walking up parent directories to the
|
||||
// scanned source root if no sibling exists) and a sibling README.md. It returns
|
||||
// the group's name candidates, resolved licenses, and supporting evidence.
|
||||
func resolveSafeTensorsDirIdentity(ctx context.Context, resolver file.Resolver, dir string, md *pkg.SafeTensorsModelInfo) safeTensorsIdentity {
|
||||
id := safeTensorsIdentity{fallbackName: safeTensorsDirName(dir)}
|
||||
|
||||
if loc, cfg := findDirHFConfig(resolver, dir); cfg != nil {
|
||||
applyHFConfig(md, cfg)
|
||||
id.nameOrPath = cfg.NameOrPath
|
||||
id.supporting = append(id.supporting, *loc)
|
||||
}
|
||||
|
||||
if loc, fm := readDirReadmeFrontmatter(resolver, path.Join(dir, "README.md")); fm != nil {
|
||||
if fm.License != "" {
|
||||
id.licenses = pkg.NewLicensesFromValuesWithContext(ctx, fm.License)
|
||||
}
|
||||
if id.nameOrPath == "" && len(fm.BaseModel) > 0 {
|
||||
id.nameOrPath = fm.BaseModel[0]
|
||||
}
|
||||
id.supporting = append(id.supporting, *loc)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// findDirHFConfig looks for a config.json beside the model files
|
||||
func findDirHFConfig(resolver file.Resolver, dir string) (*file.Location, *hfConfig) {
|
||||
for {
|
||||
if loc, cfg := readDirHFConfig(resolver, path.Join(dir, "config.json")); cfg != nil {
|
||||
return loc, cfg
|
||||
}
|
||||
parent := path.Dir(dir)
|
||||
if parent == dir {
|
||||
return nil, nil // reached the source root
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func readDirHFConfig(resolver file.Resolver, p string) (*file.Location, *hfConfig) {
|
||||
locations, err := resolver.FilesByPath(p)
|
||||
if err != nil || len(locations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rc, err := resolver.FileContentsByLocation(locations[0])
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, p)
|
||||
|
||||
var cfg hfConfig
|
||||
if err := json.NewDecoder(rc).Decode(&cfg); err != nil {
|
||||
log.Debugf("failed to decode %s: %v", p, err)
|
||||
return nil, nil
|
||||
}
|
||||
return &locations[0], &cfg
|
||||
}
|
||||
|
||||
func readDirReadmeFrontmatter(resolver file.Resolver, p string) (*file.Location, *readmeFrontmatter) {
|
||||
locations, err := resolver.FilesByPath(p)
|
||||
if err != nil || len(locations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rc, err := resolver.FileContentsByLocation(locations[0])
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, p)
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(rc, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
fm := parseFrontmatter(buf)
|
||||
if fm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &locations[0], fm
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path"
|
||||
|
||||
gcrname "github.com/google/go-containerregistry/pkg/name"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/licenses"
|
||||
)
|
||||
|
||||
// resolveSafeTensorsOCIIdentity handles the OCI-artifact case
|
||||
func resolveSafeTensorsOCIIdentity(ctx context.Context, resolver file.Resolver, md *pkg.SafeTensorsModelInfo) safeTensorsIdentity {
|
||||
ociResolver, ok := resolver.(file.OCIMediaTypeResolver)
|
||||
if !ok {
|
||||
return safeTensorsIdentity{}
|
||||
}
|
||||
|
||||
modelFileLocs, err := ociResolver.FilesByMediaType(dockerAIModelFileMediaType)
|
||||
if err != nil {
|
||||
log.Debugf("failed to list docker AI model-file layers: %v", err)
|
||||
}
|
||||
|
||||
// Collect config / readme candidates separately so the layer-iteration order
|
||||
// returned by the resolver doesn't decide the precedence.
|
||||
var configName, readmeName, readmeLicense string
|
||||
var supporting []file.Location
|
||||
for _, loc := range modelFileLocs {
|
||||
cfg, fm := classifyOCIModelFileLayer(resolver, loc)
|
||||
switch {
|
||||
case cfg != nil:
|
||||
applyHFConfig(md, cfg)
|
||||
if configName == "" {
|
||||
configName = cfg.NameOrPath
|
||||
}
|
||||
supporting = append(supporting, loc)
|
||||
case fm != nil:
|
||||
if readmeLicense == "" {
|
||||
readmeLicense = fm.License
|
||||
}
|
||||
if readmeName == "" && len(fm.BaseModel) > 0 {
|
||||
readmeName = fm.BaseModel[0]
|
||||
}
|
||||
supporting = append(supporting, loc)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: config.json _name_or_path > README base_model.
|
||||
nameOrPath := configName
|
||||
if nameOrPath == "" {
|
||||
nameOrPath = readmeName
|
||||
}
|
||||
|
||||
id := safeTensorsIdentity{
|
||||
nameOrPath: nameOrPath,
|
||||
fallbackName: ociImageRefBasename(resolver),
|
||||
supporting: supporting,
|
||||
}
|
||||
|
||||
// License precedence: a dedicated vnd.docker.ai.license layer is a
|
||||
// outranks the free-text license field in a model card's README frontmatter.
|
||||
licLocs, err := ociResolver.FilesByMediaType(dockerAILicenseMediaType)
|
||||
if err != nil {
|
||||
log.Debugf("failed to list docker AI license layers: %v", err)
|
||||
}
|
||||
switch {
|
||||
case len(licLocs) > 0:
|
||||
id.licenses = identifyLicenseLayers(ctx, resolver, licLocs)
|
||||
id.supporting = append(id.supporting, licLocs...)
|
||||
case readmeLicense != "":
|
||||
id.licenses = pkg.NewLicensesFromValuesWithContext(ctx, readmeLicense)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// ociImageReferencer is the minimal capability ociImageRefBasename needs: a
|
||||
// resolver that can surface the OCI image reference it was built from. It is
|
||||
// kept local to this package (rather than exported from the file package) so the
|
||||
// assertion stays with its only consumer.
|
||||
type ociImageReferencer interface {
|
||||
ImageReference() string
|
||||
}
|
||||
|
||||
func ociImageRefBasename(resolver file.Resolver) string {
|
||||
// TODO: we don't think this approach is generalizable quite yet, but we really do need this information.
|
||||
// (Ideally we should be NOT be type asserting on the file resolver directly).
|
||||
info, ok := resolver.(ociImageReferencer)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
ref := info.ImageReference()
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := gcrname.ParseReference(ref)
|
||||
if err != nil {
|
||||
log.Debugf("failed to parse OCI ref %q: %v", ref, err)
|
||||
return ""
|
||||
}
|
||||
return path.Base(parsed.Context().RepositoryStr())
|
||||
}
|
||||
|
||||
// classifyOCIModelFileLayer reads up to 4 MiB of a model.file layer and decodes
|
||||
// it as either an HF config.json or a README model card's YAML frontmatter,
|
||||
// based on its leading bytes. It returns whichever it recognized; both are nil
|
||||
// when the layer is neither (or fails to decode). The caller owns precedence and
|
||||
// metadata enrichment.
|
||||
func classifyOCIModelFileLayer(resolver file.Resolver, loc file.Location) (*hfConfig, *readmeFrontmatter) {
|
||||
rc, err := resolver.FileContentsByLocation(loc)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, loc.RealPath)
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(rc, 4*1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
trimmed := bytes.TrimLeft(buf, "\xef\xbb\xbf \t\r\n")
|
||||
switch {
|
||||
case bytes.HasPrefix(trimmed, []byte("---")):
|
||||
return nil, parseFrontmatter(buf)
|
||||
case bytes.HasPrefix(trimmed, []byte("{")):
|
||||
var cfg hfConfig
|
||||
if err := json.Unmarshal(buf, &cfg); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// identifyLicenseLayers turns Docker AI license-layer locations into pkg.License values.
|
||||
func identifyLicenseLayers(ctx context.Context, resolver file.Resolver, locs []file.Location) []pkg.License {
|
||||
var out []pkg.License
|
||||
var scanFallback []file.Location
|
||||
for i := range locs {
|
||||
loc := locs[i]
|
||||
if spdx := readLicenseSPDXIDFromFrontmatter(resolver, loc); spdx != "" {
|
||||
out = append(out, pkg.NewLicenseFromFieldsWithContext(ctx, spdx, "", &loc))
|
||||
continue
|
||||
}
|
||||
scanFallback = append(scanFallback, loc)
|
||||
}
|
||||
if len(scanFallback) > 0 {
|
||||
out = append(out, licenses.FindAtLocations(ctx, resolver, scanFallback...)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readLicenseSPDXIDFromFrontmatter reads a bounded prefix of a license-layer
|
||||
// blob and returns the spdx-id declared in its YAML frontmatter
|
||||
func readLicenseSPDXIDFromFrontmatter(resolver file.Resolver, loc file.Location) string {
|
||||
rc, err := resolver.FileContentsByLocation(loc)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer internal.CloseAndLogError(rc, loc.RealPath)
|
||||
|
||||
buf, err := io.ReadAll(io.LimitReader(rc, 64*1024))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parseLicenseFrontmatter(buf)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// mergeSafeTensorsGroup folds a group's per-member metadata into a single package.
|
||||
func mergeSafeTensorsGroup(members []pkg.Package) pkg.Package {
|
||||
locSet := unionLocations(members)
|
||||
aggregates, shards := bucketSafeTensorsMembers(members)
|
||||
|
||||
merged := pkg.SafeTensorsModelInfo{Format: "safetensors"}
|
||||
mergeAggregatesInto(&merged, aggregates)
|
||||
shardTensorTotal, hashes := mergeShardsInto(&merged, shards)
|
||||
|
||||
// Keep merged UserMetadata globally key-sorted so the SBOM is stable
|
||||
sort.Slice(merged.UserMetadata, func(i, j int) bool {
|
||||
return merged.UserMetadata[i].Key < merged.UserMetadata[j].Key
|
||||
})
|
||||
|
||||
if merged.TensorCount == 0 {
|
||||
merged.TensorCount = shardTensorTotal
|
||||
}
|
||||
if merged.ShardCount == 0 {
|
||||
if len(shards) > 0 {
|
||||
merged.ShardCount = len(shards)
|
||||
} else {
|
||||
merged.ShardCount = 1
|
||||
}
|
||||
}
|
||||
merged.MetadataHash = rollupHash(hashes)
|
||||
|
||||
// Parts only carry value for multi-shard models; for a single shard the
|
||||
// outer view already exposes every per-shard field.
|
||||
if len(shards) > 1 {
|
||||
parts := append([]pkg.SafeTensorsModelInfo(nil), shards...)
|
||||
sort.Slice(parts, func(i, j int) bool {
|
||||
return parts[i].MetadataHash < parts[j].MetadataHash
|
||||
})
|
||||
merged.Parts = parts
|
||||
}
|
||||
|
||||
return pkg.Package{
|
||||
Locations: locSet,
|
||||
Type: pkg.ModelPkg,
|
||||
Metadata: merged,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAggregatesInto(merged *pkg.SafeTensorsModelInfo, aggregates []pkg.SafeTensorsModelInfo) {
|
||||
for _, a := range aggregates {
|
||||
if merged.TensorCount == 0 {
|
||||
merged.TensorCount = a.TensorCount
|
||||
}
|
||||
firstNonEmpty(&merged.TotalSize, a.TotalSize)
|
||||
firstNonEmpty(&merged.Quantization, a.Quantization)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeShardsInto folds the per-shard header metadata into merged, returning
|
||||
// the summed shard TensorCount and the list of non-empty per-shard hashes for
|
||||
// the rollup. Shards carry only the content-derived fields (Quantization,
|
||||
// Parameters, UserMetadata), so those are the only fields folded in here.
|
||||
// TensorCount and Parameters are summed because each shard holds a distinct
|
||||
// slice of the model; Quantization takes the first value since all shards share
|
||||
// one precision.
|
||||
func mergeShardsInto(merged *pkg.SafeTensorsModelInfo, shards []pkg.SafeTensorsModelInfo) (shardTensorTotal uint64, hashes []string) {
|
||||
seenKV := map[string]bool{}
|
||||
for _, s := range shards {
|
||||
shardTensorTotal += s.TensorCount
|
||||
merged.Parameters += s.Parameters
|
||||
firstNonEmpty(&merged.Quantization, s.Quantization)
|
||||
for _, kv := range s.UserMetadata {
|
||||
if seenKV[kv.Key] {
|
||||
continue
|
||||
}
|
||||
seenKV[kv.Key] = true
|
||||
merged.UserMetadata = append(merged.UserMetadata, kv)
|
||||
}
|
||||
if s.MetadataHash != "" {
|
||||
hashes = append(hashes, s.MetadataHash)
|
||||
}
|
||||
}
|
||||
return shardTensorTotal, hashes
|
||||
}
|
||||
|
||||
func firstNonEmpty(dst *string, v string) {
|
||||
if *dst == "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
// unionLocations gathers every location from every member into a single set.
|
||||
func unionLocations(members []pkg.Package) file.LocationSet {
|
||||
out := file.NewLocationSet()
|
||||
for _, m := range members {
|
||||
for _, l := range m.Locations.ToSlice() {
|
||||
out.Add(l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bucketSafeTensorsMembers splits group members into aggregate-flavored entries
|
||||
// (no MetadataHash — Docker AI config blob or sharded index) and shard-flavored
|
||||
// entries (carry a content-derived MetadataHash from a header parser).
|
||||
func bucketSafeTensorsMembers(members []pkg.Package) (aggregates, shards []pkg.SafeTensorsModelInfo) {
|
||||
for _, m := range members {
|
||||
md, ok := m.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if md.MetadataHash != "" {
|
||||
shards = append(shards, md)
|
||||
continue
|
||||
}
|
||||
aggregates = append(aggregates, md)
|
||||
}
|
||||
return aggregates, shards
|
||||
}
|
||||
|
||||
func rollupHash(hashes []string) string {
|
||||
if len(hashes) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(hashes) == 1 {
|
||||
return hashes[0]
|
||||
}
|
||||
sorted := append([]string(nil), hashes...)
|
||||
sort.Strings(sorted)
|
||||
return fmt.Sprintf("%016x", xxhash.Sum64String(strings.Join(sorted, "|")))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// stPkg builds a model package carrying the given metadata, with each path
|
||||
// recorded as a primary-evidence location.
|
||||
func stPkg(md pkg.SafeTensorsModelInfo, paths ...string) pkg.Package {
|
||||
locs := make([]file.Location, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
locs = append(locs, file.NewLocation(p).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
|
||||
}
|
||||
return pkg.Package{Type: pkg.ModelPkg, Metadata: md, Locations: file.NewLocationSet(locs...)}
|
||||
}
|
||||
|
||||
// shardMeta is a content-derived shard entry: it carries a MetadataHash, which is
|
||||
// what marks a group member as a shard (vs. a hash-less aggregate config blob).
|
||||
func shardMeta(hash string, tensorCount uint64) pkg.SafeTensorsModelInfo {
|
||||
return pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: tensorCount,
|
||||
Quantization: "BF16",
|
||||
Parameters: 1000,
|
||||
MetadataHash: hash,
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeSafeTensorsGroup exercises the rollup contract directly (the cataloger
|
||||
// tests cover it only as a side effect of the merge processor). It locks how a
|
||||
// group's per-member metadata folds into one package: tensor-count summing,
|
||||
// aggregate-over-shard field precedence, UserMetadata dedup + sorting, Parts
|
||||
// rollup, ShardCount derivation, and the content MetadataHash rollup.
|
||||
func TestMergeSafeTensorsGroup(t *testing.T) {
|
||||
t.Run("single shard: hash passes through, ShardCount 1, no Parts", func(t *testing.T) {
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(shardMeta("aaaa", 5), "/m/a.safetensors")})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, pkg.ModelPkg, out.Type)
|
||||
assert.Equal(t, 1, md.ShardCount)
|
||||
assert.Equal(t, uint64(5), md.TensorCount)
|
||||
assert.Equal(t, "aaaa", md.MetadataHash, "a single shard's hash passes through unchanged")
|
||||
assert.Nil(t, md.Parts, "single-shard models do not populate Parts")
|
||||
})
|
||||
|
||||
t.Run("multi-shard: tensors summed, Parts sorted by hash, rollup is order-independent", func(t *testing.T) {
|
||||
in := []pkg.Package{
|
||||
stPkg(shardMeta("cccc", 3), "/m/c.safetensors"),
|
||||
stPkg(shardMeta("aaaa", 3), "/m/a.safetensors"),
|
||||
stPkg(shardMeta("bbbb", 3), "/m/b.safetensors"),
|
||||
}
|
||||
out := mergeSafeTensorsGroup(in)
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, 3, md.ShardCount)
|
||||
assert.Equal(t, uint64(9), md.TensorCount, "tensor counts are summed across shards")
|
||||
assert.Equal(t, uint64(3000), md.Parameters, "parameter counts are summed across shards")
|
||||
require.Len(t, md.Parts, 3)
|
||||
assert.Equal(t,
|
||||
[]string{"aaaa", "bbbb", "cccc"},
|
||||
[]string{md.Parts[0].MetadataHash, md.Parts[1].MetadataHash, md.Parts[2].MetadataHash},
|
||||
"Parts are sorted by metadata hash",
|
||||
)
|
||||
assert.Equal(t, rollupHash([]string{"aaaa", "bbbb", "cccc"}), md.MetadataHash)
|
||||
|
||||
// the rollup hash must not depend on the order members arrive in
|
||||
shuffled := []pkg.Package{
|
||||
stPkg(shardMeta("bbbb", 3), "/m/b.safetensors"),
|
||||
stPkg(shardMeta("cccc", 3), "/m/c.safetensors"),
|
||||
stPkg(shardMeta("aaaa", 3), "/m/a.safetensors"),
|
||||
}
|
||||
out2 := mergeSafeTensorsGroup(shuffled)
|
||||
assert.Equal(t, md.MetadataHash, out2.Metadata.(pkg.SafeTensorsModelInfo).MetadataHash)
|
||||
})
|
||||
|
||||
t.Run("aggregate fields win over shard-derived fields", func(t *testing.T) {
|
||||
// an aggregate (OCI config blob) carries no MetadataHash but declares the
|
||||
// authoritative totals.
|
||||
aggregate := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: 999,
|
||||
TotalSize: "5.00GB",
|
||||
Quantization: "Q4_K_M",
|
||||
}
|
||||
in := []pkg.Package{
|
||||
stPkg(aggregate, "/"),
|
||||
stPkg(shardMeta("aaaa", 3), "/"),
|
||||
stPkg(shardMeta("bbbb", 3), "/"),
|
||||
}
|
||||
out := mergeSafeTensorsGroup(in)
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, uint64(999), md.TensorCount, "aggregate TensorCount is authoritative; shard counts are not summed in")
|
||||
assert.Equal(t, "5.00GB", md.TotalSize)
|
||||
assert.Equal(t, uint64(2000), md.Parameters, "parameters are always measured from the shards (summed), not taken from the aggregate")
|
||||
assert.Equal(t, "Q4_K_M", md.Quantization, "aggregate quantization wins over the shard dtype")
|
||||
assert.Equal(t, 2, md.ShardCount, "ShardCount comes from the number of shards, not the aggregate")
|
||||
assert.Equal(t, rollupHash([]string{"aaaa", "bbbb"}), md.MetadataHash, "the content hash still rolls up the shard hashes")
|
||||
})
|
||||
|
||||
t.Run("aggregate-only group: ShardCount 1, empty hash, no Parts", func(t *testing.T) {
|
||||
aggregate := pkg.SafeTensorsModelInfo{Format: "safetensors", TensorCount: 42, TotalSize: "1.00GB"}
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(aggregate, "/")})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, uint64(42), md.TensorCount)
|
||||
assert.Equal(t, 1, md.ShardCount, "a group with no shards still reports a single shard")
|
||||
assert.Equal(t, "", md.MetadataHash, "there are no shard hashes to roll up")
|
||||
assert.Nil(t, md.Parts)
|
||||
})
|
||||
|
||||
t.Run("UserMetadata: keys merged and sorted, first value wins on conflict", func(t *testing.T) {
|
||||
// keys are intentionally unsorted within each shard so the assertion proves
|
||||
// the merge re-sorts globally; "format" appears in both shards so dedup
|
||||
// precedence (first wins) is exercised too.
|
||||
s1 := shardMeta("aaaa", 1)
|
||||
s1.UserMetadata = pkg.KeyValues{{Key: "format", Value: "pt"}, {Key: "author", Value: "alice"}}
|
||||
s2 := shardMeta("bbbb", 1)
|
||||
s2.UserMetadata = pkg.KeyValues{{Key: "format", Value: "gguf"}, {Key: "license", Value: "mit"}}
|
||||
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(s1, "/m/a.safetensors"), stPkg(s2, "/m/b.safetensors")})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, pkg.KeyValues{
|
||||
{Key: "author", Value: "alice"},
|
||||
{Key: "format", Value: "pt"}, // first shard's value wins over s2's "gguf"
|
||||
{Key: "license", Value: "mit"},
|
||||
}, md.UserMetadata)
|
||||
})
|
||||
|
||||
t.Run("members without safetensors metadata are ignored in the rollup", func(t *testing.T) {
|
||||
notST := pkg.Package{
|
||||
Type: pkg.ModelPkg,
|
||||
Metadata: pkg.GGUFFileHeader{},
|
||||
Locations: file.NewLocationSet(file.NewLocation("/m/x.gguf")),
|
||||
}
|
||||
out := mergeSafeTensorsGroup([]pkg.Package{stPkg(shardMeta("aaaa", 2), "/m/a.safetensors"), notST})
|
||||
|
||||
md := out.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
assert.Equal(t, uint64(2), md.TensorCount, "only the safetensors shard contributes")
|
||||
assert.Equal(t, 1, md.ShardCount)
|
||||
assert.Equal(t, "aaaa", md.MetadataHash)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRollupHash locks the cross-source content-fingerprint rollup: empty input
|
||||
// yields no hash, a lone shard's hash passes through unchanged (so a single-shard
|
||||
// model fingerprints identically across directory and OCI sources), and multiple
|
||||
// shards fold into one order-independent digest.
|
||||
func TestRollupHash(t *testing.T) {
|
||||
assert.Equal(t, "", rollupHash(nil), "no hashes → empty")
|
||||
assert.Equal(t, "solo", rollupHash([]string{"solo"}), "a single hash passes through unchanged")
|
||||
|
||||
ab := rollupHash([]string{"a", "b"})
|
||||
ba := rollupHash([]string{"b", "a"})
|
||||
assert.Equal(t, ab, ba, "the rollup is independent of input order")
|
||||
assert.Len(t, ab, 16, "a multi-hash rollup is a 16-char xxhash")
|
||||
assert.NotEqual(t, "a", ab)
|
||||
assert.NotEqual(t, "b", ab)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ai
|
||||
|
||||
import "path"
|
||||
|
||||
// pickSafeTensorsName implements the documented naming precedence chain:
|
||||
func pickSafeTensorsName(nameOrPath, fallbackName string) string {
|
||||
if nameOrPath != "" {
|
||||
return path.Base(nameOrPath)
|
||||
}
|
||||
return fallbackName
|
||||
}
|
||||
|
||||
// safeTensorsDirName returns the directory-scan naming fallback: the base name
|
||||
// of the group's parent directory (the group key is already that directory).
|
||||
func safeTensorsDirName(directory string) string {
|
||||
base := path.Base(directory)
|
||||
switch base {
|
||||
case "/", ".", "":
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -20,3 +20,17 @@ func newGGUFPackage(metadata *pkg.GGUFFileHeader, modelName, version, license st
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// newSafeTensorsPackage creates a SafeTensors package with the given metadata
|
||||
// and locations. Name and Licenses are intentionally not set here and done at the processor level
|
||||
func newSafeTensorsPackage(metadata *pkg.SafeTensorsModelInfo, locations ...file.Location) pkg.Package {
|
||||
p := pkg.Package{
|
||||
Locations: file.NewLocationSet(locations...),
|
||||
Type: pkg.ModelPkg,
|
||||
Metadata: *metadata,
|
||||
// PURL is intentionally not set: package-url has not yet finalized ML model support.
|
||||
}
|
||||
p.SetID()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// SafeTensors file format: [8 bytes u64 LE header size] [N bytes JSON header] [tensor data].
|
||||
// Reference: https://github.com/huggingface/safetensors#format
|
||||
const (
|
||||
maxSafeTensorsHeaderSize = 100 * 1024 * 1024 // 100MB ceiling on header JSON to prevent OOM
|
||||
)
|
||||
|
||||
// safeTensorsHeader is the decoded JSON header. Tensor entries live alongside a
|
||||
// reserved "__metadata__" key holding a string-to-string producer map. We decode
|
||||
// tensor entries into a generic map so we can iterate and count without a fixed
|
||||
// schema for every field.
|
||||
type safeTensorsHeader struct {
|
||||
metadata map[string]string
|
||||
tensors map[string]safeTensorsEntry
|
||||
}
|
||||
|
||||
// safeTensorsEntry describes a single tensor within the header JSON.
|
||||
type safeTensorsEntry struct {
|
||||
DType string `json:"dtype"`
|
||||
Shape []int64 `json:"shape"`
|
||||
DataOffsets []int64 `json:"data_offsets"`
|
||||
}
|
||||
|
||||
// readSafeTensorsHeader reads and parses the JSON header from a .safetensors
|
||||
// file (the leading `[8-byte LE length] [length bytes of JSON]` block) and
|
||||
// returns the decoded header.
|
||||
func readSafeTensorsHeader(r io.Reader) (*safeTensorsHeader, error) {
|
||||
var lenBuf [8]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, fmt.Errorf("failed to read header length: %w", err)
|
||||
}
|
||||
headerLen := binary.LittleEndian.Uint64(lenBuf[:])
|
||||
if headerLen == 0 {
|
||||
return nil, fmt.Errorf("safetensors header length is zero")
|
||||
}
|
||||
if headerLen > maxSafeTensorsHeaderSize {
|
||||
return nil, fmt.Errorf("safetensors header size %d exceeds maximum %d", headerLen, maxSafeTensorsHeaderSize)
|
||||
}
|
||||
|
||||
// Read incrementally rather than pre-allocating headerLen up front
|
||||
body, err := io.ReadAll(io.LimitReader(r, int64(headerLen)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header body: %w", err)
|
||||
}
|
||||
if uint64(len(body)) != headerLen {
|
||||
return nil, fmt.Errorf("safetensors header truncated: read %d of %d bytes", len(body), headerLen)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode safetensors header JSON: %w", err)
|
||||
}
|
||||
|
||||
h := &safeTensorsHeader{tensors: make(map[string]safeTensorsEntry, len(raw))}
|
||||
for key, val := range raw {
|
||||
if key == "__metadata__" {
|
||||
if err := json.Unmarshal(val, &h.metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode __metadata__: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var entry safeTensorsEntry
|
||||
if err := json.Unmarshal(val, &entry); err != nil {
|
||||
// Not all entries must conform; skip anything we cannot decode rather than fail.
|
||||
continue
|
||||
}
|
||||
h.tensors[key] = entry
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// parameterCount sums the element counts across all tensors in the header.
|
||||
func (h *safeTensorsHeader) parameterCount() uint64 {
|
||||
var total uint64
|
||||
for _, t := range h.tensors {
|
||||
count := uint64(1)
|
||||
for _, dim := range t.Shape {
|
||||
if dim <= 0 {
|
||||
count = 0
|
||||
break
|
||||
}
|
||||
count *= uint64(dim)
|
||||
}
|
||||
total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// dominantDType returns the dtype that accounts for the largest fraction of parameters.
|
||||
// For mixed-precision models the "dominant" dtype is still a useful summary.
|
||||
func (h *safeTensorsHeader) dominantDType() string {
|
||||
sizeByDType := make(map[string]uint64)
|
||||
for _, t := range h.tensors {
|
||||
count := uint64(1)
|
||||
for _, dim := range t.Shape {
|
||||
if dim <= 0 {
|
||||
count = 0
|
||||
break
|
||||
}
|
||||
count *= uint64(dim)
|
||||
}
|
||||
sizeByDType[t.DType] += count
|
||||
}
|
||||
var best string
|
||||
var bestSize uint64
|
||||
for dtype, size := range sizeByDType {
|
||||
if size > bestSize || (size == bestSize && dtype < best) {
|
||||
best = dtype
|
||||
bestSize = size
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// metadataHash returns a stable xxhash64 over the logical tensor content
|
||||
// (name + dtype + shape) plus the __metadata__ map. Tensor keys are sorted to
|
||||
// keep the hash deterministic across producers.
|
||||
func (h *safeTensorsHeader) metadataHash() string {
|
||||
type logicalEntry struct {
|
||||
Name string `json:"name"`
|
||||
DType string `json:"dtype"`
|
||||
Shape []int64 `json:"shape"`
|
||||
}
|
||||
entries := make([]logicalEntry, 0, len(h.tensors))
|
||||
for name, t := range h.tensors {
|
||||
entries = append(entries, logicalEntry{Name: name, DType: t.DType, Shape: t.Shape})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
type hashInput struct {
|
||||
Tensors []logicalEntry `json:"tensors"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
b, err := json.Marshal(hashInput{Tensors: entries, Metadata: h.metadata})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%016x", xxhash.Sum64(b))
|
||||
}
|
||||
|
||||
// userMetadataKeyValues converts the safetensors __metadata__ map into a
|
||||
// KeyValues slice sorted by key, so SBOM output is stable across runs. Returns
|
||||
// nil for empty input (omitempty then drops the field).
|
||||
func userMetadataKeyValues(m map[string]string) pkg.KeyValues {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make(pkg.KeyValues, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, pkg.KeyValue{Key: k, Value: m[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeDType maps a safetensors/torch dtype label to an uppercase quantization
|
||||
// shorthand matching conventions used elsewhere in syft (e.g., BF16, F16, I8).
|
||||
func normalizeDType(dtype string) string {
|
||||
switch strings.ToUpper(dtype) {
|
||||
case "BF16":
|
||||
return "BF16"
|
||||
case "F16", "FP16", "FLOAT16", "HALF":
|
||||
return "F16"
|
||||
case "F32", "FP32", "FLOAT32", "FLOAT":
|
||||
return "F32"
|
||||
case "F64", "FP64", "FLOAT64", "DOUBLE":
|
||||
return "F64"
|
||||
case "I8", "INT8":
|
||||
return "I8"
|
||||
case "U8", "UINT8":
|
||||
return "U8"
|
||||
case "I16", "INT16":
|
||||
return "I16"
|
||||
case "I32", "INT32":
|
||||
return "I32"
|
||||
case "I64", "INT64":
|
||||
return "I64"
|
||||
case "BOOL":
|
||||
return "BOOL"
|
||||
default:
|
||||
return strings.ToUpper(dtype)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"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/generic"
|
||||
)
|
||||
|
||||
// parseSafeTensorsFile decodes the JSON header of a single .safetensors file
|
||||
// (also called once per shard for sharded models) and emits a nameless package
|
||||
// whose metadata is derived purely from the header bytes. Naming, license
|
||||
// resolution, sibling enrichment, and cross-shard rollup are all handled by
|
||||
// safeTensorsMergeProcessor.
|
||||
func parseSafeTensorsFile(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
header, err := readSafeTensorsHeader(&io.LimitedReader{R: reader, N: maxSafeTensorsHeaderSize + 8})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read safetensors header: %w", err)
|
||||
}
|
||||
|
||||
// ShardCount is intentionally not set here: the merge processor is the single
|
||||
// owner of ShardCount and derives it from the number of shards in the group.
|
||||
md := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: uint64(len(header.tensors)),
|
||||
Parameters: header.parameterCount(),
|
||||
Quantization: normalizeDType(header.dominantDType()),
|
||||
UserMetadata: userMetadataKeyValues(header.metadata),
|
||||
MetadataHash: header.metadataHash(),
|
||||
}
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse safetensors file")
|
||||
}
|
||||
|
||||
// integrity check
|
||||
var _ generic.Parser = parseSafeTensorsFile
|
||||
@@ -0,0 +1,117 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"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/generic"
|
||||
)
|
||||
|
||||
// Docker AI OCI media types used by Docker Model Runner artifacts.
|
||||
const (
|
||||
dockerAIModelFileMediaType = "application/vnd.docker.ai.model.file"
|
||||
dockerAILicenseMediaType = "application/vnd.docker.ai.license"
|
||||
dockerAISafeTensorsMediaType = "application/vnd.docker.ai.safetensors"
|
||||
)
|
||||
|
||||
// dockerAIModelConfigMediaTypes are the model-config schema versions this
|
||||
// cataloger understands. Versions are enumerated explicitly rather than matched
|
||||
// with a wildcard so that a future, potentially breaking, config schema is not
|
||||
// silently consumed; add a new version here only after confirming the fields we
|
||||
// parse still apply.
|
||||
var dockerAIModelConfigMediaTypes = []string{
|
||||
"application/vnd.docker.ai.model.config.v0.1+json",
|
||||
"application/vnd.docker.ai.model.config.v0.2+json",
|
||||
}
|
||||
|
||||
// dockerAIModelConfig mirrors the JSON shape of the vnd.docker.ai.model.config
|
||||
// blob written by Docker Model Runner for AI artifacts. Only fields we use are
|
||||
// declared; unknown fields are ignored.
|
||||
type dockerAIModelConfig struct {
|
||||
Config struct {
|
||||
Format string `json:"format"`
|
||||
Quantization string `json:"quantization"`
|
||||
Size string `json:"size"`
|
||||
SafeTensors struct {
|
||||
TensorCount json.Number `json:"tensor_count"`
|
||||
} `json:"safetensors"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// parseSafeTensorsOCIConfig decodes the Docker AI model-config blob
|
||||
func parseSafeTensorsOCIConfig(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(reader, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read docker AI model config: %w", err)
|
||||
}
|
||||
|
||||
var cfg dockerAIModelConfig
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to decode docker AI model config: %w", err)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(cfg.Config.Format, "safetensors") {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Parameters is intentionally not read from the config blob: we measure the
|
||||
// true parameter count from the SafeTensors layer headers (parseSafeTensorsOCILayer)
|
||||
// so OCI and directory scans of the same model agree, rather than trusting the
|
||||
// producer-supplied label here.
|
||||
md := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
Quantization: cfg.Config.Quantization,
|
||||
TotalSize: cfg.Config.Size,
|
||||
}
|
||||
if n, err := cfg.Config.SafeTensors.TensorCount.Int64(); err == nil && n > 0 {
|
||||
md.TensorCount = uint64(n)
|
||||
}
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, unknown.IfEmptyf([]pkg.Package{p}, "unable to parse docker AI safetensors config")
|
||||
}
|
||||
|
||||
// parseSafeTensorsOCILayer decodes the JSON header of a SafeTensors weight
|
||||
// layer fetched from an OCI model artifact
|
||||
func parseSafeTensorsOCILayer(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
defer internal.CloseAndLogError(reader, reader.Path())
|
||||
|
||||
header, err := readSafeTensorsHeader(&io.LimitedReader{R: reader, N: maxSafeTensorsHeaderSize + 8})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read safetensors layer header: %w", err)
|
||||
}
|
||||
|
||||
md := pkg.SafeTensorsModelInfo{
|
||||
Format: "safetensors",
|
||||
TensorCount: uint64(len(header.tensors)),
|
||||
Parameters: header.parameterCount(),
|
||||
Quantization: normalizeDType(header.dominantDType()),
|
||||
UserMetadata: userMetadataKeyValues(header.metadata),
|
||||
MetadataHash: header.metadataHash(),
|
||||
}
|
||||
|
||||
p := newSafeTensorsPackage(
|
||||
&md,
|
||||
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
)
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
// integrity checks
|
||||
var (
|
||||
_ generic.Parser = parseSafeTensorsOCIConfig
|
||||
_ generic.Parser = parseSafeTensorsOCILayer
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,172 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// ggufMergeProcessor consolidates multiple GGUF packages into a single package
|
||||
// representing the AI model. When scanning OCI images with multiple layers,
|
||||
// each layer may produce a separate package. This processor finds the package
|
||||
// with a name and merges metadata from nameless packages into its GGUFFileParts field.
|
||||
// Only packages with a non-empty name are returned in the final result.
|
||||
func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
if err != nil {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// safeTensorsMergeProcessor owns naming, license resolution, and final package
|
||||
// assembly. SafeTensors packages reach it nameless from the parsers; it groups
|
||||
// them per model, merges the per-shard metadata, resolves a name + licenses, and
|
||||
// drops any model it cannot name.
|
||||
func safeTensorsMergeProcessor(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
if len(pkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// Separate packages with names from those without
|
||||
var namedPkgs []pkg.Package
|
||||
var namelessHeaders []pkg.GGUFFileHeader
|
||||
|
||||
for _, p := range pkgs {
|
||||
if p.Name != "" {
|
||||
namedPkgs = append(namedPkgs, p)
|
||||
} else {
|
||||
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
// We do not want a kv hash for nameless headers
|
||||
header.MetadataKeyValuesHash = ""
|
||||
namelessHeaders = append(namelessHeaders, header)
|
||||
}
|
||||
}
|
||||
// Note: we do NOT early-return when err != nil. A non-nil err here means some
|
||||
// file in the run failed to parse, but the successfully-parsed packages are
|
||||
// still nameless until this processor names or drops them. Skipping that work
|
||||
// would let those nameless packages flow downstream, where they are silently
|
||||
// dropped by missing-name compliance — turning one bad file into the loss of
|
||||
// every otherwise-valid model. So we always name/drop and propagate err.
|
||||
stPkgs, other := partitionSafeTensorsPackages(pkgs)
|
||||
if len(stPkgs) == 0 {
|
||||
return pkgs, rels, err
|
||||
}
|
||||
|
||||
// If there are no named packages, return nothing
|
||||
if len(namedPkgs) == 0 {
|
||||
return nil, rels, err
|
||||
if fromOCIArtifact(stPkgs) {
|
||||
return append(other, mergeOCIModel(ctx, resolver, stPkgs)...), rels, err
|
||||
}
|
||||
return append(other, mergeDirModels(ctx, resolver, stPkgs)...), rels, err
|
||||
}
|
||||
|
||||
// merge nameless headers into a single named package;
|
||||
// if there are multiple named packages, return them without trying to merge headers.
|
||||
// we cannot determine which nameless headers belong to which package
|
||||
// this is because the order we receive the gguf headers in is not guaranteed
|
||||
// to match the layer order in the original oci image
|
||||
if len(namedPkgs) == 1 && len(namelessHeaders) > 0 {
|
||||
winner := &namedPkgs[0]
|
||||
if header, ok := winner.Metadata.(pkg.GGUFFileHeader); ok {
|
||||
header.Parts = namelessHeaders
|
||||
winner.Metadata = header
|
||||
// partitionSafeTensorsPackages separates safetensors packages from anything else
|
||||
// flowing through the processor.
|
||||
func partitionSafeTensorsPackages(pkgs []pkg.Package) (safeTensors, other []pkg.Package) {
|
||||
for _, p := range pkgs {
|
||||
if _, ok := p.Metadata.(pkg.SafeTensorsModelInfo); ok {
|
||||
safeTensors = append(safeTensors, p)
|
||||
continue
|
||||
}
|
||||
other = append(other, p)
|
||||
}
|
||||
return safeTensors, other
|
||||
}
|
||||
|
||||
// Largest number of key value
|
||||
// fromOCIArtifact reports whether the packages came from an OCI model artifact.
|
||||
// That source (the ContainerImageModel resolver) presents every layer at the
|
||||
// virtual path "/", whereas a filesystem scan always carries a real file path. A
|
||||
// single scan is one source, so the first package is representative of the rest.
|
||||
func fromOCIArtifact(pkgs []pkg.Package) bool {
|
||||
loc := primaryEvidenceLocation(pkgs[0])
|
||||
return loc != nil && loc.RealPath == "/"
|
||||
}
|
||||
|
||||
return namedPkgs, rels, err
|
||||
// mergeOCIModel treats the whole OCI artifact as a single model: every layer
|
||||
// merges into one package, named from the artifact's config.json/README or its
|
||||
// image reference.
|
||||
func mergeOCIModel(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package) []pkg.Package {
|
||||
merged := mergeSafeTensorsGroup(pkgs)
|
||||
|
||||
md := merged.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
id := resolveSafeTensorsOCIIdentity(ctx, resolver, &md)
|
||||
merged.Metadata = md // write architecture enrichment back before assembly
|
||||
|
||||
if p, ok := assembleSafeTensorsPackage(merged, id); ok {
|
||||
return []pkg.Package{p}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeDirModels groups filesystem-scanned files by their parent directory and
|
||||
// emits one model per directory
|
||||
func mergeDirModels(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package) []pkg.Package {
|
||||
groups := groupByParentDir(pkgs)
|
||||
|
||||
// deterministic iteration order so the SBOM doesn't depend on map order
|
||||
dirs := make([]string, 0, len(groups))
|
||||
for dir := range groups {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
|
||||
var out []pkg.Package
|
||||
for _, dir := range dirs {
|
||||
merged := mergeSafeTensorsGroup(groups[dir])
|
||||
|
||||
md := merged.Metadata.(pkg.SafeTensorsModelInfo)
|
||||
id := resolveSafeTensorsDirIdentity(ctx, resolver, dir, &md)
|
||||
merged.Metadata = md // write architecture enrichment back before assembly
|
||||
|
||||
if p, ok := assembleSafeTensorsPackage(merged, id); ok {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupByParentDir buckets filesystem-scanned models by the directory their
|
||||
// primary-evidence file lives in.
|
||||
|
||||
// This encodes a deliberate assumption: a directory holds one logical
|
||||
// model, so every .safetensors file in a directory is treated as a shard of the
|
||||
// same modeland merged into one package.
|
||||
// The trade-off is that if a directory happens to contain several unrelated models,
|
||||
// they are merged into one package rather than reported separately.
|
||||
// We accept that because the conventional on-disk layout gives each model
|
||||
// (with all of its shards) its own directory.
|
||||
// We have no reliable per-file signal to tell co-located-but-independent models apart.
|
||||
func groupByParentDir(pkgs []pkg.Package) map[string][]pkg.Package {
|
||||
out := make(map[string][]pkg.Package)
|
||||
for _, p := range pkgs {
|
||||
loc := primaryEvidenceLocation(p)
|
||||
if loc == nil {
|
||||
continue
|
||||
}
|
||||
dir := path.Dir(loc.RealPath)
|
||||
out[dir] = append(out[dir], p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func primaryEvidenceLocation(p pkg.Package) *file.Location {
|
||||
locs := p.Locations.ToSlice()
|
||||
for i, l := range locs {
|
||||
if l.Annotations != nil && l.Annotations[pkg.EvidenceAnnotationKey] == pkg.PrimaryEvidenceAnnotation {
|
||||
return &locs[i]
|
||||
}
|
||||
}
|
||||
if len(locs) > 0 {
|
||||
return &locs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeTensorsIdentity is the fully-resolved naming/license result for a model.
|
||||
// Each source resolver (dir or OCI) populates it so assembly stays source-agnostic.
|
||||
type safeTensorsIdentity struct {
|
||||
nameOrPath string
|
||||
fallbackName string
|
||||
licenses []pkg.License
|
||||
supporting []file.Location
|
||||
}
|
||||
|
||||
// assembleSafeTensorsPackage finalizes a merged model from its resolved identity:
|
||||
// it picks the name, attaches licenses and supporting evidence, and sets the ID.
|
||||
// A model with no name source is dropped (ok=false).
|
||||
func assembleSafeTensorsPackage(merged pkg.Package, id safeTensorsIdentity) (pkg.Package, bool) {
|
||||
name := pickSafeTensorsName(id.nameOrPath, id.fallbackName)
|
||||
if name == "" {
|
||||
log.Debugf("dropped safetensors model package (metadata hash %q): no name source",
|
||||
merged.Metadata.(pkg.SafeTensorsModelInfo).MetadataHash)
|
||||
return pkg.Package{}, false
|
||||
}
|
||||
|
||||
if len(id.licenses) > 0 {
|
||||
merged.Licenses = pkg.NewLicenseSet(id.licenses...)
|
||||
}
|
||||
for _, loc := range id.supporting {
|
||||
merged.Locations.Add(loc.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.SupportingEvidenceAnnotation))
|
||||
}
|
||||
|
||||
merged.Name = name
|
||||
merged.SetID()
|
||||
return merged, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# SafeTensors header fixtures
|
||||
|
||||
These fixtures are `[8-byte length prefix + JSON header]` captures from
|
||||
public Docker AI model artifacts on the registry.
|
||||
|
||||
`extract_header.go` does a range-GET of the first several MB of the layer,
|
||||
slices off just `[prefix + JSON header]`, and writes
|
||||
that to disk.
|
||||
|
||||
## Refreshing a fixture
|
||||
|
||||
```sh
|
||||
# from the package root
|
||||
go run ./testdata/safetensors/extract_header.go \
|
||||
docker.io/ai/nomic-embed-text-v2-moe-safetensors:475M \
|
||||
./testdata/safetensors/nomic-embed-475M.header.safetensors
|
||||
```
|
||||
|
||||
The tool prints the layer digest it selected and the number of top-level keys
|
||||
in the captured header. If you see `header length N does not fit in M fetched
|
||||
bytes`, raise `fetchBytes` in `extract_header.go` and rerun.
|
||||
|
||||
## Notes
|
||||
|
||||
- Pick one shard, not the full sharded set. The fixture is meant to exercise
|
||||
the per-shard parser; merging across shards has its own tests.
|
||||
- Don't commit anything larger than ~1 MB. If a model has an unusually large
|
||||
header, capture a smaller model instead.
|
||||
@@ -0,0 +1,149 @@
|
||||
// extract_header is a manual fixture tool that captures the real on-disk
|
||||
// safetensors header from a Docker AI OCI model artifact (a vnd.docker.ai.safetensors
|
||||
// layer) and writes just [8-byte length prefix + JSON header] to a destination
|
||||
// file. Tensor data following the header is never downloaded, so the resulting
|
||||
// fixture is a few KB to a few MB even for multi-GB models.
|
||||
//
|
||||
// This file lives under testdata/ so the Go build system ignores it. Run it
|
||||
// manually when refreshing fixtures:
|
||||
//
|
||||
// go run ./testdata/safetensors/extract_header.go \
|
||||
// docker.io/ai/nomic-embed-text-v2-moe-safetensors:475M \
|
||||
// ./testdata/safetensors/nomic-embed-475M.header.safetensors
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
)
|
||||
|
||||
const (
|
||||
safetensorsLayerMediaType = "application/vnd.docker.ai.safetensors"
|
||||
// 8 MB matches maxHeaderBytes in the OCI model source. Real model headers
|
||||
// are well under 1 MB; the extra slack covers outliers.
|
||||
fetchBytes = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
fmt.Fprintf(os.Stderr, "usage: %s <registry-ref> <output-path>\n", os.Args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := run(os.Args[1], os.Args[2]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(refStr, outPath string) error {
|
||||
ctx := context.Background()
|
||||
ref, err := name.ParseReference(refStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse reference: %w", err)
|
||||
}
|
||||
|
||||
opts := []remote.Option{
|
||||
remote.WithAuthFromKeychain(authn.DefaultKeychain),
|
||||
remote.WithContext(ctx),
|
||||
}
|
||||
|
||||
desc, err := remote.Get(ref, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch descriptor: %w", err)
|
||||
}
|
||||
|
||||
manifest := &v1.Manifest{}
|
||||
if err := json.Unmarshal(desc.Manifest, manifest); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
|
||||
weightLayer := pickWeightLayer(manifest)
|
||||
if weightLayer == nil {
|
||||
return fmt.Errorf("no %q layer found in %s", safetensorsLayerMediaType, ref)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "selected layer %s (%d bytes on-disk)\n", weightLayer.Digest, weightLayer.Size)
|
||||
|
||||
prefix, err := fetchPrefix(ctx, ref, weightLayer.Digest, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch layer prefix: %w", err)
|
||||
}
|
||||
|
||||
header, err := sliceHeader(prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract header: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outPath, header, 0o644); err != nil {
|
||||
return fmt.Errorf("write fixture: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "wrote %d bytes to %s\n", len(header), outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickWeightLayer returns the first vnd.docker.ai.safetensors layer in the
|
||||
// manifest, or nil if none exists. For sharded models we deliberately only
|
||||
// capture one shard: the fixture is meant to exercise the parser, not the
|
||||
// merge step.
|
||||
func pickWeightLayer(manifest *v1.Manifest) *v1.Descriptor {
|
||||
for i := range manifest.Layers {
|
||||
if string(manifest.Layers[i].MediaType) == safetensorsLayerMediaType {
|
||||
return &manifest.Layers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchPrefix range-reads the first fetchBytes of a layer. Closing the reader
|
||||
// terminates the underlying HTTP body, so we never download the tensor data
|
||||
// that follows the header.
|
||||
func fetchPrefix(_ context.Context, ref name.Reference, digest v1.Hash, opts []remote.Option) ([]byte, error) {
|
||||
layer, err := remote.Layer(ref.Context().Digest(digest.String()), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, err := layer.Compressed()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
buf := make([]byte, fetchBytes)
|
||||
n, err := io.ReadFull(reader, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return nil, err
|
||||
}
|
||||
return buf[:n], nil
|
||||
}
|
||||
|
||||
// sliceHeader reads the 8-byte little-endian length prefix and returns just
|
||||
// [prefix + JSON header]. It also probes the JSON to make sure the captured
|
||||
// fixture is well-formed, so we never commit a half-truncated header.
|
||||
func sliceHeader(buf []byte) ([]byte, error) {
|
||||
if len(buf) < 8 {
|
||||
return nil, fmt.Errorf("short read: only %d bytes", len(buf))
|
||||
}
|
||||
headerLen := binary.LittleEndian.Uint64(buf[:8])
|
||||
if headerLen == 0 {
|
||||
return nil, fmt.Errorf("header length is zero")
|
||||
}
|
||||
if headerLen > uint64(len(buf)-8) {
|
||||
return nil, fmt.Errorf("header length %d does not fit in %d fetched bytes; increase fetchBytes", headerLen, len(buf))
|
||||
}
|
||||
|
||||
out := buf[:8+int(headerLen)]
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out[8:], &probe); err != nil {
|
||||
return nil, fmt.Errorf("captured JSON does not parse: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "header parses cleanly: %d top-level keys\n", len(probe))
|
||||
return out, nil
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
# Cataloger capabilities. See ../README.md for documentation.
|
||||
|
||||
catalogers:
|
||||
- ecosystem: apple # MANUAL
|
||||
name: apple-app-bundle-cataloger # AUTO-GENERATED
|
||||
type: generic # AUTO-GENERATED
|
||||
source: # AUTO-GENERATED
|
||||
file: syft/pkg/cataloger/apple/cataloger.go
|
||||
function: NewAppBundleCataloger
|
||||
selectors: # AUTO-GENERATED
|
||||
- apple
|
||||
- directory
|
||||
- image
|
||||
- installed
|
||||
- package
|
||||
parsers: # AUTO-GENERATED structure
|
||||
- function: parseInfoPlist
|
||||
detector: # AUTO-GENERATED
|
||||
method: glob # AUTO-GENERATED
|
||||
criteria: # AUTO-GENERATED
|
||||
- '**/*.app/Contents/Info.plist'
|
||||
metadata_types: # AUTO-GENERATED
|
||||
- pkg.AppleAppBundleEntry
|
||||
package_types: # AUTO-GENERATED
|
||||
- apple-app-bundle
|
||||
json_schema_types: # AUTO-GENERATED
|
||||
- AppleAppBundleEntry
|
||||
capabilities: # MANUAL - preserved across regeneration
|
||||
- name: license
|
||||
default: false
|
||||
- name: dependency.depth
|
||||
default: []
|
||||
- name: dependency.edges
|
||||
default: ""
|
||||
- name: dependency.kinds
|
||||
default: []
|
||||
- name: package_manager.files.listing
|
||||
default: false
|
||||
- name: package_manager.files.digests
|
||||
default: false
|
||||
- name: package_manager.package_integrity_hash
|
||||
default: false
|
||||
@@ -0,0 +1,12 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
)
|
||||
|
||||
// NewAppBundleCataloger returns a cataloger for Apple application bundles (.app), reading their Info.plist (CFBundle).
|
||||
func NewAppBundleCataloger() pkg.Cataloger {
|
||||
return generic.NewCataloger("apple-app-bundle-cataloger").
|
||||
WithParserByGlobs(parseInfoPlist, "**/*.app/Contents/Info.plist")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
|
||||
)
|
||||
|
||||
func Test_AppleAppBundleCataloger_Globs(t *testing.T) {
|
||||
pkgtest.NewCatalogTester().
|
||||
FromDirectory(t, "testdata/install-example").
|
||||
ExpectsResolverContentQueries([]string{
|
||||
"Applications/Slack.app/Contents/Info.plist",
|
||||
}).
|
||||
TestCataloger(t, NewAppBundleCataloger())
|
||||
}
|
||||
|
||||
func Test_AppleAppBundleCataloger(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
expected []pkg.Package
|
||||
}{
|
||||
{
|
||||
name: "xml plist (Slack)",
|
||||
fixture: "testdata/install-example",
|
||||
expected: []pkg.Package{
|
||||
{
|
||||
Name: "Slack",
|
||||
Version: "4.50.128",
|
||||
Type: pkg.AppleAppBundlePkg,
|
||||
FoundBy: "apple-app-bundle-cataloger",
|
||||
Locations: file.NewLocationSet(
|
||||
file.NewLocation("Applications/Slack.app/Contents/Info.plist").WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
),
|
||||
PURL: "", // no standard purl type for apple app bundles
|
||||
Metadata: pkg.AppleAppBundleEntry{
|
||||
BundleIdentifier: "com.tinyspeck.slackmacgap",
|
||||
Name: "Slack",
|
||||
DisplayName: "Slack",
|
||||
Executable: "Slack",
|
||||
ShortVersion: "4.50.128",
|
||||
Version: "450000128",
|
||||
PackageType: "APPL",
|
||||
MinimumSystemVersion: "12.0",
|
||||
Copyright: "©2026 Slack Technologies LLC, a Salesforce company. All rights reserved.",
|
||||
SDKName: "macosx15.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// platform/array fields; values are from a real Ghostty.app
|
||||
name: "platform fields (Ghostty)",
|
||||
fixture: "testdata/native-app-example",
|
||||
expected: []pkg.Package{
|
||||
{
|
||||
Name: "Ghostty",
|
||||
Version: "1.3.1",
|
||||
Type: pkg.AppleAppBundlePkg,
|
||||
FoundBy: "apple-app-bundle-cataloger",
|
||||
Locations: file.NewLocationSet(
|
||||
file.NewLocation("Applications/Ghostty.app/Contents/Info.plist").WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
|
||||
),
|
||||
PURL: "",
|
||||
Metadata: pkg.AppleAppBundleEntry{
|
||||
BundleIdentifier: "com.mitchellh.ghostty",
|
||||
Name: "Ghostty",
|
||||
DisplayName: "Ghostty",
|
||||
Executable: "ghostty",
|
||||
ShortVersion: "1.3.1",
|
||||
Version: "15212",
|
||||
PackageType: "APPL",
|
||||
SupportedPlatforms: []string{"MacOSX"},
|
||||
MinimumSystemVersion: "13.0",
|
||||
PlatformName: "macosx",
|
||||
SDKName: "macosx26.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgtest.NewCatalogTester().
|
||||
FromDirectory(t, tt.fixture).
|
||||
Expects(tt.expected, nil).
|
||||
TestCataloger(t, NewAppBundleCataloger())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseInfoPlist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
wantName string
|
||||
wantVersion string
|
||||
wantBundleID string
|
||||
wantNoPackage bool
|
||||
wantParseErr bool
|
||||
}{
|
||||
{
|
||||
name: "name falls back to executable",
|
||||
fixture: "testdata/parse-cases/name-from-executable.plist",
|
||||
wantName: "Exec",
|
||||
wantVersion: "1.0",
|
||||
},
|
||||
{
|
||||
name: "name falls back to bundle identifier",
|
||||
fixture: "testdata/parse-cases/name-from-identifier.plist",
|
||||
wantName: "com.example.app",
|
||||
wantVersion: "1.0",
|
||||
wantBundleID: "com.example.app",
|
||||
},
|
||||
{
|
||||
name: "version falls back to bundle version",
|
||||
fixture: "testdata/parse-cases/version-from-bundle-version.plist",
|
||||
wantName: "Name",
|
||||
wantVersion: "42",
|
||||
},
|
||||
{
|
||||
name: "no package without a name",
|
||||
fixture: "testdata/parse-cases/no-name.plist",
|
||||
wantNoPackage: true,
|
||||
},
|
||||
{
|
||||
name: "no package without a version",
|
||||
fixture: "testdata/parse-cases/no-version.plist",
|
||||
wantNoPackage: true,
|
||||
},
|
||||
{
|
||||
name: "malformed plist returns an error",
|
||||
fixture: "testdata/parse-cases/malformed.plist",
|
||||
wantParseErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pkgs, _, err := parseFixture(t, tt.fixture)
|
||||
if tt.wantParseErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.wantNoPackage {
|
||||
assert.Empty(t, pkgs)
|
||||
return
|
||||
}
|
||||
|
||||
require.Len(t, pkgs, 1)
|
||||
assert.Equal(t, tt.wantName, pkgs[0].Name)
|
||||
assert.Equal(t, tt.wantVersion, pkgs[0].Version)
|
||||
assert.Equal(t, pkg.AppleAppBundlePkg, pkgs[0].Type)
|
||||
assert.Empty(t, pkgs[0].PURL)
|
||||
meta, ok := pkgs[0].Metadata.(pkg.AppleAppBundleEntry)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.wantBundleID, meta.BundleIdentifier)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseFixture(t *testing.T, path string) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = f.Close() })
|
||||
return parseInfoPlist(context.Background(), nil, nil, file.NewLocationReadCloser(file.NewLocation(path), f))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
func newAppBundlePackage(name, version string, metadata pkg.AppleAppBundleEntry, location file.Location) pkg.Package {
|
||||
p := pkg.Package{
|
||||
Name: name,
|
||||
Version: version,
|
||||
Type: pkg.AppleAppBundlePkg,
|
||||
// note: there is no standard purl type for Apple app bundles, so we intentionally leave the PURL empty.
|
||||
Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
p.SetID()
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"howett.net/plist"
|
||||
|
||||
"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/generic"
|
||||
)
|
||||
|
||||
// infoPlist captures the raw CFBundle keys read from an Info.plist. It is kept separate from the public
|
||||
// pkg.AppleAppBundleEntry so the parser can read fields without committing to expressing all of them.
|
||||
type infoPlist struct {
|
||||
CFBundleIdentifier string `plist:"CFBundleIdentifier"`
|
||||
CFBundleName string `plist:"CFBundleName"`
|
||||
CFBundleDisplayName string `plist:"CFBundleDisplayName"`
|
||||
CFBundleExecutable string `plist:"CFBundleExecutable"`
|
||||
CFBundleShortVersionString string `plist:"CFBundleShortVersionString"`
|
||||
CFBundleVersion string `plist:"CFBundleVersion"`
|
||||
CFBundlePackageType string `plist:"CFBundlePackageType"`
|
||||
CFBundleSupportedPlatforms []string `plist:"CFBundleSupportedPlatforms"`
|
||||
LSMinimumSystemVersion string `plist:"LSMinimumSystemVersion"`
|
||||
MinimumOSVersion string `plist:"MinimumOSVersion"`
|
||||
NSHumanReadableCopyright string `plist:"NSHumanReadableCopyright"`
|
||||
DTPlatformName string `plist:"DTPlatformName"`
|
||||
DTSDKName string `plist:"DTSDKName"`
|
||||
}
|
||||
|
||||
func parseInfoPlist(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
// 5MB cap matches the convention in syft/linux; Info.plist files are far smaller
|
||||
data, err := io.ReadAll(io.LimitReader(reader, 5*1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to read file: %w", err)
|
||||
}
|
||||
|
||||
var info infoPlist
|
||||
if _, err := plist.Unmarshal(data, &info); err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to parse plist: %w", err)
|
||||
}
|
||||
|
||||
name := info.CFBundleDisplayName
|
||||
if name == "" {
|
||||
name = info.CFBundleName
|
||||
}
|
||||
if name == "" {
|
||||
name = info.CFBundleExecutable
|
||||
}
|
||||
if name == "" {
|
||||
// last-resort name so identifiable bundles aren't dropped (e.g. com.apple.Safari)
|
||||
name = info.CFBundleIdentifier
|
||||
}
|
||||
|
||||
version := info.CFBundleShortVersionString
|
||||
if version == "" {
|
||||
version = info.CFBundleVersion
|
||||
}
|
||||
|
||||
if name == "" || version == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
pkgs := []pkg.Package{
|
||||
newAppBundlePackage(name, version, info.toEntry(), reader.Location),
|
||||
}
|
||||
|
||||
return pkgs, nil, nil
|
||||
}
|
||||
|
||||
// toEntry maps the parsed plist into the public metadata type.
|
||||
func (i infoPlist) toEntry() pkg.AppleAppBundleEntry {
|
||||
return pkg.AppleAppBundleEntry{
|
||||
BundleIdentifier: i.CFBundleIdentifier,
|
||||
Name: i.CFBundleName,
|
||||
DisplayName: i.CFBundleDisplayName,
|
||||
Executable: i.CFBundleExecutable,
|
||||
ShortVersion: i.CFBundleShortVersionString,
|
||||
Version: i.CFBundleVersion,
|
||||
PackageType: i.CFBundlePackageType,
|
||||
SupportedPlatforms: i.CFBundleSupportedPlatforms,
|
||||
MinimumSystemVersion: i.LSMinimumSystemVersion,
|
||||
MinimumOSVersion: i.MinimumOSVersion,
|
||||
Copyright: i.NSHumanReadableCopyright,
|
||||
PlatformName: i.DTPlatformName,
|
||||
SDKName: i.DTSDKName,
|
||||
}
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Slack</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Slack</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Slack</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.tinyspeck.slackmacgap</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>4.50.128</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>450000128</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>©2026 Slack Technologies LLC, a Salesforce company. All rights reserved.</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx15.5</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Ghostty</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ghostty</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ghostty</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.mitchellh.ghostty</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>15212</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx26.2</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
this is not a valid plist
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Exec</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.example.app</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Name</string>
|
||||
</dict>
|
||||
</plist>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Name</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>42</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,8 +5,9 @@
|
||||
> 1. head to the correct directory: `cd testdata`
|
||||
> 2. add a new entry to `config.yaml` to track where to get the binary from (verify the entry with `make list`)
|
||||
> 3. run `make download` to get the binary
|
||||
> 4. run `make add-snippet` and follow the prompts (use `/` to search)
|
||||
> 5. add a new test case to `Test_Cataloger_PositiveCases` in `../cataloger_test.go`
|
||||
> 4. run `make add-snippet` and follow the prompts (use `/` to search; answer `y` at the `(Y/n/q)` prompt to write the snippet)
|
||||
> 5. add a new test case to `Test_Cataloger_PositiveCases` in `../classifier_cataloger_test.go`
|
||||
> 6. if you added a new classifier, regenerate the capabilities catalog from the repo root and commit the result: `go generate ./internal/capabilities`
|
||||
|
||||
|
||||
To test the binary cataloger we run it against a set of files ("test fixtures"). There are two kinds of test fixtures:
|
||||
@@ -32,7 +33,7 @@ And use tooling to list and manage the fixtures:
|
||||
- `make list` - list all fixtures
|
||||
- `make download` - download binaries that are not covered by a snippet
|
||||
- `make download-all` - download all binaries
|
||||
- `go run ./manager add-snippet` - add a new snippet based off of a configured binary
|
||||
- `make add-snippet` - add a new snippet based off of a configured binary
|
||||
- `capture-snippet.sh` - add a new snippet based off of a binary on your local machine (not recommended, but allowed)
|
||||
|
||||
There is a `config.yaml` that tracks all binaries that the tests can use. This makes it possible to download it at any time from a hosted source. Today the only method allowed is to download a container image and extract files out.
|
||||
@@ -74,10 +75,17 @@ from-images:
|
||||
## Testing
|
||||
|
||||
The test cases have been setup to allow testing against full binaries or a mix of both (default).
|
||||
To force running only against full binaries run with:
|
||||
To force running only against the original full binaries (instead of snippets), run with the
|
||||
`-must-use-original-binaries` flag:
|
||||
|
||||
```bash
|
||||
go test -must-use-full-binaries ./syft/pkg/cataloger/binary/testdata/...
|
||||
go test -v ./syft/pkg/cataloger/binary -run Test_Cataloger_PositiveCases/ -must-use-original-binaries
|
||||
```
|
||||
|
||||
To run against a single fixture, append its name to the `-run` filter:
|
||||
|
||||
```bash
|
||||
go test -v ./syft/pkg/cataloger/binary -run Test_Cataloger_PositiveCases/<name> -must-use-original-binaries
|
||||
```
|
||||
|
||||
## Adding a new test fixture
|
||||
@@ -91,7 +99,7 @@ Even if you are adding a snippet, it is best practice to:
|
||||
|
||||
1. Follow the steps above to [add a full binary](#adding-a-full-binary)
|
||||
|
||||
2. Run `go run ./manager add-snippet` and follow the prompts to create a new snippet
|
||||
2. Run `make add-snippet` and follow the prompts to create a new snippet (answer `y` at the `(Y/n/q)` prompt to write the snippet)
|
||||
- you should see your binary in the list of binaries to choose from. If not, check step 2
|
||||
- if the search results in no matching snippets, you can specify your own search with `--search-for <grep-pattern>`
|
||||
- you should see a new snippet file created in `snippets/`
|
||||
|
||||
@@ -666,6 +666,16 @@ catalogers:
|
||||
cpes:
|
||||
- cpe:2.3:a:google:chrome:*:*:*:*:*:*:*:*
|
||||
type: BinaryPkg
|
||||
- method: glob
|
||||
criteria:
|
||||
- '**/{firefox,firefox.exe}'
|
||||
packages:
|
||||
- class: firefox-binary
|
||||
name: firefox
|
||||
purl: pkg:generic/firefox
|
||||
cpes:
|
||||
- cpe:2.3:a:mozilla:firefox:*:*:*:*:*:*:*:*
|
||||
type: BinaryPkg
|
||||
- method: glob
|
||||
criteria:
|
||||
- '**/ffmpeg'
|
||||
@@ -846,6 +856,26 @@ catalogers:
|
||||
cpes:
|
||||
- cpe:2.3:a:elastic:elastic_agent:*:*:*:*:*:*:*:*
|
||||
type: BinaryPkg
|
||||
- method: glob
|
||||
criteria:
|
||||
- '**/libkrb5.so*'
|
||||
packages:
|
||||
- class: krb5-library
|
||||
name: krb5
|
||||
purl: pkg:generic/krb5
|
||||
cpes:
|
||||
- cpe:2.3:a:mit:kerberos_5:*:*:*:*:*:*:*:*
|
||||
type: BinaryPkg
|
||||
- method: glob
|
||||
criteria:
|
||||
- '**/libkrb5.so*'
|
||||
packages:
|
||||
- class: heimdal-krb5-library
|
||||
name: heimdal-krb5
|
||||
purl: pkg:generic/heimdal-krb5
|
||||
cpes:
|
||||
- cpe:2.3:a:heimdal_project:heimdal:*:*:*:*:*:*:*:*
|
||||
type: BinaryPkg
|
||||
- method: glob
|
||||
criteria:
|
||||
- '**/java'
|
||||
@@ -1035,6 +1065,9 @@ catalogers:
|
||||
detector: # AUTO-GENERATED
|
||||
method: glob # AUTO-GENERATED
|
||||
criteria: # AUTO-GENERATED
|
||||
- '**/*.DLL'
|
||||
- '**/*.EXE'
|
||||
- '**/*.BPL'
|
||||
- '**/*.dll'
|
||||
- '**/*.exe'
|
||||
- '**/*.bpl'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user