Compare commits

..
3 Commits
Author SHA1 Message Date
Alex Goodman a90cff9ae1 simplify yarn test expressions
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2022-03-07 09:08:58 -05:00
Patrick Glass 7b3cc85219 update yarn.lock parser to support yarn berry
Add support for Yarn v3 (berry) which changes the output
Collapse regex for parsing scoped and non-scoped packages
Add tests for the regex to ensure backwards compatability
and to catch issues with future changes.

Signed-off-by: Patrick Glass <patrickglass@gmail.com>
2022-03-06 21:50:18 -08:00
Patrick Glass 913bdda8df add test cases for yarn parser regex
Signed-off-by: Patrick Glass <patrickglass@gmail.com>
2022-03-06 20:41:54 -08:00
2238 changed files with 50705 additions and 719967 deletions
-133
View File
@@ -1,133 +0,0 @@
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
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.2
method: github-release
with:
repo: anchore/quill
# used for linting
- name: golangci-lint
version:
want: v1.60.3
method: github-release
with:
repo: golangci/golangci-lint
# used for showing the changelog at release
- name: glow
version:
want: v2.0.0
method: github-release
with:
repo: charmbracelet/glow
# used for signing the checksums file at release
- name: cosign
version:
want: v2.4.0
method: github-release
with:
repo: sigstore/cosign
# used in integration tests to verify JSON schemas
- name: yajsv
version:
want: v1.4.1
method: github-release
with:
repo: neilpa/yajsv
# used to release all artifacts
- name: goreleaser
version:
want: v2.2.0
method: github-release
with:
repo: goreleaser/goreleaser
# used for organizing imports during static analysis
- name: gosimports
version:
want: v0.3.8
method: github-release
with:
repo: rinchsan/gosimports
# used at release to generate the changelog
- name: chronicle
version:
want: v0.8.0
method: github-release
with:
repo: anchore/chronicle
# used during static analysis for license compliance
- name: bouncer
version:
want: v0.4.0
method: github-release
with:
repo: wagoodman/go-bouncer
# used for showing benchmark testing
- name: benchstat
version:
want: latest
method: go-proxy
with:
module: golang.org/x/perf
allow-unresolved-version: true
method: go-install
with:
entrypoint: cmd/benchstat
module: golang.org/x/perf
# used for running all local and CI tasks
- name: task
version:
want: v3.38.0
method: github-release
with:
repo: go-task/task
# used for triggering a release
- name: gh
version:
want: v2.55.0
method: github-release
with:
repo: cli/cli
# used to upload test fixture cache
- name: oras
version:
want: v1.2.0
method: github-release
with:
repo: oras-project/oras
# used to upload test fixture cache
- name: yq
version:
want: v4.44.3
method: github-release
with:
repo: mikefarah/yq
-40
View File
@@ -1,24 +1,13 @@
permit:
- BSD.*
- CC0.*
- MIT.*
- Apache.*
- MPL.*
- ISC
- WTFPL
- Unlicense
ignore-packages:
# packageurl-go is released under the MIT license located in the root of the repo at /mit.LICENSE
- github.com/anchore/packageurl-go
# both of these dependencies are specified as Apache-2.0 in their respective GitHub READMEs
- github.com/alibabacloud-go/cr-20160607/client
- github.com/alibabacloud-go/tea-xml/service
# crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Libary
- crypto/internal/boring
# from: https://github.com/spdx/tools-golang/blob/main/LICENSE.code
# The tools-golang source code is provided and may be used, at your option,
# under either:
@@ -31,32 +20,3 @@ ignore-packages:
# All these files have been put into the public domain.
# You can do whatever you want with these files.
- github.com/xi2/xz
# from: https://gitlab.com/cznic/sqlite/-/blob/v1.15.4/LICENSE
# This is a BSD-3-Clause license
- modernc.org/libc
- modernc.org/libc/errno
- modernc.org/libc/fcntl
- modernc.org/libc/fts
- modernc.org/libc/grp
- modernc.org/libc/langinfo
- modernc.org/libc/limits
- modernc.org/libc/netdb
- modernc.org/libc/netinet/in
- modernc.org/libc/poll
- modernc.org/libc/pthread
- modernc.org/libc/pwd
- modernc.org/libc/signal
- modernc.org/libc/stdio
- modernc.org/libc/stdlib
- modernc.org/libc/sys/socket
- modernc.org/libc/sys/stat
- modernc.org/libc/sys/types
- modernc.org/libc/termios
- modernc.org/libc/time
- modernc.org/libc/unistd
- modernc.org/libc/utime
- modernc.org/libc/uuid/uuid
- modernc.org/libc/wctype
- modernc.org/mathutil
- modernc.org/memory
-78
View File
@@ -1,78 +0,0 @@
enforce-v0: false
title: ""
github:
host: github.com
include-issue-pr-authors: true
include-issue-prs: true
include-issues-not-planned: false
include-prs: true
include-issues: true
include-unlabeled-issues: true
include-unlabeled-prs: true
issues-require-linked-prs: false
consider-pr-merge-commits: true
exclude-labels:
- duplicate
- question
- invalid
- wontfix
- wont-fix
- release-ignore
- changelog-ignore
- ignore
changes:
- name: security-fixes
title: Security Fixes
semver-field: patch
labels:
- security
- vulnerability
- name: added-feature
title: Added Features
semver-field: minor
labels:
- enhancement
- feature
- minor
- name: bug-fix
title: Bug Fixes
semver-field: patch
labels:
- bug
- fix
- bug-fix
- patch
- name: breaking-feature
title: Breaking Changes
semver-field: major
labels:
- breaking
- backwards-incompatible
- breaking-change
- breaking-feature
- major
- detected-breaking-change
- name: removed-feature
title: Removed Features
semver-field: major
labels:
- removed
- name: deprecated-feature
title: Deprecated Features
semver-field: minor
labels:
- deprecated
- name: unknown
title: Additional Changes
semver-field: ""
labels: []
+1 -1
View File
@@ -11,7 +11,7 @@ assignees: ''
**What you expected to happen**:
**Steps to reproduce the issue**:
**How to reproduce it (as minimally and precisely as possible)**:
**Anything else we need to know?**:
+3 -3
View File
@@ -1,6 +1,6 @@
contact_links:
- name: Join our Discourse community 💬
# link to our community Discourse site
url: https://anchore.com/discourse
- name: Join the Slack community 💬
# link to our community Slack registration page
url: https://anchore.com/slack
about: 'Come chat with us! Ask for help, join our software development efforts, or just give us feedback!'
-66
View File
@@ -1,66 +0,0 @@
name: "Bootstrap"
description: "Bootstrap all tools and dependencies"
inputs:
go-version:
description: "Go version to install"
required: true
default: "1.22.x"
go-dependencies:
description: "Download go dependencies"
required: true
default: "true"
cache-key-prefix:
description: "Prefix all cache keys with this value"
required: true
default: "181053ac82"
download-test-fixture-cache:
description: "Download test fixture cache from OCI and github actions"
required: true
default: "false"
bootstrap-apt-packages:
description: "Space delimited list of tools to install via apt"
default: "libxml2-utils"
runs:
using: "composite"
steps:
# note: go mod and build is automatically cached on default with v4+
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
if: inputs.go-version != ''
with:
go-version: ${{ inputs.go-version }}
- name: Restore tool cache
id: tool-cache
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2
with:
path: ${{ github.workspace }}/.tool
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }}
- name: Install project tools
shell: bash
run: make tools
- name: Install go dependencies
if: inputs.go-dependencies == 'true'
shell: bash
run: make ci-bootstrap-go
- name: Install apt packages
if: inputs.bootstrap-apt-packages != ''
shell: bash
run: |
DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y ${{ inputs.bootstrap-apt-packages }}
- name: Restore ORAS cache from github actions
if: inputs.download-test-fixture-cache == 'true'
uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2
with:
path: ${{ github.workspace }}/.tmp/oras-cache
key: ${{ inputs.cache-key-prefix }}-oras-cache
- name: Download test fixture cache
if: inputs.download-test-fixture-cache == 'true'
shell: bash
run: make download-test-fixture-cache
-27
View File
@@ -1,27 +0,0 @@
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"
+3
View File
@@ -0,0 +1,3 @@
dev-pki
log
signing-identity.txt
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -eu
# grab utilities
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
. "$SCRIPT_DIR"/utils.sh
# cleanup any dev certs left behind
. "$SCRIPT_DIR"/setup-import-cert.sh # defines KEYCHAIN_NAME and KEYCHAIN_PATH
. "$SCRIPT_DIR"/setup-dev.sh
cleanup_dev_signing
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set +xu
if [ -z "$AC_USERNAME" ]; then
exit_with_error "AC_USERNAME not set"
fi
if [ -z "$AC_PASSWORD" ]; then
exit_with_error "AC_PASSWORD not set"
fi
set -u
# notarize [archive-path]
#
notarize() {
binary_path=$1
archive_path=${binary_path}-archive-for-notarization.zip
title "archiving release binary into ${archive_path}"
parent=$(dirname "$binary_path")
(
cd "${parent}" && zip "${archive_path}" "$(basename ${binary_path})"
)
if [ ! -f "$archive_path" ]; then
exit_with_error "cannot find payload for notarization: $archive_path"
fi
# install gon
which gon || (brew tap mitchellh/gon && brew install mitchellh/gon/gon)
# create config (note: json via stdin with gon is broken, can only use HCL from file)
hcl_file=$(mktemp).hcl
cat <<EOF > "$hcl_file"
notarize {
path = "$archive_path"
bundle_id = "com.anchore.toolbox.syft"
}
apple_id {
username = "$AC_USERNAME"
password = "@env:AC_PASSWORD"
}
EOF
gon -log-level info "$hcl_file"
rm "${hcl_file}" "${archive_path}"
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -eu
NAME=syft-dev
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
DIR=$SCRIPT_DIR/dev-pki
FILE_PREFIX=$DIR/$NAME
IDENTITY=${NAME}-id-415d8c69793
## OpenSSL material
KEY_PASSWORD="letthedevin"
P12_PASSWORD="popeofnope"
KEY_FILE=$FILE_PREFIX-key.pem
CSR_FILE=$FILE_PREFIX-csr.pem
CERT_FILE=$FILE_PREFIX-cert.pem
EXT_FILE=$FILE_PREFIX-ext.cnf
P12_FILE=$FILE_PREFIX.p12
EXT_SECTION=codesign_reqext
# setup_signing
#
# preps the MAC_SIGNING_IDENTITY env var for use in the signing process, using ephemeral developer certificate material
#
function setup_signing() {
# check to see if this has already been done... if so, bail!
set +ue
if security find-identity -p codesigning "$KEYCHAIN_PATH" | grep $IDENTITY ; then
export MAC_SIGNING_IDENTITY=$IDENTITY
commentary "skipping creating dev certificate material (already exists)"
commentary "setting MAC_SIGNING_IDENTITY=${IDENTITY}"
return 0
fi
set -ue
title "setting up developer certificate material"
mkdir -p "${DIR}"
# configure the openssl extensions
cat << EOF > "$EXT_FILE"
[ req ]
default_bits = 2048 # RSA key size
encrypt_key = yes # Protect private key
default_md = sha256 # MD to use
utf8 = yes # Input is UTF-8
string_mask = utf8only # Emit UTF-8 strings
prompt = yes # Prompt for DN
distinguished_name = codesign_dn # DN template
req_extensions = $EXT_SECTION # Desired extensions
[ codesign_dn ]
commonName = $IDENTITY
commonName_max = 64
[ $EXT_SECTION ]
keyUsage = critical,digitalSignature
extendedKeyUsage = critical,codeSigning
subjectKeyIdentifier = hash
EOF
title "create the private key"
openssl genrsa \
-des3 \
-out "$KEY_FILE" \
-passout "pass:$KEY_PASSWORD" \
2048
title "create the csr"
openssl req \
-new \
-key "$KEY_FILE" \
-out "$CSR_FILE" \
-passin "pass:$KEY_PASSWORD" \
-config "$EXT_FILE" \
-subj "/CN=$IDENTITY"
commentary "verify the csr: we should see X509 v3 extensions for codesigning in the CSR"
openssl req -in "$CSR_FILE" -noout -text | grep -A1 "X509v3" || exit_with_error "could not find x509 extensions in CSR"
title "create the certificate"
# note: Extensions in certificates are not transferred to certificate requests and vice versa. This means that
# just because the CSR has x509 v3 extensions doesn't mean that you'll see these extensions in the cert output.
# To prove this do:
# openssl x509 -text -noout -in server.crt | grep -A10 "X509v3 extensions:"
# ... and you will see no output (if -extensions is not used). (see https://www.openssl.org/docs/man1.1.0/man1/x509.html#BUGS)
# To get the extensions, use "-extensions codesign_reqext" when creating the cert. The codesign_reqext value matches
# the section name in the ext file used in CSR / cert creation (-extfile and -config).
openssl x509 \
-req \
-days 10000 \
-in "$CSR_FILE" \
-signkey "$KEY_FILE" \
-out "$CERT_FILE" \
-extfile "$EXT_FILE" \
-passin "pass:$KEY_PASSWORD" \
-extensions $EXT_SECTION
commentary "verify the certificate: we should see our extensions"
openssl x509 -text -noout -in "$CERT_FILE" | grep -A1 'X509v3' || exit_with_error "could not find x509 extensions in certificate"
title "export cert and private key to .p12 file"
# note: this step may be entirely optional, however, I found it useful to follow the prod path which goes the route of using a p12
openssl pkcs12 \
-export \
-out "$P12_FILE" \
-inkey "$KEY_FILE" \
-in "$CERT_FILE" \
-passin "pass:$KEY_PASSWORD" \
-passout "pass:$P12_PASSWORD"
# delete the keychain if it already exists
if [ -f "${KEYCHAIN_PATH}" ]; then
cleanup_dev_signing
fi
import_signing_certificate "$P12_FILE" "$P12_PASSWORD" "$IDENTITY"
}
function cleanup_dev_signing() {
title "delete the dev keychain and all certificate material"
set -xue
security delete-keychain "$KEYCHAIN_NAME" || true
rm -f "$KEYCHAIN_PATH" || true
rm -rf "${DIR}" || true
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -eu
KEYCHAIN_NAME="syft-ephemeral-keychain"
KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db"
# import_signing_certificate
#
# imports a cert from a p12 file into a keychain used for codesigning
#
function import_signing_certificate() {
p12_file=$1
p12_password=$2
identity=$3
keychain_password="$(openssl rand -base64 100)"
title "create the a new keychain"
security create-keychain -p "$keychain_password" "$KEYCHAIN_NAME"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$keychain_password" "$KEYCHAIN_PATH"
if [ ! -f "$KEYCHAIN_PATH" ]; then
exit_with_error "cannot find keychain '$KEYCHAIN_PATH'"
fi
set +e
if ! security verify-cert -k "$KEYCHAIN_PATH" -c "$p12_file" &> /dev/null; then
set -e
title "import the cert into the new keychain if it is not already trusted by the system"
# '-t cert' is vital since it side-steps the need for user interaction with "security add-trusted-cert" (which has wider security implications)
security import "$p12_file" -P "$p12_password" -t cert -f pkcs12 -k "$KEYCHAIN_PATH" -T /usr/bin/codesign
# note: set the partition list for this certificate's private key to include "apple-tool:" and "apple:" allows the codesign command to access this keychain item without an interactive user prompt.
security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$KEYCHAIN_PATH"
else
set -e
commentary "...cert has already been imported onto the new keychain"
fi
commentary "make certain there are identities that can be used for code signing"
security find-identity -p codesigning "$KEYCHAIN_PATH" | grep -C 30 "$identity" || exit_with_error "could not find identity that can be used with codesign"
title "add the new keychain to the search path for codesign"
add_keychain "$KEYCHAIN_NAME"
commentary "verify the new keychain can be found by the security sub-system"
security list-keychains | grep "$KEYCHAIN_NAME" || exit_with_error "could not find new keychain"
export MAC_SIGNING_IDENTITY=$identity
commentary "setting MAC_SIGNING_IDENTITY=${identity}"
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -eu
assert_in_ci
IDENTITY="Developer ID Application: ANCHORE, INC. (9MJHKYX5AT)"
set +xu
if [ -z "$APPLE_DEVELOPER_ID_CERT" ]; then
exit_with_error "APPLE_DEVELOPER_ID_CERT not set"
fi
if [ -z "$APPLE_DEVELOPER_ID_CERT_PASS" ]; then
exit_with_error "APPLE_DEVELOPER_ID_CERT_PASS not set"
fi
if [ -z "$DOCKER_USERNAME" ]; then
exit_with_error "DOCKER_USERNAME not set"
fi
if [ -z "$DOCKER_PASSWORD" ]; then
exit_with_error "DOCKER_PASSWORD not set"
fi
set -u
# setup_signing
#
# preps the MAC_SIGNING_IDENTITY env var for use in the signing process, using production certificate material
#
setup_signing() {
title "setting up production certificate material"
# Write signing certificate to disk from environment variable.
p12_file="$HOME/developer_id_certificate.p12"
echo -n "$APPLE_DEVELOPER_ID_CERT" | base64 --decode > "$p12_file"
import_signing_certificate "$p12_file" "$APPLE_DEVELOPER_ID_CERT_PASS" "$IDENTITY"
# Make this new keychain the user's default keychain, so that codesign will be able to find this certificate when we specify it during signing.
security default-keychain -d "user" -s "${KEYCHAIN_PATH}"
commentary "log into docker -- required for publishing (since the default keychain has now been replaced)"
echo "${DOCKER_PASSWORD}" | docker login docker.io -u "${DOCKER_USERNAME}" --password-stdin
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -eu -o pipefail
IS_SNAPSHOT="$1"
## grab utilities
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
. "$SCRIPT_DIR"/utils.sh
mkdir -p "$SCRIPT_DIR/log"
main() {
# defines KEYCHAIN_NAME and KEYCHAIN_PATH
. "$SCRIPT_DIR"/setup-import-cert.sh
case "$IS_SNAPSHOT" in
"1" | "true" | "yes")
commentary "assuming development setup..."
. "$SCRIPT_DIR"/setup-dev.sh
;;
"0" | "false" | "no")
commentary "assuming production setup..."
. "$SCRIPT_DIR"/setup-prod.sh
;;
*)
exit_with_error "could not determine if this was a production build (isSnapshot='$IS_SNAPSHOT')"
;;
esac
# load up all signing material into a keychain (note: this should set the MAC_SIGNING_IDENTITY env var)
setup_signing
# write out identity to a file
echo -n "$MAC_SIGNING_IDENTITY" > "$SCRIPT_DIR/$SIGNING_IDENTITY_FILENAME"
}
# capture all output from a subshell to log output additionally to a file (as well as the terminal)
( (
set +u
if [ -n "$SKIP_SIGNING" ]; then
commentary "skipping signing setup..."
else
set -u
main
fi
) 2>&1) | tee "$SCRIPT_DIR/log/setup.txt"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -eu -o pipefail
BINARY_PATH="$1"
IS_SNAPSHOT="$2"
TARGET_NAME="$3"
## grab utilities
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
. "$SCRIPT_DIR"/utils.sh
mkdir -p "$SCRIPT_DIR/log"
# sign_binary [binary-path] [signing-identity]
#
# signs a single binary with cosign
#
sign_binary() {
exe_path=$1
identity=$2
if [ -x "$exe_path" ] && file -b "$exe_path" | grep -q "Mach-O"
then
echo "signing $exe_path ..."
else
echo "skip signing $exe_path ..."
return 0
fi
set -x
codesign \
-s "$identity" \
-f \
--verbose=4 \
--timestamp \
--options runtime \
"$exe_path"
if [ $? -ne 0 ]; then
exit_with_error "signing failed"
fi
codesign --verify "$exe_path" --verbose=4
set +x
}
main() {
binary_abs_path=$(realpath "$BINARY_PATH")
if [ ! -f "$binary_abs_path" ]; then
echo "archive does not exist: $binary_abs_path"
fi
case "$IS_SNAPSHOT" in
"1" | "true" | "yes")
commentary "disabling notarization..."
perform_notarization=false
;;
"0" | "false" | "no")
commentary "enabling notarization..."
. "$SCRIPT_DIR"/notarize.sh
perform_notarization=true
;;
*)
exit_with_error "could not determine if this was a production build (isSnapshot='$IS_SNAPSHOT')"
;;
esac
# grab the signing identity from the local temp file (setup by setup.sh)
MAC_SIGNING_IDENTITY=$(cat "$SCRIPT_DIR/$SIGNING_IDENTITY_FILENAME")
# sign all of the binaries in the archive and recreate the input archive with the signed binaries
sign_binary "$binary_abs_path" "$MAC_SIGNING_IDENTITY"
# send all of the binaries off to apple to bless
if $perform_notarization ; then
notarize "$binary_abs_path"
else
commentary "skipping notarization..."
fi
}
# capture all output from a subshell to log output additionally to a file (as well as the terminal)
( (
set +u
if [ -n "$SKIP_SIGNING" ]; then
commentary "skipping signing..."
else
set -u
main
fi
) 2>&1) | tee "$SCRIPT_DIR/log/signing-$(basename $BINARY_PATH)-$TARGET_NAME.txt"
+78
View File
@@ -0,0 +1,78 @@
SIGNING_IDENTITY_FILENAME=signing-identity.txt
## terminal goodies
PURPLE='\033[0;35m'
GREEN='\033[0;32m'
RED='\033[0;31m'
BOLD=$(tput -T linux bold)
RESET='\033[0m'
function success() {
echo -e "\n${GREEN}${BOLD}$@${RESET}"
}
function title() {
success "Task: $@"
}
function commentary() {
echo -e "\n${PURPLE}# $@${RESET}"
}
function error() {
echo -e "${RED}${BOLD}error: $@${RESET}"
}
function exit_with_error() {
error $@
exit 1
}
function exit_with_message() {
success $@
exit 0
}
function realpath {
echo "$(cd $(dirname $1); pwd)/$(basename $1)";
}
# this function adds all of the existing keychains plus the new one which is the same as going to Keychain Access
# and selecting "Add Keychain" to make the keychain visible under "Custom Keychains". This is done with
# "security list-keychains -s" for some reason. The downside is that this sets the search path, not appends
# to it, so you will loose existing keychains in the search path... which is truly terrible.
function add_keychain() {
keychains=$(security list-keychains -d user)
keychainNames=();
for keychain in $keychains
do
basename=$(basename "$keychain")
keychainName=${basename::${#basename}-4}
keychainNames+=("$keychainName")
done
echo "existing user keychains: ${keychainNames[@]}"
security -v list-keychains -s "${keychainNames[@]}" "$1"
}
function exit_not_ci() {
printf "WARNING! It looks like this isn't the CI environment. This script modifies the macOS Keychain setup in ways you probably wouldn't want for your own machine. It also requires an Apple Developer ID Certificate that you shouldn't have outside of the CI environment.\n\nExiting early to make sure nothing bad happens.\n"
exit 1
}
CI_HOME="/Users/runner"
function assert_in_ci() {
if [[ "${HOME}" != "${CI_HOME}" ]]; then
exit_not_ci
fi
set +u
if [ -z "${GITHUB_ACTIONS}" ]; then
exit_not_ci
fi
set -u
}
@@ -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
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
red=$(tput setaf 1)
bold=$(tput bold)
normal=$(tput sgr0)
# assert we are running in CI (or die!)
if [[ -z "$CI" ]]; then
echo "${bold}${red}This step should ONLY be run in CI. Exiting...${normal}"
exit 1
fi
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
import shlex
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
if len(sys.argv) < 3:
print("Usage: coverage.py [threshold] [go-coverage-report]")
sys.exit(1)
threshold = float(sys.argv[1])
report = sys.argv[2]
args = shlex.split(f"go tool cover -func {report}")
p = subprocess.run(args, capture_output=True, text=True)
percent_coverage = float(p.stdout.splitlines()[-1].split()[-1].replace("%", ""))
print(f"{bcolors.BOLD}Coverage: {percent_coverage}%{bcolors.ENDC}")
if percent_coverage < threshold:
print(f"{bcolors.BOLD}{bcolors.FAIL}Coverage below threshold of {threshold}%{bcolors.ENDC}")
sys.exit(1)
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import glob
import sys
import json
import hashlib
IGNORED_PREFIXES = []
def find_fingerprints_and_check_dirs(base_dir):
all_fingerprints = set(glob.glob(os.path.join(base_dir, '**', 'test*', '**', '*.fingerprint'), recursive=True))
all_fingerprints = {os.path.relpath(fp) for fp in all_fingerprints
if not any(fp.startswith(prefix) for prefix in IGNORED_PREFIXES)}
if not all_fingerprints:
show("No .fingerprint files or cache directories found.")
exit(1)
missing_content = []
valid_paths = set()
fingerprint_contents = []
for fingerprint in all_fingerprints:
path = fingerprint.replace('.fingerprint', '')
if not os.path.exists(path):
missing_content.append(path)
continue
if not os.path.isdir(path):
valid_paths.add(path)
continue
if os.listdir(path):
valid_paths.add(path)
else:
missing_content.append(path)
with open(fingerprint, 'r') as f:
content = f.read().strip()
fingerprint_contents.append((fingerprint, content))
return sorted(valid_paths), missing_content, fingerprint_contents
def parse_fingerprint_contents(fingerprint_content):
input_map = {}
for line in fingerprint_content.splitlines():
digest, path = line.split()
input_map[path] = digest
return input_map
def calculate_sha256(fingerprint_contents):
sorted_fingerprint_contents = sorted(fingerprint_contents, key=lambda x: x[0])
concatenated_contents = ''.join(content for _, content in sorted_fingerprint_contents)
sha256_hash = hashlib.sha256(concatenated_contents.encode()).hexdigest()
return sha256_hash
def calculate_file_sha256(file_path):
sha256_hash = hashlib.sha256()
with open(file_path, 'rb') as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def show(*s: str):
print(*s, file=sys.stderr)
def main(file_path: str | None):
base_dir = '.'
valid_paths, missing_content, fingerprint_contents = find_fingerprints_and_check_dirs(base_dir)
if missing_content:
show("The following paths are missing or have no content, but have corresponding .fingerprint files:")
for path in sorted(missing_content):
show(f"- {path}")
show("Please ensure these paths exist and have content if they are directories.")
exit(1)
sha256_hash = calculate_sha256(fingerprint_contents)
paths_with_digests = []
for path in sorted(valid_paths):
fingerprint_file = f"{path}.fingerprint"
try:
if os.path.exists(fingerprint_file):
file_digest = calculate_file_sha256(fingerprint_file)
# Parse the fingerprint file to get the digest/path tuples
with open(fingerprint_file, 'r') as f:
fingerprint_content = f.read().strip()
input_map = parse_fingerprint_contents(fingerprint_content)
paths_with_digests.append({
"path": path,
"digest": file_digest,
"input": input_map
})
except Exception as e:
show(f"Error processing {fingerprint_file}: {e}")
raise e
output = {
"digest": sha256_hash,
"paths": paths_with_digests
}
content = json.dumps(output, indent=2, sort_keys=True)
if file_path:
with open(file_path, 'w') as f:
f.write(content)
print(content)
if __name__ == "__main__":
file_path = None
if len(sys.argv) > 1:
file_path = sys.argv[1]
main(file_path)
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
import os
import subprocess
import hashlib
BOLD = '\033[1m'
YELLOW = '\033[0;33m'
RESET = '\033[0m'
def print_message(message):
print(f"{YELLOW}{message}{RESET}")
def sha256sum(filepath):
h = hashlib.sha256()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b""):
h.update(chunk)
return h.hexdigest()
def is_git_tracked_or_untracked(directory):
"""Returns a sorted list of files in the directory that are tracked or not ignored by Git."""
result = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
cwd=directory,
stdout=subprocess.PIPE,
text=True
)
return sorted(result.stdout.strip().splitlines())
def find_test_fixture_dirs_with_images(base_dir):
"""Find directories that contain 'test-fixtures' and at least one 'image-*' directory."""
for root, dirs, files in os.walk(base_dir):
if 'test-fixtures' in root:
image_dirs = [d for d in dirs if d.startswith('image-')]
if image_dirs:
yield os.path.realpath(root)
def generate_fingerprints():
print_message("creating fingerprint files for docker fixtures...")
for test_fixture_dir in find_test_fixture_dirs_with_images('.'):
cache_fingerprint_path = os.path.join(test_fixture_dir, 'cache.fingerprint')
with open(cache_fingerprint_path, 'w') as fingerprint_file:
for image_dir in find_image_dirs(test_fixture_dir):
for file in is_git_tracked_or_untracked(image_dir):
file_path = os.path.join(image_dir, file)
checksum = sha256sum(file_path)
path_from_fixture_dir = os.path.relpath(file_path, test_fixture_dir)
fingerprint_file.write(f"{checksum} {path_from_fixture_dir}\n")
def find_image_dirs(test_fixture_dir):
"""Find all 'image-*' directories inside a given test-fixture directory."""
result = []
for root, dirs, files in os.walk(test_fixture_dir):
for dir_name in dirs:
if dir_name.startswith('image-'):
result.append(os.path.join(root, dir_name))
return sorted(result)
if __name__ == "__main__":
generate_fingerprints()
+7 -6
View File
@@ -4,18 +4,19 @@ set -eu
ORIGINAL_STATE_DIR=$(mktemp -d "TEMP-original-state-XXXXXXXXX")
TIDY_STATE_DIR=$(mktemp -d "TEMP-tidy-state-XXXXXXXXX")
trap "cp -p ${ORIGINAL_STATE_DIR}/* ./ && git update-index -q --refresh && rm -fR ${ORIGINAL_STATE_DIR} ${TIDY_STATE_DIR}" EXIT
trap "cp -v ${ORIGINAL_STATE_DIR}/* ./ && rm -fR ${ORIGINAL_STATE_DIR} ${TIDY_STATE_DIR}" EXIT
# capturing original state of files...
cp go.mod go.sum "${ORIGINAL_STATE_DIR}"
echo "Capturing original state of files..."
cp -v go.mod go.sum "${ORIGINAL_STATE_DIR}"
# capturing state of go.mod and go.sum after running go mod tidy...
echo "Capturing state of go.mod and go.sum after running go mod tidy..."
go mod tidy
cp go.mod go.sum "${TIDY_STATE_DIR}"
cp -v go.mod go.sum "${TIDY_STATE_DIR}"
echo ""
set +e
# detect difference between the git HEAD state and the go mod tidy state
# Detect difference between the git HEAD state and the go mod tidy state
DIFF_MOD=$(diff -u "${ORIGINAL_STATE_DIR}/go.mod" "${TIDY_STATE_DIR}/go.mod")
DIFF_SUM=$(diff -u "${ORIGINAL_STATE_DIR}/go.sum" "${TIDY_STATE_DIR}/go.sum")
+398
View File
@@ -0,0 +1,398 @@
#!/bin/sh
set -e
# Code generated by godownloader on 2019-12-25T12:47:14Z. DO NOT EDIT.
#
usage() {
this=$1
cat <<EOF
$this: download go binaries for goreleaser/goreleaser
Usage: $this [-b] bindir [-d] [tag]
-b sets bindir or installation directory, Defaults to ./bin
-d turns on debug logging
[tag] is a tag from
https://github.com/goreleaser/goreleaser/releases
If tag is missing, then the latest will be used.
Generated by godownloader
https://github.com/goreleaser/godownloader
EOF
exit 2
}
parse_args() {
#BINDIR is ./bin unless set be ENV
# over-ridden by flag below
BINDIR=${BINDIR:-./bin}
while getopts "b:dh?x" arg; do
case "$arg" in
b) BINDIR="$OPTARG" ;;
d) log_set_priority 10 ;;
h | \?) usage "$0" ;;
x) set -x ;;
esac
done
shift $((OPTIND - 1))
TAG=$1
}
# this function wraps all the destructive operations
# if a curl|bash cuts off the end of the script due to
# network, either nothing will happen or will syntax error
# out preventing half-done work
execute() {
tmpdir=$(mktemp -d)
log_debug "downloading files into ${tmpdir}"
http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}"
http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}"
hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}"
srcdir="${tmpdir}"
(cd "${tmpdir}" && untar "${TARBALL}")
test ! -d "${BINDIR}" && install -d "${BINDIR}"
for binexe in $BINARIES; do
if [ "$OS" = "windows" ]; then
binexe="${binexe}.exe"
fi
install "${srcdir}/${binexe}" "${BINDIR}/"
log_info "installed ${BINDIR}/${binexe}"
done
rm -rf "${tmpdir}"
}
get_binaries() {
case "$PLATFORM" in
darwin/386) BINARIES="goreleaser" ;;
darwin/amd64) BINARIES="goreleaser" ;;
darwin/arm64) BINARIES="goreleaser" ;;
darwin/armv6) BINARIES="goreleaser" ;;
linux/386) BINARIES="goreleaser" ;;
linux/amd64) BINARIES="goreleaser" ;;
linux/arm64) BINARIES="goreleaser" ;;
linux/armv6) BINARIES="goreleaser" ;;
windows/386) BINARIES="goreleaser" ;;
windows/amd64) BINARIES="goreleaser" ;;
windows/arm64) BINARIES="goreleaser" ;;
windows/armv6) BINARIES="goreleaser" ;;
*)
log_crit "platform $PLATFORM is not supported. Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new"
exit 1
;;
esac
}
tag_to_version() {
if [ -z "${TAG}" ]; then
log_info "checking GitHub for latest tag"
else
log_info "checking GitHub for tag '${TAG}'"
fi
REALTAG=$(github_release "$OWNER/$REPO" "${TAG}") && true
if test -z "$REALTAG"; then
log_crit "unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details"
exit 1
fi
# if version starts with 'v', remove it
TAG="$REALTAG"
VERSION=${TAG#v}
}
adjust_format() {
# change format (tar.gz or zip) based on OS
case ${OS} in
windows) FORMAT=zip ;;
esac
true
}
adjust_os() {
# adjust archive name based on OS
case ${OS} in
386) OS=i386 ;;
amd64) OS=x86_64 ;;
darwin) OS=Darwin ;;
linux) OS=Linux ;;
windows) OS=Windows ;;
esac
true
}
adjust_arch() {
# adjust archive name based on ARCH
case ${ARCH} in
386) ARCH=i386 ;;
amd64) ARCH=x86_64 ;;
darwin) ARCH=Darwin ;;
linux) ARCH=Linux ;;
windows) ARCH=Windows ;;
esac
true
}
cat /dev/null <<EOF
------------------------------------------------------------------------
https://github.com/client9/shlib - portable posix shell functions
Public domain - http://unlicense.org
https://github.com/client9/shlib/blob/master/LICENSE.md
but credit (and pull requests) appreciated.
------------------------------------------------------------------------
EOF
is_command() {
command -v "$1" >/dev/null
}
echoerr() {
echo "$@" 1>&2
}
log_prefix() {
echo "$0"
}
_logp=6
log_set_priority() {
_logp="$1"
}
log_priority() {
if test -z "$1"; then
echo "$_logp"
return
fi
[ "$1" -le "$_logp" ]
}
log_tag() {
case $1 in
0) echo "emerg" ;;
1) echo "alert" ;;
2) echo "crit" ;;
3) echo "err" ;;
4) echo "warning" ;;
5) echo "notice" ;;
6) echo "info" ;;
7) echo "debug" ;;
*) echo "$1" ;;
esac
}
log_debug() {
log_priority 7 || return 0
echoerr "$(log_prefix)" "$(log_tag 7)" "$@"
}
log_info() {
log_priority 6 || return 0
echoerr "$(log_prefix)" "$(log_tag 6)" "$@"
}
log_err() {
log_priority 3 || return 0
echoerr "$(log_prefix)" "$(log_tag 3)" "$@"
}
log_crit() {
log_priority 2 || return 0
echoerr "$(log_prefix)" "$(log_tag 2)" "$@"
}
uname_os() {
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$os" in
cygwin_nt*) os="windows" ;;
mingw*) os="windows" ;;
msys_nt*) os="windows" ;;
esac
echo "$os"
}
uname_arch() {
arch=$(uname -m)
case $arch in
x86_64) arch="amd64" ;;
x86) arch="386" ;;
i686) arch="386" ;;
i386) arch="386" ;;
aarch64) arch="arm64" ;;
armv5*) arch="armv5" ;;
armv6*) arch="armv6" ;;
armv7*) arch="armv7" ;;
esac
echo ${arch}
}
uname_os_check() {
os=$(uname_os)
case "$os" in
darwin) return 0 ;;
dragonfly) return 0 ;;
freebsd) return 0 ;;
linux) return 0 ;;
android) return 0 ;;
nacl) return 0 ;;
netbsd) return 0 ;;
openbsd) return 0 ;;
plan9) return 0 ;;
solaris) return 0 ;;
windows) return 0 ;;
esac
log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib"
return 1
}
uname_arch_check() {
arch=$(uname_arch)
case "$arch" in
386) return 0 ;;
amd64) return 0 ;;
arm64) return 0 ;;
armv5) return 0 ;;
armv6) return 0 ;;
armv7) return 0 ;;
ppc64) return 0 ;;
ppc64le) return 0 ;;
mips) return 0 ;;
mipsle) return 0 ;;
mips64) return 0 ;;
mips64le) return 0 ;;
s390x) return 0 ;;
amd64p32) return 0 ;;
esac
log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib"
return 1
}
untar() {
tarball=$1
case "${tarball}" in
*.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;;
*.tar) tar --no-same-owner -xf "${tarball}" ;;
*.zip) unzip "${tarball}" ;;
*)
log_err "untar unknown archive format for ${tarball}"
return 1
;;
esac
}
http_download_curl() {
local_file=$1
source_url=$2
header=$3
if [ -z "$header" ]; then
code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url")
else
code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url")
fi
if [ "$code" != "200" ]; then
log_debug "http_download_curl received HTTP status $code"
return 1
fi
return 0
}
http_download_wget() {
local_file=$1
source_url=$2
header=$3
if [ -z "$header" ]; then
wget -q -O "$local_file" "$source_url"
else
wget -q --header "$header" -O "$local_file" "$source_url"
fi
}
http_download() {
log_debug "http_download $2"
if is_command curl; then
http_download_curl "$@"
return
elif is_command wget; then
http_download_wget "$@"
return
fi
log_crit "http_download unable to find wget or curl"
return 1
}
http_copy() {
tmp=$(mktemp)
http_download "${tmp}" "$1" "$2" || return 1
body=$(cat "$tmp")
rm -f "${tmp}"
echo "$body"
}
github_release() {
owner_repo=$1
version=$2
test -z "$version" && version="latest"
giturl="https://github.com/${owner_repo}/releases/${version}"
json=$(http_copy "$giturl" "Accept:application/json")
test -z "$json" && return 1
version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')
test -z "$version" && return 1
echo "$version"
}
hash_sha256() {
TARGET=${1:-/dev/stdin}
if is_command gsha256sum; then
hash=$(gsha256sum "$TARGET") || return 1
echo "$hash" | cut -d ' ' -f 1
elif is_command sha256sum; then
hash=$(sha256sum "$TARGET") || return 1
echo "$hash" | cut -d ' ' -f 1
elif is_command shasum; then
hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1
echo "$hash" | cut -d ' ' -f 1
elif is_command openssl; then
hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1
echo "$hash" | cut -d ' ' -f a
else
log_crit "hash_sha256 unable to find command to compute sha-256 hash"
return 1
fi
}
hash_sha256_verify() {
TARGET=$1
checksums=$2
if [ -z "$checksums" ]; then
log_err "hash_sha256_verify checksum file not specified in arg2"
return 1
fi
BASENAME=${TARGET##*/}
want=$(grep "${BASENAME}$" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)
if [ -z "$want" ]; then
log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'"
return 1
fi
got=$(hash_sha256 "$TARGET")
if [ "$want" != "$got" ]; then
log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got"
return 1
fi
}
cat /dev/null <<EOF
------------------------------------------------------------------------
End of functions from https://github.com/client9/shlib
------------------------------------------------------------------------
EOF
PROJECT_NAME="goreleaser"
OWNER=goreleaser
REPO="goreleaser"
BINARY=goreleaser
FORMAT=tar.gz
OS=$(uname_os)
ARCH=$(uname_arch)
PREFIX="$OWNER/$REPO"
# use in logging routines
log_prefix() {
echo "$PREFIX"
}
PLATFORM="${OS}/${ARCH}"
GITHUB_DOWNLOAD=https://github.com/${OWNER}/${REPO}/releases/download
uname_os_check "$OS"
uname_arch_check "$ARCH"
parse_args "$@"
get_binaries
tag_to_version
adjust_format
adjust_os
adjust_arch
log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}"
NAME=${PROJECT_NAME}_${OS}_${ARCH}
TARBALL=${NAME}.${FORMAT}
TARBALL_URL=${GITHUB_DOWNLOAD}/${TAG}/${TARBALL}
CHECKSUM=checksums.txt
CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM}
execute
log_err "this script is deprecated, please do not use it anymore. check https://github.com/goreleaser/godownloader/issues/207"
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -u
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
echo " 🔴 there are uncommitted changes, please commit them before running this check"
exit 1
fi
if ! make generate-json-schema; then
echo "Generating json schema failed"
exit 1
fi
if [ "$(git status --porcelain | wc -l)" -ne "0" ]; then
echo " 🔴 there are uncommitted changes, please commit them before running this check"
exit 1
fi
-249
View File
@@ -1,249 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import sys
import glob
import subprocess
import os
import re
DRY_RUN = False
JSON_SCHEMA_LABEL = "json-schema"
# note: we can't use "breaking-change" as the label since that might be applied manually by a user. This is a
# distinct label that we can use to indicate that the label was applied (or removed) by automation.
BREAKING_CHANGE_LABEL = "detected-breaking-change"
def main(changed_files: str | None = None, merge_base_schema_files: str | None = None):
global DRY_RUN
pr_number = os.environ.get("GITHUB_PR_NUMBER")
comment_file_path = os.environ.get("CI_COMMENT_FILE")
if not comment_file_path:
print("CI_COMMENT_FILE not set")
sys.exit(1)
if not pr_number:
DRY_RUN = True
if changed_files:
DRY_RUN = True
# read lines from file... this is useful for local testing
with open(changed_files) as f:
pr_changed_files = f.read().splitlines()
with open(merge_base_schema_files) as f:
og_json_schema_files = sort_json_schema_files(f.read().splitlines())
else:
if not is_ci():
print("Not in CI")
sys.exit(1)
if not pr_number:
print("Not a PR")
sys.exit(1)
pr_changed_files = get_pr_changed_files(pr_number)
# since we are running this in the context of the pull_request_target, the checkout is the merge base..
# that is the main branch of the original repo, NOT the branch in the forked repo (or branch in the target
# repo for non-forked PRs). This means we just need to list the current checkedout files to get a sense of
# the changes before a merge.
og_json_schema_files = list_json_schema_files()
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))
if not og_json_schema_files:
print("No schema files found in merge base")
sys.exit(1)
# pr_json_schema_files = set of PR files are added, removed, and changed files
new_schema_files = set(pr_json_schema_files) - set(og_json_schema_files)
removed_or_modified_schema_files = set(pr_json_schema_files) - set(new_schema_files)
print("new schemas: ", summarize_schema_files(new_schema_files))
print("removed or modified schemas:", summarize_schema_files(removed_or_modified_schema_files))
# if there is a new or modified schema, we should add the "json-schema" label to the PR...
if new_schema_files or removed_or_modified_schema_files:
print("\nAdding json-schema label...")
add_label(pr_number, JSON_SCHEMA_LABEL)
else:
if JSON_SCHEMA_LABEL in pr_labels:
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...
if is_breaking_change(new_schema_files, og_json_schema_files[-1]):
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)
# 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...
if removed_or_modified_schema_files:
print("\nRemoved or modified schema detected...")
schemas = sort_json_schema_files(list(removed_or_modified_schema_files))
schemas_str = "\n".join([f" - {schema}" for schema in schemas])
add_comment(comment_file_path, f"Detected modification or removal of existing json schemas:\n{schemas_str}", warning=True)
def add_comment(comment_file_path: str, comment: str, warning: bool = False, important: bool = False):
if warning or important:
comment_lines = comment.splitlines()
comment = "\n".join([f"> {line}" for line in comment_lines])
if warning:
comment = f"> [!WARNING]\n{comment}"
elif important:
comment = f"> [!IMPORTANT]\n{comment}"
# create any parent directories if they don't exist
os.makedirs(os.path.dirname(comment_file_path), exist_ok=True)
with open(comment_file_path, "w") as f:
f.write(comment)
print(f"Comment file contents: {comment_file_path}")
print(comment)
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(str(result.stderr))
sys.exit(1)
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(str(result.stderr))
sys.exit(1)
def major_version(semver: str) -> int:
return int(semver.split(".")[0])
def is_breaking_change(new_schema_files: set[str], latest_schema_file: str) -> bool:
latest_major_version = major_version(get_semver(latest_schema_file))
for file in new_schema_files:
change_major_version = major_version(get_semver(file))
if change_major_version > latest_major_version:
return True
return False
def summarize_schema_files(files: list[str]) -> list[str]:
return [get_semver(file) for file in files]
def is_ci() -> bool:
return "CI" in os.environ
def get_pr_changed_files(pr_number: str) -> list[str]:
result = run(f"gh pr view {pr_number} --json files --jq '.files.[].path'", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print("Unable to get list of changed files in PR")
print(str(result.stderr))
sys.exit(1)
list_of_files = result.stdout.splitlines()
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 = []
for file in list_of_files:
if re.match(r"^schema/json/schema-\d+\.\d+\.\d+\.json$", file):
files.append(file)
return sort_json_schema_files(files)
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])
def run(command: str, **kwargs) -> subprocess.CompletedProcess:
if DRY_RUN:
print(f"[DRY RUN] {command}")
return subprocess.CompletedProcess(args=[command], returncode=0)
print(f"[RUN] {command}")
return subprocess.run(command, **kwargs)
def get_semver(input_file: str) -> str:
return input_file.split("-")[1].split(".json")[0]
def sort_json_schema_files(files: list[str]) -> list[str]:
# sort files by schema version, where the input looks like "schema/json/schema-1.12.1.json"
# we should sort by the semantic version embedded within the basename, not the string
# 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])
return [f"schema/json/schema-{version}.json" for version in versions]
# allow for test files that have line-by-line list of files:
# .binny.yaml
# .github/actions/bootstrap/action.yaml
# .github/scripts/goreleaser-install.sh
# .github/workflows/release.yaml
# .github/workflows/update-bootstrap-tools.yml
# .github/workflows/update-cpe-dictionary-index.yml
# .github/workflows/update-stereoscope-release.yml
# .github/workflows/validations.yaml
# .gitignore
# .goreleaser.yaml
# Makefile
# Taskfile.yaml
# schema/cyclonedx/Makefile
if __name__ == "__main__":
# these are variables for a single file name that contains a list of files (line separated)
changed_files = None
merge_base_schema_files = None
if len(sys.argv) > 2:
changed_files = sys.argv[1]
merge_base_schema_files = sys.argv[2]
main(changed_files, merge_base_schema_files)
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env python3
import unittest
from unittest.mock import patch
import subprocess
import labeler
class Labeler(unittest.TestCase):
def test_major_version(self):
self.assertEqual(labeler.major_version("1.2.3"), 1)
self.assertEqual(labeler.major_version("2.0.0"), 2)
def test_is_breaking_change(self):
new_schema_files = ["schema/json/schema-2.0.0.json"]
latest_schema_file = "schema/json/schema-1.2.0.json"
self.assertTrue(labeler.is_breaking_change(new_schema_files, latest_schema_file))
new_schema_files = ["schema/json/schema-1.3.0.json"]
latest_schema_file = "schema/json/schema-1.2.0.json"
self.assertFalse(labeler.is_breaking_change(new_schema_files, latest_schema_file))
def test_summarize_schema_files(self):
files = ["schema/json/schema-1.0.0.json", "schema/json/schema-2.0.0.json"]
expected = ["1.0.0", "2.0.0"]
self.assertEqual(labeler.summarize_schema_files(files), expected)
def test_is_ci(self):
# Mock os.environ to simulate CI environment
with patch.dict("os.environ", {"CI": "true"}):
self.assertTrue(labeler.is_ci())
def test_get_pr_changed_files(self):
expected_command = "gh pr view 123 --json files --jq '.files.[].path'"
expected_output = "file1.json\nfile2.json\n"
subprocess.CompletedProcess.returncode = 0
subprocess.CompletedProcess.stdout = expected_output
with patch("labeler.run", return_value=subprocess.CompletedProcess) as mock_run:
result = labeler.get_pr_changed_files("123")
mock_run.assert_called_with(expected_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.assertEqual(result, ["file1.json", "file2.json"])
def test_filter_to_schema_files(self):
input_files = ["schema/json/schema-1.0.0.json", "not_schema.txt", "schema/json/schema-2.0.0.json"]
expected_files = ["schema/json/schema-1.0.0.json", "schema/json/schema-2.0.0.json"]
self.assertEqual(labeler.filter_to_schema_files(input_files), expected_files)
# we should be strict about what files are allowed to be processed
input_files = ["schema/json/schema-1.0.0extracontent.json", "schema/json/schema-1.0.0.md", "schema/json/schema-1.0.0.json.extracontent"]
expected_files = []
self.assertEqual(labeler.filter_to_schema_files(input_files), expected_files)
def test_get_semver(self):
input_file = "schema/json/schema-1.0.0.json"
expected_semver = "1.0.0"
self.assertEqual(labeler.get_semver(input_file), expected_semver)
def test_sort_json_schema_files(self):
files = ["schema/json/schema-1.12.1.json", "schema/json/schema-1.2.1.json"]
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()
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
set -eu
bold=$(tput bold)
normal=$(tput sgr0)
GH_CLI=.tool/gh
if ! [ -x "$(command -v $GH_CLI)" ]; then
echo "The GitHub CLI could not be found. run: make bootstrap"
exit 1
fi
$GH_CLI auth status
# set the default repo in cases where multiple remotes are defined
$GH_CLI repo set-default anchore/syft
export GITHUB_TOKEN="${GITHUB_TOKEN-"$($GH_CLI auth token)"}"
# we need all of the git state to determine the next version. Since tagging is done by
# the release pipeline it is possible to not have all of the tags from previous releases.
git fetch --tags
# populates the CHANGELOG.md and VERSION files
echo "${bold}Generating changelog...${normal}"
make changelog 2> /dev/null
NEXT_VERSION=$(cat VERSION)
if [[ "$NEXT_VERSION" == "" || "${NEXT_VERSION}" == "(Unreleased)" ]]; then
echo "Could not determine the next version to release. Exiting..."
exit 1
fi
while true; do
read -p "${bold}Do you want to trigger a release for version '${NEXT_VERSION}'?${normal} [y/n] " yn
case $yn in
[Yy]* ) echo; break;;
[Nn]* ) echo; echo "Cancelling release..."; exit;;
* ) echo "Please answer yes or no.";;
esac
done
echo "${bold}Kicking off release for ${NEXT_VERSION}${normal}..."
echo
$GH_CLI workflow run release.yaml -f version=${NEXT_VERSION}
echo
echo "${bold}Waiting for release to start...${normal}"
sleep 10
set +e
echo "${bold}Head to the release workflow to monitor the release:${normal} $($GH_CLI run list --workflow=release.yaml --limit=1 --json url --jq '.[].url')"
id=$($GH_CLI run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[].databaseId')
$GH_CLI run watch $id --exit-status || (echo ; echo "${bold}Logs of failed step:${normal}" && GH_PAGER="" $GH_CLI run view $id --log-failed)
+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
-62
View File
@@ -1,62 +0,0 @@
name: "Benchmark testing"
on:
workflow_dispatch:
pull_request:
permissions:
contents: read
jobs:
Benchmark-Test:
name: "Benchmark tests"
runs-on: ubuntu-20.04
# note: we want benchmarks to run on pull_request events in order to publish results to a sticky comment, and
# we also want to run on push such that merges to main are recorded to the cache. For this reason we don't filter
# the job by event.
steps:
- name: Checkout code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Restore base benchmark result
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
with:
path: test/results/benchmark-main.txt
# use base sha for PR or new commit hash for main push in benchmark result key
key: ${{ runner.os }}-bench-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }}
- name: Run benchmark tests
id: benchmark
run: |
REF_NAME=${GITHUB_REF##*/} make benchmark
OUTPUT=$(make show-benchstat)
OUTPUT="${OUTPUT//'%'/'%25'}" # URL encode all '%' characters
OUTPUT="${OUTPUT//$'\n'/'%0A'}" # URL encode all '\n' characters
OUTPUT="${OUTPUT//$'\r'/'%0D'}" # URL encode all '\r' characters
echo "result=$OUTPUT" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: benchmark-test-results
path: test/results/**/*
- name: Update PR benchmark results comment
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 #v2.9.0
continue-on-error: true
with:
header: benchmark
message: |
### Benchmark Test Results
<details>
<summary>Benchmark results from the latest changes vs base branch</summary>
```
${{ steps.benchmark.outputs.result }}
```
</details>
+5 -16
View File
@@ -14,16 +14,10 @@ on:
schedule:
- cron: '0 0 * * 3'
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-22.04-4core-16gb
permissions:
security-events: write
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -36,16 +30,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Install Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 #v5.0.2
with:
go-version-file: go.mod
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 #v3.26.6
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +45,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@4dd16135b69a43b6c8efb853346f8437d92d3c93 #v3.26.6
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -70,4 +59,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 #v3.26.6
uses: github/codeql-action/analyze@v1
@@ -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
@@ -1,54 +0,0 @@
name: "Detect schema changes"
on:
# IMPORTANT! This workflow is triggered by the `pull_request_target` event
# which means that forked PRs will run with access secrets from the repo
# it's forked from (the "target" repo).
#
# For this reason we only NEVER checkout the code from the pull request
# (e.g. "ref: ${{ github.event.pull_request.head.sha }}") to prevent
# accidentally running potentially untrusted code.
#
# By default the checkout will be:
# - GITHUB_SHA: Last commit on the PR base branch
# - GITHUB_REF: PR base branch
#
# ...unlike a typical PR where:
# - GITHUB_SHA: Last merge commit on the GITHUB_REF branch
# - GITHUB_REF: PR merge branch refs/pull/:prNumber/merge
pull_request_target:
env:
# note: this is used within hashFiles() so must be within the GITHUB_WORKSPACE path (or will silently fail)
CI_COMMENT_FILE: .tmp/labeler-comment.txt
# needs to be any string to uniquely identify the comment on a PR across multiple runs
COMMENT_HEADER: "label-commentary"
jobs:
label:
name: "Label changes"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- run: python .github/scripts/labeler.py
env:
# note: this token has write access to the repo
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_PR_NUMBER: ${{ github.event.number }}
- name: Delete existing comment
if: ${{ hashFiles( env.CI_COMMENT_FILE ) == '' }}
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 #v2.9.0
with:
header: ${{ env.COMMENT_HEADER }}
hide: true
hide_classify: "OUTDATED"
- name: Add comment
if: ${{ hashFiles( env.CI_COMMENT_FILE ) != '' }}
uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 #v2.9.0
with:
header: ${{ env.COMMENT_HEADER }}
path: ${{ env.CI_COMMENT_FILE }}
@@ -1,18 +0,0 @@
name: Add to OSS board
permissions:
contents: read
on:
issues:
types:
- opened
- reopened
- transferred
- labeled
jobs:
run:
uses: "anchore/workflows/.github/workflows/oss-project-board-add.yaml@main"
secrets:
token: ${{ secrets.OSS_PROJECT_GH_TOKEN }}
@@ -1,31 +0,0 @@
name: "Release: version file"
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 }}
+67 -76
View File
@@ -1,38 +1,32 @@
name: "Release"
permissions:
contents: read
on:
workflow_dispatch:
inputs:
version:
description: tag the latest commit on main with the given version (prefixed with v)
required: true
push:
# take no actions on push to any branch...
branches-ignore:
- "**"
# ... only act on release tags
tags:
- "v*"
env:
GO_VERSION: "1.17.x"
jobs:
quality-gate:
environment: release
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@v2
- 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
- name: Check if tag already exists
# note: this will fail if the tag already exists
# we don't want to release commits that have been pushed and tagged, but not necessarily merged onto main
- name: Ensure tagged commit is on main
run: |
[[ "${{ github.event.inputs.version }}" == v* ]] || (echo "version '${{ github.event.inputs.version }}' does not have a 'v' prefix" && exit 1)
git tag ${{ github.event.inputs.version }}
echo "Tag: ${GITHUB_REF##*/}"
git fetch origin main
git merge-base --is-ancestor ${GITHUB_REF##*/} origin/main && echo "${GITHUB_REF##*/} is a commit on main!"
- name: Check static analysis results
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
uses: fountainhead/action-wait-for-check@v1.0.0
id: static-analysis
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -41,7 +35,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@v1.0.0
id: unit
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -50,7 +44,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@v1.0.0
id: integration
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +53,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@v1.0.0
id: acceptance-linux
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -68,7 +62,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@v1.0.0
id: acceptance-mac
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -77,7 +71,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@v1.0.0
id: cli-linux
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -98,74 +92,71 @@ jobs:
release:
needs: [quality-gate]
runs-on: ubuntu-20.04
permissions:
contents: write
packages: write
# required for goreleaser signs section with cosign
id-token: write
# due to our code signing process, it's vital that we run our release steps on macOS
runs-on: macos-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: docker-practice/actions-setup-docker@v1
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 #v3.3.0
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
username: ${{ secrets.ANCHOREOSSWRITE_DH_USERNAME }}
password: ${{ secrets.ANCHOREOSSWRITE_DH_PAT }}
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 #v3.3.0
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: Tag release
run: |
git config --global user.name "anchoreci"
git config --global user.email "anchoreci@users.noreply.github.com"
git tag -a ${{ github.event.inputs.version }} -m "Release ${{ github.event.inputs.version }}"
git push origin --tags
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Build & publish release artifacts
run: make ci-release
run: make release
env:
# for mac signing and notarization...
QUILL_SIGN_P12: ${{ secrets.ANCHORE_APPLE_DEVELOPER_ID_CERT_CHAIN }}
QUILL_SIGN_PASSWORD: ${{ secrets.ANCHORE_APPLE_DEVELOPER_ID_CERT_PASS }}
QUILL_NOTARY_ISSUER: ${{ secrets.APPLE_NOTARY_ISSUER }}
QUILL_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
QUILL_NOTARY_KEY: ${{ secrets.APPLE_NOTARY_KEY }}
# 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 }}
DOCKER_USERNAME: ${{ secrets.TOOLBOX_DOCKER_USER }}
DOCKER_PASSWORD: ${{ secrets.TOOLBOX_DOCKER_PASS }}
# we use a different token than GITHUB_SECRETS to additionally allow updating the homebrew repos
GITHUB_TOKEN: ${{ secrets.ANCHORE_GIT_READ_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
# used during macOS code signing
APPLE_DEVELOPER_ID_CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT }}
APPLE_DEVELOPER_ID_CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASS }}
# used during macOS notarization
AC_USERNAME: ${{ secrets.ENG_CI_APPLE_ID }}
AC_PASSWORD: ${{ secrets.ENG_CI_APPLE_ID_PASS }}
- uses: anchore/sbom-action@61119d458adab75f756bc0b9e4bde25725f86a7a #v0.17.2
- uses: anchore/sbom-action@v0
continue-on-error: true
with:
artifact-name: sbom.spdx.json
- uses: 8398a7/action-slack@28ba43ae48961b90635b50953d216767a6bea486 #v3.16.2
- uses: 8398a7/action-slack@v3
continue-on-error: true
with:
status: ${{ job.status }}
fields: repo,workflow,action,eventName
text: "A new Syft release has been published: https://github.com/anchore/syft/releases/tag/${{ github.event.inputs.version }}"
text: "A new Syft release is ready to be manually published: https://github.com/anchore/syft/releases"
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
- uses: actions/upload-artifact@v2
with:
name: artifacts
path: dist/**/*
@@ -1,39 +0,0 @@
name: "Test fixture cache: publish"
on:
workflow_dispatch:
schedule:
# run nightly at 4AM UTC
- cron: "0 4 * * *"
permissions:
contents: read
jobs:
Publish:
name: "Publish test fixture image cache"
# we use this runner to get enough storage space for docker images and fixture cache
runs-on: ubuntu-22.04-4core-16gb
permissions:
packages: write
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
with:
# we want to rebuild the cache with no previous state
download-test-fixture-cache: false
- name: Run all tests
run: make test
env:
# we want to rebuild the cache with no previous state
DOWNLOAD_TEST_FIXTURE_CACHE: "false"
- name: Login to GitHub Container Registry (ORAS)
run: echo "${{ secrets.GITHUB_TOKEN }}" | .tool/oras login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Publish test fixture cache
run: make upload-test-fixture-cache
@@ -1,65 +0,0 @@
name: PR for latest versions of tools
on:
schedule:
- cron: "0 8 * * *" # 3 AM EST
workflow_dispatch:
permissions:
contents: read
jobs:
update-bootstrap-tools:
runs-on: ubuntu-latest
if: github.repository == 'anchore/syft' # only run for main repo
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
with:
bootstrap-apt-packages: ""
go-dependencies: false
- name: "Update tool versions"
id: latest-versions
run: |
make update-tools
make list-tools
export NO_COLOR=1
delimiter="$(openssl rand -hex 8)"
{
echo "status<<${delimiter}"
make list-tool-updates
echo "${delimiter}"
} >> $GITHUB_OUTPUT
{
echo "### Tool version status"
echo "\`\`\`"
make list-tool-updates
echo "\`\`\`"
} >> $GITHUB_STEP_SUMMARY
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
id: generate-token
with:
app_id: ${{ secrets.TOKEN_APP_ID }}
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
- uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 #v7.0.1
with:
signoff: true
delete-branch: true
branch: auto/latest-tools
labels: dependencies
commit-message: 'chore(deps): update tools to latest versions'
title: 'chore(deps): update tools to latest versions'
body: |
```
${{ steps.latest-versions.outputs.status }}
```
This is an auto-generated pull request to update all of the tools to the latest versions.
token: ${{ steps.generate-token.outputs.token }}
@@ -1,55 +0,0 @@
name: PR to update CPE dictionary index
on:
schedule:
- cron: "0 1 * * 1" # every monday at 1 AM
workflow_dispatch:
permissions:
contents: read
env:
SLACK_NOTIFICATIONS: true
jobs:
upgrade-cpe-dictionary-index:
runs-on: ubuntu-latest
if: github.repository == 'anchore/syft' # only run for main repo
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- run: |
make generate-cpe-dictionary-index
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
id: generate-token
with:
app_id: ${{ secrets.TOKEN_APP_ID }}
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
- uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 #v7.0.1
with:
signoff: true
delete-branch: true
branch: auto/latest-cpe-dictionary-index
labels: dependencies
commit-message: "chore(deps): update CPE dictionary index"
title: "chore(deps): update CPE dictionary index"
body: |
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
with:
status: ${{ job.status }}
fields: workflow,eventName,job
text: Syft CPE dictionary index update failed
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
if: ${{ failure() && env.SLACK_NOTIFICATIONS == 'true' }}
@@ -1,57 +0,0 @@
name: PR for latest Stereoscope release
on:
schedule:
- cron: "0 8 * * *" # 3 AM EST
workflow_dispatch:
env:
GO_VERSION: "1.21.x"
GO_STABLE_VERSION: true
permissions:
contents: read
jobs:
upgrade-stereoscope:
runs-on: ubuntu-latest
if: github.repository == 'anchore/syft' # only run for main repo
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 #v5.0.2
with:
go-version: ${{ env.GO_VERSION }}
stable: ${{ env.GO_STABLE_VERSION }}
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- run: |
LATEST_VERSION=$(git ls-remote https://github.com/anchore/stereoscope main | head -n1 | awk '{print $1;}')
# update go.mod
go get github.com/anchore/stereoscope@$LATEST_VERSION
go mod tidy
# export the version for use with create-pull-request
echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_OUTPUT
id: latest-version
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0
id: generate-token
with:
app_id: ${{ secrets.TOKEN_APP_ID }}
private_key: ${{ secrets.TOKEN_APP_PRIVATE_KEY }}
- uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 #v7.0.1
with:
signoff: true
delete-branch: true
branch: auto/latest-stereoscope
labels: dependencies
commit-message: "chore(deps): update stereoscope to ${{ steps.latest-version.outputs.LATEST_VERSION }}"
title: "chore(deps): update stereoscope to ${{ steps.latest-version.outputs.LATEST_VERSION }}"
body: |
Update Stereoscope to ${{ steps.latest-version.outputs.LATEST_VERSION }}
token: ${{ steps.generate-token.outputs.token }}
+269 -113
View File
@@ -1,14 +1,13 @@
name: "Validations"
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
pull_request:
permissions:
contents: read
env:
GO_VERSION: "1.17.x"
jobs:
@@ -17,77 +16,254 @@ jobs:
name: "Static analysis"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/checkout@v2
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Bootstrap CI environment dependencies
run: make ci-bootstrap
- name: Run static analysis
run: make static-analysis
Unit-Test:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
name: "Unit tests"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/setup-go@v2
with:
download-test-fixture-cache: true
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v2
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Bootstrap CI environment dependencies
run: make ci-bootstrap
- name: Build cache key for java test-fixture blobs (for unit tests)
run: make java-packages-fingerprint
- name: Restore Java test-fixture cache
id: unit-java-cache
uses: actions/cache@v2.1.3
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/packages.fingerprint' ) }}
- name: Run unit tests
run: make unit
- uses: actions/upload-artifact@v2
with:
name: unit-test-results
path: test/results/**/*
Integration-Test:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
name: "Integration tests"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/setup-go@v2
with:
download-test-fixture-cache: true
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v2
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Bootstrap CI environment dependencies
run: make ci-bootstrap
- name: Validate syft output against the CycloneDX schema
run: make validate-cyclonedx-schema
- name: Build key for tar cache
run: make integration-fingerprint
- name: Restore integration test cache
uses: actions/cache@v2.1.3
with:
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
Benchmark-Test:
name: "Benchmark tests"
runs-on: ubuntu-20.04
# note: we want benchmarks to run on pull_request events in order to publish results to a sticky comment, and
# we also want to run on push such that merges to main are recorded to the cache. For this reason we don't filter
# the job by event.
steps:
- uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v2
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Bootstrap CI environment dependencies
run: make ci-bootstrap
- name: Restore base benchmark result
uses: actions/cache@v2
with:
path: test/results/benchmark-main.txt
# use base sha for PR or new commit hash for main push in benchmark result key
key: ${{ runner.os }}-bench-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }}
- name: Run benchmark tests
id: benchmark
run: |
REF_NAME=${GITHUB_REF##*/} make benchmark
OUTPUT=$(make show-benchstat)
OUTPUT="${OUTPUT//'%'/'%25'}" # URL encode all '%' characters
OUTPUT="${OUTPUT//$'\n'/'%0A'}" # URL encode all '\n' characters
OUTPUT="${OUTPUT//$'\r'/'%0D'}" # URL encode all '\r' characters
echo "::set-output name=result::$OUTPUT"
- uses: actions/upload-artifact@v2
with:
name: benchmark-test-results
path: test/results/**/*
- name: Update PR benchmark results comment
uses: marocchino/sticky-pull-request-comment@v2
continue-on-error: true
with:
header: benchmark
message: |
### Benchmark Test Results
<details>
<summary>Benchmark results from the latest changes vs base branch</summary>
```
${{ steps.benchmark.outputs.result }}
```
</details>
Build-Snapshot-Artifacts:
name: "Build snapshot artifacts"
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/setup-go@v2
with:
bootstrap-apt-packages: ""
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Build snapshot artifacts
run: make snapshot
# 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/upload-artifact@v2
with:
# we need to preserve the snapshot data itself as well as the task data that confirms if the
# snapshot build is stale or not. Otherwise the downstream jobs will attempt to rebuild the snapshot
# even though it already exists.
path: |
snapshot
.task
key: snapshot-build-${{ github.run_id }}
name: artifacts
path: snapshot/**/*
Acceptance-Linux:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
@@ -95,31 +271,26 @@ jobs:
needs: [Build-Snapshot-Artifacts]
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- uses: actions/checkout@v2
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/download-artifact@v2
with:
download-test-fixture-cache: true
- name: Download snapshot build
id: snapshot-cache
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
with:
path: |
snapshot
.task
fail-on-cache-miss: true
key: snapshot-build-${{ github.run_id }}
# workaround for https://github.com/actions/cache/issues/1265
- name: (cache-miss) Snapshot build missing
if: steps.snapshot-cache.outputs.cache-hit != 'true'
run: echo "unable to download snapshots from previous job" && false
name: artifacts
path: snapshot
- name: Run comparison tests (Linux)
run: make compare-linux
- name: Build key for image cache
run: make install-fingerprint
- name: Restore install.sh test image cache
id: install-test-image-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/test/install/cache
key: ${{ runner.os }}-install-test-image-cache-${{ hashFiles('test/install/cache.fingerprint') }}
- name: Load test image cache
if: steps.install-test-image-cache.outputs.cache-hit == 'true'
run: make install-test-cache-load
@@ -138,32 +309,19 @@ jobs:
needs: [Build-Snapshot-Artifacts]
runs-on: macos-latest
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@v3.6.0
- uses: actions/checkout@v2
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/download-artifact@v2
with:
bootstrap-apt-packages: ""
go-dependencies: false
download-test-fixture-cache: true
name: artifacts
path: snapshot
- name: Download snapshot build
id: snapshot-cache
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
- name: Restore docker image cache for compare testing
id: mac-compare-testing-cache
uses: actions/cache@v2.1.3
with:
path: |
snapshot
.task
fail-on-cache-miss: true
key: snapshot-build-${{ github.run_id }}
# workaround for https://github.com/actions/cache/issues/1265
- name: (cache-miss) Snapshot build missing
if: steps.snapshot-cache.outputs.cache-hit != 'true'
run: echo "unable to download snapshots from previous job" && false
path: image.tar
key: ${{ runner.os }}-${{ hashFiles('test/compare/mac.sh') }}
- name: Run comparison tests (Mac)
run: make compare-mac
@@ -171,53 +329,51 @@ jobs:
- name: Run install.sh tests (Mac)
run: make install-test-ci-mac
Cli-Linux:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
name: "CLI tests (Linux)"
needs: [Build-Snapshot-Artifacts]
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Bootstrap environment
uses: ./.github/actions/bootstrap
- uses: actions/setup-go@v2
with:
download-test-fixture-cache: true
go-version: ${{ env.GO_VERSION }}
- name: Download snapshot build
id: snapshot-cache
uses: actions/cache/restore@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 #v4.0.2
- uses: actions/checkout@v2
- name: Restore go cache
id: go-cache
uses: actions/cache@v2.1.3
with:
path: |
snapshot
.task
fail-on-cache-miss: true
key: snapshot-build-${{ github.run_id }}
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-${{ env.GO_VERSION }}-
# workaround for https://github.com/actions/cache/issues/1265
- name: (cache-miss) Snapshot build missing
if: steps.snapshot-cache.outputs.cache-hit != 'true'
run: echo "unable to download snapshots from previous job" && false
- name: Restore tool cache
id: tool-cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/.tmp
key: ${{ runner.os }}-tool-${{ hashFiles('Makefile') }}
- name: (cache-miss) Bootstrap all project dependencies
if: steps.tool-cache.outputs.cache-hit != 'true' || steps.go-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Build key for tar cache
run: make cli-fingerprint
- name: Restore CLI test cache
uses: actions/cache@v2.1.3
with:
path: ${{ github.workspace }}/test/cli/test-fixtures/cache
key: ${{ runner.os }}-cli-test-cache-${{ hashFiles('test/cli/test-fixtures/cache.fingerprint') }}
- uses: actions/download-artifact@v2
with:
name: artifacts
path: snapshot
- name: Run CLI Tests (Linux)
run: make cli
Cleanup-Cache:
name: "Cleanup snapshot cache"
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-20.04
permissions:
actions: write
needs:
- Acceptance-Linux
- Acceptance-Mac
- Cli-Linux
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
- name: Delete snapshot cache
run: gh cache delete "snapshot-build-${{ github.run_id }}"
env:
GH_TOKEN: ${{ github.token }}
+9 -38
View File
@@ -1,40 +1,11 @@
# local development tailoring
go.work
go.work.sum
.tool-versions
# app configuration
/.syft.yaml
# tool and bin directories
.tmp/
bin/
/bin
/.bin
/build
CHANGELOG.md
/test/results
/dist
/snapshot
/.tool
/.task
# changelog generation
CHANGELOG.md
VERSION
# IDE configuration
.vscode/
.idea/
.server/
.vscode/
.history/
# test related
*.fingerprint
/test/results
coverage.txt
*.log
# probable archives
.images
*.tar
*.jar
*.war
@@ -42,7 +13,12 @@ coverage.txt
*.jpi
*.hpi
*.zip
*.iml
.idea/
*.log
.images
.tmp/
coverage.txt
bin/
# Binaries for programs and plugins
*.exe
@@ -65,8 +41,3 @@ coverage.txt
# attestation
cosign.key
cosign.pub
# Byte-compiled object files for python
__pycache__/
*.py[cod]
*$py.class
+11 -40
View File
@@ -1,7 +1,5 @@
issues:
max-same-issues: 25
# TODO: enable this when we have coverage on docstring comments
# TODO: enable this when we have coverage on docstring comments
#issues:
# # The list of ids of default excludes to include or disable.
# include:
# - EXC0002 # disable excluding of issues about comments from golint
@@ -12,10 +10,12 @@ linters:
enable:
- asciicheck
- bodyclose
- copyloopvar
- deadcode
- depguard
- dogsled
- dupl
- errcheck
- exportloopref
- funlen
- gocognit
- goconst
@@ -32,59 +32,30 @@ linters:
- nakedret
- nolintlint
- revive
- rowserrcheck
- staticcheck
- structcheck
- stylecheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- whitespace
linters-settings:
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 70
# Checks the number of statements in a function.
# 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".
# - depguard # We don't have a configuration for this yet
# - goprintffuncname # does not catch all cases and there are exceptions
# - nakedret # does not catch all cases and should not fail a build
# - gochecknoglobals
# - gochecknoinits # this is too aggressive
# - rowserrcheck disabled per generics https://github.com/golangci/golangci-lint/issues/2649
# - godot
# - godox
# - goerr113
# - goimports # we're using gosimports now instead to account for extra whitespaces (see https://github.com/golang/go/issues/20818)
# - golint # deprecated
# - gomnd # this is too aggressive
# - interfacer # this is a good idea, but is no longer supported and is prone to false positives
# - lll # without a way to specify per-line exception cases, this is not usable
# - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations
# - nestif
# - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code
# - rowserrcheck # not in a repo with sql, so this is not useful
# - scopelint # deprecated
# - structcheck # The owner seems to have abandoned the linter. Replaced by "unused".
# - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code
# - scopelint # deprecated
# - testpackage
# - varcheck # The owner seems to have abandoned the linter. Replaced by "unused".
# - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90)
# - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90)
+31 -174
View File
@@ -1,38 +1,37 @@
version: 2
release:
prerelease: auto
draft: false
draft: true
env:
# required to support multi architecture docker builds
- DOCKER_CLI_EXPERIMENTAL=enabled
- CGO_ENABLED=0
before:
hooks:
- ./.github/scripts/apple-signing/setup.sh {{ .IsSnapshot }}
builds:
- id: linux-build
dir: ./cmd/syft
binary: syft
goos:
- linux
goarch:
- amd64
- arm64
- ppc64le
- s390x
# set the modified timestamp on the output binary to the git timestamp to ensure a reproducible build
mod_timestamp: &build-timestamp '{{ .CommitTimestamp }}'
env: &build-env
- CGO_ENABLED=0
ldflags: &build-ldflags |
-w
-s
-extldflags '-static'
-X main.version={{.Version}}
-X main.gitCommit={{.Commit}}
-X main.buildDate={{.Date}}
-X main.gitDescription={{.Summary}}
-X github.com/anchore/syft/internal/version.version={{.Version}}
-X github.com/anchore/syft/internal/version.gitCommit={{.Commit}}
-X github.com/anchore/syft/internal/version.buildDate={{.Date}}
-X github.com/anchore/syft/internal/version.gitDescription={{.Summary}}
- id: darwin-build
dir: ./cmd/syft
binary: syft
goos:
- darwin
@@ -40,21 +39,23 @@ builds:
- amd64
- arm64
mod_timestamp: *build-timestamp
env: *build-env
ldflags: *build-ldflags
hooks:
post:
- cmd: .tool/quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv
env:
- QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log
# we must have signing as a build hook instead of the signs section. The signs section must register a new
# asset, where we want to replace an existing asset. A post-build hook has the advantage of not needing to
# unpackage and repackage a tar.gz with a signed binary
- ./.github/scripts/apple-signing/sign.sh "{{ .Path }}" "{{ .IsSnapshot }}" "{{ .Target }}"
- id: windows-build
dir: ./cmd/syft
binary: syft
goos:
- windows
goarch:
- amd64
mod_timestamp: *build-timestamp
env: *build-env
ldflags: *build-ldflags
archives:
@@ -82,10 +83,9 @@ nfpms:
- deb
brews:
- repository:
- tap:
owner: anchore
name: homebrew-syft
token: "{{.Env.GITHUB_BREW_TOKEN}}"
ids:
- darwin-archives
- linux-archives
@@ -95,71 +95,10 @@ brews:
dockers:
- image_templates:
- anchore/syft:debug
- anchore/syft:{{.Tag}}-debug
- ghcr.io/anchore/syft:debug
- ghcr.io/anchore/syft:{{.Tag}}-debug
goarch: amd64
dockerfile: Dockerfile.debug
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
- "--build-arg=BUILD_DATE={{.Date}}"
- "--build-arg=BUILD_VERSION={{.Version}}"
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:debug-arm64v8
- anchore/syft:{{.Tag}}-debug-arm64v8
- ghcr.io/anchore/syft:debug-arm64v8
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
goarch: arm64
dockerfile: Dockerfile.debug
use: buildx
build_flag_templates:
- "--platform=linux/arm64/v8"
- "--build-arg=BUILD_DATE={{.Date}}"
- "--build-arg=BUILD_VERSION={{.Version}}"
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:debug-ppc64le
- anchore/syft:{{.Tag}}-debug-ppc64le
- ghcr.io/anchore/syft:debug-ppc64le
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
goarch: ppc64le
dockerfile: Dockerfile.debug
use: buildx
build_flag_templates:
- "--platform=linux/ppc64le"
- "--build-arg=BUILD_DATE={{.Date}}"
- "--build-arg=BUILD_VERSION={{.Version}}"
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:debug-s390x
- anchore/syft:{{.Tag}}-debug-s390x
- ghcr.io/anchore/syft:debug-s390x
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
goarch: s390x
dockerfile: Dockerfile.debug
use: buildx
build_flag_templates:
- "--platform=linux/s390x"
- "--build-arg=BUILD_DATE={{.Date}}"
- "--build-arg=BUILD_VERSION={{.Version}}"
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:latest
- anchore/syft:{{.Tag}}
- ghcr.io/anchore/syft:latest
- ghcr.io/anchore/syft:{{.Tag}}
goarch: amd64
- "anchore/syft:latest"
- "anchore/syft:{{ .Tag }}-amd64"
- "anchore/syft:v{{ .Major }}-amd64"
- "anchore/syft:v{{ .Major }}.{{ .Minor }}-amd64"
dockerfile: Dockerfile
use: buildx
build_flag_templates:
@@ -170,8 +109,9 @@ dockers:
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:{{.Tag}}-arm64v8
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
- "anchore/syft:{{ .Tag }}-arm64v8"
- "anchore/syft:v{{ .Major }}-arm64v8"
- "anchore/syft:v{{ .Major }}.{{ .Minor }}-arm64v8"
goarch: arm64
dockerfile: Dockerfile
use: buildx
@@ -182,95 +122,12 @@ dockers:
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:{{.Tag}}-ppc64le
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
goarch: ppc64le
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/ppc64le"
- "--build-arg=BUILD_DATE={{.Date}}"
- "--build-arg=BUILD_VERSION={{.Version}}"
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
- image_templates:
- anchore/syft:{{.Tag}}-s390x
- ghcr.io/anchore/syft:{{.Tag}}-s390x
goarch: s390x
dockerfile: Dockerfile
use: buildx
build_flag_templates:
- "--platform=linux/s390x"
- "--build-arg=BUILD_DATE={{.Date}}"
- "--build-arg=BUILD_VERSION={{.Version}}"
- "--build-arg=VCS_REF={{.FullCommit}}"
- "--build-arg=VCS_URL={{.GitURL}}"
docker_manifests:
- name_template: anchore/syft:{{ .Tag }}
image_templates:
- anchore/syft:v{{ .Major }}.{{ .Minor }}-amd64
- anchore/syft:v{{ .Major }}.{{ .Minor }}-arm64v8
- name_template: anchore/syft:latest
image_templates:
- anchore/syft:{{.Tag}}
- anchore/syft:{{.Tag}}-arm64v8
- anchore/syft:{{.Tag}}-ppc64le
- anchore/syft:{{.Tag}}-s390x
- name_template: anchore/syft:debug
- anchore/syft:{{.Tag}}-debug
- anchore/syft:{{.Tag}}-debug-arm64v8
- anchore/syft:{{.Tag}}-debug-ppc64le
- anchore/syft:{{.Tag}}-debug-s390x
- name_template: anchore/syft:{{.Tag}}
image_templates:
- anchore/syft:{{.Tag}}
- anchore/syft:{{.Tag}}-arm64v8
- anchore/syft:{{.Tag}}-ppc64le
- anchore/syft:{{.Tag}}-s390x
- name_template: ghcr.io/anchore/syft:latest
image_templates:
- ghcr.io/anchore/syft:{{.Tag}}
- ghcr.io/anchore/syft:{{.Tag}}-arm64v8
- ghcr.io/anchore/syft:{{.Tag}}-ppc64le
- ghcr.io/anchore/syft:{{.Tag}}-s390x
- name_template: ghcr.io/anchore/syft:debug
image_templates:
- ghcr.io/anchore/syft:{{.Tag}}-debug
- ghcr.io/anchore/syft:{{.Tag}}-debug-arm64v8
- ghcr.io/anchore/syft:{{.Tag}}-debug-ppc64le
- ghcr.io/anchore/syft:{{.Tag}}-debug-s390x
- name_template: ghcr.io/anchore/syft:{{.Tag}}
image_templates:
- ghcr.io/anchore/syft:{{.Tag}}
- 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
- anchore/syft:v{{ .Major }}.{{ .Minor }}-amd64
- anchore/syft:v{{ .Major }}.{{ .Minor }}-arm64v8
-128
View File
@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[opensource@anchore.com](mailto:opensource@anchore.com).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+28 -74
View File
@@ -1,58 +1,7 @@
[#](#) Contributing to Syft
# Contributing to Syft
If you are looking to contribute to this project and want to open a GitHub pull request ("PR"), there are a few guidelines of what we are looking for in patches. Make sure you go through this document and ensure that your code proposal is aligned.
## Setting up your environment
Before you can contribute to Syft, you need to configure your development environment.
### Debian setup
You will need to install Go. The version on https://go.dev works best, using the system golang doesn't always work the way you might expect.
Refer to the go.mod file in the root of this repo for the recommended version of Go to install.
You will also need Docker. There's no reason the system packages shouldn't work, but we used the official Docker package. You can find instructions for installing Docker in Debian [here](https://docs.docker.com/engine/install/debian/).
You also need to install some Debian packages
```sh
sudo apt-get install build-essential zip bc libxml2-utils git
```
## Configuring Git
You will need to configure your git client with your name and email address. This is easily done from the command line.
```text
$ git config --global user.name "John Doe"
$ git config --global user.email "john.doe@example.com"
```
This username and email address will matter later in this guide.
## Fork the repo
You should fork the Syft repo using the "Fork" button at the top right of the Syft GitHub [site](https://github.com/anchore/syft/). You will be doing your development in your fork, then submit a pull request to Syft. There are many resources how to use GitHub effectively, we will not cover those here.
## Adding a feature or fix
If you look at the Syft [Issue](https://github.com/anchore/syft/issues) there are plenty of bugs and feature requests. Maybe look at the [good first issue](https://github.com/anchore/syft/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) list if you're not sure where to start.
## Commit guidelines
In the Syft project we like commits and pull requests (PR) to be easy to understand and review. Open source thrives best when everything happening is over documented and small enough to be understood.
### Granular commits
Please try to make every commit as simple as possible, but no simpler. The idea is that each commit should be a logical unit of code. Try not to commit too many tiny changes, for example every line changed in a file as a separate commit. And also try not to make a commit enormous, for example committing all your work at the end of the day.
Rather than try to follow a strict guide on what is or is not best, we try to be flexible and simple in this space. Do what makes the most sense for the changes you are trying to include.
### Commit title and description
Remember that the message you leave for a commit is for the reviewer in the present, and for someone (maybe you) changing something in the future. Please make sure the title and description used is easy to understand and explains what was done. Jokes and clever comments generally don't age well in commit messages. Just the facts please.
## Sign off your work
The `sign-off` is an added line at the end of the explanation for the commit, certifying that you wrote it or otherwise have the right to submit it as an open-source patch. By submitting a contribution, you agree to be bound by the terms of the DCO Version 1.1 and Apache License Version 2.0.
@@ -95,6 +44,13 @@ When committing your change, you can add the required line manually so that it l
Signed-off-by: John Doe <john.doe@example.com>
```
Alternatively, configure your Git client with your name and email to use the `-s` flag when creating a commit:
```text
$ git config --global user.name "John Doe"
$ git config --global user.email "john.doe@example.com"
```
Creating a signed-off commit is then possible with `-s` or `--signoff`:
```text
@@ -114,9 +70,27 @@ Date: Mon Aug 1 11:27:13 2020 -0400
Signed-off-by: John Doe <john.doe@example.com>
```
[//]: # (TODO: Commit guidelines, granular commits)
[//]: # (TODO: Commit guidelines, descriptive messages)
[//]: # (TODO: Commit guidelines, commit title, extra body description)
[//]: # (TODO: PR title and description)
## Sign your commits
To ensure the authenticity and integrity of code contributions, **we require that all commits are signed**. Signing commits proves that your commits were truly created by you, as the holder of a private key.
Configuring git to sign your commits is a straightforward process. To get set up, see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits).
## Test your changes
This project has a `Makefile` which includes many helpers running both unit and integration tests. You can run `make help` to see all the options. Although PRs will have automatic checks for these, it is useful to run them locally, ensuring they pass before submitting changes. Ensure you've bootstrapped once before running tests:
This project has a `Makefile` which includes many helpers running both unit and integration tests. Although PRs will have automatic checks for these, it is useful to run them locally, ensuring they pass before submitting changes. Ensure you've bootstrapped once before running tests:
```text
$ make bootstrap
@@ -129,26 +103,6 @@ $ make unit
$ make integration
```
You can also run `make all` to run a more extensive test suite, but there is additional configuration that will be needed for those tests to run correctly. We will not cover the extra steps here.
## Pull Request
If you made it this far and all the tests are passing, it's time to submit a Pull Request (PR) for Syft. Submitting a PR is always a scary moment as what happens next can be an unknown. The Syft project strives to be easy to work with, we appreciate all contributions. Nobody is going to yell at you or try to make you feel bad. We love contributions and know how scary that first PR can be.
### PR Title and Description
Just like the commit title and description mentioned above, the PR title and description is very important for letting others know what's happening. Please include any details you think a reviewer will need to more properly review your PR.
A PR that is very large or poorly described has a higher likelihood of being pushed to the end of the list. Reviewers like PRs they can understand and quickly review.
### What to expect next
Please be patient with the project. We try to review PRs in a timely manner, but this is highly dependent on all the other tasks we have going on. It's OK to ask for a status update every week or two, it's not OK to ask for a status update every day.
It's very likely the reviewer will have questions and suggestions for changes to your PR. If your changes don't match the current style and flow of the other code, expect a request to change what you've done.
## Document your changes
And lastly, when proposed changes are modifying user-facing functionality or output, it is expected the PR will include updates to the documentation as well. Syft is not a project that is heavy on documentation. This will mostly be updating the README and help for the tool.
If nobody knows new features exist, they can't use them!
When proposed changes are modifying user-facing functionality or output, it is expected the PR will include updates to the documentation as well.
-11
View File
@@ -1,11 +0,0 @@
# Syft Contributors
The following Syft components were contributed by external authors/organizations.
## GraalVM Native Image
A cataloger contributed by Oracle Corporation that extracts packages given within GraalVM Native Image SBOMs.
## Swift Package Manager
A cataloger contributed by Axis Communications that catalogs packages resolved by Swift Package Manager.
+17 -220
View File
@@ -3,238 +3,25 @@
## Getting started
In order to test and develop in this repo you will need the following dependencies installed:
- Golang
- docker
- make
- Python (>= 3.9)
### Docker settings for getting started
Make sure you've updated your docker settings so the default docker socket path is available.
Go to:
docker -> settings -> advanced
Make sure:
```
Allow the default Docker socket to be used
```
is checked.
Also double check that the docker context being used is the default context. If it is not, run:
`docker context use default`
After cloning the following step can help you get setup:
After cloning do the following:
1. run `make bootstrap` to download go mod dependencies, create the `/.tmp` dir, and download helper utilities.
2. run `make` to view the selection of developer commands in the Makefile
3. run `make build` to build the release snapshot binaries and packages
4. for an even quicker start you can run `go run cmd/syft/main.go` to print the syft help.
- this command `go run cmd/syft/main.go alpine:latest` will compile and run syft against `alpine:latest`
5. view the README or syft help output for more output options
2. run `make` to run linting, tests, and other verifications to make certain everything is working alright.
The main make tasks for common static analysis and testing are `lint`, `format`, `lint-fix`, `unit`, `integration`, and `cli`.
Checkout `make help` to see what other actions you can take.
See `make help` for all the current make tasks.
The main make tasks for common static analysis and testing are `lint`, `lint-fix`, `unit`, `integration`, and `cli`.
### Internal Artifactory Settings
**Not always applicable**
Some companies have Artifactory setup internally as a solution for sourcing secure dependencies.
If you're seeing an issue where the unit tests won't run because of the below error then this section might be relevant for your use case.
```
[ERROR] [ERROR] Some problems were encountered while processing the POMs
```
If you're dealing with an issue where the unit tests will not pull/build certain java fixtures check some of these settings:
- a `settings.xml` file should be available to help you communicate with your internal artifactory deployment
- this can be moved to `syft/pkg/cataloger/java/test-fixtures/java-builds/example-jenkins-plugin/` to help build the unit test-fixtures
- you'll also want to modify the `build-example-jenkins-plugin.sh` to use `settings.xml`
For more information on this setup and troubleshooting see [issue 1895](https://github.com/anchore/syft/issues/1895#issuecomment-1610085319)
## Architecture
At a high level, this is the package structure of syft:
```
./cmd/syft/
│ ├── cli/
│ │ ├── cli.go // where all commands are wired up
│ │ ├── commands/ // all command implementations
│ │ ├── options/ // all command flags and configuration options
│ │ └── ui/ // all handlers for events that are shown on the UI
│ └── main.go // entrypoint for the application
└── syft/ // the "core" syft library
├── format/ // contains code to encode or decode to and from SBOM formats
├── pkg/ // contains code to catalog packages from a source
├── sbom/ // contains the definition of an SBOM
└── source/ // contains code to create a source object for some input type (e.g. container image, directory, etc)
```
Syft's core library is implemented in the `syft` package and subpackages, where the major packages are:
- the `syft/source` package produces a `source.Source` object that can be used to catalog a directory, container, and other source types.
- the `syft` package contains a single function that can take a `source.Source` object and catalog it, producing an `sbom.SBOM` object
- the `syft/format` package contains the ability to encode and decode SBOMs to and from different SBOM formats (such as SPDX and CycloneDX)
The `cmd` package at the highest level execution flow wires up [`spf13/cobra`](https://github.com/spf13/cobra) commands for execution in the main application:
```mermaid
sequenceDiagram
participant main as cmd/syft/main
participant cli as cli.New()
participant root as root.Execute()
participant cmd as <command>.Execute()
main->>+cli:
Note right of cli: wire ALL CLI commands
Note right of cli: add flags for ALL commands
cli-->>-main: root command
main->>+root:
root->>+cmd:
cmd-->>-root: (error)
root-->>-main: (error)
Note right of cmd: Execute SINGLE command from USER
```
The `packages` command uses the core library to generate an SBOM for the given user input:
```mermaid
sequenceDiagram
participant source as source.New(ubuntu:latest)
participant sbom as sbom.SBOM
participant catalog as syft.CatalogPackages(src)
participant encoder as syft.Encode(sbom, format)
Note right of source: use "ubuntu:latest" as SBOM input
source-->>+sbom: add source to SBOM struct
source-->>+catalog: pass src to generate catalog
catalog-->-sbom: add cataloging results onto SBOM
sbom-->>encoder: pass SBOM and format desiered to syft encoder
encoder-->>source: return bytes that are the SBOM of the original input
Note right of catalog: cataloger configuration is done based on src
```
Additionally, here is a [gist of using syft as a library](https://gist.github.com/spiffcs/3027638b7ba904d07e482a712bc00d3d) to generate a SBOM for a docker image.
### `pkg.Package` object
The `pkg.Package` object is a core data structure that represents a software package. Fields like `name` and `version` probably don't need
a detailed explanation, but some of the other fields are worth a quick overview:
- `FoundBy`: the name of the cataloger that discovered this package (e.g. `python-pip-cataloger`).
- `Locations`: these are the set of paths and layer ids that were parsed to discover this package (e.g. `python-pip-cataloger`).
- `Language`: the language of the package (e.g. `python`).
- `Type`: this is a high-level categorization of the ecosystem the package resides in. For instance, even if the package is a egg, wheel, or requirements.txt reference, it is still logically a "python" package. Not all package types align with a language (e.g. `rpm`) but it is common.
- `Metadata`: specialized data for specific location(s) parsed. We should try and raise up as much raw information that seems useful. As a rule of thumb the object here should be as flat as possible and use the raw names and values from the underlying source material parsed.
When `pkg.Package` is serialized an additional `MetadataType` is shown. This is a label that helps consumers understand the datashape of the `Metadata` field.
By convention the `MetadataType` value should follow these rules of thumb:
- Only use lowercase letters, numbers, and hyphens. Use hyphens to separate words.
- **Try to anchor the name in the ecosystem, language, or packaging tooling it belongs to**. For a package manager for a language ecosystem the language, framework or runtime should be used as a prefix. For instance `pubspec-lock` is an OK name, but `dart-pubspec-lock` is better. For an OS package manager this is not necessary (e.g. `apk-db-entry` is a good name, but `alpine-apk-db-entry` is not since `alpine` and the `a` in `apk` is redundant).
- **Be as specific as possible to what the data represents**. For instance `ruby-gem` is NOT a good `MetadataType` value, but `ruby-gemspec` is. Why? Ruby gem information can come from a gemspec file or a Gemfile.lock, which are very different. The latter name provides more context as to what to expect.
- **Should describe WHAT the data is, NOT HOW it's used**. For instance `r-description-installed-file` is NOT a good `MetadataType` value since it's trying to convey that we use the DESCRIPTION file in the R ecosystem to detect installed packages. Instead simply describe what the DESCRIPTION file is itself without context of how it's used: `r-description`.
- **Use the `lock` suffix** to distinct between manifest files that loosely describe package version requirements vs files that strongly specify one and only one version of a package ("lock" files). These should only be used with respect to package managers that have the guide and lock distinction, but would not be appropriate otherwise (e.g. `rpm` does not have a guide vs lock, so `lock` should NOT be used to describe a db entry).
- **Use the `archive` suffix to indicate a package archive** (e.g. rpm file, apk file, etc) that describes the contents of the package. For example an RPM file that was cataloged would have a `rpm-archive` metadata type (not to be confused with an RPM DB record entry which would be `rpm-db-entry`).
- **Use the `entry` suffix** to indicate information about a package that was found as a single entry within file that has multiple package entries. If the entry was found within a DB or a flat-file store for an OS package manager, you should use `db-entry`.
- **Should NOT contain the phrase `package`**, though exceptions are allowed (say if the canonical name literally has the phrase package in it).
- **Should NOT contain have a `file` suffix** unless the canonical name has the term "file", such as a `pipfile` or `gemfile`. An example of a bad name for this rule is`ruby-gemspec-file`; a better name would be `ruby-gemspec`.
- **Should NOT contain the exact filename+extensions**. For instance `pipfile.lock` shouldn't really be in the name, instead try and describe what the file is: `python-pipfile-lock` (but shouldn't this be `python-pip-lock` you might ask? No, since the `pip` package manger is not related to the `pipfile` project).
- **Should NOT contain the phrase `metadata`**, unless the canonical name has this term.
- **Should represent a single use case**. For example, trying to describe Hackage metadata with a single `HackageMetadata` struct (and thus `MetadataType`) is not allowed since it represents 3 mutually exclusive use cases: representing a `stack.yaml`, `stack.lock`, or `cabal.project` file. Instead, each of these should have their own struct types and `MetadataType` values.
There are other cases that are not covered by these rules... and that's ok! The goal is to provide a consistent naming scheme that is easy to understand and use when it's applicable. If the rules do not exactly apply in your situation then just use your best judgement (or amend these rules as needed whe new common cases come up).
What if the underlying parsed data represents multiple files? There are two approaches to this:
- use the primary file to represent all the data. For instance, though the `dpkg-cataloger` looks at multiple files to get all information about a package, it's the `status` file that gets represented.
- nest each individual file's data under the `Metadata` field. For instance, the `java-archive-cataloger` may find information from on or all of the files: `pom.xml`, `pom.properties`, and `MANIFEST.MF`. However, the metadata is simply `java-metadata' with each possibility as a nested optional field.
### Syft Catalogers
Catalogers are the way in which syft is able to identify and construct packages given a set a targeted list of files.
For example, a cataloger can ask syft for all `package-lock.json` files in order to parse and raise up javascript packages
(see [how file globs](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/javascript/cataloger.go#L16-L21) and
[file parser functions](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/javascript/cataloger.go#L16-L21) are used
for a quick example).
From a high level catalogers have the following properties:
- _They are independent from one another_. The java cataloger has no idea of the processes, assumptions, or results of the python cataloger, for example.
- _They do not know what source is being analyzed_. Are we analyzing a local directory? an image? if so, the squashed representation or all layers? The catalogers do not know the answers to these questions. Only that there is an interface to query for file paths and contents from an underlying "source" being scanned.
- _Packages created by the cataloger should not be mutated after they are created_. There is one exception made for adding CPEs to a package after the cataloging phase, but that will most likely be moved back into the cataloger in the future.
Cataloger names should be unique and named with the following rules of thumb in mind:
- Must end with `-cataloger`
- Use lowercase letters, numbers, and hyphens only
- Use hyphens to separate words
- Catalogers for language ecosystems should start with the language name (e.g. `python-` for a cataloger that raises up python packages)
- Distinct between when the cataloger is searching for evidence of installed packages vs declared packages. For example, there are currently two different gemspec-based catalogers, the `ruby-gemspec-cataloger` and `ruby-installed-gemspec-cataloger`, where the latter requires that the gemspec is found within a `specifications` directory (which means it was installed, not just at the root of a source repo).
#### Building a new Cataloger
Catalogers must fulfill the [`pkg.Cataloger` interface](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger.go) in order to add packages to the SBOM.
All catalogers should be added to:
- the [global list of catalogers](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/cataloger/cataloger.go#L92-L125)
- at least one source-specific list, today the two lists are [directory catalogers and image catalogers](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/cataloger/cataloger.go#L39-L89)
For reference, catalogers are [invoked within syft](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/catalog.go#L41-L100) one after the other, and can be invoked in parallel.
`generic.NewCataloger` is an abstraction syft used to make writing common components easier (see the [apkdb cataloger](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/cataloger.go) for example usage).
It takes the following information as input:
- A `catalogerName` to identify the cataloger uniquely among all other catalogers.
- Pairs of file globs as well as parser functions to parse those files. These parser functions return a slice of [`pkg.Package`](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/pkg/package.go#L19) as well as a slice of [`artifact.Relationship`](https://github.com/anchore/syft/blob/9995950c70e849f9921919faffbfcf46401f71f3/syft/artifact/relationship.go#L31) to describe how the returned packages are related. See this [the apkdb cataloger parser function](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/parse_apk_db.go#L22-L102) as an example.
Identified packages share a common `pkg.Package` struct so be sure that when the new cataloger is constructing a new package it is using the [`Package` struct](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/package.go#L16-L31).
If you want to return more information than what is available on the `pkg.Package` struct then you can do so in the `pkg.Package.Metadata` section of the struct, which is unique for each [`pkg.Type`](https://github.com/anchore/syft/blob/v0.70.0/syft/pkg/type.go).
See [the `pkg` package](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg) for examples of the different metadata types that are supported today.
These are plugged into the `MetadataType` and `Metadata` fields in the above struct. `MetadataType` informs which type is being used. `Metadata` is an interface converted to that type.
Finally, here is an example of where the package construction is done within the apk cataloger:
- [Calling the APK package constructor from the parser function](https://github.com/anchore/syft/blob/v0.70.0/syft/pkg/cataloger/apkdb/parse_apk_db.go#L106)
- [The APK package constructor itself](https://github.com/anchore/syft/tree/v0.70.0/syft/pkg/cataloger/apkdb/package.go#L12-L27)
Interested in building a new cataloger? Checkout the [list of issues with the `new-cataloger` label](https://github.com/anchore/syft/issues?q=is%3Aopen+is%3Aissue+label%3Anew-cataloger+no%3Aassignee)!
If you have questions about implementing a cataloger feel free to file an issue or reach out to us [on discourse](https://anchore.com/discourse)!
#### Searching for files
All catalogers are provided an instance of the [`file.Resolver`](https://github.com/anchore/syft/blob/v0.70.0/syft/source/file_resolver.go#L8) to interface with the image and search for files. The implementations for these
abstractions leverage [`stereoscope`](https://github.com/anchore/stereoscope) in order to perform searching. Here is a
rough outline how that works:
1. a stereoscope `file.Index` is searched based on the input given (a path, glob, or MIME type). The index is relatively fast to search, but requires results to be filtered down to the files that exist in the specific layer(s) of interest. This is done automatically by the `filetree.Searcher` abstraction. This abstraction will fallback to searching directly against the raw `filetree.FileTree` if the index does not contain the file(s) of interest. Note: the `filetree.Searcher` is used by the `file.Resolver` abstraction.
2. Once the set of files are returned from the `filetree.Searcher` the results are filtered down further to return the most unique file results. For example, you may have requested for files by a glob that returns multiple results. These results are filtered down to deduplicate by real files, so if a result contains two references to the same file, say one accessed via symlink and one accessed via the real path, then the real path reference is returned and the symlink reference is filtered out. If both were accessed by symlink then the first (by lexical order) is returned. This is done automatically by the `file.Resolver` abstraction.
3. By the time results reach the `pkg.Cataloger` you are guaranteed to have a set of unique files that exist in the layer(s) of interest (relative to what the resolver supports).
## Testing
### Levels of testing
## Levels of testing
- `unit`: The default level of test which is distributed throughout the repo are unit tests. Any `_test.go` file that
does not reside somewhere within the `/test` directory is a unit test. Other forms of testing should be organized in
the `/test` directory. These tests should focus on correctness of functionality in depth. % test coverage metrics
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
@@ -379,4 +166,14 @@ These flags are defined at the top of the test files that have tests that use th
Snapshot testing is only as good as the manual verification of the golden snapshot file saved to the repo! Be careful
and diligent when updating these files.
## Architecture
TODO: outline:
- analysis creates a static SBOM which can be encoded and decoded.
- format objects, should strive to not add or enrich data in encoding that could otherwise be done during analysis
- pkg.Catalogers
- file catalogers
- source.Source
- file.Resolvers
- logger abstraction
- events / bus abstraction
+4 -1
View File
@@ -1,4 +1,6 @@
FROM gcr.io/distroless/static-debian11:debug@sha256:a0a404776dec98be120089ae42bbdfbe48c177921d856937d124d48eb8c0b951 AS build
FROM alpine:latest AS build
RUN apk --no-cache add ca-certificates
FROM scratch
# needed for version check HTTPS request
@@ -25,5 +27,6 @@ LABEL org.opencontainers.image.licenses="Apache-2.0"
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
LABEL io.artifacthub.package.license="Apache-2.0"
ENTRYPOINT ["/syft"]
-25
View File
@@ -1,25 +0,0 @@
FROM gcr.io/distroless/static-debian11:debug@sha256:a0a404776dec98be120089ae42bbdfbe48c177921d856937d124d48eb8c0b951
# create the /tmp dir, which is needed for image content cache
WORKDIR /tmp
COPY syft /
ARG BUILD_DATE
ARG BUILD_VERSION
ARG VCS_REF
ARG VCS_URL
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.title="syft"
LABEL org.opencontainers.image.description="CLI tool and library for generating a Software Bill of Materials from container images and filesystems"
LABEL org.opencontainers.image.source=$VCS_URL
LABEL org.opencontainers.image.revision=$VCS_REF
LABEL org.opencontainers.image.vendor="Anchore, Inc."
LABEL org.opencontainers.image.version=$BUILD_VERSION
LABEL org.opencontainers.image.licenses="Apache-2.0"
LABEL io.artifacthub.package.readme-url="https://raw.githubusercontent.com/anchore/syft/main/README.md"
LABEL io.artifacthub.package.logo-url="https://user-images.githubusercontent.com/5199289/136844524-1527b09f-c5cb-4aa9-be54-5aa92a6086c1.png"
LABEL io.artifacthub.package.license="Apache-2.0"
ENTRYPOINT ["/syft"]
+366 -33
View File
@@ -1,46 +1,379 @@
OWNER = anchore
PROJECT = syft
BIN = syft
TEMPDIR = ./.tmp
RESULTSDIR = test/results
COVER_REPORT = $(RESULTSDIR)/unit-coverage-details.txt
COVER_TOTAL = $(RESULTSDIR)/unit-coverage-summary.txt
LINTCMD = $(TEMPDIR)/golangci-lint run --tests=false --timeout=4m --config .golangci.yaml
RELEASE_CMD=$(TEMPDIR)/goreleaser release --rm-dist
SNAPSHOT_CMD=$(RELEASE_CMD) --skip-publish --snapshot
VERSION=$(shell git describe --dirty --always --tags)
COMPARE_TEST_IMAGE = centos:8.2.2004
COMPARE_DIR = ./test/compare
TOOL_DIR = .tool
BINNY = $(TOOL_DIR)/binny
TASK = $(TOOL_DIR)/task
# formatting variables
BOLD := $(shell tput -T linux bold)
PURPLE := $(shell tput -T linux setaf 5)
GREEN := $(shell tput -T linux setaf 2)
CYAN := $(shell tput -T linux setaf 6)
RED := $(shell tput -T linux setaf 1)
RESET := $(shell tput -T linux sgr0)
TITLE := $(BOLD)$(PURPLE)
SUCCESS := $(BOLD)$(GREEN)
.DEFAULT_GOAL := make-default
# the quality gate lower threshold for unit test total % coverage (by function statements)
COVERAGE_THRESHOLD := 62
## Bootstrapping targets #################################
# CI cache busting values; change these if you want CI to not use previous stored cache
INTEGRATION_CACHE_BUSTER="894d8ca"
CLI_CACHE_BUSTER="e5cdfd8"
BOOTSTRAP_CACHE="c7afb99ad"
# note: we need to assume that binny and task have not already been installed
$(BINNY):
@mkdir -p $(TOOL_DIR)
@curl -sSfL https://raw.githubusercontent.com/$(OWNER)/binny/main/install.sh | sh -s -- -b $(TOOL_DIR)
## Build variables
DISTDIR=./dist
SNAPSHOTDIR=./snapshot
OS=$(shell uname | tr '[:upper:]' '[:lower:]')
SNAPSHOT_BIN=$(shell realpath $(shell pwd)/$(SNAPSHOTDIR)/$(OS)-build_$(OS)_amd64/$(BIN))
# note: we need to assume that binny and task have not already been installed
.PHONY: task
$(TASK) task: $(BINNY)
@$(BINNY) install task -q
## Variable assertions
.PHONY: ci-bootstrap-go
ci-bootstrap-go:
ifndef TEMPDIR
$(error TEMPDIR is not set)
endif
ifndef RESULTSDIR
$(error RESULTSDIR is not set)
endif
ifndef COMPARE_DIR
$(error COMPARE_DIR is not set)
endif
ifndef DISTDIR
$(error DISTDIR is not set)
endif
ifndef SNAPSHOTDIR
$(error SNAPSHOTDIR is not set)
endif
ifndef VERSION
$(error VERSION is not set)
endif
ifndef REF_NAME
REF_NAME = $(VERSION)
endif
define title
@printf '$(TITLE)$(1)$(RESET)\n'
endef
define safe_rm_rf
bash -c 'test -z "$(1)" && false || rm -rf $(1)'
endef
define safe_rm_rf_children
bash -c 'test -z "$(1)" && false || rm -rf $(1)/*'
endef
## Tasks
.PHONY: all
all: clean static-analysis test ## Run all linux-based checks (linting, license check, unit, integration, and linux compare tests)
@printf '$(SUCCESS)All checks pass!$(RESET)\n'
.PHONY: test
test: unit validate-cyclonedx-schema integration benchmark compare-linux cli ## Run all tests (currently unit, integration, linux compare, and cli tests)
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(BOLD)$(CYAN)%-25s$(RESET)%s\n", $$1, $$2}'
.PHONY: ci-bootstrap
ci-bootstrap:
DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y bc jq libxml2-utils
.PHONY:
ci-bootstrap-mac:
github_changelog_generator --version || sudo gem install github_changelog_generator
$(RESULTSDIR):
mkdir -p $(RESULTSDIR)
$(TEMPDIR):
mkdir -p $(TEMPDIR)
.PHONY: bootstrap-tools
bootstrap-tools: $(TEMPDIR)
GO111MODULE=off GOBIN=$(shell realpath $(TEMPDIR)) go get -u golang.org/x/perf/cmd/benchstat
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(TEMPDIR)/ v1.42.1
curl -sSfL https://raw.githubusercontent.com/wagoodman/go-bouncer/master/bouncer.sh | sh -s -- -b $(TEMPDIR)/ v0.3.0
curl -sSfL https://raw.githubusercontent.com/anchore/chronicle/main/install.sh | sh -s -- -b $(TEMPDIR)/ v0.3.0
.github/scripts/goreleaser-install.sh -d -b $(TEMPDIR)/ v1.4.1
GOBIN="$(shell realpath $(TEMPDIR))" go install github.com/neilpa/yajsv@v1.4.0
GOBIN="$(shell realpath $(TEMPDIR))" go install github.com/sigstore/cosign/cmd/cosign@v1.5.1
.PHONY: bootstrap-go
bootstrap-go:
go mod download
# this is a bootstrapping catch-all, where if the target doesn't exist, we'll ensure the tools are installed and then try again
%:
@make --silent $(TASK)
@$(TASK) $@
.PHONY: bootstrap
bootstrap: $(RESULTSDIR) bootstrap-go bootstrap-tools ## Download and install all go dependencies (+ prep tooling in the ./tmp dir)
$(call title,Bootstrapping dependencies)
## Shim targets #################################
.PHONY: static-analysis
static-analysis: lint check-go-mod-tidy check-licenses
.PHONY: make-default
make-default: $(TASK)
@# run the default task in the taskfile
@$(TASK)
.PHONY: lint
lint: ## Run gofmt + golangci lint checks
$(call title,Running linters)
# ensure there are no go fmt differences
@printf "files with gofmt issues: [$(shell gofmt -l -s .)]\n"
@test -z "$(shell gofmt -l -s .)"
# for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool
TASKS := $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "test -f $(TASK) && $(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','")
# run all golangci-lint rules
$(LINTCMD)
.PHONY: $(TASKS)
$(TASKS): $(TASK)
@$(TASK) $@
# go tooling does not play well with certain filename characters, ensure the common cases don't result in future "go get" failures
$(eval MALFORMED_FILENAMES := $(shell find . | grep -e ':'))
@bash -c "[[ '$(MALFORMED_FILENAMES)' == '' ]] || (printf '\nfound unsupported filename characters:\n$(MALFORMED_FILENAMES)\n\n' && false)"
help: $(TASK)
@$(TASK) -l
.PHONY: lint-fix
lint-fix: ## Auto-format all source code + run golangci lint fixers
$(call title,Running lint fixers)
gofmt -w -s .
$(LINTCMD) --fix
go mod tidy
.PHONY: check-licenses
check-licenses: ## Ensure transitive dependencies are compliant with the current license policy
$(TEMPDIR)/bouncer check
check-go-mod-tidy:
@ .github/scripts/go-mod-tidy-check.sh && echo "go.mod and go.sum are tidy!"
.PHONY: validate-cyclonedx-schema
validate-cyclonedx-schema:
cd schema/cyclonedx && make
.PHONY: unit
unit: $(RESULTSDIR) fixtures ## Run unit tests (with coverage)
$(call title,Running unit tests)
go test -coverprofile $(COVER_REPORT) $(shell go list ./... | grep -v anchore/syft/test)
@go tool cover -func $(COVER_REPORT) | grep total | awk '{print substr($$3, 1, length($$3)-1)}' > $(COVER_TOTAL)
@echo "Coverage: $$(cat $(COVER_TOTAL))"
@if [ $$(echo "$$(cat $(COVER_TOTAL)) >= $(COVERAGE_THRESHOLD)" | bc -l) -ne 1 ]; then echo "$(RED)$(BOLD)Failed coverage quality gate (> $(COVERAGE_THRESHOLD)%)$(RESET)" && false; fi
.PHONY: benchmark
benchmark: $(RESULTSDIR) ## Run benchmark tests and compare against the baseline (if available)
$(call title,Running benchmark tests)
go test -p 1 -run=^Benchmark -bench=. -count=5 -benchmem ./... | tee $(RESULTSDIR)/benchmark-$(REF_NAME).txt
(test -s $(RESULTSDIR)/benchmark-main.txt && \
$(TEMPDIR)/benchstat $(RESULTSDIR)/benchmark-main.txt $(RESULTSDIR)/benchmark-$(REF_NAME).txt || \
$(TEMPDIR)/benchstat $(RESULTSDIR)/benchmark-$(REF_NAME).txt) \
| tee $(RESULTSDIR)/benchstat.txt
.PHONY: show-benchstat
show-benchstat:
@cat $(RESULTSDIR)/benchstat.txt
# note: this is used by CI to determine if the install test fixture cache (docker image tars) should be busted
install-fingerprint:
cd test/install && \
make cache.fingerprint
install-test: $(SNAPSHOTDIR)
cd test/install && \
make
install-test-cache-save: $(SNAPSHOTDIR)
cd test/install && \
make save
install-test-cache-load: $(SNAPSHOTDIR)
cd test/install && \
make load
install-test-ci-mac: $(SNAPSHOTDIR)
cd test/install && \
make ci-test-mac
.PHONY: integration
integration: ## Run integration tests
$(call title,Running integration tests)
go test -v ./test/integration
# note: this is used by CI to determine if the integration test fixture cache (docker image tars) should be busted
integration-fingerprint:
$(call title,Integration test fixture fingerprint)
find test/integration/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | tee /dev/stderr | md5sum | tee test/integration/test-fixtures/cache.fingerprint && echo "$(INTEGRATION_CACHE_BUSTER)" >> test/integration/test-fixtures/cache.fingerprint
.PHONY: java-packages-fingerprint
java-packages-fingerprint:
$(call title,Java test fixture fingerprint)
cd syft/pkg/cataloger/java/test-fixtures/java-builds && \
make packages.fingerprint
.PHONY: fixtures
fixtures:
$(call title,Generating test fixtures)
cd syft/pkg/cataloger/java/test-fixtures/java-builds && make
.PHONY: generate-json-schema
generate-json-schema: ## Generate a new json schema
cd schema/json && go run generate.go
.PHONY: generate-license-list
generate-license-list: ## Generate an updated spdx license list
go generate ./internal/spdxlicense/...
gofmt -s -w ./internal/spdxlicense
.PHONY: build
build: $(SNAPSHOTDIR) ## Build release snapshot binaries and packages
$(SNAPSHOTDIR): ## Build snapshot release binaries and packages
$(call title,Building snapshot artifacts)
# create a config with the dist dir overridden
echo "dist: $(SNAPSHOTDIR)" > $(TEMPDIR)/goreleaser.yaml
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
# build release snapshots
bash -c "SKIP_SIGNING=true $(SNAPSHOT_CMD) --skip-sign --config $(TEMPDIR)/goreleaser.yaml"
.PHONY: snapshot-with-signing
snapshot-with-signing: ## Build snapshot release binaries and packages (with dummy signing)
$(call title,Building snapshot artifacts (+ signing))
# create a config with the dist dir overridden
echo "dist: $(SNAPSHOTDIR)" > $(TEMPDIR)/goreleaser.yaml
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
rm -f .github/scripts/apple-signing/log/*.txt
# build release snapshots
bash -c "$(SNAPSHOT_CMD) --config $(TEMPDIR)/goreleaser.yaml || (cat .github/scripts/apple-signing/log/*.txt && false)"
# remove the keychain with the trusted self-signed cert automatically
.github/scripts/apple-signing/cleanup.sh
# note: we cannot clean the snapshot directory since the pipeline builds the snapshot separately
.PHONY: compare-mac
compare-mac: $(RESULTSDIR) $(SNAPSHOTDIR) ## Run compare tests on build snapshot binaries and packages (Mac)
$(call title,Running compare test: Run on Mac)
$(COMPARE_DIR)/mac.sh \
$(SNAPSHOTDIR) \
$(COMPARE_DIR) \
$(COMPARE_TEST_IMAGE) \
$(RESULTSDIR)
# note: we cannot clean the snapshot directory since the pipeline builds the snapshot separately
.PHONY: compare-linux
compare-linux: compare-test-deb-package-install compare-test-rpm-package-install ## Run compare tests on build snapshot binaries and packages (Linux)
.PHONY: compare-test-deb-package-install
compare-test-deb-package-install: $(RESULTSDIR) $(SNAPSHOTDIR)
$(call title,Running compare test: DEB install)
$(COMPARE_DIR)/deb.sh \
$(SNAPSHOTDIR) \
$(COMPARE_DIR) \
$(COMPARE_TEST_IMAGE) \
$(RESULTSDIR)
.PHONY: compare-test-rpm-package-install
compare-test-rpm-package-install: $(RESULTSDIR) $(SNAPSHOTDIR)
$(call title,Running compare test: RPM install)
$(COMPARE_DIR)/rpm.sh \
$(SNAPSHOTDIR) \
$(COMPARE_DIR) \
$(COMPARE_TEST_IMAGE) \
$(RESULTSDIR)
# note: this is used by CI to determine if the integration test fixture cache (docker image tars) should be busted
cli-fingerprint:
$(call title,CLI test fixture fingerprint)
find test/cli/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | md5sum | tee test/cli/test-fixtures/cache.fingerprint && echo "$(CLI_CACHE_BUSTER)" >> test/cli/test-fixtures/cache.fingerprint
.PHONY: cli
cli: $(SNAPSHOTDIR) ## Run CLI tests
chmod 755 "$(SNAPSHOT_BIN)"
$(SNAPSHOT_BIN) version
SYFT_BINARY_LOCATION='$(SNAPSHOT_BIN)' \
go test -count=1 -v ./test/cli
.PHONY: changelog
changelog: clean-changelog CHANGELOG.md
@docker run -it --rm \
-v $(shell pwd)/CHANGELOG.md:/CHANGELOG.md \
rawkode/mdv \
-t 748.5989 \
/CHANGELOG.md
CHANGELOG.md:
$(TEMPDIR)/chronicle -vv > CHANGELOG.md
.PHONY: release
release: clean-dist CHANGELOG.md ## Build and publish final binaries and packages. Intended to be run only on macOS.
$(call title,Publishing release artifacts)
# create a config with the dist dir overridden
echo "dist: $(DISTDIR)" > $(TEMPDIR)/goreleaser.yaml
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
rm -f .github/scripts/apple-signing/log/*.txt
# note: notarization cannot be done in parallel, thus --parallelism 1
bash -c "\
$(RELEASE_CMD) \
--config $(TEMPDIR)/goreleaser.yaml \
--parallelism 1 \
--release-notes <(cat CHANGELOG.md)\
|| (cat .github/scripts/apple-signing/log/*.txt && false)"
cat .github/scripts/apple-signing/log/*.txt
# TODO: turn this into a post-release hook
# upload the version file that supports the application version update check (excluding pre-releases)
.github/scripts/update-version-file.sh "$(DISTDIR)" "$(VERSION)"
.PHONY: clean
clean: clean-dist clean-snapshot clean-test-image-cache ## Remove previous builds, result reports, and test cache
$(call safe_rm_rf_children,$(RESULTSDIR))
.PHONY: clean-snapshot
clean-snapshot:
$(call safe_rm_rf,$(SNAPSHOTDIR))
rm -f $(TEMPDIR)/goreleaser.yaml
.PHONY: clean-dist
clean-dist: clean-changelog
$(call safe_rm_rf,$(DISTDIR))
rm -f $(TEMPDIR)/goreleaser.yaml
.PHONY: clean-changelog
clean-changelog:
rm -f CHANGELOG.md
clean-test-image-cache: clean-test-image-tar-cache clean-test-image-docker-cache
.PHONY: clear-test-image-tar-cache
clean-test-image-tar-cache: ## Delete all test cache (built docker image tars)
find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" -delete
.PHONY: clear-test-image-docker-cache
clean-test-image-docker-cache: ## Purge all test docker images
docker images --format '{{.ID}} {{.Repository}}' | grep stereoscope-fixture- | awk '{print $$1}' | uniq | xargs docker rmi --force
.PHONY: show-test-image-cache
show-test-image-cache: ## Show all docker and image tar cache
$(call title,Docker daemon cache)
@docker images --format '{{.ID}} {{.Repository}}:{{.Tag}}' | grep stereoscope-fixture- | sort
$(call title,Tar cache)
@find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" | sort
.PHONY: show-test-snapshots
show-test-snapshots: ## Show all test snapshots
$(call title,Test snapshots)
@find . -type f -wholename "**/test-fixtures/snapshot/*" | sort
+433 -122
View File
@@ -2,77 +2,66 @@
<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 tool 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/discourse" target="_blank"><img alt="Join our Discourse" src="https://img.shields.io/badge/Discourse-Join-blue?logo=discourse"/></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!
![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
- Linux distribution identification
- Works seamlessly with [Grype](https://github.com/anchore/grype) (a fast, modern vulnerability scanner)
- Able to create signed SBOM attestations using the [in-toto specification](https://github.com/in-toto/attestation/blob/main/spec/README.md)
- Convert between SBOM formats, such as CycloneDX, SPDX, and Syft's own format.
- Catalog container images and filesystems to discover packages and libraries.
- Generate in-toto attestations where an SBOM is included as the payload.
- Supports packages and libraries from various ecosystems (APK, DEB, RPM, Ruby Bundles, Python Wheel/Egg/requirements.txt, JavaScript NPM/Yarn, Java JAR/EAR/WAR/PAR/SAR, Jenkins plugins JPI/HPI, Go modules, PHP Composer)
- Linux distribution identification (supports Alpine, BusyBox, CentOS/RedHat, Debian/Ubuntu flavored distributions)
- Supports Docker and OCI image formats
- Direct support for [Grype](https://github.com/anchore/grype), a fast and powerful vulnerability matcher.
If you encounter an issue, please [let us know using the issue tracker](https://github.com/anchore/syft/issues).
## Installation
Syft binaries are provided for Linux, macOS and Windows.
**Note**: Currently, Syft is built only for Linux, macOS and Windows.
### Recommended
> ```bash
> curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
> ```
```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)
... or, you can specify a release version and destination directory for the installation:
```
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b <DESTINATION_DIR> <RELEASE_VERSION>
```
### Homebrew
```bash
brew tap anchore/syft
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`.
**Note**: nix packaging of Syft is [community maintained](https://github.com/NixOS/nixpkgs/blob/master/pkgs/tools/admin/syft/default.nix)
Also syft is currently only in the [unstable channel](https://nixos.wiki/wiki/Nix_channels#The_official_channels) awaiting the `22.05` release
```bash
nix-env -i syft
```
... or, just try it out in an ephemeral nix shell:
... or, just try it out in an ephemeral nix shell
```bash
nix-shell -p syft
@@ -80,101 +69,423 @@ nix-shell -p syft
## Getting started
### SBOM
To generate an SBOM for a container image:
```bash
#### SBOM
To generate an SBOM for an OCI 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`:
```bash
syft <image> --scope all-layers
**Note**: This is equivalent to specifying the `packages` subcommand:
```
syft packages <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 packages <image> --scope all-layers
```
#### SBOM Attestation
To generate an attested SBOM for an OCI image as the predicate of an in-toto attestation
```
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 SBOM as the predicate, the payload type is `application/vnd.in-toto+json`, and the signatures array is populated
with the contents needed for public key verification. For details on workflows using this command see [here](#adding-an-sbom-to-an-image-as-an-attestation-using-syft).
### 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 packages path/to/image.tar
# catalog a directory
syft packages 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
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)
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)
```
### 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:
The output format for Syft is configurable as well using the
`-o` (or `--output`) option:
```
syft <image> -o <format>
syft packages <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.
- `json`: Use this to get as much information out of Syft as possible!
- `text`: A row-oriented, human-and-machine-friendly output.
- `cyclonedx`: A XML report conforming to the [CycloneDX 1.3 specification](https://cyclonedx.org/specification/overview/).
- `cyclonedx-json`: A JSON report conforming to the [CycloneDX 1.3 specification](https://cyclonedx.org/specification/overview/).
- `spdx`: A tag-value formatted report conforming to the [SPDX 2.2 specification](https://spdx.github.io/spdx-spec/).
- `spdx-json`: A JSON report conforming to the [SPDX 2.2 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.2/schemas/spdx-schema.json).
- `table`: A columnar summary (default).
Note that flags using the @<version> can be used for earlier versions of each specification as well.
#### Multiple outputs
### Supported Ecosystems
Syft can also output _multiple_ files in differing formats by appending
`=<file>` to the option, for example to output Syft JSON and SPDX JSON:
- Alpine (apk)
- C (conan)
- C++ (conan)
- Dart (pubs)
- Debian (dpkg)
- Dotnet (deps.json)
- Objective-C (cocoapods)
- Elixir (mix)
- Erlang (rebar3)
- Go (go.mod, Go binaries)
- Haskell (cabal, stack)
- Java (jar, ear, war, par, sar, nar, native-image)
- JavaScript (npm, yarn)
- Jenkins Plugins (jpi, hpi)
- Linux kernel archives (vmlinz)
- Linux kernel modules (ko)
- Nix (outputs in /nix/store)
- PHP (composer)
- Python (wheel, egg, poetry, requirements.txt)
- Red Hat (rpm)
- Ruby (gem)
- Rust (cargo.lock)
- Swift (cocoapods, swift-package-manager)
- Wordpress plugins
```shell
syft packages <image> -o json=sbom.syft.json -o spdx-json=sbom.spdx.json
```
## Documentation
## Private Registry Authentication
Our [wiki](https://github.com/anchore/syft/wiki) contains further details on the following topics:
### 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).
* [Supported Sources](https://github.com/anchore/syft/wiki/supported-sources)
* [File Selection](https://github.com/anchore/syft/wiki/file-selection)
* [Excluding file paths](https://github.com/anchore/syft/wiki/excluding-file-paths)
* [Output formats](https://github.com/anchore/syft/wiki/output-formats)
* [Package Cataloger Selection](https://github.com/anchore/syft/wiki/package-cataloger-selection)
* [Concepts](https://github.com/anchore/syft/wiki/package-cataloger-selection#concepts)
* [Examples](https://github.com/anchore/syft/wiki/package-cataloger-selection#examples)
* [Using templates](https://github.com/anchore/syft/wiki/using-templates)
* [Multiple outputs](https://github.com/anchore/syft/wiki/multiple-outputs)
* [Private Registry Authentication](https://github.com/anchore/syft/wiki/private-registry-authentication)
* [Local Docker Credentials](https://github.com/anchore/syft/wiki/private-registry-authentication#local-docker)
* [Docker Credentials in Kubernetes](https://github.com/anchore/syft/wiki/private-registry-authentication#docker-credentials-in-kubernetes)
* [Attestation (experimental)](https://github.com/anchore/syft/wiki/attestation)
* [Keyless Support](https://github.com/anchore/syft/wiki/attestation#keyless-support)
* [Local private key support](https://github.com/anchore/syft/wiki/attestation#local-private-key-support)
* [Adding an SBOM to an image as an attestation using Syft](https://github.com/anchore/syft/wiki/attestation#adding-an-sbom-to-an-image-as-an-attestation-using-syft)
* [Configuration](https://github.com/anchore/syft/wiki/configuration)
## Contributing
An example `config.json` looks something like this:
```
// config.json
{
"auths": {
"registry.example.com": {
"username": "AzureDiamond",
"password": "hunter2"
}
}
}
```
Check out our [contributing](/CONTRIBUTING.md) guide and [developer](/DEVELOPING.md) docs.
You can run the following command as an example. It details the mount/environment configuration a container needs to access a private registry:
## Syft Team Meetings
`docker run -v ./config.json:/config/config.json -e "DOCKER_CONFIG=/config" anchore/syft:latest <private_image>`
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!
### Docker Credentials in Kubernetes
The below section shows a simple workflow on how 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.
```
# 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.
```
# 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.
## 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: table, text, json, spdx, ...)
# same as -o, --output, and SYFT_OUTPUT env var
# to specify multiple output files in differing formats, use a list:
# output:
# - "json=<syft-json-output-file>"
# - "spdx-json=<spdx-json-output-file>"
output: "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
# a list of globs to exclude from scanning. same as --exclude ; for example:
# exclude:
# - "/etc/**"
# - "./out/**/*.json"
exclude:
# 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"
# cataloging file classifications is exposed through the power-user subcommand
file-classification:
cataloger:
# enable/disable cataloging of file classifications
# SYFT_FILE_CLASSIFICATION_CATALOGER_ENABLED env var
enabled: true
# the search space to look for file classifications (options: all-layers, squashed)
# SYFT_FILE_CLASSIFICATION_CATALOGER_SCOPE env var
scope: "squashed"
# cataloging file contents is exposed through the power-user subcommand
file-contents:
cataloger:
# enable/disable cataloging of secrets
# SYFT_FILE_CONTENTS_CATALOGER_ENABLED env var
enabled: true
# the search space to look for secrets (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: []
# cataloging file metadata is exposed through the power-user subcommand
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: "sha256", "md5", "sha1")
# SYFT_FILE_METADATA_DIGESTS env var
digests: ["sha256"]
# cataloging secrets is exposed through the power-user subcommand
secrets:
cataloger:
# enable/disable cataloging of secrets
# SYFT_SECRETS_CATALOGER_ENABLED env var
enabled: true
# the search space to look for secrets (options: all-layers, squashed)
# SYFT_SECRETS_CATALOGER_SCOPE env var
scope: "all-layers"
# show extracted secret values in the final JSON report
# SYFT_SECRETS_REVEAL_VALUES env var
reveal-values: false
# skip searching a file entirely if it is above the given size (default = 1MB; unit = bytes)
# SYFT_SECRETS_SKIP_FILES_ABOVE_SIZE env var
skip-files-above-size: 1048576
# name-regex pairs to consider when searching files for secrets. Note: the regex must match single line patterns
# but may also have OPTIONAL multiline capture groups. Regexes with a named capture group of "value" will
# use the entire regex to match, but the secret value will be assumed to be entirely contained within the
# "value" named capture group.
additional-patterns: {}
# names to exclude from the secrets search, valid values are: "aws-access-key", "aws-secret-key", "pem-private-key",
# "docker-config-auth", and "generic-api-key". Note: this does not consider any names introduced in the
# "secrets.additional-patterns" config option.
# SYFT_SECRETS_EXCLUDE_PATTERN_NAMES env var
exclude-pattern-names: []
# options when pulling directly from a registry via the "registry:" 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
# 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: ""
# - ... # note, more credentials can be provided via config file only
# 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: ""
# uploading package SBOM is exposed through the packages subcommand
anchore:
# (feature-preview) the Anchore Enterprise Host or URL to upload results to (supported on Enterprise 3.0+)
# same as -H ; SYFT_ANCHORE_HOST env var
host: ""
# (feature-preview) the path after the host to the Anchore External API (supported on Enterprise 3.0+)
# same as SYFT_ANCHORE_PATH env var
path: ""
# (feature-preview) the username to authenticate against Anchore Enterprise (supported on Enterprise 3.0+)
# same as -u ; SYFT_ANCHORE_USERNAME env var
username: ""
# (feature-preview) the password to authenticate against Anchore Enterprise (supported on Enterprise 3.0+)
# same as -p ; SYFT_ANCHORE_PASSWORD env var
password: ""
# (feature-preview) path to dockerfile to be uploaded with the syft results to Anchore Enterprise (supported on Enterprise 3.0+)
# same as -d ; SYFT_ANCHORE_DOCKERFILE env var
dockerfile: ""
```
### 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. The Anchore organization offers the vulnerability scanner
[grype](https://github.com/anchore/grype) as one such tool.
One of the foundational approaches to "trust" between tools is for producers to use the artifacts generated by syft as attestations to their images.
The DSSE output of `syft attest` can be used with the [cosign](https://github.com/sigstore/cosign) tool to attach an attestation to an image.
#### Example attest
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 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 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.
+43 -51
View File
@@ -1,49 +1,14 @@
# Release
## Creating a release
This release process itself should be as automated as possible, and has only a few steps:
1. **Trigger a new release with `make release`**. At this point you'll see a preview
changelog in the terminal. If you're happy with the changelog, press `y` to continue, otherwise
you can abort and adjust the labels on the PRs and issues to be included in the release and
re-run the release trigger command.
1. A release admin must approve the release on the GitHub Actions release pipeline run page.
Once approved, the release pipeline will generate all assets and publish a GitHub Release.
1. If there is a release Milestone, close it.
Ideally releasing should be done often with small increments when possible. Unless a
breaking change is blocking the release, or no fixes/features have been merged, a good
target release cadence is between every 1 or 2 weeks.
## Retracting a release
If a release is found to be problematic, it can be retracted with the following steps:
- Deleting the GitHub Release
- Untag the docker images in the `ghcr.io` and `docker.io` registries
- Revert the brew formula in [`anchore/homebrew-syft`](https://github.com/anchore/homebrew-syft) to point to the previous release
- Add a new `retract` entry in the go.mod for the versioned release
**Note**: do not delete release tags from the git repository since there may already be references to the release
in the go proxy, which will cause confusion when trying to reuse the tag later (the H1 hash will not match and there
will be a warning when users try to pull the new release).
## Background
# Release Process
A good release process has the following qualities:
1. There is a way to plan what should be in a release
1. There is a way to see what is actually in a release
1. Allow for different kinds of releases (major breaking vs backwards compatible enhancements vs patch updates)
1. Allow for different kinds of releases (major breaking vs backwards compatible
enhancements vs patch updates)
1. Specify a repeatable way to build and publish software artifacts
### Planning a release
## Planning a release
To indicate a set of features to be released together add each issue to an in-repository
Milestone named with major-minor version to be released (e.g. `v0.1`). It is OK for other
@@ -56,13 +21,15 @@ completed, would allow the release to be considered complete. A Milestone is onl
Not all releases need to be planned. For instance, patch releases for fixes should be
released when they are ready and when releasing would not interfere with another current
release (where some partial or breaking features have already been merged).
release (where some partial or breaking features have already been merged). Beta releases
and release candidates should not be independently planned from the non-beta release. That
is, the features for a `v0.1-beta.1` release should be planned under the `v0.1` Milestone,
not under a separate `v0.1-beta.1` Milestone.
Unless necessary, feature releases should be small and frequent, which may obviate the
need for regular release planning under a Milestone.
### What is in a release
## What is in a release
Milestones are specifically for planning a release, not necessarily tracking all changes
that a release may bring (and more importantly, not all releases are necessarily planned
@@ -75,9 +42,9 @@ Changelog line. Furthermore, there should be a place to see all released version
release date for each release, the semantic version of the release, and the set of changes
for each release.
**This project auto-generates the Changelog contents for each current release and posts the
generated contents to the GitHub Release page**. Leveraging the GitHub Releases feature
allows GitHub to manage the Changelog on each release outside of the git source tree while
This project auto-generates the Changelog contents for each current release and posts the
generated contents to the GitHub Release page. Leveraging the GitHub Releases feature
allows GitHub to manage the Changelog on each release outside of the git repository while
still being hosted with the released assets.
The Changelog is generated from the metadata from in-repository issues and PRs, using
@@ -93,20 +60,45 @@ The above suggestions imply that we should:
- The appropriate label is applied to PRs and/or issues to drive specific change type
sections (deprecated, breaking, security, bug, etc)
**With this approach as we cultivate good organization of PRs and issues we automatically
get an equally good Changelog.**
With this approach as we cultivate good organization of PRs and issues we automatically
get an equally good Changelog.
### Major, minor, and patch releases
## Major, minor, and patch releases
The latest version of the tool is the only supported version, which implies that multiple
parallel release branches will not be a regular process (if ever). Multiple releases can
be planned in parallel, however, only one can be actively developed at a time. That is, if
PRs attached to a release Milestone have been merged into the main branch, that release is
now the "next" release. **This implies that the source of truth for release lies with the
git log and Changelog, not with the release Milestones** (which are purely for planning and
now the "next" release. This implies that the source of truth for release lies with the
git log and Changelog, not with the release Milestones (which are purely for planning and
tracking).
Semantic versioning should be used to indicate breaking changes, new features, and fixes.
The exception to this is `< 1.0`, where the major version is not bumped for breaking changes,
instead the minor version indicates both new features and breaking changes.
## Cutting a release
Ideally releasing should be done often with small increments when possible. Unless a
breaking change is blocking the release, or no fixes/features have been merged, a good
target release cadence is between every 2 or 4 weeks.
This release process itself should be as automated as possible, and have only a few steps:
1. Tag the main branch with a full semantic-version, prefixed with a `v`. If there is a
milestone with a partial version, the full version should be used for the git tag (e.g.
with a Milestone of `v0.1` the tag should be `v0.1.0`). You can determine the changes going
into a release by running `make changelog-unreleased`. Use this change list to determine the
release increment. After determining the release increment (major, minor, patch), create the tag.
Given the above example the command to create the tag would be `git tag v0.1.0`.
1. Push the tag. Given the above example the command to push the tag would be `git push origin v0.1.0`.
1. A release admin must approve the release on the GitHub Actions release pipeline run page.
Once approved, the release pipeline will generate all assets and draft a GitHub Release.
1. Navigate to the GitHub Release draft page to review the final changelog and publish the
release. Once published, a release-follow-up pipeline will publish derivative artifacts
(docker image to DockerHub, brew formula to the external homebrew git repo, etc).
1. If there is a release Milestone, close it.
-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.
-655
View File
@@ -1,655 +0,0 @@
version: "3"
vars:
OWNER: anchore
PROJECT: syft
CACHE_IMAGE: ghcr.io/{{ .OWNER }}/{{ .PROJECT }}/test-fixture-cache:latest
# static file dirs
TOOL_DIR: .tool
TMP_DIR: .tmp
ORAS_CACHE: "{{ .TMP_DIR }}/oras-cache"
CACHE_PATHS_FILE: "{{ .TMP_DIR }}/cache_paths.json"
LAST_CACHE_PULL_FILE: "{{ .TMP_DIR }}/last_cache_paths.json"
# TOOLS
ORAS: "{{ .TOOL_DIR }}/oras"
YQ: "{{ .TOOL_DIR }}/yq"
TASK: "{{ .TOOL_DIR }}/task"
# used for changelog generation
CHANGELOG: CHANGELOG.md
NEXT_VERSION: VERSION
# used for snapshot builds
OS:
sh: uname -s | tr '[:upper:]' '[:lower:]'
ARCH:
sh: |
[ "$(uname -m)" = "x86_64" ] && echo "amd64_v1" || echo $(uname -m)
PROJECT_ROOT:
sh: echo $PWD
# note: the snapshot dir must be a relative path starting with ./
SNAPSHOT_DIR: ./snapshot
SNAPSHOT_BIN: "{{ .PROJECT_ROOT }}/{{ .SNAPSHOT_DIR }}/{{ .OS }}-build_{{ .OS }}_{{ .ARCH }}/{{ .PROJECT }}"
SNAPSHOT_CMD: "{{ .TOOL_DIR }}/goreleaser release --config {{ .TMP_DIR }}/goreleaser.yaml --clean --snapshot --skip=publish --skip=sign"
BUILD_CMD: "{{ .TOOL_DIR }}/goreleaser build --config {{ .TMP_DIR }}/goreleaser.yaml --clean --snapshot --single-target"
RELEASE_CMD: "{{ .TOOL_DIR }}/goreleaser release --clean --release-notes {{ .CHANGELOG }}"
VERSION:
sh: git describe --dirty --always --tags
# used for install and acceptance testing
COMPARE_DIR: ./test/compare
COMPARE_TEST_IMAGE: centos:8.2.2004
env:
GNUMAKEFLAGS: '--no-print-directory'
tasks:
## High-level tasks #################################
default:
desc: Run all validation tasks
aliases:
- pr-validations
- validations
cmds:
- task: static-analysis
- task: test
- task: install-test
static-analysis:
desc: Run all static analysis tasks
cmds:
- task: check-go-mod-tidy
- task: check-licenses
- task: lint
- task: check-json-schema-drift
- task: check-binary-fixture-size
test:
desc: Run all levels of test
cmds:
- task: unit
- task: integration
- task: validate-cyclonedx-schema
- task: benchmark
- task: test-utils
- task: cli
- task: check-docker-cache
## Bootstrap tasks #################################
binny:
internal: true
# desc: Get the binny tool
generates:
- "{{ .TOOL_DIR }}/binny"
status:
- "test -f {{ .TOOL_DIR }}/binny"
cmd: "curl -sSfL https://raw.githubusercontent.com/anchore/binny/main/install.sh | sh -s -- -b .tool"
silent: true
tools:
desc: Install all tools needed for CI and local development
deps: [binny]
aliases:
- bootstrap
generates:
- ".binny.yaml"
- "{{ .TOOL_DIR }}/*"
status:
- "{{ .TOOL_DIR }}/binny check -v"
cmd: "{{ .TOOL_DIR }}/binny install -v"
silent: true
update-tools:
desc: Update pinned versions of all tools to their latest available versions
deps: [binny]
generates:
- ".binny.yaml"
- "{{ .TOOL_DIR }}/*"
cmd: "{{ .TOOL_DIR }}/binny update -v"
silent: true
list-tools:
desc: List all tools needed for CI and local development
deps: [binny]
cmd: "{{ .TOOL_DIR }}/binny list"
silent: true
list-tool-updates:
desc: List all tools that are not up to date relative to the binny config
deps: [binny]
cmd: "{{ .TOOL_DIR }}/binny list --updates"
silent: true
tmpdir:
silent: true
generates:
- "{{ .TMP_DIR }}"
cmd: "mkdir -p {{ .TMP_DIR }}"
## Static analysis tasks #################################
format:
desc: Auto-format all source code
deps: [tools]
cmds:
- gofmt -w -s .
- "{{ .TOOL_DIR }}/gosimports -local github.com/anchore -w ."
- go mod tidy
lint-fix:
desc: Auto-format all source code + run golangci lint fixers
deps: [tools]
cmds:
- task: format
- "{{ .TOOL_DIR }}/golangci-lint run --tests=false --fix"
lint:
desc: Run gofmt + golangci lint checks
vars:
BAD_FMT_FILES:
sh: gofmt -l -s .
BAD_FILE_NAMES:
sh: "find . | grep -e ':' || true"
deps: [tools]
cmds:
# ensure there are no go fmt differences
- cmd: 'test -z "{{ .BAD_FMT_FILES }}" || (echo "files with gofmt issues: [{{ .BAD_FMT_FILES }}]"; exit 1)'
silent: true
# ensure there are no files with ":" in it (a known back case in the go ecosystem)
- cmd: 'test -z "{{ .BAD_FILE_NAMES }}" || (echo "files with bad names: [{{ .BAD_FILE_NAMES }}]"; exit 1)'
silent: true
# run linting
- "{{ .TOOL_DIR }}/golangci-lint run --tests=false"
check-licenses:
# desc: Ensure transitive dependencies are compliant with the current license policy
deps: [tools]
cmd: "{{ .TOOL_DIR }}/bouncer check ./..."
check-go-mod-tidy:
# desc: Ensure go.mod and go.sum are up to date
cmds:
- cmd: .github/scripts/go-mod-tidy-check.sh && echo "go.mod and go.sum are tidy!"
silent: true
check-json-schema-drift:
desc: Ensure there is no drift between the JSON schema and the code
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 #################################
unit:
desc: Run unit tests
deps:
- tmpdir
- fixtures
vars:
TEST_PKGS:
sh: "go list ./... | grep -v {{ .OWNER }}/{{ .PROJECT }}/test | grep -v {{ .OWNER }}/{{ .PROJECT }}/cmd/syft/internal/test | tr '\n' ' '"
# unit test coverage threshold (in % coverage)
COVERAGE_THRESHOLD: 62
cmds:
- "go test -coverprofile {{ .TMP_DIR }}/unit-coverage-details.txt {{ .TEST_PKGS }}"
- cmd: ".github/scripts/coverage.py {{ .COVERAGE_THRESHOLD }} {{ .TMP_DIR }}/unit-coverage-details.txt"
silent: true
integration:
desc: Run integration tests
cmds:
- "go test -v ./cmd/syft/internal/test/integration"
# exercise most of the CLI with the data race detector
- "go run -race cmd/syft/main.go alpine:latest"
validate-cyclonedx-schema:
desc: Run integration tests
cmds:
- "cd schema/cyclonedx && make"
cli:
desc: Run CLI tests
# note: we don't want to regenerate the snapshot unless we have to. In CI it's probable
# that the cache being restored with the correct binary will be rebuilt since the timestamps
# and local checksums will not line up.
deps: [tools, snapshot]
cmds:
- cmd: "echo 'testing binary: {{ .SNAPSHOT_BIN }}'"
silent: true
- cmd: "test -f {{ .SNAPSHOT_BIN }} || (find {{ .SNAPSHOT_DIR }} && echo '\nno snapshot found' && false)"
silent: true
- "go test -count=1 -timeout=15m -v ./test/cli"
env:
SYFT_BINARY_LOCATION: "{{ .SNAPSHOT_BIN }}"
test-utils:
desc: Run tests for pipeline utils
cmds:
- cmd: .github/scripts/labeler_test.py
## Benchmark test targets #################################
benchmark:
deps: [tmpdir]
generates:
- "{{ .TMP_DIR }}/benchmark-main.txt"
cmds:
- "go test -p 1 -run=^Benchmark -bench=. -count=7 -benchmem ./... | tee {{ .TMP_DIR }}/benchmark-{{ .VERSION }}.txt"
- |
bash -c "(test -s {{ .TMP_DIR }}/benchmark-main.txt && \
{{ .TOOL_DIR }}/benchstat {{ .TMP_DIR }}/benchmark-main.txt {{ .TMP_DIR }}/benchmark-{{ .VERSION }}.txt || \
{{ .TOOL_DIR }}/benchstat {{ .TMP_DIR }}/benchmark-{{ .VERSION }}.txt) \
| tee {{ .TMP_DIR }}/benchstat.txt"
show-benchstat:
deps: [benchmark, tmpdir]
cmds:
- cmd: "cat {{ .TMP_DIR }}/benchstat.txt"
silent: true
## Test-fixture-related targets #################################
fingerprints:
desc: Generate fingerprints for all non-docker test fixture
silent: true
# this will look for `test-fixtures/Makefile` and invoke the `fingerprint` target to calculate all cache input fingerprint files
generates:
- '**/test-fixtures/**/*.fingerprint'
- test/install/cache.fingerprint
cmds:
- |
BOLD='\033[1m'
YELLOW='\033[0;33m'
RESET='\033[0m'
echo -e "${YELLOW}creating fingerprint files for non-docker fixtures...${RESET}"
for dir in $(find . -type d -name 'test-fixtures'); do
if [ -f "$dir/Makefile" ]; then
# for debugging...
#echo -e "${YELLOW}• calculating fingerprints in $dir... ${RESET}"
(make -C "$dir" fingerprint)
fi
done
# for debugging...
# echo -e "generated all fixture fingerprints"
- .github/scripts/fingerprint_docker_fixtures.py
- |
# if DOWNLOAD_TEST_FIXTURE_CACHE is set to 'false', then we don't need to calculate the fingerprint for the cache
if [ "$DOWNLOAD_TEST_FIXTURE_CACHE" = "false" ]; then
exit 0
fi
.github/scripts/find_cache_paths.py {{ .CACHE_PATHS_FILE }} > /dev/null
refresh-fixtures:
desc: Clear and fetch all test fixture cache
aliases:
- fixtures
silent: true
deps:
- tools
cmds:
- |
BOLD='\033[1m'
PURPLE='\033[0;35m'
RESET='\033[0m'
# if DOWNLOAD_TEST_FIXTURE_CACHE is set to 'false', then skip the cache download and always build
if [ "$DOWNLOAD_TEST_FIXTURE_CACHE" = "false" ]; then
echo -e "${BOLD}${PURPLE}skipping cache download, rebuilding cache...${RESET}"
{{ .TASK }} build-fixtures
exit 0
fi
LATEST_FINGERPRINT=$(docker manifest inspect {{ .CACHE_IMAGE }} | {{ .YQ }} -r '.annotations.fingerprint')
echo "latest cache: $LATEST_FINGERPRINT"
if [ -f {{ .LAST_CACHE_PULL_FILE }} ]; then
LAST_PULL_FINGERPRINT=$(cat {{ .LAST_CACHE_PULL_FILE }} | {{ .YQ }} -r '.digest')
else
echo -e "${BOLD}${PURPLE}empty cache, downloading cache...${RESET}"
{{ .TASK }} download-test-fixture-cache
exit 0
fi
{{ .TASK }} fingerprints
WANT_FINGERPRINT=$(cat {{ .CACHE_PATHS_FILE }} | {{ .YQ }} -r '.digest')
echo "desired cache: $WANT_FINGERPRINT"
echo "last pulled cache: $LAST_PULL_FINGERPRINT"
# if we already have the latest cache, skip the refresh
if [ "$LAST_PULL_FINGERPRINT" = "$WANT_FINGERPRINT" ]; then
echo -e "${BOLD}${PURPLE}already have the latest cache (skipping cache download)${RESET}"
exit 0
fi
# at this point we only refresh the cache if we want the same cache that is currently available.
# we don't by default refresh the cache if the cache if it is simply different from what we have,
# because we may be working on a code change that doesn't require a cache refresh (but could trigger one,
# which would be annoying to deal with in a development workflow).
if [ "$LATEST_FINGERPRINT" = "$WANT_FINGERPRINT" ]; then
echo -e "${BOLD}${PURPLE}found newer cache! downloading cache...${RESET}"
{{ .TASK }} download-test-fixture-cache
else
echo -e "${BOLD}${PURPLE}found different cache, but isn't clear if it's newer (skipping cache download and manually building)${RESET}"
{{ .YQ }} eval '.paths[] | "\(.digest) \(.path)"' {{ .LAST_CACHE_PULL_FILE }} > .tmp/last_cache_lines
{{ .YQ }} eval '.paths[] | "\(.digest) \(.path)"' {{ .CACHE_PATHS_FILE }} > .tmp/cache_lines
diff .tmp/last_cache_lines .tmp/cache_lines || true
echo -e "${BOLD}${PURPLE}diff with more context...${RESET}"
diff -U10000 {{ .LAST_CACHE_PULL_FILE }} {{ .CACHE_PATHS_FILE }} || true
echo -e "${BOLD}${PURPLE}detected changes to input material, manually building fixtures...${RESET}"
{{ .TASK }} build-fixtures
fi
build-fixtures:
desc: Generate all non-docker test fixtures
silent: true
# this will look for `test-fixtures/Makefile` and invoke the `fixtures` target to generate any and all test fixtures
cmds:
- |
BOLD='\033[1m'
YELLOW='\033[0;33m'
RESET='\033[0m'
# Use a for loop with command substitution to avoid subshell issues
for dir in $(find . -type d -name 'test-fixtures'); do
if [ -f "$dir/Makefile" ]; then
echo -e "${YELLOW}${BOLD}generating fixtures in $dir${RESET}"
(make -C "$dir" fixtures)
fi
done
echo -e "${BOLD}generated all fixtures${RESET}"
download-test-fixture-cache:
desc: Download test fixture cache from ghcr.io
deps: [tools, clean-cache]
vars:
CACHE_DIGEST:
sh: docker manifest inspect {{ .CACHE_IMAGE }} | {{ .YQ }} -r '.annotations.fingerprint'
cmds:
- silent: true
cmd: |
# if oras cache is > 4 GB, delete it
if [ -d {{ .ORAS_CACHE }} ]; then
total_size=$(du -c {{ .ORAS_CACHE }} | grep total | awk '{print $1}')
if [ "$total_size" -gt 4194304 ]; then
echo 'deleting oras cache'
rm -rf {{ .ORAS_CACHE }}
fi
fi
- "ORAS_CACHE={{ .ORAS_CACHE }} {{ .ORAS }} pull {{ .CACHE_IMAGE }}"
- "cp {{ .CACHE_PATHS_FILE }} {{ .LAST_CACHE_PULL_FILE }}"
upload-test-fixture-cache:
desc: Upload the test fixture cache to ghcr.io
deps: [tools, fingerprints]
silent: true
cmd: |
set -eu
oras_command="{{ .ORAS }} push {{ .CACHE_IMAGE }}"
paths=$(cat {{ .CACHE_PATHS_FILE }} | {{ .YQ }} -r '.paths[].path')
for path in $paths; do
oras_command+=" $path"
done
oras_command+=" {{ .CACHE_PATHS_FILE }}"
oras_command+=" --annotation org.opencontainers.image.source=https://github.com/{{ .OWNER }}/{{ .PROJECT }}"
oras_command+=" --annotation fingerprint=$(cat {{ .CACHE_PATHS_FILE }} | {{ .YQ }} -r '.digest')"
echo "Executing: $oras_command"
eval $oras_command
show-test-image-cache:
silent: true
cmds:
- "echo 'Docker daemon cache:'"
- "docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}:{{`{{.Tag}}`}}' | grep stereoscope-fixture- | sort"
- "echo '\nTar cache:'"
- 'find . -type f -wholename "**/test-fixtures/cache/stereoscope-fixture-*.tar" | sort'
check-docker-cache:
desc: Ensure docker caches aren't using too much disk space
silent: true
cmd: |
total_size=$(find . | grep cache | grep tar | xargs du -c | grep total | awk '{print $1}')
find . | grep cache | grep tar | xargs du
echo "total $total_size KB"
if [ "$total_size" -gt 1048576 ]; then
echo 'docker cache is larger than 1GB'
exit 1
fi
## install.sh testing targets #################################
install-test:
cmds:
- "cd test/install && make"
install-test-cache-save:
cmds:
- "cd test/install && make save"
install-test-cache-load:
cmds:
- "cd test/install && make load"
install-test-ci-mac:
cmds:
- "cd test/install && make ci-test-mac"
generate-compare-file:
cmd: "go run ./cmd/syft {{ .COMPARE_TEST_IMAGE }} -o json > {{ .COMPARE_DIR }}/test-fixtures/acceptance-{{ .COMPARE_TEST_IMAGE }}.json"
compare-mac:
deps: [tmpdir]
cmd: |
{{ .COMPARE_DIR }}/mac.sh \
{{ .SNAPSHOT_DIR }} \
{{ .COMPARE_DIR }} \
{{ .COMPARE_TEST_IMAGE }} \
{{ .TMP_DIR }}
compare-linux:
cmds:
- task: compare-test-deb-package-install
- task: compare-test-rpm-package-install
compare-test-deb-package-install:
deps: [tmpdir]
cmd: |
{{ .COMPARE_DIR }}/deb.sh \
{{ .SNAPSHOT_DIR }} \
{{ .COMPARE_DIR }} \
{{ .COMPARE_TEST_IMAGE }} \
{{ .TMP_DIR }}
compare-test-rpm-package-install:
deps: [tmpdir]
cmd: |
{{ .COMPARE_DIR }}/rpm.sh \
{{ .SNAPSHOT_DIR }} \
{{ .COMPARE_DIR }} \
{{ .COMPARE_TEST_IMAGE }} \
{{ .TMP_DIR }}
## Code and data generation targets #################################
generate:
desc: Add data generation tasks
cmds:
- task: generate-json-schema
- task: generate-license-list
- task: generate-cpe-dictionary-index
generate-json-schema:
desc: Generate a new JSON schema
cmds:
- "cd syft/internal && go generate . && cd jsonschema && go run . && go fmt ../..."
generate-license-list:
desc: Generate an updated license processing code off of the latest available SPDX license list
cmds:
- "go generate ./internal/spdxlicense/..."
- "gofmt -s -w ./internal/spdxlicense"
generate-cpe-dictionary-index:
desc: Generate the CPE index based off of the latest available CPE dictionary
dir: "syft/pkg/cataloger/internal/cpegenerate/dictionary"
cmds:
- "go generate"
## Build-related targets #################################
build:
desc: Build the project
deps: [tools, tmpdir]
generates:
- "{{ .PROJECT }}"
cmds:
- silent: true
cmd: |
echo "dist: {{ .SNAPSHOT_DIR }}" > {{ .TMP_DIR }}/goreleaser.yaml
cat .goreleaser.yaml >> {{ .TMP_DIR }}/goreleaser.yaml
- "{{ .BUILD_CMD }}"
snapshot:
desc: Create a snapshot release
aliases:
- build
deps: [tools, tmpdir]
sources:
- cmd/**/*.go
- syft/**/*.go
- internal/**/*.go
method: checksum
generates:
- "{{ .SNAPSHOT_BIN }}"
cmds:
- silent: true
cmd: |
echo "dist: {{ .SNAPSHOT_DIR }}" > {{ .TMP_DIR }}/goreleaser.yaml
cat .goreleaser.yaml >> {{ .TMP_DIR }}/goreleaser.yaml
- "{{ .SNAPSHOT_CMD }}"
changelog:
desc: Generate a changelog
deps: [tools]
generates:
- "{{ .CHANGELOG }}"
- "{{ .NEXT_VERSION }}"
cmds:
- "{{ .TOOL_DIR }}/chronicle -vv -n --version-file {{ .NEXT_VERSION }} > {{ .CHANGELOG }}"
- "{{ .TOOL_DIR }}/glow {{ .CHANGELOG }}"
## Release targets #################################
release:
desc: Create a release
interactive: true
deps: [tools]
cmds:
- cmd: .github/scripts/trigger-release.sh
silent: true
## CI-only targets #################################
ci-check:
# desc: "[CI only] Are you in CI?"
cmds:
- cmd: .github/scripts/ci-check.sh
silent: true
ci-release:
# desc: "[CI only] Create a release"
deps: [tools]
cmds:
- task: ci-check
- "{{ .TOOL_DIR }}/chronicle -vvv > CHANGELOG.md"
- cmd: "cat CHANGELOG.md"
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 #################################
clean-snapshot:
desc: Remove any snapshot builds
cmds:
- "rm -rf {{ .SNAPSHOT_DIR }}"
- "rm -rf {{ .TMP_DIR }}/goreleaser.yaml"
clean-docker-cache:
desc: Remove all docker cache tars and images from the daemon
cmds:
- find . -type d -wholename "**/test-fixtures/cache" | xargs rm -rf
- docker images --format '{{`{{.ID}}`}} {{`{{.Repository}}`}}' | grep stereoscope-fixture- | awk '{print $1}' | uniq | xargs -r docker rmi --force
clean-oras-cache:
desc: Remove all cache for oras commands
cmd: rm -rf {{ .ORAS_CACHE }}
clean-cache:
desc: Remove all image docker tar cache, images from the docker daemon, and ephemeral test fixtures
cmds:
- task: clean-docker-cache
- |
BOLD='\033[1m'
YELLOW='\033[0;33m'
RESET='\033[0m'
# Use a for loop with command substitution to avoid subshell issues
for dir in $(find . -type d -name 'test-fixtures'); do
if [ -f "$dir/Makefile" ]; then
echo -e "${YELLOW}${BOLD}deleting ephemeral test fixtures in $dir${RESET}"
(make -C "$dir" clean)
fi
done
echo -e "${BOLD}Deleted all ephemeral test fixtures${RESET}"
- rm -f {{ .LAST_CACHE_PULL_FILE }} {{ .CACHE_PATHS_FILE }}
-5
View File
@@ -1,5 +0,0 @@
# See documentation here: https://github.com/artifacthub/hub/blob/v1.6.0/docs/metadata/artifacthub-repo.yml
repositoryID: eced152f-b15d-4879-8b3b-1175397192ba
owners:
- name: wagoodman
email: wagoodman@gmail.com
+309
View File
@@ -0,0 +1,309 @@
package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/source"
"github.com/in-toto/in-toto-golang/in_toto"
"github.com/pkg/errors"
"github.com/pkg/profile"
"github.com/sigstore/cosign/cmd/cosign/cli/sign"
"github.com/sigstore/cosign/pkg/cosign"
"github.com/sigstore/cosign/pkg/cosign/attestation"
"github.com/sigstore/sigstore/pkg/signature/dsse"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/wagoodman/go-partybus"
signatureoptions "github.com/sigstore/sigstore/pkg/signature/options"
)
const (
attestExample = ` {{.appName}} {{.command}} --output [FORMAT] --key [KEY] alpine:latest
Supports the following image sources:
{{.appName}} {{.command}} --key [KEY] 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}} --key [KEY] path/to/a/file/or/dir only for OCI tar or OCI directory
`
attestSchemeHelp = "\n" + indent + schemeHelpHeader + "\n" + imageSchemeHelp
attestHelp = attestExample + attestSchemeHelp
intotoJSONDsseType = `application/vnd.in-toto+json`
)
var attestFormats = []format.Option{format.SPDXJSONOption, format.CycloneDxJSONOption, format.JSONOption}
var (
attestCmd = &cobra.Command{
Use: "attest --output [FORMAT] --key [KEY] [SOURCE]",
Short: "Generate a package SBOM as an attestation for the given [SOURCE] container image",
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation",
Example: internal.Tprintf(attestHelp, map[string]interface{}{
"appName": internal.ApplicationName,
"command": "attest",
}),
Args: validateInputArgs,
SilenceUsage: true,
SilenceErrors: true,
PreRunE: func(cmd *cobra.Command, args []string) (err error) {
if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem {
return fmt.Errorf("cannot profile CPU and memory simultaneously")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if appConfig.Dev.ProfileCPU {
defer profile.Start(profile.CPUProfile).Stop()
} else if appConfig.Dev.ProfileMem {
defer profile.Start(profile.MemProfile).Stop()
}
return attestExec(cmd.Context(), cmd, args)
},
}
)
func fetchPassword(_ bool) (b []byte, err error) {
potentiallyPipedInput, err := internal.IsPipedInput()
if err != nil {
log.Warnf("unable to determine if there is piped input: %+v", err)
}
switch {
case appConfig.Attest.Password != "":
return []byte(appConfig.Attest.Password), nil
case potentiallyPipedInput:
// handle piped in passwords
pwBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("unable to get password from stdin: %w", err)
}
// be resilient to input that may have newline characters (in case someone is using echo without -n)
cleanPw := strings.TrimRight(string(pwBytes), "\n")
return []byte(cleanPw), nil
case internal.IsTerminal():
return cosign.GetPassFromTerm(false)
}
return nil, errors.New("no method available to fetch password")
}
func selectPassFunc(keypath string) (cosign.PassFunc, error) {
keyContents, err := os.ReadFile(keypath)
if err != nil {
return nil, err
}
var fn cosign.PassFunc = func(bool) (b []byte, err error) { return nil, nil }
_, err = cosign.LoadPrivateKey(keyContents, nil)
if err != nil {
fn = fetchPassword
}
return fn, nil
}
func attestExec(ctx context.Context, _ *cobra.Command, args []string) error {
// can only be an image for attestation or OCI DIR
userInput := args[0]
si, err := source.ParseInput(userInput, false)
if err != nil {
return fmt.Errorf("could not generate source input for attest command: %w", err)
}
switch si.Scheme {
case source.ImageScheme, source.UnknownScheme:
// at this point we know that it cannot be dir: or file: schemes, so we will assume that the unknown scheme could represent an image
si.Scheme = source.ImageScheme
default:
return fmt.Errorf("attest command can only be used with image sources but discovered %q when given %q", si.Scheme, userInput)
}
// if the original detection was from a local daemon we want to short circuit
// that and attempt to generate the image source from a registry source instead
switch si.ImageSource {
case image.UnknownSource, image.OciRegistrySource:
si.ImageSource = image.OciRegistrySource
default:
return fmt.Errorf("attest command can only be used with image sources fetch directly from the registry, but discovered an image source of %q when given %q", si.ImageSource, userInput)
}
if len(appConfig.Output) > 1 {
return fmt.Errorf("unable to generate attestation for more than one output")
}
output := format.ParseOption(appConfig.Output[0])
predicateType := assertPredicateType(output)
if predicateType == "" {
return fmt.Errorf("could not produce attestation predicate for given format: %q. Available formats: %+v", output, attestFormats)
}
passFunc, err := selectPassFunc(appConfig.Attest.Key)
if err != nil {
return err
}
ko := sign.KeyOpts{
KeyRef: appConfig.Attest.Key,
PassFunc: passFunc,
}
sv, err := sign.SignerFromKeyOpts(ctx, "", ko)
if err != nil {
return err
}
defer sv.Close()
return eventLoop(
attestationExecWorker(*si, output, predicateType, sv),
setupSignals(),
eventSubscription,
stereoscope.Cleanup,
ui.Select(isVerbose(), appConfig.Quiet)...,
)
}
func attestationExecWorker(sourceInput source.Input, output format.Option, predicateType string, sv *sign.SignerVerifier) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
src, cleanup, err := source.NewFromRegistry(sourceInput, appConfig.Registry.ToOptions(), appConfig.Exclusions)
if cleanup != nil {
defer cleanup()
}
if err != nil {
errs <- fmt.Errorf("failed to construct source from user input %q: %w", sourceInput.UserInput, err)
return
}
s, err := generateSBOM(src, errs)
if err != nil {
errs <- err
return
}
sbomBytes, err := syft.Encode(*s, output)
if err != nil {
errs <- err
return
}
err = generateAttestation(sbomBytes, src, sv, predicateType)
if err != nil {
errs <- err
return
}
}()
return errs
}
func assertPredicateType(output format.Option) string {
switch output {
case format.SPDXJSONOption:
return in_toto.PredicateSPDX
// Tentative see https://github.com/in-toto/attestation/issues/82
case format.CycloneDxJSONOption:
return "https://cyclonedx.org/bom"
case format.JSONOption:
return "https://syft.dev/bom"
default:
return ""
}
}
func findValidDigest(digests []string) string {
// since we are only using the OCI repo provider for this source we are safe that this is only 1 value
// see https://github.com/anchore/stereoscope/blob/25ebd49a842b5ac0a20c2e2b4b81335b64ad248c/pkg/image/oci/registry_provider.go#L57-L63
split := strings.Split(digests[0], "sha256:")
return split[1]
}
func generateAttestation(predicate []byte, src *source.Source, sv *sign.SignerVerifier, predicateType string) error {
switch len(src.Image.Metadata.RepoDigests) {
case 0:
return fmt.Errorf("cannot generate attestation since no repo digests were found; make sure you're passing an OCI registry source for the attest command")
case 1:
default:
return fmt.Errorf("cannot generate attestation since multiple repo digests were found for the image: %+v", src.Image.Metadata.RepoDigests)
}
wrapped := dsse.WrapSigner(sv, intotoJSONDsseType)
sh, err := attestation.GenerateStatement(attestation.GenerateOpts{
Predicate: bytes.NewBuffer(predicate),
Type: predicateType,
Digest: findValidDigest(src.Image.Metadata.RepoDigests),
})
if err != nil {
return err
}
payload, err := json.Marshal(sh)
if err != nil {
return err
}
signedPayload, err := wrapped.SignMessage(bytes.NewReader(payload), signatureoptions.WithContext(context.Background()))
if err != nil {
return errors.Wrap(err, "unable to sign SBOM")
}
bus.Publish(partybus.Event{
Type: event.Exit,
Value: func() error {
_, err := os.Stdout.Write(signedPayload)
return err
},
})
return nil
}
func init() {
setAttestFlags(attestCmd.Flags())
if err := bindAttestConfigOptions(attestCmd.Flags()); err != nil {
panic(err)
}
rootCmd.AddCommand(attestCmd)
}
func setAttestFlags(flags *pflag.FlagSet) {
// key options
flags.StringP("key", "", "cosign.key",
"path to the private key file to use for attestation",
)
// in-toto attestations only support JSON predicates, so not all SBOM formats that syft can output are supported
flags.StringP(
"output", "o", string(format.JSONOption),
fmt.Sprintf("the SBOM format encapsulated within the attestation, available options=%v", attestFormats),
)
}
func bindAttestConfigOptions(flags *pflag.FlagSet) error {
// note: output is not included since this configuration option is shared between multiple subcommands
if err := viper.BindPFlag("attest.key", flags.Lookup("key")); err != nil {
return err
}
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package cmd
import (
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/event"
"github.com/wagoodman/go-partybus"
)
func checkForApplicationUpdate() {
if appConfig.CheckForAppUpdate {
log.Debugf("checking if new vesion of %s is available", internal.ApplicationName)
isAvailable, newVersion, err := version.IsUpdateAvailable()
if err != nil {
// this should never stop the application
log.Errorf(err.Error())
}
if isAvailable {
log.Infof("new version of %s is available: %s (current version is %s)", internal.ApplicationName, newVersion, version.FromBuild().Version)
bus.Publish(partybus.Event{
Type: event.AppUpdateAvailable,
Value: newVersion,
})
} else {
log.Debugf("no new %s update available", internal.ApplicationName)
}
}
}
+167
View File
@@ -0,0 +1,167 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"sort"
"github.com/spf13/pflag"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/internal/config"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/logger"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft"
"github.com/gookit/color"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/wagoodman/go-partybus"
)
var (
appConfig *config.Application
eventBus *partybus.Bus
eventSubscription *partybus.Subscription
)
func init() {
cobra.OnInitialize(
initCmdAliasBindings,
initAppConfig,
initLogging,
logAppConfig,
checkForApplicationUpdate,
logAppVersion,
initEventBus,
)
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, color.Red.Sprint(err.Error()))
os.Exit(1)
}
}
// we must setup the config-cli bindings first before the application configuration is parsed. However, this cannot
// be done without determining what the primary command that the config options should be bound to since there are
// shared concerns (the root-packages alias).
func initCmdAliasBindings() {
activeCmd, _, err := rootCmd.Find(os.Args[1:])
if err != nil {
panic(err)
}
// enable all cataloger by default if power-user command is run
if activeCmd == powerUserCmd {
config.PowerUserCatalogerEnabledDefault()
}
// set bindings based on the packages alias
switch activeCmd {
case packagesCmd, rootCmd:
// note: we need to lazily bind config options since they are shared between both the root command
// and the packages command. Otherwise there will be global viper state that is in contention.
// See for more details: https://github.com/spf13/viper/issues/233 . Additionally, the bindings must occur BEFORE
// reading the application configuration, which implies that it must be an initializer (or rewrite the command
// initialization structure against typical patterns used with cobra, which is somewhat extreme for a
// temporary alias)
if err = bindPackagesConfigOptions(activeCmd.Flags()); err != nil {
panic(err)
}
case attestCmd:
// the --output option is an independently defined flag, but a shared config option
if err = bindSharedOutputConfigOption(attestCmd.Flags()); err != nil {
panic(err)
}
// even though the root command or packages command is NOT being run, we still need default bindings
// such that application config parsing passes.
if err = bindExclusivePackagesConfigOptions(packagesCmd.Flags()); err != nil {
panic(err)
}
default:
// even though the root command or packages command is NOT being run, we still need default bindings
// such that application config parsing passes.
if err = bindPackagesConfigOptions(packagesCmd.Flags()); err != nil {
panic(err)
}
}
}
func bindSharedOutputConfigOption(flags *pflag.FlagSet) error {
if err := viper.BindPFlag("output", flags.Lookup("output")); err != nil {
return err
}
return nil
}
func initAppConfig() {
cfg, err := config.LoadApplicationConfig(viper.GetViper(), persistentOpts)
if err != nil {
fmt.Printf("failed to load application config: \n\t%+v\n", err)
os.Exit(1)
}
appConfig = cfg
}
func initLogging() {
cfg := logger.LogrusConfig{
EnableConsole: (appConfig.Log.FileLocation == "" || appConfig.CliOptions.Verbosity > 0) && !appConfig.Quiet,
EnableFile: appConfig.Log.FileLocation != "",
Level: appConfig.Log.LevelOpt,
Structured: appConfig.Log.Structured,
FileLocation: appConfig.Log.FileLocation,
}
logWrapper := logger.NewLogrusLogger(cfg)
syft.SetLogger(logWrapper)
stereoscope.SetLogger(&logger.LogrusNestedLogger{
Logger: logWrapper.Logger.WithField("from-lib", "stereoscope"),
})
}
func logAppConfig() {
log.Debugf("application config:\n%+v", color.Magenta.Sprint(appConfig.String()))
}
func initEventBus() {
eventBus = partybus.NewBus()
eventSubscription = eventBus.Subscribe()
stereoscope.SetBus(eventBus)
syft.SetBus(eventBus)
}
func logAppVersion() {
versionInfo := version.FromBuild()
log.Infof("syft version: %s", versionInfo.Version)
var fields map[string]interface{}
bytes, err := json.Marshal(versionInfo)
if err != nil {
return
}
err = json.Unmarshal(bytes, &fields)
if err != nil {
return
}
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for idx, field := range keys {
value := fields[field]
branch := "├──"
if idx == len(fields)-1 {
branch = "└──"
}
log.Debugf(" %s %s: %s", branch, field, value)
}
}
+113
View File
@@ -0,0 +1,113 @@
package cmd
import (
"context"
"os"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/spf13/cobra"
)
// completionCmd represents the completion command
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish]",
Short: "Generate a shell completion for Syft (listing local docker images)",
Long: `To load completions (docker image list):
Bash:
$ source <(syft completion bash)
# To load completions for each session, execute once:
Linux:
$ syft completion bash > /etc/bash_completion.d/syft
MacOS:
$ syft completion bash > /usr/local/etc/bash_completion.d/syft
Zsh:
# If shell completion is not already enabled in your environment you will need
# to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ syft completion zsh > "${fpath[1]}/_syft"
# You will need to start a new shell for this setup to take effect.
Fish:
$ syft completion fish | source
# To load completions for each session, execute once:
$ syft completion fish > ~/.config/fish/completions/syft.fish
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish"},
Args: cobra.ExactValidArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var err error
switch args[0] {
case "bash":
err = cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
err = cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
err = cmd.Root().GenFishCompletion(os.Stdout, true)
}
if err != nil {
panic(err)
}
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}
func dockerImageValidArgsFunction(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided
dockerImageRepoTags, err := listLocalDockerImages(toComplete)
if err != nil {
// Indicates that an error occurred and completions should be ignored
return []string{"completion failed"}, cobra.ShellCompDirectiveError
}
if len(dockerImageRepoTags) == 0 {
return []string{"no docker images found"}, cobra.ShellCompDirectiveError
}
// ShellCompDirectiveDefault indicates that the shell will perform its default behavior after completions have
// been provided (without implying other possible directives)
return dockerImageRepoTags, cobra.ShellCompDirectiveDefault
}
func listLocalDockerImages(prefix string) ([]string, error) {
var repoTags = make([]string, 0)
ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return repoTags, err
}
// Only want to return tagged images
imageListArgs := filters.NewArgs()
imageListArgs.Add("dangling", "false")
images, err := cli.ImageList(ctx, types.ImageListOptions{All: false, Filters: imageListArgs})
if err != nil {
return repoTags, err
}
for _, image := range images {
// image may have multiple tags
for _, tag := range image.RepoTags {
if strings.HasPrefix(tag, prefix) {
repoTags = append(repoTags, tag)
}
}
}
return repoTags, nil
}
+98
View File
@@ -0,0 +1,98 @@
package cmd
import (
"errors"
"fmt"
"os"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui"
"github.com/hashicorp/go-multierror"
"github.com/wagoodman/go-partybus"
)
// eventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and
// signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until
// an eventual graceful exit.
// nolint:funlen
func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...ui.UI) error {
defer cleanupFn()
events := subscription.Events()
var err error
var ux ui.UI
if ux, err = setupUI(subscription.Unsubscribe, uxs...); err != nil {
return err
}
var retErr error
var forceTeardown bool
for {
if workerErrs == nil && events == nil {
break
}
select {
case err, isOpen := <-workerErrs:
if !isOpen {
workerErrs = nil
continue
}
if err != nil {
// capture the error from the worker and unsubscribe to complete a graceful shutdown
retErr = multierror.Append(retErr, err)
_ = subscription.Unsubscribe()
// the worker has exited, we may have been mid-handling events for the UI which should now be
// ignored, in which case forcing a teardown of the UI irregardless of the state is required.
forceTeardown = true
}
case e, isOpen := <-events:
if !isOpen {
events = nil
continue
}
if err := ux.Handle(e); err != nil {
if errors.Is(err, partybus.ErrUnsubscribe) {
events = nil
} else {
retErr = multierror.Append(retErr, err)
// TODO: should we unsubscribe? should we try to halt execution? or continue?
}
}
case <-signals:
// ignore further results from any event source and exit ASAP, but ensure that all cache is cleaned up.
// we ignore further errors since cleaning up the tmp directories will affect running catalogers that are
// reading/writing from/to their nested temp dirs. This is acceptable since we are bailing without result.
// TODO: potential future improvement would be to pass context into workers with a cancel function that is
// to the event loop. In this way we can have a more controlled shutdown even at the most nested levels
// of processing.
events = nil
workerErrs = nil
forceTeardown = true
}
}
if err := ux.Teardown(forceTeardown); err != nil {
retErr = multierror.Append(retErr, err)
}
return retErr
}
// setupUI takes one or more UIs that responds to events and takes a event bus unsubscribe function for use
// during teardown. With the given UIs, the first UI which the ui.Setup() function does not return an error
// will be utilized in execution. Providing a set of UIs allows for the caller to provide graceful fallbacks
// when there are environmental problem (e.g. unable to setup a TUI with the current TTY).
func setupUI(unsubscribe func() error, uis ...ui.UI) (ui.UI, error) {
for _, ux := range uis {
if err := ux.Setup(unsubscribe); err != nil {
log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err)
continue
}
return ux, nil
}
return nil, fmt.Errorf("unable to setup any UI")
}
+455
View File
@@ -0,0 +1,455 @@
package cmd
import (
"fmt"
"os"
"syscall"
"testing"
"time"
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/syft/event"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/wagoodman/go-partybus"
)
var _ ui.UI = (*uiMock)(nil)
type uiMock struct {
t *testing.T
finalEvent partybus.Event
unsubscribe func() error
mock.Mock
}
func (u *uiMock) Setup(unsubscribe func() error) error {
u.t.Logf("UI Setup called")
u.unsubscribe = unsubscribe
return u.Called(unsubscribe).Error(0)
}
func (u *uiMock) Handle(event partybus.Event) error {
u.t.Logf("UI Handle called: %+v", event.Type)
if event == u.finalEvent {
assert.NoError(u.t, u.unsubscribe())
}
return u.Called(event).Error(0)
}
func (u *uiMock) Teardown(_ bool) error {
u.t.Logf("UI Teardown called")
return u.Called().Error(0)
}
func Test_eventLoop_gracefulExit(t *testing.T) {
test := func(t *testing.T) {
testBus := partybus.NewBus()
subscription := testBus.Subscribe()
t.Cleanup(testBus.Close)
finalEvent := partybus.Event{
Type: event.Exit,
}
worker := func() <-chan error {
ret := make(chan error)
go func() {
t.Log("worker running")
// send an empty item (which is ignored) ensuring we've entered the select statement,
// then close (a partial shutdown).
ret <- nil
t.Log("worker sent nothing")
close(ret)
t.Log("worker closed")
// do the other half of the shutdown
testBus.Publish(finalEvent)
t.Log("worker published final event")
}()
return ret
}
signaler := func() <-chan os.Signal {
return nil
}
ux := &uiMock{
t: t,
finalEvent: finalEvent,
}
// ensure the mock sees at least the final event
ux.On("Handle", finalEvent).Return(nil)
// ensure the mock sees basic setup/teardown events
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
ux.On("Teardown").Return(nil)
var cleanupCalled bool
cleanupFn := func() {
t.Log("cleanup called")
cleanupCalled = true
}
assert.NoError(t,
eventLoop(
worker(),
signaler(),
subscription,
cleanupFn,
ux,
),
)
assert.True(t, cleanupCalled, "cleanup function not called")
ux.AssertExpectations(t)
}
// if there is a bug, then there is a risk of the event loop never returning
testWithTimeout(t, 5*time.Second, test)
}
func Test_eventLoop_workerError(t *testing.T) {
test := func(t *testing.T) {
testBus := partybus.NewBus()
subscription := testBus.Subscribe()
t.Cleanup(testBus.Close)
workerErr := fmt.Errorf("worker error")
worker := func() <-chan error {
ret := make(chan error)
go func() {
t.Log("worker running")
// send an empty item (which is ignored) ensuring we've entered the select statement,
// then close (a partial shutdown).
ret <- nil
t.Log("worker sent nothing")
ret <- workerErr
t.Log("worker sent error")
close(ret)
t.Log("worker closed")
// note: NO final event is fired
}()
return ret
}
signaler := func() <-chan os.Signal {
return nil
}
ux := &uiMock{
t: t,
}
// ensure the mock sees basic setup/teardown events
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
ux.On("Teardown").Return(nil)
var cleanupCalled bool
cleanupFn := func() {
t.Log("cleanup called")
cleanupCalled = true
}
// ensure we see an error returned
assert.ErrorIs(t,
eventLoop(
worker(),
signaler(),
subscription,
cleanupFn,
ux,
),
workerErr,
"should have seen a worker error, but did not",
)
assert.True(t, cleanupCalled, "cleanup function not called")
ux.AssertExpectations(t)
}
// if there is a bug, then there is a risk of the event loop never returning
testWithTimeout(t, 5*time.Second, test)
}
func Test_eventLoop_unsubscribeError(t *testing.T) {
test := func(t *testing.T) {
testBus := partybus.NewBus()
subscription := testBus.Subscribe()
t.Cleanup(testBus.Close)
finalEvent := partybus.Event{
Type: event.Exit,
}
worker := func() <-chan error {
ret := make(chan error)
go func() {
t.Log("worker running")
// send an empty item (which is ignored) ensuring we've entered the select statement,
// then close (a partial shutdown).
ret <- nil
t.Log("worker sent nothing")
close(ret)
t.Log("worker closed")
// do the other half of the shutdown
testBus.Publish(finalEvent)
t.Log("worker published final event")
}()
return ret
}
signaler := func() <-chan os.Signal {
return nil
}
ux := &uiMock{
t: t,
finalEvent: finalEvent,
}
// ensure the mock sees at least the final event... note the unsubscribe error here
ux.On("Handle", finalEvent).Return(partybus.ErrUnsubscribe)
// ensure the mock sees basic setup/teardown events
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
ux.On("Teardown").Return(nil)
var cleanupCalled bool
cleanupFn := func() {
t.Log("cleanup called")
cleanupCalled = true
}
// unsubscribe errors should be handled and ignored, not propagated. We are additionally asserting that
// this case is handled as a controlled shutdown (this test should not timeout)
assert.NoError(t,
eventLoop(
worker(),
signaler(),
subscription,
cleanupFn,
ux,
),
)
assert.True(t, cleanupCalled, "cleanup function not called")
ux.AssertExpectations(t)
}
// if there is a bug, then there is a risk of the event loop never returning
testWithTimeout(t, 5*time.Second, test)
}
func Test_eventLoop_handlerError(t *testing.T) {
test := func(t *testing.T) {
testBus := partybus.NewBus()
subscription := testBus.Subscribe()
t.Cleanup(testBus.Close)
finalEvent := partybus.Event{
Type: event.Exit,
Error: fmt.Errorf("an exit error occured"),
}
worker := func() <-chan error {
ret := make(chan error)
go func() {
t.Log("worker running")
// send an empty item (which is ignored) ensuring we've entered the select statement,
// then close (a partial shutdown).
ret <- nil
t.Log("worker sent nothing")
close(ret)
t.Log("worker closed")
// do the other half of the shutdown
testBus.Publish(finalEvent)
t.Log("worker published final event")
}()
return ret
}
signaler := func() <-chan os.Signal {
return nil
}
ux := &uiMock{
t: t,
finalEvent: finalEvent,
}
// ensure the mock sees at least the final event... note the event error is propagated
ux.On("Handle", finalEvent).Return(finalEvent.Error)
// ensure the mock sees basic setup/teardown events
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
ux.On("Teardown").Return(nil)
var cleanupCalled bool
cleanupFn := func() {
t.Log("cleanup called")
cleanupCalled = true
}
// handle errors SHOULD propagate the event loop. We are additionally asserting that this case is
// handled as a controlled shutdown (this test should not timeout)
assert.ErrorIs(t,
eventLoop(
worker(),
signaler(),
subscription,
cleanupFn,
ux,
),
finalEvent.Error,
"should have seen a event error, but did not",
)
assert.True(t, cleanupCalled, "cleanup function not called")
ux.AssertExpectations(t)
}
// if there is a bug, then there is a risk of the event loop never returning
testWithTimeout(t, 5*time.Second, test)
}
func Test_eventLoop_signalsStopExecution(t *testing.T) {
test := func(t *testing.T) {
testBus := partybus.NewBus()
subscription := testBus.Subscribe()
t.Cleanup(testBus.Close)
worker := func() <-chan error {
// the worker will never return work and the event loop will always be waiting...
return make(chan error)
}
signaler := func() <-chan os.Signal {
ret := make(chan os.Signal)
go func() {
ret <- syscall.SIGINT
// note: we do NOT close the channel to ensure the event loop does not depend on that behavior to exit
}()
return ret
}
ux := &uiMock{
t: t,
}
// ensure the mock sees basic setup/teardown events
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
ux.On("Teardown").Return(nil)
var cleanupCalled bool
cleanupFn := func() {
t.Log("cleanup called")
cleanupCalled = true
}
assert.NoError(t,
eventLoop(
worker(),
signaler(),
subscription,
cleanupFn,
ux,
),
)
assert.True(t, cleanupCalled, "cleanup function not called")
ux.AssertExpectations(t)
}
// if there is a bug, then there is a risk of the event loop never returning
testWithTimeout(t, 5*time.Second, test)
}
func Test_eventLoop_uiTeardownError(t *testing.T) {
test := func(t *testing.T) {
testBus := partybus.NewBus()
subscription := testBus.Subscribe()
t.Cleanup(testBus.Close)
finalEvent := partybus.Event{
Type: event.Exit,
}
worker := func() <-chan error {
ret := make(chan error)
go func() {
t.Log("worker running")
// send an empty item (which is ignored) ensuring we've entered the select statement,
// then close (a partial shutdown).
ret <- nil
t.Log("worker sent nothing")
close(ret)
t.Log("worker closed")
// do the other half of the shutdown
testBus.Publish(finalEvent)
t.Log("worker published final event")
}()
return ret
}
signaler := func() <-chan os.Signal {
return nil
}
ux := &uiMock{
t: t,
finalEvent: finalEvent,
}
teardownError := fmt.Errorf("sorry, dave, the UI doesn't want to be torn down")
// ensure the mock sees at least the final event... note the event error is propagated
ux.On("Handle", finalEvent).Return(nil)
// ensure the mock sees basic setup/teardown events
ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
ux.On("Teardown").Return(teardownError)
var cleanupCalled bool
cleanupFn := func() {
t.Log("cleanup called")
cleanupCalled = true
}
// ensure we see an error returned
assert.ErrorIs(t,
eventLoop(
worker(),
signaler(),
subscription,
cleanupFn,
ux,
),
teardownError,
"should have seen a UI teardown error, but did not",
)
assert.True(t, cleanupCalled, "cleanup function not called")
ux.AssertExpectations(t)
}
// if there is a bug, then there is a risk of the event loop never returning
testWithTimeout(t, 5*time.Second, test)
}
func testWithTimeout(t *testing.T, timeout time.Duration, test func(*testing.T)) {
done := make(chan bool)
go func() {
test(t)
done <- true
}()
select {
case <-time.After(timeout):
t.Fatal("test timed out")
case <-done:
}
}
+72
View File
@@ -0,0 +1,72 @@
package cmd
import (
"fmt"
"strings"
"github.com/anchore/syft/internal/formats"
"github.com/anchore/syft/internal/output"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/sbom"
"github.com/hashicorp/go-multierror"
)
// makeWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer
// or an error but neither both and if there is no error, sbom.Writer.Close() should be called
func makeWriter(outputs []string, defaultFile string) (sbom.Writer, error) {
outputOptions, err := parseOptions(outputs, defaultFile)
if err != nil {
return nil, err
}
writer, err := output.MakeWriter(outputOptions...)
if err != nil {
return nil, err
}
return writer, nil
}
// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file
func parseOptions(outputs []string, defaultFile string) (out []output.WriterOption, errs error) {
// always should have one option -- we generally get the default of "table", but just make sure
if len(outputs) == 0 {
outputs = append(outputs, string(format.TableOption))
}
for _, name := range outputs {
name = strings.TrimSpace(name)
// split to at most two parts for <format>=<file>
parts := strings.SplitN(name, "=", 2)
// the format option is the first part
name = parts[0]
// default to the --file or empty string if not specified
file := defaultFile
// If a file is specified as part of the output option, use that
if len(parts) > 1 {
file = parts[1]
}
option := format.ParseOption(name)
if option == format.UnknownFormatOption {
errs = multierror.Append(errs, fmt.Errorf("bad output format: '%s'", name))
continue
}
encoder := formats.ByOption(option)
if encoder == nil {
errs = multierror.Append(errs, fmt.Errorf("unknown format: %s", outputFormat))
continue
}
out = append(out, output.WriterOption{
Format: *encoder,
Path: file,
})
}
return out, errs
}
+78
View File
@@ -0,0 +1,78 @@
package cmd
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestOutputWriterConfig(t *testing.T) {
tmp := t.TempDir() + "/"
tests := []struct {
outputs []string
file string
err bool
expected []string
}{
{
outputs: []string{},
expected: []string{""},
},
{
outputs: []string{"json"},
expected: []string{""},
},
{
file: "test-1.json",
expected: []string{"test-1.json"},
},
{
outputs: []string{"json=test-2.json"},
expected: []string{"test-2.json"},
},
{
outputs: []string{"json=test-3-1.json", "spdx-json=test-3-2.json"},
expected: []string{"test-3-1.json", "test-3-2.json"},
},
{
outputs: []string{"text", "json=test-4.json"},
expected: []string{"", "test-4.json"},
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("%s/%s", test.outputs, test.file), func(t *testing.T) {
outputs := test.outputs
for i, val := range outputs {
outputs[i] = strings.Replace(val, "=", "="+tmp, 1)
}
file := test.file
if file != "" {
file = tmp + file
}
_, err := makeWriter(test.outputs, file)
if test.err {
assert.Error(t, err)
return
} else {
assert.NoError(t, err)
}
for _, expected := range test.expected {
if expected != "" {
assert.FileExists(t, tmp+expected)
} else if file != "" {
assert.FileExists(t, file)
} else {
assert.NoFileExists(t, expected)
}
}
})
}
}
+383
View File
@@ -0,0 +1,383 @@
package cmd
import (
"context"
"fmt"
"io/ioutil"
"os"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/anchore"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/pkg/cataloger"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
"github.com/pkg/profile"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/wagoodman/go-partybus"
)
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.2 Tag-Value formatted SBOM
{{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.2 JSON formatted SBOM
{{.appName}} {{.command}} alpine:latest -vv show verbose debug information
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, 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)
`
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" + indent + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp
packagesHelp = packagesExample + packagesSchemeHelp
)
var (
packagesCmd = &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": internal.ApplicationName,
"command": "packages",
}),
Args: validateInputArgs,
SilenceUsage: true,
SilenceErrors: true,
PreRunE: func(cmd *cobra.Command, args []string) (err error) {
if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem {
return fmt.Errorf("cannot profile CPU and memory simultaneously")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if appConfig.Dev.ProfileCPU {
defer profile.Start(profile.CPUProfile).Stop()
} else if appConfig.Dev.ProfileMem {
defer profile.Start(profile.MemProfile).Stop()
}
return packagesExec(cmd, args)
},
ValidArgsFunction: dockerImageValidArgsFunction,
}
)
func init() {
setPackageFlags(packagesCmd.Flags())
rootCmd.AddCommand(packagesCmd)
}
func setPackageFlags(flags *pflag.FlagSet) {
// Formatting & Input options //////////////////////////////////////////////
flags.StringP(
"scope", "s", cataloger.DefaultSearchConfig().Scope.String(),
fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes))
flags.StringArrayP(
"output", "o", []string{string(format.TableOption)},
fmt.Sprintf("report output format, options=%v", format.AllOptions),
)
flags.StringP(
"file", "", "",
"file to write the default report output to (default is STDOUT)",
)
// Upload options //////////////////////////////////////////////////////////
flags.StringP(
"host", "H", "",
"the hostname or URL of the Anchore Enterprise instance to upload to",
)
flags.StringP(
"username", "u", "",
"the username to authenticate against Anchore Enterprise",
)
flags.StringP(
"password", "p", "",
"the password to authenticate against Anchore Enterprise",
)
flags.StringP(
"dockerfile", "d", "",
"include dockerfile for upload to Anchore Enterprise",
)
flags.StringArrayP(
"exclude", "", nil,
"exclude paths from being scanned using a glob expression",
)
flags.Bool(
"overwrite-existing-image", false,
"overwrite an existing image during the upload to Anchore Enterprise",
)
flags.Uint(
"import-timeout", 30,
"set a timeout duration (in seconds) for the upload to Anchore Enterprise",
)
}
func bindPackagesConfigOptions(flags *pflag.FlagSet) error {
if err := bindExclusivePackagesConfigOptions(flags); err != nil {
return err
}
if err := bindSharedOutputConfigOption(flags); err != nil {
return err
}
return nil
}
// NOTE(alex): Write a helper for the binding operation, which can be used to perform the binding but also double check that the intended effect was had or else return an error. Another thought is to somehow provide zero-valued defaults for all values in our config struct (maybe with reflection?). There may be a mechanism that already exists in viper that protects against this that I'm not aware of. ref: https://github.com/anchore/syft/pull/805#discussion_r801931192
func bindExclusivePackagesConfigOptions(flags *pflag.FlagSet) error {
// Formatting & Input options //////////////////////////////////////////////
// note: output is not included since this configuration option is shared between multiple subcommands
if err := viper.BindPFlag("package.cataloger.scope", flags.Lookup("scope")); err != nil {
return err
}
if err := viper.BindPFlag("file", flags.Lookup("file")); err != nil {
return err
}
if err := viper.BindPFlag("exclude", flags.Lookup("exclude")); err != nil {
return err
}
// Upload options //////////////////////////////////////////////////////////
if err := viper.BindPFlag("anchore.host", flags.Lookup("host")); err != nil {
return err
}
if err := viper.BindPFlag("anchore.username", flags.Lookup("username")); err != nil {
return err
}
if err := viper.BindPFlag("anchore.password", flags.Lookup("password")); err != nil {
return err
}
if err := viper.BindPFlag("anchore.dockerfile", flags.Lookup("dockerfile")); err != nil {
return err
}
if err := viper.BindPFlag("anchore.overwrite-existing-image", flags.Lookup("overwrite-existing-image")); err != nil {
return err
}
if err := viper.BindPFlag("anchore.import-timeout", flags.Lookup("import-timeout")); err != nil {
return err
}
return nil
}
func validateInputArgs(cmd *cobra.Command, args []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("an image/directory argument is required")
}
return cobra.MaximumNArgs(1)(cmd, args)
}
func packagesExec(_ *cobra.Command, args []string) error {
writer, err := makeWriter(appConfig.Output, appConfig.File)
if err != nil {
return err
}
defer func() {
if err := writer.Close(); err != nil {
log.Warnf("unable to write to report destination: %w", err)
}
}()
// could be an image or a directory, with or without a scheme
userInput := args[0]
si, err := source.ParseInput(userInput, true)
if err != nil {
return fmt.Errorf("could not generate source input for packages command: %w", err)
}
return eventLoop(
packagesExecWorker(*si, writer),
setupSignals(),
eventSubscription,
stereoscope.Cleanup,
ui.Select(isVerbose(), appConfig.Quiet)...,
)
}
func isVerbose() (result bool) {
isPipedInput, err := internal.IsPipedInput()
if err != nil {
// since we can't tell if there was piped input we assume that there could be to disable the ETUI
log.Warnf("unable to determine if there is piped input: %+v", err)
return true
}
// verbosity should consider if there is piped input (in which case we should not show the ETUI)
return appConfig.CliOptions.Verbosity > 0 || isPipedInput
}
func generateSBOM(src *source.Source, errs chan error) (*sbom.SBOM, error) {
tasks, err := tasks()
if err != nil {
return nil, err
}
s := sbom.SBOM{
Source: src.Metadata,
Descriptor: sbom.Descriptor{
Name: internal.ApplicationName,
Version: version.FromBuild().Version,
Configuration: appConfig,
},
}
buildRelationships(&s, src, tasks, errs)
return &s, nil
}
func buildRelationships(s *sbom.SBOM, src *source.Source, tasks []task, errs chan error) {
var relationships []<-chan artifact.Relationship
for _, task := range tasks {
c := make(chan artifact.Relationship)
relationships = append(relationships, c)
go runTask(task, &s.Artifacts, src, c, errs)
}
s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...)
}
func packagesExecWorker(si source.Input, writer sbom.Writer) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
src, cleanup, err := source.New(si, appConfig.Registry.ToOptions(), appConfig.Exclusions)
if cleanup != nil {
defer cleanup()
}
if err != nil {
errs <- fmt.Errorf("failed to construct source from user input %q: %w", si.UserInput, err)
return
}
s, err := generateSBOM(src, errs)
if err != nil {
errs <- err
return
}
if s == nil {
errs <- fmt.Errorf("no SBOM produced for %q", si.UserInput)
}
if appConfig.Anchore.Host != "" {
if err := runPackageSbomUpload(src, *s); err != nil {
errs <- err
return
}
}
bus.Publish(partybus.Event{
Type: event.Exit,
Value: func() error { return writer.Write(*s) },
})
}()
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
}
func runPackageSbomUpload(src *source.Source, s sbom.SBOM) error {
log.Infof("uploading results to %s", appConfig.Anchore.Host)
if src.Metadata.Scheme != source.ImageScheme {
return fmt.Errorf("unable to upload results: only images are supported")
}
var dockerfileContents []byte
if appConfig.Anchore.Dockerfile != "" {
if _, err := os.Stat(appConfig.Anchore.Dockerfile); os.IsNotExist(err) {
return fmt.Errorf("unable dockerfile=%q does not exist: %w", appConfig.Anchore.Dockerfile, err)
}
fh, err := os.Open(appConfig.Anchore.Dockerfile)
if err != nil {
return fmt.Errorf("unable to open dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err)
}
dockerfileContents, err = ioutil.ReadAll(fh)
if err != nil {
return fmt.Errorf("unable to read dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err)
}
}
c, err := anchore.NewClient(anchore.Configuration{
BaseURL: appConfig.Anchore.Host,
Username: appConfig.Anchore.Username,
Password: appConfig.Anchore.Password,
})
if err != nil {
return fmt.Errorf("failed to create anchore client: %w", err)
}
importCfg := anchore.ImportConfig{
ImageMetadata: src.Image.Metadata,
SBOM: s,
Dockerfile: dockerfileContents,
OverwriteExistingUpload: appConfig.Anchore.OverwriteExistingImage,
Timeout: appConfig.Anchore.ImportTimeout,
}
if err := c.Import(context.Background(), importCfg); err != nil {
return fmt.Errorf("failed to upload results to host=%s: %+v", appConfig.Anchore.Host, err)
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package cmd
import (
"fmt"
"os"
"github.com/anchore/stereoscope"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/output"
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
"github.com/gookit/color"
"github.com/pkg/profile"
"github.com/spf13/cobra"
"github.com/wagoodman/go-partybus"
)
const powerUserExample = ` {{.appName}} {{.command}} <image>
DEPRECATED - THIS COMMAND WILL BE REMOVED in v1.0.0
Only image sources are supported (e.g. docker: , podman: , docker-archive: , oci: , etc.), the directory source (dir:) is not supported.
All behavior is controlled via application configuration and environment variables (see https://github.com/anchore/syft#configuration)
`
var powerUserOpts = struct {
configPath string
}{}
var powerUserCmd = &cobra.Command{
Use: "power-user [IMAGE]",
Short: "Run bulk operations on container images",
Example: internal.Tprintf(powerUserExample, map[string]interface{}{
"appName": internal.ApplicationName,
"command": "power-user",
}),
Args: validateInputArgs,
Hidden: true,
SilenceUsage: true,
SilenceErrors: true,
PreRunE: func(cmd *cobra.Command, args []string) error {
if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem {
return fmt.Errorf("cannot profile CPU and memory simultaneously")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if appConfig.Dev.ProfileCPU {
defer profile.Start(profile.CPUProfile).Stop()
} else if appConfig.Dev.ProfileMem {
defer profile.Start(profile.MemProfile).Stop()
}
return powerUserExec(cmd, args)
},
ValidArgsFunction: dockerImageValidArgsFunction,
}
func init() {
powerUserCmd.Flags().StringVarP(&powerUserOpts.configPath, "config", "c", "", "config file path with all power-user options")
rootCmd.AddCommand(powerUserCmd)
}
func powerUserExec(_ *cobra.Command, args []string) error {
// could be an image or a directory, with or without a scheme
userInput := args[0]
writer, err := output.MakeWriter(output.WriterOption{
Format: syftjson.Format(),
Path: appConfig.File,
})
if err != nil {
return err
}
defer func() {
if err := writer.Close(); err != nil {
log.Warnf("unable to write to report destination: %+v", err)
}
// inform user at end of run that command will be removed
deprecated := color.Style{color.Red, color.OpBold}.Sprint("DEPRECATED: This command will be removed in v1.0.0")
fmt.Fprintln(os.Stderr, deprecated)
}()
return eventLoop(
powerUserExecWorker(userInput, writer),
setupSignals(),
eventSubscription,
stereoscope.Cleanup,
ui.Select(isVerbose(), appConfig.Quiet)...,
)
}
func powerUserExecWorker(userInput string, writer sbom.Writer) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
appConfig.Secrets.Cataloger.Enabled = true
appConfig.FileMetadata.Cataloger.Enabled = true
appConfig.FileContents.Cataloger.Enabled = true
appConfig.FileClassification.Cataloger.Enabled = true
tasks, err := tasks()
if err != nil {
errs <- err
return
}
si, err := source.ParseInput(userInput, true)
if err != nil {
errs <- err
return
}
src, cleanup, err := source.New(*si, appConfig.Registry.ToOptions(), appConfig.Exclusions)
if err != nil {
errs <- err
return
}
if cleanup != nil {
defer cleanup()
}
s := sbom.SBOM{
Source: src.Metadata,
Descriptor: sbom.Descriptor{
Name: internal.ApplicationName,
Version: version.FromBuild().Version,
Configuration: appConfig,
},
}
var relationships []<-chan artifact.Relationship
for _, task := range tasks {
c := make(chan artifact.Relationship)
relationships = append(relationships, c)
go runTask(task, &s.Artifacts, src, c, errs)
}
s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...)
bus.Publish(partybus.Event{
Type: event.Exit,
Value: func() error { return writer.Write(s) },
})
}()
return errs
}
+52
View File
@@ -0,0 +1,52 @@
package cmd
import (
"fmt"
"os"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/config"
"github.com/anchore/syft/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var persistentOpts = config.CliOnlyOptions{}
// rootCmd is currently an alias for the packages command
var rootCmd = &cobra.Command{
Short: packagesCmd.Short,
Long: packagesCmd.Long,
Args: packagesCmd.Args,
Example: packagesCmd.Example,
SilenceUsage: true,
SilenceErrors: true,
PreRunE: packagesCmd.PreRunE,
RunE: packagesCmd.RunE,
ValidArgsFunction: packagesCmd.ValidArgsFunction,
Version: version.FromBuild().Version,
}
const indent = " "
func init() {
// set universal flags
rootCmd.PersistentFlags().StringVarP(&persistentOpts.ConfigPath, "config", "c", "", "application config file")
// setting the version template to just print out the string since we already have a templatized version string
rootCmd.SetVersionTemplate(fmt.Sprintf("%s {{.Version}}\n", internal.ApplicationName))
flag := "quiet"
rootCmd.PersistentFlags().BoolP(
flag, "q", false,
"suppress all logging output",
)
if err := viper.BindPFlag(flag, rootCmd.PersistentFlags().Lookup(flag)); err != nil {
fmt.Printf("unable to bind flag '%s': %+v", flag, err)
os.Exit(1)
}
rootCmd.PersistentFlags().CountVarP(&persistentOpts.Verbosity, "verbose", "v", "increase verbosity (-v = info, -vv = debug)")
// set common options that are not universal (package subcommand-alias specific)
setPackageFlags(rootCmd.Flags())
}
+20
View File
@@ -0,0 +1,20 @@
package cmd
import (
"os"
"os/signal"
"syscall"
)
func setupSignals() <-chan os.Signal {
c := make(chan os.Signal, 1) // Note: A buffered channel is recommended for this; see https://golang.org/pkg/os/signal/#Notify
interruptions := []os.Signal{
syscall.SIGINT,
syscall.SIGTERM,
}
signal.Notify(c, interruptions...)
return c
}
-61
View File
@@ -1,61 +0,0 @@
package cli
import (
"io"
"os"
cranecmd "github.com/google/go-containerregistry/cmd/crane/cmd"
"github.com/spf13/cobra"
"github.com/anchore/clio"
"github.com/anchore/syft/cmd/syft/internal"
"github.com/anchore/syft/cmd/syft/internal/commands"
)
// Application constructs the `syft packages` command and aliases the root command to `syft packages`.
// It is also responsible for organizing flag usage and injecting the application config for each command.
// It also constructs the syft attest command and the syft version command.
// `RunE` is the earliest that the complete application configuration can be loaded.
func Application(id clio.Identification) clio.Application {
app, _ := create(id, os.Stdout)
return app
}
// Command returns the root command for the syft CLI application. This is useful for embedding the entire syft CLI
// into an existing application.
func Command(id clio.Identification) *cobra.Command {
_, cmd := create(id, os.Stdout)
return cmd
}
func create(id clio.Identification, out io.Writer) (clio.Application, *cobra.Command) {
clioCfg := internal.AppClioSetupConfig(id, out)
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)
// root is currently an alias for the scan command
rootCmd := commands.Root(app, scanCmd)
// add sub-commands
rootCmd.AddCommand(
scanCmd,
commands.Packages(app, scanCmd), // this is currently an alias for the scan command
commands.Cataloger(app),
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.
return app, rootCmd
}
@@ -1,19 +0,0 @@
[TestHandler_handleAttestationStarted/attesting_in_progress/task_line - 1]
⠋ Creating a thing running a thing
---
[TestHandler_handleAttestationStarted/attesting_in_progress/log - 1]
░░ contents
░░ of
░░ stuff!
---
[TestHandler_handleAttestationStarted/attesting_complete/task_line - 1]
✔ Created a thing running a thing
---
[TestHandler_handleAttestationStarted/attesting_complete/log - 1]
---
@@ -1,24 +0,0 @@
[TestHandler_handleCatalogerTaskStarted/cataloging_task_in_progress - 1]
⠙ Cataloging contents
⠙ some task title ━━━━━━━━━━━━━━━━━━━━ [some stage]
---
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_in_progress - 1]
⠙ Cataloging contents
└── ⠙ some task title ━━━━━━━━━━━━━━━━━━━━ [some stage]
---
[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
---
[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete_with_removal - 1]
⠙ Cataloging contents
---
@@ -1,8 +0,0 @@
[TestHandler_handleFetchImage/fetch_image_in_progress - 1]
⠋ Loading image ━━━━━━━━━━━━━━━━━━━━ [current] the-image
---
[TestHandler_handleFetchImage/fetch_image_complete - 1]
✔ Loaded image the-image
---
@@ -1,8 +0,0 @@
[TestHandler_handleFileIndexingStarted/cataloging_in_progress - 1]
⠋ Indexing file system ━━━━━━━━━━━━━━━━━━━━ [current] /some/path
---
[TestHandler_handleFileIndexingStarted/cataloging_complete - 1]
✔ Indexed file system /some/path
---
@@ -1,12 +0,0 @@
[Test_dockerPullStatusFormatter_Render/pulling - 1]
3 Layers▕▅▃ ▏[12 B / 30 B]
---
[Test_dockerPullStatusFormatter_Render/download_complete - 1]
3 Layers▕█▃ ▏[30 B] Extracting...
---
[Test_dockerPullStatusFormatter_Render/complete - 1]
3 Layers▕███▏[30 B] Extracting...
---
@@ -1,8 +0,0 @@
[TestHandler_handleReadImage/read_image_in_progress - 1]
⠋ Parsing image ━━━━━━━━━━━━━━━━━━━━ id
---
[TestHandler_handleReadImage/read_image_complete - 1]
✔ Parsed image id
---
-255
View File
@@ -1,255 +0,0 @@
package ui
import (
"bufio"
"fmt"
"io"
"strings"
"sync"
"time"
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/zyedidia/generic/queue"
"github.com/anchore/bubbly/bubbles/taskprogress"
"github.com/anchore/syft/internal/log"
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
)
var (
_ tea.Model = (*attestLogFrame)(nil)
)
type attestLogFrame struct {
reader *backgroundLineReader
prog progress.Progressable
lines []string
completed bool
failed bool
windowSize tea.WindowSizeMsg
id uint32
sequence int
updateDuration time.Duration
borderStype lipgloss.Style
}
// attestLogFrameTickMsg indicates that the timer has ticked and we should render a frame.
type attestLogFrameTickMsg struct {
Time time.Time
Sequence int
ID uint32
}
type backgroundLineReader struct {
limit int
lines *queue.Queue[string]
lock *sync.RWMutex
// This is added specifically for tests to assert when the background reader is done.
// The main UI uses the global ui wait group from the handler to otherwise block
// Shared concerns among multiple model made it difficult to test using the global wait group
// so this is added to allow tests to assert when the background reader is done.
running *sync.WaitGroup
}
func (m *Handler) handleAttestationStarted(e partybus.Event) []tea.Model {
reader, prog, taskInfo, err := syftEventParsers.ParseAttestationStartedEvent(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
stage := progress.Stage{}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: taskInfo.Title.Default,
Running: taskInfo.Title.WhileRunning,
Success: taskInfo.Title.OnSuccess,
},
taskprogress.WithStagedProgressable(
struct {
progress.Progressable
progress.Stager
}{
Progressable: prog,
Stager: &stage,
},
),
)
tsk.HideStageOnSuccess = false
if taskInfo.Context != "" {
tsk.Context = []string{taskInfo.Context}
}
borderStyle := tsk.HintStyle
return []tea.Model{
tsk,
newLogFrame(newBackgroundLineReader(m.Running, reader, &stage), prog, borderStyle),
}
}
func newLogFrame(reader *backgroundLineReader, prog progress.Progressable, borderStyle lipgloss.Style) attestLogFrame {
return attestLogFrame{
reader: reader,
prog: prog,
id: uuid.Must(uuid.NewUUID()).ID(),
updateDuration: 250 * time.Millisecond,
borderStype: borderStyle,
}
}
func newBackgroundLineReader(wg *sync.WaitGroup, reader io.Reader, stage *progress.Stage) *backgroundLineReader {
r := &backgroundLineReader{
limit: 7,
lock: &sync.RWMutex{},
lines: queue.New[string](),
running: &sync.WaitGroup{},
}
// tracks the background reader for the global handler wait group
wg.Add(1)
// tracks the background reader for the local wait group (used in tests to decouple from the global handler wait group)
r.running.Add(1)
go func() {
r.read(reader, stage)
wg.Done()
r.running.Done()
}()
return r
}
func (l *backgroundLineReader) read(reader io.Reader, stage *progress.Stage) {
s := bufio.NewScanner(reader)
for s.Scan() {
l.lock.Lock()
text := s.Text()
l.lines.Enqueue(text)
if strings.Contains(text, "tlog entry created with index") {
fields := strings.SplitN(text, ":", 2)
present := text
if len(fields) == 2 {
present = fmt.Sprintf("transparency log index: %s", fields[1])
}
stage.Current = present
} else if strings.Contains(text, "WARNING: skipping transparency log upload") {
stage.Current = "transparency log upload skipped"
}
// only show the last X lines of the shell output
for l.lines.Len() > l.limit {
l.lines.Dequeue()
}
l.lock.Unlock()
}
}
func (l backgroundLineReader) Lines() []string {
l.lock.RLock()
defer l.lock.RUnlock()
var lines []string
l.lines.Each(func(line string) {
lines = append(lines, line)
})
return lines
}
func (l attestLogFrame) Init() tea.Cmd {
// this is the periodic update of state information
return func() tea.Msg {
return attestLogFrameTickMsg{
// The time at which the tick occurred.
Time: time.Now(),
// The ID of the log frame that this message belongs to. This can be
// helpful when routing messages, however bear in mind that log frames
// will ignore messages that don't contain ID by default.
ID: l.id,
Sequence: l.sequence,
}
}
}
func (l attestLogFrame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
l.windowSize = msg
return l, nil
case attestLogFrameTickMsg:
l.lines = l.reader.Lines()
l.completed = progress.IsCompleted(l.prog)
err := l.prog.Error()
l.failed = err != nil && !progress.IsErrCompleted(err)
tickCmd := l.handleTick(msg)
return l, tickCmd
}
return l, nil
}
func (l attestLogFrame) View() string {
if l.completed && !l.failed {
return ""
}
sb := strings.Builder{}
for _, line := range l.lines {
sb.WriteString(fmt.Sprintf(" %s %s\n", l.borderStype.Render("░░"), line))
}
return sb.String()
}
func (l attestLogFrame) queueNextTick() tea.Cmd {
return tea.Tick(l.updateDuration, func(t time.Time) tea.Msg {
return attestLogFrameTickMsg{
Time: t,
ID: l.id,
Sequence: l.sequence,
}
})
}
func (l *attestLogFrame) handleTick(msg attestLogFrameTickMsg) tea.Cmd {
// If an ID is set, and the ID doesn't belong to this log frame, reject the message.
if msg.ID > 0 && msg.ID != l.id {
return nil
}
// If a sequence is set, and it's not the one we expect, reject the message.
// This prevents the log frame from receiving too many messages and
// thus updating too frequently.
if msg.Sequence > 0 && msg.Sequence != l.sequence {
return nil
}
l.sequence++
// note: even if the log is completed we should still respond to stage changes and window size events
return l.queueNextTick()
}
-140
View File
@@ -1,140 +0,0 @@
package ui
import (
"strings"
"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_handleAttestationStarted(t *testing.T) {
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
iterations int
}{
{
name: "attesting in progress",
// note: this model depends on a background reader. Multiple iterations ensures that the
// reader has time to at least start and process the test fixture before the runModel
// test harness completes (which is a fake event loop anyway).
iterations: 1,
eventFn: func(t *testing.T) partybus.Event {
reader := strings.NewReader("contents\nof\nstuff!")
src := monitor.GenericTask{
Title: monitor.Title{
Default: "Create a thing",
WhileRunning: "Creating a thing",
OnSuccess: "Created a thing",
},
Context: "running a thing",
}
mon := progress.NewManual(-1)
mon.Set(50)
value := &monitor.ShellProgress{
Reader: reader,
Progressable: mon,
}
return partybus.Event{
Type: syftEvent.AttestationStarted,
Source: src,
Value: value,
}
},
},
{
name: "attesting complete",
// note: this model depends on a background reader. Multiple iterations ensures that the
// reader has time to at least start and process the test fixture before the runModel
// test harness completes (which is a fake event loop anyway).
iterations: 1,
eventFn: func(t *testing.T) partybus.Event {
reader := strings.NewReader("contents\nof\nstuff!")
src := monitor.GenericTask{
Title: monitor.Title{
Default: "Create a thing",
WhileRunning: "Creating a thing",
OnSuccess: "Created a thing",
},
Context: "running a thing",
}
mon := progress.NewManual(-1)
mon.Set(50)
mon.SetCompleted()
value := &monitor.ShellProgress{
Reader: reader,
Progressable: mon,
}
return partybus.Event{
Type: syftEvent.AttestationStarted,
Source: src,
Value: value,
}
},
},
}
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, 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{
Time: time.Now(),
Sequence: tsk.Sequence(),
ID: tsk.ID(),
})
got := gotModel.View()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
t.Run("log", func(t *testing.T) {
log, ok := models[1].(attestLogFrame)
require.True(t, ok)
gotModel := 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)
})
})
}
}
-123
View File
@@ -1,123 +0,0 @@
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 _ tea.Model = (*catalogerTaskModel)(nil)
type catalogerTaskModel struct {
model tree.Model
modelFactory taskModelFactory
}
func newCatalogerTaskTreeModel(f taskModelFactory) *catalogerTaskModel {
t := tree.NewModel()
t.Padding = " "
t.RootsWithoutPrefix = true
return &catalogerTaskModel{
modelFactory: f,
model: t,
}
}
type newCatalogerTaskRowEvent struct {
info monitor.GenericTask
prog progress.StagedProgressable
}
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)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil, 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()
}
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,
}
}
return models, cmd
}
@@ -1,198 +0,0 @@
package ui
import (
"testing"
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"
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
iterations int
}{
{
name: "cataloging task in progress",
eventFn: func(t *testing.T) partybus.Event {
value := &monitor.CatalogerTaskProgress{
AtomicStage: progress.NewAtomicStage("some stage"),
Manual: progress.NewManual(100),
}
value.Manual.Add(50)
return partybus.Event{
Type: syftEvent.CatalogerTaskStarted,
Source: monitor.GenericTask{
Title: title,
HideOnSuccess: false,
HideStageOnSuccess: false,
ID: "my-id",
},
Value: value,
}
},
},
{
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),
}
value.Manual.Add(50)
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",
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: 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,
}
},
},
{
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),
}
value.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,
}
},
},
}
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)
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),
},
)
require.Len(t, models, 1)
require.NotNil(t, cmd)
model := models[0]
tr, ok := model.(*catalogerTaskModel)
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()
t.Log(got)
snaps.MatchSnapshot(t, got)
})
}
}
-32
View File
@@ -1,32 +0,0 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/go-partybus"
"github.com/anchore/bubbly/bubbles/taskprogress"
stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers"
"github.com/anchore/syft/internal/log"
)
func (m *Handler) handleFetchImage(e partybus.Event) []tea.Model {
imgName, prog, err := stereoEventParsers.ParseFetchImage(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Load image",
Running: "Loading image",
Success: "Loaded image",
},
taskprogress.WithStagedProgressable(prog),
)
if imgName != "" {
tsk.Context = []string{imgName}
}
return []tea.Model{tsk}
}
-102
View File
@@ -1,102 +0,0 @@
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"
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
)
func TestHandler_handleFetchImage(t *testing.T) {
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
iterations int
}{
{
name: "fetch image 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: stereoscopeEvent.FetchImage,
Source: "the-image",
Value: mon,
}
},
},
{
name: "fetch image 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: stereoscopeEvent.FetchImage,
Source: "the-image",
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)
gotModel := 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)
})
}
}
-31
View File
@@ -1,31 +0,0 @@
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) handleFileIndexingStarted(e partybus.Event) []tea.Model {
path, prog, err := syftEventParsers.ParseFileIndexingStarted(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Index files system",
Running: "Indexing file system",
Success: "Indexed file system",
},
taskprogress.WithStagedProgressable(prog),
)
tsk.Context = []string{path}
return []tea.Model{tsk}
}
@@ -1,102 +0,0 @@
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_handleFileIndexingStarted(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.FileIndexingStarted,
Source: "/some/path",
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.FileIndexingStarted,
Source: "/some/path",
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)
gotModel := 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)
})
}
}
@@ -1,190 +0,0 @@
package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
stereoscopeParsers "github.com/anchore/stereoscope/pkg/event/parsers"
"github.com/anchore/stereoscope/pkg/image/containerd"
"github.com/anchore/syft/internal/log"
)
var _ interface {
progress.Stager
progress.Progressable
} = (*containerdPullProgressAdapter)(nil)
type containerdPullStatus interface {
Complete() bool
Layers() []containerd.LayerID
Current(containerd.LayerID) progress.Progressable
}
type containerdPullProgressAdapter struct {
status containerdPullStatus
formatter containerdPullStatusFormatter
}
type containerdPullStatusFormatter struct {
auxInfoStyle lipgloss.Style
pullCompletedStyle lipgloss.Style
pullDownloadStyle lipgloss.Style
pullStageChars []string
layerCaps []string
}
func (m *Handler) handlePullContainerdImage(e partybus.Event) []tea.Model {
_, pullStatus, err := stereoscopeParsers.ParsePullContainerdImage(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
if pullStatus == nil {
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Pull image",
Running: "Pulling image",
Success: "Pulled image",
},
taskprogress.WithStagedProgressable(
newContainerdPullProgressAdapter(pullStatus),
),
)
tsk.HintStyle = lipgloss.NewStyle()
tsk.HintEndCaps = nil
return []tea.Model{tsk}
}
func newContainerdPullProgressAdapter(status *containerd.PullStatus) *containerdPullProgressAdapter {
return &containerdPullProgressAdapter{
status: status,
formatter: newContainerdPullStatusFormatter(),
}
}
func newContainerdPullStatusFormatter() containerdPullStatusFormatter {
return containerdPullStatusFormatter{
auxInfoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
pullCompletedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#fcba03")),
pullDownloadStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
pullStageChars: strings.Split("▁▃▄▅▆▇█", ""),
layerCaps: strings.Split("▕▏", ""),
}
}
func (d containerdPullProgressAdapter) Size() int64 {
return -1
}
func (d containerdPullProgressAdapter) Current() int64 {
return 1
}
func (d containerdPullProgressAdapter) Error() error {
if d.status.Complete() {
return progress.ErrCompleted
}
// TODO: return intermediate error indications
return nil
}
func (d containerdPullProgressAdapter) Stage() string {
return d.formatter.Render(d.status)
}
// Render crafts the given docker image pull status summarized into a single line.
func (f containerdPullStatusFormatter) Render(pullStatus containerdPullStatus) string {
var size, current uint64
layers := pullStatus.Layers()
status := make(map[containerd.LayerID]progress.Progressable)
completed := make([]string, len(layers))
// fetch the current state
for idx, layer := range layers {
completed[idx] = " "
status[layer] = pullStatus.Current(layer)
}
numCompleted := 0
for idx, layer := range layers {
prog := status[layer]
curN := prog.Current()
curSize := prog.Size()
if progress.IsCompleted(prog) {
input := f.pullStageChars[len(f.pullStageChars)-1]
completed[idx] = f.formatPullPhase(prog.Error() != nil, input)
} else if curN != 0 {
var ratio float64
switch {
case curN == 0 || curSize < 0:
ratio = 0
case curN >= curSize:
ratio = 1
default:
ratio = float64(curN) / float64(curSize)
}
i := int(ratio * float64(len(f.pullStageChars)-1))
input := f.pullStageChars[i]
completed[idx] = f.formatPullPhase(status[layer].Error() != nil, input)
}
if progress.IsErrCompleted(status[layer].Error()) {
numCompleted++
}
}
for _, layer := range layers {
prog := status[layer]
size += uint64(prog.Size())
current += uint64(prog.Current())
}
var progStr, auxInfo string
if len(layers) > 0 {
render := strings.Join(completed, "")
prefix := f.pullCompletedStyle.Render(fmt.Sprintf("%d Layers", len(layers)))
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s / %s]", humanize.Bytes(current), humanize.Bytes(size)))
if len(layers) == numCompleted {
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s] Extracting...", humanize.Bytes(size)))
}
progStr = fmt.Sprintf("%s%s%s%s", prefix, f.layerCap(false), render, f.layerCap(true))
}
return progStr + auxInfo
}
// formatPullPhase returns a single character that represents the status of a layer pull.
func (f containerdPullStatusFormatter) formatPullPhase(completed bool, inputStr string) string {
if completed {
return f.pullCompletedStyle.Render(f.pullStageChars[len(f.pullStageChars)-1])
}
return f.pullDownloadStyle.Render(inputStr)
}
func (f containerdPullStatusFormatter) layerCap(end bool) string {
l := len(f.layerCaps)
if l == 0 {
return ""
}
if end {
return f.layerCaps[l-1]
}
return f.layerCaps[0]
}
-201
View File
@@ -1,201 +0,0 @@
package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
stereoscopeParsers "github.com/anchore/stereoscope/pkg/event/parsers"
"github.com/anchore/stereoscope/pkg/image/docker"
"github.com/anchore/syft/internal/log"
)
var _ interface {
progress.Stager
progress.Progressable
} = (*dockerPullProgressAdapter)(nil)
type dockerPullStatus interface {
Complete() bool
Layers() []docker.LayerID
Current(docker.LayerID) docker.LayerState
}
type dockerPullProgressAdapter struct {
status dockerPullStatus
formatter dockerPullStatusFormatter
}
type dockerPullStatusFormatter struct {
auxInfoStyle lipgloss.Style
dockerPullCompletedStyle lipgloss.Style
dockerPullDownloadStyle lipgloss.Style
dockerPullExtractStyle lipgloss.Style
dockerPullStageChars []string
layerCaps []string
}
func (m *Handler) handlePullDockerImage(e partybus.Event) []tea.Model {
_, pullStatus, err := stereoscopeParsers.ParsePullDockerImage(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Pull image",
Running: "Pulling image",
Success: "Pulled image",
},
taskprogress.WithStagedProgressable(
newDockerPullProgressAdapter(pullStatus),
),
)
tsk.HintStyle = lipgloss.NewStyle()
tsk.HintEndCaps = nil
return []tea.Model{tsk}
}
func newDockerPullProgressAdapter(status dockerPullStatus) *dockerPullProgressAdapter {
return &dockerPullProgressAdapter{
status: status,
formatter: newDockerPullStatusFormatter(),
}
}
func newDockerPullStatusFormatter() dockerPullStatusFormatter {
return dockerPullStatusFormatter{
auxInfoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
dockerPullCompletedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#fcba03")),
dockerPullDownloadStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")),
dockerPullExtractStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")),
dockerPullStageChars: strings.Split("▁▃▄▅▆▇█", ""),
layerCaps: strings.Split("▕▏", ""),
}
}
func (d dockerPullProgressAdapter) Size() int64 {
return -1
}
func (d dockerPullProgressAdapter) Current() int64 {
return 1
}
func (d dockerPullProgressAdapter) Error() error {
if d.status.Complete() {
return progress.ErrCompleted
}
// TODO: return intermediate error indications
return nil
}
func (d dockerPullProgressAdapter) Stage() string {
return d.formatter.Render(d.status)
}
// Render crafts the given docker image pull status summarized into a single line.
func (f dockerPullStatusFormatter) Render(pullStatus dockerPullStatus) string {
var size, current uint64
layers := pullStatus.Layers()
status := make(map[docker.LayerID]docker.LayerState)
completed := make([]string, len(layers))
// fetch the current state
for idx, layer := range layers {
completed[idx] = " "
status[layer] = pullStatus.Current(layer)
}
numCompleted := 0
for idx, layer := range layers {
prog := status[layer].PhaseProgress
curN := prog.Current()
curSize := prog.Size()
if progress.IsCompleted(prog) {
input := f.dockerPullStageChars[len(f.dockerPullStageChars)-1]
completed[idx] = f.formatDockerPullPhase(status[layer].Phase, input)
} else if curN != 0 {
var ratio float64
switch {
case curN == 0 || curSize < 0:
ratio = 0
case curN >= curSize:
ratio = 1
default:
ratio = float64(curN) / float64(curSize)
}
i := int(ratio * float64(len(f.dockerPullStageChars)-1))
input := f.dockerPullStageChars[i]
completed[idx] = f.formatDockerPullPhase(status[layer].Phase, input)
}
if progress.IsErrCompleted(status[layer].DownloadProgress.Error()) {
numCompleted++
}
}
for _, layer := range layers {
prog := status[layer].DownloadProgress
size += uint64(prog.Size())
current += uint64(prog.Current())
}
var progStr, auxInfo string
if len(layers) > 0 {
render := strings.Join(completed, "")
prefix := f.dockerPullCompletedStyle.Render(fmt.Sprintf("%d Layers", len(layers)))
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s / %s]", humanize.Bytes(current), humanize.Bytes(size)))
if len(layers) == numCompleted {
auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s] Extracting...", humanize.Bytes(size)))
}
progStr = fmt.Sprintf("%s%s%s%s", prefix, f.layerCap(false), render, f.layerCap(true))
}
return progStr + auxInfo
}
// formatDockerPullPhase returns a single character that represents the status of a layer pull.
func (f dockerPullStatusFormatter) formatDockerPullPhase(phase docker.PullPhase, inputStr string) string {
switch phase {
case docker.WaitingPhase:
// ignore any progress related to waiting
return " "
case docker.PullingFsPhase, docker.DownloadingPhase:
return f.dockerPullDownloadStyle.Render(inputStr)
case docker.DownloadCompletePhase:
return f.dockerPullDownloadStyle.Render(f.dockerPullStageChars[len(f.dockerPullStageChars)-1])
case docker.ExtractingPhase:
return f.dockerPullExtractStyle.Render(inputStr)
case docker.VerifyingChecksumPhase, docker.PullCompletePhase:
return f.dockerPullCompletedStyle.Render(inputStr)
case docker.AlreadyExistsPhase:
return f.dockerPullCompletedStyle.Render(f.dockerPullStageChars[len(f.dockerPullStageChars)-1])
default:
return inputStr
}
}
func (f dockerPullStatusFormatter) layerCap(end bool) string {
l := len(f.layerCaps)
if l == 0 {
return ""
}
if end {
return f.layerCaps[l-1]
}
return f.layerCaps[0]
}
@@ -1,163 +0,0 @@
package ui
import (
"testing"
"github.com/gkampitakis/go-snaps/snaps"
"github.com/wagoodman/go-progress"
"github.com/anchore/stereoscope/pkg/image/docker"
)
var _ dockerPullStatus = (*mockDockerPullStatus)(nil)
type mockDockerPullStatus struct {
complete bool
layers []docker.LayerID
current map[docker.LayerID]docker.LayerState
}
func (m mockDockerPullStatus) Complete() bool {
return m.complete
}
func (m mockDockerPullStatus) Layers() []docker.LayerID {
return m.layers
}
func (m mockDockerPullStatus) Current(id docker.LayerID) docker.LayerState {
return m.current[id]
}
func Test_dockerPullStatusFormatter_Render(t *testing.T) {
tests := []struct {
name string
status dockerPullStatus
}{
{
name: "pulling",
status: func() dockerPullStatus {
complete := progress.NewManual(10)
complete.Set(10)
complete.SetCompleted()
quarter := progress.NewManual(10)
quarter.Set(2)
half := progress.NewManual(10)
half.Set(6)
empty := progress.NewManual(10)
return mockDockerPullStatus{
complete: false,
layers: []docker.LayerID{
"sha256:1",
"sha256:2",
"sha256:3",
},
current: map[docker.LayerID]docker.LayerState{
"sha256:1": {
Phase: docker.ExtractingPhase,
PhaseProgress: half,
DownloadProgress: complete,
},
"sha256:2": {
Phase: docker.DownloadingPhase,
PhaseProgress: quarter,
DownloadProgress: quarter,
},
"sha256:3": {
Phase: docker.WaitingPhase,
PhaseProgress: empty,
DownloadProgress: empty,
},
},
}
}(),
},
{
name: "download complete",
status: func() dockerPullStatus {
complete := progress.NewManual(10)
complete.Set(10)
complete.SetCompleted()
quarter := progress.NewManual(10)
quarter.Set(2)
half := progress.NewManual(10)
half.Set(6)
empty := progress.NewManual(10)
return mockDockerPullStatus{
complete: false,
layers: []docker.LayerID{
"sha256:1",
"sha256:2",
"sha256:3",
},
current: map[docker.LayerID]docker.LayerState{
"sha256:1": {
Phase: docker.ExtractingPhase,
PhaseProgress: complete,
DownloadProgress: complete,
},
"sha256:2": {
Phase: docker.ExtractingPhase,
PhaseProgress: quarter,
DownloadProgress: complete,
},
"sha256:3": {
Phase: docker.ExtractingPhase,
PhaseProgress: empty,
DownloadProgress: complete,
},
},
}
}(),
},
{
name: "complete",
status: func() dockerPullStatus {
complete := progress.NewManual(10)
complete.Set(10)
complete.SetCompleted()
return mockDockerPullStatus{
complete: true,
layers: []docker.LayerID{
"sha256:1",
"sha256:2",
"sha256:3",
},
current: map[docker.LayerID]docker.LayerState{
"sha256:1": {
Phase: docker.PullCompletePhase,
PhaseProgress: complete,
DownloadProgress: complete,
},
"sha256:2": {
Phase: docker.PullCompletePhase,
PhaseProgress: complete,
DownloadProgress: complete,
},
"sha256:3": {
Phase: docker.PullCompletePhase,
PhaseProgress: complete,
DownloadProgress: complete,
},
},
}
}(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newDockerPullStatusFormatter()
snaps.MatchSnapshot(t, f.Render(tt.status))
})
}
}
-33
View File
@@ -1,33 +0,0 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/go-partybus"
"github.com/anchore/bubbly/bubbles/taskprogress"
stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers"
"github.com/anchore/syft/internal/log"
)
func (m *Handler) handleReadImage(e partybus.Event) []tea.Model {
imgMetadata, prog, err := stereoEventParsers.ParseReadImage(e)
if err != nil {
log.WithFields("error", err).Warn("unable to parse event")
return nil
}
tsk := m.newTaskProgress(
taskprogress.Title{
Default: "Parse image",
Running: "Parsing image",
Success: "Parsed image",
},
taskprogress.WithProgress(prog),
)
if imgMetadata != nil {
tsk.Context = []string{imgMetadata.ID}
}
return []tea.Model{tsk}
}
-120
View File
@@ -1,120 +0,0 @@
package ui
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/gkampitakis/go-snaps/snaps"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/stretchr/testify/require"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/bubbly/bubbles/taskprogress"
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
"github.com/anchore/stereoscope/pkg/image"
)
func TestHandler_handleReadImage(t *testing.T) {
tests := []struct {
name string
eventFn func(*testing.T) partybus.Event
iterations int
}{
{
name: "read image in progress",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(50)
src := image.Metadata{
ID: "id",
Size: 42,
Config: v1.ConfigFile{
Architecture: "arch",
Author: "auth",
Container: "cont",
OS: "os",
OSVersion: "os-ver",
Variant: "vari",
},
MediaType: "media",
ManifestDigest: "digest",
Architecture: "arch",
Variant: "var",
OS: "os",
}
return partybus.Event{
Type: stereoscopeEvent.ReadImage,
Source: src,
Value: prog,
}
},
},
{
name: "read image complete",
eventFn: func(t *testing.T) partybus.Event {
prog := &progress.Manual{}
prog.SetTotal(100)
prog.Set(100)
prog.SetCompleted()
src := image.Metadata{
ID: "id",
Size: 42,
Config: v1.ConfigFile{
Architecture: "arch",
Author: "auth",
Container: "cont",
OS: "os",
OSVersion: "os-ver",
Variant: "vari",
},
MediaType: "media",
ManifestDigest: "digest",
Architecture: "arch",
Variant: "var",
OS: "os",
}
return partybus.Event{
Type: stereoscopeEvent.ReadImage,
Source: src,
Value: prog,
}
},
},
}
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)
gotModel := 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)
})
}
}
-80
View File
@@ -1,80 +0,0 @@
package ui
import (
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/wagoodman/go-partybus"
"github.com/anchore/bubbly"
"github.com/anchore/bubbly/bubbles/taskprogress"
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
syftEvent "github.com/anchore/syft/syft/event"
)
var _ interface {
bubbly.EventHandler
bubbly.MessageListener
bubbly.HandleWaiter
} = (*Handler)(nil)
type HandlerConfig struct {
TitleWidth int
AdjustDefaultTask func(taskprogress.Model) taskprogress.Model
}
type Handler struct {
WindowSize tea.WindowSizeMsg
Running *sync.WaitGroup
Config HandlerConfig
bubbly.EventHandler
onNewCatalogerTask *sync.Once
}
func DefaultHandlerConfig() HandlerConfig {
return HandlerConfig{
TitleWidth: 30,
}
}
func New(cfg HandlerConfig) *Handler {
d := bubbly.NewEventDispatcher()
h := &Handler{
EventHandler: d,
Running: &sync.WaitGroup{},
Config: cfg,
onNewCatalogerTask: &sync.Once{},
}
// 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,
})
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
}
}
func (m *Handler) Wait() {
m.Running.Wait()
}
-19
View File
@@ -1,19 +0,0 @@
package ui
import "github.com/anchore/bubbly/bubbles/taskprogress"
func (m Handler) newTaskProgress(title taskprogress.Title, opts ...taskprogress.Option) taskprogress.Model {
tsk := taskprogress.New(m.Running, opts...)
tsk.HideProgressOnSuccess = true
tsk.HideStageOnSuccess = true
tsk.WindowSize = m.WindowSize
tsk.TitleWidth = m.Config.TitleWidth
tsk.TitleOptions = title
if m.Config.AdjustDefaultTask != nil {
tsk = m.Config.AdjustDefaultTask(tsk)
}
return tsk
}
-56
View File
@@ -1,56 +0,0 @@
package ui
import (
"reflect"
"sync"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func runModel(t testing.TB, m tea.Model, iterations int, message tea.Msg, h ...*sync.WaitGroup) tea.Model {
t.Helper()
if iterations == 0 {
iterations = 1
}
m.Init()
var cmd tea.Cmd = func() tea.Msg {
return message
}
for _, each := range h {
if each != nil {
each.Wait()
}
}
for i := 0; cmd != nil && i < iterations; i++ {
msgs := flatten(cmd())
var nextCmds []tea.Cmd
var next tea.Cmd
for _, msg := range msgs {
t.Logf("Message: %+v %+v\n", reflect.TypeOf(msg), msg)
m, next = m.Update(msg)
nextCmds = append(nextCmds, next)
}
cmd = tea.Batch(nextCmds...)
}
return m
}
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}
}
}
return msgs
}
-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
}
-284
View File
@@ -1,284 +0,0 @@
package commands
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/wagoodman/go-partybus"
"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/internal/ui"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/event/monitor"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"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/sbom"
)
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
`
attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp
attestHelp = attestExample + attestSchemeHelp
cosignBinName = "cosign"
)
type attestOptions 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"`
Attest options.Attest `yaml:"attest" mapstructure:"attest"`
Cache options.Cache `json:"-" yaml:"cache" mapstructure:"cache"`
}
func Attest(app clio.Application) *cobra.Command {
id := app.ID()
opts := defaultAttestOptions()
// template format explicitly not allowed
opts.Format.Template.Enabled = false
return app.SetupCommand(&cobra.Command{
Use: "attest --output [FORMAT] <IMAGE>",
Short: "Generate an SBOM as an attestation for the given [SOURCE] container image",
Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation that will be uploaded to the image registry",
Example: internal.Tprintf(attestHelp, map[string]interface{}{
"appName": id.Name,
"command": "attest",
}),
Args: validateScanArgs,
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])
},
}, &opts)
}
func defaultAttestOptions() attestOptions {
return attestOptions{
Output: defaultAttestOutputOptions(),
UpdateCheck: options.DefaultUpdateCheck(),
Catalog: options.DefaultCatalog(),
Cache: options.DefaultCache(),
}
}
func defaultAttestOutputOptions() options.Output {
return options.Output{
AllowMultipleOutputs: false,
AllowToFile: false,
AllowableOptions: []string{
string(syftjson.ID),
string(cyclonedxjson.ID),
string(spdxjson.ID),
string(spdxtagvalue.ID),
},
Outputs: []string{syftjson.ID.String()},
OutputFile: options.OutputFile{ //nolint:staticcheck
Enabled: false, // explicitly not allowed
},
Format: options.DefaultFormat(),
}
}
func runAttest(ctx context.Context, 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")
}
// this is the file that will contain the SBOM being attested
f, err := os.CreateTemp("", "syft-attest-")
if err != nil {
return fmt.Errorf("unable to create temp file: %w", err)
}
defer os.Remove(f.Name())
s, err := generateSBOMForAttestation(ctx, id, &opts.Catalog, userInput)
if err != nil {
return fmt.Errorf("unable to build SBOM: %w", err)
}
if err = writeSBOMToFormattedFile(s, f, opts); err != nil {
return fmt.Errorf("unable to write SBOM to file: %w", err)
}
if err = createAttestation(f.Name(), opts, userInput); err != nil {
return err
}
bus.Notify("Attestation has been created, please check your registry for the output or use the cosign command:")
bus.Notify(fmt.Sprintf("cosign download attestation %s", userInput))
return nil
}
func writeSBOMToFormattedFile(s *sbom.SBOM, sbomFile io.Writer, opts *attestOptions) error {
if sbomFile == nil {
return fmt.Errorf("no output file provided")
}
encs, err := opts.Format.Encoders()
if err != nil {
return fmt.Errorf("unable to create encoders: %w", err)
}
encoders := format.NewEncoderCollection(encs...)
encoder := encoders.GetByString(opts.Outputs[0])
if encoder == nil {
return fmt.Errorf("unable to find encoder for %q", opts.Outputs[0])
}
if err = encoder.Encode(sbomFile, *s); err != nil {
return fmt.Errorf("unable to encode SBOM: %w", err)
}
return nil
}
func createAttestation(sbomFilepath string, opts *attestOptions, userInput string) error {
execCmd, err := attestCommand(sbomFilepath, opts, userInput)
if err != nil {
return fmt.Errorf("unable to craft attest command: %w", err)
}
log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation")
// bus adapter for ui to hook into stdout via an os pipe
r, w, err := os.Pipe()
if err != nil {
return fmt.Errorf("unable to create os pipe: %w", err)
}
defer w.Close()
mon := progress.NewManual(-1)
bus.Publish(
partybus.Event{
Type: event.AttestationStarted,
Source: monitor.GenericTask{
Title: monitor.Title{
Default: "Create attestation",
WhileRunning: "Creating attestation",
OnSuccess: "Created attestation",
},
Context: "cosign",
},
Value: &monitor.ShellProgress{
Reader: r,
Progressable: mon,
},
},
)
execCmd.Stdout = w
execCmd.Stderr = w
// attest the SBOM
err = execCmd.Run()
if err != nil {
mon.SetError(err)
return fmt.Errorf("unable to attest SBOM: %w", err)
}
mon.SetCompleted()
return nil
}
func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) (*exec.Cmd, error) {
outputNames := opts.OutputNameSet()
var outputName string
switch outputNames.Size() {
case 0:
return nil, fmt.Errorf("no output format specified")
case 1:
outputName = outputNames.List()[0]
default:
return nil, fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
}
args := []string{"attest", userInput, "--predicate", sbomFilepath, "--type", predicateType(outputName), "-y"}
if opts.Attest.Key != "" {
args = append(args, "--key", opts.Attest.Key.String())
}
execCmd := exec.Command(cosignBinName, args...)
execCmd.Env = os.Environ()
if opts.Attest.Key != "" {
execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password))
} else {
// no key provided, use cosign's keyless mode
execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1")
}
return execCmd, nil
}
func predicateType(outputName string) string {
// select the 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":
return "cyclonedx"
case "spdx-tag-value", "spdx-tv":
return "spdx"
case "spdx-json", "json":
return "spdxjson"
default:
return "custom"
}
}
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)
if err != nil {
return nil, 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)
if err != nil {
return nil, err
}
if s == nil {
return nil, fmt.Errorf("no SBOM produced for %q", userInput)
}
return s, nil
}
func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
-331
View File
@@ -1,331 +0,0 @@
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/syft/sbom"
"github.com/anchore/syft/syft/source"
)
func Test_writeSBOMToFormattedFile(t *testing.T) {
type args struct {
s *sbom.SBOM
opts *attestOptions
}
tests := []struct {
name string
args args
wantSbomFile string
wantErr bool
}{
{
name: "go case",
args: args{
opts: &attestOptions{
Output: func() options.Output {
def := defaultAttestOutputOptions()
def.Outputs = []string{"syft-json"}
return def
}(),
},
s: &sbom.SBOM{
Artifacts: sbom.Artifacts{},
Relationships: nil,
Source: source.Description{
ID: "source-id",
Name: "source-name",
Version: "source-version",
},
Descriptor: sbom.Descriptor{
Name: "syft-test",
Version: "non-version",
},
},
},
wantSbomFile: `{
"artifacts": [],
"artifactRelationships": [],
"source": {
"id": "source-id",
"name": "source-name",
"version": "source-version",
"type": "",
"metadata": null
},
"distro": {},
"descriptor": {
"name": "syft-test",
"version": "non-version"
},
"schema": {}
}`,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sbomFile := &bytes.Buffer{}
err := writeSBOMToFormattedFile(tt.args.s, sbomFile, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("writeSBOMToFormattedFile() error = %v, wantErr %v", err, tt.wantErr)
return
}
// redact the schema block
re := regexp.MustCompile(`(?s)"schema":\W*\{.*?},?`)
subject := re.ReplaceAllString(sbomFile.String(), `"schema":{}`)
assert.JSONEq(t, tt.wantSbomFile, subject)
})
}
}
func Test_attestCommand(t *testing.T) {
cmdPrefix := cosignBinName
lp, err := exec.LookPath(cosignBinName)
if err == nil {
cmdPrefix = lp
}
fullCmd := func(args string) string {
return fmt.Sprintf("%s %s", cmdPrefix, args)
}
type args struct {
sbomFilepath string
opts attestOptions
userInput string
}
tests := []struct {
name string
args args
wantCmd string
wantEnvVars map[string]string
notEnvVars []string
wantErr require.ErrorAssertionFunc
}{
{
name: "with key and password",
args: args{
userInput: "myimage",
sbomFilepath: "/tmp/sbom-filepath.json",
opts: func() attestOptions {
def := defaultAttestOptions()
def.Outputs = []string{"syft-json"}
def.Attest.Key = "key"
def.Attest.Password = "password"
return def
}(),
},
wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y --key key"),
wantEnvVars: map[string]string{
"COSIGN_PASSWORD": "password",
},
notEnvVars: []string{
"COSIGN_EXPERIMENTAL", // only for keyless
},
},
{
name: "keyless",
args: args{
userInput: "myimage",
sbomFilepath: "/tmp/sbom-filepath.json",
opts: func() attestOptions {
def := defaultAttestOptions()
def.Outputs = []string{"syft-json"}
return def
}(),
},
wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y"),
wantEnvVars: map[string]string{
"COSIGN_EXPERIMENTAL": "1",
},
notEnvVars: []string{
"COSIGN_PASSWORD",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr == nil {
tt.wantErr = require.NoError
}
got, err := attestCommand(tt.args.sbomFilepath, &tt.args.opts, tt.args.userInput)
tt.wantErr(t, err)
if err != nil {
return
}
require.NotNil(t, got)
assert.Equal(t, tt.wantCmd, got.String())
gotEnv := strset.New(got.Env...)
for k, v := range tt.wantEnvVars {
assert.True(t, gotEnv.Has(fmt.Sprintf("%s=%s", k, v)))
}
for _, k := range tt.notEnvVars {
for _, env := range got.Env {
fields := strings.Split(env, "=")
if fields[0] == k {
t.Errorf("attestCommand() unexpected environment variable %s", k)
}
}
}
})
}
}
func Test_predicateType(t *testing.T) {
tests := []struct {
name string
want string
}{
{
name: "cyclonedx-json",
want: "cyclonedx",
},
{
name: "spdx-tag-value",
want: "spdx",
},
{
name: "spdx-tv",
want: "spdx",
},
{
name: "spdx-json",
want: "spdxjson",
},
{
name: "json",
want: "spdxjson",
},
{
name: "syft-json",
want: "custom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, predicateType(tt.name), "predicateType(%v)", tt.name)
})
}
}
func Test_buildSBOMForAttestation(t *testing.T) {
// note: this test is only meant to test that the filter function is wired
// and not the correctness of the function in depth
type args struct {
id clio.Identification
opts *options.Catalog
userInput string
}
tests := []struct {
name string
args args
want *sbom.SBOM
wantErr require.ErrorAssertionFunc
}{
{
name: "do not allow directory scans",
args: args{
opts: func() *options.Catalog {
def := defaultAttestOptions()
return &def.Catalog
}(),
userInput: "dir:/tmp/something",
},
wantErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr == nil {
tt.wantErr = require.NoError
}
_, err := generateSBOMForAttestation(context.Background(), tt.args.id, tt.args.opts, tt.args.userInput)
tt.wantErr(t, err)
if err != nil {
return
}
})
}
}
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)
}
}
}
-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())
}
-102
View File
@@ -1,102 +0,0 @@
package commands
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/anchore/clio"
"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/log"
"github.com/anchore/syft/syft/format"
)
const (
convertExample = ` {{.appName}} {{.command}} img.syft.json -o spdx-json convert a syft SBOM to spdx-json, output goes to stdout
{{.appName}} {{.command}} img.syft.json -o cyclonedx-json=img.cdx.json convert a syft SBOM to CycloneDX, output is written to the file "img.cdx.json""
{{.appName}} {{.command}} - -o spdx-json convert an SBOM from STDIN to spdx-json
`
)
type ConvertOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
}
func Convert(app clio.Application) *cobra.Command {
id := app.ID()
opts := &ConvertOptions{
UpdateCheck: options.DefaultUpdateCheck(),
Output: options.DefaultOutput(),
}
return app.SetupCommand(&cobra.Command{
Use: "convert [SOURCE-SBOM] -o [FORMAT]",
Short: "Convert between SBOM formats",
Long: "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format. For more info about data loss between formats see https://github.com/anchore/syft#format-conversion-experimental",
Example: internal.Tprintf(convertExample, map[string]interface{}{
"appName": id.Name,
"command": "convert",
}),
Args: validateConvertArgs,
PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
RunE: func(_ *cobra.Command, args []string) error {
restoreStdout := ui.CaptureStdoutToTraceLog()
defer restoreStdout()
return RunConvert(opts, args[0])
},
}, opts)
}
func validateConvertArgs(cmd *cobra.Command, args []string) error {
return validateArgs(cmd, args, "an SBOM argument is required")
}
func RunConvert(opts *ConvertOptions, userInput string) error {
log.Warn("convert is an experimental feature, run `syft convert -h` for help")
writer, err := opts.SBOMWriter()
if err != nil {
return err
}
var reader io.ReadSeekCloser
if userInput == "-" {
// though os.Stdin is an os.File, it does not support seeking
// you will get errors such as "seek /dev/stdin: illegal seek".
// We need to buffer what we read.
reader = internal.NewBufferedSeeker(os.Stdin)
} else {
f, err := os.Open(userInput)
if err != nil {
return fmt.Errorf("failed to open SBOM file: %w", err)
}
defer func() {
_ = f.Close()
}()
reader = f
}
s, _, _, err := format.Decode(reader)
if err != nil {
return fmt.Errorf("failed to decode SBOM: %w", err)
}
if s == nil {
return fmt.Errorf("no SBOM produced")
}
if err := writer.Write(*s); err != nil {
return fmt.Errorf("failed to write SBOM: %w", err)
}
return nil
}
-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))
})
}
}

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