mirror of
https://github.com/anchore/syft.git
synced 2026-10-12 14:17:20 +02:00
Compare commits
212
Commits
v1.36.0
...
sbom-split
+7
-7
@@ -2,7 +2,7 @@ tools:
|
||||
# we want to use a pinned version of binny to manage the toolchain (so binny manages itself!)
|
||||
- name: binny
|
||||
version:
|
||||
want: v0.10.0
|
||||
want: v0.11.3
|
||||
method: github-release
|
||||
with:
|
||||
repo: anchore/binny
|
||||
@@ -26,7 +26,7 @@ tools:
|
||||
# used for linting
|
||||
- name: golangci-lint
|
||||
version:
|
||||
want: v2.5.0
|
||||
want: v2.11.2
|
||||
method: github-release
|
||||
with:
|
||||
repo: golangci/golangci-lint
|
||||
@@ -42,7 +42,7 @@ tools:
|
||||
# used for signing the checksums file at release
|
||||
- name: cosign
|
||||
version:
|
||||
want: v3.0.2
|
||||
want: v3.0.5
|
||||
method: github-release
|
||||
with:
|
||||
repo: sigstore/cosign
|
||||
@@ -58,7 +58,7 @@ tools:
|
||||
# used to release all artifacts
|
||||
- name: goreleaser
|
||||
version:
|
||||
want: v2.12.6
|
||||
want: v2.14.2
|
||||
method: github-release
|
||||
with:
|
||||
repo: goreleaser/goreleaser
|
||||
@@ -90,7 +90,7 @@ tools:
|
||||
# used for running all local and CI tasks
|
||||
- name: task
|
||||
version:
|
||||
want: v3.45.4
|
||||
want: v3.49.1
|
||||
method: github-release
|
||||
with:
|
||||
repo: go-task/task
|
||||
@@ -98,7 +98,7 @@ tools:
|
||||
# used for triggering a release
|
||||
- name: gh
|
||||
version:
|
||||
want: v2.82.1
|
||||
want: v2.87.3
|
||||
method: github-release
|
||||
with:
|
||||
repo: cli/cli
|
||||
@@ -114,7 +114,7 @@ tools:
|
||||
# used to upload test fixture cache
|
||||
- name: yq
|
||||
version:
|
||||
want: v4.48.1
|
||||
want: v4.52.4
|
||||
method: github-release
|
||||
with:
|
||||
repo: mikefarah/yq
|
||||
|
||||
@@ -5,7 +5,7 @@ inputs:
|
||||
go-version:
|
||||
description: "Go version to install"
|
||||
required: true
|
||||
default: ">= 1.24"
|
||||
default: "1.26.x"
|
||||
go-dependencies:
|
||||
description: "Download go dependencies"
|
||||
required: true
|
||||
@@ -13,7 +13,7 @@ inputs:
|
||||
cache-key-prefix:
|
||||
description: "Prefix all cache keys with this value"
|
||||
required: true
|
||||
default: "181053ac82"
|
||||
default: "53ac821810"
|
||||
download-test-fixture-cache:
|
||||
description: "Download test fixture cache from OCI and github actions"
|
||||
required: true
|
||||
@@ -29,15 +29,16 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# note: go mod and build is automatically cached on default with v4+
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
if: inputs.go-version != ''
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: Restore tool cache
|
||||
if: inputs.tools == 'true'
|
||||
id: tool-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tool
|
||||
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }}
|
||||
@@ -45,7 +46,10 @@ runs:
|
||||
- name: Install project tools
|
||||
shell: bash
|
||||
if: inputs.tools == 'true'
|
||||
run: make tools
|
||||
run: |
|
||||
make tools
|
||||
.tool/binny list
|
||||
.tool/binny check
|
||||
|
||||
- name: Install go dependencies
|
||||
if: inputs.go-dependencies == 'true'
|
||||
@@ -63,7 +67,7 @@ runs:
|
||||
|
||||
- name: Restore ORAS cache from github actions
|
||||
if: inputs.download-test-fixture-cache == 'true'
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ${{ github.workspace }}/.tmp/oras-cache
|
||||
key: ${{ inputs.cache-key-prefix }}-oras-cache
|
||||
|
||||
+38
-11
@@ -1,27 +1,54 @@
|
||||
# Dependabot configuration
|
||||
#
|
||||
# Grouping behavior (see inline comments for details):
|
||||
# - Minor + patch updates: grouped into a single PR per ecosystem
|
||||
# - Major version bumps: individual PR per dependency
|
||||
# - Security updates: individual PR per dependency
|
||||
#
|
||||
# Note: "patch" refers to semver version bumps (1.2.3 -> 1.2.4), not security fixes.
|
||||
# Security updates are identified separately via GitHub's Advisory Database and
|
||||
# can be any version bump (patch, minor, or major) that fixes a known CVE.
|
||||
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
go-minor-patch:
|
||||
applies-to: version-updates # security updates get individual PRs
|
||||
patterns:
|
||||
- "*"
|
||||
update-types: # major omitted, gets individual PRs
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/bootstrap"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/.github/actions/bootstrap"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
applies-to: version-updates # security updates get individual PRs
|
||||
patterns:
|
||||
- "*"
|
||||
update-types: # major omitted, gets individual PRs
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Description
|
||||
## Description
|
||||
|
||||
Please include a summary of the changes along with any relevant motivation and context,
|
||||
or link to an issue where this is explained.
|
||||
<!-- Please include a summary of the changes along with any relevant motivation and context -->
|
||||
|
||||
<!-- If this completes an issue, please include: -->
|
||||
<!-- If CLI output changed, show an example (before/after if helpful) -->
|
||||
|
||||
- Fixes
|
||||
<!-- If this changes application or API configuration, describe new/changed/removed options -->
|
||||
|
||||
## Type of change
|
||||
|
||||
@@ -18,8 +17,12 @@ or link to an issue where this is explained.
|
||||
- [ ] Chore (improve the developer experience, fix a test flake, etc, without changing the visible behavior of Syft)
|
||||
- [ ] Performance (make Syft run faster or use less memory, without changing visible behavior much)
|
||||
|
||||
# Checklist:
|
||||
## Checklist
|
||||
|
||||
- [ ] I have added unit tests that cover changed behavior
|
||||
- [ ] I have tested my code in common scenarios and confirmed there are no regressions
|
||||
- [ ] I have added comments to my code, particularly in hard-to-understand sections
|
||||
|
||||
## Issue references
|
||||
|
||||
<!-- If this fixes an issue, include "Fixes #<issue-number>" or otherwise list the issue references -->
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
|
||||
echo " 🔴 there are uncommitted changes, please commit them before running this check"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! make generate-capabilities REFRESH=false; then
|
||||
echo "Generating capability descriptions failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
|
||||
echo " 🔴 there is drift in capability descriptions! Please run 'make generate-capabilities' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
@@ -24,7 +24,7 @@ while IFS= read -r -d '' file; do
|
||||
echo "File $file is greater than ${size} bytes."
|
||||
found_large_files=1
|
||||
fi
|
||||
done < <(find "$directory" -type f -print0)
|
||||
done < <(git ls-files -z "$directory")
|
||||
|
||||
if [ "$found_large_files" -eq 1 ]; then
|
||||
echo "Script failed: Some files are greater than ${size} bytes."
|
||||
|
||||
@@ -33,9 +33,9 @@ def is_git_tracked_or_untracked(directory):
|
||||
|
||||
|
||||
def find_test_fixture_dirs_with_images(base_dir):
|
||||
"""Find directories that contain 'test-fixtures' and at least one 'image-*' directory."""
|
||||
"""Find directories that contain 'testdata' and at least one 'image-*' directory."""
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
if 'test-fixtures' in root:
|
||||
if 'testdata' in root:
|
||||
image_dirs = [d for d in dirs if d.startswith('image-')]
|
||||
if image_dirs:
|
||||
yield os.path.realpath(root)
|
||||
|
||||
@@ -12,6 +12,6 @@ if ! make generate-json-schema; then
|
||||
fi
|
||||
|
||||
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
|
||||
echo " 🔴 there are uncommitted changes, please commit them before running this check"
|
||||
echo " 🔴 there is drift in json schema! Please run 'make generate-json-schema' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -36,18 +36,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 #v6.0.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 #v3.29.5
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -58,7 +59,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 #v3.29.5
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -72,4 +73,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 #v3.29.5
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -9,16 +9,35 @@ on:
|
||||
version:
|
||||
description: tag the latest commit on main with the given version (prefixed with v)
|
||||
required: true
|
||||
phase:
|
||||
description: the specific workflow phase to run or all
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- "all"
|
||||
- "install-script-only"
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
environment: release
|
||||
if: ${{ github.event.inputs.phase == 'all' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Validate Apple notarization credentials
|
||||
run: .tool/quill submission list
|
||||
env:
|
||||
QUILL_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER }}
|
||||
QUILL_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
|
||||
QUILL_NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY }}
|
||||
|
||||
- name: Check if running on main
|
||||
if: github.ref != 'refs/heads/main'
|
||||
# we are using the following flag when running `cosign blob-verify` for checksum signature verification:
|
||||
@@ -108,15 +127,21 @@ jobs:
|
||||
false
|
||||
|
||||
release:
|
||||
needs: [quality-gate]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [ quality-gate ]
|
||||
if: ${{ github.event.inputs.phase == 'all' }}
|
||||
# runs-on.com: compute instances for parallel builds
|
||||
# spot disabled: reliability for build workflows (used for releases too)
|
||||
# goreleaser uses parallelism of 12, so we need more CPUs
|
||||
# s3-cache: faster actions cache
|
||||
# tmpfs: faster io-intensive workflows
|
||||
runs-on: runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=s3-cache+tmpfs
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# required for goreleaser signs section with cosign
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
@@ -125,13 +150,13 @@ jobs:
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 #v3.5.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 #v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }}
|
||||
password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 #v3.5.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 #v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -161,25 +186,35 @@ jobs:
|
||||
# for updating brew formula in anchore/homebrew-syft
|
||||
GITHUB_BREW_TOKEN: ${{ secrets.ANCHOREOPS_GITHUB_OSS_WRITE_TOKEN }}
|
||||
|
||||
- uses: anchore/sbom-action@aa0e114b2e19480f157109b9922bda359bd98b90 #v0.20.8
|
||||
- uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 #v0.23.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
file: go.mod
|
||||
artifact-name: sbom.spdx.json
|
||||
|
||||
- uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e #v3.19.0
|
||||
- name: Notify Slack of new release
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a #v2.1.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
fields: repo,workflow,action,eventName
|
||||
text: "A new Syft release has been published: https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}"
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
webhook: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
text: "A new Syft release has been published: https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}"
|
||||
blocks:
|
||||
- type: section
|
||||
text:
|
||||
type: mrkdwn
|
||||
text: |
|
||||
*A new Syft release has been published* :rocket:
|
||||
• Release: <https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}|${{ github.event.inputs.version }}>
|
||||
• Repo: `${{ github.repository }}`
|
||||
• Workflow: `${{ github.workflow }}`
|
||||
• Event: `${{ github.event_name }}`
|
||||
if: ${{ success() }}
|
||||
|
||||
release-install-script:
|
||||
needs: [release]
|
||||
if: ${{ needs.release.result == 'success' }}
|
||||
needs: [ release ]
|
||||
if: ${{ always() && (needs.release.result == 'success' || github.event.inputs.phase == 'install-script-only') }}
|
||||
uses: "anchore/workflows/.github/workflows/release-install-script.yaml@main"
|
||||
with:
|
||||
tag: ${{ github.event.inputs.version }}
|
||||
|
||||
@@ -13,13 +13,16 @@ jobs:
|
||||
|
||||
Publish:
|
||||
name: "Publish test fixture image cache"
|
||||
# we use this runner to get enough storage space for docker images and fixture cache
|
||||
runs-on: ubuntu-22.04-4core-16gb
|
||||
# runs-on.com: general purpose instances for building test fixture cache
|
||||
# spot disabled: can run up to an hour, avoid interruptions
|
||||
# s3-cache: faster actions cache
|
||||
# family: note that m*d instances have local nvme storage which is beneficial for building large test fixture images
|
||||
runs-on: "runs-on=${{ github.run_id }}/cpu=8+16/ram=32+64/family=m5d+m5ad+m5dn+m6gd+m6id+m6idn+m7gd+m8gd/spot=false/extras=s3-cache"
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -30,7 +33,8 @@ jobs:
|
||||
download-test-fixture-cache: false
|
||||
|
||||
- name: Run all tests
|
||||
run: make test
|
||||
run: |
|
||||
make test
|
||||
env:
|
||||
# we want to rebuild the cache with no previous state
|
||||
DOWNLOAD_TEST_FIXTURE_CACHE: "false"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'anchore' # only run for main repo (not forks)
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -31,13 +31,13 @@ jobs:
|
||||
with:
|
||||
repos: ${{ github.event.inputs.repos }}
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf #v2.2.1
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private-key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
echo "\`\`\`"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf #v2.2.1
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private-key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
|
||||
@@ -14,28 +14,45 @@ env:
|
||||
jobs:
|
||||
upgrade-cpe-dictionary-index:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
id: bootstrap
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
- name: Login to GitHub Container Registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | .tool/oras login ghcr.io -u "$ACTOR" --password-stdin
|
||||
env:
|
||||
ACTOR: ${{ github.actor }}
|
||||
|
||||
- run: |
|
||||
make generate-cpe-dictionary-index
|
||||
- name: Pull CPE cache from registry
|
||||
run: make generate:cpe-index:cache:pull
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
- name: Update CPE cache from NVD API
|
||||
run: make generate:cpe-index:cache:update
|
||||
env:
|
||||
NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
|
||||
|
||||
- name: Generate CPE dictionary index
|
||||
run: make generate:cpe-index:build
|
||||
|
||||
- name: Push updated CPE cache to registry
|
||||
run: make generate:cpe-index:cache:push
|
||||
|
||||
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf #v2.2.1
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private-key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
@@ -47,11 +64,21 @@ jobs:
|
||||
Update CPE dictionary index based on the latest available CPE dictionary
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e #v3.19.0
|
||||
- name: Notify Slack on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a #v2.1.1
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
fields: workflow,eventName,job
|
||||
text: Syft CPE dictionary index update failed
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
webhook: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
text: "Syft CPE dictionary index update failed"
|
||||
blocks:
|
||||
- type: section
|
||||
text:
|
||||
type: mrkdwn
|
||||
text: |
|
||||
*Syft CPE dictionary index update failed*
|
||||
• Workflow: `${{ github.workflow }}`
|
||||
• Event: `${{ github.event_name }}`
|
||||
• Job Status: `${{ job.status }}`
|
||||
• <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>
|
||||
if: ${{ failure() && env.SLACK_NOTIFICATIONS == 'true' }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: PR to update SPDX license list
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # every monday at 6 AM UTC
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SLACK_NOTIFICATIONS: true
|
||||
|
||||
jobs:
|
||||
upgrade-spdx-license-list:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'anchore/syft' # only run for main repo
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
|
||||
- run: |
|
||||
make generate-license-list
|
||||
|
||||
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.TOKEN_APP_ID }}
|
||||
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
|
||||
with:
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
branch: auto/latest-spdx-license-list
|
||||
labels: dependencies
|
||||
commit-message: "chore(deps): update SPDX license list"
|
||||
title: "chore(deps): update SPDX license list"
|
||||
body: |
|
||||
Update SPDX license list based on the latest available list from spdx.org
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a #v2.1.1
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
text: "Syft SPDX license list update failed"
|
||||
blocks:
|
||||
- type: section
|
||||
text:
|
||||
type: mrkdwn
|
||||
text: |
|
||||
*Syft SPDX license list update failed*
|
||||
• Workflow: `${{ github.workflow }}`
|
||||
• Event: `${{ github.event_name }}`
|
||||
• Job Status: `${{ job.status }}`
|
||||
• <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>
|
||||
if: ${{ failure() && env.SLACK_NOTIFICATIONS == 'true' }}
|
||||
@@ -23,13 +23,14 @@ jobs:
|
||||
contents: read
|
||||
security-events: write # for uploading SARIF results
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run zizmor"
|
||||
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
with:
|
||||
config-file: .github/zizmor.yml
|
||||
sarif-upload: true
|
||||
config: .github/zizmor.yml
|
||||
# Disable SARIF upload so the step is a simple pass/fail gate
|
||||
advanced-security: false
|
||||
inputs: .github
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
name: "Validations"
|
||||
|
||||
# we should cancel any in-progress runs for the same workflow + PR/ref
|
||||
# so that we can avoid redundant work / save on CI minutes
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
@@ -8,33 +14,39 @@ on:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
Static-Analysis:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Static analysis"
|
||||
runs-on: ubuntu-24.04
|
||||
# runs-on.com: memory & general purpose instances for testing
|
||||
# spot enabled: ok to interrupt non-production workloads
|
||||
# s3-cache: faster actions cache
|
||||
# tmpfs: faster io-intensive workflows
|
||||
runs-on: &test-runner "runs-on=${{ github.run_id }}/cpu=4+8/ram=32+128/family=r5+r6+r7+r8+m4+m5+m6+m7+m8/spot=price-capacity-optimized/extras=s3-cache+tmpfs"
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
# the self-consistency tests for the output of the capabilities code generation depends on unit test
|
||||
# output from ./syft/pkg/... packages. Therefore we need to download the test fixture cache here
|
||||
# so that running the few unit tests as part of static analysis works correctly.
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Run static analysis
|
||||
run: make static-analysis
|
||||
|
||||
|
||||
Unit-Test:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Unit tests"
|
||||
# we need more storage than what's on the default runner
|
||||
runs-on: ubuntu-22.04-4core-16gb
|
||||
runs-on: *test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -46,13 +58,15 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: make unit
|
||||
|
||||
- name: Check for capability drift
|
||||
run: make check-capability-drift
|
||||
|
||||
Integration-Test:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Integration tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: *test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,12 +81,19 @@ jobs:
|
||||
- name: Run integration tests
|
||||
run: make integration
|
||||
|
||||
|
||||
Build-Snapshot-Artifacts:
|
||||
name: "Build snapshot artifacts"
|
||||
runs-on: ubuntu-24.04
|
||||
# runs-on.com: compute instances for parallel builds
|
||||
# spot disabled: reliability for build workflows (used for releases too)
|
||||
# goreleaser uses parallelism of 12, so we need more CPUs
|
||||
# s3-cache: faster actions cache
|
||||
# tmpfs: faster io-intensive workflows
|
||||
runs-on: "runs-on=${{ github.run_id }}/cpu=16+32/ram=32+128/family=c5+c6+c7+c8/spot=false/extras=s3-cache+tmpfs"
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
# required for magic-cache from runs-on to function with artifact upload/download (see https://runs-on.com/caching/magic-cache/#actionsupload-artifact-compatibility)
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # v2.0.3
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -87,84 +108,23 @@ jobs:
|
||||
- name: Smoke test snapshot build
|
||||
run: make snapshot-smoke-test
|
||||
|
||||
# why not use actions/upload-artifact? It is very slow (3 minutes to upload ~600MB of data, vs 10 seconds with this approach).
|
||||
# see https://github.com/actions/upload-artifact/issues/199 for more info
|
||||
- name: Upload snapshot artifacts
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0
|
||||
with:
|
||||
# we need to preserve the snapshot data itself as well as the task data that confirms if the
|
||||
# snapshot build is stale or not. Otherwise the downstream jobs will attempt to rebuild the snapshot
|
||||
# even though it already exists.
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
|
||||
|
||||
Upload-Snapshot-Artifacts:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Upload snapshot artifacts"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bootstrap environment
|
||||
uses: ./.github/actions/bootstrap
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
|
||||
- run: npm install @actions/artifact@2.2.2
|
||||
|
||||
- uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { readdirSync } = require('fs')
|
||||
const { DefaultArtifactClient } = require('@actions/artifact')
|
||||
const artifact = new DefaultArtifactClient()
|
||||
const ls = d => readdirSync(d, { withFileTypes: true })
|
||||
const baseDir = "./snapshot"
|
||||
const dirs = ls(baseDir).filter(f => f.isDirectory()).map(f => f.name)
|
||||
const uploads = []
|
||||
for (const dir of dirs) {
|
||||
// uploadArtifact returns Promise<{id, size}>
|
||||
uploads.push(artifact.uploadArtifact(
|
||||
// name of the archive:
|
||||
`${dir}`,
|
||||
// array of all files to include:
|
||||
ls(`${baseDir}/${dir}`).map(f => `${baseDir}/${dir}/${f.name}`),
|
||||
// base directory to trim from entries:
|
||||
`${baseDir}/${dir}`,
|
||||
{ retentionDays: 30 }
|
||||
))
|
||||
}
|
||||
// wait for all uploads to finish
|
||||
Promise.all(uploads)
|
||||
name: snapshot
|
||||
path: snapshot/
|
||||
retention-days: 30
|
||||
|
||||
Acceptance-Linux:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Acceptance tests (Linux)"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: *test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
# required for magic-cache from runs-on to function with artifact upload/download (see https://runs-on.com/caching/magic-cache/#actionsupload-artifact-compatibility)
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # v2.0.3
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -173,20 +133,14 @@ jobs:
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0
|
||||
- name: Download snapshot artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
name: snapshot
|
||||
path: snapshot
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
- name: Restore binary permissions
|
||||
run: chmod +x snapshot/*/syft snapshot/*/*.exe 2>/dev/null || true
|
||||
|
||||
- name: Run comparison tests (Linux)
|
||||
run: make compare-linux
|
||||
@@ -202,17 +156,17 @@ jobs:
|
||||
if: steps.install-test-image-cache.outputs.cache-hit != 'true'
|
||||
run: make install-test-cache-save
|
||||
|
||||
|
||||
Acceptance-Mac:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "Acceptance tests (Mac)"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
# note: macos runners aren't supported yet for runs-on managed runners.
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -223,20 +177,14 @@ jobs:
|
||||
go-dependencies: false
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0
|
||||
- name: Download snapshot artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
name: snapshot
|
||||
path: snapshot
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
- name: Restore binary permissions
|
||||
run: chmod +x snapshot/*/syft 2>/dev/null || true
|
||||
|
||||
- name: Run comparison tests (Mac)
|
||||
run: make compare-mac
|
||||
@@ -244,14 +192,16 @@ jobs:
|
||||
- name: Run install.sh tests (Mac)
|
||||
run: make install-test-ci-mac
|
||||
|
||||
|
||||
Cli-Linux:
|
||||
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
|
||||
name: "CLI tests (Linux)"
|
||||
needs: [Build-Snapshot-Artifacts]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: *test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
# required for magic-cache from runs-on to function with artifact upload/download (see https://runs-on.com/caching/magic-cache/#actionsupload-artifact-compatibility)
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # v2.0.3
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -260,42 +210,14 @@ jobs:
|
||||
with:
|
||||
download-test-fixture-cache: true
|
||||
|
||||
- name: Download snapshot build
|
||||
id: snapshot-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 #v4.3.0
|
||||
- name: Download snapshot artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 #v8.0.0
|
||||
with:
|
||||
path: |
|
||||
snapshot
|
||||
.task
|
||||
fail-on-cache-miss: true
|
||||
key: snapshot-build-${{ github.run_id }}
|
||||
name: snapshot
|
||||
path: snapshot
|
||||
|
||||
# workaround for https://github.com/actions/cache/issues/1265
|
||||
- name: (cache-miss) Snapshot build missing
|
||||
if: steps.snapshot-cache.outputs.cache-hit != 'true'
|
||||
run: echo "unable to download snapshots from previous job" && false
|
||||
- name: Restore binary permissions
|
||||
run: chmod +x snapshot/*/syft snapshot/*/*.exe 2>/dev/null || true
|
||||
|
||||
- name: Run CLI Tests (Linux)
|
||||
run: make cli
|
||||
|
||||
|
||||
Cleanup-Cache:
|
||||
name: "Cleanup snapshot cache"
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: write
|
||||
needs:
|
||||
- Acceptance-Linux
|
||||
- Acceptance-Mac
|
||||
- Cli-Linux
|
||||
- Upload-Snapshot-Artifacts
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Delete snapshot cache
|
||||
run: gh cache delete "snapshot-build-${{ github.run_id }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
ignore:
|
||||
# Allow unpinned uses of trusted internal anchore/workflows actions
|
||||
- update-anchore-dependencies.yml
|
||||
|
||||
config:
|
||||
policies:
|
||||
# anchore/workflows is an internal repository; using @main is acceptable
|
||||
anchore/*: any
|
||||
dangerous-triggers:
|
||||
ignore:
|
||||
# Safe use of pull_request_target - only runs trusted scripts from base repo,
|
||||
|
||||
@@ -19,6 +19,7 @@ bin/
|
||||
/.task
|
||||
/generate
|
||||
/specs
|
||||
mise.toml
|
||||
|
||||
# changelog generation
|
||||
CHANGELOG.md
|
||||
@@ -35,6 +36,8 @@ VERSION
|
||||
/test/results
|
||||
coverage.txt
|
||||
*.log
|
||||
**/test-fixtures/test-observations.json
|
||||
**/testdata/test-observations.json
|
||||
|
||||
# probable archives
|
||||
.images
|
||||
@@ -73,3 +76,5 @@ cosign.pub
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ linters:
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
# we have multiple packages in syft that might overlap with the stblib; their names reflect their purpose
|
||||
- linters:
|
||||
- revive
|
||||
text: "var-naming: avoid package names that conflict"
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
|
||||
+3
-126
@@ -1,128 +1,5 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
All contributors for any Anchore project must follow the [Contributor Covenant Code of Conduct](https://oss.anchore.com/docs/contributing/code-of-conduct/).
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[opensource@anchore.com](mailto:opensource@anchore.com).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
**TLDR:** Be kind, be respectful, and assume good intentions. We're all here to build great software together.
|
||||
|
||||
+7
-148
@@ -1,154 +1,13 @@
|
||||
[#](#) Contributing to Syft
|
||||
# Contributing
|
||||
|
||||
If you are looking to contribute to this project and want to open a GitHub pull request ("PR"), there are a few guidelines of what we are looking for in patches. Make sure you go through this document and ensure that your code proposal is aligned.
|
||||
Thank you for your interest in contributing to Syft!
|
||||
|
||||
## Setting up your environment
|
||||
Please see the [contribution guide](https://oss.anchore.com/docs/contributing/syft/) for development requirements and helpful tips to get started developing in the repo. For a deeper dive, please see the [architecture docs](https://oss.anchore.com/docs/architecture/syft/).
|
||||
|
||||
Before you can contribute to Syft, you need to configure your development environment.
|
||||
**Have a question or need help?** Check out our [issues and discussions guide](https://oss.anchore.com/docs/contributing/issues-and-discussions/) to find the right place to start a conversation.
|
||||
|
||||
### Debian setup
|
||||
**Ready to submit code?** Our [pull request guide](https://oss.anchore.com/docs/contributing/pull-requests/) covers everything from title conventions to the review process. Don't forget that ***all commits require a [sign-off](https://oss.anchore.com/docs/contributing/sign-off/)***.
|
||||
|
||||
You will need to install Go. The version on https://go.dev works best, using the system golang doesn't always work the way you might expect.
|
||||
**Found a security issue?** Please do **not** open a public issue. Instead, see our [security policy](https://oss.anchore.com/docs/contributing/security/) for how to report vulnerabilities responsibly.
|
||||
|
||||
Refer to the go.mod file in the root of this repo for the recommended version of Go to install.
|
||||
|
||||
You will also need Docker. There's no reason the system packages shouldn't work, but we used the official Docker package. You can find instructions for installing Docker in Debian [here](https://docs.docker.com/engine/install/debian/).
|
||||
|
||||
You also need to install some Debian packages
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential zip bc libxml2-utils git
|
||||
```
|
||||
|
||||
## Configuring Git
|
||||
|
||||
You will need to configure your git client with your name and email address. This is easily done from the command line.
|
||||
|
||||
```text
|
||||
$ git config --global user.name "John Doe"
|
||||
$ git config --global user.email "john.doe@example.com"
|
||||
```
|
||||
|
||||
This username and email address will matter later in this guide.
|
||||
|
||||
## Fork the repo
|
||||
|
||||
You should fork the Syft repo using the "Fork" button at the top right of the Syft GitHub [site](https://github.com/anchore/syft/). You will be doing your development in your fork, then submit a pull request to Syft. There are many resources how to use GitHub effectively, we will not cover those here.
|
||||
|
||||
## Adding a feature or fix
|
||||
|
||||
If you look at the Syft [Issue](https://github.com/anchore/syft/issues) there are plenty of bugs and feature requests. Maybe look at the [good first issue](https://github.com/anchore/syft/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) list if you're not sure where to start.
|
||||
|
||||
## Commit guidelines
|
||||
|
||||
In the Syft project we like commits and pull requests (PR) to be easy to understand and review. Open source thrives best when everything happening is over documented and small enough to be understood.
|
||||
|
||||
### Granular commits
|
||||
|
||||
Please try to make every commit as simple as possible, but no simpler. The idea is that each commit should be a logical unit of code. Try not to commit too many tiny changes, for example every line changed in a file as a separate commit. And also try not to make a commit enormous, for example committing all your work at the end of the day.
|
||||
|
||||
Rather than try to follow a strict guide on what is or is not best, we try to be flexible and simple in this space. Do what makes the most sense for the changes you are trying to include.
|
||||
|
||||
### Commit title and description
|
||||
|
||||
Remember that the message you leave for a commit is for the reviewer in the present, and for someone (maybe you) changing something in the future. Please make sure the title and description used is easy to understand and explains what was done. Jokes and clever comments generally don't age well in commit messages. Just the facts please.
|
||||
|
||||
## Sign off your work
|
||||
|
||||
The `sign-off` is an added line at the end of the explanation for the commit, certifying that you wrote it or otherwise have the right to submit it as an open-source patch. By submitting a contribution, you agree to be bound by the terms of the DCO Version 1.1 and Apache License Version 2.0.
|
||||
|
||||
Signing off a commit certifies the below Developer's Certificate of Origin (DCO):
|
||||
|
||||
```text
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
```
|
||||
|
||||
All contributions to this project are licensed under the [Apache License Version 2.0, January 2004](http://www.apache.org/licenses/).
|
||||
|
||||
When committing your change, you can add the required line manually so that it looks like this:
|
||||
|
||||
```text
|
||||
Signed-off-by: John Doe <john.doe@example.com>
|
||||
```
|
||||
|
||||
Creating a signed-off commit is then possible with `-s` or `--signoff`:
|
||||
|
||||
```text
|
||||
$ git commit -s -m "this is a commit message"
|
||||
```
|
||||
|
||||
To double-check that the commit was signed-off, look at the log output:
|
||||
|
||||
```text
|
||||
$ git log -1
|
||||
commit 37ceh170e4hb283bb73d958f2036ee5k07e7fde7 (HEAD -> issue-35, origin/main, main)
|
||||
Author: John Doe <john.doe@example.com>
|
||||
Date: Mon Aug 1 11:27:13 2020 -0400
|
||||
|
||||
this is a commit message
|
||||
|
||||
Signed-off-by: John Doe <john.doe@example.com>
|
||||
```
|
||||
|
||||
## Test your changes
|
||||
|
||||
This project has a `Makefile` which includes many helpers running both unit and integration tests. You can run `make help` to see all the options. Although PRs will have automatic checks for these, it is useful to run them locally, ensuring they pass before submitting changes. Ensure you've bootstrapped once before running tests:
|
||||
|
||||
```text
|
||||
$ make bootstrap
|
||||
```
|
||||
|
||||
You only need to bootstrap once. After the bootstrap process, you can run the tests as many times as needed:
|
||||
|
||||
```text
|
||||
$ make unit
|
||||
$ make integration
|
||||
```
|
||||
|
||||
You can also run `make all` to run a more extensive test suite, but there is additional configuration that will be needed for those tests to run correctly. We will not cover the extra steps here.
|
||||
|
||||
## Pull Request
|
||||
|
||||
If you made it this far and all the tests are passing, it's time to submit a Pull Request (PR) for Syft. Submitting a PR is always a scary moment as what happens next can be an unknown. The Syft project strives to be easy to work with, we appreciate all contributions. Nobody is going to yell at you or try to make you feel bad. We love contributions and know how scary that first PR can be.
|
||||
|
||||
### PR Title and Description
|
||||
|
||||
Just like the commit title and description mentioned above, the PR title and description is very important for letting others know what's happening. Please include any details you think a reviewer will need to more properly review your PR.
|
||||
|
||||
A PR that is very large or poorly described has a higher likelihood of being pushed to the end of the list. Reviewers like PRs they can understand and quickly review.
|
||||
|
||||
### What to expect next
|
||||
|
||||
Please be patient with the project. We try to review PRs in a timely manner, but this is highly dependent on all the other tasks we have going on. It's OK to ask for a status update every week or two, it's not OK to ask for a status update every day.
|
||||
|
||||
It's very likely the reviewer will have questions and suggestions for changes to your PR. If your changes don't match the current style and flow of the other code, expect a request to change what you've done.
|
||||
|
||||
## Document your changes
|
||||
|
||||
And lastly, when proposed changes are modifying user-facing functionality or output, it is expected the PR will include updates to the documentation as well. Syft is not a project that is heavy on documentation. This will mostly be updating the README and help for the tool.
|
||||
|
||||
If nobody knows new features exist, they can't use them!
|
||||
**Want to help improve the docs?** Check out the [anchore/oss-docs](https://github.com/anchore/oss-docs) repository.
|
||||
|
||||
-392
@@ -1,392 +0,0 @@
|
||||
# Developing
|
||||
|
||||
## Getting started
|
||||
|
||||
In order to test and develop in this repo you will need the following dependencies installed:
|
||||
- Golang
|
||||
- docker
|
||||
- make
|
||||
- Python (>= 3.9)
|
||||
|
||||
### Docker settings for getting started
|
||||
Make sure you've updated your docker settings so the default docker socket path is available.
|
||||
|
||||
Go to:
|
||||
|
||||
docker -> settings -> advanced
|
||||
|
||||
Make sure:
|
||||
|
||||
```
|
||||
Allow the default Docker socket to be used
|
||||
```
|
||||
|
||||
is checked.
|
||||
|
||||
Also double check that the docker context being used is the default context. If it is not, run:
|
||||
|
||||
`docker context use default`
|
||||
|
||||
After cloning, the following steps can help you get setup:
|
||||
1. run `make bootstrap` to download go mod dependencies, create the `/.tmp` dir, and download helper utilities.
|
||||
2. run `make` to view the selection of developer commands in the Makefile
|
||||
3. run `make build` to build the release snapshot binaries and packages
|
||||
4. for an even quicker start you can run `go run cmd/syft/main.go` to print the syft help.
|
||||
- this command `go run cmd/syft/main.go alpine:latest` will compile and run syft against `alpine:latest`
|
||||
5. view the README or syft help output for more output options
|
||||
|
||||
The main make tasks for common static analysis and testing are `lint`, `format`, `lint-fix`, `unit`, `integration`, and `cli`.
|
||||
|
||||
See `make help` for all the current make tasks.
|
||||
|
||||
### Internal Artifactory Settings
|
||||
|
||||
**Not always applicable**
|
||||
|
||||
Some companies have Artifactory setup internally as a solution for sourcing secure dependencies.
|
||||
If you're seeing an issue where the unit tests won't run because of the below error then this section might be relevant for your use case.
|
||||
|
||||
```
|
||||
[ERROR] [ERROR] Some problems were encountered while processing the POMs
|
||||
```
|
||||
|
||||
If you're dealing with an issue where the unit tests will not pull/build certain java fixtures check some of these settings:
|
||||
|
||||
- a `settings.xml` file should be available to help you communicate with your internal artifactory deployment
|
||||
- this can be moved to `syft/pkg/cataloger/java/test-fixtures/java-builds/example-jenkins-plugin/` to help build the unit test-fixtures
|
||||
- you'll also want to modify the `build-example-jenkins-plugin.sh` to use `settings.xml`
|
||||
|
||||
For more information on this setup and troubleshooting see [issue 1895](https://github.com/anchore/syft/issues/1895#issuecomment-1610085319)
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
At a high level, this is the package structure of syft:
|
||||
```
|
||||
./cmd/syft/
|
||||
│ ├── cli/
|
||||
│ │ ├── cli.go // where all commands are wired up
|
||||
│ │ ├── commands/ // all command implementations
|
||||
│ │ ├── options/ // all command flags and configuration options
|
||||
│ │ └── ui/ // all handlers for events that are shown on the UI
|
||||
│ └── main.go // entrypoint for the application
|
||||
└── syft/ // the "core" syft library
|
||||
├── format/ // contains code to encode or decode to and from SBOM formats
|
||||
├── pkg/ // contains code to catalog packages from a source
|
||||
├── sbom/ // contains the definition of an SBOM
|
||||
└── source/ // contains code to create a source object for some input type (e.g. container image, directory, etc)
|
||||
```
|
||||
|
||||
Syft's core library is implemented in the `syft` package and subpackages, where the major packages are:
|
||||
|
||||
- the `syft/source` package produces a `source.Source` object that can be used to catalog a directory, container, and other source types.
|
||||
- the `syft` package contains a single function that can take a `source.Source` object and catalog it, producing an `sbom.SBOM` object
|
||||
- the `syft/format` package contains the ability to encode and decode SBOMs to and from different SBOM formats (such as SPDX and CycloneDX)
|
||||
|
||||
The `cmd` package at the highest level execution flow wires up [`spf13/cobra`](https://github.com/spf13/cobra) commands for execution in the main application:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant main as cmd/syft/main
|
||||
participant cli as cli.New()
|
||||
participant root as root.Execute()
|
||||
participant cmd as <command>.Execute()
|
||||
|
||||
main->>+cli:
|
||||
|
||||
Note right of cli: wire ALL CLI commands
|
||||
Note right of cli: add flags for ALL commands
|
||||
|
||||
cli-->>-main: root command
|
||||
|
||||
main->>+root:
|
||||
root->>+cmd:
|
||||
cmd-->>-root: (error)
|
||||
|
||||
root-->>-main: (error)
|
||||
|
||||
Note right of cmd: Execute SINGLE command from USER
|
||||
```
|
||||
|
||||
The `packages` command uses the core library to generate an SBOM for the given user input:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant source as source.New(ubuntu:latest)
|
||||
participant sbom as sbom.SBOM
|
||||
participant catalog as syft.CatalogPackages(src)
|
||||
participant encoder as syft.Encode(sbom, format)
|
||||
|
||||
Note right of source: use "ubuntu:latest" as SBOM input
|
||||
|
||||
source-->>+sbom: add source to SBOM struct
|
||||
source-->>+catalog: pass src to generate catalog
|
||||
catalog-->-sbom: add cataloging results onto SBOM
|
||||
sbom-->>encoder: pass SBOM and format desired to syft encoder
|
||||
encoder-->>source: return bytes that are the SBOM of the original input
|
||||
|
||||
Note right of catalog: cataloger configuration is done based on src
|
||||
```
|
||||
|
||||
Additionally, here is a [gist of using syft as a library](https://gist.github.com/spiffcs/3027638b7ba904d07e482a712bc00d3d) to generate a SBOM for a docker image.
|
||||
|
||||
|
||||
### `pkg.Package` object
|
||||
|
||||
The `pkg.Package` object is a core data structure that represents a software package. Fields like `name` and `version` probably don't need
|
||||
a detailed explanation, but some of the other fields are worth a quick overview:
|
||||
|
||||
- `FoundBy`: the name of the cataloger that discovered this package (e.g. `python-pip-cataloger`).
|
||||
- `Locations`: these are the set of paths and layer ids that were parsed to discover this package (e.g. `python-pip-cataloger`).
|
||||
- `Language`: the language of the package (e.g. `python`).
|
||||
- `Type`: this is a high-level categorization of the ecosystem the package resides in. For instance, even if the package is a egg, wheel, or requirements.txt reference, it is still logically a "python" package. Not all package types align with a language (e.g. `rpm`) but it is common.
|
||||
- `Metadata`: specialized data for specific location(s) parsed. We should try and raise up as much raw information that seems useful. As a rule of thumb the object here should be as flat as possible and use the raw names and values from the underlying source material parsed.
|
||||
|
||||
When `pkg.Package` is serialized an additional `MetadataType` is shown. This is a label that helps consumers understand the datashape of the `Metadata` field.
|
||||
|
||||
By convention the `MetadataType` value should follow these rules of thumb:
|
||||
|
||||
- Only use lowercase letters, numbers, and hyphens. Use hyphens to separate words.
|
||||
- **Try to anchor the name in the ecosystem, language, or packaging tooling it belongs to**. For a package manager for a language ecosystem the language, framework or runtime should be used as a prefix. For instance `pubspec-lock` is an OK name, but `dart-pubspec-lock` is better. For an OS package manager this is not necessary (e.g. `apk-db-entry` is a good name, but `alpine-apk-db-entry` is not since `alpine` and the `a` in `apk` is redundant).
|
||||
- **Be as specific as possible to what the data represents**. For instance `ruby-gem` is NOT a good `MetadataType` value, but `ruby-gemspec` is. Why? Ruby gem information can come from a gemspec file or a Gemfile.lock, which are very different. The latter name provides more context as to what to expect.
|
||||
- **Should describe WHAT the data is, NOT HOW it's used**. For instance `r-description-installed-file` is NOT a good `MetadataType` value since it's trying to convey that we use the DESCRIPTION file in the R ecosystem to detect installed packages. Instead simply describe what the DESCRIPTION file is itself without context of how it's used: `r-description`.
|
||||
- **Use the `lock` suffix** to distinct between manifest files that loosely describe package version requirements vs files that strongly specify one and only one version of a package ("lock" files). These should only be used with respect to package managers that have the guide and lock distinction, but would not be appropriate otherwise (e.g. `rpm` does not have a guide vs lock, so `lock` should NOT be used to describe a db entry).
|
||||
- **Use the `archive` suffix to indicate a package archive** (e.g. rpm file, apk file, etc) that describes the contents of the package. For example an RPM file that was cataloged would have a `rpm-archive` metadata type (not to be confused with an RPM DB record entry which would be `rpm-db-entry`).
|
||||
- **Use the `entry` suffix** to indicate information about a package that was found as a single entry within file that has multiple package entries. If the entry was found within a DB or a flat-file store for an OS package manager, you should use `db-entry`.
|
||||
- **Should NOT contain the phrase `package`**, though exceptions are allowed (say if the canonical name literally has the phrase package in it).
|
||||
- **Should NOT contain have a `file` suffix** unless the canonical name has the term "file", such as a `pipfile` or `gemfile`. An example of a bad name for this rule is`ruby-gemspec-file`; a better name would be `ruby-gemspec`.
|
||||
- **Should NOT contain the exact filename+extensions**. For instance `pipfile.lock` shouldn't really be in the name, instead try and describe what the file is: `python-pipfile-lock` (but shouldn't this be `python-pip-lock` you might ask? No, since the `pip` package manger is not related to the `pipfile` project).
|
||||
- **Should NOT contain the phrase `metadata`**, unless the canonical name has this term.
|
||||
- **Should represent a single use case**. For example, trying to describe Hackage metadata with a single `HackageMetadata` struct (and thus `MetadataType`) is not allowed since it represents 3 mutually exclusive use cases: representing a `stack.yaml`, `stack.lock`, or `cabal.project` file. Instead, each of these should have their own struct types and `MetadataType` values.
|
||||
|
||||
There are other cases that are not covered by these rules... and that's ok! The goal is to provide a consistent naming scheme that is easy to understand and use when it's applicable. If the rules do not exactly apply in your situation then just use your best judgement (or amend these rules as needed whe new common cases come up).
|
||||
|
||||
What if the underlying parsed data represents multiple files? There are two approaches to this:
|
||||
- use the primary file to represent all the data. For instance, though the `dpkg-cataloger` looks at multiple files to get all information about a package, it's the `status` file that gets represented.
|
||||
- nest each individual file's data under the `Metadata` field. For instance, the `java-archive-cataloger` may find information from on or all of the files: `pom.xml`, `pom.properties`, and `MANIFEST.MF`. However, the metadata is simply `java-metadata' with each possibility as a nested optional field.
|
||||
|
||||
### Syft Catalogers
|
||||
|
||||
Catalogers are the way in which syft is able to identify and construct packages given a set a targeted list of files.
|
||||
For example, a cataloger can ask syft for all `package-lock.json` files in order to parse and raise up javascript packages
|
||||
(see [how file globs](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/javascript/cataloger.go#L16-L21) and
|
||||
[file parser functions](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/javascript/cataloger.go#L16-L21) are used
|
||||
for a quick example).
|
||||
|
||||
From a high level catalogers have the following properties:
|
||||
|
||||
- _They are independent from one another_. The java cataloger has no idea of the processes, assumptions, or results of the python cataloger, for example.
|
||||
|
||||
- _They do not know what source is being analyzed_. Are we analyzing a local directory? an image? if so, the squashed representation or all layers? The catalogers do not know the answers to these questions. Only that there is an interface to query for file paths and contents from an underlying "source" being scanned.
|
||||
|
||||
- _Packages created by the cataloger should not be mutated after they are created_. There is one exception made for adding CPEs to a package after the cataloging phase, but that will most likely be moved back into the cataloger in the future.
|
||||
|
||||
|
||||
Cataloger names should be unique and named with the following rules of thumb in mind:
|
||||
|
||||
- Must end with `-cataloger`
|
||||
- Use lowercase letters, numbers, and hyphens only
|
||||
- Use hyphens to separate words
|
||||
- Catalogers for language ecosystems should start with the language name (e.g. `python-` for a cataloger that raises up python packages)
|
||||
- Distinguish between when the cataloger is searching for evidence of installed packages vs declared packages. For example, there are currently two different gemspec-based catalogers, the `ruby-gemspec-cataloger` and `ruby-installed-gemspec-cataloger`, where the latter requires that the gemspec is found within a `specifications` directory (which means it was installed, not just at the root of a source repo).
|
||||
|
||||
#### Building a new Cataloger
|
||||
|
||||
Catalogers must fulfill the [`pkg.Cataloger` interface](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger.go) in order to add packages to the SBOM.
|
||||
All catalogers should be added to:
|
||||
- the [global list of catalogers](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/cataloger/cataloger.go#L92-L125)
|
||||
- at least one source-specific list, today the two lists are [directory catalogers and image catalogers](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/cataloger/cataloger.go#L39-L89)
|
||||
|
||||
For reference, catalogers are [invoked within syft](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/catalog.go#L41-L100) one after the other, and can be invoked in parallel.
|
||||
|
||||
`generic.NewCataloger` is an abstraction syft used to make writing common components easier (see the [apkdb cataloger](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/cataloger.go) for example usage).
|
||||
It takes the following information as input:
|
||||
- A `catalogerName` to identify the cataloger uniquely among all other catalogers.
|
||||
- Pairs of file globs as well as parser functions to parse those files. These parser functions return a slice of [`pkg.Package`](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/package.go#L19) as well as a slice of [`artifact.Relationship`](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/artifact/relationship.go#L31) to describe how the returned packages are related. See this [the apkdb cataloger parser function](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/parse_apk_db.go#L22-L102) as an example.
|
||||
|
||||
Identified packages share a common `pkg.Package` struct so be sure that when the new cataloger is constructing a new package it is using the [`Package` struct](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/package.go#L16-L31).
|
||||
If you want to return more information than what is available on the `pkg.Package` struct then you can do so in the `pkg.Package.Metadata` section of the struct, which is unique for each [`pkg.Type`](https://github.com/anchore/syft/blob/v0.70.0/syft/pkg/type.go).
|
||||
See [the `pkg` package](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg) for examples of the different metadata types that are supported today.
|
||||
These are plugged into the `MetadataType` and `Metadata` fields in the above struct. `MetadataType` informs which type is being used. `Metadata` is an interface converted to that type.
|
||||
|
||||
Finally, here is an example of where the package construction is done within the apk cataloger:
|
||||
- [Calling the APK package constructor from the parser function](https://github.com/anchore/syft/blob/v0.70.0/syft/pkg/cataloger/apkdb/parse_apk_db.go#L106)
|
||||
- [The APK package constructor itself](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/package.go#L12-L27)
|
||||
|
||||
Interested in building a new cataloger? Checkout the [list of issues with the `new-cataloger` label](https://github.com/anchore/syft/issues?q=is%3Aopen+is%3Aissue+label%3Anew-cataloger+no%3Aassignee)!
|
||||
If you have questions about implementing a cataloger feel free to file an issue or reach out to us [on discourse](https://anchore.com/discourse)!
|
||||
|
||||
|
||||
#### Searching for files
|
||||
|
||||
All catalogers are provided an instance of the [`file.Resolver`](https://github.com/anchore/syft/blob/v0.70.0/syft/source/file_resolver.go#L8) to interface with the image and search for files. The implementations for these
|
||||
abstractions leverage [`stereoscope`](https://github.com/anchore/stereoscope) in order to perform searching. Here is a
|
||||
rough outline how that works:
|
||||
|
||||
1. a stereoscope `file.Index` is searched based on the input given (a path, glob, or MIME type). The index is relatively fast to search, but requires results to be filtered down to the files that exist in the specific layer(s) of interest. This is done automatically by the `filetree.Searcher` abstraction. This abstraction will fallback to searching directly against the raw `filetree.FileTree` if the index does not contain the file(s) of interest. Note: the `filetree.Searcher` is used by the `file.Resolver` abstraction.
|
||||
2. Once the set of files are returned from the `filetree.Searcher` the results are filtered down further to return the most unique file results. For example, you may have requested for files by a glob that returns multiple results. These results are filtered down to deduplicate by real files, so if a result contains two references to the same file, say one accessed via symlink and one accessed via the real path, then the real path reference is returned and the symlink reference is filtered out. If both were accessed by symlink then the first (by lexical order) is returned. This is done automatically by the `file.Resolver` abstraction.
|
||||
3. By the time results reach the `pkg.Cataloger` you are guaranteed to have a set of unique files that exist in the layer(s) of interest (relative to what the resolver supports).
|
||||
|
||||
## Testing
|
||||
|
||||
### Testing commands
|
||||
|
||||
* `make help` shows a list of available commands
|
||||
* `make unit`, `make integration`, `make cli`, and `make acceptance` run those test suites (see below)
|
||||
* `make test` runs all those tests (and is therefore pretty slow)
|
||||
* `make fixtures` clears and re-fetches all test fixtures.
|
||||
* `go test ./syft/pkg/` for example can test particular packages, assuming fixtures are already made
|
||||
* `make clean-cache` cleans all test cache. Note that subsequent test runs will be slower after this
|
||||
|
||||
|
||||
### Levels of testing
|
||||
|
||||
- `unit`: The default level of test which is distributed throughout the repo are unit tests. Any `_test.go` file that
|
||||
does not reside somewhere within the `/test` directory is a unit test. Other forms of testing should be organized in
|
||||
the `/test` directory. These tests should focus on correctness of functionality in depth. % test coverage metrics
|
||||
only considers unit tests and no other forms of testing.
|
||||
|
||||
- `integration`: located within `cmd/syft/internal/test/integration`, these tests focus on the behavior surfaced by the common library
|
||||
entrypoints from the `syft` package and make light assertions about the results surfaced. Additionally, these tests
|
||||
tend to make diversity assertions for enum-like objects, ensuring that as enum values are added to a definition
|
||||
that integration tests will automatically fail if no test attempts to use that enum value. For more details see
|
||||
the "Data diversity and freshness assertions" section below.
|
||||
|
||||
- `cli`: located with in `test/cli`, these are tests that test the correctness of application behavior from a
|
||||
snapshot build. This should be used in cases where a unit or integration test will not do or if you are looking
|
||||
for in-depth testing of code in the `cmd/` package (such as testing the proper behavior of application configuration,
|
||||
CLI switches, and glue code before syft library calls).
|
||||
|
||||
- `acceptance`: located within `test/compare` and `test/install`, these are smoke-like tests that ensure that application
|
||||
packaging and installation works as expected. For example, during release we provide RPM packages as a download
|
||||
artifact. We also have an accompanying RPM acceptance test that installs the RPM from a snapshot build and ensures the
|
||||
output of a syft invocation matches canned expected output. New acceptance tests should be added for each release artifact
|
||||
and architecture supported (when possible).
|
||||
|
||||
### Data diversity and freshness assertions
|
||||
|
||||
It is important that tests against the codebase are flexible enough to begin failing when they do not cover "enough"
|
||||
of the objects under test. "Cover" in this case does not mean that some percentage of the code has been executed
|
||||
during testing, but instead that there is enough diversity of data input reflected in testing relative to the
|
||||
definitions available.
|
||||
|
||||
For instance, consider an enum-like value like so:
|
||||
```go
|
||||
type Language string
|
||||
|
||||
const (
|
||||
Java Language = "java"
|
||||
JavaScript Language = "javascript"
|
||||
Python Language = "python"
|
||||
Ruby Language = "ruby"
|
||||
Go Language = "go"
|
||||
)
|
||||
```
|
||||
|
||||
Say we have a test that exercises all the languages defined today:
|
||||
|
||||
```go
|
||||
func TestCatalogPackages(t *testing.T) {
|
||||
testTable := []struct {
|
||||
// ... the set of test cases that test all languages
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func (t *testing.T) {
|
||||
// use inputFixturePath and assert that syft.CatalogPackages() returns the set of expected Package objects
|
||||
// ...
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where each test case has a `inputFixturePath` that would result with packages from each language. This test is
|
||||
brittle since it does not assert that all languages were exercised directly and future modifications (such as
|
||||
adding a new language) won't be covered by any test cases.
|
||||
|
||||
To address this the enum-like object should have a definition of all objects that can be used in testing:
|
||||
|
||||
```go
|
||||
type Language string
|
||||
|
||||
// const( Java Language = ..., ... )
|
||||
|
||||
var AllLanguages = []Language{
|
||||
Java,
|
||||
JavaScript,
|
||||
Python,
|
||||
Ruby,
|
||||
Go,
|
||||
Rust,
|
||||
}
|
||||
```
|
||||
|
||||
Allowing testing to automatically fail when adding a new language:
|
||||
|
||||
```go
|
||||
func TestCatalogPackages(t *testing.T) {
|
||||
testTable := []struct {
|
||||
// ... the set of test cases that (hopefully) covers all languages
|
||||
}
|
||||
|
||||
// new stuff...
|
||||
observedLanguages := strset.New()
|
||||
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func (t *testing.T) {
|
||||
// use inputFixturePath and assert that syft.CatalogPackages() returns the set of expected Package objects
|
||||
// ...
|
||||
|
||||
// new stuff...
|
||||
for _, actualPkg := range actual {
|
||||
observedLanguages.Add(string(actualPkg.Language))
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// new stuff...
|
||||
for _, expectedLanguage := range pkg.AllLanguages {
|
||||
if !observedLanguages.Contains(expectedLanguage) {
|
||||
t.Errorf("failed to test language=%q", expectedLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a better test since it will fail when someone adds a new language but fails to write a test case that should
|
||||
exercise that new language. This method is ideal for integration-level testing, where testing correctness in depth
|
||||
is not needed (that is what unit tests are for) but instead testing in breadth to ensure that units are well integrated.
|
||||
|
||||
A similar case can be made for data freshness; if the quality of the results will be diminished if the input data
|
||||
is not kept up to date then a test should be written (when possible) to assert any input data is not stale.
|
||||
|
||||
An example of this is the static list of licenses that is stored in `internal/spdxlicense` for use by the SPDX
|
||||
presenters. This list is updated and published periodically by an external group and syft can grab and update this
|
||||
list by running `go generate ./...` from the root of the repo.
|
||||
|
||||
An integration test has been written to grabs the latest license list version externally and compares that version
|
||||
with the version generated in the codebase. If they differ, the test fails, indicating to someone that there is an
|
||||
action needed to update it.
|
||||
|
||||
**_The key takeaway is to try and write tests that fail when data assumptions change and not just when code changes.**_
|
||||
|
||||
### Snapshot tests
|
||||
|
||||
The format objects make a lot of use of "snapshot" testing, where you save the expected output bytes from a call into the
|
||||
git repository and during testing make a comparison of the actual bytes from the subject under test with the golden
|
||||
copy saved in the repo. The "golden" files are stored in the `test-fixtures/snapshot` directory relative to the go
|
||||
package under test and should always be updated by invoking `go test` on the specific test file with a specific CLI
|
||||
update flag provided.
|
||||
|
||||
Many of the `Format` tests make use of this approach, where the raw SBOM report is saved in the repo and the test
|
||||
compares that SBOM with what is generated from the latest presenter code. The following command can be used to
|
||||
update the golden files for the various snapshot tests:
|
||||
|
||||
```bash
|
||||
make update-format-golden-files
|
||||
```
|
||||
|
||||
These flags are defined at the top of the test files that have tests that use the snapshot files.
|
||||
|
||||
Snapshot testing is only as good as the manual verification of the golden snapshot file saved to the repo! Be careful
|
||||
and diligent when updating these files.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ make-default: $(TASK)
|
||||
@$(TASK)
|
||||
|
||||
# for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool
|
||||
TASKS := $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','")
|
||||
TASKS := $(shell bash -c "test -f $(TASK) && NO_COLOR=1 $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && NO_COLOR=1 $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','")
|
||||
|
||||
.PHONY: $(TASKS)
|
||||
$(TASKS): $(TASK)
|
||||
|
||||
@@ -18,171 +18,74 @@
|
||||
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
Syft is a powerful and easy-to-use open-source tool for generating Software Bill of Materials (SBOMs) for container images and filesystems. It provides detailed visibility into the packages and dependencies in your software, helping you manage vulnerabilities, license compliance, and software supply chain security.
|
||||
|
||||
Syft development is sponsored by [Anchore](https://anchore.com/), and is released under the [Apache-2.0 License](https://github.com/anchore/syft?tab=Apache-2.0-1-ov-file). For commercial support options with Syft or Grype, please [contact Anchore](https://get.anchore.com/contact/).
|
||||
|
||||
## Features
|
||||
- Generates SBOMs for container images, filesystems, archives, and more to discover packages and libraries
|
||||
- Supports OCI, Docker and [Singularity](https://github.com/sylabs/singularity) image formats
|
||||
- Linux distribution identification
|
||||
- Works seamlessly with [Grype](https://github.com/anchore/grype) (a fast, modern vulnerability scanner)
|
||||
- Able to create signed SBOM attestations using the [in-toto specification](https://github.com/in-toto/attestation/blob/main/spec/README.md)
|
||||
- Convert between SBOM formats, such as CycloneDX, SPDX, and Syft's own format.
|
||||
|
||||
- Generates SBOMs for **container images**, **filesystems**, **archives** (see the docs for a full list of [supported scan targets](https://oss.anchore.com/docs/guides/sbom/scan-targets/))
|
||||
- Supports dozens of packaging ecosystems (e.g. Alpine (apk), Debian (dpkg), RPM, Go, Python, Java, JavaScript, Ruby, Rust, PHP, .NET, and [many more](https://oss.anchore.com/docs/capabilities/all-packages/))
|
||||
- Supports OCI, Docker, [Singularity](https://github.com/sylabs/singularity), and [more image formats](https://oss.anchore.com/docs/guides/sbom/scan-targets/)
|
||||
- Works seamlessly with [Grype](https://github.com/anchore/grype) for vulnerability scanning
|
||||
- Multiple output formats (**CycloneDX**, **SPDX**, **Syft JSON**, and [more](https://oss.anchore.com/docs/guides/sbom/formats/)) including the ability to [convert between SBOM formats](https://oss.anchore.com/docs/guides/sbom/conversion/)
|
||||
- Create signed SBOM attestations using the [in-toto specification](https://github.com/in-toto/attestation/blob/main/spec/README.md)
|
||||
|
||||
> [!TIP]
|
||||
> **New to Syft? Check out the [Getting Started guide](https://oss.anchore.com/docs/guides/sbom/getting-started/) for a walkthrough!**
|
||||
|
||||
## Installation
|
||||
|
||||
Syft binaries are provided for Linux, macOS and Windows.
|
||||
|
||||
### Recommended
|
||||
> ```bash
|
||||
> curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
|
||||
> ```
|
||||
|
||||
Install script options:
|
||||
- `-b`: Specify a custom installation directory (defaults to `./bin`)
|
||||
- `-d`: More verbose logging levels (`-d` for debug, `-dd` for trace)
|
||||
- `-v`: Verify the signature of the downloaded artifact before installation (requires [`cosign`](https://github.com/sigstore/cosign) to be installed)
|
||||
|
||||
### Homebrew
|
||||
The quickest way to get up and going:
|
||||
```bash
|
||||
brew install syft
|
||||
curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
|
||||
```
|
||||
|
||||
### Scoop
|
||||
> [!TIP]
|
||||
> **See [Installation docs](https://oss.anchore.com/docs/installation/syft/) for more ways to get Syft, including Homebrew, Docker, Scoop, Chocolatey, Nix, and more!**
|
||||
|
||||
```powershell
|
||||
scoop install syft
|
||||
```
|
||||
## The basics
|
||||
|
||||
### Chocolatey
|
||||
|
||||
The chocolatey distribution of Syft is community-maintained and not distributed by the Anchore team
|
||||
|
||||
```powershell
|
||||
choco install syft -y
|
||||
```
|
||||
|
||||
### Nix
|
||||
|
||||
**Note**: Nix packaging of Syft is [community maintained](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/sy/syft/package.nix). Syft is available in the [stable channel](https://wiki.nixos.org/wiki/Nix_channels#The_official_channels) since NixOS `22.05`.
|
||||
See the packages within a container image or directory:
|
||||
|
||||
```bash
|
||||
nix-env -i syft
|
||||
# container image
|
||||
syft alpine:latest
|
||||
|
||||
# directory
|
||||
syft ./my-project
|
||||
```
|
||||
|
||||
... or, just try it out in an ephemeral nix shell:
|
||||
To get an SBOM, specify one or more output formats:
|
||||
|
||||
```bash
|
||||
nix-shell -p syft
|
||||
# SBOM to stdout
|
||||
syft <image> -o cyclonedx-json
|
||||
|
||||
# Multiple SBOMs to files
|
||||
syft <image> -o spdx-json=./spdx.json -o cyclonedx-json=./cdx.json
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
### SBOM
|
||||
> [!TIP]
|
||||
> **Check out the [Getting Started guide](https://oss.anchore.com/docs/guides/sbom/getting-started/)** to explore all of the capabilities and features.
|
||||
>
|
||||
> **Want to know all of the ins-and-outs of Syft?** Check out the [CLI docs](https://oss.anchore.com/docs/reference/syft/cli/), [configuration docs](https://oss.anchore.com/docs/reference/syft/configuration/), and [JSON schema](https://oss.anchore.com/docs/reference/syft/json/latest/).
|
||||
|
||||
To generate an SBOM for a container image:
|
||||
|
||||
```bash
|
||||
syft <image>
|
||||
```
|
||||
|
||||
The above output includes only software that is visible in the container (i.e., the squashed representation of the image). To include software from all image layers in the SBOM, regardless of its presence in the final image, provide `--scope all-layers`:
|
||||
|
||||
```bash
|
||||
syft <image> --scope all-layers
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
The output format for Syft is configurable as well using the `-o` (or `--output`) option:
|
||||
|
||||
```
|
||||
syft <image> -o <format>
|
||||
```
|
||||
|
||||
Where the `formats` available are:
|
||||
- `syft-json`: Use this to get as much information out of Syft as possible!
|
||||
- `syft-text`: A row-oriented, human-and-machine-friendly output.
|
||||
- `cyclonedx-xml`: A XML report conforming to the [CycloneDX 1.6 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-xml@1.5`: A XML report conforming to the [CycloneDX 1.5 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-json`: A JSON report conforming to the [CycloneDX 1.6 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `cyclonedx-json@1.5`: A JSON report conforming to the [CycloneDX 1.5 specification](https://cyclonedx.org/specification/overview/).
|
||||
- `spdx-tag-value`: A tag-value formatted report conforming to the [SPDX 2.3 specification](https://spdx.github.io/spdx-spec/v2.3/).
|
||||
- `spdx-tag-value@2.2`: A tag-value formatted report conforming to the [SPDX 2.2 specification](https://spdx.github.io/spdx-spec/v2.2.2/).
|
||||
- `spdx-json`: A JSON report conforming to the [SPDX 2.3 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.3/schemas/spdx-schema.json).
|
||||
- `spdx-json@2.2`: A JSON report conforming to the [SPDX 2.2 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.2/schemas/spdx-schema.json).
|
||||
- `github-json`: A JSON report conforming to GitHub's dependency snapshot format.
|
||||
- `syft-table`: A columnar summary (default).
|
||||
- `template`: Lets the user specify the output format. See ["Using templates"](#using-templates) below.
|
||||
|
||||
Note that flags using the @<version> can be used for earlier versions of each specification as well.
|
||||
|
||||
### Supported Ecosystems
|
||||
|
||||
- Alpine (apk)
|
||||
- Bitnami packages
|
||||
- C (conan)
|
||||
- C++ (conan)
|
||||
- Dart (pubs)
|
||||
- Debian (dpkg)
|
||||
- Dotnet (deps.json)
|
||||
- Objective-C (cocoapods)
|
||||
- Elixir (mix)
|
||||
- Erlang (rebar3)
|
||||
- Go (go.mod, Go binaries)
|
||||
- GitHub (workflows, actions)
|
||||
- Haskell (cabal, stack)
|
||||
- Java (jar, ear, war, par, sar, nar, native-image)
|
||||
- JavaScript (npm, yarn)
|
||||
- Jenkins Plugins (jpi, hpi)
|
||||
- Linux kernel archives (vmlinz)
|
||||
- Linux kernel modules (ko)
|
||||
- Nix (outputs in /nix/store)
|
||||
- PHP (composer, PECL, Pear)
|
||||
- Python (wheel, egg, poetry, requirements.txt, uv)
|
||||
- Red Hat (rpm)
|
||||
- Ruby (gem)
|
||||
- Rust (cargo.lock, auditable binary)
|
||||
- Swift (cocoapods, swift-package-manager)
|
||||
- Wordpress plugins
|
||||
- Terraform providers (.terraform.lock.hcl)
|
||||
|
||||
## Documentation
|
||||
|
||||
Our [wiki](https://github.com/anchore/syft/wiki) contains further details on the following topics:
|
||||
|
||||
* [Supported Sources](https://github.com/anchore/syft/wiki/supported-sources)
|
||||
* [File Selection](https://github.com/anchore/syft/wiki/file-selection)
|
||||
* [Excluding file paths](https://github.com/anchore/syft/wiki/excluding-file-paths)
|
||||
* [Output formats](https://github.com/anchore/syft/wiki/output-formats)
|
||||
* [Package Cataloger Selection](https://github.com/anchore/syft/wiki/package-cataloger-selection)
|
||||
* [Concepts](https://github.com/anchore/syft/wiki/package-cataloger-selection#concepts)
|
||||
* [Examples](https://github.com/anchore/syft/wiki/package-cataloger-selection#examples)
|
||||
* [Using templates](https://github.com/anchore/syft/wiki/using-templates)
|
||||
* [Multiple outputs](https://github.com/anchore/syft/wiki/multiple-outputs)
|
||||
* [Private Registry Authentication](https://github.com/anchore/syft/wiki/private-registry-authentication)
|
||||
* [Local Docker Credentials](https://github.com/anchore/syft/wiki/private-registry-authentication#local-docker)
|
||||
* [Docker Credentials in Kubernetes](https://github.com/anchore/syft/wiki/private-registry-authentication#docker-credentials-in-kubernetes)
|
||||
* [Attestation (experimental)](https://github.com/anchore/syft/wiki/attestation)
|
||||
* [Keyless Support](https://github.com/anchore/syft/wiki/attestation#keyless-support)
|
||||
* [Local private key support](https://github.com/anchore/syft/wiki/attestation#local-private-key-support)
|
||||
* [Adding an SBOM to an image as an attestation using Syft](https://github.com/anchore/syft/wiki/attestation#adding-an-sbom-to-an-image-as-an-attestation-using-syft)
|
||||
* [Configuration](https://github.com/anchore/syft/wiki/configuration)
|
||||
|
||||
## Contributing
|
||||
|
||||
Check out our [contributing](/CONTRIBUTING.md) guide and [developer](/DEVELOPING.md) docs.
|
||||
We encourage users to help make these tools better by [submitting issues](https://github.com/anchore/syft/issues) when you find a bug or want a new feature.
|
||||
Check out our [contributing overview](https://oss.anchore.com/docs/contributing/) and [developer-specific documentation](https://oss.anchore.com/docs/contributing/syft/) if you are interested in providing code contributions.
|
||||
|
||||
## Syft Team Meetings
|
||||
|
||||
The Syft Team hold regular community meetings online. All are welcome to join to bring topics for discussion.
|
||||
|
||||
<p xmlns:cc="http://creativecommons.org/ns#" xmlns:dct="http://purl.org/dc/terms/">
|
||||
Syft development is sponsored by <a href="https://anchore.com/">Anchore</a>, and is released under the <a href="https://github.com/anchore/syft?tab=Apache-2.0-1-ov-file">Apache-2.0 License</a>.
|
||||
The <a property="dct:title" rel="cc:attributionURL" href="https://anchore.com/wp-content/uploads/2024/11/syft-logo.svg">Syft logo</a> by <a rel="cc:attributionURL dct:creator" property="cc:attributionName" href="https://anchore.com/">Anchore</a> is licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="license noopener noreferrer" style="display:inline-block;">CC BY 4.0<img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt=""><img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt=""></a>
|
||||
</p>
|
||||
|
||||
For commercial support options with Syft or Grype, please [contact Anchore](https://get.anchore.com/contact/).
|
||||
|
||||
## Come talk to us!
|
||||
|
||||
The Syft Team holds regular community meetings online. All are welcome to join to bring topics for discussion.
|
||||
- Check the [calendar](https://calendar.google.com/calendar/u/0/r?cid=Y182OTM4dGt0MjRtajI0NnNzOThiaGtnM29qNEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t) for the next meeting date.
|
||||
- Add items to the [agenda](https://docs.google.com/document/d/1ZtSAa6fj2a6KRWviTn3WoJm09edvrNUp4Iz_dOjjyY8/edit?usp=sharing) (join [this group](https://groups.google.com/g/anchore-oss-community) for write access to the [agenda](https://docs.google.com/document/d/1ZtSAa6fj2a6KRWviTn3WoJm09edvrNUp4Iz_dOjjyY8/edit?usp=sharing))
|
||||
- See you there!
|
||||
|
||||
## Syft Logo
|
||||
|
||||
<p xmlns:cc="http://creativecommons.org/ns#" xmlns:dct="http://purl.org/dc/terms/"><a property="dct:title" rel="cc:attributionURL" href="https://anchore.com/wp-content/uploads/2024/11/syft-logo.svg">Syft Logo</a> by <a rel="cc:attributionURL dct:creator" property="cc:attributionName" href="https://anchore.com/">Anchore</a> is licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="license noopener noreferrer" style="display:inline-block;">CC BY 4.0<img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt=""><img style="height:22px!important;margin-left:3px;vertical-align:text-bottom;" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt=""></a></p>
|
||||
|
||||
+2
-18
@@ -2,31 +2,15 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
<!-- Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
-->
|
||||
|
||||
Security updates are applied only to the most recent release, try to always be up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
<!-- Use this section to tell people how to report a vulnerability.
|
||||
|
||||
Tell them where to go, how often they can expect to get an update on a
|
||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||
declined, etc. -->
|
||||
|
||||
To report a security issue, please email
|
||||
[security@anchore.com](mailto:security@anchore.com)
|
||||
with a description of the issue, the steps you took to create the issue,
|
||||
affected versions, and, if known, mitigations for the issue.
|
||||
|
||||
All support will be made on a best effort basis, so please indicate the "urgency level" of the vulnerability as Critical, High, Medium or Low.
|
||||
|
||||
For more details, see our [security policy documentation](https://oss.anchore.com/docs/contributing/security/).
|
||||
|
||||
+67
-15
@@ -1,10 +1,16 @@
|
||||
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
generate:cpe-index: ./task.d/generate/cpe-index.yaml
|
||||
|
||||
vars:
|
||||
OWNER: anchore
|
||||
PROJECT: syft
|
||||
|
||||
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:latest
|
||||
# v1: when fixtures were located at test-fixtures dirs
|
||||
# v2: migration to testdata dirs
|
||||
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:v2
|
||||
|
||||
# static file dirs
|
||||
TOOL_DIR: .tool
|
||||
@@ -186,10 +192,15 @@ tasks:
|
||||
cmds:
|
||||
- .github/scripts/json-schema-drift-check.sh
|
||||
|
||||
check-capability-drift:
|
||||
desc: Ensure there is no drift between the capabilities data and the code
|
||||
cmds:
|
||||
- .github/scripts/capability-drift-check.sh
|
||||
|
||||
check-binary-fixture-size:
|
||||
desc: Ensure that the binary test fixtures are not too large
|
||||
cmds:
|
||||
- .github/scripts/check_binary_fixture_size.sh syft/pkg/cataloger/binary/test-fixtures/classifiers/snippets
|
||||
- .github/scripts/check_binary_fixture_size.sh syft/pkg/cataloger/binary/testdata/classifiers/snippets
|
||||
|
||||
|
||||
## Testing tasks #################################
|
||||
@@ -214,6 +225,7 @@ tasks:
|
||||
# unit test coverage threshold (in % coverage)
|
||||
COVERAGE_THRESHOLD: 62
|
||||
cmds:
|
||||
- task: clean-test-observations
|
||||
- "go test -coverprofile {{ .TMP_DIR }}/unit-coverage-details.txt {{ .TEST_PKGS }}"
|
||||
- cmd: ".github/scripts/coverage.py {{ .COVERAGE_THRESHOLD }} {{ .TMP_DIR }}/unit-coverage-details.txt"
|
||||
silent: true
|
||||
@@ -255,9 +267,9 @@ tasks:
|
||||
fingerprints:
|
||||
desc: Generate fingerprints for all non-docker test fixture
|
||||
silent: true
|
||||
# this will look for `test-fixtures/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
|
||||
# this will look for `testdata/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
|
||||
generates:
|
||||
- '**/test-fixtures/**/*.fingerprint'
|
||||
- '**/testdata/**/*.fingerprint'
|
||||
- test/install/cache.fingerprint
|
||||
cmds:
|
||||
- |
|
||||
@@ -266,7 +278,7 @@ tasks:
|
||||
RESET='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}creating fingerprint files for non-docker fixtures...${RESET}"
|
||||
for dir in $(find . -type d -name 'test-fixtures'); do
|
||||
for dir in $(find . -type d -name 'testdata'); do
|
||||
if [ -f "$dir/Makefile" ]; then
|
||||
# for debugging...
|
||||
#echo -e "${YELLOW}• calculating fingerprints in $dir... ${RESET}"
|
||||
@@ -359,18 +371,21 @@ tasks:
|
||||
build-fixtures:
|
||||
desc: Generate all non-docker test fixtures
|
||||
silent: true
|
||||
# this will look for `test-fixtures/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
|
||||
# this will look for `testdata/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
|
||||
cmds:
|
||||
- |
|
||||
# we want to stop on the first build error
|
||||
set -e
|
||||
|
||||
BOLD='\033[1m'
|
||||
YELLOW='\033[0;33m'
|
||||
RESET='\033[0m'
|
||||
|
||||
# Use a for loop with command substitution to avoid subshell issues
|
||||
for dir in $(find . -type d -name 'test-fixtures'); do
|
||||
for dir in $(find . -type d -name 'testdata'); do
|
||||
if [ -f "$dir/Makefile" ]; then
|
||||
echo -e "${YELLOW}${BOLD}generating fixtures in $dir${RESET}"
|
||||
(make -C "$dir" fixtures)
|
||||
make -C "$dir" fixtures
|
||||
fi
|
||||
done
|
||||
echo -e "${BOLD}generated all fixtures${RESET}"
|
||||
@@ -421,7 +436,7 @@ tasks:
|
||||
- "echo 'Docker daemon cache:'"
|
||||
- "docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}:{{`{{.Tag}}`}}' | grep stereoscope-fixture- | sort"
|
||||
- "echo '\nTar cache:'"
|
||||
- 'find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" | sort'
|
||||
- 'find . -type f -wholename "**/testdata/cache/stereoscope-fixture-*.tar" | sort'
|
||||
|
||||
check-docker-cache:
|
||||
desc: Ensure docker caches aren't using too much disk space
|
||||
@@ -455,7 +470,7 @@ tasks:
|
||||
- "cd test/install && make ci-test-mac"
|
||||
|
||||
generate-compare-file:
|
||||
cmd: "go run ./cmd/syft {{ .COMPARE_TEST_IMAGE }} -o json > {{ .COMPARE_DIR }}/test-fixtures/acceptance-{{ .COMPARE_TEST_IMAGE }}.json"
|
||||
cmd: "go run ./cmd/syft {{ .COMPARE_TEST_IMAGE }} -o json > {{ .COMPARE_DIR }}/testdata/acceptance-{{ .COMPARE_TEST_IMAGE }}.json"
|
||||
|
||||
compare-mac:
|
||||
deps: [tmpdir]
|
||||
@@ -496,6 +511,7 @@ tasks:
|
||||
desc: Add data generation tasks
|
||||
cmds:
|
||||
- task: generate-json-schema
|
||||
- task: generate-capabilities
|
||||
- task: generate-license-list
|
||||
- task: generate-cpe-dictionary-index
|
||||
|
||||
@@ -511,10 +527,32 @@ tasks:
|
||||
- "gofmt -s -w ./internal/spdxlicense"
|
||||
|
||||
generate-cpe-dictionary-index:
|
||||
desc: Generate the CPE index based off of the latest available CPE dictionary
|
||||
dir: "syft/pkg/cataloger/internal/cpegenerate/dictionary"
|
||||
desc: Generate the CPE index from local cache
|
||||
cmds:
|
||||
- "go generate"
|
||||
- task: generate:cpe-index:cache:pull
|
||||
- task: generate:cpe-index:cache:update
|
||||
- task: generate:cpe-index:build
|
||||
|
||||
generate-capabilities:
|
||||
desc: Generate the capabilities data file
|
||||
deps:
|
||||
- tmpdir
|
||||
- fixtures
|
||||
vars:
|
||||
# set REFRESH=true to run package tests first and refresh test observations (default: true)
|
||||
REFRESH: '{{ .REFRESH | default "true" }}'
|
||||
cmds:
|
||||
# remove all test observations prior to regenerating
|
||||
- task: clean-test-observations
|
||||
if: '{{ eq .REFRESH "true" }}'
|
||||
# this is required to update test observations; such evidence is used to update the packages/*.yaml
|
||||
- cmd: "go test ./syft/pkg/... -count=1"
|
||||
if: '{{ eq .REFRESH "true" }}'
|
||||
- "go generate ./internal/capabilities/..."
|
||||
- "gofmt -s -w ./internal/capabilities"
|
||||
# now that we have the latest capabilities, run completeness tests to ensure this is self-consistent
|
||||
# note: -p 1 forces sequential package execution to avoid race between generate (writes) and internal (reads)
|
||||
- "SYFT_ENABLE_COMPLETENESS_TESTS=true go test -p 1 ./internal/capabilities/... -count=1"
|
||||
|
||||
|
||||
## Build-related targets #################################
|
||||
@@ -607,6 +645,15 @@ tasks:
|
||||
|
||||
## Cleanup targets #################################
|
||||
|
||||
clean:
|
||||
desc: Remove all cache files and old builds
|
||||
cmds:
|
||||
- task: clean-snapshot
|
||||
- task: clean-cache
|
||||
- task: clean-test-observations
|
||||
- task: clean-docker-cache
|
||||
- task: clean-oras-cache
|
||||
|
||||
clean-snapshot:
|
||||
desc: Remove any snapshot builds
|
||||
cmds:
|
||||
@@ -616,7 +663,7 @@ tasks:
|
||||
clean-docker-cache:
|
||||
desc: Remove all docker cache tars and images from the daemon
|
||||
cmds:
|
||||
- find . -type d -wholename "**/test-fixtures/cache" | xargs rm -rf
|
||||
- find . -type d -wholename "**/testdata/cache" | xargs rm -rf
|
||||
- docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}' | grep stereoscope-fixture- | awk '{print $1}' | uniq | xargs -r docker rmi --force
|
||||
|
||||
clean-oras-cache:
|
||||
@@ -633,7 +680,7 @@ tasks:
|
||||
RESET='\033[0m'
|
||||
|
||||
# Use a for loop with command substitution to avoid subshell issues
|
||||
for dir in $(find . -type d -name 'test-fixtures'); do
|
||||
for dir in $(find . -type d -name 'testdata'); do
|
||||
if [ -f "$dir/Makefile" ]; then
|
||||
echo -e "${YELLOW}${BOLD}deleting ephemeral test fixtures in $dir${RESET}"
|
||||
(make -C "$dir" clean)
|
||||
@@ -641,3 +688,8 @@ tasks:
|
||||
done
|
||||
echo -e "${BOLD}Deleted all ephemeral test fixtures${RESET}"
|
||||
- rm -f {{ .LAST_CACHE_PULL_FILE }} {{ .CACHE_PATHS_FILE }}
|
||||
|
||||
clean-test-observations:
|
||||
desc: Remove all test observations (i.e. testdata/test-observations.json)
|
||||
cmds:
|
||||
- find . -type f -wholename "**/testdata/test-observations.json" | xargs rm -f
|
||||
|
||||
@@ -48,6 +48,7 @@ func create(id clio.Identification, out io.Writer) (clio.Application, *cobra.Com
|
||||
commands.Cataloger(app),
|
||||
commands.Attest(app),
|
||||
commands.Convert(app),
|
||||
commands.Split(app),
|
||||
clio.VersionCommand(id, schemaVersion),
|
||||
clio.ConfigCommand(app, nil),
|
||||
cranecmd.NewCmdAuthLogin(id.Name), // syft login uses the same command as crane
|
||||
|
||||
@@ -219,7 +219,7 @@ func (l attestLogFrame) View() string {
|
||||
sb := strings.Builder{}
|
||||
|
||||
for _, line := range l.lines {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s\n", l.borderStype.Render("░░"), line))
|
||||
fmt.Fprintf(&sb, " %s %s\n", l.borderStype.Render("░░"), line)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
@@ -49,7 +49,7 @@ func AppClioSetupConfig(id clio.Identification, out io.Writer) *clio.SetupConfig
|
||||
},
|
||||
).
|
||||
WithPostRuns(func(_ *clio.State, _ error) {
|
||||
stereoscope.Cleanup()
|
||||
stereoscope.Cleanup() //nolint:staticcheck // we don't have access to the image object here
|
||||
})
|
||||
return clioCfg
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp
|
||||
attestHelp = attestExample + attestSchemeHelp
|
||||
cosignBinName = "cosign"
|
||||
jsonFormat = "json"
|
||||
)
|
||||
|
||||
type attestOptions struct {
|
||||
@@ -93,9 +94,7 @@ func defaultAttestOutputOptions() options.Output {
|
||||
string(spdxtagvalue.ID),
|
||||
},
|
||||
Outputs: []string{syftjson.ID.String()},
|
||||
OutputFile: options.OutputFile{ //nolint:staticcheck
|
||||
Enabled: false, // explicitly not allowed
|
||||
},
|
||||
// OutputFile is omitted (defaults to Enabled: false, explicitly not allowed for attestation)
|
||||
Format: options.DefaultFormat(),
|
||||
}
|
||||
}
|
||||
@@ -240,7 +239,7 @@ func predicateType(outputName string) string {
|
||||
return "cyclonedx"
|
||||
case "spdx-tag-value", "spdx-tv":
|
||||
return "spdx"
|
||||
case "spdx-json", "json":
|
||||
case "spdx-json", jsonFormat:
|
||||
return "spdxjson"
|
||||
default:
|
||||
return "custom"
|
||||
@@ -253,7 +252,6 @@ func generateSBOMForAttestation(ctx context.Context, id clio.Identification, opt
|
||||
}
|
||||
|
||||
src, err := getSource(ctx, opts, userInput, stereoscope.RegistryTag)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
@@ -16,5 +18,14 @@ func Cataloger(app clio.Application) *cobra.Command {
|
||||
CatalogerList(app),
|
||||
)
|
||||
|
||||
// only add cataloger info command if experimental capabilities feature is enabled
|
||||
if isCapabilitiesExperimentEnabled() {
|
||||
cmd.AddCommand(CatalogerCaps(app))
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func isCapabilitiesExperimentEnabled() bool {
|
||||
return os.Getenv("SYFT_EXP_CAPABILITIES") == "true"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/renderer"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/capabilities"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
_ "github.com/anchore/syft/syft/pkg/cataloger" // register all cataloger capabilities, otherwise the info command will not function
|
||||
)
|
||||
|
||||
var (
|
||||
yesStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // hi green
|
||||
noStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark grey
|
||||
criteriaWidth = 60
|
||||
criteriaMargin = 10
|
||||
)
|
||||
|
||||
// types for JSON cataloger info output
|
||||
type (
|
||||
configFieldInfo struct {
|
||||
Key string `json:"key"`
|
||||
Description string `json:"description"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
}
|
||||
|
||||
configInfo struct {
|
||||
Type string `json:"type"`
|
||||
Fields []configFieldInfo `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
detectorPackageInfo struct {
|
||||
Class string `json:"class"`
|
||||
Name string `json:"name"`
|
||||
PURL string `json:"purl"`
|
||||
CPEs []string `json:"cpes"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
patternInfo struct {
|
||||
Method string `json:"method"`
|
||||
Criteria []string `json:"criteria"`
|
||||
Conditions []capabilities.DetectorCondition `json:"conditions,omitempty"`
|
||||
Packages []detectorPackageInfo `json:"packages,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
PackageTypes []string `json:"package_types,omitempty"`
|
||||
JSONSchemaTypes []string `json:"json_schema_types,omitempty"`
|
||||
Capabilities capabilities.CapabilitySet `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
catalogerInfo struct {
|
||||
Ecosystem string `json:"ecosystem,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Selectors []string `json:"selectors,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
Patterns []patternInfo `json:"patterns,omitempty"`
|
||||
Capabilities capabilities.CapabilitySet `json:"capabilities,omitempty"`
|
||||
Config *configInfo `json:"config,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
type catalogerInfoOptions struct {
|
||||
Output string `yaml:"output" json:"output" mapstructure:"output"`
|
||||
options.CatalogerSelection `yaml:",inline" json:",inline" mapstructure:",squash"`
|
||||
Names []string // cataloger names from args
|
||||
}
|
||||
|
||||
func (o *catalogerInfoOptions) setNames(args []string) error {
|
||||
o.Names = args
|
||||
|
||||
usingLegacyCatalogers := len(o.Catalogers) > 0
|
||||
usingNewCatalogers := len(o.DefaultCatalogers) > 0 || len(o.SelectCatalogers) > 0
|
||||
usingSelection := usingNewCatalogers || usingLegacyCatalogers
|
||||
|
||||
if usingSelection && len(o.Names) > 0 {
|
||||
return fmt.Errorf("cannot use both cataloger name arguments and '--catalogers'/'--select-catalogers'/'--default-catalogers' flags")
|
||||
}
|
||||
|
||||
if usingSelection {
|
||||
// get all available package cataloger tasks
|
||||
pkgTaskFactories := task.DefaultPackageTaskFactories()
|
||||
allPkgTasks, err := pkgTaskFactories.Tasks(task.DefaultCatalogingFactoryConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create pkg cataloger tasks: %w", err)
|
||||
}
|
||||
|
||||
// make the selection based on user input
|
||||
defaultCatalogers := options.FlattenAndSort(o.DefaultCatalogers)
|
||||
selectCatalogers := options.FlattenAndSort(o.SelectCatalogers)
|
||||
selectedTaskGroups, _, err := task.SelectInGroups(
|
||||
[][]task.Task{allPkgTasks},
|
||||
cataloging.NewSelectionRequest().
|
||||
WithDefaults(defaultCatalogers...).
|
||||
WithExpression(selectCatalogers...),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to select catalogers: %w", err)
|
||||
}
|
||||
|
||||
// build the list of cataloger names based on the selection
|
||||
for _, g := range selectedTaskGroups {
|
||||
for _, t := range g {
|
||||
o.Names = append(o.Names, t.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *catalogerInfoOptions) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP(&o.Output, "output", "o", "format to output the cataloger info (available: table, json)")
|
||||
}
|
||||
|
||||
func defaultCatalogerCapsOptions() *catalogerInfoOptions {
|
||||
return &catalogerInfoOptions{
|
||||
CatalogerSelection: options.CatalogerSelection{
|
||||
// this is different than the default behavior where a scan will automatically detect the default set
|
||||
DefaultCatalogers: []string{"all"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func CatalogerCaps(app clio.Application) *cobra.Command {
|
||||
opts := defaultCatalogerCapsOptions()
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "info [OPTIONS] [CATALOGER_NAMES...]",
|
||||
Short: "Show detailed capabilities of catalogers",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
PreRunE: disableUI(app, os.Stdout),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
if err := opts.setNames(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runCatalogerInfo(opts)
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func runCatalogerInfo(opts *catalogerInfoOptions) error {
|
||||
doc, err := capabilities.LoadDocument()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to load cataloger capabilities: %w", err)
|
||||
}
|
||||
|
||||
// filter by cataloger names if provided
|
||||
catalogers := doc.Catalogers
|
||||
if len(opts.Names) > 0 {
|
||||
catalogers = filterCatalogersByName(catalogers, opts.Names)
|
||||
}
|
||||
|
||||
report, err := catalogerInfoReport(opts, doc, catalogers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to generate cataloger info report: %w", err)
|
||||
}
|
||||
|
||||
bus.Report(report)
|
||||
bus.Notify("Note: the `cataloger info` command is experimental and may change or be removed without notice. Do not depend on its output in production systems.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterCatalogersByName(catalogers []capabilities.CatalogerEntry, names []string) []capabilities.CatalogerEntry {
|
||||
nameSet := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
nameSet[name] = true
|
||||
}
|
||||
|
||||
var filtered []capabilities.CatalogerEntry
|
||||
for _, cat := range catalogers {
|
||||
if nameSet[cat.Name] {
|
||||
filtered = append(filtered, cat)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func catalogerInfoReport(opts *catalogerInfoOptions, doc *capabilities.Document, catalogers []capabilities.CatalogerEntry) (string, error) {
|
||||
switch opts.Output {
|
||||
case jsonFormat:
|
||||
return renderCatalogerInfoJSON(doc, catalogers)
|
||||
case "table", "":
|
||||
return renderCatalogerInfoTable(doc, catalogers), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q, must be one of: table, json", opts.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func renderCatalogerInfoJSON(doc *capabilities.Document, catalogers []capabilities.CatalogerEntry) (string, error) {
|
||||
type document struct {
|
||||
Catalogers []catalogerInfo `json:"catalogers"`
|
||||
}
|
||||
|
||||
docOut := document{}
|
||||
|
||||
// sort catalogers by ecosystem then name
|
||||
sort.Slice(catalogers, func(i, j int) bool {
|
||||
if catalogers[i].Ecosystem != catalogers[j].Ecosystem {
|
||||
return catalogers[i].Ecosystem < catalogers[j].Ecosystem
|
||||
}
|
||||
return catalogers[i].Name < catalogers[j].Name
|
||||
})
|
||||
|
||||
for _, cat := range catalogers {
|
||||
info := catalogerInfo{
|
||||
Ecosystem: cat.Ecosystem,
|
||||
Name: cat.Name,
|
||||
Type: cat.Type,
|
||||
Selectors: cat.Selectors,
|
||||
Deprecated: isDeprecatedCataloger(cat.Selectors),
|
||||
}
|
||||
|
||||
// convert parsers to patterns if available
|
||||
info.Patterns = convertParsersToPatterns(cat.Parsers)
|
||||
|
||||
// if no parsers, use detectors instead
|
||||
if len(info.Patterns) == 0 {
|
||||
info.Capabilities = cat.Capabilities
|
||||
info.Patterns = convertDetectorsToPatterns(cat.Detectors, cat.PackageTypes, cat.JSONSchemaTypes)
|
||||
}
|
||||
|
||||
info.Config = getConfigInfoFromDocument(doc, cat.Config)
|
||||
|
||||
docOut.Catalogers = append(docOut.Catalogers, info)
|
||||
}
|
||||
|
||||
by, err := json.Marshal(docOut)
|
||||
return string(by), err
|
||||
}
|
||||
|
||||
// isDeprecatedCataloger checks if a cataloger is deprecated based on its selectors
|
||||
func isDeprecatedCataloger(selectors []string) bool {
|
||||
for _, selector := range selectors {
|
||||
if selector == "deprecated" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// convertDetectorPackages converts detector package info to the JSON output format
|
||||
func convertDetectorPackages(pkgs []capabilities.DetectorPackageInfo) []detectorPackageInfo {
|
||||
var result []detectorPackageInfo
|
||||
for _, pkg := range pkgs {
|
||||
result = append(result, detectorPackageInfo{
|
||||
Class: pkg.Class,
|
||||
Name: pkg.Name,
|
||||
PURL: pkg.PURL,
|
||||
CPEs: pkg.CPEs,
|
||||
Type: pkg.Type,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertParsersToPatterns converts parser entries to pattern info for JSON output
|
||||
func convertParsersToPatterns(parsers []capabilities.Parser) []patternInfo {
|
||||
var patterns []patternInfo
|
||||
for _, parser := range parsers {
|
||||
patterns = append(patterns, patternInfo{
|
||||
Method: string(parser.Detector.Method),
|
||||
Criteria: parser.Detector.Criteria,
|
||||
Conditions: parser.Detector.Conditions,
|
||||
Packages: convertDetectorPackages(parser.Detector.Packages),
|
||||
Comment: parser.Detector.Comment,
|
||||
PackageTypes: parser.PackageTypes,
|
||||
JSONSchemaTypes: parser.JSONSchemaTypes,
|
||||
Capabilities: parser.Capabilities,
|
||||
})
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// convertDetectorsToPatterns converts detector entries to pattern info for JSON output (for non-parser catalogers)
|
||||
func convertDetectorsToPatterns(detectors []capabilities.Detector, packageTypes, jsonSchemaTypes []string) []patternInfo {
|
||||
var patterns []patternInfo
|
||||
for _, det := range detectors {
|
||||
patterns = append(patterns, patternInfo{
|
||||
Method: string(det.Method),
|
||||
Criteria: det.Criteria,
|
||||
Conditions: det.Conditions,
|
||||
Packages: convertDetectorPackages(det.Packages),
|
||||
Comment: det.Comment,
|
||||
PackageTypes: packageTypes,
|
||||
JSONSchemaTypes: jsonSchemaTypes,
|
||||
})
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
// getConfigInfoFromDocument retrieves config info from the capabilities document
|
||||
func getConfigInfoFromDocument(doc *capabilities.Document, configType string) *configInfo {
|
||||
if configType == "" {
|
||||
return nil
|
||||
}
|
||||
configEntry, ok := doc.Configs[configType]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cfg := &configInfo{
|
||||
Type: configType,
|
||||
}
|
||||
for _, field := range configEntry.Fields {
|
||||
cfg.Fields = append(cfg.Fields, configFieldInfo{
|
||||
Key: field.Key,
|
||||
Description: field.Description,
|
||||
AppKey: field.AppKey,
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func renderCatalogerInfoTable(_ *capabilities.Document, catalogers []capabilities.CatalogerEntry) string {
|
||||
if len(catalogers) == 0 {
|
||||
return noStyle.Render("No catalogers found")
|
||||
}
|
||||
|
||||
// sort catalogers by ecosystem then name
|
||||
sort.Slice(catalogers, func(i, j int) bool {
|
||||
if catalogers[i].Ecosystem != catalogers[j].Ecosystem {
|
||||
return catalogers[i].Ecosystem < catalogers[j].Ecosystem
|
||||
}
|
||||
return catalogers[i].Name < catalogers[j].Name
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
// create table with hierarchical merging
|
||||
table := tablewriter.NewTable(&buf,
|
||||
tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
|
||||
Settings: tw.Settings{Separators: tw.Separators{BetweenRows: tw.On}},
|
||||
})),
|
||||
tablewriter.WithConfig(tablewriter.Config{
|
||||
Row: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{MergeMode: tw.MergeHierarchical},
|
||||
Alignment: tw.CellAlignment{Global: tw.AlignLeft},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// set headers
|
||||
table.Header("ECOSYSTEM", "CATALOGER", "CRITERIA", "LICENSE", "NODES", "EDGES", "KINDS", "LISTING", "DIGESTS", "HASH")
|
||||
|
||||
// build rows for each cataloger
|
||||
var data [][]string
|
||||
for _, cat := range catalogers {
|
||||
ecosystem := cat.Ecosystem
|
||||
if ecosystem == "" {
|
||||
ecosystem = "other"
|
||||
}
|
||||
|
||||
if cat.Type == "generic" {
|
||||
// generic catalogers: one row per parser
|
||||
for _, parser := range cat.Parsers {
|
||||
criteria := formatCriteria([]capabilities.Detector{parser.Detector})
|
||||
row := buildTableRowFromCapabilities(ecosystem, cat.Name, criteria, parser.Capabilities)
|
||||
data = append(data, row)
|
||||
}
|
||||
} else {
|
||||
// custom catalogers: one row with all detectors
|
||||
criteria := formatCriteria(cat.Detectors)
|
||||
row := buildTableRowFromCapabilities(ecosystem, cat.Name, criteria, cat.Capabilities)
|
||||
data = append(data, row)
|
||||
}
|
||||
}
|
||||
|
||||
// add all rows at once for better merging
|
||||
_ = table.Bulk(data)
|
||||
_ = table.Render()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildTableRowFromCapabilities builds a table row from capability values
|
||||
func buildTableRowFromCapabilities(ecosystem, name, criteria string, caps capabilities.CapabilitySet) []string {
|
||||
// extract capability default values
|
||||
license := extractBoolCapability(caps, "license")
|
||||
nodes := extractNodesCapability(caps)
|
||||
edges := extractStringCapability(caps, "dependency.edges")
|
||||
kinds := extractArrayCapability(caps, "dependency.kinds")
|
||||
listing := extractBoolCapability(caps, "package_manager.files.listing")
|
||||
digests := extractBoolCapability(caps, "package_manager.files.digests")
|
||||
hash := extractBoolCapability(caps, "package_manager.package_integrity_hash")
|
||||
|
||||
return []string{
|
||||
ecosystem,
|
||||
name,
|
||||
criteria,
|
||||
license,
|
||||
nodes,
|
||||
edges,
|
||||
kinds,
|
||||
listing,
|
||||
digests,
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
// extractBoolCapability extracts a boolean capability value and formats it
|
||||
func extractBoolCapability(caps capabilities.CapabilitySet, name string) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == name {
|
||||
if b, ok := cap.Default.(bool); ok {
|
||||
if b {
|
||||
return yesStyle.Render("✔")
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// extractStringCapability extracts a string capability value
|
||||
func extractStringCapability(caps capabilities.CapabilitySet, name string) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == name {
|
||||
if s, ok := cap.Default.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// extractArrayCapability extracts an array capability value and formats it as comma-separated
|
||||
func extractArrayCapability(caps capabilities.CapabilitySet, name string) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == name {
|
||||
// handle various array types
|
||||
switch v := cap.Default.(type) {
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
return strings.Join(v, ", ")
|
||||
}
|
||||
case []interface{}:
|
||||
if len(v) > 0 {
|
||||
strs := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
strs = append(strs, fmt.Sprintf("%v", item))
|
||||
}
|
||||
return strings.Join(strs, ", ")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// extractNodesCapability extracts dependency.depth and formats it
|
||||
// maps ["direct", "indirect"] to "transitive"
|
||||
func extractNodesCapability(caps capabilities.CapabilitySet) string {
|
||||
for _, cap := range caps {
|
||||
if cap.Name == "dependency.depth" {
|
||||
switch v := cap.Default.(type) {
|
||||
case []string:
|
||||
return formatDepthStringArray(v)
|
||||
case []interface{}:
|
||||
return formatDepthInterfaceArray(v)
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
}
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
|
||||
// formatDepthStringArray formats a []string dependency depth value
|
||||
func formatDepthStringArray(v []string) string {
|
||||
if len(v) == 0 {
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
if hasBothDirectAndIndirect(v) {
|
||||
return "transitive"
|
||||
}
|
||||
return strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
// formatDepthInterfaceArray formats a []interface{} dependency depth value
|
||||
func formatDepthInterfaceArray(v []interface{}) string {
|
||||
if len(v) == 0 {
|
||||
return noStyle.Render("·")
|
||||
}
|
||||
strs := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
strs = append(strs, fmt.Sprintf("%v", item))
|
||||
}
|
||||
if hasBothDirectAndIndirect(strs) {
|
||||
return "transitive"
|
||||
}
|
||||
return strings.Join(strs, ", ")
|
||||
}
|
||||
|
||||
// hasBothDirectAndIndirect checks if a slice contains both "direct" and "indirect" strings
|
||||
func hasBothDirectAndIndirect(items []string) bool {
|
||||
hasDirect := false
|
||||
hasIndirect := false
|
||||
for _, item := range items {
|
||||
if item == "direct" {
|
||||
hasDirect = true
|
||||
}
|
||||
if item == "indirect" {
|
||||
hasIndirect = true
|
||||
}
|
||||
}
|
||||
return hasDirect && hasIndirect
|
||||
}
|
||||
|
||||
func formatCriteria(detectors []capabilities.Detector) string {
|
||||
var allCriteria []string
|
||||
methods := strset.New()
|
||||
|
||||
for _, det := range detectors {
|
||||
allCriteria = append(allCriteria, det.Criteria...)
|
||||
methods.Add(string(det.Method))
|
||||
}
|
||||
|
||||
// smart word wrapping: wrap by word (criterion) up to criteriaWidth,
|
||||
// allow up to criteriaWidth+criteriaMargin before forcing a new line
|
||||
var lines []string
|
||||
var currentLine []string
|
||||
currentLength := 0
|
||||
|
||||
for _, criterion := range allCriteria {
|
||||
// calculate length including the criterion plus ", " separator (except for first item)
|
||||
itemLength := len(criterion)
|
||||
if len(currentLine) > 0 {
|
||||
itemLength += 2 // for ", "
|
||||
}
|
||||
|
||||
newLength := currentLength + itemLength
|
||||
|
||||
if len(currentLine) > 0 && newLength > criteriaWidth {
|
||||
// check if it's within margin
|
||||
if newLength <= criteriaWidth+criteriaMargin {
|
||||
// close enough, add it anyway
|
||||
currentLine = append(currentLine, criterion)
|
||||
currentLength = newLength
|
||||
} else {
|
||||
// too long, finalize current line and start new one
|
||||
lines = append(lines, strings.Join(currentLine, ", "))
|
||||
currentLine = []string{criterion}
|
||||
currentLength = len(criterion)
|
||||
}
|
||||
} else {
|
||||
// fits within width or first item
|
||||
currentLine = append(currentLine, criterion)
|
||||
currentLength = newLength
|
||||
}
|
||||
}
|
||||
|
||||
// add final line
|
||||
if len(currentLine) > 0 {
|
||||
lines = append(lines, strings.Join(currentLine, ", "))
|
||||
}
|
||||
|
||||
methodsList := methods.List()
|
||||
sort.Strings(methodsList)
|
||||
method := strings.Join(methodsList, ", ")
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
joined := strings.Join(lines, "\n")
|
||||
if method != string(capabilities.GlobDetection) {
|
||||
return fmt.Sprintf("%s (%s)", joined, method)
|
||||
}
|
||||
return joined
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/capabilities"
|
||||
)
|
||||
|
||||
func Test_isDeprecatedCataloger(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selectors []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty selectors",
|
||||
selectors: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no deprecated selector",
|
||||
selectors: []string{"python", "pip", "package"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "has deprecated selector",
|
||||
selectors: []string{"python", "deprecated", "package"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "only deprecated selector",
|
||||
selectors: []string{"deprecated"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isDeprecatedCataloger(tt.selectors)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hasBothDirectAndIndirect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty slice",
|
||||
items: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "only direct",
|
||||
items: []string{"direct"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "only indirect",
|
||||
items: []string{"indirect"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "both direct and indirect",
|
||||
items: []string{"direct", "indirect"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both with other items",
|
||||
items: []string{"other", "direct", "more", "indirect", "stuff"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated items",
|
||||
items: []string{"foo", "bar", "baz"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasBothDirectAndIndirect(tt.items)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_filterCatalogersByName(t *testing.T) {
|
||||
catalogers := []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
{Name: "java-cataloger", Ecosystem: "java"},
|
||||
{Name: "go-cataloger", Ecosystem: "golang"},
|
||||
{Name: "npm-cataloger", Ecosystem: "javascript"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
catalogers []capabilities.CatalogerEntry
|
||||
names []string
|
||||
want []capabilities.CatalogerEntry
|
||||
}{
|
||||
{
|
||||
name: "empty names returns empty",
|
||||
catalogers: catalogers,
|
||||
names: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single match",
|
||||
catalogers: catalogers,
|
||||
names: []string{"python-cataloger"},
|
||||
want: []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple matches",
|
||||
catalogers: catalogers,
|
||||
names: []string{"python-cataloger", "java-cataloger"},
|
||||
want: []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
{Name: "java-cataloger", Ecosystem: "java"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no matches",
|
||||
catalogers: catalogers,
|
||||
names: []string{"nonexistent"},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "partial matches",
|
||||
catalogers: catalogers,
|
||||
names: []string{"python-cataloger", "nonexistent"},
|
||||
want: []capabilities.CatalogerEntry{
|
||||
{Name: "python-cataloger", Ecosystem: "python"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := filterCatalogersByName(tt.catalogers, tt.names)
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Errorf("filterCatalogersByName() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_formatCriteria(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
detectors []capabilities.Detector
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty detectors",
|
||||
detectors: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single glob detector",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.py"},
|
||||
},
|
||||
},
|
||||
want: "**/*.py",
|
||||
},
|
||||
{
|
||||
name: "multiple criteria single detector",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.py", "**/*.pyc", "**/requirements.txt"},
|
||||
},
|
||||
},
|
||||
want: "**/*.py, **/*.pyc, **/requirements.txt",
|
||||
},
|
||||
{
|
||||
name: "path detection shows method",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.PathDetection,
|
||||
Criteria: []string{"/usr/bin/python"},
|
||||
},
|
||||
},
|
||||
want: "/usr/bin/python (path)",
|
||||
},
|
||||
{
|
||||
name: "mimetype detection shows method",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.MIMETypeDetection,
|
||||
Criteria: []string{"application/x-python"},
|
||||
},
|
||||
},
|
||||
want: "application/x-python (mimetype)",
|
||||
},
|
||||
{
|
||||
name: "multiple detectors combine criteria",
|
||||
detectors: []capabilities.Detector{
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.py"},
|
||||
},
|
||||
{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: []string{"**/*.pyc"},
|
||||
},
|
||||
},
|
||||
want: "**/*.py, **/*.pyc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatCriteria(tt.detectors)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testDocument() *capabilities.Document {
|
||||
return &capabilities.Document{
|
||||
Configs: map[string]capabilities.CatalogerConfigEntry{
|
||||
"test-config": {
|
||||
Fields: []capabilities.CatalogerConfigFieldEntry{
|
||||
{Key: "field1", Description: "test field"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Test_catalogerInfoReport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *catalogerInfoOptions
|
||||
doc *capabilities.Document
|
||||
catalogers []capabilities.CatalogerEntry
|
||||
wantErr require.ErrorAssertionFunc
|
||||
assertions func(t *testing.T, got string)
|
||||
}{
|
||||
{
|
||||
name: "empty catalogers table",
|
||||
opts: &catalogerInfoOptions{Output: "table"},
|
||||
doc: testDocument(),
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, "No catalogers found")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty catalogers json",
|
||||
opts: &catalogerInfoOptions{Output: "json"},
|
||||
doc: testDocument(),
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, `"catalogers":null`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single cataloger table output",
|
||||
opts: &catalogerInfoOptions{Output: "table"},
|
||||
doc: testDocument(),
|
||||
catalogers: []capabilities.CatalogerEntry{
|
||||
{
|
||||
Name: "test-cataloger",
|
||||
Ecosystem: "test",
|
||||
Type: "custom",
|
||||
Detectors: []capabilities.Detector{
|
||||
{Method: capabilities.GlobDetection, Criteria: []string{"**/*.test"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, "test-cataloger")
|
||||
assert.Contains(t, got, "ECOSYSTEM")
|
||||
assert.Contains(t, got, "CATALOGER")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single cataloger json output",
|
||||
opts: &catalogerInfoOptions{Output: "json"},
|
||||
doc: testDocument(),
|
||||
catalogers: []capabilities.CatalogerEntry{
|
||||
{
|
||||
Name: "test-cataloger",
|
||||
Ecosystem: "test",
|
||||
Type: "custom",
|
||||
Selectors: []string{"test", "custom"},
|
||||
},
|
||||
},
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, `"name":"test-cataloger"`)
|
||||
assert.Contains(t, got, `"ecosystem":"test"`)
|
||||
assert.Contains(t, got, `"type":"custom"`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deprecated cataloger json output",
|
||||
opts: &catalogerInfoOptions{Output: "json"},
|
||||
doc: testDocument(),
|
||||
catalogers: []capabilities.CatalogerEntry{
|
||||
{
|
||||
Name: "old-cataloger",
|
||||
Ecosystem: "legacy",
|
||||
Type: "custom",
|
||||
Selectors: []string{"legacy", "deprecated"},
|
||||
},
|
||||
},
|
||||
assertions: func(t *testing.T, got string) {
|
||||
assert.Contains(t, got, `"deprecated":true`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid output format",
|
||||
opts: &catalogerInfoOptions{Output: "invalid"},
|
||||
doc: testDocument(),
|
||||
wantErr: require.Error,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = require.NoError
|
||||
}
|
||||
|
||||
got, err := catalogerInfoReport(tt.opts, tt.doc, tt.catalogers)
|
||||
tt.wantErr(t, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if tt.assertions != nil {
|
||||
tt.assertions(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_formatCriteria_wordWrapping(t *testing.T) {
|
||||
// test word wrapping behavior with long criteria lists
|
||||
longCriteria := make([]string, 20)
|
||||
for i := range longCriteria {
|
||||
longCriteria[i] = "**/file" + strings.Repeat("x", 10)
|
||||
}
|
||||
|
||||
detector := capabilities.Detector{
|
||||
Method: capabilities.GlobDetection,
|
||||
Criteria: longCriteria,
|
||||
}
|
||||
|
||||
got := formatCriteria([]capabilities.Detector{detector})
|
||||
|
||||
// should contain newlines for wrapping
|
||||
assert.Contains(t, got, "\n")
|
||||
// should contain all criteria (joined with ", " and possibly split across lines)
|
||||
for _, c := range longCriteria {
|
||||
assert.Contains(t, got, c)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/capabilities"
|
||||
"github.com/anchore/syft/internal/task"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
)
|
||||
@@ -28,25 +29,23 @@ var (
|
||||
)
|
||||
|
||||
type catalogerListOptions struct {
|
||||
Output string `yaml:"output" json:"output" mapstructure:"output"`
|
||||
DefaultCatalogers []string `yaml:"default-catalogers" json:"default-catalogers" mapstructure:"default-catalogers"`
|
||||
SelectCatalogers []string `yaml:"select-catalogers" json:"select-catalogers" mapstructure:"select-catalogers"`
|
||||
ShowHidden bool `yaml:"show-hidden" json:"show-hidden" mapstructure:"show-hidden"`
|
||||
Output string `yaml:"output" json:"output" mapstructure:"output"`
|
||||
options.CatalogerSelection `yaml:",inline" json:",inline" mapstructure:",squash"`
|
||||
ShowHidden bool `yaml:"show-hidden" json:"show-hidden" mapstructure:"show-hidden"`
|
||||
}
|
||||
|
||||
func (o *catalogerListOptions) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP(&o.Output, "output", "o", "format to output the cataloger list (available: table, json)")
|
||||
|
||||
flags.StringArrayVarP(&o.DefaultCatalogers, "override-default-catalogers", "", "override the default catalogers with an expression")
|
||||
|
||||
flags.StringArrayVarP(&o.SelectCatalogers, "select-catalogers", "", "select catalogers with an expression")
|
||||
|
||||
flags.BoolVarP(&o.ShowHidden, "show-hidden", "s", "show catalogers that have been de-selected")
|
||||
}
|
||||
|
||||
func defaultCatalogerListOptions() *catalogerListOptions {
|
||||
return &catalogerListOptions{
|
||||
DefaultCatalogers: []string{"all"},
|
||||
CatalogerSelection: options.CatalogerSelection{
|
||||
// this is different than the default behavior where a scan will automatically detect the default set
|
||||
DefaultCatalogers: []string{"all"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +86,8 @@ func runCatalogerList(opts *catalogerListOptions) error {
|
||||
}
|
||||
|
||||
func catalogerListReport(opts *catalogerListOptions, allTaskGroups [][]task.Task) (string, error) {
|
||||
defaultCatalogers := options.Flatten(opts.DefaultCatalogers)
|
||||
selectCatalogers := options.Flatten(opts.SelectCatalogers)
|
||||
defaultCatalogers := options.FlattenAndSort(opts.DefaultCatalogers)
|
||||
selectCatalogers := options.FlattenAndSort(opts.SelectCatalogers)
|
||||
selectedTaskGroups, selectionEvidence, err := task.SelectInGroups(
|
||||
allTaskGroups,
|
||||
cataloging.NewSelectionRequest().
|
||||
@@ -101,7 +100,7 @@ func catalogerListReport(opts *catalogerListOptions, allTaskGroups [][]task.Task
|
||||
var report string
|
||||
|
||||
switch opts.Output {
|
||||
case "json":
|
||||
case jsonFormat:
|
||||
report, err = renderCatalogerListJSON(flattenTaskGroups(selectedTaskGroups), selectionEvidence, defaultCatalogers, selectCatalogers)
|
||||
case "table", "":
|
||||
if opts.ShowHidden {
|
||||
@@ -306,22 +305,14 @@ func formatToken(token string, selection *task.TokenSelection, included bool, de
|
||||
}
|
||||
|
||||
func extractTaskInfo(tasks []task.Task) ([]string, map[string][]string) {
|
||||
infos := capabilities.ExtractCatalogerInfo(tasks)
|
||||
|
||||
tagsByName := make(map[string][]string)
|
||||
var names []string
|
||||
|
||||
for _, tsk := range tasks {
|
||||
var tags []string
|
||||
name := tsk.Name()
|
||||
|
||||
if s, ok := tsk.(task.Selector); ok {
|
||||
set := strset.New(s.Selectors()...)
|
||||
set.Remove(name)
|
||||
tags = set.List()
|
||||
sort.Strings(tags)
|
||||
}
|
||||
|
||||
tagsByName[name] = tags
|
||||
names = append(names, name)
|
||||
for _, info := range infos {
|
||||
tagsByName[info.Name] = info.Selectors
|
||||
names = append(names, info.Name)
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
@@ -59,7 +59,9 @@ const (
|
||||
nonImageSchemeHelp = ` {{.appName}} {{.command}} dir:path/to/yourproject read directly from a path on disk (any directory)
|
||||
{{.appName}} {{.command}} file:path/to/yourproject/file read directly from a path on disk (any single file)
|
||||
`
|
||||
scanSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp
|
||||
modelSchemeHelp = ` {{.appName}} {{.command}} oci-model-registry:ai/llama3.2 scan an OCI model artifact from a registry (e.g. Docker Hub AI models)
|
||||
`
|
||||
scanSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + modelSchemeHelp + nonImageSchemeHelp
|
||||
|
||||
scanHelp = scanExample + scanSchemeHelp
|
||||
)
|
||||
@@ -185,7 +187,6 @@ func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, use
|
||||
}
|
||||
|
||||
src, err := getSource(ctx, &opts.Catalog, userInput, sources...)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -19,30 +19,30 @@ func Test_scanOptions_validateLegacyOptionsNotUsed(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "config file with no legacy options",
|
||||
cfg: "test-fixtures/scan-configs/no-legacy-options.yaml",
|
||||
cfg: "testdata/scan-configs/no-legacy-options.yaml",
|
||||
},
|
||||
{
|
||||
name: "config file with default image pull source legacy option",
|
||||
cfg: "test-fixtures/scan-configs/with-default-pull-source.yaml",
|
||||
cfg: "testdata/scan-configs/with-default-pull-source.yaml",
|
||||
wantErr: assertErrorContains("source.image.default-pull-source"),
|
||||
},
|
||||
{
|
||||
name: "config file with exclude-binary-overlap-by-ownership legacy option",
|
||||
cfg: "test-fixtures/scan-configs/with-exclude-binary-overlap-by-ownership.yaml",
|
||||
cfg: "testdata/scan-configs/with-exclude-binary-overlap-by-ownership.yaml",
|
||||
wantErr: assertErrorContains("package.exclude-binary-overlap-by-ownership"),
|
||||
},
|
||||
{
|
||||
name: "config file with file string legacy option",
|
||||
cfg: "test-fixtures/scan-configs/with-file-string.yaml",
|
||||
cfg: "testdata/scan-configs/with-file-string.yaml",
|
||||
wantErr: assertErrorContains("outputs"),
|
||||
},
|
||||
{
|
||||
name: "config file with file section",
|
||||
cfg: "test-fixtures/scan-configs/with-file-section.yaml",
|
||||
cfg: "testdata/scan-configs/with-file-section.yaml",
|
||||
},
|
||||
{
|
||||
name: "config file with base-path legacy option",
|
||||
cfg: "test-fixtures/scan-configs/with-base-path.yaml",
|
||||
cfg: "testdata/scan-configs/with-base-path.yaml",
|
||||
wantErr: assertErrorContains("source.base-path"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/anchore/fangs"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/cmd/syft/internal/options"
|
||||
"github.com/anchore/syft/cmd/syft/internal/split"
|
||||
"github.com/anchore/syft/cmd/syft/internal/ui"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
const (
|
||||
splitExample = ` {{.appName}} {{.command}} image.sbom.json split SBOM into one file per package
|
||||
{{.appName}} {{.command}} image.sbom.json --pkg musl split only the musl package
|
||||
{{.appName}} {{.command}} image.sbom.json --pkg musl --dir /tmp write output to /tmp directory
|
||||
{{.appName}} {{.command}} image.sbom.json --drop source --drop location:fsid drop source and filesystem IDs
|
||||
`
|
||||
)
|
||||
|
||||
// SplitOptions defines the options for the split command
|
||||
type SplitOptions struct {
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
Packages []string `yaml:"packages" json:"packages" mapstructure:"packages"`
|
||||
OutputDir string `yaml:"output-dir" json:"output-dir" mapstructure:"output-dir"`
|
||||
Drop []string `yaml:"drop" json:"drop" mapstructure:"drop"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
fangs.FieldDescriber
|
||||
} = (*SplitOptions)(nil)
|
||||
|
||||
func (o *SplitOptions) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringArrayVarP(&o.Packages, "pkg", "p", "package(s) to split (can be specified multiple times)")
|
||||
flags.StringVarP(&o.OutputDir, "dir", "d", "output directory for split SBOMs (default: current directory)")
|
||||
flags.StringArrayVarP(&o.Drop, "drop", "", "drop options to apply (source, descriptor, distro, pkg:*, file:*, location:*, all)")
|
||||
}
|
||||
|
||||
func (o *SplitOptions) DescribeFields(descriptions fangs.FieldDescriptionSet) {
|
||||
descriptions.Add(&o.Packages, "package queries to match for splitting (ID, PURL, name, or name@version)")
|
||||
descriptions.Add(&o.OutputDir, "output directory for split SBOM files")
|
||||
descriptions.Add(&o.Drop, "options for dropping SBOM sections (source, descriptor, distro, pkg:licenses, pkg:metadata.files, file:metadata, file:digests, file:executable, file:unknowns, file:licenses, file:contents, location:fsid, location:non-primary-evidence, all)")
|
||||
}
|
||||
|
||||
// Split creates the split command
|
||||
func Split(app clio.Application) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
opts := &SplitOptions{
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
OutputDir: ".",
|
||||
}
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
Use: "split [SOURCE-SBOM] [flags]",
|
||||
Short: "Split an SBOM into separate SBOMs per package",
|
||||
Long: `[Experimental] Split a syft-format SBOM into one or more SBOMs, one per package.
|
||||
Each output SBOM contains only the target package, its related packages (connected via
|
||||
ownership-by-file-overlap and evident-by relationships), and their associated files.
|
||||
|
||||
If no --pkg flags are specified, creates one SBOM file per package in the source SBOM.
|
||||
If --pkg flags are specified, only creates SBOM files for the matching packages.
|
||||
|
||||
Package matching (in order of precedence):
|
||||
1. Exact package ID
|
||||
2. Exact PURL or PURL prefix
|
||||
3. Case-insensitive package name
|
||||
4. name@version format
|
||||
|
||||
Drop options:
|
||||
source Drop the source object entirely
|
||||
descriptor Drop the descriptor object
|
||||
distro Drop the distro (Linux distribution) object
|
||||
pkg:licenses Drop package licenses
|
||||
pkg:metadata.files Drop files from package metadata (for FileOwner types)
|
||||
file:metadata Drop file metadata (size, permissions, etc.)
|
||||
file:digests Drop file digests
|
||||
file:executable Drop executable metadata
|
||||
file:unknowns Drop unknown file entries
|
||||
file:licenses Drop file-level licenses
|
||||
file:contents Drop file contents
|
||||
location:fsid Clear FileSystemID from all coordinates
|
||||
location:non-primary-evidence Drop locations without "evidence": "primary" annotation
|
||||
all Apply all drop options above`,
|
||||
Example: internal.Tprintf(splitExample, map[string]interface{}{
|
||||
"appName": id.Name,
|
||||
"command": "split",
|
||||
}),
|
||||
Args: validateSplitArgs,
|
||||
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
restoreStdout := ui.CaptureStdoutToTraceLog()
|
||||
defer restoreStdout()
|
||||
|
||||
return RunSplit(opts, args[0])
|
||||
},
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func validateSplitArgs(cmd *cobra.Command, args []string) error {
|
||||
return validateArgs(cmd, args, "an SBOM argument is required")
|
||||
}
|
||||
|
||||
// RunSplit executes the split operation
|
||||
func RunSplit(opts *SplitOptions, userInput string) error {
|
||||
log.Warn("split is an experimental feature, run `syft split -h` for help")
|
||||
|
||||
// validate drop options
|
||||
for _, d := range opts.Drop {
|
||||
if !split.ValidDropOption(d) {
|
||||
return fmt.Errorf("invalid drop option: %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
// parse drop options
|
||||
dropOpts := split.ParseDropOptions(opts.Drop)
|
||||
dropLocationFSID := split.HasDropLocationFSID(dropOpts)
|
||||
dropNonPrimaryEvidence := split.HasDropLocationNonPrimaryEvidence(dropOpts)
|
||||
|
||||
// read SBOM
|
||||
var reader io.ReadSeekCloser
|
||||
if userInput == "-" {
|
||||
reader = internal.NewBufferedSeeker(os.Stdin)
|
||||
} else {
|
||||
f, err := os.Open(userInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open SBOM file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
reader = f
|
||||
}
|
||||
|
||||
s, formatID, _, err := format.Decode(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode SBOM: %w", err)
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return fmt.Errorf("no SBOM produced")
|
||||
}
|
||||
|
||||
// warn if not syft-json format
|
||||
if formatID != syftjson.ID {
|
||||
log.Warnf("input SBOM is not syft-json format (detected: %s), some data may be lost", formatID)
|
||||
}
|
||||
|
||||
// determine target packages
|
||||
var targetPackages []pkg.Package
|
||||
if len(opts.Packages) == 0 {
|
||||
// split all packages
|
||||
targetPackages = s.Artifacts.Packages.Sorted()
|
||||
} else {
|
||||
// match specified packages
|
||||
targetPackages = split.MatchPackages(s.Artifacts.Packages, opts.Packages)
|
||||
if len(targetPackages) == 0 {
|
||||
return fmt.Errorf("no packages matched the specified queries: %v", opts.Packages)
|
||||
}
|
||||
log.Infof("matched %d package(s) for splitting", len(targetPackages))
|
||||
}
|
||||
|
||||
// ensure output directory exists
|
||||
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// split SBOM
|
||||
results := split.Split(*s, targetPackages, dropLocationFSID, dropNonPrimaryEvidence)
|
||||
|
||||
// create encoder
|
||||
encoder, err := syftjson.NewFormatEncoderWithConfig(syftjson.EncoderConfig{
|
||||
Pretty: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create encoder: %w", err)
|
||||
}
|
||||
|
||||
// get fields to remove from JSON output
|
||||
fieldsToRemove := split.GetJSONFieldsToRemove(dropOpts)
|
||||
|
||||
// write output files
|
||||
for _, result := range results {
|
||||
// apply drop options (excluding location:* options which are handled in Split)
|
||||
filteredDropOpts := make([]split.DropOption, 0, len(dropOpts))
|
||||
for _, opt := range dropOpts {
|
||||
if opt != split.DropLocationFSID && opt != split.DropLocationNonPrimaryEvidence {
|
||||
filteredDropOpts = append(filteredDropOpts, opt)
|
||||
}
|
||||
}
|
||||
split.ApplyDropOptions(&result.SBOM, filteredDropOpts)
|
||||
|
||||
// generate output filename using package ID
|
||||
outputFile := filepath.Join(opts.OutputDir, fmt.Sprintf("%s.json", result.TargetPackage.ID()))
|
||||
|
||||
if err := writeSBOMWithFieldRemoval(encoder, result.SBOM, outputFile, fieldsToRemove); err != nil {
|
||||
return fmt.Errorf("failed to write SBOM for package %s: %w", result.TargetPackage.Name, err)
|
||||
}
|
||||
|
||||
log.Infof("wrote %s (%s@%s)", outputFile, result.TargetPackage.Name, result.TargetPackage.Version)
|
||||
}
|
||||
|
||||
log.Infof("split complete: %d SBOM(s) written to %s", len(results), opts.OutputDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSBOMWithFieldRemoval(encoder sbom.FormatEncoder, s sbom.SBOM, outputFile string, fieldsToRemove []string) error {
|
||||
// if no fields to remove, use direct encoding
|
||||
if len(fieldsToRemove) == 0 {
|
||||
f, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
return encoder.Encode(f, s)
|
||||
}
|
||||
|
||||
// encode to buffer first
|
||||
var buf bytes.Buffer
|
||||
if err := encoder.Encode(&buf, s); err != nil {
|
||||
return fmt.Errorf("failed to encode SBOM: %w", err)
|
||||
}
|
||||
|
||||
// parse as generic map to remove fields
|
||||
var doc map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
|
||||
return fmt.Errorf("failed to parse encoded SBOM: %w", err)
|
||||
}
|
||||
|
||||
// remove specified fields
|
||||
for _, field := range fieldsToRemove {
|
||||
delete(doc, field)
|
||||
}
|
||||
|
||||
// re-encode with pretty printing
|
||||
output, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-encode SBOM: %w", err)
|
||||
}
|
||||
|
||||
// write to file
|
||||
if err := os.WriteFile(outputFile, append(output, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunSplit_BasicExecution(t *testing.T) {
|
||||
// use the example SBOM
|
||||
sbomPath := "../../../../examples/decode_sbom/alpine.syft.json"
|
||||
if _, err := os.Stat(sbomPath); os.IsNotExist(err) {
|
||||
t.Skip("example SBOM not found, skipping integration test")
|
||||
}
|
||||
|
||||
// create temporary output directory
|
||||
tmpDir, err := os.MkdirTemp("", "syft-split-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
opts := &SplitOptions{
|
||||
Packages: []string{"alpine-baselayout"},
|
||||
OutputDir: tmpDir,
|
||||
Drop: []string{},
|
||||
}
|
||||
|
||||
err = RunSplit(opts, sbomPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify output files were created
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, entries, "expected at least one output file")
|
||||
|
||||
// verify output file is valid JSON
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, content)
|
||||
// basic JSON validation - should start with {
|
||||
assert.True(t, len(content) > 0 && content[0] == '{', "output should be valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSplit_InvalidDropOption(t *testing.T) {
|
||||
opts := &SplitOptions{
|
||||
Packages: []string{"test"},
|
||||
OutputDir: ".",
|
||||
Drop: []string{"invalid-option"},
|
||||
}
|
||||
|
||||
err := RunSplit(opts, "some-sbom.json")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid drop option")
|
||||
}
|
||||
|
||||
func TestRunSplit_NoMatchingPackages(t *testing.T) {
|
||||
sbomPath := "../../../../examples/decode_sbom/alpine.syft.json"
|
||||
if _, err := os.Stat(sbomPath); os.IsNotExist(err) {
|
||||
t.Skip("example SBOM not found, skipping integration test")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "syft-split-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
opts := &SplitOptions{
|
||||
Packages: []string{"nonexistent-package-xyz"},
|
||||
OutputDir: tmpDir,
|
||||
Drop: []string{},
|
||||
}
|
||||
|
||||
err = RunSplit(opts, sbomPath)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no packages matched")
|
||||
}
|
||||
|
||||
func TestRunSplit_WithDropOptions(t *testing.T) {
|
||||
sbomPath := "../../../../examples/decode_sbom/alpine.syft.json"
|
||||
if _, err := os.Stat(sbomPath); os.IsNotExist(err) {
|
||||
t.Skip("example SBOM not found, skipping integration test")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "syft-split-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
opts := &SplitOptions{
|
||||
Packages: []string{"alpine-baselayout"},
|
||||
OutputDir: tmpDir,
|
||||
Drop: []string{"source", "location:fsid"},
|
||||
}
|
||||
|
||||
err = RunSplit(opts, sbomPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify output file was created
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, entries)
|
||||
}
|
||||
|
||||
func TestRunSplit_FileNotFound(t *testing.T) {
|
||||
opts := &SplitOptions{
|
||||
Packages: []string{"test"},
|
||||
OutputDir: ".",
|
||||
Drop: []string{},
|
||||
}
|
||||
|
||||
err := RunSplit(opts, "/nonexistent/path/sbom.json")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to open SBOM file")
|
||||
}
|
||||
@@ -31,17 +31,16 @@ import (
|
||||
|
||||
type Catalog struct {
|
||||
// high-level cataloger configuration
|
||||
Catalogers []string `yaml:"-" json:"catalogers" mapstructure:"catalogers"` // deprecated and not shown in yaml output
|
||||
DefaultCatalogers []string `yaml:"default-catalogers" json:"default-catalogers" mapstructure:"default-catalogers"`
|
||||
SelectCatalogers []string `yaml:"select-catalogers" json:"select-catalogers" mapstructure:"select-catalogers"`
|
||||
Package packageConfig `yaml:"package" json:"package" mapstructure:"package"`
|
||||
License licenseConfig `yaml:"license" json:"license" mapstructure:"license"`
|
||||
File fileConfig `yaml:"file" json:"file" mapstructure:"file"`
|
||||
Scope string `yaml:"scope" json:"scope" mapstructure:"scope"`
|
||||
Parallelism int `yaml:"parallelism" json:"parallelism" mapstructure:"parallelism"` // the number of catalog workers to run in parallel
|
||||
Relationships relationshipsConfig `yaml:"relationships" json:"relationships" mapstructure:"relationships"`
|
||||
Compliance complianceConfig `yaml:"compliance" json:"compliance" mapstructure:"compliance"`
|
||||
Enrich []string `yaml:"enrich" json:"enrich" mapstructure:"enrich"`
|
||||
CatalogerSelection `yaml:",inline" json:",inline" mapstructure:",squash"`
|
||||
|
||||
Package packageConfig `yaml:"package" json:"package" mapstructure:"package"`
|
||||
License licenseConfig `yaml:"license" json:"license" mapstructure:"license"`
|
||||
File fileConfig `yaml:"file" json:"file" mapstructure:"file"`
|
||||
Scope string `yaml:"scope" json:"scope" mapstructure:"scope"`
|
||||
Parallelism int `yaml:"parallelism" json:"parallelism" mapstructure:"parallelism"` // the number of catalog workers to run in parallel
|
||||
Relationships relationshipsConfig `yaml:"relationships" json:"relationships" mapstructure:"relationships"`
|
||||
Compliance complianceConfig `yaml:"compliance" json:"compliance" mapstructure:"compliance"`
|
||||
Enrich []string `yaml:"enrich" json:"enrich" mapstructure:"enrich"`
|
||||
|
||||
// ecosystem-specific cataloger configuration
|
||||
Dotnet dotnetConfig `yaml:"dotnet" json:"dotnet" mapstructure:"dotnet"`
|
||||
@@ -77,6 +76,8 @@ func DefaultCatalog() Catalog {
|
||||
Package: defaultPackageConfig(),
|
||||
License: defaultLicenseConfig(),
|
||||
LinuxKernel: defaultLinuxKernelConfig(),
|
||||
JavaScript: defaultJavaScriptConfig(),
|
||||
Python: defaultPythonConfig(),
|
||||
Nix: defaultNixConfig(),
|
||||
Dotnet: defaultDotnetConfig(),
|
||||
Golang: defaultGolangConfig(),
|
||||
@@ -188,7 +189,8 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
|
||||
WithFromContents(cfg.Golang.MainModuleVersion.FromContents).
|
||||
WithFromBuildSettings(cfg.Golang.MainModuleVersion.FromBuildSettings).
|
||||
WithFromLDFlags(cfg.Golang.MainModuleVersion.FromLDFlags),
|
||||
),
|
||||
).
|
||||
WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)),
|
||||
JavaScript: javascript.DefaultCatalogerConfig().
|
||||
WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)).
|
||||
WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)).
|
||||
@@ -198,9 +200,10 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
|
||||
},
|
||||
Nix: nix.DefaultConfig().
|
||||
WithCaptureOwnedFiles(cfg.Nix.CaptureOwnedFiles),
|
||||
Python: python.CatalogerConfig{
|
||||
GuessUnpinnedRequirements: cfg.Python.GuessUnpinnedRequirements,
|
||||
},
|
||||
Python: python.DefaultCatalogerConfig().
|
||||
WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.Python), cfg.Python.SearchRemoteLicenses)).
|
||||
WithPypiBaseURL(cfg.Python.PypiBaseURL).
|
||||
WithGuessUnpinnedRequirements(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.Python), cfg.Python.GuessUnpinnedRequirements)),
|
||||
JavaArchive: java.DefaultArchiveCatalogerConfig().
|
||||
WithUseMavenLocalRepository(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.Java, task.Maven), cfg.Java.UseMavenLocalRepository)).
|
||||
WithMavenLocalRepositoryDir(cfg.Java.MavenLocalRepositoryDir).
|
||||
@@ -228,26 +231,9 @@ func (cfg *Catalog) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringArrayVarP(&cfg.Exclusions, "exclude", "",
|
||||
"exclude paths from being scanned using a glob expression")
|
||||
|
||||
flags.StringArrayVarP(&cfg.Catalogers, "catalogers", "",
|
||||
"enable one or more package catalogers")
|
||||
|
||||
flags.IntVarP(&cfg.Parallelism, "parallelism", "",
|
||||
"number of cataloger workers to run in parallel")
|
||||
|
||||
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
|
||||
if err := pfp.PFlagSet().MarkDeprecated("catalogers", "use: override-default-catalogers and select-catalogers"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
panic("unable to mark flags as deprecated")
|
||||
}
|
||||
|
||||
flags.StringArrayVarP(&cfg.DefaultCatalogers, "override-default-catalogers", "",
|
||||
"set the base set of catalogers to use (defaults to 'image' or 'directory' depending on the scan source)")
|
||||
|
||||
flags.StringArrayVarP(&cfg.SelectCatalogers, "select-catalogers", "",
|
||||
"add, remove, and filter the catalogers to be used")
|
||||
|
||||
flags.StringArrayVarP(&cfg.Enrich, "enrich", "",
|
||||
fmt.Sprintf("enable package data enrichment from local and online sources (options: %s)", strings.Join(publicisedEnrichmentOptions, ", ")))
|
||||
|
||||
@@ -274,22 +260,8 @@ Available options are: %s`, strings.Join(publicisedEnrichmentOptions, ", ")))
|
||||
}
|
||||
|
||||
func (cfg *Catalog) PostLoad() error {
|
||||
usingLegacyCatalogers := len(cfg.Catalogers) > 0
|
||||
usingNewCatalogers := len(cfg.DefaultCatalogers) > 0 || len(cfg.SelectCatalogers) > 0
|
||||
|
||||
if usingLegacyCatalogers && usingNewCatalogers {
|
||||
return fmt.Errorf("cannot use both 'catalogers' and 'select-catalogers'/'default-catalogers' flags")
|
||||
}
|
||||
|
||||
cfg.From = Flatten(cfg.From)
|
||||
|
||||
cfg.Catalogers = Flatten(cfg.Catalogers)
|
||||
cfg.DefaultCatalogers = Flatten(cfg.DefaultCatalogers)
|
||||
cfg.SelectCatalogers = Flatten(cfg.SelectCatalogers)
|
||||
cfg.Enrich = Flatten(cfg.Enrich)
|
||||
|
||||
// for backwards compatibility
|
||||
cfg.DefaultCatalogers = append(cfg.DefaultCatalogers, cfg.Catalogers...)
|
||||
cfg.Enrich = FlattenAndSort(cfg.Enrich)
|
||||
|
||||
s := source.ParseScope(cfg.Scope)
|
||||
if s == source.UnknownScope {
|
||||
@@ -311,6 +283,11 @@ func Flatten(commaSeparatedEntries []string) []string {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FlattenAndSort(commaSeparatedEntries []string) []string {
|
||||
out := Flatten(commaSeparatedEntries)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -320,6 +297,7 @@ var publicisedEnrichmentOptions = []string{
|
||||
task.Golang,
|
||||
task.Java,
|
||||
task.JavaScript,
|
||||
task.Python,
|
||||
}
|
||||
|
||||
func enrichmentEnabled(enrichDirectives []string, features ...string) *bool {
|
||||
|
||||
@@ -8,55 +8,12 @@ import (
|
||||
)
|
||||
|
||||
func TestCatalog_PostLoad(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
options Catalog
|
||||
assert func(t *testing.T, options Catalog)
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "mutually exclusive cataloger flags (cat / def-cat)",
|
||||
options: Catalog{
|
||||
Catalogers: []string{"foo,bar", "42"},
|
||||
DefaultCatalogers: []string{"some,thing"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "mutually exclusive cataloger flags (cat / sel-cat)",
|
||||
options: Catalog{
|
||||
Catalogers: []string{"foo,bar", "42"},
|
||||
SelectCatalogers: []string{"some,thing"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "allow old cataloger flags",
|
||||
options: Catalog{
|
||||
Catalogers: []string{"foo,bar"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
assert: func(t *testing.T, options Catalog) {
|
||||
assert.Equal(t, []string{"bar", "foo"}, options.DefaultCatalogers) // note: sorted order
|
||||
assert.Equal(t, []string{"bar", "foo"}, options.Catalogers) // note: sorted order
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allow new cataloger flags",
|
||||
options: Catalog{
|
||||
SelectCatalogers: []string{"foo,bar", "42"},
|
||||
DefaultCatalogers: []string{"some,thing"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
assert: func(t *testing.T, options Catalog) {
|
||||
assert.Equal(t, []string{"42", "bar", "foo"}, options.SelectCatalogers) // note: sorted order
|
||||
assert.Equal(t, []string{"some", "thing"}, options.DefaultCatalogers) // note: sorted order
|
||||
assert.Empty(t, options.Catalogers)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "must have package overlap flag when pruning binaries by overlap",
|
||||
options: Catalog{
|
||||
@@ -79,6 +36,98 @@ func TestCatalog_PostLoad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatten(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "preserves order of comma-separated values",
|
||||
input: []string{"registry,docker,oci-dir"},
|
||||
expected: []string{"registry", "docker", "oci-dir"},
|
||||
},
|
||||
{
|
||||
name: "preserves order across multiple entries",
|
||||
input: []string{"registry,docker", "oci-dir"},
|
||||
expected: []string{"registry", "docker", "oci-dir"},
|
||||
},
|
||||
{
|
||||
name: "trims whitespace",
|
||||
input: []string{" registry , docker ", " oci-dir "},
|
||||
expected: []string{"registry", "docker", "oci-dir"},
|
||||
},
|
||||
{
|
||||
name: "handles single value",
|
||||
input: []string{"registry"},
|
||||
expected: []string{"registry"},
|
||||
},
|
||||
{
|
||||
name: "handles empty input",
|
||||
input: []string{},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "preserves reverse alphabetical order",
|
||||
input: []string{"zebra,yankee,xray"},
|
||||
expected: []string{"zebra", "yankee", "xray"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Flatten(tt.input)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenAndSort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "sorts comma-separated values",
|
||||
input: []string{"registry,docker,oci-dir"},
|
||||
expected: []string{"docker", "oci-dir", "registry"},
|
||||
},
|
||||
{
|
||||
name: "sorts across multiple entries",
|
||||
input: []string{"registry,docker", "oci-dir"},
|
||||
expected: []string{"docker", "oci-dir", "registry"},
|
||||
},
|
||||
{
|
||||
name: "trims whitespace and sorts",
|
||||
input: []string{" registry , docker ", " oci-dir "},
|
||||
expected: []string{"docker", "oci-dir", "registry"},
|
||||
},
|
||||
{
|
||||
name: "handles single value",
|
||||
input: []string{"registry"},
|
||||
expected: []string{"registry"},
|
||||
},
|
||||
{
|
||||
name: "handles empty input",
|
||||
input: []string{},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "sorts reverse alphabetical order",
|
||||
input: []string{"zebra,yankee,xray"},
|
||||
expected: []string{"xray", "yankee", "zebra"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FlattenAndSort(tt.input)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_enrichmentEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
directives string
|
||||
@@ -139,7 +188,7 @@ func Test_enrichmentEnabled(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.directives, func(t *testing.T) {
|
||||
got := enrichmentEnabled(Flatten([]string{test.directives}), test.test)
|
||||
got := enrichmentEnabled(FlattenAndSort([]string{test.directives}), test.test)
|
||||
assert.Equal(t, test.expected, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/fangs"
|
||||
)
|
||||
|
||||
type CatalogerSelection struct {
|
||||
// high-level cataloger configuration
|
||||
Catalogers []string `yaml:"-" json:"catalogers" mapstructure:"catalogers"` // deprecated and not shown in yaml output
|
||||
DefaultCatalogers []string `yaml:"default-catalogers" json:"default-catalogers" mapstructure:"default-catalogers"`
|
||||
SelectCatalogers []string `yaml:"select-catalogers" json:"select-catalogers" mapstructure:"select-catalogers"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
clio.PostLoader
|
||||
} = (*CatalogerSelection)(nil)
|
||||
|
||||
func (cfg *CatalogerSelection) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringArrayVarP(&cfg.Catalogers, "catalogers", "",
|
||||
"enable one or more package catalogers")
|
||||
|
||||
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
|
||||
if err := pfp.PFlagSet().MarkDeprecated("catalogers", "use: override-default-catalogers and select-catalogers"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
panic("unable to mark flags as deprecated")
|
||||
}
|
||||
|
||||
flags.StringArrayVarP(&cfg.DefaultCatalogers, "override-default-catalogers", "",
|
||||
"set the base set of catalogers to use (defaults to 'image' or 'directory' depending on the scan source)")
|
||||
|
||||
flags.StringArrayVarP(&cfg.SelectCatalogers, "select-catalogers", "",
|
||||
"add, remove, and filter the catalogers to be used")
|
||||
}
|
||||
|
||||
func (cfg *CatalogerSelection) PostLoad() error {
|
||||
usingLegacyCatalogers := len(cfg.Catalogers) > 0
|
||||
usingNewCatalogers := len(cfg.DefaultCatalogers) > 0 || len(cfg.SelectCatalogers) > 0
|
||||
|
||||
if usingLegacyCatalogers && usingNewCatalogers {
|
||||
return fmt.Errorf("cannot use both 'catalogers' and 'select-catalogers'/'default-catalogers' flags %q | %q %q", cfg.Catalogers, cfg.SelectCatalogers, cfg.DefaultCatalogers)
|
||||
}
|
||||
|
||||
cfg.Catalogers = FlattenAndSort(cfg.Catalogers)
|
||||
cfg.DefaultCatalogers = FlattenAndSort(cfg.DefaultCatalogers)
|
||||
cfg.SelectCatalogers = FlattenAndSort(cfg.SelectCatalogers)
|
||||
|
||||
// for backwards compatibility
|
||||
cfg.DefaultCatalogers = append(cfg.DefaultCatalogers, cfg.Catalogers...)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCatalogerSelection_PostLoad(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
options CatalogerSelection
|
||||
assert func(t *testing.T, options CatalogerSelection)
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "mutually exclusive cataloger flags (cat / def-cat)",
|
||||
options: CatalogerSelection{
|
||||
Catalogers: []string{"foo,bar", "42"},
|
||||
DefaultCatalogers: []string{"some,thing"},
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "mutually exclusive cataloger flags (cat / sel-cat)",
|
||||
options: CatalogerSelection{
|
||||
Catalogers: []string{"foo,bar", "42"},
|
||||
SelectCatalogers: []string{"some,thing"},
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "allow old cataloger flags",
|
||||
options: CatalogerSelection{
|
||||
Catalogers: []string{"foo,bar"},
|
||||
},
|
||||
assert: func(t *testing.T, options CatalogerSelection) {
|
||||
assert.Equal(t, []string{"bar", "foo"}, options.DefaultCatalogers) // note: sorted order
|
||||
assert.Equal(t, []string{"bar", "foo"}, options.Catalogers) // note: sorted order
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allow new cataloger flags",
|
||||
options: CatalogerSelection{
|
||||
SelectCatalogers: []string{"foo,bar", "42"},
|
||||
DefaultCatalogers: []string{"some,thing"},
|
||||
},
|
||||
assert: func(t *testing.T, options CatalogerSelection) {
|
||||
assert.Equal(t, []string{"42", "bar", "foo"}, options.SelectCatalogers) // note: sorted order
|
||||
assert.Equal(t, []string{"some", "thing"}, options.DefaultCatalogers) // note: sorted order
|
||||
assert.Empty(t, options.Catalogers)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = assert.NoError
|
||||
}
|
||||
tt.wantErr(t, tt.options.PostLoad(), fmt.Sprintf("PostLoad()"))
|
||||
if tt.assert != nil {
|
||||
tt.assert(t, tt.options)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ type dotnetConfig struct {
|
||||
PropagateDLLClaimsToParents bool `mapstructure:"propagate-dll-claims-to-parents" json:"propagate-dll-claims-to-parents" yaml:"propagate-dll-claims-to-parents"`
|
||||
|
||||
RelaxDLLClaimsWhenBundlingDetected bool `mapstructure:"relax-dll-claims-when-bundling-detected" json:"relax-dll-claims-when-bundling-detected" yaml:"relax-dll-claims-when-bundling-detected"`
|
||||
|
||||
ExcludeProjectReferences bool `mapstructure:"exclude-project-references" json:"exclude-project-references" yaml:"exclude-project-references"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
@@ -24,6 +26,7 @@ func (o *dotnetConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
|
||||
descriptions.Add(&o.DepPackagesMustClaimDLL, `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.`)
|
||||
descriptions.Add(&o.PropagateDLLClaimsToParents, `treat DLL claims or on-disk evidence for child packages as DLL claims or on-disk evidence for any parent package`)
|
||||
descriptions.Add(&o.RelaxDLLClaimsWhenBundlingDetected, `show all packages from the deps.json if bundling tooling is present as a dependency (e.g. ILRepack)`)
|
||||
descriptions.Add(&o.ExcludeProjectReferences, `exclude packages with type "project" from deps.json output (these are internal project references, not NuGet packages)`)
|
||||
}
|
||||
|
||||
func defaultDotnetConfig() dotnetConfig {
|
||||
@@ -33,5 +36,6 @@ func defaultDotnetConfig() dotnetConfig {
|
||||
DepPackagesMustClaimDLL: def.DepPackagesMustClaimDLL,
|
||||
PropagateDLLClaimsToParents: def.PropagateDLLClaimsToParents,
|
||||
RelaxDLLClaimsWhenBundlingDetected: def.RelaxDLLClaimsWhenBundlingDetected,
|
||||
ExcludeProjectReferences: def.ExcludeProjectReferences,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type golangConfig struct {
|
||||
Proxy string `json:"proxy" yaml:"proxy" mapstructure:"proxy"`
|
||||
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"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
@@ -37,6 +38,7 @@ if unset this defaults to $GONOPROXY`)
|
||||
descriptions.Add(&o.MainModuleVersion, `the go main module version discovered from binaries built with the go compiler will
|
||||
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.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
|
||||
(e.g. v0.0.0-20220308212642-53e6d0aaf6fb) when a more accurate version cannot be found otherwise`)
|
||||
@@ -64,5 +66,6 @@ func defaultGolangConfig() golangConfig {
|
||||
FromContents: def.MainModuleVersion.FromContents,
|
||||
FromBuildSettings: def.MainModuleVersion.FromBuildSettings,
|
||||
},
|
||||
UsePackagesLib: nil, // this defaults to true, which is the API default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/clio"
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/javascript"
|
||||
)
|
||||
|
||||
type javaScriptConfig struct {
|
||||
SearchRemoteLicenses *bool `json:"search-remote-licenses" yaml:"search-remote-licenses" mapstructure:"search-remote-licenses"`
|
||||
@@ -12,6 +15,24 @@ var _ interface {
|
||||
clio.FieldDescriber
|
||||
} = (*javaScriptConfig)(nil)
|
||||
|
||||
func defaultJavaScriptConfig() javaScriptConfig {
|
||||
def := javascript.DefaultCatalogerConfig()
|
||||
var includeDevDependencies *bool
|
||||
if def.IncludeDevDependencies {
|
||||
includeDevDependencies = &def.IncludeDevDependencies
|
||||
}
|
||||
|
||||
var searchRemoteLicenses *bool
|
||||
if def.SearchRemoteLicenses {
|
||||
searchRemoteLicenses = &def.SearchRemoteLicenses
|
||||
}
|
||||
return javaScriptConfig{
|
||||
NpmBaseURL: def.NPMBaseURL,
|
||||
SearchRemoteLicenses: searchRemoteLicenses,
|
||||
IncludeDevDependencies: includeDevDependencies,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *javaScriptConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
|
||||
descriptions.Add(&o.SearchRemoteLicenses, `enables Syft to use the network to fill in more detailed license information`)
|
||||
descriptions.Add(&o.NpmBaseURL, `base NPM url to use`)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/clio"
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/kernel"
|
||||
)
|
||||
|
||||
type linuxKernelConfig struct {
|
||||
CatalogModules bool `json:"catalog-modules" yaml:"catalog-modules" mapstructure:"catalog-modules"`
|
||||
}
|
||||
|
||||
func defaultLinuxKernelConfig() linuxKernelConfig {
|
||||
def := kernel.DefaultLinuxKernelCatalogerConfig()
|
||||
return linuxKernelConfig{
|
||||
CatalogModules: true,
|
||||
CatalogModules: def.CatalogModules,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/clio"
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/python"
|
||||
)
|
||||
|
||||
type pythonConfig struct {
|
||||
GuessUnpinnedRequirements bool `json:"guess-unpinned-requirements" yaml:"guess-unpinned-requirements" mapstructure:"guess-unpinned-requirements"`
|
||||
SearchRemoteLicenses *bool `json:"search-remote-licenses" yaml:"search-remote-licenses" mapstructure:"search-remote-licenses"`
|
||||
PypiBaseURL string `json:"pypi-base-url" yaml:"pypi-base-url" mapstructure:"pypi-base-url"`
|
||||
GuessUnpinnedRequirements *bool `json:"guess-unpinned-requirements" yaml:"guess-unpinned-requirements" mapstructure:"guess-unpinned-requirements"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FieldDescriber
|
||||
} = (*pythonConfig)(nil)
|
||||
|
||||
func defaultPythonConfig() pythonConfig {
|
||||
def := python.DefaultCatalogerConfig()
|
||||
return pythonConfig{
|
||||
GuessUnpinnedRequirements: &def.GuessUnpinnedRequirements,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *pythonConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
|
||||
descriptions.Add(&o.SearchRemoteLicenses, `enables Syft to use the network to fill in more detailed license information`)
|
||||
descriptions.Add(&o.PypiBaseURL, `base Pypi url to use`)
|
||||
descriptions.Add(&o.GuessUnpinnedRequirements, `when running across entries in requirements.txt that do not specify a specific version
|
||||
(e.g. "sqlalchemy >= 1.0.0, <= 2.0.0, != 3.0.0, <= 3.0.0"), attempt to guess what the version could
|
||||
be based on the version requirements specified (e.g. "1.0.0"). When enabled the lowest expressible version
|
||||
|
||||
@@ -122,7 +122,7 @@ func formatVersionOptions(nameVersionPairs []string) string {
|
||||
for _, name := range sortedAvailableFormats {
|
||||
s.WriteString("\n")
|
||||
|
||||
s.WriteString(fmt.Sprintf(" - %s", name))
|
||||
fmt.Fprintf(&s, " - %s", name)
|
||||
|
||||
if len(availableVersions[name]) > 0 {
|
||||
s.WriteString(" @ ")
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
// DropOption represents a drop option for filtering SBOM contents
|
||||
type DropOption string
|
||||
|
||||
const (
|
||||
DropSource DropOption = "source"
|
||||
DropDescriptor DropOption = "descriptor"
|
||||
DropDistro DropOption = "distro"
|
||||
DropPkgLicenses DropOption = "pkg:licenses"
|
||||
DropPkgMetadataFiles DropOption = "pkg:metadata.files"
|
||||
DropFileMetadata DropOption = "file:metadata"
|
||||
DropFileDigests DropOption = "file:digests"
|
||||
DropFileExecutable DropOption = "file:executable"
|
||||
DropFileUnknowns DropOption = "file:unknowns"
|
||||
DropFileLicenses DropOption = "file:licenses"
|
||||
DropFileContents DropOption = "file:contents"
|
||||
DropLocationFSID DropOption = "location:fsid"
|
||||
DropLocationNonPrimaryEvidence DropOption = "location:non-primary-evidence"
|
||||
DropAll DropOption = "all"
|
||||
)
|
||||
|
||||
// AllDropOptions returns all valid drop options (excluding "all")
|
||||
func AllDropOptions() []DropOption {
|
||||
return []DropOption{
|
||||
DropSource,
|
||||
DropDescriptor,
|
||||
DropDistro,
|
||||
DropPkgLicenses,
|
||||
DropPkgMetadataFiles,
|
||||
DropFileMetadata,
|
||||
DropFileDigests,
|
||||
DropFileExecutable,
|
||||
DropFileUnknowns,
|
||||
DropFileLicenses,
|
||||
DropFileContents,
|
||||
DropLocationFSID,
|
||||
DropLocationNonPrimaryEvidence,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseDropOptions parses string values into DropOption values
|
||||
func ParseDropOptions(values []string) []DropOption {
|
||||
var opts []DropOption
|
||||
for _, v := range values {
|
||||
opt := DropOption(strings.ToLower(strings.TrimSpace(v)))
|
||||
if opt == DropAll {
|
||||
return AllDropOptions()
|
||||
}
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// ApplyDropOptions applies the specified drop options to the SBOM
|
||||
func ApplyDropOptions(s *sbom.SBOM, opts []DropOption) {
|
||||
if s == nil || len(opts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
switch opt {
|
||||
case DropSource:
|
||||
s.Source = source.Description{}
|
||||
case DropDescriptor:
|
||||
s.Descriptor = sbom.Descriptor{}
|
||||
case DropDistro:
|
||||
s.Artifacts.LinuxDistribution = nil
|
||||
case DropPkgLicenses:
|
||||
clearPackageLicenses(s)
|
||||
case DropPkgMetadataFiles:
|
||||
clearPackageMetadataFiles(s)
|
||||
case DropFileMetadata:
|
||||
s.Artifacts.FileMetadata = nil
|
||||
case DropFileDigests:
|
||||
s.Artifacts.FileDigests = nil
|
||||
case DropFileExecutable:
|
||||
s.Artifacts.Executables = nil
|
||||
case DropFileUnknowns:
|
||||
s.Artifacts.Unknowns = nil
|
||||
case DropFileLicenses:
|
||||
s.Artifacts.FileLicenses = nil
|
||||
case DropFileContents:
|
||||
s.Artifacts.FileContents = nil
|
||||
case DropLocationFSID:
|
||||
clearFileSystemIDs(s)
|
||||
case DropLocationNonPrimaryEvidence:
|
||||
clearNonPrimaryEvidenceLocations(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetJSONFieldsToRemove returns the JSON field names that should be completely removed from output
|
||||
func GetJSONFieldsToRemove(opts []DropOption) []string {
|
||||
var fields []string
|
||||
for _, opt := range opts {
|
||||
switch opt {
|
||||
case DropSource:
|
||||
fields = append(fields, "source")
|
||||
case DropDescriptor:
|
||||
fields = append(fields, "descriptor")
|
||||
case DropDistro:
|
||||
fields = append(fields, "distro")
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// clearFileSystemIDs clears FileSystemID from all coordinates in file artifacts and relationships.
|
||||
// Note: package locations are handled separately in the splitter when creating new packages.
|
||||
func clearFileSystemIDs(s *sbom.SBOM) {
|
||||
// clear from file metadata
|
||||
if s.Artifacts.FileMetadata != nil {
|
||||
newMetadata := make(map[file.Coordinates]file.Metadata)
|
||||
for coord, meta := range s.Artifacts.FileMetadata {
|
||||
newCoord := file.Coordinates{RealPath: coord.RealPath}
|
||||
newMetadata[newCoord] = meta
|
||||
}
|
||||
s.Artifacts.FileMetadata = newMetadata
|
||||
}
|
||||
|
||||
// clear from file digests
|
||||
if s.Artifacts.FileDigests != nil {
|
||||
newDigests := make(map[file.Coordinates][]file.Digest)
|
||||
for coord, digests := range s.Artifacts.FileDigests {
|
||||
newCoord := file.Coordinates{RealPath: coord.RealPath}
|
||||
newDigests[newCoord] = digests
|
||||
}
|
||||
s.Artifacts.FileDigests = newDigests
|
||||
}
|
||||
|
||||
// clear from file contents
|
||||
if s.Artifacts.FileContents != nil {
|
||||
newContents := make(map[file.Coordinates]string)
|
||||
for coord, contents := range s.Artifacts.FileContents {
|
||||
newCoord := file.Coordinates{RealPath: coord.RealPath}
|
||||
newContents[newCoord] = contents
|
||||
}
|
||||
s.Artifacts.FileContents = newContents
|
||||
}
|
||||
|
||||
// clear from file licenses
|
||||
if s.Artifacts.FileLicenses != nil {
|
||||
newLicenses := make(map[file.Coordinates][]file.License)
|
||||
for coord, licenses := range s.Artifacts.FileLicenses {
|
||||
newCoord := file.Coordinates{RealPath: coord.RealPath}
|
||||
newLicenses[newCoord] = licenses
|
||||
}
|
||||
s.Artifacts.FileLicenses = newLicenses
|
||||
}
|
||||
|
||||
// clear from executables
|
||||
if s.Artifacts.Executables != nil {
|
||||
newExec := make(map[file.Coordinates]file.Executable)
|
||||
for coord, exec := range s.Artifacts.Executables {
|
||||
newCoord := file.Coordinates{RealPath: coord.RealPath}
|
||||
newExec[newCoord] = exec
|
||||
}
|
||||
s.Artifacts.Executables = newExec
|
||||
}
|
||||
|
||||
// clear from unknowns
|
||||
if s.Artifacts.Unknowns != nil {
|
||||
newUnknowns := make(map[file.Coordinates][]string)
|
||||
for coord, unknowns := range s.Artifacts.Unknowns {
|
||||
newCoord := file.Coordinates{RealPath: coord.RealPath}
|
||||
newUnknowns[newCoord] = unknowns
|
||||
}
|
||||
s.Artifacts.Unknowns = newUnknowns
|
||||
}
|
||||
|
||||
// clear from relationships that reference file coordinates
|
||||
newRelationships := make([]artifact.Relationship, 0, len(s.Relationships))
|
||||
for _, rel := range s.Relationships {
|
||||
newRel := rel
|
||||
|
||||
if coord, ok := rel.From.(file.Coordinates); ok {
|
||||
newRel.From = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
if coord, ok := rel.To.(file.Coordinates); ok {
|
||||
newRel.To = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
|
||||
newRelationships = append(newRelationships, newRel)
|
||||
}
|
||||
s.Relationships = newRelationships
|
||||
}
|
||||
|
||||
// clearPackageLicenses removes licenses from all packages in the SBOM
|
||||
func clearPackageLicenses(s *sbom.SBOM) {
|
||||
if s.Artifacts.Packages == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
p.Licenses = pkg.NewLicenseSet()
|
||||
s.Artifacts.Packages.Delete(p.ID())
|
||||
s.Artifacts.Packages.Add(p)
|
||||
}
|
||||
}
|
||||
|
||||
// clearNonPrimaryEvidenceLocations removes locations that don't have "evidence": "primary" annotation
|
||||
func clearNonPrimaryEvidenceLocations(s *sbom.SBOM) {
|
||||
if s.Artifacts.Packages == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
newLocations := file.NewLocationSet()
|
||||
for _, loc := range p.Locations.ToSlice() {
|
||||
if loc.Annotations != nil && loc.Annotations["evidence"] == "primary" {
|
||||
newLocations.Add(loc)
|
||||
}
|
||||
}
|
||||
p.Locations = newLocations
|
||||
s.Artifacts.Packages.Delete(p.ID())
|
||||
s.Artifacts.Packages.Add(p)
|
||||
}
|
||||
}
|
||||
|
||||
// clearPackageMetadataFiles clears the Files field from any package metadata that implements FileOwner
|
||||
func clearPackageMetadataFiles(s *sbom.SBOM) {
|
||||
if s.Artifacts.Packages == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
if p.Metadata == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
newMetadata := clearMetadataFiles(p.Metadata)
|
||||
if newMetadata != nil {
|
||||
p.Metadata = newMetadata
|
||||
s.Artifacts.Packages.Delete(p.ID())
|
||||
s.Artifacts.Packages.Add(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearMetadataFiles returns a copy of the metadata with Files field cleared if it implements FileOwner.
|
||||
// Returns nil if the metadata type is not recognized as a FileOwner implementer.
|
||||
func clearMetadataFiles(metadata any) any {
|
||||
switch m := metadata.(type) {
|
||||
case pkg.ApkDBEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.RpmDBEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.DpkgDBEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.AlpmDBEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.PortageEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.NixStoreEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.PythonPackage:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.CondaMetaPackage:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.BitnamiSBOMEntry:
|
||||
m.Files = nil
|
||||
return m
|
||||
case pkg.JavaVMInstallation:
|
||||
m.Files = nil
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidDropOption checks if a string is a valid drop option
|
||||
func ValidDropOption(s string) bool {
|
||||
opt := DropOption(strings.ToLower(strings.TrimSpace(s)))
|
||||
if opt == DropAll {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(AllDropOptions(), opt)
|
||||
}
|
||||
|
||||
// HasDropLocationFSID checks if the drop options include location:fsid
|
||||
func HasDropLocationFSID(opts []DropOption) bool {
|
||||
return slices.Contains(opts, DropLocationFSID)
|
||||
}
|
||||
|
||||
// HasDropLocationNonPrimaryEvidence checks if the drop options include location:non-primary-evidence
|
||||
func HasDropLocationNonPrimaryEvidence(opts []DropOption) bool {
|
||||
return slices.Contains(opts, DropLocationNonPrimaryEvidence)
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func TestParseDropOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
want []DropOption
|
||||
}{
|
||||
{
|
||||
name: "single option",
|
||||
values: []string{"source"},
|
||||
want: []DropOption{DropSource},
|
||||
},
|
||||
{
|
||||
name: "multiple options",
|
||||
values: []string{"source", "descriptor", "file:digests"},
|
||||
want: []DropOption{DropSource, DropDescriptor, DropFileDigests},
|
||||
},
|
||||
{
|
||||
name: "all option expands",
|
||||
values: []string{"all"},
|
||||
want: AllDropOptions(),
|
||||
},
|
||||
{
|
||||
name: "case insensitive",
|
||||
values: []string{"SOURCE", "File:Digests"},
|
||||
want: []DropOption{DropSource, DropFileDigests},
|
||||
},
|
||||
{
|
||||
name: "with whitespace",
|
||||
values: []string{" source ", " location:fsid"},
|
||||
want: []DropOption{DropSource, DropLocationFSID},
|
||||
},
|
||||
{
|
||||
name: "empty values",
|
||||
values: []string{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseDropOptions(tt.values)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDropOption(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "valid source", input: "source", want: true},
|
||||
{name: "valid descriptor", input: "descriptor", want: true},
|
||||
{name: "valid distro", input: "distro", want: true},
|
||||
{name: "valid file:metadata", input: "file:metadata", want: true},
|
||||
{name: "valid file:digests", input: "file:digests", want: true},
|
||||
{name: "valid file:executable", input: "file:executable", want: true},
|
||||
{name: "valid file:unknowns", input: "file:unknowns", want: true},
|
||||
{name: "valid file:licenses", input: "file:licenses", want: true},
|
||||
{name: "valid file:contents", input: "file:contents", want: true},
|
||||
{name: "valid location:fsid", input: "location:fsid", want: true},
|
||||
{name: "valid location:non-primary-evidence", input: "location:non-primary-evidence", want: true},
|
||||
{name: "valid pkg:licenses", input: "pkg:licenses", want: true},
|
||||
{name: "valid pkg:metadata.files", input: "pkg:metadata.files", want: true},
|
||||
{name: "valid all", input: "all", want: true},
|
||||
{name: "case insensitive", input: "SOURCE", want: true},
|
||||
{name: "invalid option", input: "invalid", want: false},
|
||||
{name: "partial match", input: "file", want: false},
|
||||
{name: "empty string", input: "", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ValidDropOption(tt.input)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDropOptions(t *testing.T) {
|
||||
coord := file.Coordinates{
|
||||
RealPath: "/test/path",
|
||||
FileSystemID: "layer123",
|
||||
}
|
||||
|
||||
baseSBOM := func() *sbom.SBOM {
|
||||
return &sbom.SBOM{
|
||||
Source: source.Description{
|
||||
ID: "test-id",
|
||||
Name: "test-source",
|
||||
},
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: "syft",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: pkg.NewCollection(),
|
||||
FileMetadata: map[file.Coordinates]file.Metadata{coord: {}},
|
||||
FileDigests: map[file.Coordinates][]file.Digest{coord: {{Algorithm: "sha256", Value: "abc"}}},
|
||||
FileContents: map[file.Coordinates]string{coord: "contents"},
|
||||
FileLicenses: map[file.Coordinates][]file.License{coord: {{Value: "MIT"}}},
|
||||
Executables: map[file.Coordinates]file.Executable{coord: {}},
|
||||
Unknowns: map[file.Coordinates][]string{coord: {"unknown"}},
|
||||
},
|
||||
Relationships: []artifact.Relationship{
|
||||
{
|
||||
From: coord,
|
||||
To: coord,
|
||||
Type: artifact.EvidentByRelationship,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []DropOption
|
||||
verify func(t *testing.T, s *sbom.SBOM)
|
||||
}{
|
||||
{
|
||||
name: "drop source",
|
||||
opts: []DropOption{DropSource},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Empty(t, s.Source.ID)
|
||||
assert.Empty(t, s.Source.Name)
|
||||
// other fields should be preserved
|
||||
assert.Equal(t, "syft", s.Descriptor.Name)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop descriptor",
|
||||
opts: []DropOption{DropDescriptor},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Empty(t, s.Descriptor.Name)
|
||||
assert.Empty(t, s.Descriptor.Version)
|
||||
// other fields should be preserved
|
||||
assert.Equal(t, "test-id", s.Source.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop distro",
|
||||
opts: []DropOption{DropDistro},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.LinuxDistribution)
|
||||
// other fields should be preserved
|
||||
assert.Equal(t, "test-id", s.Source.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop file:metadata",
|
||||
opts: []DropOption{DropFileMetadata},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.FileMetadata)
|
||||
// other file artifacts should be preserved
|
||||
assert.NotNil(t, s.Artifacts.FileDigests)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop file:digests",
|
||||
opts: []DropOption{DropFileDigests},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.FileDigests)
|
||||
// other file artifacts should be preserved
|
||||
assert.NotNil(t, s.Artifacts.FileMetadata)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop file:executable",
|
||||
opts: []DropOption{DropFileExecutable},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.Executables)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop file:unknowns",
|
||||
opts: []DropOption{DropFileUnknowns},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.Unknowns)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop file:licenses",
|
||||
opts: []DropOption{DropFileLicenses},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.FileLicenses)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop file:contents",
|
||||
opts: []DropOption{DropFileContents},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Nil(t, s.Artifacts.FileContents)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop location:fsid clears FileSystemID",
|
||||
opts: []DropOption{DropLocationFSID},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
// check that FileSystemID is cleared from file metadata
|
||||
for coord := range s.Artifacts.FileMetadata {
|
||||
assert.Empty(t, coord.FileSystemID, "FileSystemID should be empty in FileMetadata")
|
||||
assert.Equal(t, "/test/path", coord.RealPath)
|
||||
}
|
||||
// check relationships
|
||||
for _, rel := range s.Relationships {
|
||||
if c, ok := rel.From.(file.Coordinates); ok {
|
||||
assert.Empty(t, c.FileSystemID, "FileSystemID should be empty in relationship From")
|
||||
}
|
||||
if c, ok := rel.To.(file.Coordinates); ok {
|
||||
assert.Empty(t, c.FileSystemID, "FileSystemID should be empty in relationship To")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop pkg:metadata.files clears files from ApkDBEntry",
|
||||
opts: []DropOption{DropPkgMetadataFiles},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
// first add a package with ApkDBEntry metadata
|
||||
p := pkg.Package{
|
||||
Name: "test-apk",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.ApkPkg,
|
||||
Metadata: pkg.ApkDBEntry{
|
||||
Package: "test-apk",
|
||||
Files: []pkg.ApkFileRecord{
|
||||
{Path: "/usr/bin/test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
s.Artifacts.Packages.Add(p)
|
||||
|
||||
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
|
||||
|
||||
// verify files were cleared
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
if p.Name == "test-apk" {
|
||||
meta, ok := p.Metadata.(pkg.ApkDBEntry)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, meta.Files)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop pkg:metadata.files clears files from RpmDBEntry",
|
||||
opts: []DropOption{DropPkgMetadataFiles},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
p := pkg.Package{
|
||||
Name: "test-rpm",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.RpmPkg,
|
||||
Metadata: pkg.RpmDBEntry{
|
||||
Name: "test-rpm",
|
||||
Files: []pkg.RpmFileRecord{
|
||||
{Path: "/usr/bin/test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
s.Artifacts.Packages.Add(p)
|
||||
|
||||
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
|
||||
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
if p.Name == "test-rpm" {
|
||||
meta, ok := p.Metadata.(pkg.RpmDBEntry)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, meta.Files)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop pkg:metadata.files clears files from DpkgDBEntry",
|
||||
opts: []DropOption{DropPkgMetadataFiles},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
p := pkg.Package{
|
||||
Name: "test-deb",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.DebPkg,
|
||||
Metadata: pkg.DpkgDBEntry{
|
||||
Package: "test-deb",
|
||||
Files: []pkg.DpkgFileRecord{
|
||||
{Path: "/usr/bin/test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
s.Artifacts.Packages.Add(p)
|
||||
|
||||
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
|
||||
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
if p.Name == "test-deb" {
|
||||
meta, ok := p.Metadata.(pkg.DpkgDBEntry)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, meta.Files)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop pkg:metadata.files clears files from PythonPackage",
|
||||
opts: []DropOption{DropPkgMetadataFiles},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
p := pkg.Package{
|
||||
Name: "test-python",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.PythonPkg,
|
||||
Metadata: pkg.PythonPackage{
|
||||
Name: "test-python",
|
||||
Files: []pkg.PythonFileRecord{
|
||||
{Path: "/usr/lib/python/test.py"},
|
||||
},
|
||||
},
|
||||
}
|
||||
s.Artifacts.Packages.Add(p)
|
||||
|
||||
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
|
||||
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
if p.Name == "test-python" {
|
||||
meta, ok := p.Metadata.(pkg.PythonPackage)
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, meta.Files)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drop pkg:metadata.files does nothing for non-FileOwner metadata",
|
||||
opts: []DropOption{DropPkgMetadataFiles},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
// add a package with metadata that doesn't implement FileOwner
|
||||
p := pkg.Package{
|
||||
Name: "test-npm",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.NpmPkg,
|
||||
Metadata: pkg.NpmPackage{
|
||||
Name: "test-npm",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
s.Artifacts.Packages.Add(p)
|
||||
|
||||
ApplyDropOptions(s, []DropOption{DropPkgMetadataFiles})
|
||||
|
||||
// verify package still exists with metadata intact
|
||||
for p := range s.Artifacts.Packages.Enumerate() {
|
||||
if p.Name == "test-npm" {
|
||||
meta, ok := p.Metadata.(pkg.NpmPackage)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "test-npm", meta.Name)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil SBOM does not panic",
|
||||
opts: []DropOption{DropSource},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
// nothing to verify, just ensure no panic
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty options does nothing",
|
||||
opts: []DropOption{},
|
||||
verify: func(t *testing.T, s *sbom.SBOM) {
|
||||
assert.Equal(t, "test-id", s.Source.ID)
|
||||
assert.Equal(t, "syft", s.Descriptor.Name)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var s *sbom.SBOM
|
||||
if tt.name != "nil SBOM does not panic" {
|
||||
s = baseSBOM()
|
||||
}
|
||||
|
||||
// should not panic
|
||||
require.NotPanics(t, func() {
|
||||
ApplyDropOptions(s, tt.opts)
|
||||
})
|
||||
|
||||
if s != nil {
|
||||
tt.verify(t, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDropLocationFSID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []DropOption
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "has location:fsid",
|
||||
opts: []DropOption{DropSource, DropLocationFSID, DropDescriptor},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no location:fsid",
|
||||
opts: []DropOption{DropSource, DropDescriptor},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty opts",
|
||||
opts: []DropOption{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil opts",
|
||||
opts: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := HasDropLocationFSID(tt.opts)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJSONFieldsToRemove(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []DropOption
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "source only",
|
||||
opts: []DropOption{DropSource},
|
||||
want: []string{"source"},
|
||||
},
|
||||
{
|
||||
name: "descriptor only",
|
||||
opts: []DropOption{DropDescriptor},
|
||||
want: []string{"descriptor"},
|
||||
},
|
||||
{
|
||||
name: "distro only",
|
||||
opts: []DropOption{DropDistro},
|
||||
want: []string{"distro"},
|
||||
},
|
||||
{
|
||||
name: "all three",
|
||||
opts: []DropOption{DropSource, DropDescriptor, DropDistro},
|
||||
want: []string{"source", "descriptor", "distro"},
|
||||
},
|
||||
{
|
||||
name: "mixed with file options",
|
||||
opts: []DropOption{DropSource, DropFileDigests, DropDescriptor, DropFileMetadata},
|
||||
want: []string{"source", "descriptor"},
|
||||
},
|
||||
{
|
||||
name: "file options only returns empty",
|
||||
opts: []DropOption{DropFileDigests, DropFileMetadata, DropLocationFSID},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty opts",
|
||||
opts: []DropOption{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "nil opts",
|
||||
opts: nil,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GetJSONFieldsToRemove(tt.opts)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// MatchPackages finds packages in the collection that match the given queries.
|
||||
// Match order (first match wins):
|
||||
// 1. Exact package ID match
|
||||
// 2. Exact PURL match or PURL prefix match
|
||||
// 3. Case-insensitive name match
|
||||
// 4. name@version format match
|
||||
func MatchPackages(collection *pkg.Collection, queries []string) []pkg.Package {
|
||||
if collection == nil || len(queries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// build indexes for efficient lookups
|
||||
byID := make(map[artifact.ID]pkg.Package)
|
||||
byPURL := make(map[string]pkg.Package)
|
||||
byNameLower := make(map[string][]pkg.Package)
|
||||
byNameVersion := make(map[string]pkg.Package)
|
||||
|
||||
for p := range collection.Enumerate() {
|
||||
byID[p.ID()] = p
|
||||
if p.PURL != "" {
|
||||
byPURL[p.PURL] = p
|
||||
}
|
||||
nameLower := strings.ToLower(p.Name)
|
||||
byNameLower[nameLower] = append(byNameLower[nameLower], p)
|
||||
nameVersion := strings.ToLower(p.Name + "@" + p.Version)
|
||||
byNameVersion[nameVersion] = p
|
||||
}
|
||||
|
||||
// track matched packages to avoid duplicates
|
||||
matched := make(map[artifact.ID]pkg.Package)
|
||||
|
||||
for _, query := range queries {
|
||||
// 1. exact package ID match
|
||||
if p, ok := byID[artifact.ID(query)]; ok {
|
||||
matched[p.ID()] = p
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. exact PURL match
|
||||
if p, ok := byPURL[query]; ok {
|
||||
matched[p.ID()] = p
|
||||
continue
|
||||
}
|
||||
|
||||
// 2b. PURL prefix match (e.g., "pkg:apk/alpine/musl" matches "pkg:apk/alpine/musl@1.2.2")
|
||||
if strings.HasPrefix(query, "pkg:") {
|
||||
for purl, p := range byPURL {
|
||||
if strings.HasPrefix(purl, query) {
|
||||
matched[p.ID()] = p
|
||||
}
|
||||
}
|
||||
if len(matched) > 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
// 3. case-insensitive name match
|
||||
if pkgs, ok := byNameLower[queryLower]; ok {
|
||||
for _, p := range pkgs {
|
||||
matched[p.ID()] = p
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. name@version format match
|
||||
if p, ok := byNameVersion[queryLower]; ok {
|
||||
matched[p.ID()] = p
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// convert map to slice
|
||||
result := make([]pkg.Package, 0, len(matched))
|
||||
for _, p := range matched {
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
// sort for stable output
|
||||
pkg.Sort(result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
func TestMatchPackages(t *testing.T) {
|
||||
// create test packages
|
||||
pkgA := pkg.Package{
|
||||
Name: "alpine-baselayout",
|
||||
Version: "3.2.0-r7",
|
||||
PURL: "pkg:apk/alpine/alpine-baselayout@3.2.0-r7",
|
||||
}
|
||||
pkgA.SetID()
|
||||
|
||||
pkgB := pkg.Package{
|
||||
Name: "musl",
|
||||
Version: "1.2.2-r0",
|
||||
PURL: "pkg:apk/alpine/musl@1.2.2-r0",
|
||||
}
|
||||
pkgB.SetID()
|
||||
|
||||
pkgC := pkg.Package{
|
||||
Name: "Musl", // different case
|
||||
Version: "2.0.0",
|
||||
PURL: "pkg:apk/alpine/Musl@2.0.0",
|
||||
}
|
||||
pkgC.SetID()
|
||||
|
||||
collection := pkg.NewCollection(pkgA, pkgB, pkgC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queries []string
|
||||
want []pkg.Package
|
||||
}{
|
||||
{
|
||||
name: "match by exact package ID",
|
||||
queries: []string{string(pkgA.ID())},
|
||||
want: []pkg.Package{pkgA},
|
||||
},
|
||||
{
|
||||
name: "match by exact PURL",
|
||||
queries: []string{"pkg:apk/alpine/musl@1.2.2-r0"},
|
||||
want: []pkg.Package{pkgB},
|
||||
},
|
||||
{
|
||||
name: "match by PURL prefix",
|
||||
queries: []string{"pkg:apk/alpine/musl"},
|
||||
want: []pkg.Package{pkgB},
|
||||
},
|
||||
{
|
||||
name: "match by case-insensitive name",
|
||||
queries: []string{"musl"},
|
||||
want: []pkg.Package{pkgB, pkgC},
|
||||
},
|
||||
{
|
||||
name: "match by name@version",
|
||||
queries: []string{"musl@1.2.2-r0"},
|
||||
want: []pkg.Package{pkgB},
|
||||
},
|
||||
{
|
||||
name: "multiple queries",
|
||||
queries: []string{"alpine-baselayout", "musl@1.2.2-r0"},
|
||||
want: []pkg.Package{pkgA, pkgB},
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
queries: []string{"nonexistent"},
|
||||
want: []pkg.Package{},
|
||||
},
|
||||
{
|
||||
name: "empty queries",
|
||||
queries: []string{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "nil collection returns nil",
|
||||
queries: []string{"musl"},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
coll := collection
|
||||
if tt.name == "nil collection returns nil" {
|
||||
coll = nil
|
||||
}
|
||||
|
||||
got := MatchPackages(coll, tt.queries)
|
||||
|
||||
if tt.want == nil {
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
|
||||
require.Len(t, got, len(tt.want))
|
||||
|
||||
if len(tt.want) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// sort both for comparison
|
||||
pkg.Sort(tt.want)
|
||||
|
||||
// compare using cmp.Diff, ignoring unexported fields
|
||||
opts := []cmp.Option{
|
||||
cmpopts.IgnoreUnexported(pkg.Package{}),
|
||||
cmpopts.IgnoreFields(pkg.Package{}, "Locations", "Licenses", "CPEs"),
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.want, got, opts...); diff != "" {
|
||||
t.Errorf("MatchPackages() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
// relationshipIndex indexes relationships for efficient lookup by from/to IDs.
|
||||
// This is a simplified version tailored for the split functionality.
|
||||
type relationshipIndex struct {
|
||||
relationships []*sortableRelationship
|
||||
fromID map[artifact.ID]*mappedRelationships
|
||||
toID map[artifact.ID]*mappedRelationships
|
||||
}
|
||||
|
||||
// newRelationshipIndex creates a new relationship index from the given relationships
|
||||
func newRelationshipIndex(relationships ...artifact.Relationship) *relationshipIndex {
|
||||
idx := &relationshipIndex{
|
||||
fromID: make(map[artifact.ID]*mappedRelationships),
|
||||
toID: make(map[artifact.ID]*mappedRelationships),
|
||||
}
|
||||
|
||||
for _, r := range relationships {
|
||||
// prevent duplicates
|
||||
if idx.contains(r) {
|
||||
continue
|
||||
}
|
||||
|
||||
fromID := r.From.ID()
|
||||
toID := r.To.ID()
|
||||
|
||||
sr := &sortableRelationship{
|
||||
from: fromID,
|
||||
to: toID,
|
||||
relationship: r,
|
||||
}
|
||||
|
||||
idx.relationships = append(idx.relationships, sr)
|
||||
|
||||
// add from -> to mapping
|
||||
if idx.fromID[fromID] == nil {
|
||||
idx.fromID[fromID] = &mappedRelationships{}
|
||||
}
|
||||
idx.fromID[fromID].add(toID, sr)
|
||||
|
||||
// add to -> from mapping
|
||||
if idx.toID[toID] == nil {
|
||||
idx.toID[toID] = &mappedRelationships{}
|
||||
}
|
||||
idx.toID[toID].add(fromID, sr)
|
||||
}
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// from returns all relationships from the given identifiable, filtered by types
|
||||
func (i *relationshipIndex) from(identifiable artifact.Identifiable, types ...artifact.RelationshipType) []artifact.Relationship {
|
||||
return toSortedSlice(fromMapped(i.fromID, identifiable), types)
|
||||
}
|
||||
|
||||
// to returns all relationships to the given identifiable, filtered by types
|
||||
func (i *relationshipIndex) to(identifiable artifact.Identifiable, types ...artifact.RelationshipType) []artifact.Relationship {
|
||||
return toSortedSlice(fromMapped(i.toID, identifiable), types)
|
||||
}
|
||||
|
||||
// coordinates returns all file coordinates referenced by relationships for the given identifiable
|
||||
func (i *relationshipIndex) coordinates(identifiable artifact.Identifiable, types ...artifact.RelationshipType) []file.Coordinates {
|
||||
// get relationships in both directions
|
||||
rels := append(fromMapped(i.fromID, identifiable), fromMapped(i.toID, identifiable)...)
|
||||
sorted := toSortedSlice(rels, types)
|
||||
|
||||
var coords []file.Coordinates
|
||||
for _, rel := range sorted {
|
||||
if c, ok := rel.From.(file.Coordinates); ok {
|
||||
coords = append(coords, c)
|
||||
}
|
||||
if c, ok := rel.To.(file.Coordinates); ok {
|
||||
coords = append(coords, c)
|
||||
}
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
// all returns all relationships, optionally filtered by types
|
||||
func (i *relationshipIndex) all(types ...artifact.RelationshipType) []artifact.Relationship {
|
||||
return toSortedSlice(i.relationships, types)
|
||||
}
|
||||
|
||||
// contains checks if the relationship is already in the index
|
||||
func (i *relationshipIndex) contains(r artifact.Relationship) bool {
|
||||
mapped := i.fromID[r.From.ID()]
|
||||
if mapped == nil {
|
||||
return false
|
||||
}
|
||||
typeMap := mapped.typeMap[r.Type]
|
||||
if typeMap == nil {
|
||||
return false
|
||||
}
|
||||
return typeMap[r.To.ID()] != nil
|
||||
}
|
||||
|
||||
type mappedRelationships struct {
|
||||
typeMap map[artifact.RelationshipType]map[artifact.ID]*sortableRelationship
|
||||
allRelated []*sortableRelationship
|
||||
}
|
||||
|
||||
func (m *mappedRelationships) add(id artifact.ID, sr *sortableRelationship) {
|
||||
m.allRelated = append(m.allRelated, sr)
|
||||
if m.typeMap == nil {
|
||||
m.typeMap = make(map[artifact.RelationshipType]map[artifact.ID]*sortableRelationship)
|
||||
}
|
||||
if m.typeMap[sr.relationship.Type] == nil {
|
||||
m.typeMap[sr.relationship.Type] = make(map[artifact.ID]*sortableRelationship)
|
||||
}
|
||||
m.typeMap[sr.relationship.Type][id] = sr
|
||||
}
|
||||
|
||||
type sortableRelationship struct {
|
||||
from artifact.ID
|
||||
to artifact.ID
|
||||
relationship artifact.Relationship
|
||||
}
|
||||
|
||||
func fromMapped(idMap map[artifact.ID]*mappedRelationships, identifiable artifact.Identifiable) []*sortableRelationship {
|
||||
if identifiable == nil {
|
||||
return nil
|
||||
}
|
||||
mapped := idMap[identifiable.ID()]
|
||||
if mapped == nil {
|
||||
return nil
|
||||
}
|
||||
return mapped.allRelated
|
||||
}
|
||||
|
||||
func toSortedSlice(relationships []*sortableRelationship, types []artifact.RelationshipType) []artifact.Relationship {
|
||||
slices.SortFunc(relationships, func(a, b *sortableRelationship) int {
|
||||
cmp := strings.Compare(string(a.relationship.Type), string(b.relationship.Type))
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
cmp = strings.Compare(string(a.from), string(b.from))
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(string(a.to), string(b.to))
|
||||
})
|
||||
|
||||
var out []artifact.Relationship
|
||||
for _, r := range relationships {
|
||||
if len(types) == 0 || slices.Contains(types, r.relationship.Type) {
|
||||
out = append(out, r.relationship)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
// allowedRelationshipTypes are the only relationship types allowed in split output
|
||||
var allowedRelationshipTypes = []artifact.RelationshipType{
|
||||
artifact.OwnershipByFileOverlapRelationship,
|
||||
artifact.EvidentByRelationship,
|
||||
}
|
||||
|
||||
// Result represents the result of splitting an SBOM for a single target package
|
||||
type Result struct {
|
||||
TargetPackage pkg.Package
|
||||
SBOM sbom.SBOM
|
||||
}
|
||||
|
||||
// Split splits the source SBOM into separate SBOMs, one for each target package.
|
||||
// Each output SBOM contains the target package, its connected packages (via ownership-by-file-overlap
|
||||
// and evident-by relationships), and all related files.
|
||||
func Split(source sbom.SBOM, targetPackages []pkg.Package, dropLocationFSID, dropNonPrimaryEvidence bool) []Result {
|
||||
if len(targetPackages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
relIndex := newRelationshipIndex(source.Relationships...)
|
||||
results := make([]Result, 0, len(targetPackages))
|
||||
|
||||
for _, target := range targetPackages {
|
||||
result := splitForPackage(source, target, relIndex, dropLocationFSID, dropNonPrimaryEvidence)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// splitForPackage creates a new SBOM containing only the target package and its related artifacts
|
||||
func splitForPackage(source sbom.SBOM, target pkg.Package, relIndex *relationshipIndex, dropLocationFSID, dropNonPrimaryEvidence bool) Result {
|
||||
// find all connected packages via BFS
|
||||
connectedPkgs := findConnectedPackages(source, target, relIndex)
|
||||
|
||||
// collect all kept package IDs for relationship filtering
|
||||
keptPkgIDs := make(map[artifact.ID]bool)
|
||||
for _, p := range connectedPkgs {
|
||||
keptPkgIDs[p.ID()] = true
|
||||
}
|
||||
|
||||
// find related file coordinates from relationships and package locations
|
||||
keptCoords := collectFileCoordinates(connectedPkgs, relIndex, dropLocationFSID, dropNonPrimaryEvidence)
|
||||
|
||||
// build filtered SBOM
|
||||
filteredSBOM := buildFilteredSBOM(source, connectedPkgs, keptPkgIDs, keptCoords, relIndex, dropLocationFSID, dropNonPrimaryEvidence)
|
||||
|
||||
return Result{
|
||||
TargetPackage: target,
|
||||
SBOM: filteredSBOM,
|
||||
}
|
||||
}
|
||||
|
||||
// findConnectedPackages uses BFS to find all packages connected to the target via traversal relationships
|
||||
func findConnectedPackages(source sbom.SBOM, target pkg.Package, relIndex *relationshipIndex) []pkg.Package {
|
||||
visited := make(map[artifact.ID]bool)
|
||||
var result []pkg.Package
|
||||
|
||||
queue := []pkg.Package{target}
|
||||
visited[target.ID()] = true
|
||||
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, current)
|
||||
|
||||
// find connected packages through relationships (both directions)
|
||||
for _, rel := range relIndex.from(current, allowedRelationshipTypes...) {
|
||||
toID := rel.To.ID()
|
||||
if visited[toID] {
|
||||
continue
|
||||
}
|
||||
// check if the target is a package in the source collection
|
||||
if p := source.Artifacts.Packages.Package(toID); p != nil {
|
||||
visited[toID] = true
|
||||
queue = append(queue, *p)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rel := range relIndex.to(current, allowedRelationshipTypes...) {
|
||||
fromID := rel.From.ID()
|
||||
if visited[fromID] {
|
||||
continue
|
||||
}
|
||||
// check if the source is a package in the source collection
|
||||
if p := source.Artifacts.Packages.Package(fromID); p != nil {
|
||||
visited[fromID] = true
|
||||
queue = append(queue, *p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// collectFileCoordinates gathers all file coordinates related to the kept packages
|
||||
func collectFileCoordinates(packages []pkg.Package, relIndex *relationshipIndex, dropLocationFSID, dropNonPrimaryEvidence bool) map[file.Coordinates]bool {
|
||||
coords := make(map[file.Coordinates]bool)
|
||||
|
||||
for _, p := range packages {
|
||||
// collect coordinates from package locations
|
||||
for _, loc := range p.Locations.ToSlice() {
|
||||
// skip non-primary evidence locations if requested
|
||||
if dropNonPrimaryEvidence {
|
||||
if loc.Annotations == nil || loc.Annotations["evidence"] != "primary" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
coord := loc.Coordinates
|
||||
if dropLocationFSID {
|
||||
coord = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
coords[coord] = true
|
||||
}
|
||||
|
||||
// collect coordinates from allowed relationship types only
|
||||
for _, c := range relIndex.coordinates(p, allowedRelationshipTypes...) {
|
||||
coord := c
|
||||
if dropLocationFSID {
|
||||
coord = file.Coordinates{RealPath: c.RealPath}
|
||||
}
|
||||
coords[coord] = true
|
||||
}
|
||||
}
|
||||
|
||||
return coords
|
||||
}
|
||||
|
||||
// buildFilteredSBOM creates a new SBOM with only the kept packages, files, and relationships
|
||||
func buildFilteredSBOM(source sbom.SBOM, packages []pkg.Package, keptPkgIDs map[artifact.ID]bool, keptCoords map[file.Coordinates]bool, relIndex *relationshipIndex, dropLocationFSID, dropNonPrimaryEvidence bool) sbom.SBOM {
|
||||
// create new package collection
|
||||
newPkgCollection := pkg.NewCollection()
|
||||
for _, p := range packages {
|
||||
// filter non-primary evidence locations if requested
|
||||
if dropNonPrimaryEvidence {
|
||||
p = filterPackageNonPrimaryLocations(p)
|
||||
}
|
||||
// if dropLocationFSID is enabled, we need to clear FileSystemID from package locations
|
||||
if dropLocationFSID {
|
||||
p = clearPackageFileSystemIDs(p)
|
||||
}
|
||||
newPkgCollection.Add(p)
|
||||
}
|
||||
|
||||
// filter file artifacts
|
||||
filteredSBOM := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: newPkgCollection,
|
||||
FileMetadata: filterFileMap(source.Artifacts.FileMetadata, keptCoords, dropLocationFSID),
|
||||
FileDigests: filterFileMap(source.Artifacts.FileDigests, keptCoords, dropLocationFSID),
|
||||
FileContents: filterFileMap(source.Artifacts.FileContents, keptCoords, dropLocationFSID),
|
||||
FileLicenses: filterFileMap(source.Artifacts.FileLicenses, keptCoords, dropLocationFSID),
|
||||
Executables: filterFileMap(source.Artifacts.Executables, keptCoords, dropLocationFSID),
|
||||
Unknowns: filterFileMap(source.Artifacts.Unknowns, keptCoords, dropLocationFSID),
|
||||
LinuxDistribution: source.Artifacts.LinuxDistribution,
|
||||
},
|
||||
Relationships: filterRelationships(relIndex.all(), keptPkgIDs, keptCoords, dropLocationFSID),
|
||||
Source: source.Source,
|
||||
Descriptor: source.Descriptor,
|
||||
}
|
||||
|
||||
return filteredSBOM
|
||||
}
|
||||
|
||||
// filterPackageNonPrimaryLocations creates a copy of the package with only primary evidence locations
|
||||
func filterPackageNonPrimaryLocations(p pkg.Package) pkg.Package {
|
||||
newLocations := file.NewLocationSet()
|
||||
for _, loc := range p.Locations.ToSlice() {
|
||||
if loc.Annotations != nil && loc.Annotations["evidence"] == "primary" {
|
||||
newLocations.Add(loc)
|
||||
}
|
||||
}
|
||||
p.Locations = newLocations
|
||||
return p
|
||||
}
|
||||
|
||||
// filterFileMap filters a map of file.Coordinates to only include kept coordinates
|
||||
func filterFileMap[T any](m map[file.Coordinates]T, keptCoords map[file.Coordinates]bool, dropLocationFSID bool) map[file.Coordinates]T {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[file.Coordinates]T)
|
||||
for coord, value := range m {
|
||||
checkCoord := coord
|
||||
if dropLocationFSID {
|
||||
checkCoord = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
if keptCoords[checkCoord] {
|
||||
outputCoord := coord
|
||||
if dropLocationFSID {
|
||||
outputCoord = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
result[outputCoord] = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// filterRelationships filters relationships to only include allowed types referencing kept artifacts
|
||||
func filterRelationships(relationships []artifact.Relationship, keptPkgIDs map[artifact.ID]bool, keptCoords map[file.Coordinates]bool, dropLocationFSID bool) []artifact.Relationship {
|
||||
var result []artifact.Relationship
|
||||
|
||||
for _, rel := range relationships {
|
||||
// only keep allowed relationship types
|
||||
if !isAllowedRelationshipType(rel.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if both ends of the relationship reference kept artifacts
|
||||
fromKept := isArtifactKept(rel.From, keptPkgIDs, keptCoords, dropLocationFSID)
|
||||
toKept := isArtifactKept(rel.To, keptPkgIDs, keptCoords, dropLocationFSID)
|
||||
|
||||
if fromKept && toKept {
|
||||
newRel := rel
|
||||
if dropLocationFSID {
|
||||
if coord, ok := rel.From.(file.Coordinates); ok {
|
||||
newRel.From = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
if coord, ok := rel.To.(file.Coordinates); ok {
|
||||
newRel.To = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
}
|
||||
result = append(result, newRel)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// isAllowedRelationshipType checks if a relationship type is in the allowed list
|
||||
func isAllowedRelationshipType(t artifact.RelationshipType) bool {
|
||||
for _, allowed := range allowedRelationshipTypes {
|
||||
if t == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isArtifactKept checks if an artifact (package or file) is in the kept set
|
||||
func isArtifactKept(a artifact.Identifiable, keptPkgIDs map[artifact.ID]bool, keptCoords map[file.Coordinates]bool, dropLocationFSID bool) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// check if it's a file coordinate
|
||||
if coord, ok := a.(file.Coordinates); ok {
|
||||
checkCoord := coord
|
||||
if dropLocationFSID {
|
||||
checkCoord = file.Coordinates{RealPath: coord.RealPath}
|
||||
}
|
||||
return keptCoords[checkCoord]
|
||||
}
|
||||
|
||||
// otherwise check package ID
|
||||
return keptPkgIDs[a.ID()]
|
||||
}
|
||||
|
||||
// clearPackageFileSystemIDs creates a copy of the package with FileSystemID cleared from all locations
|
||||
func clearPackageFileSystemIDs(p pkg.Package) pkg.Package {
|
||||
newLocations := file.NewLocationSet()
|
||||
for _, loc := range p.Locations.ToSlice() {
|
||||
newLoc := file.Location{
|
||||
LocationData: file.LocationData{
|
||||
Coordinates: file.Coordinates{
|
||||
RealPath: loc.RealPath,
|
||||
// FileSystemID intentionally left empty
|
||||
},
|
||||
AccessPath: loc.AccessPath,
|
||||
},
|
||||
LocationMetadata: loc.LocationMetadata,
|
||||
}
|
||||
newLocations.Add(newLoc)
|
||||
}
|
||||
p.Locations = newLocations
|
||||
|
||||
// also clear from license locations
|
||||
newLicenses := pkg.NewLicenseSet()
|
||||
for _, lic := range p.Licenses.ToSlice() {
|
||||
newLicLocs := file.NewLocationSet()
|
||||
for _, loc := range lic.Locations.ToSlice() {
|
||||
newLoc := file.Location{
|
||||
LocationData: file.LocationData{
|
||||
Coordinates: file.Coordinates{
|
||||
RealPath: loc.RealPath,
|
||||
},
|
||||
AccessPath: loc.AccessPath,
|
||||
},
|
||||
LocationMetadata: loc.LocationMetadata,
|
||||
}
|
||||
newLicLocs.Add(newLoc)
|
||||
}
|
||||
lic.Locations = newLicLocs
|
||||
newLicenses.Add(lic)
|
||||
}
|
||||
p.Licenses = newLicenses
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
package split
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/linux"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func TestSplit(t *testing.T) {
|
||||
// create test coordinates
|
||||
coord1 := file.Coordinates{RealPath: "/lib/apk/db/installed", FileSystemID: "layer1"}
|
||||
coord2 := file.Coordinates{RealPath: "/usr/lib/libmusl.so", FileSystemID: "layer1"}
|
||||
coord3 := file.Coordinates{RealPath: "/unrelated/file", FileSystemID: "layer1"}
|
||||
|
||||
// create test packages
|
||||
pkgA := pkg.Package{
|
||||
Name: "alpine-baselayout",
|
||||
Version: "3.2.0-r7",
|
||||
Type: pkg.ApkPkg,
|
||||
Locations: file.NewLocationSet(file.NewLocationFromCoordinates(coord1)),
|
||||
}
|
||||
pkgA.SetID()
|
||||
|
||||
pkgB := pkg.Package{
|
||||
Name: "musl",
|
||||
Version: "1.2.2-r0",
|
||||
Type: pkg.ApkPkg,
|
||||
Locations: file.NewLocationSet(file.NewLocationFromCoordinates(coord2)),
|
||||
}
|
||||
pkgB.SetID()
|
||||
|
||||
pkgC := pkg.Package{
|
||||
Name: "unrelated",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.ApkPkg,
|
||||
Locations: file.NewLocationSet(file.NewLocationFromCoordinates(coord3)),
|
||||
}
|
||||
pkgC.SetID()
|
||||
|
||||
// create source SBOM with relationships
|
||||
sourceSBOM := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: pkg.NewCollection(pkgA, pkgB, pkgC),
|
||||
FileMetadata: map[file.Coordinates]file.Metadata{
|
||||
coord1: {MIMEType: "text/plain"},
|
||||
coord2: {MIMEType: "application/x-sharedlib"},
|
||||
coord3: {MIMEType: "text/plain"},
|
||||
},
|
||||
FileDigests: map[file.Coordinates][]file.Digest{
|
||||
coord1: {{Algorithm: "sha256", Value: "abc123"}},
|
||||
coord2: {{Algorithm: "sha256", Value: "def456"}},
|
||||
coord3: {{Algorithm: "sha256", Value: "ghi789"}},
|
||||
},
|
||||
LinuxDistribution: &linux.Release{ID: "alpine", VersionID: "3.12"},
|
||||
},
|
||||
Relationships: []artifact.Relationship{
|
||||
// pkgA owns pkgB via file overlap
|
||||
{
|
||||
From: pkgA,
|
||||
To: pkgB,
|
||||
Type: artifact.OwnershipByFileOverlapRelationship,
|
||||
},
|
||||
// pkgA is evident by coord1
|
||||
{
|
||||
From: pkgA,
|
||||
To: coord1,
|
||||
Type: artifact.EvidentByRelationship,
|
||||
},
|
||||
// pkgB is evident by coord2
|
||||
{
|
||||
From: pkgB,
|
||||
To: coord2,
|
||||
Type: artifact.EvidentByRelationship,
|
||||
},
|
||||
// pkgC is evident by coord3 (unrelated)
|
||||
{
|
||||
From: pkgC,
|
||||
To: coord3,
|
||||
Type: artifact.EvidentByRelationship,
|
||||
},
|
||||
},
|
||||
Source: source.Description{
|
||||
ID: "test-source-id",
|
||||
Name: "test-image",
|
||||
},
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: "syft",
|
||||
Version: "test",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
targetPackages []pkg.Package
|
||||
dropLocationFSID bool
|
||||
dropNonPrimaryEvidence bool
|
||||
wantCount int
|
||||
verify func(t *testing.T, results []Result)
|
||||
}{
|
||||
{
|
||||
name: "split single package with connected packages",
|
||||
targetPackages: []pkg.Package{pkgA},
|
||||
dropLocationFSID: false,
|
||||
wantCount: 1,
|
||||
verify: func(t *testing.T, results []Result) {
|
||||
require.Len(t, results, 1)
|
||||
result := results[0]
|
||||
|
||||
// target package should be pkgA
|
||||
assert.Equal(t, pkgA.Name, result.TargetPackage.Name)
|
||||
|
||||
// should include both pkgA and pkgB (connected via ownership)
|
||||
assert.Equal(t, 2, result.SBOM.Artifacts.Packages.PackageCount())
|
||||
|
||||
// should include coord1 and coord2 (related to pkgA and pkgB)
|
||||
assert.Contains(t, result.SBOM.Artifacts.FileMetadata, coord1)
|
||||
assert.Contains(t, result.SBOM.Artifacts.FileMetadata, coord2)
|
||||
|
||||
// should NOT include coord3 (unrelated)
|
||||
assert.NotContains(t, result.SBOM.Artifacts.FileMetadata, coord3)
|
||||
|
||||
// source and descriptor should be preserved
|
||||
assert.Equal(t, "test-source-id", result.SBOM.Source.ID)
|
||||
assert.Equal(t, "syft", result.SBOM.Descriptor.Name)
|
||||
|
||||
// linux distribution should be preserved
|
||||
require.NotNil(t, result.SBOM.Artifacts.LinuxDistribution)
|
||||
assert.Equal(t, "alpine", result.SBOM.Artifacts.LinuxDistribution.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "split unrelated package",
|
||||
targetPackages: []pkg.Package{pkgC},
|
||||
dropLocationFSID: false,
|
||||
wantCount: 1,
|
||||
verify: func(t *testing.T, results []Result) {
|
||||
require.Len(t, results, 1)
|
||||
result := results[0]
|
||||
|
||||
// should only include pkgC
|
||||
assert.Equal(t, 1, result.SBOM.Artifacts.Packages.PackageCount())
|
||||
|
||||
// should only include coord3
|
||||
assert.Contains(t, result.SBOM.Artifacts.FileMetadata, coord3)
|
||||
assert.NotContains(t, result.SBOM.Artifacts.FileMetadata, coord1)
|
||||
assert.NotContains(t, result.SBOM.Artifacts.FileMetadata, coord2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "split with dropLocationFSID",
|
||||
targetPackages: []pkg.Package{pkgA},
|
||||
dropLocationFSID: true,
|
||||
wantCount: 1,
|
||||
verify: func(t *testing.T, results []Result) {
|
||||
require.Len(t, results, 1)
|
||||
result := results[0]
|
||||
|
||||
// FileSystemID should be cleared from coordinates
|
||||
for coord := range result.SBOM.Artifacts.FileMetadata {
|
||||
assert.Empty(t, coord.FileSystemID, "FileSystemID should be empty")
|
||||
}
|
||||
|
||||
// package locations should also have FileSystemID cleared
|
||||
for p := range result.SBOM.Artifacts.Packages.Enumerate() {
|
||||
for _, loc := range p.Locations.ToSlice() {
|
||||
assert.Empty(t, loc.FileSystemID, "package location FileSystemID should be empty")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "split multiple packages",
|
||||
targetPackages: []pkg.Package{pkgA, pkgC},
|
||||
dropLocationFSID: false,
|
||||
wantCount: 2,
|
||||
verify: func(t *testing.T, results []Result) {
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// first result should be for pkgA
|
||||
assert.Equal(t, pkgA.Name, results[0].TargetPackage.Name)
|
||||
|
||||
// second result should be for pkgC
|
||||
assert.Equal(t, pkgC.Name, results[1].TargetPackage.Name)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty target packages",
|
||||
targetPackages: []pkg.Package{},
|
||||
dropLocationFSID: false,
|
||||
wantCount: 0,
|
||||
verify: func(t *testing.T, results []Result) {
|
||||
assert.Nil(t, results)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := Split(sourceSBOM, tt.targetPackages, tt.dropLocationFSID, tt.dropNonPrimaryEvidence)
|
||||
|
||||
if tt.wantCount == 0 {
|
||||
assert.Nil(t, results)
|
||||
} else {
|
||||
require.Len(t, results, tt.wantCount)
|
||||
}
|
||||
|
||||
if tt.verify != nil {
|
||||
tt.verify(t, results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConnectedPackages(t *testing.T) {
|
||||
// create test packages
|
||||
pkgA := pkg.Package{Name: "a", Version: "1.0"}
|
||||
pkgA.SetID()
|
||||
|
||||
pkgB := pkg.Package{Name: "b", Version: "1.0"}
|
||||
pkgB.SetID()
|
||||
|
||||
pkgC := pkg.Package{Name: "c", Version: "1.0"}
|
||||
pkgC.SetID()
|
||||
|
||||
pkgD := pkg.Package{Name: "d", Version: "1.0"}
|
||||
pkgD.SetID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target pkg.Package
|
||||
relationships []artifact.Relationship
|
||||
wantNames []string
|
||||
}{
|
||||
{
|
||||
name: "single package no relationships",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
// pkgB -> pkgC (unrelated)
|
||||
{From: pkgB, To: pkgC, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
},
|
||||
wantNames: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "connected via ownership-by-file-overlap",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
{From: pkgA, To: pkgB, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
},
|
||||
wantNames: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "connected via evident-by (to package)",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
{From: pkgA, To: pkgB, Type: artifact.EvidentByRelationship},
|
||||
},
|
||||
wantNames: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "chain of connections",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
{From: pkgA, To: pkgB, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
{From: pkgB, To: pkgC, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
},
|
||||
wantNames: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "circular relationships",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
{From: pkgA, To: pkgB, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
{From: pkgB, To: pkgC, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
{From: pkgC, To: pkgA, Type: artifact.OwnershipByFileOverlapRelationship},
|
||||
},
|
||||
wantNames: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "ignores contains relationship",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
{From: pkgA, To: pkgB, Type: artifact.ContainsRelationship},
|
||||
},
|
||||
wantNames: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "ignores dependency-of relationship",
|
||||
target: pkgA,
|
||||
relationships: []artifact.Relationship{
|
||||
{From: pkgA, To: pkgB, Type: artifact.DependencyOfRelationship},
|
||||
},
|
||||
wantNames: []string{"a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sourceSBOM := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: pkg.NewCollection(pkgA, pkgB, pkgC, pkgD),
|
||||
},
|
||||
Relationships: tt.relationships,
|
||||
}
|
||||
|
||||
relIndex := newRelationshipIndex(sourceSBOM.Relationships...)
|
||||
got := findConnectedPackages(sourceSBOM, tt.target, relIndex)
|
||||
|
||||
gotNames := make([]string, len(got))
|
||||
for i, p := range got {
|
||||
gotNames[i] = p.Name
|
||||
}
|
||||
|
||||
// sort for comparison
|
||||
assert.ElementsMatch(t, tt.wantNames, gotNames)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearPackageFileSystemIDs(t *testing.T) {
|
||||
coord := file.Coordinates{RealPath: "/test/path", FileSystemID: "layer123"}
|
||||
loc := file.NewLocationFromCoordinates(coord)
|
||||
loc.AccessPath = "/test/access"
|
||||
|
||||
licLoc := file.NewLocationFromCoordinates(coord)
|
||||
lic := pkg.License{
|
||||
Value: "MIT",
|
||||
Locations: file.NewLocationSet(licLoc),
|
||||
}
|
||||
|
||||
p := pkg.Package{
|
||||
Name: "test",
|
||||
Version: "1.0",
|
||||
Locations: file.NewLocationSet(loc),
|
||||
Licenses: pkg.NewLicenseSet(lic),
|
||||
}
|
||||
|
||||
result := clearPackageFileSystemIDs(p)
|
||||
|
||||
// check package locations
|
||||
for _, l := range result.Locations.ToSlice() {
|
||||
assert.Empty(t, l.FileSystemID, "package location FileSystemID should be empty")
|
||||
assert.Equal(t, "/test/path", l.RealPath)
|
||||
assert.Equal(t, "/test/access", l.AccessPath)
|
||||
}
|
||||
|
||||
// check license locations
|
||||
for _, l := range result.Licenses.ToSlice() {
|
||||
for _, ll := range l.Locations.ToSlice() {
|
||||
assert.Empty(t, ll.FileSystemID, "license location FileSystemID should be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageIDStabilityThroughSplit(t *testing.T) {
|
||||
// this test verifies that package IDs remain stable through all split transformations,
|
||||
// including dropping licenses, filtering locations, and clearing filesystem IDs.
|
||||
// Package IDs are content-addressable and should NOT change when we modify packages
|
||||
// during the split process.
|
||||
|
||||
// create a package with multiple locations (primary and non-primary evidence)
|
||||
primaryLoc := file.NewLocation("/lib/apk/db/installed")
|
||||
primaryLoc.Annotations = map[string]string{"evidence": "primary"}
|
||||
|
||||
nonPrimaryLoc := file.NewLocation("/some/other/path")
|
||||
nonPrimaryLoc.Annotations = map[string]string{"evidence": "supporting"}
|
||||
|
||||
// add filesystem IDs to both
|
||||
primaryLoc.Coordinates.FileSystemID = "layer123"
|
||||
nonPrimaryLoc.Coordinates.FileSystemID = "layer456"
|
||||
|
||||
// create license with location
|
||||
licLoc := file.NewLocation("/lib/apk/db/installed")
|
||||
licLoc.Coordinates.FileSystemID = "layer123"
|
||||
lic := pkg.License{
|
||||
Value: "MIT",
|
||||
Locations: file.NewLocationSet(licLoc),
|
||||
}
|
||||
|
||||
testPkg := pkg.Package{
|
||||
Name: "test-package",
|
||||
Version: "1.0.0",
|
||||
Type: pkg.ApkPkg,
|
||||
Locations: file.NewLocationSet(primaryLoc, nonPrimaryLoc),
|
||||
Licenses: pkg.NewLicenseSet(lic),
|
||||
}
|
||||
testPkg.SetID()
|
||||
originalID := testPkg.ID()
|
||||
|
||||
// verify original ID is set
|
||||
require.NotEmpty(t, originalID, "original package ID should be set")
|
||||
|
||||
// create a simple SBOM for the split operation
|
||||
sourceSBOM := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: pkg.NewCollection(testPkg),
|
||||
FileMetadata: map[file.Coordinates]file.Metadata{
|
||||
primaryLoc.Coordinates: {MIMEType: "text/plain"},
|
||||
nonPrimaryLoc.Coordinates: {MIMEType: "text/plain"},
|
||||
},
|
||||
},
|
||||
Relationships: []artifact.Relationship{
|
||||
{
|
||||
From: testPkg,
|
||||
To: primaryLoc.Coordinates,
|
||||
Type: artifact.EvidentByRelationship,
|
||||
},
|
||||
},
|
||||
Source: source.Description{
|
||||
ID: "test-source",
|
||||
Name: "test",
|
||||
},
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: "syft",
|
||||
Version: "test",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dropLocationFSID bool
|
||||
dropNonPrimaryEvidence bool
|
||||
}{
|
||||
{
|
||||
name: "no drop options",
|
||||
dropLocationFSID: false,
|
||||
dropNonPrimaryEvidence: false,
|
||||
},
|
||||
{
|
||||
name: "drop location:fsid only",
|
||||
dropLocationFSID: true,
|
||||
dropNonPrimaryEvidence: false,
|
||||
},
|
||||
{
|
||||
name: "drop location:non-primary-evidence only",
|
||||
dropLocationFSID: false,
|
||||
dropNonPrimaryEvidence: true,
|
||||
},
|
||||
{
|
||||
name: "drop both location options",
|
||||
dropLocationFSID: true,
|
||||
dropNonPrimaryEvidence: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := Split(sourceSBOM, []pkg.Package{testPkg}, tt.dropLocationFSID, tt.dropNonPrimaryEvidence)
|
||||
require.Len(t, results, 1)
|
||||
|
||||
resultSBOM := results[0].SBOM
|
||||
|
||||
// verify there's exactly one package in the result
|
||||
require.Equal(t, 1, resultSBOM.Artifacts.Packages.PackageCount())
|
||||
|
||||
// get the package from the result
|
||||
var resultPkg *pkg.Package
|
||||
for p := range resultSBOM.Artifacts.Packages.Enumerate() {
|
||||
resultPkg = &p
|
||||
break
|
||||
}
|
||||
require.NotNil(t, resultPkg)
|
||||
|
||||
// THE CRITICAL ASSERTION: package ID must remain stable
|
||||
assert.Equal(t, originalID, resultPkg.ID(),
|
||||
"package ID changed after split with dropLocationFSID=%v, dropNonPrimaryEvidence=%v",
|
||||
tt.dropLocationFSID, tt.dropNonPrimaryEvidence)
|
||||
|
||||
// verify the target package ID also matches
|
||||
assert.Equal(t, originalID, results[0].TargetPackage.ID(),
|
||||
"target package ID changed")
|
||||
|
||||
// additional verification: if we dropped non-primary evidence, locations should be filtered
|
||||
if tt.dropNonPrimaryEvidence {
|
||||
locs := resultPkg.Locations.ToSlice()
|
||||
for _, loc := range locs {
|
||||
assert.Equal(t, "primary", loc.Annotations["evidence"],
|
||||
"non-primary locations should be filtered out")
|
||||
}
|
||||
}
|
||||
|
||||
// additional verification: if we dropped fsid, it should be empty
|
||||
if tt.dropLocationFSID {
|
||||
for _, loc := range resultPkg.Locations.ToSlice() {
|
||||
assert.Empty(t, loc.FileSystemID, "FileSystemID should be cleared")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageIDStabilityWithDropOptions(t *testing.T) {
|
||||
// this test verifies that package IDs remain stable when using ApplyDropOptions
|
||||
// which includes pkg:licenses
|
||||
|
||||
coord := file.Coordinates{RealPath: "/test/path", FileSystemID: "layer123"}
|
||||
loc := file.NewLocationFromCoordinates(coord)
|
||||
loc.Annotations = map[string]string{"evidence": "primary"}
|
||||
|
||||
lic := pkg.License{
|
||||
Value: "GPL-2.0",
|
||||
Locations: file.NewLocationSet(loc),
|
||||
}
|
||||
|
||||
testPkg := pkg.Package{
|
||||
Name: "license-test-pkg",
|
||||
Version: "2.0.0",
|
||||
Type: pkg.ApkPkg,
|
||||
Locations: file.NewLocationSet(loc),
|
||||
Licenses: pkg.NewLicenseSet(lic),
|
||||
Metadata: pkg.ApkDBEntry{
|
||||
Package: "license-test-pkg",
|
||||
Files: []pkg.ApkFileRecord{
|
||||
{Path: "/usr/bin/test"},
|
||||
},
|
||||
},
|
||||
}
|
||||
testPkg.SetID()
|
||||
originalID := testPkg.ID()
|
||||
|
||||
require.NotEmpty(t, originalID)
|
||||
|
||||
// create SBOM
|
||||
testSBOM := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: pkg.NewCollection(testPkg),
|
||||
},
|
||||
Source: source.Description{
|
||||
ID: "test",
|
||||
Name: "test",
|
||||
},
|
||||
Descriptor: sbom.Descriptor{
|
||||
Name: "syft",
|
||||
Version: "test",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []DropOption
|
||||
}{
|
||||
{
|
||||
name: "drop pkg:licenses",
|
||||
opts: []DropOption{DropPkgLicenses},
|
||||
},
|
||||
{
|
||||
name: "drop location:fsid via ApplyDropOptions",
|
||||
opts: []DropOption{DropLocationFSID},
|
||||
},
|
||||
{
|
||||
name: "drop location:non-primary-evidence via ApplyDropOptions",
|
||||
opts: []DropOption{DropLocationNonPrimaryEvidence},
|
||||
},
|
||||
{
|
||||
name: "drop all location and pkg options",
|
||||
opts: []DropOption{DropPkgLicenses, DropLocationFSID, DropLocationNonPrimaryEvidence},
|
||||
},
|
||||
{
|
||||
name: "drop pkg:metadata.files",
|
||||
opts: []DropOption{DropPkgMetadataFiles},
|
||||
},
|
||||
{
|
||||
name: "drop all pkg options",
|
||||
opts: []DropOption{DropPkgLicenses, DropPkgMetadataFiles},
|
||||
},
|
||||
{
|
||||
name: "drop source and descriptor (should not affect package ID)",
|
||||
opts: []DropOption{DropSource, DropDescriptor, DropDistro},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// make a copy of the SBOM for this test
|
||||
sbomCopy := sbom.SBOM{
|
||||
Artifacts: sbom.Artifacts{
|
||||
Packages: pkg.NewCollection(testPkg),
|
||||
},
|
||||
Source: testSBOM.Source,
|
||||
Descriptor: testSBOM.Descriptor,
|
||||
}
|
||||
|
||||
// apply drop options
|
||||
ApplyDropOptions(&sbomCopy, tt.opts)
|
||||
|
||||
// get the package from the modified SBOM
|
||||
var resultPkg *pkg.Package
|
||||
for p := range sbomCopy.Artifacts.Packages.Enumerate() {
|
||||
resultPkg = &p
|
||||
break
|
||||
}
|
||||
require.NotNil(t, resultPkg)
|
||||
|
||||
// THE CRITICAL ASSERTION: package ID must remain stable
|
||||
assert.Equal(t, originalID, resultPkg.ID(),
|
||||
"package ID changed after applying drop options: %v", tt.opts)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ var imageOnlyTestCases = []testCase{
|
||||
"Serilog.Sinks.Console": "4.0.1",
|
||||
//"System.Diagnostics.DiagnosticSource": "6.0.0", // no dll claims in deps.json targets section
|
||||
//"System.Runtime.CompilerServices.Unsafe": "6.0.0", // no dll claims in deps.json targets section
|
||||
"TestCommon": "1.0.0",
|
||||
//"TestCommon": "1.0.0",
|
||||
"TestLibrary": "1.0.0",
|
||||
},
|
||||
},
|
||||
@@ -274,7 +274,7 @@ var dirOnlyTestCases = []testCase{
|
||||
"Serilog.Sinks.Console": "4.0.1",
|
||||
//"System.Diagnostics.DiagnosticSource": "6.0.0", // no dll claims in deps.json targets section
|
||||
//"System.Runtime.CompilerServices.Unsafe": "6.0.0", // no dll claims in deps.json targets section
|
||||
"TestCommon": "1.0.0",
|
||||
//"TestCommon": "1.0.0",
|
||||
"TestLibrary": "1.0.0",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ func BenchmarkImagePackageCatalogers(b *testing.B) {
|
||||
// get the source object for the image
|
||||
theSource, err := syft.GetSource(context.Background(), tarPath, syft.DefaultGetSourceConfig().WithSources("docker-archive"))
|
||||
require.NoError(b, err)
|
||||
|
||||
b.Cleanup(func() {
|
||||
require.NoError(b, theSource.Close())
|
||||
})
|
||||
@@ -88,6 +87,7 @@ func TestPkgCoverageImage(t *testing.T) {
|
||||
definedPkgs.Remove(string(pkg.TerraformPkg))
|
||||
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // we have coverage for pear instead
|
||||
definedPkgs.Remove(string(pkg.CondaPkg))
|
||||
definedPkgs.Remove(string(pkg.ModelPkg))
|
||||
|
||||
var cases []testCase
|
||||
cases = append(cases, commonTestCases...)
|
||||
@@ -125,7 +125,7 @@ func TestPkgCoverageImage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPkgCoverageDirectory(t *testing.T) {
|
||||
sbom, _ := catalogDirectory(t, "test-fixtures/image-pkg-coverage")
|
||||
sbom, _ := catalogDirectory(t, "testdata/image-pkg-coverage")
|
||||
|
||||
observedLanguages := strset.New()
|
||||
definedLanguages := strset.New()
|
||||
@@ -162,6 +162,7 @@ func TestPkgCoverageDirectory(t *testing.T) {
|
||||
definedPkgs.Remove(string(pkg.UnknownPkg))
|
||||
definedPkgs.Remove(string(pkg.CondaPkg))
|
||||
definedPkgs.Remove(string(pkg.PhpPeclPkg)) // this is covered as pear packages
|
||||
definedPkgs.Remove(string(pkg.ModelPkg))
|
||||
|
||||
// for directory scans we should not expect to see any of the following package types
|
||||
definedPkgs.Remove(string(pkg.KbPkg))
|
||||
@@ -260,7 +261,7 @@ func TestPkgCoverageImage_HasEvidence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPkgCoverageDirectory_HasEvidence(t *testing.T) {
|
||||
sbom, _ := catalogDirectory(t, "test-fixtures/image-pkg-coverage")
|
||||
sbom, _ := catalogDirectory(t, "testdata/image-pkg-coverage")
|
||||
|
||||
var cases []testCase
|
||||
cases = append(cases, commonTestCases...)
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
func TestFileCataloging_Default(t *testing.T) {
|
||||
cfg := options.DefaultCatalog().ToSBOMConfig(clio.Identification{})
|
||||
cfg = cfg.WithFilesConfig(filecataloging.DefaultConfig())
|
||||
sbom, _ := catalogDirectoryWithConfig(t, "test-fixtures/files", cfg)
|
||||
sbom, _ := catalogDirectoryWithConfig(t, "testdata/files", cfg)
|
||||
|
||||
var metadata map[file.Coordinates]file.Metadata
|
||||
|
||||
@@ -48,13 +48,13 @@ func TestFileCataloging_AllFiles(t *testing.T) {
|
||||
SkipFilesAboveSize: 30,
|
||||
},
|
||||
})
|
||||
sbom, _ := catalogDirectoryWithConfig(t, "test-fixtures/files", cfg)
|
||||
sbom, _ := catalogDirectoryWithConfig(t, "testdata/files", cfg)
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
testPath := func(path string) string {
|
||||
return filepath.Join(pwd, "test-fixtures/files", path)
|
||||
return filepath.Join(pwd, "testdata/files", path)
|
||||
}
|
||||
|
||||
metadata := map[file.Coordinates]file.Metadata{
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNpmPackageLockDirectory(t *testing.T) {
|
||||
sbom, _ := catalogDirectory(t, "test-fixtures/npm-lock")
|
||||
sbom, _ := catalogDirectory(t, "testdata/npm-lock")
|
||||
|
||||
foundPackages := strset.New()
|
||||
|
||||
@@ -32,10 +32,11 @@ func TestNpmPackageLockDirectory(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestYarnPackageLockDirectory(t *testing.T) {
|
||||
sbom, _ := catalogDirectory(t, "test-fixtures/yarn-lock")
|
||||
sbom, _ := catalogDirectory(t, "testdata/yarn-lock")
|
||||
|
||||
foundPackages := strset.New()
|
||||
expectedPackages := strset.New("async@0.9.2", "async@3.2.3", "merge-objects@1.0.5", "should-type@1.3.0", "@4lolo/resize-observer-polyfill@1.5.2")
|
||||
// merge-objects and should-type are devDependencies in package.json and are excluded by default
|
||||
expectedPackages := strset.New("async@0.9.2", "async@3.2.3", "@4lolo/resize-observer-polyfill@1.5.2")
|
||||
|
||||
for actualPkg := range sbom.Artifacts.Packages.Enumerate(pkg.NpmPkg) {
|
||||
for _, actualLocation := range actualPkg.Locations.ToSlice() {
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestBinaryElfRelationships(t *testing.T) {
|
||||
}
|
||||
|
||||
// run the test...
|
||||
sbom, _ := catalogFixtureImage(t, "elf-test-fixtures", source.SquashedScope)
|
||||
sbom, _ := catalogFixtureImage(t, "elf-testdata", source.SquashedScope)
|
||||
|
||||
// get a mapping of package names to their IDs
|
||||
nameToId := map[string]artifact.ID{}
|
||||
|
||||
@@ -278,7 +278,7 @@ func packageCatalogerExports(t *testing.T) map[string]exportTokenSet {
|
||||
if info.IsDir() ||
|
||||
!strings.HasSuffix(info.Name(), ".go") ||
|
||||
strings.HasSuffix(info.Name(), "_test.go") ||
|
||||
strings.Contains(path, "test-fixtures") ||
|
||||
strings.Contains(path, "testdata") ||
|
||||
strings.Contains(path, "internal") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -11,47 +10,52 @@ import (
|
||||
)
|
||||
|
||||
func TestPackageDeduplication(t *testing.T) {
|
||||
// this test verifies that package deduplication works correctly across layers.
|
||||
// The test fixture installs/upgrades packages in multiple stages, creating
|
||||
// duplicate RPM DB entries across layers. Without deduplication, we'd see ~600 packages.
|
||||
//
|
||||
// Note: we index by package name (not name-version) to be resilient to Rocky Linux
|
||||
// repo updates. Location counts are summed across all versions of each package.
|
||||
tests := []struct {
|
||||
scope source.Scope
|
||||
packageCount int
|
||||
instanceCount map[string]int
|
||||
locationCount map[string]int
|
||||
instanceCount map[string]int // how many distinct package instances (by name)
|
||||
locationCount map[string]int // total locations across ALL versions of each package
|
||||
}{
|
||||
{
|
||||
scope: source.AllLayersScope,
|
||||
packageCount: 175, // without deduplication this would be ~600
|
||||
packageCount: 176, // without deduplication this would be ~600
|
||||
instanceCount: map[string]int{
|
||||
"basesystem": 1,
|
||||
"wget": 1,
|
||||
"curl-minimal": 2, // upgraded in the image
|
||||
"curl-minimal": 2, // base + upgraded (2 different versions)
|
||||
"vsftpd": 1,
|
||||
"httpd": 1, // rpm, - we exclude binary
|
||||
"httpd": 1,
|
||||
},
|
||||
locationCount: map[string]int{
|
||||
"basesystem-11-13.el9": 5, // in all layers
|
||||
"curl-minimal-7.76.1-26.el9_3.2.0.1": 2, // base + wget layer
|
||||
"curl-minimal-7.76.1-31.el9": 3, // curl upgrade layer + all above layers
|
||||
"wget-1.21.1-8.el9_4": 4, // wget + all above layers
|
||||
"vsftpd-3.0.5-6.el9": 2, // vsftpd + all above layers
|
||||
"httpd-2.4.62-4.el9": 1, // last layer
|
||||
"basesystem": 5, // in all layers
|
||||
"curl-minimal": 5, // total across both versions (2 + 3)
|
||||
"wget": 4, // wget + all above layers
|
||||
"vsftpd": 2, // vsftpd + all above layers
|
||||
"httpd": 1, // last layer
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: source.SquashedScope,
|
||||
packageCount: 169,
|
||||
packageCount: 170,
|
||||
instanceCount: map[string]int{
|
||||
"basesystem": 1,
|
||||
"wget": 1,
|
||||
"curl-minimal": 1, // upgraded, but the most recent
|
||||
"curl-minimal": 1, // deduped to latest
|
||||
"vsftpd": 1,
|
||||
"httpd": 1, // rpm, binary is now excluded by overlap
|
||||
"httpd": 1,
|
||||
},
|
||||
locationCount: map[string]int{
|
||||
"basesystem-11-13.el9": 1,
|
||||
"curl-minimal-7.76.1-31.el9": 1, // upgrade
|
||||
"wget-1.21.1-8.el9_4": 1,
|
||||
"vsftpd-3.0.5-6.el9": 1,
|
||||
"httpd-2.4.62-4.el9": 1,
|
||||
"basesystem": 1,
|
||||
"curl-minimal": 1,
|
||||
"wget": 1,
|
||||
"vsftpd": 1,
|
||||
"httpd": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -59,35 +63,32 @@ func TestPackageDeduplication(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.scope), func(t *testing.T) {
|
||||
sbom, _ := catalogFixtureImage(t, "image-vertical-package-dups", tt.scope)
|
||||
|
||||
// verify binary packages have names
|
||||
for _, p := range sbom.Artifacts.Packages.Sorted() {
|
||||
if p.Type == pkg.BinaryPkg {
|
||||
assert.NotEmpty(t, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// verify exact package count
|
||||
assert.Equal(t, tt.packageCount, sbom.Artifacts.Packages.PackageCount())
|
||||
for name, expectedInstanceCount := range tt.instanceCount {
|
||||
|
||||
// verify instance count by package name
|
||||
for name, expectedCount := range tt.instanceCount {
|
||||
pkgs := sbom.Artifacts.Packages.PackagesByName(name)
|
||||
|
||||
// with multiple packages with the same name, something is wrong (or this is the wrong fixture)
|
||||
if assert.Len(t, pkgs, expectedInstanceCount, "unexpected package count for %s", name) {
|
||||
for _, p := range pkgs {
|
||||
nameVersion := fmt.Sprintf("%s-%s", name, p.Version)
|
||||
expectedLocationCount, ok := tt.locationCount[nameVersion]
|
||||
if !ok {
|
||||
t.Errorf("missing name-version: %s", nameVersion)
|
||||
continue
|
||||
}
|
||||
|
||||
// we should see merged locations (assumption, there was 1 location for each package)
|
||||
assert.Len(t, p.Locations.ToSlice(), expectedLocationCount, "unexpected location count for %s", nameVersion)
|
||||
|
||||
// all paths should match
|
||||
assert.Len(t, p.Locations.CoordinateSet().Paths(), 1, "unexpected location count for %s", nameVersion)
|
||||
}
|
||||
}
|
||||
assert.Len(t, pkgs, expectedCount, "unexpected instance count for %s", name)
|
||||
}
|
||||
|
||||
// verify total location count across all versions of each package
|
||||
for name, expectedLocCount := range tt.locationCount {
|
||||
pkgs := sbom.Artifacts.Packages.PackagesByName(name)
|
||||
totalLocations := 0
|
||||
for _, p := range pkgs {
|
||||
totalLocations += len(p.Locations.ToSlice())
|
||||
}
|
||||
assert.Equal(t, expectedLocCount, totalLocations, "unexpected total location count for %s", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../syft/pkg/cataloger/binary/test-fixtures/elf-test-fixtures
|
||||
-1
@@ -1 +0,0 @@
|
||||
See the syft/cataloger/java/test-fixtures/java-builds dir to generate test fixtures and copy to here manually.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# we should strive to not commit blobs to the repo and strive to keep the build process of how blobs are acquired in-repo.
|
||||
# this blob is generated from syft/syft/catalogers/java/test-fixtures/java-builds , however, preserving the build process
|
||||
# this blob is generated from syft/syft/catalogers/java/testdata/java-builds , however, preserving the build process
|
||||
# twice in the repo seems redundant (even via symlink). Given that the fixture is a few kilobytes in size, the build process is already
|
||||
# captured, and integration tests should only be testing if jars can be discovered (not necessarily depth in java detection
|
||||
# functionality), committing it seems like an acceptable exception.
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../../../../syft/pkg/cataloger/binary/testdata/elf-testdata
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user