Compare commits

..
Author SHA1 Message Date
Alex Goodman 5e75fcf49d [wip] add support for linux/arm64 for java openjdk binary classifier
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2023-11-29 10:23:31 -05:00
1175 changed files with 16446 additions and 118508 deletions
+6 -27
View File
@@ -1,21 +1,11 @@
tools:
# we want to use a pinned version of binny to manage the toolchain (so binny manages itself!)
- name: binny
version:
want: v0.8.0
want: v0.6.2
method: github-release
with:
repo: anchore/binny
# used to produce SBOMs during release
- name: syft
version:
want: latest
method: github-release
with:
repo: anchore/syft
# used to sign mac binaries at release
- name: quill
version:
want: v0.4.1
@@ -23,15 +13,13 @@ tools:
with:
repo: anchore/quill
# used for linting
- name: golangci-lint
version:
want: v1.59.1
want: v1.55.2
method: github-release
with:
repo: golangci/golangci-lint
# used for showing the changelog at release
- name: glow
version:
want: v1.5.1
@@ -39,15 +27,13 @@ tools:
with:
repo: charmbracelet/glow
# used for signing the checksums file at release
- name: cosign
version:
want: v2.2.4
want: v2.2.1
method: github-release
with:
repo: sigstore/cosign
# used in integration tests to verify JSON schemas
- name: yajsv
version:
want: v1.4.1
@@ -55,15 +41,13 @@ tools:
with:
repo: neilpa/yajsv
# used to release all artifacts
- name: goreleaser
version:
want: v2.0.1
want: v1.22.1
method: github-release
with:
repo: goreleaser/goreleaser
# used for organizing imports during static analysis
- name: gosimports
version:
want: v0.3.8
@@ -71,7 +55,6 @@ tools:
with:
repo: rinchsan/gosimports
# used at release to generate the changelog
- name: chronicle
version:
want: v0.8.0
@@ -79,7 +62,6 @@ tools:
with:
repo: anchore/chronicle
# used during static analysis for license compliance
- name: bouncer
version:
want: v0.4.0
@@ -87,7 +69,6 @@ tools:
with:
repo: wagoodman/go-bouncer
# used for showing benchmark testing
- name: benchstat
version:
want: latest
@@ -100,18 +81,16 @@ tools:
entrypoint: cmd/benchstat
module: golang.org/x/perf
# used for running all local and CI tasks
- name: task
version:
want: v3.37.2
want: v3.31.0
method: github-release
with:
repo: go-task/task
# used for triggering a release
- name: gh
version:
want: v2.51.0
want: v2.39.2
method: github-release
with:
repo: cli/cli
+1 -1
View File
@@ -1,4 +1,4 @@
enforce-v0: false
enforce-v0: true # don't make breaking-change label bump major version before 1.0.
title: ""
github:
+3 -3
View File
@@ -5,7 +5,7 @@ inputs:
go-version:
description: "Go version to install"
required: true
default: "1.22.x"
default: "1.21.x"
go-dependencies:
description: "Download go dependencies"
required: true
@@ -27,14 +27,14 @@ runs:
using: "composite"
steps:
# note: go mod and build is automatically cached on default with v4+
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
- uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe #v4.1.0
if: inputs.go-version != ''
with:
go-version: ${{ inputs.go-version }}
- name: Restore tool cache
id: tool-cache
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: ${{ github.workspace }}/.tool
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }}
+1 -16
View File
@@ -1,27 +1,12 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/.github/actions/boostrap"
schedule:
interval: "daily"
open-pull-requests-limit: 10
labels:
- "dependencies"
open-pull-requests-limit: 10
@@ -1,36 +0,0 @@
#!/bin/bash
# current limit for fixture size
size=1000
if [ $# -eq 0 ]; then
echo "Usage: $0 <directory>"
exit 1
fi
directory="$1"
# Remove trailing slash using parameter expansion
directory="${directory%/}"
if [ ! -d "$directory" ]; then
echo "Directory not found: $directory"
exit 1
fi
found_large_files=0
while IFS= read -r -d '' file; do
if [ $(wc -c < "$file") -gt $size ]; then
echo "File $file is greater than ${size} bytes."
found_large_files=1
fi
done < <(find "$directory" -type f -print0)
if [ "$found_large_files" -eq 1 ]; then
echo "Script failed: Some files are greater than ${size} bytes."
exit 1
else
echo "All files in $directory and its subdirectories are ${size} bytes or smaller. Check passed."
exit 0
fi
+6 -22
View File
@@ -56,8 +56,6 @@ def main(changed_files: str | None = None, merge_base_schema_files: str | None =
pr_json_schema_files = filter_to_schema_files(pr_changed_files)
pr_labels = get_pr_labels(pr_number)
# print("schema files in pr: ", summarize_schema_files(pr_json_schema_files))
# print("og schema files: ", summarize_schema_files(og_json_schema_files))
@@ -78,8 +76,7 @@ def main(changed_files: str | None = None, merge_base_schema_files: str | None =
add_label(pr_number, JSON_SCHEMA_LABEL)
else:
if JSON_SCHEMA_LABEL in pr_labels:
remove_label(pr_number, JSON_SCHEMA_LABEL)
remove_label(pr_number, JSON_SCHEMA_LABEL)
# new schema files should be scrutinized, comparing the latest and added versions to see if it's a breaking
# change (major version bump). Warn about it on the PR via adding a breaking-change label...
@@ -87,8 +84,7 @@ def main(changed_files: str | None = None, merge_base_schema_files: str | None =
print("\nBreaking change detected...")
add_label(pr_number, BREAKING_CHANGE_LABEL)
else:
if BREAKING_CHANGE_LABEL in pr_labels:
remove_label(pr_number, BREAKING_CHANGE_LABEL)
remove_label(pr_number, BREAKING_CHANGE_LABEL)
# modifying an existing schema could be a breaking change, we should warn about it on the PR via a comment...
# removing schema files should never be allowed, we should warn about it on the PR via a comment...
@@ -123,7 +119,7 @@ def add_label(pr_number: str, label: str):
# run "gh pr edit --add-label <label>"
result = run(f"gh pr edit {pr_number} --add-label {label}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print(f"Unable to add '{label!r}' label to PR, error:")
print(f"Unable to add {label!r} label to PR with")
print(str(result.stderr))
sys.exit(1)
@@ -132,7 +128,7 @@ def remove_label(pr_number: str, label: str):
# run "gh pr edit --remove-label <label>"
result = run(f"gh pr edit {pr_number} --remove-label {label}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print(f"Unable to remove '{label!r}' label from PR, error:")
print(f"Unable to label PR with {label!r}")
print(str(result.stderr))
sys.exit(1)
@@ -169,17 +165,6 @@ def get_pr_changed_files(pr_number: str) -> list[str]:
return list_of_files
def get_pr_labels(pr_number: str) -> list[str]:
result = run(f"gh pr view {pr_number} --json labels --jq '.labels[].name'", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print("Unable to get list of labels on PR")
print(str(result.stderr))
sys.exit(1)
list_of_labels = result.stdout.splitlines()
return list_of_labels
def filter_to_schema_files(list_of_files: list[str]) -> list[str]:
# get files matching "schema/json/schema-*.json"
files = []
@@ -191,8 +176,7 @@ def filter_to_schema_files(list_of_files: list[str]) -> list[str]:
def list_json_schema_files() -> list[str]:
# list files in "schema/json" directory matching the pattern of "schema-*.json"
# special case: always ignore the "latest" schema file
return sort_json_schema_files([f for f in glob.glob("schema/json/schema-*.json") if "latest" not in f])
return sort_json_schema_files(list(glob.glob("schema/json/schema-*.json")))
def run(command: str, **kwargs) -> subprocess.CompletedProcess:
@@ -213,7 +197,7 @@ def sort_json_schema_files(files: list[str]) -> list[str]:
# so that "schema/json/schema-1.2.1.json" comes before "schema/json/schema-1.12.1.json".
versions = [get_semver(file) for file in files if file]
versions = sorted(versions, key=lambda s: [int(u) for u in s.split('.') if "." in s])
versions = sorted(versions, key=lambda s: [int(u) for u in s.split('.')])
return [f"schema/json/schema-{version}.json" for version in versions]
-5
View File
@@ -60,11 +60,6 @@ class Labeler(unittest.TestCase):
expected_sorted_files = ["schema/json/schema-1.2.1.json", "schema/json/schema-1.12.1.json"]
self.assertEqual(labeler.sort_json_schema_files(files), expected_sorted_files)
# ensure that "latest" doesn't cause a problem and is ultimately ignored
files = ["schema/json/schema-1.12.1.json", "schema/json/schema-_bogus.json"]
expected_sorted_files = ["schema/json/schema-_bogus.json", "schema/json/schema-1.12.1.json"]
self.assertEqual(labeler.sort_json_schema_files(files), expected_sorted_files)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -2,8 +2,8 @@
set -ue
BIN="syft"
VERSION_FILE="VERSION"
VERSION=$1
DISTDIR=$1
VERSION=$2
# the source of truth as to whether we want to notify users of an update is if the release just created is NOT
# flagged as a pre-release on github
@@ -12,9 +12,10 @@ if [[ "$(curl -SsL https://api.github.com/repos/anchore/${BIN}/releases/tags/${V
exit 0
fi
echo "creating and publishing version file (${VERSION})"
echo "creating and publishing version file"
# create a version file for version-update checks
VERSION_FILE="${DISTDIR}/VERSION"
echo "${VERSION}" | tee "${VERSION_FILE}"
# upload the version file that supports the application version update check
+4 -4
View File
@@ -17,13 +17,13 @@ jobs:
# the job by event.
steps:
- name: Checkout code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Restore base benchmark result
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: test/results/benchmark-main.txt
# use base sha for PR or new commit hash for main push in benchmark result key
@@ -39,13 +39,13 @@ jobs:
OUTPUT="${OUTPUT//$'\r'/'%0D'}" # URL encode all '\r' characters
echo "result=$OUTPUT" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
- uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: benchmark-test-results
path: test/results/**/*
- name: Update PR benchmark results comment
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 #v2.9.0
uses: marocchino/sticky-pull-request-comment@efaaab3fd41a9c3de579aba759d2552635e590fd #v2.8.0
continue-on-error: true
with:
header: benchmark
+6 -6
View File
@@ -20,7 +20,7 @@ permissions:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-22.04-4core-16gb
runs-on: ubuntu-latest
permissions:
security-events: write
@@ -36,16 +36,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Install Go
uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 #v5.0.1
uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe #v4.1.0
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@23acc5c183826b7a8a97bce3cecc52db901f8251 #v3.25.10
uses: github/codeql-action/init@407ffafae6a767df3e0230c3df91b6443ae8df75 #v2.22.8
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,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@23acc5c183826b7a8a97bce3cecc52db901f8251 #v3.25.10
uses: github/codeql-action/autobuild@407ffafae6a767df3e0230c3df91b6443ae8df75 #v2.22.8
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -70,4 +70,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@23acc5c183826b7a8a97bce3cecc52db901f8251 #v3.25.10
uses: github/codeql-action/analyze@407ffafae6a767df3e0230c3df91b6443ae8df75 #v2.22.8
@@ -1,10 +0,0 @@
name: Dependabot Automation
on:
pull_request:
permissions:
pull-requests: write
jobs:
run:
uses: anchore/workflows/.github/workflows/dependabot-automation.yaml@main
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- run: python .github/scripts/labeler.py
env:
@@ -40,7 +40,7 @@ jobs:
- name: Delete existing comment
if: ${{ hashFiles( env.CI_COMMENT_FILE ) == '' }}
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 #v2.9.0
uses: marocchino/sticky-pull-request-comment@efaaab3fd41a9c3de579aba759d2552635e590fd #v2.8.0
with:
header: ${{ env.COMMENT_HEADER }}
hide: true
@@ -48,7 +48,7 @@ jobs:
- name: Add comment
if: ${{ hashFiles( env.CI_COMMENT_FILE ) != '' }}
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 #v2.9.0
uses: marocchino/sticky-pull-request-comment@efaaab3fd41a9c3de579aba759d2552635e590fd #v2.8.0
with:
header: ${{ env.COMMENT_HEADER }}
path: ${{ env.CI_COMMENT_FILE }}
+48
View File
@@ -0,0 +1,48 @@
name: "Release Homebrew"
on:
workflow_dispatch:
release:
types:
- released
jobs:
homebrew:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
with:
# we need to be able to get the commit for a tag
fetch-depth: 0
- name: Get release info
id: release_info
run: |
echo -n "tag_name=$(gh release view --json tagName --jq '.tagName')" | tee -a $GITHUB_OUTPUT
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get commit info
id: git_info
run: |
git checkout ${{ steps.release_info.outputs.tag_name }}
echo -n "commit=$(git rev-parse HEAD)" | tee -a $GITHUB_OUTPUT
- name: Update Homebrew formula
uses: dawidd6/action-homebrew-bump-formula@d3667e5ae14df19579e4414897498e3e88f2f458 # v3.10.0
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
org: anchore
formula: syft
tag: ${{ steps.release_info.outputs.tag_name }}
revision: ${{ steps.git_info.outputs.commit }}
- uses: 8398a7/action-slack@fbd6aa58ba854a740e11a35d0df80cb5d12101d8 #v3.15.1
if: ${{ failure() }}
with:
status: ${{ job.status }}
fields: repo,workflow,action,eventName
text: "Post-release homebrew publish workflow has failed: https://github.com/anchore/syft/actions/workflows/release-homebrew.yaml"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
@@ -1,31 +0,0 @@
name: "Release"
on:
workflow_dispatch:
inputs:
version:
description: release version to update the version file with (prefixed with v)
required: true
workflow_call:
inputs:
version:
type: string
description: release version to update the version file with (prefixed with v)
required: true
jobs:
release:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Update version file
run: make ci-release-version-file
env:
RELEASE_VERSION: ${{ github.event.inputs.version }}
# for updating the VERSION file in S3...
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
+18 -32
View File
@@ -15,15 +15,7 @@ jobs:
environment: release
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- 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:
# --certificate-identity-regexp "https://github.com/anchore/.github/workflows/release.yaml@refs/heads/main"
# if we are not on the main branch, the signature will not be verifiable since the suffix requires the main branch
# at the time of when the OIDC token was issued on the Github Actions runner.
run: echo "This can only be run on the main branch otherwise releases produced will not be verifiable with cosign" && exit 1
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Check if tag already exists
# note: this will fail if the tag already exists
@@ -32,7 +24,7 @@ jobs:
git tag ${{ github.event.inputs.version }}
- name: Check static analysis results
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0
id: static-analysis
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -41,7 +33,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Check unit test results
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0
id: unit
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -50,7 +42,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Check integration test results
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0
id: integration
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +51,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Check acceptance test results (linux)
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0
id: acceptance-linux
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -68,7 +60,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Check acceptance test results (mac)
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0
id: acceptance-mac
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -77,7 +69,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Check cli test results (linux)
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@297be350cf8393728ea4d4b39435c7d7ae167c93 # v1.1.0
id: cli-linux
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -102,10 +94,8 @@ jobs:
permissions:
contents: write
packages: write
# required for goreleaser signs section with cosign
id-token: write
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
with:
fetch-depth: 0
@@ -113,13 +103,13 @@ jobs:
uses: ./.github/actions/bootstrap
- name: Login to Docker Hub
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 #v3.2.0
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d #v3.0.0
with:
username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }}
password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }}
username: ${{ secrets.TOOLBOX_DOCKER_USER }}
password: ${{ secrets.TOOLBOX_DOCKER_PASS }}
- name: Login to GitHub Container Registry
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 #v3.2.0
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d #v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -146,14 +136,17 @@ jobs:
# for creating the release (requires write access to packages and content)
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# for updating brew formula in anchore/homebrew-syft
GITHUB_BREW_TOKEN: ${{ secrets.ANCHOREOPS_GITHUB_OSS_WRITE_TOKEN }}
GITHUB_BREW_TOKEN: ${{ secrets.ANCHORE_GIT_READ_TOKEN }}
# for updating the VERSION file in S3...
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
- uses: anchore/sbom-action@e8d2a6937ecead383dfe75190d104edd1f9c5751 #v0.16.0
- uses: anchore/sbom-action@fd74a6fb98a204a1ad35bbfae0122c1a302ff88b #v0.15.0
continue-on-error: true
with:
artifact-name: sbom.spdx.json
- uses: 8398a7/action-slack@28ba43ae48961b90635b50953d216767a6bea486 #v3.16.2
- uses: 8398a7/action-slack@fbd6aa58ba854a740e11a35d0df80cb5d12101d8 #v3.15.1
continue-on-error: true
with:
status: ${{ job.status }}
@@ -162,10 +155,3 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
if: ${{ success() }}
release-version-file:
needs: [release]
uses: ./.github/workflows/release-version-file.yaml
with:
version: ${{ github.event.inputs.version }}
secrets: inherit
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'anchore/syft' # only run for main repo
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
@@ -50,7 +50,7 @@ jobs:
app_id: ${{ secrets.TOKEN_APP_ID }}
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
- uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c #v6.1.0
- uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 #v5.0.2
with:
signoff: true
delete-branch: true
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'anchore/syft' # only run for main repo
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
@@ -33,7 +33,7 @@ jobs:
app_id: ${{ secrets.TOKEN_APP_ID }}
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
- uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c #v6.1.0
- uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 #v5.0.2
with:
signoff: true
delete-branch: true
@@ -45,7 +45,7 @@ jobs:
Update CPE dictionary index based on the latest available CPE dictionary
token: ${{ steps.generate-token.outputs.token }}
- uses: 8398a7/action-slack@28ba43ae48961b90635b50953d216767a6bea486 #v3.16.2
- uses: 8398a7/action-slack@fbd6aa58ba854a740e11a35d0df80cb5d12101d8 #v3.15.1
with:
status: ${{ job.status }}
fields: workflow,eventName,job
@@ -17,9 +17,9 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'anchore/syft' # only run for main repo
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 #v5.0.1
- uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe #v4.1.0
with:
go-version: ${{ env.GO_VERSION }}
stable: ${{ env.GO_STABLE_VERSION }}
@@ -44,7 +44,7 @@ jobs:
app_id: ${{ secrets.TOKEN_APP_ID }}
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
- uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c #v6.1.0
- uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 #v5.0.2
with:
signoff: true
delete-branch: true
+24 -38
View File
@@ -17,7 +17,7 @@ jobs:
name: "Static analysis"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
@@ -31,49 +31,37 @@ jobs:
name: "Unit tests"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Restore file executable test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
with:
path: syft/file/cataloger/executable/test-fixtures/elf/bin
key: ${{ runner.os }}-unit-file-executable-elf-cache-${{ hashFiles( 'syft/file/cataloger/executable/test-fixtures/elf/cache.fingerprint' ) }}
- name: Restore file executable shared-info test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
with:
path: syft/file/cataloger/executable/test-fixtures/shared-info/bin
key: ${{ runner.os }}-unit-file-executable-shared-info-cache-${{ hashFiles( 'syft/file/cataloger/executable/test-fixtures/shared-info/cache.fingerprint' ) }}
- name: Restore Java test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: syft/pkg/cataloger/java/test-fixtures/java-builds/packages
key: ${{ runner.os }}-unit-java-cache-${{ hashFiles( 'syft/pkg/cataloger/java/test-fixtures/java-builds/cache.fingerprint' ) }}
- name: Restore RPM test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: syft/pkg/cataloger/redhat/test-fixtures/rpms
key: ${{ runner.os }}-unit-rpm-cache-${{ hashFiles( 'syft/pkg/cataloger/redhat/test-fixtures/rpms.fingerprint' ) }}
- name: Restore go binary test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: syft/pkg/cataloger/golang/test-fixtures/archs/binaries
key: ${{ runner.os }}-unit-go-binaries-cache-${{ hashFiles( 'syft/pkg/cataloger/golang/test-fixtures/archs/binaries.fingerprint' ) }}
- name: Restore binary cataloger test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: syft/pkg/cataloger/binary/test-fixtures/classifiers/bin
path: syft/pkg/cataloger/binary/test-fixtures/classifiers/dynamic
key: ${{ runner.os }}-unit-binary-cataloger-cache-${{ hashFiles( 'syft/pkg/cataloger/binary/test-fixtures/cache.fingerprint' ) }}
- name: Restore Kernel test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: syft/pkg/cataloger/kernel/test-fixtures/cache
key: ${{ runner.os }}-unit-kernel-cache-${{ hashFiles( 'syft/pkg/cataloger/kernel/test-fixtures/cache.fingerprint' ) }}
@@ -87,7 +75,7 @@ jobs:
name: "Integration tests"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
@@ -96,10 +84,10 @@ jobs:
run: make validate-cyclonedx-schema
- name: Restore integration test cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: ${{ github.workspace }}/cmd/syft/internal/test/integration/test-fixtures/cache
key: ${{ runner.os }}-integration-test-cache-${{ hashFiles('/cmd/syft/internal/test/integration/test-fixtures/cache.fingerprint') }}
path: ${{ github.workspace }}/test/integration/test-fixtures/cache
key: ${{ runner.os }}-integration-test-cache-${{ hashFiles('test/integration/test-fixtures/cache.fingerprint') }}
- name: Run integration tests
run: make integration
@@ -109,7 +97,7 @@ jobs:
name: "Build snapshot artifacts"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
@@ -122,7 +110,7 @@ jobs:
# 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@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache/save@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
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
@@ -139,14 +127,14 @@ jobs:
needs: [Build-Snapshot-Artifacts]
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Download snapshot build
id: snapshot-cache
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache/restore@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: |
snapshot
@@ -164,7 +152,7 @@ jobs:
- name: Restore install.sh test image cache
id: install-test-image-cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: ${{ github.workspace }}/test/install/cache
key: ${{ runner.os }}-install-test-image-cache-${{ hashFiles('test/install/cache.fingerprint') }}
@@ -187,10 +175,7 @@ jobs:
needs: [Build-Snapshot-Artifacts]
runs-on: macos-latest
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@v3.5.0
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
@@ -198,10 +183,11 @@ jobs:
bootstrap-apt-packages: ""
compute-fingerprints: "false"
go-dependencies: false
go-version: ""
- name: Download snapshot build
id: snapshot-cache
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache/restore@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: |
snapshot
@@ -216,7 +202,7 @@ jobs:
- name: Restore docker image cache for compare testing
id: mac-compare-testing-cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: image.tar
key: ${{ runner.os }}-${{ hashFiles('test/compare/mac.sh') }}
@@ -234,20 +220,20 @@ jobs:
needs: [Build-Snapshot-Artifacts]
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Restore CLI test-fixture cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: ${{ github.workspace }}/test/cli/test-fixtures/cache
key: ${{ runner.os }}-cli-test-cache-${{ hashFiles('test/cli/test-fixtures/cache.fingerprint') }}
- name: Download snapshot build
id: snapshot-cache
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
uses: actions/cache/restore@704facf57e6136b1bc63b828d79edcd491f0ee84 #v3.3.2
with:
path: |
snapshot
+1
View File
@@ -32,6 +32,7 @@ VERSION
/test/results
coverage.txt
*.log
test/integration/test-fixtures/**/go.sum
# probable archives
.images
-8
View File
@@ -49,18 +49,10 @@ linters-settings:
# If lower than 0, disable the check.
# Default: 40
statements: 50
gocritic:
enabled-checks:
- deferInLoop
- ruleguard
settings:
ruleguard:
rules: "test/rules/rules.go"
output:
uniq-by-line: false
run:
timeout: 10m
tests: false
# do not enable...
# - deadcode # The owner seems to have abandoned the linter. Replaced by "unused".
+1 -28
View File
@@ -1,5 +1,3 @@
version: 2
release:
prerelease: auto
draft: false
@@ -82,7 +80,7 @@ nfpms:
- deb
brews:
- repository:
- tap:
owner: anchore
name: homebrew-syft
token: "{{.Env.GITHUB_BREW_TOKEN}}"
@@ -249,28 +247,3 @@ docker_manifests:
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
- ghcr.io/anchore/syft:{{.Tag}}-s390x
sboms:
- artifacts: archive
# this is relative to the snapshot/dist directory, not the root of the repo
cmd: ../.tool/syft
documents:
- "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"
args:
- "scan"
- "$artifact"
- "--output"
- "json=$document"
signs:
- cmd: .tool/cosign
signature: "${artifact}.sig"
certificate: "${artifact}.pem"
args:
- "sign-blob"
- "--oidc-issuer=https://token.actions.githubusercontent.com"
- "--output-certificate=${certificate}"
- "--output-signature=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
+1 -1
View File
@@ -234,7 +234,7 @@ rough outline how that works:
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
- `integration`: located within `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
+764 -129
View File
@@ -2,27 +2,25 @@
<img src="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png" width="271" alt="Cute pink owl syft logo">
</p>
# Syft
[![Validations](https://github.com/anchore/syft/actions/workflows/validations.yaml/badge.svg)](https://github.com/anchore/syft/actions/workflows/validations.yaml)
[![Go Report Card](https://goreportcard.com/badge/github.com/anchore/syft)](https://goreportcard.com/report/github.com/anchore/syft)
[![GitHub release](https://img.shields.io/github/release/anchore/syft.svg)](https://github.com/anchore/syft/releases/latest)
[![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/anchore/syft.svg)](https://github.com/anchore/syft)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/anchore/syft/blob/main/LICENSE)
[![Slack Invite](https://img.shields.io/badge/Slack-Join-blue?logo=slack)](https://anchore.com/slack)
**A CLI tool and Go library for generating a Software Bill of Materials (SBOM) from container images and filesystems. Exceptional for vulnerability detection when used with a scanner like [Grype](https://github.com/anchore/grype).**
A CLI tool and Go library for generating a Software Bill of Materials (SBOM) from container images and filesystems. Exceptional for vulnerability detection when used with a scanner like [Grype](https://github.com/anchore/grype).
<p align="center">
&nbsp;<a href="https://github.com/anchore/syft/actions/workflows/validations.yaml" target="_blank"><img alt="Validations" src="https://github.com/anchore/syft/actions/workflows/validations.yaml/badge.svg"></a>&nbsp;
&nbsp;<a href="https://goreportcard.com/report/github.com/anchore/syft" target="_blank"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/anchore/syft"></a>&nbsp;
&nbsp;<a href="https://github.com/anchore/syft/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/release/anchore/syft.svg"></a>&nbsp;
&nbsp;<a href="https://github.com/anchore/syft" target="_blank"><img alt="GitHub go.mod Go version" src="https://img.shields.io/github/go-mod/go-version/anchore/syft.svg"></a>&nbsp;
&nbsp;<a href"" target="_blank"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/License-Apache%202.0-blue.svg"></a>&nbsp;
&nbsp;<a href="https://anchore.com/slack" target="_blank"><img alt="Slack" src="https://img.shields.io/badge/Slack-Join-blue?logo=slack"></a>&nbsp;
</p>
### Join our community meetings!
- Calendar: https://calendar.google.com/calendar/u/0/r?cid=Y182OTM4dGt0MjRtajI0NnNzOThiaGtnM29qNEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t
- 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)
- All are welcome!
For commercial support options with Syft or Grype, please [contact Anchore](https://get.anchore.com/contact/)
![syft-demo](https://user-images.githubusercontent.com/590471/90277200-2a253000-de33-11ea-893f-32c219eea11a.gif)
## 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
@@ -31,94 +29,6 @@ Syft development is sponsored by [Anchore](https://anchore.com/), and is release
- 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.
## Installation
Syft binaries are provided for Linux, macOS and Windows.
### Recommended
> ```bash
> curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | 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
```bash
brew install syft
```
### Scoop
```powershell
scoop install syft
```
### 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/tools/admin/syft/default.nix). Syft is available in the [stable channel](https://wiki.nixos.org/wiki/Nix_channels#The_official_channels) since NixOS `22.05`.
```bash
nix-env -i syft
```
... or, just try it out in an ephemeral nix shell:
```bash
nix-shell -p syft
```
## Getting started
### SBOM
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)
@@ -144,33 +54,758 @@ Note that flags using the @<version> can be used for earlier versions of each sp
- Ruby (gem)
- Rust (cargo.lock)
- Swift (cocoapods, swift-package-manager)
- Wordpress plugins
## Documentation
## Installation
Our [wiki](https://github.com/anchore/syft/wiki) contains further details on the following topics:
**Note**: Currently, Syft is built only for Linux, macOS and Windows.
* [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)
### Recommended
```bash
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
```
## Syft Team Meetings
... or, you can specify a release version and destination directory for the installation:
The Syft Team hold 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!
```
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b <DESTINATION_DIR> <RELEASE_VERSION>
```
### Chocolatey
The chocolatey distribution of syft is community maintained and not distributed by the anchore team
```powershell
choco install syft -y
```
### Scoop
```powershell
scoop install syft
```
### Homebrew
```bash
brew install syft
```
### Nix
**Note**: Nix packaging of Syft is [community maintained](https://github.com/NixOS/nixpkgs/blob/master/pkgs/tools/admin/syft/default.nix). Syft is available in the [stable channel](https://nixos.wiki/wiki/Nix_channels#The_official_channels) since NixOS `22.05`.
```bash
nix-env -i syft
```
... or, just try it out in an ephemeral nix shell:
```bash
nix-shell -p syft
```
## Getting started
### SBOM
To generate an SBOM for a container image:
```
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`:
```
syft <image> --scope all-layers
```
### Supported sources
Syft can generate a SBOM from a variety of sources:
```
# catalog a container image archive (from the result of `docker image save ...`, `podman save ...`, or `skopeo copy` commands)
syft path/to/image.tar
# catalog a Singularity Image Format (SIF) container
syft path/to/image.sif
# catalog a directory
syft path/to/dir
```
Sources can be explicitly provided with a scheme:
```
docker:yourrepo/yourimage:tag use images from the Docker daemon
podman:yourrepo/yourimage:tag use images from the Podman daemon
containerd:yourrepo/yourimage:tag use images from the Containerd daemon
docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save"
oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise)
oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
singularity:path/to/yourimage.sif read directly from a Singularity Image Format (SIF) container on disk
dir:path/to/yourproject read directly from a path on disk (any directory)
file:path/to/yourproject/file read directly from a path on disk (any single file)
registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
```
If an image source is not provided and cannot be detected from the given reference it is assumed the image should be pulled from the Docker daemon.
If docker is not present, then the Podman daemon is attempted next, followed by reaching out directly to the image registry last.
This default behavior can be overridden with the `default-image-pull-source` configuration option (See [Configuration](https://github.com/anchore/syft#configuration) for more details).
### Default Cataloger Configuration by scan type
Syft uses different default sets of catalogers depending on what it is scanning: a container image or a directory on disk. The default catalogers for an image scan assumes that package installation steps have already been completed. For example, Syft will identify Python packages that have egg or wheel metadata files under a site-packages directory, since this indicates software actually installed on an image.
However, if you are scanning a directory, Syft doesn't assume that all relevant software is installed, and will use catalogers that can identify declared dependencies that may not yet be installed on the final system: for example, dependencies listed in a Python requirements.txt.
You can override the list of enabled/disabled catalogers by using the "catalogers" keyword in the [Syft configuration file](https://github.com/anchore/syft#configuration).
##### Image Scanning:
- alpmdb
- apkdb
- binary
- dotnet-deps
- dotnet-portable-executable
- dpkgdb
- go-module-binary
- graalvm-native-image
- java
- javascript-package
- linux-kernel
- nix-store
- php-composer-installed
- portage
- python-package
- rpm-db
- ruby-gemspec
- sbom
##### Directory Scanning:
- alpmdb
- apkdb
- binary
- cocoapods
- conan
- dartlang-lock
- dotnet-deps
- dotnet-portable-executable
- dpkgdb
- elixir-mix-lock
- erlang-rebar-lock
- go-mod-file
- go-module-binary
- graalvm-native-image
- haskell
- java
- java-gradle-lockfile
- java-pom
- javascript-lock
- linux-kernel
- nix-store
- php-composer-lock
- portage
- python-index
- python-package
- rpm-db
- rpm-file
- ruby-gemfile
- rust-cargo-lock
- sbom
- swift-package-manager
##### Non Default:
- cargo-auditable-binary
### Excluding file paths
Syft can exclude files and paths from being scanned within a source by using glob expressions
with one or more `--exclude` parameters:
```
syft <source> --exclude './out/**/*.json' --exclude /etc
```
**Note:** in the case of _image scanning_, since the entire filesystem is scanned it is
possible to use absolute paths like `/etc` or `/usr/**/*.txt` whereas _directory scans_
exclude files _relative to the specified directory_. For example: scanning `/usr/foo` with
`--exclude ./package.json` would exclude `/usr/foo/package.json` and `--exclude '**/package.json'`
would exclude all `package.json` files under `/usr/foo`. For _directory scans_,
it is required to begin path expressions with `./`, `*/`, or `**/`, all of which
will be resolved _relative to the specified scan directory_. Keep in mind, your shell
may attempt to expand wildcards, so put those parameters in single quotes, like:
`'**/*.json'`.
### 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.4 specification](https://cyclonedx.org/specification/overview/).
- `cyclonedx-json`: A JSON report conforming to the [CycloneDX 1.4 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.
## Using templates
Syft lets you define custom output formats, using [Go templates](https://pkg.go.dev/text/template). Here's how it works:
- Define your format as a Go template, and save this template as a file.
- Set the output format to "template" (`-o template`).
- Specify the path to the template file (`-t ./path/to/custom.template`).
- Syft's template processing uses the same data models as the `syft-json` output format — so if you're wondering what data is available as you author a template, you can use the output from `syft <image> -o syft-json` as a reference.
**Example:** You could make Syft output data in CSV format by writing a Go template that renders CSV data and then running `syft <image> -o template -t ~/path/to/csv.tmpl`.
Here's what the `csv.tmpl` file might look like:
```gotemplate
"Package","Version Installed","Found by"
{{- range .Artifacts}}
"{{.Name}}","{{.Version}}","{{.FoundBy}}"
{{- end}}
```
Which would produce output like:
```text
"Package","Version Installed","Found by"
"alpine-baselayout","3.2.0-r20","apkdb-cataloger"
"alpine-baselayout-data","3.2.0-r20","apkdb-cataloger"
"alpine-keys","2.4-r1","apkdb-cataloger"
...
```
Syft also includes a vast array of utility templating functions from [sprig](http://masterminds.github.io/sprig/) apart from the default Golang [text/template](https://pkg.go.dev/text/template#hdr-Functions) to allow users to customize the output format.
Lastly, Syft has custom templating functions defined in `./syft/format/template/encoder.go` to help parse the passed-in JSON structs.
## Multiple outputs
Syft can also output _multiple_ files in differing formats by appending
`=<file>` to the option, for example to output Syft JSON and SPDX JSON:
```shell
syft <image> -o syft-json=sbom.syft.json -o spdx-json=sbom.spdx.json
```
## Private Registry Authentication
### Local Docker Credentials
When a container runtime is not present, Syft can still utilize credentials configured in common credential sources (such as `~/.docker/config.json`). It will pull images from private registries using these credentials. The config file is where your credentials are stored when authenticating with private registries via some command like `docker login`. For more information see the `go-containerregistry` [documentation](https://github.com/google/go-containerregistry/tree/main/pkg/authn).
An example `config.json` looks something like this:
```json
{
"auths": {
"registry.example.com": {
"username": "AzureDiamond",
"password": "hunter2"
}
}
}
```
You can run the following command as an example. It details the mount/environment configuration a container needs to access a private registry:
```
docker run -v ./config.json:/config/config.json -e "DOCKER_CONFIG=/config" anchore/syft:latest <private_image>
```
### Docker Credentials in Kubernetes
Here's a simple workflow to mount this config file as a secret into a container on Kubernetes.
1. Create a secret. The value of `config.json` is important. It refers to the specification detailed [here](https://github.com/google/go-containerregistry/tree/main/pkg/authn#the-config-file). Below this section is the `secret.yaml` file that the pod configuration will consume as a volume. The key `config.json` is important. It will end up being the name of the file when mounted into the pod.
```yaml
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: registry-config
namespace: syft
data:
config.json: <base64 encoded config.json>
```
`kubectl apply -f secret.yaml`
2. Create your pod running syft. The env `DOCKER_CONFIG` is important because it advertises where to look for the credential file. In the below example, setting `DOCKER_CONFIG=/config` informs syft that credentials can be found at `/config/config.json`. This is why we used `config.json` as the key for our secret. When mounted into containers the secrets' key is used as the filename. The `volumeMounts` section mounts our secret to `/config`. The `volumes` section names our volume and leverages the secret we created in step one.
```yaml
# pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: syft-k8s-usage
spec:
containers:
- image: anchore/syft:latest
name: syft-private-registry-demo
env:
- name: DOCKER_CONFIG
value: /config
volumeMounts:
- mountPath: /config
name: registry-config
readOnly: true
args:
- <private_image>
volumes:
- name: registry-config
secret:
secretName: registry-config
```
`kubectl apply -f pod.yaml`
3. The user can now run `kubectl logs syft-private-registry-demo`. The logs should show the Syft analysis for the `<private_image>` provided in the pod configuration.
Using the above information, users should be able to configure private registry access without having to do so in the `grype` or `syft` configuration files. They will also not be dependent on a Docker daemon, (or some other runtime software) for registry configuration and access.
## Format conversion (experimental)
The ability to convert existing SBOMs means you can create SBOMs in different formats quickly, without the need to regenerate the SBOM from scratch, which may take significantly more time.
```
syft convert <ORIGINAL-SBOM-FILE> -o <NEW-SBOM-FORMAT>[=<NEW-SBOM-FILE>]
```
This feature is experimental and data might be lost when converting formats. Packages are the main SBOM component easily transferable across formats, whereas files and relationships, as well as other information Syft doesn't support, are more likely to be lost.
We support formats with wide community usage AND good encode/decode support by Syft. The supported formats are:
- Syft JSON (```-o syft-json```)
- SPDX 2.2 JSON (```-o spdx-json```)
- SPDX 2.2 tag-value (```-o spdx-tag-value```)
- CycloneDX 1.4 JSON (```-o cyclonedx-json```)
- CycloneDX 1.4 XML (```-o cyclonedx-xml```)
Conversion example:
```sh
syft alpine:latest -o syft-json=sbom.syft.json # generate a syft SBOM
syft convert sbom.syft.json -o cyclonedx-json=sbom.cdx.json # convert it to CycloneDX
```
## Attestation (experimental)
### Keyless support
Syft supports generating attestations using cosign's [keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) signatures.
Note: users need to have >= v1.12.0 of cosign installed for this command to function
To use this feature with a format like CycloneDX json simply run:
```
syft attest --output cyclonedx-json <IMAGE WITH OCI WRITE ACCESS>
```
This command will open a web browser and allow the user to authenticate their OIDC identity as the root of trust for the attestation (Github, Google, Microsoft).
After authenticating, Syft will upload the attestation to the OCI registry specified by the image that the user has write access to.
You will need to make sure your credentials are configured for the OCI registry you are uploading to so that the attestation can write successfully.
Users can then verify the attestation(or any image with attestations) by running:
```
COSIGN_EXPERIMENTAL=1 cosign verify-attestation <IMAGE_WITH_ATTESTATIONS>
```
Users should see that the uploaded attestation claims are validated, the claims exist within the transparency log, and certificates on the attestations were verified against [fulcio](https://github.com/SigStore/fulcio).
There will also be a printout of the certificates subject `<user identity>` and the certificate issuer URL: `<provider of user identity (Github, Google, Microsoft)>`:
```
Certificate subject: test.email@testdomain.com
Certificate issuer URL: https://accounts.google.com
```
### Local private key support
To generate an SBOM attestation for a container image using a local private key:
```
syft attest --output [FORMAT] --key [KEY] [SOURCE] [flags]
```
The above output is in the form of the [DSSE envelope](https://github.com/secure-systems-lab/dsse/blob/master/envelope.md#dsse-envelope).
The payload is a base64 encoded `in-toto` statement with the generated SBOM as the predicate. For details on workflows using this command see [here](#adding-an-sbom-to-an-image-as-an-attestation-using-syft).
## Configuration
Configuration search paths:
- `.syft.yaml`
- `.syft/config.yaml`
- `~/.syft.yaml`
- `<XDG_CONFIG_HOME>/syft/config.yaml`
Configuration options (example values are the default):
```yaml
# the output format(s) of the SBOM report (options: syft-table, syft-text, syft-json, spdx-json, ...)
# same as -o, --output, and SYFT_OUTPUT env var
# to specify multiple output files in differing formats, use a list:
# output:
# - "syft-json=<syft-json-output-file>"
# - "spdx-json=<spdx-json-output-file>"
output: "syft-table"
# suppress all output (except for the SBOM report)
# same as -q ; SYFT_QUIET env var
quiet: false
# same as --file; write output report to a file (default is to write to stdout)
file: ""
# enable/disable checking for application updates on startup
# same as SYFT_CHECK_FOR_APP_UPDATE env var
check-for-app-update: true
# allows users to specify which image source should be used to generate the sbom
# valid values are: registry, docker, podman
default-image-pull-source: ""
# a list of globs to exclude from scanning. same as --exclude ; for example:
# exclude:
# - "/etc/**"
# - "./out/**/*.json"
exclude: []
# allows users to exclude synthetic binary packages from the sbom
# these packages are removed if an overlap with a non-synthetic package is found
exclude-binary-overlap-by-ownership: true
# os and/or architecture to use when referencing container images (e.g. "windows/armv6" or "arm64")
# same as --platform; SYFT_PLATFORM env var
platform: ""
# set the list of package catalogers to use when generating the SBOM
# default = empty (cataloger set determined automatically by the source type [image or file/directory])
# catalogers:
# - alpm-db-cataloger
# - apkdb-cataloger
# - binary-cataloger
# - cargo-auditable-binary-cataloger
# - cocoapods-cataloger
# - conan-cataloger
# - dartlang-lock-cataloger
# - dotnet-deps-cataloger
# - dotnet-portable-executable-cataloger
# - dpkg-db-cataloger
# - elixir-mix-lock-cataloger
# - erlang-rebar-lock-cataloger
# - go-module-file-cataloger
# - go-module-binary-cataloger
# - graalvm-native-image-cataloger
# - haskell-cataloger
# - java-cataloger
# - java-gradle-lockfile-cataloger
# - java-pom-cataloger
# - javascript-lock-cataloger
# - javascript-package-cataloger
# - linux-kernel-cataloger
# - nix-store-cataloger
# - php-composer-installed-cataloger
# - php-composer-lock-cataloger
# - portage-cataloger
# - python-package-cataloger
# - python-installed-package-cataloger
# - rpm-db-cataloger
# - rpm-archive-cataloger
# - ruby-gemfile-cataloger
# - ruby-installed-gemspec-cataloger
# - rust-cargo-lock-cataloger
# - sbom-cataloger
# - spm-cataloger
catalogers:
# all format configuration
format:
# default value for all formats that support the "pretty" option (default is unset)
pretty:
# all syft-json format options
json:
# include space indention and newlines (inherits default value from 'format.pretty' or 'false' if parent is unset)
# note: inherits default value from 'format.pretty' or 'false' if parent is unset
pretty: false
# transform any syft-json output to conform to an approximation of the v11.0.1 schema. This includes:
# - using the package metadata type names from before v12 of the JSON schema (changed in https://github.com/anchore/syft/pull/1983)
#
# Note: this will still include package types and fields that were added at or after json schema v12. This means
# that output might not strictly be json schema v11 compliant, however, for consumers that require time to port
# over to the final syft 1.0 json output this option can be used to ease the transition.
#
# Note: long term support for this option is not guaranteed.
legacy: false
# all template format options
template:
# path to the template file to use when rendering the output with the `template` output format.
# Note that all template paths are based on the current syft-json schema.
# same as -t ; SYFT_TEMPLATE_PATH env var
path: ""
# all spdx-json format options
spdx-json:
# include space indention and newlines
# note: inherits default value from 'format.pretty' or 'false' if parent is unset
pretty: false
# all cyclonedx-json format options
cyclonedx-json:
# include space indention and newlines
# note: inherits default value from 'format.pretty' or 'false' if parent is unset
pretty: false
# all cyclonedx-xml format options
cyclonedx-xml:
# include space indention
# note: inherits default value from 'format.pretty' or 'false' if parent is unset
pretty: false
# cataloging packages is exposed through the packages and power-user subcommands
package:
# search within archives that do contain a file index to search against (zip)
# note: for now this only applies to the java package cataloger
# SYFT_PACKAGE_SEARCH_INDEXED_ARCHIVES env var
search-indexed-archives: true
# search within archives that do not contain a file index to search against (tar, tar.gz, tar.bz2, etc)
# note: enabling this may result in a performance impact since all discovered compressed tars will be decompressed
# note: for now this only applies to the java package cataloger
# SYFT_PACKAGE_SEARCH_UNINDEXED_ARCHIVES env var
search-unindexed-archives: false
cataloger:
# enable/disable cataloging of packages
# SYFT_PACKAGE_CATALOGER_ENABLED env var
enabled: true
# the search space to look for packages (options: all-layers, squashed)
# same as -s ; SYFT_PACKAGE_CATALOGER_SCOPE env var
scope: "squashed"
golang:
# search for go package licences in the GOPATH of the system running Syft, note that this is outside the
# container filesystem and potentially outside the root of a local directory scan
# SYFT_GOLANG_SEARCH_LOCAL_MOD_CACHE_LICENSES env var
search-local-mod-cache-licenses: false
# specify an explicit go mod cache directory, if unset this defaults to $GOPATH/pkg/mod or $HOME/go/pkg/mod
# SYFT_GOLANG_LOCAL_MOD_CACHE_DIR env var
local-mod-cache-dir: ""
# search for go package licences by retrieving the package from a network proxy
# SYFT_GOLANG_SEARCH_REMOTE_LICENSES env var
search-remote-licenses: false
# remote proxy to use when retrieving go packages from the network,
# if unset this defaults to $GOPROXY followed by https://proxy.golang.org
# SYFT_GOLANG_PROXY env var
proxy: ""
# specifies packages which should not be fetched by proxy
# if unset this defaults to $GONOPROXY
# SYFT_GOLANG_NOPROXY env var
no-proxy: ""
java:
maven-url: "https://repo1.maven.org/maven2"
max-parent-recursive-depth: 5
# enables Syft to use the network to fill in more detailed information about artifacts
# currently this enables searching maven-url for license data
# when running across pom.xml files that could have more information, syft will
# explicitly search maven for license information by querying the online pom when this is true
# this option is helpful for when the parent pom has more data,
# that is not accessible from within the final built artifact
use-network: false
linux-kernel:
# whether to catalog linux kernel modules found within lib/modules/** directories
# SYFT_LINUX_KERNEL_CATALOG_MODULES env var
catalog-modules: true
python:
# 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
# when given an arbitrary constraint will be used (even if that version may not be available/published).
guess-unpinned-requirements: false
file-contents:
cataloger:
# enable/disable cataloging of file contents
# SYFT_FILE_CONTENTS_CATALOGER_ENABLED env var
enabled: true
# the search space to look for file contents (options: all-layers, squashed)
# SYFT_FILE_CONTENTS_CATALOGER_SCOPE env var
scope: "squashed"
# skip searching a file entirely if it is above the given size (default = 1MB; unit = bytes)
# SYFT_FILE_CONTENTS_SKIP_FILES_ABOVE_SIZE env var
skip-files-above-size: 1048576
# file globs for the cataloger to match on
# SYFT_FILE_CONTENTS_GLOBS env var
globs: []
file-metadata:
cataloger:
# enable/disable cataloging of file metadata
# SYFT_FILE_METADATA_CATALOGER_ENABLED env var
enabled: true
# the search space to look for file metadata (options: all-layers, squashed)
# SYFT_FILE_METADATA_CATALOGER_SCOPE env var
scope: "squashed"
# the file digest algorithms to use when cataloging files (options: "md5", "sha1", "sha224", "sha256", "sha384", "sha512")
# SYFT_FILE_METADATA_DIGESTS env var
digests: ["sha256"]
# maximum number of workers used to process the list of package catalogers in parallel
parallelism: 1
# options that apply to all scan sources
source:
# alias name for the source
# SYFT_SOURCE_NAME env var; --source-name flag
name: ""
# alias version for the source
# SYFT_SOURCE_VERSION env var; --source-version flag
version: ""
# options affecting the file source type
file:
# the file digest algorithms to use on the scanned file (options: "md5", "sha1", "sha224", "sha256", "sha384", "sha512")
digests: ["sha256"]
# options when pulling directly from a registry via the "registry:" or "containerd:" scheme
registry:
# skip TLS verification when communicating with the registry
# SYFT_REGISTRY_INSECURE_SKIP_TLS_VERIFY env var
insecure-skip-tls-verify: false
# use http instead of https when connecting to the registry
# SYFT_REGISTRY_INSECURE_USE_HTTP env var
insecure-use-http: false
# filepath to a CA certificate (or directory containing *.crt, *.cert, *.pem) used to generate the client certificate
# SYFT_REGISTRY_CA_CERT env var
ca-cert: ""
# credentials for specific registries
auth:
# the URL to the registry (e.g. "docker.io", "localhost:5000", etc.)
# SYFT_REGISTRY_AUTH_AUTHORITY env var
- authority: ""
# SYFT_REGISTRY_AUTH_USERNAME env var
username: ""
# SYFT_REGISTRY_AUTH_PASSWORD env var
password: ""
# note: token and username/password are mutually exclusive
# SYFT_REGISTRY_AUTH_TOKEN env var
token: ""
# filepath to the client certificate used for TLS authentication to the registry
# SYFT_REGISTRY_AUTH_TLS_CERT env var
tls-cert: ""
# filepath to the client key used for TLS authentication to the registry
# SYFT_REGISTRY_AUTH_TLS_KEY env var
tls-key: ""
# - ... # note, more credentials can be provided via config file only (not env vars)
# generate an attested SBOM
attest:
# path to the private key file to use for attestation
# SYFT_ATTEST_KEY env var
key: "cosign.key"
# password to decrypt to given private key
# SYFT_ATTEST_PASSWORD env var, additionally responds to COSIGN_PASSWORD
password: ""
log:
# use structured logging
# same as SYFT_LOG_STRUCTURED env var
structured: false
# the log level; note: detailed logging suppress the ETUI
# same as SYFT_LOG_LEVEL env var
level: "error"
# location to write the log file (default is not to have a log file)
# same as SYFT_LOG_FILE env var
file: ""
```
### Adding an SBOM to an image as an attestation using Syft
`syft attest --output [FORMAT] --key [KEY] [SOURCE] [flags]`
SBOMs themselves can serve as input to different analysis tools. [Grype](https://github.com/anchore/grype), a vulnerability scanner CLI tool from Anchore, is one such tool. Publishers of container images can use attestations to enable their consumers to trust Syft-generated SBOM descriptions of those container images. To create and provide these attestations, image publishers can run `syft attest` in conjunction with the [cosign](https://github.com/sigstore/cosign) tool to attach SBOM attestations to their images.
#### Example attestation
Note for the following example replace `docker.io/image:latest` with an image you own. You should also have push access to
its remote reference. Replace `$MY_PRIVATE_KEY` with a private key you own or have generated with cosign.
```bash
syft attest --key $MY_PRIVATE_KEY -o spdx-json docker.io/image:latest > image_latest_sbom_attestation.json
cosign attach attestation --attestation image_latest_sbom_attestation.json docker.io/image:latest
```
Verify the new attestation exists on your image.
```bash
cosign verify-attestation --key $MY_PUBLIC_KEY --type spdxjson docker.io/image:latest | jq '.payload | @base64d | .payload | fromjson | .predicate'
```
You should see this output along with the attached SBOM:
```
Verification for docker.io/image:latest --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- The signatures were verified against the specified public key
- Any certificates were verified against the Fulcio roots.
```
Consumers of your image can now trust that the SBOM associated with your image is correct and from a trusted source.
The SBOM can be piped to Grype:
```bash
cosign verify-attestation --key $MY_PUBLIC_KEY --type spdxjson docker.io/image:latest | jq '.payload | @base64d | .payload | fromjson | .predicate' | grype
```
-32
View File
@@ -1,32 +0,0 @@
# Security Policy
## 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 the best effort base, so please indicate the "urgency level" of the vulnerability as Critical, High, Medium or Low.
+11 -34
View File
@@ -54,7 +54,6 @@ tasks:
- task: check-licenses
- task: lint
- task: check-json-schema-drift
- task: check-binary-fixture-size
test:
desc: Run all levels of test
@@ -170,11 +169,6 @@ tasks:
cmds:
- .github/scripts/json-schema-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
## Testing tasks #################################
@@ -185,7 +179,7 @@ tasks:
- fixtures
vars:
TEST_PKGS:
sh: "go list ./... | grep -v {{ .OWNER }}/{{ .PROJECT }}/test | grep -v {{ .OWNER }}/{{ .PROJECT }}/cmd/syft/internal/test | tr '\n' ' '"
sh: "go list ./... | grep -v {{ .OWNER }}/{{ .PROJECT }}/test | tr '\n' ' '"
# unit test coverage threshold (in % coverage)
COVERAGE_THRESHOLD: 62
@@ -197,7 +191,7 @@ tasks:
integration:
desc: Run integration tests
cmds:
- "go test -v ./cmd/syft/internal/test/integration"
- "go test -v ./test/integration"
# exercise most of the CLI with the data race detector
- "go run -race cmd/syft/main.go alpine:latest"
@@ -265,9 +259,7 @@ tasks:
fingerprints:
desc: Generate test fixture fingerprints
generates:
- cmd/syft/internal/test/integration/test-fixtures/cache.fingerprint
- syft/file/cataloger/executable/test-fixtures/elf/cache.fingerprint
- syft/file/cataloger/executable/test-fixtures/shared-info/cache.fingerprint
- test/integration/test-fixtures/cache.fingerprint
- syft/pkg/cataloger/binary/test-fixtures/cache.fingerprint
- syft/pkg/cataloger/java/test-fixtures/java-builds/cache.fingerprint
- syft/pkg/cataloger/golang/test-fixtures/archs/binaries.fingerprint
@@ -276,22 +268,19 @@ tasks:
- test/install/cache.fingerprint
- test/cli/test-fixtures/cache.fingerprint
cmds:
# for EXECUTABLE unit test fixtures
- "cd syft/file/cataloger/executable/test-fixtures/elf && make cache.fingerprint"
- "cd syft/file/cataloger/executable/test-fixtures/shared-info && make cache.fingerprint"
# for IMAGE integration test fixtures
- "cd cmd/syft/internal/test/integration/test-fixtures && make cache.fingerprint"
# for BINARY unit test fixtures
- "cd test/integration/test-fixtures && make cache.fingerprint"
# for BINARY test fixtures
- "cd syft/pkg/cataloger/binary/test-fixtures && make cache.fingerprint"
# for JAVA BUILD unit test fixtures
# for JAVA BUILD test fixtures
- "cd syft/pkg/cataloger/java/test-fixtures/java-builds && make cache.fingerprint"
# for GO BINARY unit test fixtures
# for GO BINARY test fixtures
- "cd syft/pkg/cataloger/golang/test-fixtures/archs && make binaries.fingerprint"
# for RPM unit test fixtures
# for RPM test fixtures
- "cd syft/pkg/cataloger/redhat/test-fixtures && make rpms.fingerprint"
# for Kernel unit test fixtures
# for Kernel test fixtures
- "cd syft/pkg/cataloger/kernel/test-fixtures && make cache.fingerprint"
# for INSTALL test fixtures
# for INSTALL integration test fixtures
- "cd test/install && make cache.fingerprint"
# for CLI test fixtures
- "cd test/cli/test-fixtures && make cache.fingerprint"
@@ -299,8 +288,6 @@ tasks:
fixtures:
desc: Generate test fixtures
cmds:
- "cd syft/file/cataloger/executable/test-fixtures/elf && make"
- "cd syft/file/cataloger/executable/test-fixtures/shared-info && make"
- "cd syft/pkg/cataloger/java/test-fixtures/java-builds && make"
- "cd syft/pkg/cataloger/redhat/test-fixtures && make"
- "cd syft/pkg/cataloger/binary/test-fixtures && make"
@@ -390,7 +377,7 @@ tasks:
generate-cpe-dictionary-index:
desc: Generate the CPE index based off of the latest available CPE dictionary
dir: "syft/pkg/cataloger/internal/cpegenerate/dictionary"
dir: "syft/pkg/cataloger/common/cpe/dictionary"
cmds:
- "go generate"
@@ -470,16 +457,6 @@ tasks:
silent: true
- "{{ .RELEASE_CMD }}"
ci-release-version-file:
# desc: "[CI only] Update the version file"
deps: [tools]
cmds:
- task: ci-check
- ".github/scripts/update-version-file.sh {{ .RELEASE_VERSION }}"
requires:
vars:
- RELEASE_VERSION
## Cleanup targets #################################
+51 -14
View File
@@ -8,8 +8,13 @@ import (
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/syft/cmd/syft/internal"
"github.com/anchore/syft/cmd/syft/internal/commands"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/cmd/syft/cli/commands"
handler "github.com/anchore/syft/cmd/syft/cli/ui"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/redact"
)
// Application constructs the `syft packages` command and aliases the root command to `syft packages`.
@@ -29,33 +34,65 @@ func Command(id clio.Identification) *cobra.Command {
}
func create(id clio.Identification, out io.Writer) (clio.Application, *cobra.Command) {
clioCfg := internal.AppClioSetupConfig(id, out)
clioCfg := clio.NewSetupConfig(id).
WithGlobalConfigFlag(). // add persistent -c <path> for reading an application config from
WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config
WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text
WithUIConstructor(
// select a UI based on the logging configuration and state of stdin (if stdin is a tty)
func(cfg clio.Config) ([]clio.UI, error) {
noUI := ui.None(out, cfg.Log.Quiet)
if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet {
return []clio.UI{noUI}, nil
}
return []clio.UI{
ui.New(out, cfg.Log.Quiet,
handler.New(handler.DefaultHandlerConfig()),
),
noUI,
}, nil
},
).
WithInitializers(
func(state *clio.State) error {
// clio is setting up and providing the bus, redact store, and logger to the application. Once loaded,
// we can hoist them into the internal packages for global use.
stereoscope.SetBus(state.Bus)
bus.Set(state.Bus)
redact.Set(state.RedactStore)
log.Set(state.Logger)
stereoscope.SetLogger(state.Logger)
return nil
},
).
WithPostRuns(func(state *clio.State, err error) {
stereoscope.Cleanup()
})
app := clio.New(*clioCfg)
// since root is aliased as the packages cmd we need to construct this command first
// we also need the command to have information about the `root` options because of this alias
scanCmd := commands.Scan(app)
packagesCmd := commands.Packages(app)
// root is currently an alias for the scan command
rootCmd := commands.Root(app, scanCmd)
// rootCmd is currently an alias for the packages command
rootCmd := commands.Root(app, packagesCmd)
// add sub-commands
rootCmd.AddCommand(
scanCmd,
commands.Packages(app, scanCmd), // this is currently an alias for the scan command
commands.Cataloger(app),
packagesCmd,
commands.Attest(app),
commands.Convert(app),
clio.VersionCommand(id),
clio.ConfigCommand(app, nil),
cranecmd.NewCmdAuthLogin(id.Name), // syft login uses the same command as crane
)
// note: we would direct cobra to use our writer explicitly with rootCmd.SetOut(out) , however this causes
// deprecation warnings to be shown to stdout via the writer instead of stderr. This is unfortunate since this
// does not appear to be the correct behavior on cobra's part https://github.com/spf13/cobra/issues/1708 .
// In the future this functionality should be restored.
// explicitly set Cobra output to the real stdout to write things like errors and help
rootCmd.SetOut(out)
return app, rootCmd
}
@@ -1,7 +1,6 @@
package commands
import (
"context"
"fmt"
"io"
"os"
@@ -13,8 +12,7 @@ import (
"github.com/wagoodman/go-progress"
"github.com/anchore/clio"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
@@ -27,10 +25,11 @@ import (
"github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
const (
attestExample = ` {{.appName}} {{.command}} --output [FORMAT] alpine:latest defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry
attestExample = ` {{.appName}} {{.command}} --output [FORMAT] alpine:latest defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry
`
attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp
attestHelp = attestExample + attestSchemeHelp
@@ -42,8 +41,7 @@ type attestOptions struct {
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"`
Attest options.Attest `yaml:"attest" mapstructure:"attest"`
Cache options.Cache `json:"-" yaml:"cache" mapstructure:"cache"`
options.Attest `yaml:",inline" mapstructure:",squash"`
}
func Attest(app clio.Application) *cobra.Command {
@@ -62,13 +60,13 @@ func Attest(app clio.Application) *cobra.Command {
"appName": id.Name,
"command": "attest",
}),
Args: validateScanArgs,
Args: validatePackagesArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(cmd *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return runAttest(cmd.Context(), id, &opts, args[0])
return runAttest(id, &opts, args[0])
},
}, &opts)
}
@@ -78,7 +76,6 @@ func defaultAttestOptions() attestOptions {
Output: defaultAttestOutputOptions(),
UpdateCheck: options.DefaultUpdateCheck(),
Catalog: options.DefaultCatalog(),
Cache: options.DefaultCache(),
}
}
@@ -101,7 +98,7 @@ func defaultAttestOutputOptions() options.Output {
}
//nolint:funlen
func runAttest(ctx context.Context, id clio.Identification, opts *attestOptions, userInput string) error {
func runAttest(id clio.Identification, opts *attestOptions, userInput string) error {
// TODO: what other validation here besides binary name?
if !commandExists(cosignBinName) {
return fmt.Errorf("'syft attest' requires cosign to be installed, however it does not appear to be on PATH")
@@ -114,7 +111,7 @@ func runAttest(ctx context.Context, id clio.Identification, opts *attestOptions,
}
defer os.Remove(f.Name())
s, err := generateSBOMForAttestation(ctx, id, &opts.Catalog, userInput)
s, err := generateSBOMForAttestation(id, &opts.Catalog, userInput)
if err != nil {
return fmt.Errorf("unable to build SBOM: %w", err)
}
@@ -234,7 +231,7 @@ func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) (
}
func predicateType(outputName string) string {
// select the Cosign predicate type based on defined output type
// Select Cosign predicate type based on defined output type
// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
switch strings.ToLower(outputName) {
case "cyclonedx-json":
@@ -248,12 +245,8 @@ func predicateType(outputName string) string {
}
}
func generateSBOMForAttestation(ctx context.Context, id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
if len(opts.From) > 1 || (len(opts.From) == 1 && opts.From[0] != stereoscope.RegistryTag) {
return nil, fmt.Errorf("attest requires use of an OCI registry directly, one or more of the specified sources is unsupported: %v", opts.From)
}
src, err := getSource(ctx, opts, userInput, stereoscope.RegistryTag)
func generateSBOMForAttestation(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) {
src, err := getSource(opts, userInput, onlyContainerImages)
if err != nil {
return nil, err
@@ -267,7 +260,7 @@ func generateSBOMForAttestation(ctx context.Context, id clio.Identification, opt
}
}()
s, err := generateSBOM(ctx, id, src, opts)
s, err := generateSBOM(id, src, opts)
if err != nil {
return nil, err
}
@@ -279,6 +272,13 @@ func generateSBOMForAttestation(ctx context.Context, id clio.Identification, opt
return s, nil
}
func onlyContainerImages(d *source.Detection) error {
if !d.IsContainerImage() {
return fmt.Errorf("attestations are only supported for oci images at this time")
}
return nil
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
@@ -2,24 +2,18 @@ package commands
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"regexp"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/scylladb/go-set/strset"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/clio"
"github.com/anchore/clio/cliotestutils"
"github.com/anchore/syft/cmd/syft/internal"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
@@ -264,7 +258,7 @@ func Test_buildSBOMForAttestation(t *testing.T) {
if tt.wantErr == nil {
tt.wantErr = require.NoError
}
_, err := generateSBOMForAttestation(context.Background(), tt.args.id, tt.args.opts, tt.args.userInput)
_, err := generateSBOMForAttestation(tt.args.id, tt.args.opts, tt.args.userInput)
tt.wantErr(t, err)
if err != nil {
return
@@ -272,60 +266,3 @@ func Test_buildSBOMForAttestation(t *testing.T) {
})
}
}
func Test_attestCLIWiring(t *testing.T) {
id := clio.Identification{
Name: "syft",
Version: "testing",
}
cfg := internal.AppClioSetupConfig(id, io.Discard)
tests := []struct {
name string
assertionFunc func(*testing.T, *cobra.Command, []string, ...any)
wantOpts attestOptions
args []string
env map[string]string
}{
{
name: "key flag is accepted",
args: []string{"some-image:some-tag", "--key", "some-cosign-key.key"},
assertionFunc: hasAttestOpts(options.Attest{Key: "some-cosign-key.key"}),
},
{
name: "key password is read from env",
args: []string{"some-image:some-tag", "--key", "cosign.key"},
env: map[string]string{
"SYFT_ATTEST_PASSWORD": "some-password",
},
assertionFunc: hasAttestOpts(options.Attest{
Key: "cosign.key",
Password: "some-password",
}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
t.Setenv(k, v)
}
}
app := cliotestutils.NewApplication(t, cfg, tt.assertionFunc)
cmd := Attest(app)
cmd.SetArgs(tt.args)
err := cmd.Execute()
assert.NoError(t, err)
})
}
}
func hasAttestOpts(wantOpts options.Attest) cliotestutils.AssertionFunc {
return func(t *testing.T, _ *cobra.Command, _ []string, cfgs ...any) {
assert.Equal(t, len(cfgs), 1)
attestOpts, ok := cfgs[0].(*attestOptions)
require.True(t, ok)
if d := cmp.Diff(wantOpts, attestOpts.Attest); d != "" {
t.Errorf("mismatched attest options (-want +got):\n%s", d)
}
}
}
@@ -8,7 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
@@ -47,7 +47,7 @@ func Convert(app clio.Application) *cobra.Command {
}),
Args: validateConvertArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(_ *cobra.Command, args []string) error {
RunE: func(cmd *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
+255
View File
@@ -0,0 +1,255 @@
package commands
import (
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/cmd/syft/cli/eventloop"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
const (
packagesExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages
{{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details
{{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM
{{.appName}} {{.command}} alpine:latest -o cyclonedx-json show a CycloneDX JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.3 Tag-Value formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx@2.2 show a SPDX 2.2 Tag-Value formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.3 JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx-json@2.2 show a SPDX 2.2 JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -vv show verbose debug information
{{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl show a SBOM formatted according to given template file
Supports the following image sources:
{{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry.
{{.appName}} {{.command}} path/to/a/file/or/dir a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory
`
schemeHelpHeader = "You can also explicitly specify the scheme to use:"
imageSchemeHelp = ` {{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon
{{.appName}} {{.command}} podman:yourrepo/yourimage:tag explicitly use the Podman daemon
{{.appName}} {{.command}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
{{.appName}} {{.command}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save"
{{.appName}} {{.command}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise)
{{.appName}} {{.command}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
{{.appName}} {{.command}} singularity:path/to/yourimage.sif read directly from a Singularity Image Format (SIF) container on disk
`
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)
`
packagesSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp
packagesHelp = packagesExample + packagesSchemeHelp
)
type packagesOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"`
}
func defaultPackagesOptions() *packagesOptions {
return &packagesOptions{
Output: options.DefaultOutput(),
UpdateCheck: options.DefaultUpdateCheck(),
Catalog: options.DefaultCatalog(),
}
}
//nolint:dupl
func Packages(app clio.Application) *cobra.Command {
id := app.ID()
opts := defaultPackagesOptions()
return app.SetupCommand(&cobra.Command{
Use: "packages [SOURCE]",
Short: "Generate a package SBOM",
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems",
Example: internal.Tprintf(packagesHelp, map[string]interface{}{
"appName": id.Name,
"command": "packages",
}),
Args: validatePackagesArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(cmd *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return runPackages(id, opts, args[0])
},
}, opts)
}
func validatePackagesArgs(cmd *cobra.Command, args []string) error {
return validateArgs(cmd, args, "an image/directory argument is required")
}
func validateArgs(cmd *cobra.Command, args []string, error string) error {
if len(args) == 0 {
// in the case that no arguments are given we want to show the help text and return with a non-0 return code.
if err := cmd.Help(); err != nil {
return fmt.Errorf("unable to display help: %w", err)
}
return fmt.Errorf(error)
}
return cobra.MaximumNArgs(1)(cmd, args)
}
// nolint:funlen
func runPackages(id clio.Identification, opts *packagesOptions, userInput string) error {
writer, err := opts.SBOMWriter()
if err != nil {
return err
}
src, err := getSource(&opts.Catalog, userInput)
if err != nil {
return err
}
defer func() {
if src != nil {
if err := src.Close(); err != nil {
log.Tracef("unable to close source: %+v", err)
}
}
}()
s, err := generateSBOM(id, src, &opts.Catalog)
if err != nil {
return err
}
if s == nil {
return fmt.Errorf("no SBOM produced for %q", userInput)
}
if err := writer.Write(*s); err != nil {
return fmt.Errorf("failed to write SBOM: %w", err)
}
return nil
}
func getSource(opts *options.Catalog, userInput string, filters ...func(*source.Detection) error) (source.Source, error) {
detection, err := source.Detect(
userInput,
source.DetectConfig{
DefaultImageSource: opts.DefaultImagePullSource,
},
)
if err != nil {
return nil, fmt.Errorf("could not deteremine source: %w", err)
}
for _, filter := range filters {
if err := filter(detection); err != nil {
return nil, err
}
}
var platform *image.Platform
if opts.Platform != "" {
platform, err = image.NewPlatform(opts.Platform)
if err != nil {
return nil, fmt.Errorf("invalid platform: %w", err)
}
}
hashers, err := file.Hashers(opts.Source.File.Digests...)
if err != nil {
return nil, fmt.Errorf("invalid hash: %w", err)
}
src, err := detection.NewSource(
source.DetectionSourceConfig{
Alias: source.Alias{
Name: opts.Source.Name,
Version: opts.Source.Version,
},
RegistryOptions: opts.Registry.ToOptions(),
Platform: platform,
Exclude: source.ExcludeConfig{
Paths: opts.Exclusions,
},
DigestAlgorithms: hashers,
BasePath: opts.BasePath,
},
)
if err != nil {
if userInput == "power-user" {
bus.Notify("Note: the 'power-user' command has been removed.")
}
return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err)
}
return src, nil
}
func generateSBOM(id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
tasks, err := eventloop.Tasks(opts)
if err != nil {
return nil, err
}
s := sbom.SBOM{
Source: src.Describe(),
Descriptor: sbom.Descriptor{
Name: id.Name,
Version: id.Version,
Configuration: opts,
},
}
err = buildRelationships(&s, src, tasks)
return &s, err
}
func buildRelationships(s *sbom.SBOM, src source.Source, tasks []eventloop.Task) error {
var errs error
var relationships []<-chan artifact.Relationship
for _, task := range tasks {
c := make(chan artifact.Relationship)
relationships = append(relationships, c)
go func(task eventloop.Task) {
err := eventloop.RunTask(task, &s.Artifacts, src, c)
if err != nil {
errs = multierror.Append(errs, err)
}
}(task)
}
s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...)
return errs
}
func mergeRelationships(cs ...<-chan artifact.Relationship) (relationships []artifact.Relationship) {
for _, c := range cs {
for n := range c {
relationships = append(relationships, n)
}
}
return relationships
}
@@ -12,7 +12,7 @@ import (
func Root(app clio.Application, packagesCmd *cobra.Command) *cobra.Command {
id := app.ID()
opts := defaultScanOptions()
opts := defaultPackagesOptions()
return app.SetupRootCommand(&cobra.Command{
Use: fmt.Sprintf("%s [SOURCE]", app.ID().Name),
@@ -25,7 +25,7 @@ func Root(app clio.Application, packagesCmd *cobra.Command) *cobra.Command {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return runScan(cmd.Context(), id, opts, args[0])
return runPackages(id, opts, args[0])
},
}, opts)
}
@@ -11,8 +11,8 @@ import (
"github.com/anchore/clio"
hashiVersion "github.com/anchore/go-version"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/internal"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event"
@@ -28,7 +28,7 @@ var latestAppVersionURL = struct {
}
func applicationUpdateCheck(id clio.Identification, check *options.UpdateCheck) func(cmd *cobra.Command, args []string) error {
return func(_ *cobra.Command, _ []string) error {
return func(cmd *cobra.Command, args []string) error {
if check.CheckForAppUpdate {
checkForApplicationUpdate(id)
}
+151
View File
@@ -0,0 +1,151 @@
package eventloop
import (
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/internal/file"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file/cataloger/filecontent"
"github.com/anchore/syft/syft/file/cataloger/filedigest"
"github.com/anchore/syft/syft/file/cataloger/filemetadata"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
type Task func(*sbom.Artifacts, source.Source) ([]artifact.Relationship, error)
func Tasks(opts *options.Catalog) ([]Task, error) {
var tasks []Task
generators := []func(opts *options.Catalog) (Task, error){
generateCatalogPackagesTask,
generateCatalogFileMetadataTask,
generateCatalogFileDigestsTask,
generateCatalogContentsTask,
}
for _, generator := range generators {
task, err := generator(opts)
if err != nil {
return nil, err
}
if task != nil {
tasks = append(tasks, task)
}
}
return tasks, nil
}
func generateCatalogPackagesTask(opts *options.Catalog) (Task, error) {
if !opts.Package.Cataloger.Enabled {
return nil, nil
}
task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) {
packageCatalog, relationships, theDistro, err := syft.CatalogPackages(src, opts.ToCatalogerConfig())
results.Packages = packageCatalog
results.LinuxDistribution = theDistro
return relationships, err
}
return task, nil
}
func generateCatalogFileMetadataTask(opts *options.Catalog) (Task, error) {
if !opts.FileMetadata.Cataloger.Enabled {
return nil, nil
}
metadataCataloger := filemetadata.NewCataloger()
task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) {
resolver, err := src.FileResolver(opts.FileMetadata.Cataloger.GetScope())
if err != nil {
return nil, err
}
result, err := metadataCataloger.Catalog(resolver)
if err != nil {
return nil, err
}
results.FileMetadata = result
return nil, nil
}
return task, nil
}
func generateCatalogFileDigestsTask(opts *options.Catalog) (Task, error) {
if !opts.FileMetadata.Cataloger.Enabled {
return nil, nil
}
hashes, err := file.Hashers(opts.FileMetadata.Digests...)
if err != nil {
return nil, err
}
digestsCataloger := filedigest.NewCataloger(hashes)
task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) {
resolver, err := src.FileResolver(opts.FileMetadata.Cataloger.GetScope())
if err != nil {
return nil, err
}
result, err := digestsCataloger.Catalog(resolver)
if err != nil {
return nil, err
}
results.FileDigests = result
return nil, nil
}
return task, nil
}
func generateCatalogContentsTask(opts *options.Catalog) (Task, error) {
if !opts.FileContents.Cataloger.Enabled {
return nil, nil
}
contentsCataloger, err := filecontent.NewCataloger(opts.FileContents.Globs, opts.FileContents.SkipFilesAboveSize) //nolint:staticcheck
if err != nil {
return nil, err
}
task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) {
resolver, err := src.FileResolver(opts.FileContents.Cataloger.GetScope())
if err != nil {
return nil, err
}
result, err := contentsCataloger.Catalog(resolver)
if err != nil {
return nil, err
}
results.FileContents = result
return nil, nil
}
return task, nil
}
func RunTask(t Task, a *sbom.Artifacts, src source.Source, c chan<- artifact.Relationship) error {
defer close(c)
relationships, err := t(a, src)
if err != nil {
return err
}
for _, relationship := range relationships {
c <- relationship
}
return nil
}
@@ -4,24 +4,14 @@ import (
"github.com/anchore/clio"
)
var _ clio.FlagAdder = (*Attest)(nil)
type Attest struct {
// IMPORTANT: do not show the attestation key/password in any YAML/JSON output (sensitive information)
Key secret `yaml:"key" json:"key" mapstructure:"key"`
Password secret `yaml:"password" json:"password" mapstructure:"password"`
}
var _ interface {
clio.FlagAdder
clio.FieldDescriber
} = (*Attest)(nil)
var _ clio.FlagAdder = (*Attest)(nil)
func (o *Attest) AddFlags(flags clio.FlagSet) {
func (o Attest) AddFlags(flags clio.FlagSet) {
flags.StringVarP((*string)(&o.Key), "key", "k", "the key to use for the attestation")
}
func (o *Attest) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&o.Password, `password to decrypt to given private key
additionally responds to COSIGN_PASSWORD env var`)
}
+176
View File
@@ -0,0 +1,176 @@
package options
import (
"fmt"
"sort"
"strings"
"github.com/iancoleman/strcase"
"github.com/mitchellh/go-homedir"
"github.com/scylladb/go-set/strset"
"github.com/anchore/clio"
"github.com/anchore/fangs"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/cataloging"
"github.com/anchore/syft/syft/pkg/cataloger"
golangCataloger "github.com/anchore/syft/syft/pkg/cataloger/golang"
javaCataloger "github.com/anchore/syft/syft/pkg/cataloger/java"
"github.com/anchore/syft/syft/pkg/cataloger/kernel"
pythonCataloger "github.com/anchore/syft/syft/pkg/cataloger/python"
"github.com/anchore/syft/syft/source"
)
type Catalog struct {
Catalogers []string `yaml:"catalogers" json:"catalogers" mapstructure:"catalogers"`
Package pkg `yaml:"package" json:"package" mapstructure:"package"`
Golang golang `yaml:"golang" json:"golang" mapstructure:"golang"`
Java java `yaml:"java" json:"java" mapstructure:"java"`
LinuxKernel linuxKernel `yaml:"linux-kernel" json:"linux-kernel" mapstructure:"linux-kernel"`
Python python `yaml:"python" json:"python" mapstructure:"python"`
FileMetadata fileMetadata `yaml:"file-metadata" json:"file-metadata" mapstructure:"file-metadata"`
FileContents fileContents `yaml:"file-contents" json:"file-contents" mapstructure:"file-contents"`
Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"`
Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
Platform string `yaml:"platform" json:"platform" mapstructure:"platform"`
Name string `yaml:"name" json:"name" mapstructure:"name"`
Source sourceCfg `yaml:"source" json:"source" mapstructure:"source"`
Parallelism int `yaml:"parallelism" json:"parallelism" mapstructure:"parallelism"` // the number of catalog workers to run in parallel
DefaultImagePullSource string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"` // specify default image pull source
BasePath string `yaml:"base-path" json:"base-path" mapstructure:"base-path"` // specify base path for all file paths
ExcludeBinaryOverlapByOwnership bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"` // exclude synthetic binary packages owned by os package files
}
var _ interface {
clio.FlagAdder
clio.PostLoader
} = (*Catalog)(nil)
func DefaultCatalog() Catalog {
return Catalog{
Package: defaultPkg(),
LinuxKernel: defaultLinuxKernel(),
FileMetadata: defaultFileMetadata(),
FileContents: defaultFileContents(),
Source: defaultSourceCfg(),
Parallelism: 1,
ExcludeBinaryOverlapByOwnership: true,
}
}
func (cfg *Catalog) AddFlags(flags clio.FlagSet) {
var validScopeValues []string
for _, scope := range source.AllScopes {
validScopeValues = append(validScopeValues, strcase.ToDelimited(string(scope), '-'))
}
flags.StringVarP(&cfg.Package.Cataloger.Scope, "scope", "s",
fmt.Sprintf("selection of layers to catalog, options=%v", validScopeValues))
flags.StringVarP(&cfg.Platform, "platform", "",
"an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux')")
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.StringVarP(&cfg.Source.Name, "name", "",
"set the name of the target being analyzed")
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
flagSet := pfp.PFlagSet()
flagSet.Lookup("name").Deprecated = "use: source-name"
}
flags.StringVarP(&cfg.Source.Name, "source-name", "",
"set the name of the target being analyzed")
flags.StringVarP(&cfg.Source.Version, "source-version", "",
"set the version of the target being analyzed")
flags.StringVarP(&cfg.BasePath, "base-path", "",
"base directory for scanning, no links will be followed above this directory, and all paths will be reported relative to this directory")
}
func (cfg *Catalog) PostLoad() error {
// parse options on this struct
var catalogers []string
for _, c := range cfg.Catalogers {
for _, f := range strings.Split(c, ",") {
catalogers = append(catalogers, strings.TrimSpace(f))
}
}
sort.Strings(catalogers)
cfg.Catalogers = catalogers
if err := checkDefaultSourceValues(cfg.DefaultImagePullSource); err != nil {
return err
}
if cfg.Name != "" {
log.Warnf("name parameter is deprecated. please use: source-name. name will be removed in a future version")
if cfg.Source.Name == "" {
cfg.Source.Name = cfg.Name
}
}
return nil
}
func (cfg Catalog) ToCatalogerConfig() cataloger.Config {
return cataloger.Config{
Search: cataloger.SearchConfig{
IncludeIndexedArchives: cfg.Package.SearchIndexedArchives,
IncludeUnindexedArchives: cfg.Package.SearchUnindexedArchives,
Scope: cfg.Package.Cataloger.GetScope(),
},
Catalogers: cfg.Catalogers,
Parallelism: cfg.Parallelism,
Golang: golangCataloger.DefaultCatalogerConfig().
WithSearchLocalModCacheLicenses(cfg.Golang.SearchLocalModCacheLicenses).
WithLocalModCacheDir(cfg.Golang.LocalModCacheDir).
WithSearchRemoteLicenses(cfg.Golang.SearchRemoteLicenses).
WithProxy(cfg.Golang.Proxy).
WithNoProxy(cfg.Golang.NoProxy),
LinuxKernel: kernel.LinuxKernelCatalogerConfig{
CatalogModules: cfg.LinuxKernel.CatalogModules,
},
Java: javaCataloger.DefaultArchiveCatalogerConfig().
WithUseNetwork(cfg.Java.UseNetwork).
WithMavenBaseURL(cfg.Java.MavenURL).
WithArchiveTraversal(
cataloging.ArchiveSearchConfig{
IncludeIndexedArchives: cfg.Package.SearchIndexedArchives,
IncludeUnindexedArchives: cfg.Package.SearchUnindexedArchives,
},
cfg.Java.MaxParentRecursiveDepth),
Python: pythonCataloger.CatalogerConfig{
GuessUnpinnedRequirements: cfg.Python.GuessUnpinnedRequirements,
},
ExcludeBinaryOverlapByOwnership: cfg.ExcludeBinaryOverlapByOwnership,
}
}
var validDefaultSourceValues = []string{"registry", "docker", "podman", ""}
func checkDefaultSourceValues(source string) error {
validValues := strset.New(validDefaultSourceValues...)
if !validValues.Has(source) {
validValuesString := strings.Join(validDefaultSourceValues, ", ")
return fmt.Errorf("%s is not a valid default source; please use one of the following: %s''", source, validValuesString)
}
return nil
}
func expandFilePath(file string) (string, error) {
if file != "" {
expandedPath, err := homedir.Expand(file)
if err != nil {
return "", fmt.Errorf("unable to expand file path=%q: %w", file, err)
}
file = expandedPath
}
return file, nil
}
+6
View File
@@ -0,0 +1,6 @@
package options
// Config holds a reference to the specific config file that was used to load application configuration
type Config struct {
ConfigFile string `yaml:"config" json:"config" mapstructure:"config"`
}
+21
View File
@@ -0,0 +1,21 @@
package options
import (
"github.com/anchore/syft/internal/file"
"github.com/anchore/syft/syft/source"
)
type fileContents struct {
Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"`
SkipFilesAboveSize int64 `yaml:"skip-files-above-size" json:"skip-files-above-size" mapstructure:"skip-files-above-size"`
Globs []string `yaml:"globs" json:"globs" mapstructure:"globs"`
}
func defaultFileContents() fileContents {
return fileContents{
Cataloger: scope{
Scope: source.SquashedScope.String(),
},
SkipFilesAboveSize: 1 * file.MB,
}
}
+19
View File
@@ -0,0 +1,19 @@
package options
import (
"github.com/anchore/syft/syft/source"
)
type fileMetadata struct {
Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"`
Digests []string `yaml:"digests" json:"digests" mapstructure:"digests"`
}
func defaultFileMetadata() fileMetadata {
return fileMetadata{
Cataloger: scope{
Scope: source.SquashedScope.String(),
},
Digests: []string{"sha256"},
}
}
+129
View File
@@ -0,0 +1,129 @@
package options
import (
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/anchore/clio"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/format/github"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/format/table"
"github.com/anchore/syft/syft/format/template"
"github.com/anchore/syft/syft/format/text"
"github.com/anchore/syft/syft/sbom"
)
var _ clio.PostLoader = (*Format)(nil)
// Format contains all user configuration for output formatting.
type Format struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
Template FormatTemplate `yaml:"template" json:"template" mapstructure:"template"`
SyftJSON FormatSyftJSON `yaml:"json" json:"json" mapstructure:"json"`
SPDXJSON FormatSPDXJSON `yaml:"spdx-json" json:"spdx-json" mapstructure:"spdx-json"`
CyclonedxJSON FormatCyclonedxJSON `yaml:"cyclonedx-json" json:"cyclonedx-json" mapstructure:"cyclonedx-json"`
CyclonedxXML FormatCyclonedxXML `yaml:"cyclonedx-xml" json:"cyclonedx-xml" mapstructure:"cyclonedx-xml"`
}
func (o *Format) PostLoad() error {
o.SyftJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.SyftJSON.Pretty)
o.SPDXJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.SPDXJSON.Pretty)
o.CyclonedxJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.CyclonedxJSON.Pretty)
o.CyclonedxXML.Pretty = multiLevelOption[bool](false, o.Pretty, o.CyclonedxXML.Pretty)
return nil
}
func DefaultFormat() Format {
return Format{
Template: DefaultFormatTemplate(),
SyftJSON: DefaultFormatJSON(),
SPDXJSON: DefaultFormatSPDXJSON(),
CyclonedxJSON: DefaultFormatCyclonedxJSON(),
CyclonedxXML: DefaultFormatCyclonedxXML(),
}
}
func (o *Format) Encoders() ([]sbom.FormatEncoder, error) {
// setup all encoders based on the configuration
var list encoderList
// in the future there will be application configuration options that can be used to set the default output format
list.addWithErr(template.ID)(o.Template.formatEncoders())
list.addWithErr(syftjson.ID)(o.SyftJSON.formatEncoders())
list.add(table.ID)(table.NewFormatEncoder())
list.add(text.ID)(text.NewFormatEncoder())
list.add(github.ID)(github.NewFormatEncoder())
list.addWithErr(cyclonedxxml.ID)(o.CyclonedxXML.formatEncoders())
list.addWithErr(cyclonedxjson.ID)(o.CyclonedxJSON.formatEncoders())
list.addWithErr(spdxjson.ID)(o.SPDXJSON.formatEncoders())
list.addWithErr(spdxtagvalue.ID)(spdxTagValueEncoders())
return list.encoders, list.err
}
// TODO: when application configuration is made for this format then this should be ported to the options object
// that is created for that configuration (as done with the template output option)
func spdxTagValueEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range spdxtagvalue.SupportedVersions() {
enc, err := spdxtagvalue.NewFormatEncoderWithConfig(spdxtagvalue.EncoderConfig{Version: v})
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
type encoderList struct {
encoders []sbom.FormatEncoder
err error
}
func (l *encoderList) addWithErr(name sbom.FormatID) func([]sbom.FormatEncoder, error) {
return func(encs []sbom.FormatEncoder, err error) {
if err != nil {
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: %w", name, err))
return
}
for _, enc := range encs {
if enc == nil {
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: nil encoder returned", name))
continue
}
l.encoders = append(l.encoders, enc)
}
}
}
func (l *encoderList) add(name sbom.FormatID) func(...sbom.FormatEncoder) {
return func(encs ...sbom.FormatEncoder) {
for _, enc := range encs {
if enc == nil {
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: nil encoder returned", name))
continue
}
l.encoders = append(l.encoders, enc)
}
}
}
func multiLevelOption[T any](defaultValue T, option ...*T) *T {
result := defaultValue
for _, opt := range option {
if opt != nil {
result = *opt
}
}
return &result
}
@@ -0,0 +1,43 @@
package options
import (
"github.com/hashicorp/go-multierror"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatCyclonedxJSON struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatCyclonedxJSON() FormatCyclonedxJSON {
return FormatCyclonedxJSON{}
}
func (o FormatCyclonedxJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range cyclonedxjson.SupportedVersions() {
enc, err := cyclonedxjson.NewFormatEncoderWithConfig(o.buildConfig(v))
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func (o FormatCyclonedxJSON) buildConfig(version string) cyclonedxjson.EncoderConfig {
var pretty bool
if o.Pretty != nil {
pretty = *o.Pretty
}
return cyclonedxjson.EncoderConfig{
Version: version,
Pretty: pretty,
}
}
@@ -13,7 +13,7 @@ func TestFormatCyclonedxJSON_buildConfig(t *testing.T) {
ft := &FormatCyclonedxJSON{}
ft = setAllToNonZero(t, ft).(*FormatCyclonedxJSON)
subject := ft.config("Version")
subject := ft.buildConfig("Version")
assertExpectedValue(t, subject)
}
@@ -0,0 +1,43 @@
package options
import (
"github.com/hashicorp/go-multierror"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/sbom"
)
type FormatCyclonedxXML struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatCyclonedxXML() FormatCyclonedxXML {
return FormatCyclonedxXML{}
}
func (o FormatCyclonedxXML) formatEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range cyclonedxxml.SupportedVersions() {
enc, err := cyclonedxxml.NewFormatEncoderWithConfig(o.buildConfig(v))
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func (o FormatCyclonedxXML) buildConfig(version string) cyclonedxxml.EncoderConfig {
var pretty bool
if o.Pretty != nil {
pretty = *o.Pretty
}
return cyclonedxxml.EncoderConfig{
Version: version,
Pretty: pretty,
}
}
@@ -10,6 +10,6 @@ func TestFormatCyclonedxXML_buildConfig(t *testing.T) {
ft := FormatCyclonedxXML{}
ftp := setAllToNonZero(t, &ft).(*FormatCyclonedxXML)
subject := ftp.config("Version")
subject := ftp.buildConfig("Version")
assertExpectedValue(t, subject)
}
+43
View File
@@ -0,0 +1,43 @@
package options
import (
"github.com/hashicorp/go-multierror"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatSPDXJSON struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatSPDXJSON() FormatSPDXJSON {
return FormatSPDXJSON{}
}
func (o FormatSPDXJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range spdxjson.SupportedVersions() {
enc, err := spdxjson.NewFormatEncoderWithConfig(o.buildConfig(v))
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func (o FormatSPDXJSON) buildConfig(v string) spdxjson.EncoderConfig {
var pretty bool
if o.Pretty != nil {
pretty = *o.Pretty
}
return spdxjson.EncoderConfig{
Version: v,
Pretty: pretty,
}
}
@@ -10,6 +10,6 @@ func TestFormatSPDXJSON_buildConfig(t *testing.T) {
ft := &FormatSPDXJSON{}
ft = setAllToNonZero(t, ft).(*FormatSPDXJSON)
subject := ft.config("Version")
subject := ft.buildConfig("Version")
assertExpectedValue(t, subject)
}
@@ -2,6 +2,7 @@ package options
import (
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatSyftJSON struct {
@@ -15,7 +16,12 @@ func DefaultFormatJSON() FormatSyftJSON {
}
}
func (o FormatSyftJSON) config() syftjson.EncoderConfig {
func (o FormatSyftJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
enc, err := syftjson.NewFormatEncoderWithConfig(o.buildConfig())
return []sbom.FormatEncoder{enc}, err
}
func (o FormatSyftJSON) buildConfig() syftjson.EncoderConfig {
var pretty bool
if o.Pretty != nil {
pretty = *o.Pretty
@@ -10,6 +10,6 @@ func TestFormatSyftJSON_buildConfig(t *testing.T) {
ft := &FormatSyftJSON{}
ft = setAllToNonZero(t, ft).(*FormatSyftJSON)
subject := ft.config()
subject := ft.buildConfig()
assertExpectedValue(t, subject)
}
@@ -3,6 +3,7 @@ package options
import (
"github.com/anchore/clio"
"github.com/anchore/syft/syft/format/template"
"github.com/anchore/syft/syft/sbom"
)
var _ clio.FlagAdder = (*FormatTemplate)(nil)
@@ -10,7 +11,6 @@ var _ clio.FlagAdder = (*FormatTemplate)(nil)
type FormatTemplate struct {
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
Path string `yaml:"path" json:"path" mapstructure:"path"` // -t template file to use for output
Legacy bool `yaml:"legacy" json:"legacy" mapstructure:"legacy"`
}
func DefaultFormatTemplate() FormatTemplate {
@@ -26,9 +26,12 @@ func (o *FormatTemplate) AddFlags(flags clio.FlagSet) {
}
}
func (o FormatTemplate) config() template.EncoderConfig {
return template.EncoderConfig{
TemplatePath: o.Path,
Legacy: o.Legacy,
func (o FormatTemplate) formatEncoders() ([]sbom.FormatEncoder, error) {
if !o.Enabled {
return nil, nil
}
enc, err := template.NewFormatEncoder(template.EncoderConfig{
TemplatePath: o.Path,
})
return []sbom.FormatEncoder{enc}, err
}
+9
View File
@@ -0,0 +1,9 @@
package options
type golang struct {
SearchLocalModCacheLicenses bool `json:"search-local-mod-cache-licenses" yaml:"search-local-mod-cache-licenses" mapstructure:"search-local-mod-cache-licenses"`
LocalModCacheDir string `json:"local-mod-cache-dir" yaml:"local-mod-cache-dir" mapstructure:"local-mod-cache-dir"`
SearchRemoteLicenses bool `json:"search-remote-licenses" yaml:"search-remote-licenses" mapstructure:"search-remote-licenses"`
Proxy string `json:"proxy" yaml:"proxy" mapstructure:"proxy"`
NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"`
}
+7
View File
@@ -0,0 +1,7 @@
package options
type java struct {
UseNetwork bool `yaml:"use-network" json:"use-network" mapstructure:"use-network"`
MavenURL string `yaml:"maven-url" json:"maven-url" mapstructure:"maven-url"`
MaxParentRecursiveDepth int `yaml:"max-parent-recursive-depth" json:"max-parent-recursive-depth" mapstructure:"max-parent-recursive-depth"`
}
+11
View File
@@ -0,0 +1,11 @@
package options
type linuxKernel struct {
CatalogModules bool `json:"catalog-modules" yaml:"catalog-modules" mapstructure:"catalog-modules"`
}
func defaultLinuxKernel() linuxKernel {
return linuxKernel{
CatalogModules: true,
}
}
@@ -24,7 +24,6 @@ import (
var _ interface {
clio.FlagAdder
clio.PostLoader
clio.FieldDescriber
} = (*Output)(nil)
// Output has the standard output options syft accepts: multiple -o, --file, --template
@@ -71,15 +70,6 @@ func (o *Output) AddFlags(flags clio.FlagSet) {
fmt.Sprintf("report output format (<format>=<file> to output to a file), formats=%v", names))
}
func (o *Output) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&o.Outputs, `the output format(s) of the SBOM report (options: syft-table, syft-text, syft-json, spdx-json, ...)
to specify multiple output files in differing formats, use a list:
output:
- "syft-json=<syft-json-output-file>"
- "spdx-json=<spdx-json-output-file>"
`)
}
func (o Output) SBOMWriter() (sbom.Writer, error) {
names := o.OutputNameSet()
@@ -106,7 +96,7 @@ func (o Output) SBOMWriter() (sbom.Writer, error) {
}
}
return makeSBOMWriter(o.Outputs, o.LegacyFile, encoders)
return makeSBOMWriter(o.Outputs, o.File, encoders)
}
func (o Output) OutputNameSet() *strset.Set {
@@ -1,10 +1,6 @@
package options
import (
"fmt"
"github.com/mitchellh/go-homedir"
"github.com/anchore/clio"
"github.com/anchore/fangs"
"github.com/anchore/syft/syft/sbom"
@@ -17,13 +13,13 @@ var _ interface {
// Deprecated: OutputFile supports the --file to write the SBOM output to
type OutputFile struct {
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
LegacyFile string `yaml:"-" json:"-" mapstructure:"legacyFile"`
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
File string `yaml:"file" json:"file" mapstructure:"file"`
}
func (o *OutputFile) AddFlags(flags clio.FlagSet) {
if o.Enabled {
flags.StringVarP(&o.LegacyFile, "file", "",
flags.StringVarP(&o.File, "file", "",
"file to write the default report output to (default is STDOUT)")
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
@@ -37,12 +33,12 @@ func (o *OutputFile) PostLoad() error {
if !o.Enabled {
return nil
}
if o.LegacyFile != "" {
file, err := expandFilePath(o.LegacyFile)
if o.File != "" {
file, err := expandFilePath(o.File)
if err != nil {
return err
}
o.LegacyFile = file
o.File = file
}
return nil
}
@@ -51,21 +47,10 @@ func (o *OutputFile) SBOMWriter(f sbom.FormatEncoder) (sbom.Writer, error) {
if !o.Enabled {
return nil, nil
}
writer, err := newSBOMMultiWriter(newSBOMWriterDescription(f, o.LegacyFile))
writer, err := newSBOMMultiWriter(newSBOMWriterDescription(f, o.File))
if err != nil {
return nil, err
}
return writer, nil
}
func expandFilePath(file string) (string, error) {
if file != "" {
expandedPath, err := homedir.Expand(file)
if err != nil {
return "", fmt.Errorf("unable to expand file path=%q: %w", file, err)
}
file = expandedPath
}
return file, nil
}
+23
View File
@@ -0,0 +1,23 @@
package options
import (
"github.com/anchore/syft/syft/pkg/cataloger"
)
type pkg struct {
Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"`
SearchUnindexedArchives bool `yaml:"search-unindexed-archives" json:"search-unindexed-archives" mapstructure:"search-unindexed-archives"`
SearchIndexedArchives bool `yaml:"search-indexed-archives" json:"search-indexed-archives" mapstructure:"search-indexed-archives"`
}
func defaultPkg() pkg {
c := cataloger.DefaultSearchConfig()
return pkg{
SearchIndexedArchives: c.IncludeIndexedArchives,
SearchUnindexedArchives: c.IncludeUnindexedArchives,
Cataloger: scope{
Enabled: true,
Scope: c.Scope.String(),
},
}
}
+5
View File
@@ -0,0 +1,5 @@
package options
type python struct {
GuessUnpinnedRequirements bool `json:"guess-unpinned-requirements" yaml:"guess-unpinned-requirements" mapstructure:"guess-unpinned-requirements"`
}
@@ -18,19 +18,16 @@ type RegistryCredentials struct {
TLSKey string `yaml:"tls-key,omitempty" json:"tls-key,omitempty" mapstructure:"tls-key"`
}
type registryConfig struct {
type registry struct {
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify" json:"insecure-skip-tls-verify" mapstructure:"insecure-skip-tls-verify"`
InsecureUseHTTP bool `yaml:"insecure-use-http" json:"insecure-use-http" mapstructure:"insecure-use-http"`
Auth []RegistryCredentials `yaml:"auth" json:"auth" mapstructure:"auth"`
CACert string `yaml:"ca-cert" json:"ca-cert" mapstructure:"ca-cert"`
}
var _ interface {
clio.PostLoader
clio.FieldDescriber
} = (*registryConfig)(nil)
var _ clio.PostLoader = (*registry)(nil)
func (cfg *registryConfig) PostLoad() error {
func (cfg *registry) PostLoad() error {
// there may be additional credentials provided by env var that should be appended to the set of credentials
authority, username, password, token, tlsCert, tlsKey :=
os.Getenv("SYFT_REGISTRY_AUTH_AUTHORITY"),
@@ -58,20 +55,6 @@ func (cfg *registryConfig) PostLoad() error {
return nil
}
func (cfg *registryConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
descriptions.Add(&cfg.InsecureSkipTLSVerify, "skip TLS verification when communicating with the registry")
descriptions.Add(&cfg.InsecureUseHTTP, "use http instead of https when connecting to the registry")
descriptions.Add(&cfg.CACert, "filepath to a CA certificate (or directory containing *.crt, *.cert, *.pem) used to generate the client certificate")
descriptions.Add(&cfg.Auth, `Authentication credentials for specific registries. Each entry describes authentication for a specific authority:
- authority: the registry authority URL the URL to the registry (e.g. "docker.io", "localhost:5000", etc.) (env: SYFT_REGISTRY_AUTH_AUTHORITY)
username: a username if using basic credentials (env: SYFT_REGISTRY_AUTH_USERNAME)
password: a corresponding password (env: SYFT_REGISTRY_AUTH_PASSWORD)
token: a token if using token-based authentication, mutually exclusive with username/password (env: SYFT_REGISTRY_AUTH_TOKEN)
tls-cert: filepath to the client certificate used for TLS authentication to the registry (env: SYFT_REGISTRY_AUTH_TLS_CERT)
tls-key: filepath to the client key used for TLS authentication to the registry (env: SYFT_REGISTRY_AUTH_TLS_KEY)
`)
}
func hasNonEmptyCredentials(username, password, token, tlsCert, tlsKey string) bool {
hasUserPass := username != "" && password != ""
hasToken := token != ""
@@ -79,7 +62,7 @@ func hasNonEmptyCredentials(username, password, token, tlsCert, tlsKey string) b
return hasUserPass || hasToken || hasTLSMaterial
}
func (cfg *registryConfig) ToOptions() *image.RegistryOptions {
func (cfg *registry) ToOptions() *image.RegistryOptions {
var auth = make([]image.RegistryCredentials, len(cfg.Auth))
for i, a := range cfg.Auth {
auth[i] = image.RegistryCredentials{
@@ -72,19 +72,19 @@ func TestHasNonEmptyCredentials(t *testing.T) {
func Test_registry_ToOptions(t *testing.T) {
tests := []struct {
name string
input registryConfig
input registry
expected image.RegistryOptions
}{
{
name: "no registry options",
input: registryConfig{},
input: registry{},
expected: image.RegistryOptions{
Credentials: []image.RegistryCredentials{},
},
},
{
name: "set InsecureSkipTLSVerify",
input: registryConfig{
input: registry{
InsecureSkipTLSVerify: true,
},
expected: image.RegistryOptions{
@@ -94,7 +94,7 @@ func Test_registry_ToOptions(t *testing.T) {
},
{
name: "set InsecureUseHTTP",
input: registryConfig{
input: registry{
InsecureUseHTTP: true,
},
expected: image.RegistryOptions{
@@ -104,7 +104,7 @@ func Test_registry_ToOptions(t *testing.T) {
},
{
name: "set all bool options",
input: registryConfig{
input: registry{
InsecureSkipTLSVerify: true,
InsecureUseHTTP: true,
},
@@ -116,7 +116,7 @@ func Test_registry_ToOptions(t *testing.T) {
},
{
name: "provide all tls configuration",
input: registryConfig{
input: registry{
CACert: "ca.crt",
InsecureSkipTLSVerify: true,
Auth: []RegistryCredentials{
+27
View File
@@ -0,0 +1,27 @@
package options
import (
"fmt"
"github.com/anchore/clio"
"github.com/anchore/syft/syft/source"
)
type scope struct {
Enabled bool `yaml:"enabled" json:"enabled" mapstructure:"enabled"`
Scope string `yaml:"scope" json:"scope" mapstructure:"scope"`
}
var _ clio.PostLoader = (*scope)(nil)
func (opt *scope) PostLoad() error {
s := opt.GetScope()
if s == source.UnknownScope {
return fmt.Errorf("bad scope value %v", opt.Scope)
}
return nil
}
func (opt scope) GetScope() source.Scope {
return source.ParseScope(opt.Scope)
}
+19
View File
@@ -0,0 +1,19 @@
package options
type sourceCfg struct {
Name string `json:"name" yaml:"name" mapstructure:"name"`
Version string `json:"version" yaml:"version" mapstructure:"version"`
File fileSource `json:"file" yaml:"file" mapstructure:"file"`
}
type fileSource struct {
Digests []string `json:"digests" yaml:"digests" mapstructure:"digests"`
}
func defaultSourceCfg() sourceCfg {
return sourceCfg{
File: fileSource{
Digests: []string{"sha256"},
},
}
}
@@ -1,7 +1,5 @@
package options
import "github.com/anchore/fangs"
type UpdateCheck struct {
CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not
}
@@ -11,7 +9,3 @@ func DefaultUpdateCheck() UpdateCheck {
CheckForAppUpdate: true,
}
}
func (cfg *UpdateCheck) DescribeFields(descriptions fangs.FieldDescriptionSet) {
descriptions.Add(&cfg.CheckForAppUpdate, "whether to check for an application update on start up or not")
}
@@ -1,24 +1,16 @@
[TestHandler_handleCatalogerTaskStarted/cataloging_task_in_progress - 1]
⠙ Cataloging contents
⠙ some task title ━━━━━━━━━━━━━━━━━━━━ [some stage]
some task title [some value]
---
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_in_progress - 1]
⠙ Cataloging contents
└── ⠙ some task title ━━━━━━━━━━━━━━━━━━━━ [some stage]
└── some task title [some value]
---
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete - 1]
⠙ Cataloging contents
└── ✔ some task done [some stage]
---
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete_--_hide_stage - 1]
⠙ Cataloging contents
└── ✔ some task done
✔ └── some task done [some value]
---
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete_with_removal - 1]
⠙ Cataloging contents
---
@@ -0,0 +1,8 @@
[TestHandler_handleFileDigestsCatalogerStarted/cataloging_in_progress - 1]
⠋ Cataloging file digests ━━━━━━━━━━━━━━━━━━━━ [current]
---
[TestHandler_handleFileDigestsCatalogerStarted/cataloging_complete - 1]
✔ Cataloged file digests
---
@@ -0,0 +1,8 @@
[TestHandler_handleFileMetadataCatalogerStarted/cataloging_in_progress - 1]
⠋ Cataloging file metadata ━━━━━━━━━━━━━━━━━━━━ [current]
---
[TestHandler_handleFileMetadataCatalogerStarted/cataloging_complete - 1]
✔ Cataloged file metadata
---
@@ -0,0 +1,16 @@
[TestHandler_handlePackageCatalogerStarted/cataloging_in_progress - 1]
⠋ Cataloging packages [50 packages]
---
[TestHandler_handlePackageCatalogerStarted/cataloging_only_files_complete - 1]
⠋ Cataloging packages [50 packages]
---
[TestHandler_handlePackageCatalogerStarted/cataloging_only_packages_complete - 1]
⠋ Cataloging packages [100 packages]
---
[TestHandler_handlePackageCatalogerStarted/cataloging_complete - 1]
✔ Cataloged packages [100 packages]
---
@@ -0,0 +1,8 @@
[TestHandler_handleSecretsCatalogerStarted/cataloging_in_progress - 1]
⠋ Cataloging secrets ━━━━━━━━━━━━━━━━━━━━ [64 secrets]
---
[TestHandler_handleSecretsCatalogerStarted/cataloging_complete - 1]
✔ Cataloged secrets [64 secrets]
---
+3 -10
View File
@@ -100,21 +100,18 @@ func TestHandler_handleAttestationStarted(t *testing.T) {
Height: 80,
}
models, _ := handler.Handle(event)
models := handler.Handle(event)
require.Len(t, models, 2)
t.Run("task line", func(t *testing.T) {
tsk, ok := models[0].(taskprogress.Model)
require.True(t, ok)
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
got := gotModel.View()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
@@ -122,15 +119,11 @@ func TestHandler_handleAttestationStarted(t *testing.T) {
t.Run("log", func(t *testing.T) {
log, ok := models[1].(attestLogFrame)
require.True(t, ok)
gotModel := runModel(t, log, tt.iterations, attestLogFrameTickMsg{
got := runModel(t, log, tt.iterations, attestLogFrameTickMsg{
Time: time.Now(),
Sequence: log.sequence,
ID: log.id,
}, log.reader.running)
got := gotModel.View()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
+42 -93
View File
@@ -3,121 +3,70 @@ package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
"github.com/anchore/bubbly/bubbles/tree"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event/monitor"
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
)
// we standardize how rows are instantiated to ensure consistency in the appearance across the UI
type taskModelFactory func(title taskprogress.Title, opts ...taskprogress.Option) taskprogress.Model
var _ progress.Stager = (*catalogerTaskStageAdapter)(nil)
var _ tea.Model = (*catalogerTaskModel)(nil)
type catalogerTaskModel struct {
model tree.Model
modelFactory taskModelFactory
type catalogerTaskStageAdapter struct {
mon *monitor.CatalogerTask
}
func newCatalogerTaskTreeModel(f taskModelFactory) *catalogerTaskModel {
t := tree.NewModel()
t.Padding = " "
t.RootsWithoutPrefix = true
return &catalogerTaskModel{
modelFactory: f,
model: t,
func newCatalogerTaskStageAdapter(mon *monitor.CatalogerTask) *catalogerTaskStageAdapter {
return &catalogerTaskStageAdapter{
mon: mon,
}
}
type newCatalogerTaskRowEvent struct {
info monitor.GenericTask
prog progress.StagedProgressable
func (c catalogerTaskStageAdapter) Stage() string {
return c.mon.GetValue()
}
func (cts catalogerTaskModel) Init() tea.Cmd {
return cts.model.Init()
}
func (cts catalogerTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
event, ok := msg.(newCatalogerTaskRowEvent)
if !ok {
model, cmd := cts.model.Update(msg)
cts.model = model.(tree.Model)
return cts, cmd
}
info, prog := event.info, event.prog
tsk := cts.modelFactory(
taskprogress.Title{
Default: info.Title.Default,
Running: info.Title.WhileRunning,
Success: info.Title.OnSuccess,
},
taskprogress.WithStagedProgressable(prog),
)
if info.Context != "" {
tsk.Context = []string{info.Context}
}
tsk.HideOnSuccess = info.HideOnSuccess
tsk.HideStageOnSuccess = info.HideStageOnSuccess
tsk.HideProgressOnSuccess = true
if info.ParentID != "" {
tsk.TitleStyle = lipgloss.NewStyle()
}
if err := cts.model.Add(info.ParentID, info.ID, tsk); err != nil {
log.WithFields("error", err).Error("unable to add cataloger task to tree model")
}
return cts, tsk.Init()
}
func (cts catalogerTaskModel) View() string {
return cts.model.View()
}
func (m *Handler) handleCatalogerTaskStarted(e partybus.Event) ([]tea.Model, tea.Cmd) {
mon, info, err := syftEventParsers.ParseCatalogerTaskStarted(e)
func (m *Handler) handleCatalogerTaskStarted(e partybus.Event) []tea.Model {
mon, err := syftEventParsers.ParseCatalogerTaskStarted(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil, nil
return nil
}
var models []tea.Model
// only create the new cataloger task tree once to manage all cataloger task events
m.onNewCatalogerTask.Do(func() {
models = append(models, newCatalogerTaskTreeModel(m.newTaskProgress))
})
// we need to update the cataloger task model with a new row. We should never update the model outside of the
// bubbletea update-render event loop. Instead, we return a command that will be executed by the bubbletea runtime,
// producing a message that is passed to the cataloger task model. This is the prescribed way to update models
// in bubbletea.
if info.ID == "" {
// ID is optional from the consumer perspective, but required internally
info.ID = uuid.Must(uuid.NewRandom()).String()
var prefix string
if mon.SubStatus {
// TODO: support list of sub-statuses, not just a single leaf
prefix = "└── "
}
cmd := func() tea.Msg {
// this message will cause the cataloger task model to add a new row to the output based on the given task
// information and progress data.
return newCatalogerTaskRowEvent{
info: *info,
prog: mon,
}
}
tsk := m.newTaskProgress(
taskprogress.Title{
// TODO: prefix should not be part of the title, but instead a separate field that is aware of the tree structure
Default: prefix + mon.Title,
Running: prefix + mon.Title,
Success: prefix + mon.TitleOnCompletion,
},
taskprogress.WithStagedProgressable(
struct {
progress.Stager
progress.Progressable
}{
Progressable: mon.GetMonitor(),
Stager: newCatalogerTaskStageAdapter(mon),
},
),
)
return models, cmd
// TODO: this isn't ideal since the model stays around after it is no longer needed, but it works for now
tsk.HideOnSuccess = mon.RemoveOnCompletion
tsk.HideStageOnSuccess = false
tsk.HideProgressOnSuccess = false
tsk.TitleStyle = lipgloss.NewStyle()
// TODO: this is a hack to get the spinner to not show up, but ideally the component would support making the spinner optional
tsk.Spinner.Spinner.Frames = []string{" "}
return []tea.Model{tsk}
}
+44 -119
View File
@@ -2,22 +2,19 @@ package ui
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/gkampitakis/go-snaps/snaps"
"github.com/stretchr/testify/require"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
syftEvent "github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/event/monitor"
)
func TestHandler_handleCatalogerTaskStarted(t *testing.T) {
title := monitor.Title{
Default: "some task title",
OnSuccess: "some task done",
}
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
@@ -26,171 +23,99 @@ func TestHandler_handleCatalogerTaskStarted(t *testing.T) {
{
name: "cataloging task in progress",
eventFn: func(t *testing.T) partybus.Event {
value := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage("some stage"),
Manual: progress.NewManual(100),
src := &monitor.CatalogerTask{
SubStatus: false,
RemoveOnCompletion: false,
Title: "some task title",
TitleOnCompletion: "some task done",
}
value.Manual.Add(50)
src.SetValue("some value")
return partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: monitor.GenericTask{
Title: title,
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: "my-id",
},
Value: value,
Type: syftEvent.CatalogerTaskStarted,
Source: src,
}
},
},
{
name: "cataloging sub task in progress",
eventFn: func(t *testing.T) partybus.Event {
value := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage("some stage"),
Manual: progress.NewManual(100),
src := &monitor.CatalogerTask{
SubStatus: true,
RemoveOnCompletion: false,
Title: "some task title",
TitleOnCompletion: "some task done",
}
value.Manual.Add(50)
src.SetValue("some value")
return partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: monitor.GenericTask{
Title: title,
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: "my-id",
ParentID: "top-level-task",
},
Value: value,
Type: syftEvent.CatalogerTaskStarted,
Source: src,
}
},
},
{
name: "cataloging sub task complete",
eventFn: func(t *testing.T) partybus.Event {
value := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage("some stage"),
Manual: progress.NewManual(100),
src := &monitor.CatalogerTask{
SubStatus: true,
RemoveOnCompletion: false,
Title: "some task title",
TitleOnCompletion: "some task done",
}
value.SetCompleted()
src.SetValue("some value")
src.SetCompleted()
return partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: monitor.GenericTask{
Title: title,
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: "my-id",
ParentID: "top-level-task",
},
Value: value,
}
},
},
{
name: "cataloging sub task complete -- hide stage",
eventFn: func(t *testing.T) partybus.Event {
value := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage("some stage"),
Manual: progress.NewManual(100),
}
value.SetCompleted()
return partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: monitor.GenericTask{
Title: title,
HideOnSuccess: false,
HideStageOnSuccess: true,
ID: "my-id",
ParentID: "top-level-task",
},
Value: value,
Type: syftEvent.CatalogerTaskStarted,
Source: src,
}
},
},
{
name: "cataloging sub task complete with removal",
eventFn: func(t *testing.T) partybus.Event {
value := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage("some stage"),
Manual: progress.NewManual(100),
src := &monitor.CatalogerTask{
SubStatus: true,
RemoveOnCompletion: true,
Title: "some task title",
TitleOnCompletion: "some task done",
}
value.SetCompleted()
src.SetValue("some value")
src.SetCompleted()
return partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: monitor.GenericTask{
Title: title,
HideOnSuccess: true,
HideStageOnSuccess: false,
ID: "my-id",
ParentID: "top-level-task",
},
Value: value,
Type: syftEvent.CatalogerTaskStarted,
Source: src,
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// need to be able to get the initial newCatalogerTaskRowEvent + initialize the nested taskprogress model
if tt.iterations == 0 {
tt.iterations = 2
}
e := tt.eventFn(t)
event := tt.eventFn(t)
handler := New(DefaultHandlerConfig())
handler.WindowSize = tea.WindowSizeMsg{
Width: 100,
Height: 80,
}
info := monitor.GenericTask{
Title: monitor.Title{
Default: "Catalog contents",
WhileRunning: "Cataloging contents",
OnSuccess: "Cataloged contents",
},
ID: "top-level-task",
}
// note: this line / event is not under test, only needed to show a sub status
kickoffEvent := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage(""),
Manual: progress.NewManual(-1),
}
models, cmd := handler.Handle(
partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: info,
Value: progress.StagedProgressable(kickoffEvent),
},
)
models := handler.Handle(event)
require.Len(t, models, 1)
require.NotNil(t, cmd)
model := models[0]
tr, ok := model.(*catalogerTaskModel)
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
gotModel := runModel(t, tr, tt.iterations, cmd())
models, cmd = handler.Handle(e)
require.Len(t, models, 0)
require.NotNil(t, cmd)
gotModel = runModel(t, gotModel, tt.iterations, cmd())
got := gotModel.View()
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
t.Log(got)
snaps.MatchSnapshot(t, got)
})
+2 -5
View File
@@ -80,21 +80,18 @@ func TestHandler_handleFetchImage(t *testing.T) {
Height: 80,
}
models, _ := handler.Handle(event)
models := handler.Handle(event)
require.Len(t, models, 1)
model := models[0]
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
got := gotModel.View()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
@@ -0,0 +1,28 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/go-partybus"
"github.com/anchore/bubbly/bubbles/taskprogress"
"github.com/anchore/syft/internal/log"
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
)
func (m *Handler) handleFileDigestsCatalogerStarted(e partybus.Event) []tea.Model {
prog, err := syftEventParsers.ParseFileDigestsCatalogingStarted(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Catalog file digests",
Running: "Cataloging file digests",
Success: "Cataloged file digests",
}, taskprogress.WithStagedProgressable(prog),
)
return []tea.Model{tsk}
}
@@ -0,0 +1,97 @@
package ui
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/gkampitakis/go-snaps/snaps"
"github.com/stretchr/testify/require"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
syftEvent "github.com/anchore/syft/syft/event"
)
func TestHandler_handleFileDigestsCatalogerStarted(t *testing.T) {
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
iterations int
}{
{
name: "cataloging in progress",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(50)
mon := struct {
progress.Progressable
progress.Stager
}{
Progressable: prog,
Stager: &progress.Stage{
Current: "current",
},
}
return partybus.Event{
Type: syftEvent.FileDigestsCatalogerStarted,
Value: mon,
}
},
},
{
name: "cataloging complete",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(100)
prog.SetCompleted()
mon := struct {
progress.Progressable
progress.Stager
}{
Progressable: prog,
Stager: &progress.Stage{
Current: "current",
},
}
return partybus.Event{
Type: syftEvent.FileDigestsCatalogerStarted,
Value: mon,
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := tt.eventFn(t)
handler := New(DefaultHandlerConfig())
handler.WindowSize = tea.WindowSizeMsg{
Width: 100,
Height: 80,
}
models := handler.Handle(event)
require.Len(t, models, 1)
model := models[0]
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
t.Log(got)
snaps.MatchSnapshot(t, got)
})
}
}
+2 -5
View File
@@ -80,21 +80,18 @@ func TestHandler_handleFileIndexingStarted(t *testing.T) {
Height: 80,
}
models, _ := handler.Handle(event)
models := handler.Handle(event)
require.Len(t, models, 1)
model := models[0]
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
got := gotModel.View()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
@@ -0,0 +1,29 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/go-partybus"
"github.com/anchore/bubbly/bubbles/taskprogress"
"github.com/anchore/syft/internal/log"
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
)
func (m *Handler) handleFileMetadataCatalogerStarted(e partybus.Event) []tea.Model {
prog, err := syftEventParsers.ParseFileMetadataCatalogingStarted(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Catalog file metadata",
Running: "Cataloging file metadata",
Success: "Cataloged file metadata",
},
taskprogress.WithStagedProgressable(prog),
)
return []tea.Model{tsk}
}
@@ -0,0 +1,97 @@
package ui
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/gkampitakis/go-snaps/snaps"
"github.com/stretchr/testify/require"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
syftEvent "github.com/anchore/syft/syft/event"
)
func TestHandler_handleFileMetadataCatalogerStarted(t *testing.T) {
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
iterations int
}{
{
name: "cataloging in progress",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(50)
mon := struct {
progress.Progressable
progress.Stager
}{
Progressable: prog,
Stager: &progress.Stage{
Current: "current",
},
}
return partybus.Event{
Type: syftEvent.FileMetadataCatalogerStarted,
Value: mon,
}
},
},
{
name: "cataloging complete",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(100)
prog.SetCompleted()
mon := struct {
progress.Progressable
progress.Stager
}{
Progressable: prog,
Stager: &progress.Stage{
Current: "current",
},
}
return partybus.Event{
Type: syftEvent.FileMetadataCatalogerStarted,
Value: mon,
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := tt.eventFn(t)
handler := New(DefaultHandlerConfig())
handler.WindowSize = tea.WindowSizeMsg{
Width: 100,
Height: 80,
}
models := handler.Handle(event)
require.Len(t, models, 1)
model := models[0]
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
t.Log(got)
snaps.MatchSnapshot(t, got)
})
}
}
@@ -0,0 +1,87 @@
package ui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
"github.com/anchore/syft/internal/log"
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
"github.com/anchore/syft/syft/pkg/cataloger"
)
var _ progress.StagedProgressable = (*packageCatalogerProgressAdapter)(nil)
type packageCatalogerProgressAdapter struct {
monitor *cataloger.Monitor
monitors []progress.Monitorable
}
func newPackageCatalogerProgressAdapter(monitor *cataloger.Monitor) packageCatalogerProgressAdapter {
return packageCatalogerProgressAdapter{
monitor: monitor,
monitors: []progress.Monitorable{
monitor.FilesProcessed,
monitor.PackagesDiscovered,
},
}
}
func (p packageCatalogerProgressAdapter) Stage() string {
return fmt.Sprintf("%d packages", p.monitor.PackagesDiscovered.Current())
}
func (p packageCatalogerProgressAdapter) Current() int64 {
return p.monitor.PackagesDiscovered.Current()
}
func (p packageCatalogerProgressAdapter) Error() error {
completedMonitors := 0
for _, monitor := range p.monitors {
err := monitor.Error()
if err == nil {
continue
}
if progress.IsErrCompleted(err) {
completedMonitors++
continue
}
// something went wrong
return err
}
if completedMonitors == len(p.monitors) && len(p.monitors) > 0 {
return p.monitors[0].Error()
}
return nil
}
func (p packageCatalogerProgressAdapter) Size() int64 {
// this is an inherently unknown value (indeterminate total number of packages to discover)
return -1
}
func (m *Handler) handlePackageCatalogerStarted(e partybus.Event) []tea.Model {
monitor, err := syftEventParsers.ParsePackageCatalogerStarted(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Catalog packages",
Running: "Cataloging packages",
Success: "Cataloged packages",
},
taskprogress.WithStagedProgressable(
newPackageCatalogerProgressAdapter(monitor),
),
)
tsk.HideStageOnSuccess = false
return []tea.Model{tsk}
}
@@ -0,0 +1,133 @@
package ui
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/gkampitakis/go-snaps/snaps"
"github.com/stretchr/testify/require"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
syftEvent "github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/pkg/cataloger"
)
func TestHandler_handlePackageCatalogerStarted(t *testing.T) {
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
iterations int
}{
{
name: "cataloging in progress",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(50)
mon := cataloger.Monitor{
FilesProcessed: progress.NewManual(-1),
PackagesDiscovered: prog,
}
return partybus.Event{
Type: syftEvent.PackageCatalogerStarted,
Value: mon,
}
},
},
{
name: "cataloging only files complete",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(50)
files := progress.NewManual(-1)
files.SetCompleted()
mon := cataloger.Monitor{
FilesProcessed: files,
PackagesDiscovered: prog,
}
return partybus.Event{
Type: syftEvent.PackageCatalogerStarted,
Value: mon,
}
},
},
{
name: "cataloging only packages complete",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(100)
prog.SetCompleted()
files := progress.NewManual(-1)
mon := cataloger.Monitor{
FilesProcessed: files,
PackagesDiscovered: prog,
}
return partybus.Event{
Type: syftEvent.PackageCatalogerStarted,
Value: mon,
}
},
},
{
name: "cataloging complete",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(100)
prog.SetCompleted()
files := progress.NewManual(-1)
files.SetCompleted()
mon := cataloger.Monitor{
FilesProcessed: files,
PackagesDiscovered: prog,
}
return partybus.Event{
Type: syftEvent.PackageCatalogerStarted,
Value: mon,
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := tt.eventFn(t)
handler := New(DefaultHandlerConfig())
handler.WindowSize = tea.WindowSizeMsg{
Width: 100,
Height: 80,
}
models := handler.Handle(event)
require.Len(t, models, 1)
model := models[0]
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
t.Log(got)
snaps.MatchSnapshot(t, got)
})
}
}
+2 -5
View File
@@ -98,21 +98,18 @@ func TestHandler_handleReadImage(t *testing.T) {
Height: 80,
}
models, _ := handler.Handle(event)
models := handler.Handle(event)
require.Len(t, models, 1)
model := models[0]
tsk, ok := model.(taskprogress.Model)
require.True(t, ok)
gotModel := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
got := gotModel.View()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
+13 -19
View File
@@ -29,8 +29,6 @@ type Handler struct {
Config HandlerConfig
bubbly.EventHandler
onNewCatalogerTask *sync.Once
}
func DefaultHandlerConfig() HandlerConfig {
@@ -43,32 +41,28 @@ func New(cfg HandlerConfig) *Handler {
d := bubbly.NewEventDispatcher()
h := &Handler{
EventHandler: d,
Running: &sync.WaitGroup{},
Config: cfg,
onNewCatalogerTask: &sync.Once{},
EventHandler: d,
Running: &sync.WaitGroup{},
Config: cfg,
}
// register all supported event types with the respective handler functions
d.AddHandlers(map[partybus.EventType]bubbly.EventHandlerFn{
stereoscopeEvent.PullDockerImage: simpleHandler(h.handlePullDockerImage),
stereoscopeEvent.PullContainerdImage: simpleHandler(h.handlePullContainerdImage),
stereoscopeEvent.ReadImage: simpleHandler(h.handleReadImage),
stereoscopeEvent.FetchImage: simpleHandler(h.handleFetchImage),
syftEvent.FileIndexingStarted: simpleHandler(h.handleFileIndexingStarted),
syftEvent.AttestationStarted: simpleHandler(h.handleAttestationStarted),
syftEvent.CatalogerTaskStarted: h.handleCatalogerTaskStarted,
stereoscopeEvent.PullDockerImage: h.handlePullDockerImage,
stereoscopeEvent.PullContainerdImage: h.handlePullContainerdImage,
stereoscopeEvent.ReadImage: h.handleReadImage,
stereoscopeEvent.FetchImage: h.handleFetchImage,
syftEvent.PackageCatalogerStarted: h.handlePackageCatalogerStarted,
syftEvent.FileDigestsCatalogerStarted: h.handleFileDigestsCatalogerStarted,
syftEvent.FileMetadataCatalogerStarted: h.handleFileMetadataCatalogerStarted,
syftEvent.FileIndexingStarted: h.handleFileIndexingStarted,
syftEvent.AttestationStarted: h.handleAttestationStarted,
syftEvent.CatalogerTaskStarted: h.handleCatalogerTaskStarted,
})
return h
}
func simpleHandler(fn func(partybus.Event) []tea.Model) bubbly.EventHandlerFn {
return func(e partybus.Event) ([]tea.Model, tea.Cmd) {
return fn(e), nil
}
}
func (m *Handler) OnMessage(msg tea.Msg) {
if msg, ok := msg.(tea.WindowSizeMsg); ok {
m.WindowSize = msg
+27 -13
View File
@@ -4,11 +4,12 @@ import (
"reflect"
"sync"
"testing"
"unsafe"
tea "github.com/charmbracelet/bubbletea"
)
func runModel(t testing.TB, m tea.Model, iterations int, message tea.Msg, h ...*sync.WaitGroup) tea.Model {
func runModel(t testing.TB, m tea.Model, iterations int, message tea.Msg, h ...*sync.WaitGroup) string {
t.Helper()
if iterations == 0 {
iterations = 1
@@ -36,21 +37,34 @@ func runModel(t testing.TB, m tea.Model, iterations int, message tea.Msg, h ...*
cmd = tea.Batch(nextCmds...)
}
return m
return m.View()
}
func flatten(ps ...tea.Msg) (msgs []tea.Msg) {
for _, p := range ps {
if bm, ok := p.(tea.BatchMsg); ok {
for _, m := range bm {
if m == nil {
continue
}
msgs = append(msgs, flatten(m())...)
}
} else {
msgs = []tea.Msg{p}
func flatten(p tea.Msg) (msgs []tea.Msg) {
if reflect.TypeOf(p).Name() == "batchMsg" {
partials := extractBatchMessages(p)
for _, m := range partials {
msgs = append(msgs, flatten(m)...)
}
} else {
msgs = []tea.Msg{p}
}
return msgs
}
func extractBatchMessages(m tea.Msg) (ret []tea.Msg) {
sliceMsgType := reflect.SliceOf(reflect.TypeOf(tea.Cmd(nil)))
value := reflect.ValueOf(m) // note: this is technically unaddressable
// make our own instance that is addressable
valueCopy := reflect.New(value.Type()).Elem()
valueCopy.Set(value)
cmds := reflect.NewAt(sliceMsgType, unsafe.Pointer(valueCopy.UnsafeAddr())).Elem()
for i := 0; i < cmds.Len(); i++ {
item := cmds.Index(i)
r := item.Call(nil)
ret = append(ret, r[0].Interface().(tea.Msg))
}
return ret
}
-55
View File
@@ -1,55 +0,0 @@
package internal
import (
"io"
"os"
"github.com/anchore/clio"
"github.com/anchore/stereoscope"
ui2 "github.com/anchore/syft/cmd/syft/cli/ui"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/redact"
)
func AppClioSetupConfig(id clio.Identification, out io.Writer) *clio.SetupConfig {
clioCfg := clio.NewSetupConfig(id).
WithGlobalConfigFlag(). // add persistent -c <path> for reading an application config from
WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config
WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text
WithUIConstructor(
// select a UI based on the logging configuration and state of stdin (if stdin is a tty)
func(cfg clio.Config) ([]clio.UI, error) {
noUI := ui.None(out, cfg.Log.Quiet)
if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet {
return []clio.UI{noUI}, nil
}
return []clio.UI{
ui.New(out, cfg.Log.Quiet,
ui2.New(ui2.DefaultHandlerConfig()),
),
noUI,
}, nil
},
).
WithInitializers(
func(state *clio.State) error {
// clio is setting up and providing the bus, redact store, and logger to the application. Once loaded,
// we can hoist them into the internal packages for global use.
stereoscope.SetBus(state.Bus)
bus.Set(state.Bus)
redact.Set(state.RedactStore)
log.Set(state.Logger)
stereoscope.SetLogger(state.Logger)
return nil
},
).
WithPostRuns(func(_ *clio.State, _ error) {
stereoscope.Cleanup()
})
return clioCfg
}
-20
View File
@@ -1,20 +0,0 @@
package commands
import (
"github.com/spf13/cobra"
"github.com/anchore/clio"
)
func Cataloger(app clio.Application) *cobra.Command {
cmd := &cobra.Command{
Use: "cataloger",
Short: "Show available catalogers and configuration",
}
cmd.AddCommand(
CatalogerList(app),
)
return cmd
}
@@ -1,267 +0,0 @@
package commands
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/scylladb/go-set/strset"
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/task"
"github.com/anchore/syft/syft/cataloging/pkgcataloging"
)
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"`
}
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"},
}
}
func CatalogerList(app clio.Application) *cobra.Command {
opts := defaultCatalogerListOptions()
return app.SetupCommand(&cobra.Command{
Use: "list [OPTIONS]",
Short: "List available catalogers",
RunE: func(_ *cobra.Command, _ []string) error {
return runCatalogerList(opts)
},
}, opts)
}
func runCatalogerList(opts *catalogerListOptions) error {
factories := task.DefaultPackageTaskFactories()
allTasks, err := factories.Tasks(task.DefaultCatalogingFactoryConfig())
if err != nil {
return fmt.Errorf("unable to create cataloger tasks: %w", err)
}
report, err := catalogerListReport(opts, allTasks)
if err != nil {
return fmt.Errorf("unable to generate cataloger list report: %w", err)
}
bus.Report(report)
return nil
}
func catalogerListReport(opts *catalogerListOptions, allTasks []task.Task) (string, error) {
selectedTasks, selectionEvidence, err := task.Select(allTasks,
pkgcataloging.NewSelectionRequest().
WithDefaults(opts.DefaultCatalogers...).
WithExpression(opts.SelectCatalogers...),
)
if err != nil {
return "", fmt.Errorf("unable to select catalogers: %w", err)
}
var report string
switch opts.Output {
case "json":
report, err = renderCatalogerListJSON(selectedTasks, selectionEvidence, opts.DefaultCatalogers, opts.SelectCatalogers)
case "table", "":
if opts.ShowHidden {
report = renderCatalogerListTable(allTasks, selectionEvidence, opts.DefaultCatalogers, opts.SelectCatalogers)
} else {
report = renderCatalogerListTable(selectedTasks, selectionEvidence, opts.DefaultCatalogers, opts.SelectCatalogers)
}
}
if err != nil {
return "", fmt.Errorf("unable to render cataloger list: %w", err)
}
return report, nil
}
func renderCatalogerListJSON(tasks []task.Task, selection task.Selection, defaultSelections, selections []string) (string, error) {
type node struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
names, tagsByName := extractTaskInfo(tasks)
nodesByName := make(map[string]node)
for name := range tagsByName {
tagsSelected := selection.TokensByTask[name].SelectedOn.List()
if len(tagsSelected) == 1 && tagsSelected[0] == "all" {
tagsSelected = tagsByName[name]
}
sort.Strings(tagsSelected)
if tagsSelected == nil {
// ensure collections are not null
tagsSelected = []string{}
}
nodesByName[name] = node{
Name: name,
Tags: tagsSelected,
}
}
type document struct {
DefaultSelection []string `json:"default"`
Selection []string `json:"selection"`
Catalogers []node `json:"catalogers"`
}
if selections == nil {
// ensure collections are not null
selections = []string{}
}
doc := document{
DefaultSelection: defaultSelections,
Selection: selections,
}
for _, name := range names {
doc.Catalogers = append(doc.Catalogers, nodesByName[name])
}
by, err := json.Marshal(doc)
return string(by), err
}
func renderCatalogerListTable(tasks []task.Task, selection task.Selection, defaultSelections, selections []string) string {
t := table.NewWriter()
t.SetStyle(table.StyleLight)
t.AppendHeader(table.Row{"Cataloger", "Tags"})
names, tagsByName := extractTaskInfo(tasks)
rowsByName := make(map[string]table.Row)
for name, tags := range tagsByName {
rowsByName[name] = formatRow(name, tags, selection)
}
for _, name := range names {
t.AppendRow(rowsByName[name])
}
report := t.Render()
if len(selections) > 0 {
header := "Selected by expressions:\n"
for _, expr := range selections {
header += fmt.Sprintf(" - %q\n", expr)
}
report = header + report
}
if len(defaultSelections) > 0 {
header := "Default selections:\n"
for _, expr := range defaultSelections {
header += fmt.Sprintf(" - %q\n", expr)
}
report = header + report
}
return report
}
func formatRow(name string, tags []string, selection task.Selection) table.Row {
isIncluded := selection.Result.Has(name)
var selections *task.TokenSelection
if s, exists := selection.TokensByTask[name]; exists {
selections = &s
}
var formattedTags []string
for _, tag := range tags {
formattedTags = append(formattedTags, formatToken(tag, selections, isIncluded))
}
var tagStr string
if isIncluded {
tagStr = strings.Join(formattedTags, ", ")
} else {
tagStr = strings.Join(formattedTags, grey.Render(", "))
}
// TODO: selection should keep warnings (non-selections) in struct
return table.Row{
formatToken(name, selections, isIncluded),
tagStr,
}
}
var (
green = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // hi green
grey = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark grey
red = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // high red
)
func formatToken(token string, selection *task.TokenSelection, included bool) string {
if included && selection != nil {
// format all tokens in selection in green
if selection.SelectedOn.Has(token) {
return green.Render(token)
}
return token
}
// format all tokens in selection in red, all others in grey
if selection != nil && selection.DeselectedOn.Has(token) {
return red.Render(token)
}
return grey.Render(token)
}
func extractTaskInfo(tasks []task.Task) ([]string, map[string][]string) {
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)
}
sort.Strings(names)
return names, tagsByName
}
@@ -1,201 +0,0 @@
package commands
import (
"context"
"strings"
"testing"
"github.com/scylladb/go-set/strset"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/internal/sbomsync"
"github.com/anchore/syft/internal/task"
"github.com/anchore/syft/syft/file"
)
var _ interface {
task.Task
task.Selector
} = (*dummyTask)(nil)
type dummyTask struct {
name string
selectors []string
}
func (d dummyTask) HasAllSelectors(s ...string) bool {
return strset.New(d.selectors...).Has(s...)
}
func (d dummyTask) Selectors() []string {
return d.selectors
}
func (d dummyTask) Name() string {
return d.name
}
func (d dummyTask) Execute(_ context.Context, _ file.Resolver, _ sbomsync.Builder) error {
panic("implement me")
}
func testTasks() []task.Task {
return []task.Task{
dummyTask{
name: "task1",
selectors: []string{"image", "a", "b", "1"},
},
dummyTask{
name: "task2",
selectors: []string{"image", "b", "c", "2"},
},
dummyTask{
name: "task3",
selectors: []string{"directory", "c", "d", "3"},
},
dummyTask{
name: "task4",
selectors: []string{"directory", "d", "e", "4"},
},
}
}
func Test_catalogerListReport(t *testing.T) {
tests := []struct {
name string
options *catalogerListOptions
want string
wantErr require.ErrorAssertionFunc
}{
{
name: "no expressions, table",
options: func() *catalogerListOptions {
c := defaultCatalogerListOptions()
c.Output = "table"
return c
}(),
want: `
Default selections:
- "all"
┌───────────┬────────────────────┐
│ CATALOGER │ TAGS │
├───────────┼────────────────────┤
│ task1 │ 1, a, b, image │
│ task2 │ 2, b, c, image │
│ task3 │ 3, c, d, directory │
│ task4 │ 4, d, directory, e │
└───────────┴────────────────────┘
`,
},
{
name: "no expressions, json",
options: func() *catalogerListOptions {
c := defaultCatalogerListOptions()
c.Output = "json"
return c
}(),
want: `
{"default":["all"],"selection":[],"catalogers":[{"name":"task1","tags":["1","a","b","image"]},{"name":"task2","tags":["2","b","c","image"]},{"name":"task3","tags":["3","c","d","directory"]},{"name":"task4","tags":["4","d","directory","e"]}]}
`,
},
{
name: "no expressions, default selection, table",
options: func() *catalogerListOptions {
c := defaultCatalogerListOptions()
c.Output = "table"
c.DefaultCatalogers = []string{
"image",
}
return c
}(),
want: `
Default selections:
- "image"
┌───────────┬────────────────┐
│ CATALOGER │ TAGS │
├───────────┼────────────────┤
│ task1 │ 1, a, b, image │
│ task2 │ 2, b, c, image │
└───────────┴────────────────┘
`,
},
{
name: "no expressions, default selection, json",
options: func() *catalogerListOptions {
c := defaultCatalogerListOptions()
c.Output = "json"
c.DefaultCatalogers = []string{
"image",
}
return c
}(),
want: `
{"default":["image"],"selection":[],"catalogers":[{"name":"task1","tags":["image"]},{"name":"task2","tags":["image"]}]}
`,
},
{
name: "with expressions, default selection, table",
options: func() *catalogerListOptions {
c := defaultCatalogerListOptions()
c.Output = "table"
c.DefaultCatalogers = []string{
"image",
}
c.SelectCatalogers = []string{
"-directory",
"+task3",
"-c",
"b",
}
return c
}(),
want: `
Default selections:
- "image"
Selected by expressions:
- "-directory"
- "+task3"
- "-c"
- "b"
┌───────────┬────────────────────┐
│ CATALOGER │ TAGS │
├───────────┼────────────────────┤
│ task1 │ 1, a, b, image │
│ task3 │ 3, c, d, directory │
└───────────┴────────────────────┘
`,
},
{
name: "with expressions, default selection, json",
options: func() *catalogerListOptions {
c := defaultCatalogerListOptions()
c.Output = "json"
c.DefaultCatalogers = []string{
"image",
}
c.SelectCatalogers = []string{
"-directory",
"+task3",
"-c",
"b",
}
return c
}(),
want: `
{"default":["image"],"selection":["-directory","+task3","-c","b"],"catalogers":[{"name":"task1","tags":["b","image"]},{"name":"task3","tags":["task3"]}]}
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr == nil {
tt.wantErr = require.NoError
}
got, err := catalogerListReport(tt.options, testTasks())
tt.wantErr(t, err)
assert.Equal(t, strings.TrimSpace(tt.want), strings.TrimSpace(got))
})
}
}
@@ -1,20 +0,0 @@
package commands
import (
"os"
"testing"
gologgerredact "github.com/anchore/go-logger/adapter/redact"
"github.com/anchore/syft/internal/redact"
)
func TestMain(m *testing.M) {
// Initialize global state needed to test clio/cobra commands directly
// Should be kept minimal.
// Initialize redact store once for all tests in the commands package
// Redact store must be wired up here because syft will panic unless
// a redact store is wired up exactly once
redact.Set(gologgerredact.NewStore())
os.Exit(m.Run())
}
-37
View File
@@ -1,37 +0,0 @@
package commands
import (
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
)
func Packages(app clio.Application, scanCmd *cobra.Command) *cobra.Command {
id := app.ID()
opts := defaultScanOptions()
cmd := app.SetupCommand(&cobra.Command{
Use: "packages [SOURCE]",
Short: scanCmd.Short,
Long: scanCmd.Long,
Args: scanCmd.Args,
Example: internal.Tprintf(scanHelp, map[string]interface{}{
"appName": id.Name,
"command": "packages",
}),
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(cmd *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return runScan(cmd.Context(), id, opts, args[0])
},
}, opts)
cmd.Deprecated = "use `syft scan` instead"
return cmd
}
-300
View File
@@ -1,300 +0,0 @@
package commands
import (
"errors"
"fmt"
"testing"
"github.com/hashicorp/go-multierror"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/internal/task"
)
func Test_filterExpressionErrors_expressionErrorsHelp(t *testing.T) {
tests := []struct {
name string
err error
wantExpErrs []task.ErrInvalidExpression
wantErr assert.ErrorAssertionFunc
wantHelp string
}{
{
name: "no errors",
err: nil,
wantExpErrs: nil,
wantErr: assert.NoError,
wantHelp: "",
},
{
name: "single non-expression error is retained",
err: errors.New("foo"),
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.Equal(t, "foo", err.Error())
},
wantHelp: "",
},
{
name: "multiple non-expression sibling errors are retained",
err: func() error {
var err error
err = multierror.Append(err, errors.New("foo"))
err = multierror.Append(err, errors.New("bar"))
return err
}(),
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
// note: this is the default formatting from the hashicorp multierror object
expected := `2 errors occurred:
* foo
* bar
`
return assert.Equal(t, expected, err.Error())
},
wantHelp: "",
},
{
name: "has multiple expression errors (with sibling errors)",
err: func() error {
var err error
err = multierror.Append(err, errors.New("foo"))
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
err = multierror.Append(err, errors.New("bar"))
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed})
err = multierror.Append(err, errors.New("last"))
return err
}(),
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
expected := `5 errors occurred:
* foo
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
* bar
* invalid expression: "bar": names are not allowed with this operation (must use tags)
* last
`
return assert.Equal(t, expected, err.Error())
},
wantExpErrs: []task.ErrInvalidExpression{
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed},
},
wantHelp: `Suggestions:
❖ Given expression "--select-catalogers foo"
However, tags are not allowed with this operation (must use exact names).
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
❖ Given expression "--select-catalogers bar"
However, names are not allowed with this operation (must use tags).
It seems like you are intending to add a cataloger in addition to the default set.
... Did you mean "--select-catalogers +bar" instead?
`,
},
{
name: "has multiple expression errors (with error chains and sibling errors)",
err: func() error {
var err error
err = multierror.Append(err, fmt.Errorf("foo: %w", fmt.Errorf("bar: %w", errors.New("last"))))
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed})
err = multierror.Append(err, errors.New("bottom"))
return fmt.Errorf("top: %w", fmt.Errorf("middle: %w", err))
}(),
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
expected := `top: middle: 4 errors occurred:
* foo: bar: last
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
* invalid expression: "bar": names are not allowed with this operation (must use tags)
* bottom
`
return assert.Equal(t, expected, err.Error())
},
wantExpErrs: []task.ErrInvalidExpression{
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed},
},
wantHelp: `Suggestions:
❖ Given expression "--select-catalogers foo"
However, tags are not allowed with this operation (must use exact names).
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
❖ Given expression "--select-catalogers bar"
However, names are not allowed with this operation (must use tags).
It seems like you are intending to add a cataloger in addition to the default set.
... Did you mean "--select-catalogers +bar" instead?
`,
},
{
name: "has multiple expression errors (with error chains and sibling errors)",
err: func() error {
var err error
err = multierror.Append(err, fmt.Errorf("foo: %w", fmt.Errorf("bar: %w", errors.New("last"))))
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed})
err = multierror.Append(err, errors.New("bottom"))
// note we wrap the top error in a chain
return fmt.Errorf("top: %w", fmt.Errorf("middle: %w", err))
}(),
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
expected := `top: middle: 4 errors occurred:
* foo: bar: last
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
* invalid expression: "bar": names are not allowed with this operation (must use tags)
* bottom
`
return assert.Equal(t, expected, err.Error())
},
wantExpErrs: []task.ErrInvalidExpression{
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
{Expression: "bar", Operation: task.SubSelectOperation, Err: task.ErrNamesNotAllowed},
},
wantHelp: `Suggestions:
❖ Given expression "--select-catalogers foo"
However, tags are not allowed with this operation (must use exact names).
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
❖ Given expression "--select-catalogers bar"
However, names are not allowed with this operation (must use tags).
It seems like you are intending to add a cataloger in addition to the default set.
... Did you mean "--select-catalogers +bar" instead?
`,
},
{
name: "preserve for any errors within ErrInvalidExpression types",
err: func() error {
var err error
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed})
err = multierror.Append(err, task.ErrInvalidExpression{Expression: "bar", Operation: task.SubSelectOperation, Err: errors.New("explanation")}) // this is what makes this test different...
return err
}(),
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
// note: the errors are removed and the help text shows the enriched error help
expected := `2 errors occurred:
* invalid expression: "foo": tags are not allowed with this operation (must use exact names)
* invalid expression: "bar": explanation
`
return assert.Equal(t, expected, err.Error())
},
wantExpErrs: []task.ErrInvalidExpression{
{Expression: "foo", Operation: task.AddOperation, Err: task.ErrTagsNotAllowed},
{Expression: "bar", Operation: task.SubSelectOperation, Err: errors.New("explanation")},
},
wantHelp: `Suggestions:
❖ Given expression "--select-catalogers foo"
However, tags are not allowed with this operation (must use exact names).
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
If you are certain this is what you want to do, use "--override-default-catalogers foo" instead.
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpErrs := filterExpressionErrors(tt.err)
tt.wantErr(t, tt.err) // ensure the error still remains
assert.Equal(t, tt.wantExpErrs, gotExpErrs)
gotHelp := expressionErrorsHelp(gotExpErrs)
assert.Equal(t, tt.wantHelp, gotHelp)
})
}
}
func Test_expressionSuggestions(t *testing.T) {
tests := []struct {
name string
expErr task.ErrInvalidExpression
want string
}{
{
name: "no embedded error",
expErr: task.ErrInvalidExpression{
Expression: "example",
},
want: ``,
},
{
name: "general error",
expErr: task.ErrInvalidExpression{
Err: errors.New("general error message"),
Expression: "example",
},
want: ``,
},
{
name: "ErrUnknownNameOrTag with add operation",
expErr: task.ErrInvalidExpression{
Err: task.ErrUnknownNameOrTag,
Operation: task.AddOperation,
Expression: "+example",
},
want: ``,
},
{
name: "ErrUnknownNameOrTag with subselect operation",
expErr: task.ErrInvalidExpression{
Err: task.ErrUnknownNameOrTag,
Operation: task.SubSelectOperation,
Expression: "example",
},
want: ``,
},
{
name: "ErrNamesNotAllowed with subselect operator",
expErr: task.ErrInvalidExpression{
Err: task.ErrNamesNotAllowed,
Operation: task.SubSelectOperation,
Expression: "example",
},
want: ` ❖ Given expression "--select-catalogers example"
However, names are not allowed with this operation (must use tags).
It seems like you are intending to add a cataloger in addition to the default set.
... Did you mean "--select-catalogers +example" instead?
`,
},
{
name: "ErrTagsNotAllowed with add operation",
expErr: task.ErrInvalidExpression{
Err: task.ErrTagsNotAllowed,
Operation: task.AddOperation,
Expression: "+example",
},
want: ` ❖ Given expression "--select-catalogers +example"
However, tags are not allowed with this operation (must use exact names).
Adding groups of catalogers may result in surprising behavior (create inaccurate SBOMs).
If you are certain this is what you want to do, use "--override-default-catalogers example" instead.
`,
},
{
name: "ErrAllNotAllowed with subselect operation",
expErr: task.ErrInvalidExpression{
Err: task.ErrAllNotAllowed,
Operation: task.SubSelectOperation,
Expression: "example",
},
want: ` ❖ Given expression "--select-catalogers example"
However, you cannot use the 'all' operand in this context.
It seems like you are intending to use all catalogers (which is not recommended).
... Did you mean "--override-default-catalogers example" instead?
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, expressionSuggetions(tt.expErr))
})
}
}
-449
View File
@@ -1,449 +0,0 @@
package commands
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/anchore/clio"
"github.com/anchore/go-collections"
"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/cmd/syft/internal/options"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/task"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
"github.com/anchore/syft/syft/source/sourceproviders"
)
const (
scanExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages
{{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details
{{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM
{{.appName}} {{.command}} alpine:latest -o cyclonedx-json show a CycloneDX JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.3 Tag-Value formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx@2.2 show a SPDX 2.2 Tag-Value formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.3 JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx-json@2.2 show a SPDX 2.2 JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -vv show verbose debug information
{{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl show a SBOM formatted according to given template file
Supports the following image sources:
{{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry.
{{.appName}} {{.command}} path/to/a/file/or/dir a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory
`
schemeHelpHeader = "You can also explicitly specify the scheme to use:"
imageSchemeHelp = ` {{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon
{{.appName}} {{.command}} podman:yourrepo/yourimage:tag explicitly use the Podman daemon
{{.appName}} {{.command}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required)
{{.appName}} {{.command}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save"
{{.appName}} {{.command}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise)
{{.appName}} {{.command}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
{{.appName}} {{.command}} singularity:path/to/yourimage.sif read directly from a Singularity Image Format (SIF) container on disk
`
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
scanHelp = scanExample + scanSchemeHelp
)
type scanOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"`
Cache options.Cache `json:"-" yaml:"cache" mapstructure:"cache"`
}
func defaultScanOptions() *scanOptions {
return &scanOptions{
Output: options.DefaultOutput(),
UpdateCheck: options.DefaultUpdateCheck(),
Catalog: options.DefaultCatalog(),
Cache: options.DefaultCache(),
}
}
//nolint:dupl
func Scan(app clio.Application) *cobra.Command {
id := app.ID()
opts := defaultScanOptions()
return app.SetupCommand(&cobra.Command{
Use: "scan [SOURCE]",
Short: "Generate an SBOM",
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems",
Example: internal.Tprintf(scanHelp, map[string]interface{}{
"appName": id.Name,
"command": "scan",
}),
Args: validateScanArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(cmd *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return runScan(cmd.Context(), id, opts, args[0])
},
}, opts)
}
func (o *scanOptions) PostLoad() error {
return o.validateLegacyOptionsNotUsed()
}
func (o *scanOptions) validateLegacyOptionsNotUsed() error {
if o.Config.ConfigFile == "" {
return nil
}
// check for legacy config file shapes that are no longer valid
type legacyConfig struct {
BasePath *string `yaml:"base-path" json:"base-path" mapstructure:"base-path"`
DefaultImagePullSource *string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"`
ExcludeBinaryOverlapByOwnership *bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"`
File any `yaml:"file" json:"file" mapstructure:"file"`
}
by, err := os.ReadFile(o.Config.ConfigFile)
if err != nil {
return fmt.Errorf("unable to read config file during validations %q: %w", o.Config.ConfigFile, err)
}
var legacy legacyConfig
if err := yaml.Unmarshal(by, &legacy); err != nil {
return fmt.Errorf("unable to parse config file during validations %q: %w", o.Config.ConfigFile, err)
}
if legacy.DefaultImagePullSource != nil {
return fmt.Errorf("the config file option 'default-image-pull-source' has been removed, please use 'source.image.default-pull-source' instead")
}
if legacy.ExcludeBinaryOverlapByOwnership != nil {
return fmt.Errorf("the config file option 'exclude-binary-overlap-by-ownership' has been removed, please use 'package.exclude-binary-overlap-by-ownership' instead")
}
if legacy.BasePath != nil {
return fmt.Errorf("the config file option 'base-path' has been removed, please use 'source.base-path' instead")
}
if legacy.File != nil && reflect.TypeOf(legacy.File).Kind() == reflect.String {
return fmt.Errorf("the config file option 'file' has been removed, please use 'outputs' instead")
}
return nil
}
func validateScanArgs(cmd *cobra.Command, args []string) error {
return validateArgs(cmd, args, "an image/directory argument is required")
}
func validateArgs(cmd *cobra.Command, args []string, error string) error {
if len(args) == 0 {
// in the case that no arguments are given we want to show the help text and return with a non-0 return code.
if err := cmd.Help(); err != nil {
return fmt.Errorf("unable to display help: %w", err)
}
return fmt.Errorf(error)
}
return cobra.MaximumNArgs(1)(cmd, args)
}
func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, userInput string) error {
writer, err := opts.SBOMWriter()
if err != nil {
return err
}
sources := opts.From
if len(sources) == 0 {
// extract a scheme if it matches any provider tag; this is a holdover for compatibility, using the --from flag is recommended
explicitSource, newUserInput := stereoscope.ExtractSchemeSource(userInput, allSourceProviderTags()...)
if explicitSource != "" {
sources = append(sources, explicitSource)
userInput = newUserInput
}
}
src, err := getSource(ctx, &opts.Catalog, userInput, sources...)
if err != nil {
return err
}
defer func() {
if src != nil {
if err := src.Close(); err != nil {
log.Tracef("unable to close source: %+v", err)
}
}
}()
s, err := generateSBOM(ctx, id, src, &opts.Catalog)
if err != nil {
return err
}
if s == nil {
return fmt.Errorf("no SBOM produced for %q", userInput)
}
if err := writer.Write(*s); err != nil {
return fmt.Errorf("failed to write SBOM: %w", err)
}
return nil
}
func getSource(ctx context.Context, opts *options.Catalog, userInput string, sources ...string) (source.Source, error) {
cfg := syft.DefaultGetSourceConfig().
WithRegistryOptions(opts.Registry.ToOptions()).
WithAlias(source.Alias{
Name: opts.Source.Name,
Version: opts.Source.Version,
}).
WithExcludeConfig(source.ExcludeConfig{
Paths: opts.Exclusions,
}).
WithBasePath(opts.Source.BasePath).
WithSources(sources...).
WithDefaultImagePullSource(opts.Source.Image.DefaultPullSource)
var err error
var platform *image.Platform
if opts.Platform != "" {
platform, err = image.NewPlatform(opts.Platform)
if err != nil {
return nil, fmt.Errorf("invalid platform: %w", err)
}
cfg = cfg.WithPlatform(platform)
}
if opts.Source.File.Digests != nil {
hashers, err := file.Hashers(opts.Source.File.Digests...)
if err != nil {
return nil, fmt.Errorf("invalid hash algorithm: %w", err)
}
cfg = cfg.WithDigestAlgorithms(hashers...)
}
src, err := syft.GetSource(ctx, userInput, cfg)
if err != nil {
return nil, fmt.Errorf("could not determine source: %w", err)
}
return src, nil
}
func generateSBOM(ctx context.Context, id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
s, err := syft.CreateSBOM(ctx, src, opts.ToSBOMConfig(id))
if err != nil {
expErrs := filterExpressionErrors(err)
notifyExpressionErrors(expErrs)
return nil, err
}
return s, nil
}
func filterExpressionErrors(err error) []task.ErrInvalidExpression {
if err == nil {
return nil
}
expErrs := processErrors(err)
return expErrs
}
// processErrors traverses error chains and multierror lists and returns all ErrInvalidExpression errors found
func processErrors(err error) []task.ErrInvalidExpression {
var result []task.ErrInvalidExpression
var processError func(...error)
processError = func(errs ...error) {
for _, e := range errs {
// note: using errors.As will result in surprising behavior (since that will traverse the error chain,
// potentially skipping over nodes in a list of errors)
if cerr, ok := e.(task.ErrInvalidExpression); ok {
result = append(result, cerr)
continue
}
var multiErr *multierror.Error
if errors.As(e, &multiErr) {
processError(multiErr.Errors...)
}
}
}
processError(err)
return result
}
func notifyExpressionErrors(expErrs []task.ErrInvalidExpression) {
helpText := expressionErrorsHelp(expErrs)
if helpText == "" {
return
}
bus.Notify(helpText)
}
func expressionErrorsHelp(expErrs []task.ErrInvalidExpression) string {
// enrich all errors found with CLI hints
if len(expErrs) == 0 {
return ""
}
sb := strings.Builder{}
sb.WriteString("Suggestions:\n\n")
found := false
for i, expErr := range expErrs {
help := expressionSuggetions(expErr)
if help == "" {
continue
}
found = true
sb.WriteString(help)
if i != len(expErrs)-1 {
sb.WriteString("\n")
}
}
if !found {
return ""
}
return sb.String()
}
const expressionHelpTemplate = " ❖ Given expression %q\n%s%s"
func expressionSuggetions(expErr task.ErrInvalidExpression) string {
if expErr.Err == nil {
return ""
}
hint := getHintPhrase(expErr)
if hint == "" {
return ""
}
return fmt.Sprintf(expressionHelpTemplate,
getExpression(expErr),
indentMsg(getExplanation(expErr)),
indentMsg(hint),
)
}
func indentMsg(msg string) string {
if msg == "" {
return ""
}
lines := strings.Split(msg, "\n")
for i, line := range lines {
lines[i] = " " + line
}
return strings.Join(lines, "\n") + "\n"
}
func getExpression(expErr task.ErrInvalidExpression) string {
flag := "--select-catalogers"
if expErr.Operation == task.SetOperation {
flag = "--override-default-catalogers"
}
return fmt.Sprintf("%s %s", flag, expErr.Expression)
}
func getExplanation(expErr task.ErrInvalidExpression) string {
err := expErr.Err
if errors.Is(err, task.ErrUnknownNameOrTag) {
noun := ""
switch expErr.Operation {
case task.AddOperation:
noun = "name"
case task.SubSelectOperation:
noun = "tag"
default:
noun = "name or tag"
}
return fmt.Sprintf("However, %q is not a recognized cataloger %s.", trimOperation(expErr.Expression), noun)
}
if errors.Is(err, task.ErrNamesNotAllowed) {
if expErr.Operation == task.SubSelectOperation {
return "However, " + err.Error() + ".\nIt seems like you are intending to add a cataloger in addition to the default set." // nolint:goconst
}
return "However, " + err.Error() + "." // nolint:goconst
}
if errors.Is(err, task.ErrTagsNotAllowed) {
return "However, " + err.Error() + ".\nAdding groups of catalogers may result in surprising behavior (create inaccurate SBOMs)." // nolint:goconst
}
if errors.Is(err, task.ErrAllNotAllowed) {
return "However, you " + err.Error() + ".\nIt seems like you are intending to use all catalogers (which is not recommended)."
}
if err != nil {
return "However, this is not valid: " + err.Error()
}
return ""
}
func getHintPhrase(expErr task.ErrInvalidExpression) string {
if errors.Is(expErr.Err, task.ErrUnknownNameOrTag) {
return ""
}
switch expErr.Operation {
case task.AddOperation:
if errors.Is(expErr.Err, task.ErrTagsNotAllowed) {
return fmt.Sprintf("If you are certain this is what you want to do, use %q instead.", "--override-default-catalogers "+trimOperation(expErr.Expression))
}
case task.SubSelectOperation:
didYouMean := "... Did you mean %q instead?"
if errors.Is(expErr.Err, task.ErrNamesNotAllowed) {
return fmt.Sprintf(didYouMean, "--select-catalogers +"+expErr.Expression)
}
if errors.Is(expErr.Err, task.ErrAllNotAllowed) {
return fmt.Sprintf(didYouMean, "--override-default-catalogers "+expErr.Expression)
}
}
return ""
}
func trimOperation(x string) string {
return strings.TrimLeft(x, "+-")
}
func allSourceProviderTags() []string {
return collections.TaggedValueSet[source.Provider]{}.Join(sourceproviders.All("", nil)...).Tags()
}
-66
View File
@@ -1,66 +0,0 @@
package commands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/cmd/syft/internal/options"
)
func Test_scanOptions_validateLegacyOptionsNotUsed(t *testing.T) {
tests := []struct {
name string
cfg string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no config file",
},
{
name: "config file with no legacy options",
cfg: "test-fixtures/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",
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",
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",
wantErr: assertErrorContains("outputs"),
},
{
name: "config file with file section",
cfg: "test-fixtures/scan-configs/with-file-section.yaml",
},
{
name: "config file with base-path legacy option",
cfg: "test-fixtures/scan-configs/with-base-path.yaml",
wantErr: assertErrorContains("source.base-path"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr == nil {
tt.wantErr = assert.NoError
}
o := &scanOptions{
Config: options.Config{ConfigFile: tt.cfg},
}
tt.wantErr(t, o.validateLegacyOptionsNotUsed())
})
}
}
func assertErrorContains(contains string) assert.ErrorAssertionFunc {
return func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.ErrorContains(t, err, contains, i...)
}
}
@@ -1,2 +0,0 @@
# simple, valid syft config with no legacy fields used
parallelism: 5
@@ -1 +0,0 @@
base-path: "something"
@@ -1 +0,0 @@
default-image-pull-source: "something"
@@ -1 +0,0 @@
exclude-binary-overlap-by-ownership: true
@@ -1,2 +0,0 @@
file:
selection: all
@@ -1 +0,0 @@
file: path

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