Compare commits

...
32 Commits
Author SHA1 Message Date
Alfredo Deza b3098f3423 Merge pull request #250 from anchore/issue-169
Add distro information to JSON presenter
2020-11-09 14:18:48 -05:00
Alfredo Deza c2cf4eb7b0 update tests for the new distro information
Signed-off-by: Alfredo Deza <adeza@anchore.com>
2020-11-09 12:16:00 -05:00
Alfredo Deza 1e79986188 json: update the document to include distro information
Signed-off-by: Alfredo Deza <adeza@anchore.com>
2020-11-09 12:16:00 -05:00
Dan Luhring 3699a917fd Merge pull request #254 from anchore/updates-to-mac-releases
Updates for macOS release process
2020-11-06 13:49:54 -05:00
Dan Luhring 501870f89f Update README for secure macOS releases
Signed-off-by: Dan Luhring <dan.luhring@anchore.com>
2020-11-06 13:40:31 -05:00
Dan Luhring c878cc361c Update install.sh to handle new macOS releases
Signed-off-by: Dan Luhring <dan.luhring@anchore.com>
2020-11-06 13:39:26 -05:00
Dan Luhring 8fb5b17dbd Use .Version in place of .Tag for release asset name
Signed-off-by: Dan Luhring <dan.luhring@anchore.com>
2020-11-06 13:36:02 -05:00
Alex Goodman b6eb589b78 Merge pull request #251 from anchore/add-rpm-file-info
Add RPM file info sourced from the RPM DB
2020-11-06 11:06:34 -05:00
Alex Goodman 6aba2f48d4 split out rpmdb file processing to helper + lint fixes
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-11-06 10:44:27 -05:00
Alex Goodman 0205e72be9 ensure resolvers ignore directories for "FilesBy*" methods
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-11-06 07:21:38 -05:00
Alex Goodman 8095cd9980 add rpmdb file info to cataloger
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-11-06 07:21:23 -05:00
Dan Luhring ecfc471ce5 Resolve security warning for macOS users (#249)
* Add support for macOS signing and notarization

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>

* Use Docker to run the changelog generator locally

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>
2020-11-04 15:47:55 -05:00
Alex Goodman a52750bdd3 enhance alpine file discovery (#248)
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-11-04 09:56:52 -05:00
Alex Goodman 773581704c bump threshold for inline compare for jenkins image
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-30 15:14:32 -04:00
Alex Goodman 6e98752c6b Merge pull request #247 from anchore/syft-java-cataloger-integration
General Java cataloger enhancements
2020-10-30 13:55:16 -04:00
Alex Goodman e2593cd6b7 remove extra fields from the compare script metadata namedtuple
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-30 10:59:53 -04:00
Alex Goodman 232cd13035 update tests for enhanced java pkg pairings
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-30 10:44:45 -04:00
Alex Goodman 2532928afa bump java compare testing thresholds
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-30 10:06:47 -04:00
Alex Goodman 03dbfb8dfb improve java name and version extraction as well as parent pkg pairing
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-30 08:12:25 -04:00
Alex Goodman a5cba13ddf enable more flexible java manifest structure (closer to the spec)
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-29 16:26:04 -04:00
Alex Goodman a4f22e65fc expand compare testing images
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-29 13:52:51 -04:00
Alex Goodman ab45be98b8 append java nested package names to the virtual path
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-29 13:52:34 -04:00
Alex Goodman fc991bc62e partial java comparison with extra metadata
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-29 12:40:49 -04:00
Alex Goodman 1230650771 allow for java manifest data to be optional
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-29 11:28:27 -04:00
Alex Goodman 452426d1d6 Merge pull request #245 from anchore/update-java-cataloger-for-engine
Java cataloger miscellaneous fixes
2020-10-28 17:36:44 -04:00
Alex Goodman 2675891110 remove parent java package from json && add java manifest section parsing
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-28 17:19:06 -04:00
Dan Luhring bffc4713a7 Update zip archive handling to match globs as expected (#244)
* Refactor zip file tests

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>

* Add glob support for leading slashes

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>

* Update zip testing to account for glob matching

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>

* Ignore .DS_STORE

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>

* Improve normalization of zip entry names

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>

* Rename zip test helpers file

Signed-off-by: Dan Luhring <dan.luhring@anchore.com>
2020-10-28 16:50:40 -04:00
Alex Goodman 62f6146c37 do not include optional fields
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-28 15:58:54 -04:00
Alex Goodman 8bcba7d830 update json schema and test for java virtualPath
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-28 15:25:30 -04:00
Alex Goodman 76c8f24090 add java virtual path
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-28 13:53:44 -04:00
Alex Goodman cc466e47da bump python version for acceptance tests
Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-27 14:53:52 -04:00
Alex Goodman f9407d0ce4 Add java/npm to inline comparison (#235)
* Adds java and npm package comparison
* Adds probable matching of extra packages syft found and missing packages that syft did not find (but inline did). This way there is a section of output that fuzzy-matches the package names to get a better sense of "real" problems (actual missing packages) vs slightly mismatched metadata during troubleshooting.
* Adds a set or probable missing packages to the report based on the probable matches (again, to aid in troubleshooting)
* Fixes image reference clean function to support references with registries
* Only shows metadata differences when the package was found by both inline and syft
* Splits the inline-compare code into more manageable pieces

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
2020-10-27 13:43:36 -04:00
61 changed files with 2425 additions and 998 deletions
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -eu
CI_HOME="/Users/runner"
if [[ "${HOME}" != "${CI_HOME}" ]]; then
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
fi
# Install gon (see https://github.com/mitchellh/gon for details).
brew tap mitchellh/gon
brew install mitchellh/gon/gon
# Write signing certificate to disk from environment variable.
CERT_FILE="$HOME/developer_id_certificate.p12"
echo -n "$APPLE_DEVELOPER_ID_CERT" | base64 --decode > "$CERT_FILE"
# In order to have all keychain interactions avoid an interactive user prompt, we need to control the password for the keychain in question, which means we need to create a new keychain into which we'll import the signing certificate and from which we'll later access this certificate during code signing.
EPHEMERAL_KEYCHAIN="ci-ephemeral-keychain"
EPHEMERAL_KEYCHAIN_PASSWORD="$(openssl rand -base64 100)"
security create-keychain -p "${EPHEMERAL_KEYCHAIN_PASSWORD}" "${EPHEMERAL_KEYCHAIN}"
# Import signing certificate into the keychain. (This is a pre-requisite for gon, which is invoked via goreleaser.)
EPHEMERAL_KEYCHAIN_FULL_PATH="$HOME/Library/Keychains/${EPHEMERAL_KEYCHAIN}-db"
security import "${CERT_FILE}" -k "${EPHEMERAL_KEYCHAIN_FULL_PATH}" -P "${APPLE_DEVELOPER_ID_CERT_PASS}" -T "$(command -v codesign)"
# Setting 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. (codesign is invoked by gon.)
security set-key-partition-list -S "apple-tool:,apple:" -s -k "${EPHEMERAL_KEYCHAIN_PASSWORD}" "${EPHEMERAL_KEYCHAIN_FULL_PATH}"
# 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 "${EPHEMERAL_KEYCHAIN_FULL_PATH}"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -eu
IS_SNAPSHOT="$1" # e.g. "true", "false"
if [[ "${IS_SNAPSHOT}" == "true" ]]; then
# This is a snapshot build —— skipping signing and notarization...
exit 0
fi
GON_CONFIG="$2" # e.g. "gon.hcl"
NEW_DMG_NAME="$3" # e.g. "./dist/syft-0.1.0.dmg"
ORIGINAL_DMG_NAME="./dist/output.dmg" # This should match dmg output_path in the gon config file.
gon "${GON_CONFIG}"
mv -v "${ORIGINAL_DMG_NAME}" "${NEW_DMG_NAME}"
+4 -9
View File
@@ -15,14 +15,9 @@ fi
echo "creating and publishing version file"
# create a version file for version-update checks
echo "${VERSION}" | tee ${DISTDIR}/VERSION
VERSION_FILE="${DISTDIR}/VERSION"
echo "${VERSION}" | tee "${VERSION_FILE}"
# upload the version file that supports the application version update check
docker run --rm \
-i \
-e AWS_DEFAULT_REGION=us-west-2 \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
-v $(pwd)/${DISTDIR}/:/distmount \
amazon/aws-cli \
s3 cp /distmount/VERSION s3://toolbox-data.anchore.io/${BIN}/releases/latest/VERSION
export AWS_DEFAULT_REGION=us-west-2
aws s3 cp "${VERSION_FILE}" s3://toolbox-data.anchore.io/${BIN}/releases/latest/VERSION
+6 -7
View File
@@ -14,7 +14,7 @@ env:
jobs:
Build-Snapshot-Artifacts:
runs-on: ubuntu-latest
runs-on: macos-latest # We're creating these snapshot builds on macOS to be consistent with our release workflow's build process, which also takes place on macOS (due to code signing requirements).
steps:
- uses: actions/setup-go@v2
@@ -39,9 +39,6 @@ jobs:
if: steps.bootstrap-cache.outputs.cache-hit != 'true'
run: make bootstrap
- name: Bootstrap CI dependencies
run: make ci-bootstrap
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v2
@@ -79,7 +76,8 @@ jobs:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
Acceptance-Linux:
needs: [ Build-Snapshot-Artifacts ]
runs-on: ubuntu-latest
# come Nov 30 2020 ubuntu-latest will be ubuntu-20.04, until then it needs to be explicitly referenced due to python 3.7 specific features being used
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
@@ -129,7 +127,8 @@ jobs:
# Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline
Inline-Compare:
needs: [ Build-Snapshot-Artifacts ]
runs-on: ubuntu-latest
# come Nov 30 2020 ubuntu-latest will be ubuntu-20.04, until then it needs to be explicitly referenced due to python 3.7 specific features being used
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
@@ -159,4 +158,4 @@ jobs:
text: The syft acceptance tests have failed tragically!
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TOOLBOX_WEBHOOK_URL }}
if: ${{ failure() }}
if: ${{ failure() }}
+8 -7
View File
@@ -13,7 +13,7 @@ env:
jobs:
wait-for-checks:
runs-on: ubuntu-latest
runs-on: ubuntu-latest # This OS choice is arbitrary. None of the steps in this job are specific to either Linux or macOS.
steps:
- uses: actions/checkout@v2
@@ -82,7 +82,7 @@ jobs:
release:
needs: [ wait-for-checks ]
runs-on: ubuntu-latest
runs-on: macos-latest # Due to our code signing process, it's vital that we run our release steps on macOS.
steps:
- uses: actions/setup-go@v2
@@ -93,6 +93,7 @@ jobs:
with:
fetch-depth: 0
# We are expecting this cache to have been created during the "Build-Snapshot-Artifacts" job in the "Acceptance" workflow.
- name: Restore bootstrap cache
id: cache
uses: actions/cache@v2
@@ -105,10 +106,6 @@ jobs:
${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }}-
${{ runner.os }}-go-${{ env.GO_VERSION }}-
- name: Bootstrap dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: make ci-bootstrap
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v2
@@ -132,6 +129,10 @@ jobs:
SIGNING_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
AWS_ACCESS_KEY_ID: ${{ secrets.TOOLBOX_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TOOLBOX_AWS_SECRET_ACCESS_KEY }}
APPLE_DEVELOPER_ID_CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT }} # Used during macOS code signing.
APPLE_DEVELOPER_ID_CERT_PASS: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASS }} # Used during macOS code signing.
AC_USERNAME: ${{ secrets.ENG_CI_APPLE_ID }} # Used during macOS notarization.
AC_PASSWORD: ${{ secrets.ENG_CI_APPLE_ID_PASS }} # Used during macOS notarization.
- uses: 8398a7/action-slack@v3
with:
@@ -145,4 +146,4 @@ jobs:
- uses: actions/upload-artifact@v2
with:
name: artifacts
path: dist/**/*
path: dist/**/*
+3
View File
@@ -29,3 +29,6 @@ coverage.txt
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# macOS Finder metadata
.DS_STORE
+30 -4
View File
@@ -6,18 +6,37 @@ release:
# If set to true, will not auto-publish the release. This is done to allow us to review the changelog before publishing.
draft: true
signs:
- artifacts: checksum
args: ["--output", "${signature}", "--detach-sign", "${artifact}"]
# This ensures any macOS signed artifacts get included with the release.
extra_files:
- glob: "./dist/*.dmg"
builds:
- binary: syft
id: syft
env:
- CGO_ENABLED=0
goos:
# windows not supported yet (due to jotframe)
# - windows
- linux
goarch:
- amd64
# Set the modified timestamp on the output binary to the git timestamp (to ensure a reproducible build)
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags: |
-w
-s
-extldflags '-static'
-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.gitTreeState={{.Env.BUILD_GIT_TREE_STATE}}
# For more info on this macOS build, see: https://github.com/mitchellh/gon#usage-with-goreleaser
- binary: syft
id: syft-macos
env:
- CGO_ENABLED=0
goos:
- darwin
goarch:
- amd64
@@ -31,6 +50,12 @@ builds:
-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.gitTreeState={{.Env.BUILD_GIT_TREE_STATE}}
hooks:
post: ./.github/scripts/mac-sign-and-notarize.sh "{{.IsSnapshot}}" "gon.hcl" "./dist/syft_{{.Version}}_{{.Target}}.dmg"
signs:
- artifacts: checksum
args: ["--output", "${signature}", "--detach-sign", "${artifact}"]
nfpms:
- license: "Apache 2.0"
@@ -50,7 +75,8 @@ brews:
archives:
- format: tar.gz
builds:
- syft # i.e. Linux only
format_overrides:
- goos: windows
format: zip
+14 -6
View File
@@ -74,6 +74,10 @@ help:
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
.PHONY: bootstrap
bootstrap: ## Download and install all go dependencies (+ prep tooling in the ./tmp dir)
$(call title,Bootstrapping dependencies)
@@ -226,8 +230,7 @@ acceptance-test-rpm-package-install: $(SNAPSHOTDIR)
changelog-release:
@echo "Last tag: $(SECOND_TO_LAST_TAG)"
@echo "Current tag: $(VERSION)"
@docker run -i --rm \
-v "$(shell pwd)":/usr/local/src/your-app ferrarimarco/github-changelog-generator \
@github_changelog_generator \
--user anchore \
--project $(BIN) \
-t ${GITHUB_TOKEN} \
@@ -241,8 +244,9 @@ changelog-release:
.PHONY: changelog-unreleased
changelog-unreleased: ## show the current changelog that will be produced on the next release (note: requires GITHUB_TOKEN set)
@docker run -it --rm \
-v "$(shell pwd)":/usr/local/src/your-app ferrarimarco/github-changelog-generator \
@docker run -it --rm \
-v "$(shell pwd)":/usr/local/src/your-app \
ferrarimarco/github-changelog-generator \
--user anchore \
--project $(BIN) \
-t ${GITHUB_TOKEN} \
@@ -258,8 +262,12 @@ changelog-unreleased: ## show the current changelog that will be produced on the
/CHANGELOG.md
.PHONY: release
release: clean-dist changelog-release ## Build and publish final binaries and packages
release: clean-dist ci-bootstrap-mac changelog-release ## Build and publish final binaries and packages. Intended to be run only on macOS.
$(call title,Publishing release artifacts)
# Prepare for macOS-specific signing process
.github/scripts/mac-prepare-for-signing.sh
# create a config with the dist dir overridden
echo "dist: $(DISTDIR)" > $(TEMPDIR)/goreleaser.yaml
cat .goreleaser.yaml >> $(TEMPDIR)/goreleaser.yaml
@@ -290,4 +298,4 @@ clean-dist:
.PHONY: clean-json-schema-examples
clean-json-schema-examples:
rm -f schema/json/examples/*
rm -f schema/json/examples/*
+2 -7
View File
@@ -54,7 +54,7 @@ Where the `format`s available are:
## Installation
**Recommended**
**Recommended (macOS and Linux)**
```bash
# install the latest version to /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
@@ -63,17 +63,12 @@ curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b <SOME_BIN_PATH> <RELEASE_VERSION>
```
**macOS**
**Homebrew (macOS)**
```bash
brew tap anchore/syft
brew install syft
```
You may experience a "macOS cannot verify app is free from malware" error upon running Syft because it is not yet signed and notarized. You can override this using `xattr`.
```bash
xattr -rd com.apple.quarantine syft
```
## Configuration
Configuration search paths:
+3 -3
View File
@@ -4,14 +4,14 @@ go 1.14
require (
github.com/adrg/xdg v0.2.1
github.com/anchore/go-rpmdb v0.0.0-20200811175839-cbc751c28e8e
github.com/anchore/go-rpmdb v0.0.0-20201106153645-0043963c2e12
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b
github.com/anchore/stereoscope v0.0.0-20200925184903-c82da54e98fe
github.com/anchore/stereoscope v0.0.0-20201106140100-12e75c48f409
github.com/bmatcuk/doublestar v1.3.3
github.com/docker/docker v17.12.0-ce-rc1.0.20200309214505-aa6a9891b09c+incompatible
github.com/dustin/go-humanize v1.0.0
github.com/go-test/deep v1.0.6
github.com/go-test/deep v1.0.7
github.com/google/uuid v1.1.1
github.com/gookit/color v1.2.7
github.com/hashicorp/go-multierror v1.1.0
+6
View File
@@ -126,12 +126,16 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/anchore/go-rpmdb v0.0.0-20200811175839-cbc751c28e8e h1:kty6r0R2JeaNPeWKSYDC+HW3hkqwFh4PP5TQ8pUPYFw=
github.com/anchore/go-rpmdb v0.0.0-20200811175839-cbc751c28e8e/go.mod h1:iYuIG0Nai8dR0ri3LhZQKUyO1loxUWAGvoWhXDmjy1A=
github.com/anchore/go-rpmdb v0.0.0-20201106153645-0043963c2e12 h1:xbeIbn5F52JVx3RUIajxCj8b0y+9lywspql4sFhcxWQ=
github.com/anchore/go-rpmdb v0.0.0-20201106153645-0043963c2e12/go.mod h1:juoyWXIj7sJ1IDl4E/KIfyLtovbs5XQVSIdaQifFQT8=
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 h1:VzprUTpc0vW0nnNKJfJieyH/TZ9UYAnTZs5/gHTdAe8=
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04/go.mod h1:6dK64g27Qi1qGQZ67gFmBFvEHScy0/C8qhQhNe5B5pQ=
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZVsCYMrIZBpFxwV26CbsuoEh5muXD5I1Ods=
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E=
github.com/anchore/stereoscope v0.0.0-20200925184903-c82da54e98fe h1:m4NSyTo2fVUoUHAV/ZVqE/PFMr/y8oz9HRrhWLk9It0=
github.com/anchore/stereoscope v0.0.0-20200925184903-c82da54e98fe/go.mod h1:2Jja/4l0zYggW52og+nn0rut4i+OYjCf9vTyrM8RT4E=
github.com/anchore/stereoscope v0.0.0-20201106140100-12e75c48f409 h1:xKSpDRjmYrEFrdMeDh4AuSUAFc99pdro6YFBKxy2um0=
github.com/anchore/stereoscope v0.0.0-20201106140100-12e75c48f409/go.mod h1:2Jja/4l0zYggW52og+nn0rut4i+OYjCf9vTyrM8RT4E=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/apex/log v1.1.4/go.mod h1:AlpoD9aScyQfJDVHmLMEcx4oU6LqzkWp4Mg9GdAcEvQ=
github.com/apex/log v1.3.0 h1:1fyfbPvUwD10nMoh3hY6MXzvZShJQn9/ck7ATgAt5pA=
@@ -299,6 +303,8 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/go-toolsmith/astcast v1.0.0 h1:JojxlmI6STnFVG9yOImLeGREv8W2ocNUM+iOhR6jE7g=
github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4=
github.com/go-toolsmith/astcopy v1.0.0 h1:OMgl1b1MEpjFQ1m5ztEO06rz5CUd3oBv9RF7+DyvdG8=
+11
View File
@@ -0,0 +1,11 @@
source = ["./dist/syft-macos_darwin_amd64/syft"] # The 'dist' directory path should ideally reference an env var, where the source of truth is the Makefile. I wasn't able to figure out how to solve this.
bundle_id = "com.anchore.toolbox.syft"
sign {
application_identity = "Developer ID Application: ANCHORE, INC. (9MJHKYX5AT)"
}
dmg {
output_path = "./dist/output.dmg"
volume_name = "Syft"
}
+27 -13
View File
@@ -1,6 +1,6 @@
#!/bin/sh
set -e
# Code generated by godownloader on 2020-08-10T20:55:46Z. DO NOT EDIT.
# Code generated by godownloader on 2020-08-10T20:55:46Z.
#
usage() {
@@ -45,11 +45,16 @@ parse_args() {
execute() {
tmpdir=$(mktemp -d)
log_debug "downloading files into ${tmpdir}"
http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}"
http_download "${tmpdir}/${ARCHIVE}" "${ARCHIVE_URL}"
http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}"
hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}"
# macOS has its own secure verification mechanism, and checksums.txt is not used.
if [ "$OS" != "darwin" ]; then
hash_sha256_verify "${tmpdir}/${ARCHIVE}" "${tmpdir}/${CHECKSUM}"
fi
srcdir="${tmpdir}"
(cd "${tmpdir}" && untar "${TARBALL}")
(cd "${tmpdir}" && unpack "${ARCHIVE}")
test ! -d "${BINDIR}" && install -d "${BINDIR}"
for binexe in $BINARIES; do
if [ "$OS" = "windows" ]; then
@@ -89,6 +94,7 @@ tag_to_version() {
adjust_format() {
# change format (tar.gz or zip) based on OS
case ${OS} in
darwin) FORMAT=dmg ;;
windows) FORMAT=zip ;;
esac
true
@@ -221,18 +227,26 @@ uname_arch_check() {
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}" ;;
unpack() {
archive=$1
case "${archive}" in
*.tar.gz | *.tgz) tar --no-same-owner -xzf "${archive}" ;;
*.tar) tar --no-same-owner -xf "${archive}" ;;
*.zip) unzip "${archive}" ;;
*.dmg) extract_from_dmg "${archive}" ;;
*)
log_err "untar unknown archive format for ${tarball}"
log_err "unpack unknown archive format for ${archive}"
return 1
;;
esac
}
extract_from_dmg() {
dmg_file=$1
mount_point="/Volumes/tmp-dmg"
hdiutil attach -quiet -mountpoint "${mount_point}" "${dmg_file}"
cp -fR "${mount_point}/" ./
hdiutil detach -quiet -force "${mount_point}"
}
http_download_curl() {
local_file=$1
source_url=$2
@@ -366,8 +380,8 @@ adjust_arch
log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}"
NAME=${PROJECT_NAME}_${VERSION}_${OS}_${ARCH}
TARBALL=${NAME}.${FORMAT}
TARBALL_URL=${GITHUB_DOWNLOAD}/${TAG}/${TARBALL}
ARCHIVE=${NAME}.${FORMAT}
ARCHIVE_URL=${GITHUB_DOWNLOAD}/${TAG}/${ARCHIVE}
CHECKSUM=${PROJECT_NAME}_${VERSION}_checksums.txt
CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM}
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
set -eux
zip -r "$1" zip-source
# $1 —— absolute path to destination file, should end with .zip, ideally
# $2 —— absolute path to directory from which to add entries to the archive
pushd "$2" && find . -print | zip "$1" -@ && popd
+131
View File
@@ -0,0 +1,131 @@
package file
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"syscall"
"testing"
)
var expectedZipArchiveEntries = []string{
"some-dir" + string(os.PathSeparator),
filepath.Join("some-dir", "a-file.txt"),
"b-file.txt",
"nested.zip",
}
// fatalIfError calls the supplied function. If the function returns a non-nil error, t.Fatal(err) is called.
func fatalIfError(t *testing.T, fn func() error) {
t.Helper()
if fn == nil {
return
}
err := fn()
if err != nil {
t.Fatal(err)
}
}
// createZipArchive creates a new ZIP archive file at destinationArchivePath based on the directory found at
// sourceDirPath.
func createZipArchive(t *testing.T, sourceDirPath, destinationArchivePath string) error {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("unable to get cwd: %+v", err)
}
cmd := exec.Command("./generate-zip-fixture.sh", destinationArchivePath, path.Base(sourceDirPath))
cmd.Dir = filepath.Join(cwd, "test-fixtures")
if err := cmd.Start(); err != nil {
return fmt.Errorf("unable to start generate zip fixture script: %+v", err)
}
if err := cmd.Wait(); err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
// The program has exited with an exit code != 0
// This works on both Unix and Windows. Although package
// syscall is generally platform dependent, WaitStatus is
// defined for both Unix and Windows and in both cases has
// an ExitStatus() method with the same signature.
if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
if status.ExitStatus() != 0 {
return fmt.Errorf("failed to generate fixture: rc=%d", status.ExitStatus())
}
}
} else {
return fmt.Errorf("unable to get generate fixture script result: %+v", err)
}
}
return nil
}
// setupZipFileTest encapsulates common test setup work for zip file tests. It returns a cleanup function,
// which should be called (typically deferred) by the caller, the path of the created zip archive, and an error,
// which should trigger a fatal test failure in the consuming test. The returned cleanup function will never be nil
// (even if there's an error), and it should always be called.
func setupZipFileTest(t *testing.T, sourceDirPath string) (func() error, string, error) {
t.Helper()
// Keep track of any needed cleanup work as we go
var cleanupFns []func() error
cleanup := func(fns []func() error) func() error {
return func() error {
for _, fn := range fns {
err := fn()
if err != nil {
return err
}
}
return nil
}
}
archivePrefix, err := ioutil.TempFile("", "syft-ziputil-archive-TEST-")
if err != nil {
return cleanup(cleanupFns), "", fmt.Errorf("unable to create tempfile: %+v", err)
}
cleanupFns = append(cleanupFns, func() error { return os.Remove(archivePrefix.Name()) })
destinationArchiveFilePath := archivePrefix.Name() + ".zip"
t.Logf("archive path: %s", destinationArchiveFilePath)
err = createZipArchive(t, sourceDirPath, destinationArchiveFilePath)
cleanupFns = append(cleanupFns, func() error { return os.Remove(destinationArchiveFilePath) })
if err != nil {
return cleanup(cleanupFns), "", err
}
cwd, err := os.Getwd()
if err != nil {
return cleanup(cleanupFns), "", fmt.Errorf("unable to get cwd: %+v", err)
}
t.Logf("running from: %s", cwd)
return cleanup(cleanupFns), destinationArchiveFilePath, nil
}
// TODO: Consider moving any non-git asset generation to a task (e.g. make) that's run ahead of running go tests.
func ensureNestedZipExists(t *testing.T, sourceDirPath string) error {
t.Helper()
nestedArchiveFilePath := path.Join(sourceDirPath, "nested.zip")
err := createZipArchive(t, sourceDirPath, nestedArchiveFilePath)
if err != nil {
return fmt.Errorf("unable to create nested archive for test fixture: %+v", err)
}
return nil
}
+14 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"sort"
"strings"
"github.com/anchore/syft/internal"
@@ -26,7 +27,11 @@ func (z ZipFileManifest) GlobMatch(patterns ...string) []string {
for _, pattern := range patterns {
for entry := range z {
if GlobMatch(pattern, entry) {
// We want to match globs as if entries begin with a leading slash (akin to an absolute path)
// so that glob logic is consistent inside and outside of ZIP archives
normalizedEntry := normalizeZipEntryName(entry)
if GlobMatch(pattern, normalizedEntry) {
uniqueMatches.Add(entry)
}
}
@@ -56,3 +61,11 @@ func NewZipFileManifest(archivePath string) (ZipFileManifest, error) {
}
return manifest, nil
}
func normalizeZipEntryName(entry string) string {
if !strings.HasPrefix(entry, "/") {
return "/" + entry
}
return entry
}
+109
View File
@@ -0,0 +1,109 @@
package file
import (
"encoding/json"
"os"
"path"
"testing"
)
func TestNewZipFileManifest(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
sourceDirPath := path.Join(cwd, "test-fixtures", "zip-source")
err = ensureNestedZipExists(t, sourceDirPath)
if err != nil {
t.Fatal(err)
}
cleanup, archiveFilePath, err := setupZipFileTest(t, sourceDirPath)
defer fatalIfError(t, cleanup)
if err != nil {
t.Fatal(err)
}
actual, err := NewZipFileManifest(archiveFilePath)
if err != nil {
t.Fatalf("unable to extract from unzip archive: %+v", err)
}
if len(expectedZipArchiveEntries) != len(actual) {
t.Fatalf("mismatched manifest: %d != %d", len(actual), len(expectedZipArchiveEntries))
}
for _, e := range expectedZipArchiveEntries {
_, ok := actual[e]
if !ok {
t.Errorf("missing path: %s", e)
}
}
if t.Failed() {
b, err := json.MarshalIndent(actual, "", " ")
if err != nil {
t.Fatalf("can't show results: %+v", err)
}
t.Errorf("full result: %s", string(b))
}
}
func TestZipFileManifest_GlobMatch(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
sourceDirPath := path.Join(cwd, "test-fixtures", "zip-source")
err = ensureNestedZipExists(t, sourceDirPath)
if err != nil {
t.Fatal(err)
}
cleanup, archiveFilePath, err := setupZipFileTest(t, sourceDirPath)
//goland:noinspection GoNilness
defer fatalIfError(t, cleanup)
if err != nil {
t.Fatal(err)
}
z, err := NewZipFileManifest(archiveFilePath)
if err != nil {
t.Fatalf("unable to extract from unzip archive: %+v", err)
}
cases := []struct {
glob string
expected string
}{
{
"/b*",
"b-file.txt",
},
{
"*/a-file.txt",
"some-dir/a-file.txt",
},
{
"**/*.zip",
"nested.zip",
},
}
for _, tc := range cases {
t.Run(tc.glob, func(t *testing.T) {
glob := tc.glob
results := z.GlobMatch(glob)
if len(results) == 1 && results[0] == tc.expected {
return
}
t.Errorf("unexpected results for glob '%s': %+v", glob, results)
})
}
}
+31 -122
View File
@@ -6,47 +6,14 @@ import (
"io"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/go-test/deep"
)
func generateFixture(t *testing.T, archivePath string) {
cwd, err := os.Getwd()
if err != nil {
t.Errorf("unable to get cwd: %+v", err)
}
cmd := exec.Command("./generate-zip-fixture.sh", archivePath)
cmd.Dir = filepath.Join(cwd, "test-fixtures")
if err := cmd.Start(); err != nil {
t.Fatalf("unable to start generate zip fixture script: %+v", err)
}
if err := cmd.Wait(); err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
// The program has exited with an exit code != 0
// This works on both Unix and Windows. Although package
// syscall is generally platform dependent, WaitStatus is
// defined for both Unix and Windows and in both cases has
// an ExitStatus() method with the same signature.
if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
if status.ExitStatus() != 0 {
t.Fatalf("failed to generate fixture: rc=%d", status.ExitStatus())
}
}
} else {
t.Fatalf("unable to get generate fixture script result: %+v", err)
}
}
}
func equal(r1, r2 io.Reader) (bool, error) {
w1 := sha256.New()
w2 := sha256.New()
@@ -67,54 +34,50 @@ func equal(r1, r2 io.Reader) (bool, error) {
}
func TestUnzipToDir(t *testing.T) {
archivePrefix, err := ioutil.TempFile("", "syft-ziputil-archive-TEST-")
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("unable to create tempfile: %+v", err)
t.Fatal(err)
}
defer os.Remove(archivePrefix.Name())
// the zip utility will add ".zip" to the end of the given name
archivePath := archivePrefix.Name() + ".zip"
defer os.Remove(archivePath)
t.Logf("archive path: %s", archivePath)
generateFixture(t, archivePrefix.Name())
goldenRootDir := filepath.Join(cwd, "test-fixtures")
sourceDirPath := path.Join(goldenRootDir, "zip-source")
cleanup, archiveFilePath, err := setupZipFileTest(t, sourceDirPath)
defer fatalIfError(t, cleanup)
if err != nil {
t.Fatal(err)
}
contentsDir, err := ioutil.TempDir("", "syft-ziputil-contents-TEST-")
unzipDestinationDir, err := ioutil.TempDir("", "syft-ziputil-contents-TEST-")
defer os.RemoveAll(unzipDestinationDir)
if err != nil {
t.Fatalf("unable to create tempdir: %+v", err)
}
defer os.RemoveAll(contentsDir)
t.Logf("content path: %s", contentsDir)
t.Logf("content path: %s", unzipDestinationDir)
cwd, err := os.Getwd()
if err != nil {
t.Errorf("unable to get cwd: %+v", err)
}
t.Logf("running from: %s", cwd)
// note: zip utility already includes "zip-source" as a parent dir for all contained files
goldenRootDir := filepath.Join(cwd, "test-fixtures")
expectedPaths := 4
expectedPaths := len(expectedZipArchiveEntries)
observedPaths := 0
err = UnzipToDir(archivePath, contentsDir)
err = UnzipToDir(archiveFilePath, unzipDestinationDir)
if err != nil {
t.Fatalf("unable to unzip archive: %+v", err)
}
// compare the source dir tree and the unzipped tree
err = filepath.Walk(filepath.Join(contentsDir, "zip-source"),
err = filepath.Walk(unzipDestinationDir,
func(path string, info os.FileInfo, err error) error {
t.Logf("unzipped path: %s", path)
observedPaths++
// We don't unzip the root archive dir, since there's no archive entry for it
if path != unzipDestinationDir {
t.Logf("unzipped path: %s", path)
observedPaths++
}
if err != nil {
t.Fatalf("this should not happen")
return err
}
goldenPath := filepath.Join(goldenRootDir, strings.TrimPrefix(path, contentsDir))
goldenPath := filepath.Join(sourceDirPath, strings.TrimPrefix(path, unzipDestinationDir))
if info.IsDir() {
i, err := os.Stat(goldenPath)
@@ -156,12 +119,11 @@ func TestUnzipToDir(t *testing.T) {
}
if observedPaths != expectedPaths {
t.Errorf("missed test paths: %d!=%d", observedPaths, expectedPaths)
t.Errorf("missed test paths: %d != %d", observedPaths, expectedPaths)
}
}
func TestExtractFilesFromZipFile(t *testing.T) {
func TestContentsFromZip(t *testing.T) {
archivePrefix, err := ioutil.TempFile("", "syft-ziputil-archive-TEST-")
if err != nil {
t.Fatalf("unable to create tempfile: %+v", err)
@@ -172,7 +134,10 @@ func TestExtractFilesFromZipFile(t *testing.T) {
defer os.Remove(archivePath)
t.Logf("archive path: %s", archivePath)
generateFixture(t, archivePrefix.Name())
err = createZipArchive(t, "zip-source", archivePrefix.Name())
if err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
@@ -181,8 +146,8 @@ func TestExtractFilesFromZipFile(t *testing.T) {
t.Logf("running from: %s", cwd)
aFilePath := filepath.Join("zip-source", "some-dir", "a-file.txt")
bFilePath := filepath.Join("zip-source", "b-file.txt")
aFilePath := filepath.Join("some-dir", "a-file.txt")
bFilePath := filepath.Join("b-file.txt")
expected := map[string]string{
aFilePath: "A file! nice!",
@@ -207,60 +172,4 @@ func TestExtractFilesFromZipFile(t *testing.T) {
t.Errorf("full result: %s", string(b))
}
}
func TestZipFileManifest(t *testing.T) {
archivePrefix, err := ioutil.TempFile("", "syft-ziputil-archive-TEST-")
if err != nil {
t.Fatalf("unable to create tempfile: %+v", err)
}
defer os.Remove(archivePrefix.Name())
// the zip utility will add ".zip" to the end of the given name
archivePath := archivePrefix.Name() + ".zip"
defer os.Remove(archivePath)
t.Logf("archive path: %s", archivePath)
generateFixture(t, archivePrefix.Name())
cwd, err := os.Getwd()
if err != nil {
t.Errorf("unable to get cwd: %+v", err)
}
t.Logf("running from: %s", cwd)
expected := []string{
filepath.Join("zip-source") + string(os.PathSeparator),
filepath.Join("zip-source", "some-dir") + string(os.PathSeparator),
filepath.Join("zip-source", "some-dir", "a-file.txt"),
filepath.Join("zip-source", "b-file.txt"),
}
actual, err := NewZipFileManifest(archivePath)
if err != nil {
t.Fatalf("unable to extract from unzip archive: %+v", err)
}
if len(expected) != len(actual) {
t.Fatalf("mismatched manifest: %d != %d", len(actual), len(expected))
}
for _, e := range expected {
_, ok := actual[e]
if !ok {
t.Errorf("missing path: %s", e)
}
}
if t.Failed() {
b, err := json.MarshalIndent(actual, "", " ")
if err != nil {
t.Fatalf("can't show results: %+v", err)
}
t.Errorf("full result: %s", string(b))
}
}
+69 -288
View File
@@ -125,116 +125,90 @@
"type": "string"
},
"manifest": {
"anyOf": [
{
"type": "null"
},
{
"properties": {
"main": {
"properties": {
"extraFields": {
"properties": {
"Archiver-Version": {
"type": "string"
},
"Build-Jdk": {
"type": "string"
},
"Built-By": {
"type": "string"
},
"Created-By": {
"type": "string"
},
"Extension-Name": {
"type": "string"
},
"Group-Id": {
"type": "string"
},
"Hudson-Version": {
"type": "string"
},
"Jenkins-Version": {
"type": "string"
},
"Long-Name": {
"type": "string"
},
"Main-Class": {
"type": "string"
},
"Minimum-Java-Version": {
"type": "string"
},
"Plugin-Dependencies": {
"type": "string"
},
"Plugin-Developers": {
"type": "string"
},
"Plugin-License-Name": {
"type": "string"
},
"Plugin-License-Url": {
"type": "string"
},
"Plugin-ScmUrl": {
"type": "string"
},
"Plugin-Version": {
"type": "string"
},
"Short-Name": {
"type": "string"
}
},
"required": [
"Archiver-Version",
"Build-Jdk",
"Built-By",
"Created-By"
],
"type": "object"
},
"implementationTitle": {
"Archiver-Version": {
"type": "string"
},
"implementationVendor": {
"Build-Jdk": {
"type": "string"
},
"implementationVersion": {
"Built-By": {
"type": "string"
},
"manifestVersion": {
"Created-By": {
"type": "string"
},
"name": {
"Extension-Name": {
"type": "string"
},
"specificationTitle": {
"Group-Id": {
"type": "string"
},
"specificationVendor": {
"Hudson-Version": {
"type": "string"
},
"specificationVersion": {
"Implementation-Title": {
"type": "string"
},
"Implementation-Version": {
"type": "string"
},
"Jenkins-Version": {
"type": "string"
},
"Long-Name": {
"type": "string"
},
"Main-Class": {
"type": "string"
},
"Manifest-Version": {
"type": "string"
},
"Minimum-Java-Version": {
"type": "string"
},
"Plugin-Dependencies": {
"type": "string"
},
"Plugin-Developers": {
"type": "string"
},
"Plugin-License-Name": {
"type": "string"
},
"Plugin-License-Url": {
"type": "string"
},
"Plugin-ScmUrl": {
"type": "string"
},
"Plugin-Version": {
"type": "string"
},
"Short-Name": {
"type": "string"
},
"Specification-Title": {
"type": "string"
}
},
"required": [
"extraFields",
"implementationTitle",
"implementationVendor",
"implementationVersion",
"manifestVersion",
"name",
"specificationTitle",
"specificationVendor",
"specificationVersion"
"Archiver-Version",
"Build-Jdk",
"Built-By",
"Created-By",
"Manifest-Version"
],
"type": "object"
}
]
},
"required": [
"main"
],
"type": "object"
},
"name": {
"type": "string"
@@ -245,210 +219,11 @@
"package": {
"type": "string"
},
"parentPackage": {
"anyOf": [
{
"type": "null"
},
{
"properties": {
"foundBy": {
"type": "string"
},
"language": {
"type": "integer"
},
"licenses": {
"type": "null"
},
"manifest": {
"type": "string"
},
"metadata": {
"properties": {
"manifest": {
"properties": {
"extraFields": {
"properties": {
"Archiver-Version": {
"type": "string"
},
"Build-Jdk": {
"type": "string"
},
"Built-By": {
"type": "string"
},
"Created-By": {
"type": "string"
},
"Extension-Name": {
"type": "string"
},
"Group-Id": {
"type": "string"
},
"Hudson-Version": {
"type": "string"
},
"Jenkins-Version": {
"type": "string"
},
"Long-Name": {
"type": "string"
},
"Main-Class": {
"type": "string"
},
"Minimum-Java-Version": {
"type": "string"
},
"Plugin-Dependencies": {
"type": "string"
},
"Plugin-Developers": {
"type": "string"
},
"Plugin-License-Name": {
"type": "string"
},
"Plugin-License-Url": {
"type": "string"
},
"Plugin-ScmUrl": {
"type": "string"
},
"Plugin-Version": {
"type": "string"
},
"Short-Name": {
"type": "string"
}
},
"required": [
"Archiver-Version",
"Build-Jdk",
"Built-By",
"Created-By"
],
"type": "object"
},
"implementationTitle": {
"type": "string"
},
"implementationVendor": {
"type": "string"
},
"implementationVersion": {
"type": "string"
},
"manifestVersion": {
"type": "string"
},
"name": {
"type": "string"
},
"specificationTitle": {
"type": "string"
},
"specificationVendor": {
"type": "string"
},
"specificationVersion": {
"type": "string"
}
},
"required": [
"extraFields",
"implementationTitle",
"implementationVendor",
"implementationVersion",
"manifestVersion",
"name",
"specificationTitle",
"specificationVendor",
"specificationVersion"
],
"type": "object"
},
"parentPackage": {
"type": "null"
},
"pomProperties": {
"properties": {
"Path": {
"type": "string"
},
"artifactId": {
"type": "string"
},
"extraFields": {
"type": "null"
},
"groupId": {
"type": "string"
},
"name": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"Path",
"artifactId",
"extraFields",
"groupId",
"name",
"version"
],
"type": "object"
}
},
"required": [
"manifest",
"parentPackage",
"pomProperties"
],
"type": "object"
},
"metadataType": {
"type": "string"
},
"sources": {
"type": "null"
},
"type": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"foundBy",
"language",
"licenses",
"manifest",
"metadata",
"metadataType",
"sources",
"type",
"version"
],
"type": "object"
}
]
},
"platform": {
"type": "string"
},
"pomProperties": {
"properties": {
"Path": {
"type": "string"
},
"artifactId": {
"type": "string"
},
@@ -461,16 +236,19 @@
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"Path",
"artifactId",
"extraFields",
"groupId",
"name",
"path",
"version"
],
"type": "object"
@@ -510,6 +288,9 @@
},
"version": {
"type": "string"
},
"virtualPath": {
"type": "string"
}
},
"type": "object"
+12 -3
View File
@@ -89,7 +89,16 @@ func parseApkDBEntry(reader io.Reader) (*pkg.ApkMetadata, error) {
switch key {
case "F":
lastFile = "/" + value
currentFile := "/" + value
newFileRecord := pkg.ApkFileRecord{
Path: currentFile,
}
files = append(files, newFileRecord)
fileRecord = &files[len(files)-1]
// future aux references are relative to previous "F" records
lastFile = currentFile
continue
case "R":
newFileRecord := pkg.ApkFileRecord{
@@ -97,7 +106,7 @@ func parseApkDBEntry(reader io.Reader) (*pkg.ApkMetadata, error) {
}
files = append(files, newFileRecord)
fileRecord = &files[len(files)-1]
case "a":
case "a", "M":
ownershipFields := strings.Split(value, ":")
if len(ownershipFields) < 3 {
log.Errorf("unexpected APK ownership field: %q", value)
@@ -108,7 +117,7 @@ func parseApkDBEntry(reader io.Reader) (*pkg.ApkMetadata, error) {
continue
}
fileRecord.OwnerUID = ownershipFields[0]
fileRecord.OwnerGUI = ownershipFields[1]
fileRecord.OwnerGID = ownershipFields[1]
fileRecord.Permissions = ownershipFields[2]
// note: there are more optional fields available that we are not capturing, e.g.:
// "0:0:755:Q1JaDEHQHBbizhEzoWK1YxuraNU/4="
+418 -17
View File
@@ -19,10 +19,25 @@ func TestExtraFileAttributes(t *testing.T) {
name: "test extra file attributes (checksum) are ignored",
expected: pkg.ApkMetadata{
Files: []pkg.ApkFileRecord{
{
Path: "/usr",
},
{
Path: "/usr/lib",
},
{
Path: "/usr/lib/jvm",
},
{
Path: "/usr/lib/jvm/java-1.8-openjdk",
},
{
Path: "/usr/lib/jvm/java-1.8-openjdk/bin",
},
{
Path: "/usr/lib/jvm/java-1.8-openjdk/bin/policytool",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1M0C9qfC/+kdRiOodeihG2GMRtkE=",
},
@@ -60,13 +75,13 @@ func TestExtraFileAttributes(t *testing.T) {
}
}
func TestSinglePackage(t *testing.T) {
func TestSinglePackageDetails(t *testing.T) {
tests := []struct {
name string
fixture string
expected pkg.ApkMetadata
}{
{
name: "Test Single Package",
fixture: "test-fixtures/single",
expected: pkg.ApkMetadata{
Package: "musl-utils",
OriginPackage: "musl",
@@ -82,51 +97,428 @@ func TestSinglePackage(t *testing.T) {
PullChecksum: "Q1bTtF5526tETKfL+lnigzIDvm+2o=",
GitCommitOfAport: "4024cc3b29ad4c65544ad068b8f59172b5494306",
Files: []pkg.ApkFileRecord{
{
Path: "/sbin",
},
{
Path: "/sbin/ldconfig",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1Kja2+POZKxEkUOZqwSjC6kmaED4=",
},
{
Path: "/usr",
},
{
Path: "/usr/bin",
},
{
Path: "/usr/bin/iconv",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1CVmFbdY+Hv6/jAHl1gec2Kbx1EY=",
},
{
Path: "/usr/bin/ldd",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1yFAhGggmL7ERgbIA7KQxyTzf3ks=",
},
{
Path: "/usr/bin/getconf",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1dAdYK8M/INibRQF5B3Rw7cmNDDA=",
},
{
Path: "/usr/bin/getent",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1eR2Dz/WylabgbWMTkd2+hGmEya4=",
},
},
},
},
{
fixture: "test-fixtures/base",
expected: pkg.ApkMetadata{
Package: "alpine-baselayout",
OriginPackage: "alpine-baselayout",
Version: "3.2.0-r6",
Description: "Alpine base dir structure and init scripts",
Maintainer: "Natanael Copa <ncopa@alpinelinux.org>",
License: "GPL-2.0-only",
Architecture: "x86_64",
URL: "https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout",
Size: 19917,
InstalledSize: 409600,
PullDependencies: "/bin/sh so:libc.musl-x86_64.so.1",
PullChecksum: "Q1myMNfd7u5v5UTgNHeq1e31qTjZU=",
GitCommitOfAport: "e1c51734fa96fa4bac92e9f14a474324c67916fc",
Files: []pkg.ApkFileRecord{
{
Path: "/dev",
},
{
Path: "/dev/pts",
},
{
Path: "/dev/shm",
},
{
Path: "/etc",
},
{
Path: "/etc/fstab",
Checksum: "Q11Q7hNe8QpDS531guqCdrXBzoA/o=",
},
{
Path: "/etc/group",
Checksum: "Q1oJ16xWudgKOrXIEquEDzlF2Lsm4=",
},
{
Path: "/etc/hostname",
Checksum: "Q16nVwYVXP/tChvUPdukVD2ifXOmc=",
},
{
Path: "/etc/hosts",
Checksum: "Q1BD6zJKZTRWyqGnPi4tSfd3krsMU=",
},
{
Path: "/etc/inittab",
Checksum: "Q1TsthbhW7QzWRe1E/NKwTOuD4pHc=",
},
{
Path: "/etc/modules",
Checksum: "Q1toogjUipHGcMgECgPJX64SwUT1M=",
},
{
Path: "/etc/motd",
Checksum: "Q1XmduVVNURHQ27TvYp1Lr5TMtFcA=",
},
{
Path: "/etc/mtab",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "777",
Checksum: "Q1kiljhXXH1LlQroHsEJIkPZg2eiw=",
},
{
Path: "/etc/passwd",
Checksum: "Q1TchuuLUfur0izvfZQZxgN/LJhB8=",
},
{
Path: "/etc/profile",
Checksum: "Q1KpFb8kl5LvwXWlY3e58FNsjrI34=",
},
{
Path: "/etc/protocols",
Checksum: "Q13FqXUnvuOpMDrH/6rehxuYAEE34=",
},
{
Path: "/etc/services",
Checksum: "Q1C6HJNgQvLWqt5VY+n7MZJ1rsDuY=",
},
{
Path: "/etc/shadow",
OwnerUID: "0",
OwnerGID: "42",
Permissions: "640",
Checksum: "Q1ltrPIAW2zHeDiajsex2Bdmq3uqA=",
},
{
Path: "/etc/shells",
Checksum: "Q1ojm2YdpCJ6B/apGDaZ/Sdb2xJkA=",
},
{
Path: "/etc/sysctl.conf",
Checksum: "Q14upz3tfnNxZkIEsUhWn7Xoiw96g=",
},
{
Path: "/etc/apk",
},
{
Path: "/etc/conf.d",
},
{
Path: "/etc/crontabs",
},
{
Path: "/etc/crontabs/root",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "600",
Checksum: "Q1vfk1apUWI4yLJGhhNRd0kJixfvY=",
},
{
Path: "/etc/init.d",
},
{
Path: "/etc/modprobe.d",
},
{
Path: "/etc/modprobe.d/aliases.conf",
Checksum: "Q1WUbh6TBYNVK7e4Y+uUvLs/7viqk=",
},
{
Path: "/etc/modprobe.d/blacklist.conf",
Checksum: "Q1xxYGU6S6TLQvb7ervPrWWwAWqMg=",
},
{
Path: "/etc/modprobe.d/i386.conf",
Checksum: "Q1pnay/njn6ol9cCssL7KiZZ8etlc=",
},
{
Path: "/etc/modprobe.d/kms.conf",
Checksum: "Q1ynbLn3GYDpvajba/ldp1niayeog=",
},
{
Path: "/etc/modules-load.d",
},
{
Path: "/etc/network",
},
{
Path: "/etc/network/if-down.d",
},
{
Path: "/etc/network/if-post-down.d",
},
{
Path: "/etc/network/if-pre-up.d",
},
{
Path: "/etc/network/if-up.d",
},
{
Path: "/etc/opt",
},
{
Path: "/etc/periodic",
},
{
Path: "/etc/periodic/15min",
},
{
Path: "/etc/periodic/daily",
},
{
Path: "/etc/periodic/hourly",
},
{
Path: "/etc/periodic/monthly",
},
{
Path: "/etc/periodic/weekly",
},
{
Path: "/etc/profile.d",
},
{
Path: "/etc/profile.d/color_prompt",
Checksum: "Q10wL23GuSCVfumMRgakabUI6EsSk=",
},
{
Path: "/etc/profile.d/locale",
Checksum: "Q1R4bIEpnKxxOSrlnZy9AoawqZ5DU=",
},
{
Path: "/etc/sysctl.d",
},
{
Path: "/home",
},
{
Path: "/lib",
},
{
Path: "/lib/firmware",
},
{
Path: "/lib/mdev",
},
{
Path: "/lib/modules-load.d",
},
{
Path: "/lib/sysctl.d",
},
{
Path: "/lib/sysctl.d/00-alpine.conf",
Checksum: "Q1HpElzW1xEgmKfERtTy7oommnq6c=",
},
{
Path: "/media",
},
{
Path: "/media/cdrom",
},
{
Path: "/media/floppy",
},
{
Path: "/media/usb",
},
{
Path: "/mnt",
},
{
Path: "/opt",
},
{
Path: "/proc",
},
{
Path: "/root",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "700",
},
{
Path: "/run",
},
{
Path: "/sbin",
},
{
Path: "/sbin/mkmntdirs",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1YeuSmC7iDbEWrusPzA/zUQF6YSg=",
},
{
Path: "/srv",
},
{
Path: "/sys",
},
{
Path: "/tmp",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "1777",
},
{
Path: "/usr",
},
{
Path: "/usr/lib",
},
{
Path: "/usr/lib/modules-load.d",
},
{
Path: "/usr/local",
},
{
Path: "/usr/local/bin",
},
{
Path: "/usr/local/lib",
},
{
Path: "/usr/local/share",
},
{
Path: "/usr/sbin",
},
{
Path: "/usr/share",
},
{
Path: "/usr/share/man",
},
{
Path: "/usr/share/misc",
},
{
Path: "/var",
},
{
Path: "/var/run",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "777",
Checksum: "Q11/SNZz/8cK2dSKK+cJpVrZIuF4Q=",
},
{
Path: "/var/cache",
},
{
Path: "/var/cache/misc",
},
{
Path: "/var/empty",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "555",
},
{
Path: "/var/lib",
},
{
Path: "/var/lib/misc",
},
{
Path: "/var/local",
},
{
Path: "/var/lock",
},
{
Path: "/var/lock/subsys",
},
{
Path: "/var/log",
},
{
Path: "/var/mail",
},
{
Path: "/var/opt",
},
{
Path: "/var/spool",
},
{
Path: "/var/spool/mail",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "777",
Checksum: "Q1dzbdazYZA2nTzSIG3YyNw7d4Juc=",
},
{
Path: "/var/spool/cron",
},
{
Path: "/var/spool/cron/crontabs",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "777",
Checksum: "Q1OFZt+ZMp7j0Gny0rqSKuWJyqYmA=",
},
{
Path: "/var/tmp",
OwnerUID: "0",
OwnerGID: "0",
Permissions: "1777",
},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
file, err := os.Open("test-fixtures/single")
t.Run(test.fixture, func(t *testing.T) {
file, err := os.Open(test.fixture)
if err != nil {
t.Fatal("Unable to read test_fixtures/single: ", err)
t.Fatal("Unable to read fixture: ", err)
}
defer func() {
err := file.Close()
@@ -203,38 +595,47 @@ func TestMultiplePackages(t *testing.T) {
PullChecksum: "Q1bTtF5526tETKfL+lnigzIDvm+2o=",
GitCommitOfAport: "4024cc3b29ad4c65544ad068b8f59172b5494306",
Files: []pkg.ApkFileRecord{
{
Path: "/sbin",
},
{
Path: "/sbin/ldconfig",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1Kja2+POZKxEkUOZqwSjC6kmaED4=",
},
{
Path: "/usr",
},
{
Path: "/usr/bin",
},
{
Path: "/usr/bin/iconv",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1CVmFbdY+Hv6/jAHl1gec2Kbx1EY=",
},
{
Path: "/usr/bin/ldd",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1yFAhGggmL7ERgbIA7KQxyTzf3ks=",
},
{
Path: "/usr/bin/getconf",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1dAdYK8M/INibRQF5B3Rw7cmNDDA=",
},
{
Path: "/usr/bin/getent",
OwnerUID: "0",
OwnerGUI: "0",
OwnerGID: "0",
Permissions: "755",
Checksum: "Q1eR2Dz/WylabgbWMTkd2+hGmEya4=",
},
+149
View File
@@ -0,0 +1,149 @@
C:Q1myMNfd7u5v5UTgNHeq1e31qTjZU=
P:alpine-baselayout
V:3.2.0-r6
A:x86_64
S:19917
I:409600
T:Alpine base dir structure and init scripts
U:https://git.alpinelinux.org/cgit/aports/tree/main/alpine-baselayout
L:GPL-2.0-only
o:alpine-baselayout
m:Natanael Copa <ncopa@alpinelinux.org>
t:1590679557
c:e1c51734fa96fa4bac92e9f14a474324c67916fc
D:/bin/sh so:libc.musl-x86_64.so.1
p:cmd:mkmntdirs
F:dev
F:dev/pts
F:dev/shm
F:etc
R:fstab
Z:Q11Q7hNe8QpDS531guqCdrXBzoA/o=
R:group
Z:Q1oJ16xWudgKOrXIEquEDzlF2Lsm4=
R:hostname
Z:Q16nVwYVXP/tChvUPdukVD2ifXOmc=
R:hosts
Z:Q1BD6zJKZTRWyqGnPi4tSfd3krsMU=
R:inittab
Z:Q1TsthbhW7QzWRe1E/NKwTOuD4pHc=
R:modules
Z:Q1toogjUipHGcMgECgPJX64SwUT1M=
R:motd
Z:Q1XmduVVNURHQ27TvYp1Lr5TMtFcA=
R:mtab
a:0:0:777
Z:Q1kiljhXXH1LlQroHsEJIkPZg2eiw=
R:passwd
Z:Q1TchuuLUfur0izvfZQZxgN/LJhB8=
R:profile
Z:Q1KpFb8kl5LvwXWlY3e58FNsjrI34=
R:protocols
Z:Q13FqXUnvuOpMDrH/6rehxuYAEE34=
R:services
Z:Q1C6HJNgQvLWqt5VY+n7MZJ1rsDuY=
R:shadow
a:0:42:640
Z:Q1ltrPIAW2zHeDiajsex2Bdmq3uqA=
R:shells
Z:Q1ojm2YdpCJ6B/apGDaZ/Sdb2xJkA=
R:sysctl.conf
Z:Q14upz3tfnNxZkIEsUhWn7Xoiw96g=
F:etc/apk
F:etc/conf.d
F:etc/crontabs
R:root
a:0:0:600
Z:Q1vfk1apUWI4yLJGhhNRd0kJixfvY=
F:etc/init.d
F:etc/modprobe.d
R:aliases.conf
Z:Q1WUbh6TBYNVK7e4Y+uUvLs/7viqk=
R:blacklist.conf
Z:Q1xxYGU6S6TLQvb7ervPrWWwAWqMg=
R:i386.conf
Z:Q1pnay/njn6ol9cCssL7KiZZ8etlc=
R:kms.conf
Z:Q1ynbLn3GYDpvajba/ldp1niayeog=
F:etc/modules-load.d
F:etc/network
F:etc/network/if-down.d
F:etc/network/if-post-down.d
F:etc/network/if-pre-up.d
F:etc/network/if-up.d
F:etc/opt
F:etc/periodic
F:etc/periodic/15min
F:etc/periodic/daily
F:etc/periodic/hourly
F:etc/periodic/monthly
F:etc/periodic/weekly
F:etc/profile.d
R:color_prompt
Z:Q10wL23GuSCVfumMRgakabUI6EsSk=
R:locale
Z:Q1R4bIEpnKxxOSrlnZy9AoawqZ5DU=
F:etc/sysctl.d
F:home
F:lib
F:lib/firmware
F:lib/mdev
F:lib/modules-load.d
F:lib/sysctl.d
R:00-alpine.conf
Z:Q1HpElzW1xEgmKfERtTy7oommnq6c=
F:media
F:media/cdrom
F:media/floppy
F:media/usb
F:mnt
F:opt
F:proc
F:root
M:0:0:700
F:run
F:sbin
R:mkmntdirs
a:0:0:755
Z:Q1YeuSmC7iDbEWrusPzA/zUQF6YSg=
F:srv
F:sys
F:tmp
M:0:0:1777
F:usr
F:usr/lib
F:usr/lib/modules-load.d
F:usr/local
F:usr/local/bin
F:usr/local/lib
F:usr/local/share
F:usr/sbin
F:usr/share
F:usr/share/man
F:usr/share/misc
F:var
R:run
a:0:0:777
Z:Q11/SNZz/8cK2dSKK+cJpVrZIuF4Q=
F:var/cache
F:var/cache/misc
F:var/empty
M:0:0:555
F:var/lib
F:var/lib/misc
F:var/local
F:var/lock
F:var/lock/subsys
F:var/log
F:var/mail
F:var/opt
F:var/spool
R:mail
a:0:0:777
Z:Q1dzbdazYZA2nTzSIG3YyNw7d4Juc=
F:var/spool/cron
R:crontabs
a:0:0:777
Z:Q1OFZt+ZMp7j0Gny0rqSKuWJyqYmA=
F:var/tmp
M:0:0:1777
+14 -7
View File
@@ -10,11 +10,20 @@ import (
"github.com/anchore/syft/syft/pkg"
)
// match on versions and anything after the version. This is used to isolate the name from the version.
// match examples:
// wagon-webdav-1.0.2-rc1-hudson.jar ---> -1.0.2-rc1-hudson.jar
// windows-remote-command-1.0.jar ---> -1.0.jar
// wstx-asl-1-2.jar ---> -1-2.jar
// guava-rc0.jar ---> -rc0.jar
var versionAreaPattern = regexp.MustCompile(`-(?P<version>(\d+\.)?(\d+\.)?(r?c?\d+)(-[a-zA-Z0-9\-.]+)*)(?P<remaining>.*)$`)
// match on explicit versions. This is used for extracting version information.
// match examples:
// pkg-extra-field-4.3.2-rc1 --> match(name=pkg-extra-field version=4.3.2-rc1)
// pkg-extra-field-4.3-rc1 --> match(name=pkg-extra-field version=4.3-rc1)
// pkg-extra-field-4.3 --> match(name=pkg-extra-field version=4.3)
var versionPattern = regexp.MustCompile(`(?P<name>.+)-(?P<version>(\d+\.)?(\d+\.)?(\*|\d+)(-[a-zA-Z0-9\-\.]+)*)`)
var versionPattern = regexp.MustCompile(`-(?P<version>(\d+\.)?(\d+\.)?(r?c?\d+)(-[a-zA-Z0-9\-.]+)*)`)
type archiveFilename struct {
raw string
@@ -70,10 +79,8 @@ func (a archiveFilename) version() string {
}
func (a archiveFilename) name() string {
// there should be only one name, if there is more or less then something is wrong
if len(a.fields) != 1 {
return ""
}
return a.fields[0]["name"]
// derive the name from the archive name (no path or extension) and remove any versions found
basename := filepath.Base(a.raw)
cleaned := strings.TrimSuffix(basename, filepath.Ext(basename))
return versionAreaPattern.ReplaceAllString(cleaned, "")
}
+68 -1
View File
@@ -1,9 +1,10 @@
package java
import (
"testing"
"github.com/anchore/syft/syft/pkg"
"github.com/sergi/go-diff/diffmatchpatch"
"testing"
)
func TestExtractInfoFromJavaArchiveFilename(t *testing.T) {
@@ -56,12 +57,78 @@ func TestExtractInfoFromJavaArchiveFilename(t *testing.T) {
name: "pkg-extra-field-maven",
ty: pkg.JenkinsPluginPkg,
},
{
filename: "/some/path-with-version-5.4.3/wagon-webdav-1.0.2-beta-2.2.3a-hudson.jar",
version: "1.0.2-beta-2.2.3a-hudson",
extension: "jar",
name: "wagon-webdav",
ty: pkg.JavaPkg,
},
{
filename: "/some/path-with-version-5.4.3/wagon-webdav-1.0.2-beta-2.2.3-hudson.jar",
version: "1.0.2-beta-2.2.3-hudson",
extension: "jar",
name: "wagon-webdav",
ty: pkg.JavaPkg,
},
{
filename: "/some/path-with-version-5.4.3/windows-remote-command-1.0.jar",
version: "1.0",
extension: "jar",
name: "windows-remote-command",
ty: pkg.JavaPkg,
},
{
filename: "/some/path-with-version-5.4.3/wagon-http-lightweight-1.0.5-beta-2.jar",
version: "1.0.5-beta-2",
extension: "jar",
name: "wagon-http-lightweight",
ty: pkg.JavaPkg,
},
{
filename: "/hudson.war:WEB-INF/lib/commons-jelly-1.1-hudson-20100305.jar",
version: "1.1-hudson-20100305",
extension: "jar",
name: "commons-jelly",
ty: pkg.JavaPkg,
},
{
filename: "/hudson.war:WEB-INF/lib/jtidy-4aug2000r7-dev-hudson-1.jar",
// I don't see how we can reliably account for this case
//version: "4aug2000r7-dev-hudson-1",
version: "",
extension: "jar",
name: "jtidy",
ty: pkg.JavaPkg,
},
{
filename: "/hudson.war:WEB-INF/lib/trilead-ssh2-build212-hudson-5.jar",
// I don't see how we can reliably account for this case
//version: "build212-hudson-5",
version: "5",
extension: "jar",
// name: "trilead-ssh2",
name: "trilead-ssh2-build212-hudson",
ty: pkg.JavaPkg,
},
{
filename: "/hudson.war:WEB-INF/lib/guava-r06.jar",
version: "r06",
extension: "jar",
name: "guava",
ty: pkg.JavaPkg,
},
}
for _, test := range tests {
t.Run(test.filename, func(t *testing.T) {
obj := newJavaArchiveFilename(test.filename)
ty := obj.pkgType()
if ty != test.ty {
t.Errorf("mismatched type: %+v != %v", ty, test.ty)
}
version := obj.version()
if version != test.version {
dmp := diffmatchpatch.New()
+41 -8
View File
@@ -66,13 +66,17 @@ func newJavaArchiveParser(virtualPath string, reader io.Reader, detectNested boo
return nil, cleanupFn, fmt.Errorf("unable to read files from java archive: %w", err)
}
// fetch the last element of the virtual path
virtualElements := strings.Split(virtualPath, ":")
currentFilepath := virtualElements[len(virtualElements)-1]
return &archiveParser{
discoveredPkgs: internal.NewStringSet(),
fileManifest: fileManifest,
virtualPath: virtualPath,
archivePath: archivePath,
contentPath: contentPath,
fileInfo: newJavaArchiveFilename(virtualPath),
fileInfo: newJavaArchiveFilename(currentFilepath),
detectNested: detectNested,
}, cleanupFn, nil
}
@@ -120,7 +124,7 @@ func (j *archiveParser) parse() ([]pkg.Package, error) {
// discoverMainPackage parses the root Java manifest used as the parent package to all discovered nested packages.
func (j *archiveParser) discoverMainPackage() (*pkg.Package, error) {
// search and parse java manifest files
manifestMatches := j.fileManifest.GlobMatch(manifestPath)
manifestMatches := j.fileManifest.GlobMatch(manifestGlob)
if len(manifestMatches) > 1 {
return nil, fmt.Errorf("found multiple manifests in the jar: %+v", manifestMatches)
} else if len(manifestMatches) == 0 {
@@ -136,7 +140,7 @@ func (j *archiveParser) discoverMainPackage() (*pkg.Package, error) {
// parse the manifest file into a rich object
manifestContents := contents[manifestMatches[0]]
manifest, err := parseJavaManifest(strings.NewReader(manifestContents))
manifest, err := parseJavaManifest(j.archivePath, strings.NewReader(manifestContents))
if err != nil {
return nil, fmt.Errorf("failed to parse java manifest (%s): %w", j.virtualPath, err)
}
@@ -148,13 +152,15 @@ func (j *archiveParser) discoverMainPackage() (*pkg.Package, error) {
Type: pkg.JavaPkg,
MetadataType: pkg.JavaMetadataType,
Metadata: pkg.JavaMetadata{
Manifest: manifest,
VirtualPath: j.virtualPath,
Manifest: manifest,
},
}, nil
}
// discoverPkgsFromPomProperties parses Maven POM properties for a given parent package, returning all listed Java packages found and
// associating each discovered package to the given parent package.
// nolint:funlen,gocognit
func (j *archiveParser) discoverPkgsFromPomProperties(parentPkg *pkg.Package) ([]pkg.Package, error) {
var pkgs = make([]pkg.Package, 0)
parentKey := uniquePkgKey(parentPkg)
@@ -176,6 +182,13 @@ func (j *archiveParser) discoverPkgsFromPomProperties(parentPkg *pkg.Package) ([
if propsObj.Version != "" && propsObj.ArtifactID != "" {
// TODO: if there is no parentPkg (no java manifest) one of these poms could be the parent. We should discover the right parent and attach the correct info accordingly to each discovered package
// keep the artifact name within the virtual path if this package does not match the parent package
vPathSuffix := ""
if !strings.HasPrefix(propsObj.ArtifactID, parentPkg.Name) {
vPathSuffix += ":" + propsObj.ArtifactID
}
virtualPath := j.virtualPath + vPathSuffix
// discovered props = new package
p := pkg.Package{
Name: propsObj.ArtifactID,
@@ -184,6 +197,7 @@ func (j *archiveParser) discoverPkgsFromPomProperties(parentPkg *pkg.Package) ([
Type: pkg.JavaPkg,
MetadataType: pkg.JavaMetadataType,
Metadata: pkg.JavaMetadata{
VirtualPath: virtualPath,
PomProperties: propsObj,
Parent: parentPkg,
},
@@ -191,16 +205,35 @@ func (j *archiveParser) discoverPkgsFromPomProperties(parentPkg *pkg.Package) ([
pkgKey := uniquePkgKey(&p)
if !j.discoveredPkgs.Contains(pkgKey) {
// only keep packages we haven't seen yet
pkgs = append(pkgs, p)
} else if pkgKey == parentKey {
// the name/version pair matches...
matchesParentPkg := pkgKey == parentKey
// the virtual path matches...
matchesParentPkg = matchesParentPkg || parentPkg.Metadata.(pkg.JavaMetadata).VirtualPath == virtualPath
// the pom artifactId has the parent name or vice versa
if propsObj.ArtifactID != "" {
matchesParentPkg = matchesParentPkg || strings.Contains(parentPkg.Name, propsObj.ArtifactID) || strings.Contains(propsObj.ArtifactID, parentPkg.Name)
}
if matchesParentPkg {
// we've run across more information about our parent package, add this info to the parent package metadata
// the pom properties is typically a better source of information for name and version than the manifest
if p.Name != parentPkg.Name {
parentPkg.Name = p.Name
}
if p.Version != parentPkg.Version {
parentPkg.Version = p.Version
}
parentMetadata, ok := parentPkg.Metadata.(pkg.JavaMetadata)
if ok {
parentMetadata.PomProperties = propsObj
parentPkg.Metadata = parentMetadata
}
} else if !j.discoveredPkgs.Contains(pkgKey) {
// only keep packages we haven't seen yet (and are not related to the parent package)
pkgs = append(pkgs, p)
}
}
}
+60 -83
View File
@@ -78,51 +78,6 @@ func generateJavaBuildFixture(t *testing.T, fixturePath string) {
}
}
func TestSelectName(t *testing.T) {
tests := []struct {
desc string
manifest pkg.JavaManifest
archive archiveFilename
expected string
}{
{
desc: "name from Implementation-Title",
archive: archiveFilename{},
manifest: pkg.JavaManifest{
Name: "",
SpecTitle: "",
ImplTitle: "maven-wrapper",
},
expected: "maven-wrapper",
},
{
desc: "Implementation-Title does not override",
manifest: pkg.JavaManifest{
Name: "Foo",
SpecTitle: "",
ImplTitle: "maven-wrapper",
},
archive: archiveFilename{
fields: []map[string]string{
{"name": "omg"},
},
},
expected: "omg",
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
result := selectName(&test.manifest, test.archive)
if result != test.expected {
t.Errorf("mismatch in names: '%s' != '%s'", result, test.expected)
}
})
}
}
func TestParseJar(t *testing.T) {
tests := []struct {
fixture string
@@ -143,12 +98,14 @@ func TestParseJar(t *testing.T) {
Type: pkg.JenkinsPluginPkg,
MetadataType: pkg.JavaMetadataType,
Metadata: pkg.JavaMetadata{
VirtualPath: "test-fixtures/java-builds/packages/example-jenkins-plugin.hpi",
Manifest: &pkg.JavaManifest{
ManifestVersion: "1.0",
SpecTitle: "The Jenkins Plugins Parent POM Project",
ImplTitle: "example-jenkins-plugin",
ImplVersion: "1.0-SNAPSHOT",
Extra: map[string]string{
Main: map[string]string{
"Manifest-Version": "1.0",
"Specification-Title": "The Jenkins Plugins Parent POM Project",
"Implementation-Title": "example-jenkins-plugin",
"Implementation-Version": "1.0-SNAPSHOT",
// extra fields...
"Archiver-Version": "Plexus Archiver",
"Plugin-License-Url": "https://opensource.org/licenses/MIT",
"Plugin-License-Name": "MIT License",
@@ -188,8 +145,11 @@ func TestParseJar(t *testing.T) {
Type: pkg.JavaPkg,
MetadataType: pkg.JavaMetadataType,
Metadata: pkg.JavaMetadata{
VirtualPath: "test-fixtures/java-builds/packages/example-java-app-gradle-0.1.0.jar",
Manifest: &pkg.JavaManifest{
ManifestVersion: "1.0",
Main: map[string]string{
"Manifest-Version": "1.0",
},
},
},
},
@@ -208,9 +168,11 @@ func TestParseJar(t *testing.T) {
Type: pkg.JavaPkg,
MetadataType: pkg.JavaMetadataType,
Metadata: pkg.JavaMetadata{
VirtualPath: "test-fixtures/java-builds/packages/example-java-app-maven-0.1.0.jar",
Manifest: &pkg.JavaManifest{
ManifestVersion: "1.0",
Extra: map[string]string{
Main: map[string]string{
"Manifest-Version": "1.0",
// extra fields...
"Archiver-Version": "Plexus Archiver",
"Created-By": "Apache Maven 3.6.3",
"Built-By": "?",
@@ -233,6 +195,9 @@ func TestParseJar(t *testing.T) {
Type: pkg.JavaPkg,
MetadataType: pkg.JavaMetadataType,
Metadata: pkg.JavaMetadata{
// ensure that nested packages with different names than that of the parent are appended as
// a suffix on the virtual path
VirtualPath: "test-fixtures/java-builds/packages/example-java-app-maven-0.1.0.jar:joda-time",
PomProperties: &pkg.PomProperties{
Path: "META-INF/maven/joda-time/joda-time/pom.properties",
GroupID: "joda-time",
@@ -299,11 +264,11 @@ func TestParseJar(t *testing.T) {
metadata := a.Metadata.(pkg.JavaMetadata)
metadata.Parent = nil
// ignore select fields
// ignore select fields (only works for the main section)
for _, field := range test.ignoreExtras {
if metadata.Manifest != nil && metadata.Manifest.Extra != nil {
if _, ok := metadata.Manifest.Extra[field]; ok {
delete(metadata.Manifest.Extra, field)
if metadata.Manifest != nil && metadata.Manifest.Main != nil {
if _, ok := metadata.Manifest.Main[field]; ok {
delete(metadata.Manifest.Main, field)
}
}
}
@@ -338,7 +303,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-boot-starter",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "jul-to-slf4j",
@@ -350,7 +315,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-boot-starter-validation",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "hibernate-validator",
@@ -362,7 +327,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-expression",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "jakarta.validation-api",
@@ -370,11 +335,11 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-web",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "spring-boot-starter-actuator",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "log4j-api",
@@ -394,23 +359,23 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-aop",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "spring-boot-actuator-autoconfigure",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "spring-jcl",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "spring-boot",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "spring-boot-starter-logging",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "jakarta.annotation-api",
@@ -418,7 +383,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-webmvc",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "HdrHistogram",
@@ -426,7 +391,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-boot-starter-web",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "logback-classic",
@@ -438,7 +403,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-boot-starter-json",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "jackson-databind",
@@ -454,7 +419,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-boot-autoconfigure",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "jackson-datatype-jdk8",
@@ -470,11 +435,11 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-beans",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "spring-boot-actuator",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "slf4j-api",
@@ -482,7 +447,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-core",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
{
Name: "logback-core",
@@ -502,7 +467,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-boot-starter-tomcat",
Version: "2.2.2.RELEASE",
Version: "2.2.2",
},
{
Name: "classmate",
@@ -510,7 +475,7 @@ func TestParseNestedJar(t *testing.T) {
},
{
Name: "spring-context",
Version: "5.2.2.RELEASE",
Version: "5.2.2",
},
},
},
@@ -531,7 +496,7 @@ func TestParseNestedJar(t *testing.T) {
t.Fatalf("failed to parse java archive: %+v", err)
}
nameVersionPairSet := internal.NewStringSet()
expectedNameVersionPairSet := internal.NewStringSet()
makeKey := func(p *pkg.Package) string {
if p == nil {
@@ -541,20 +506,32 @@ func TestParseNestedJar(t *testing.T) {
}
for _, e := range test.expected {
nameVersionPairSet.Add(makeKey(&e))
expectedNameVersionPairSet.Add(makeKey(&e))
}
if len(actual) != len(nameVersionPairSet) {
if len(actual) != len(expectedNameVersionPairSet) {
actualNameVersionPairSet := internal.NewStringSet()
for _, a := range actual {
t.Log(" ", a)
key := makeKey(&a)
actualNameVersionPairSet.Add(key)
if !expectedNameVersionPairSet.Contains(key) {
t.Logf("extra package: %s", a)
}
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(nameVersionPairSet))
for _, key := range expectedNameVersionPairSet.ToSlice() {
if !actualNameVersionPairSet.Contains(key) {
t.Logf("missing package: %s", key)
}
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(expectedNameVersionPairSet))
}
for _, a := range actual {
actualKey := makeKey(&a)
if !nameVersionPairSet.Contains(actualKey) {
if !expectedNameVersionPairSet.Contains(actualKey) {
t.Errorf("unexpected pkg: %q", actualKey)
}
@@ -567,7 +544,7 @@ func TestParseNestedJar(t *testing.T) {
if metadata.Parent == nil {
t.Errorf("unassigned error for pkg=%q", actualKey)
} else if makeKey(metadata.Parent) != "spring-boot|0.0.1-SNAPSHOT" {
// NB: this is a hard-coded condition to simplify the test harness
// NB: this is a hard-coded condition to simplify the test harness to account for https://github.com/micrometer-metrics/micrometer/issues/1785
if a.Name == "pcollections" {
if metadata.Parent.Name != "micrometer-core" {
t.Errorf("nested 'pcollections' pkg has wrong parent: %q", metadata.Parent.Name)
+59 -36
View File
@@ -4,25 +4,41 @@ import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/pkg"
"github.com/mitchellh/mapstructure"
)
const manifestPath = "META-INF/MANIFEST.MF"
const manifestGlob = "/META-INF/MANIFEST.MF"
func parseJavaManifest(reader io.Reader) (*pkg.JavaManifest, error) {
// nolint:funlen
// parseJavaManifest takes MANIFEST.MF file content and returns sections of parsed key/value pairs.
// For more information: https://docs.oracle.com/en/java/javase/11/docs/specs/jar/jar.html#jar-manifest
func parseJavaManifest(path string, reader io.Reader) (*pkg.JavaManifest, error) {
var manifest pkg.JavaManifest
manifestMap := make(map[string]string)
sections := []map[string]string{
make(map[string]string),
}
currentSection := 0
scanner := bufio.NewScanner(reader)
var lastKey string
for scanner.Scan() {
line := scanner.Text()
// ignore empty lines
// empty lines denote section separators
if strings.TrimSpace(line) == "" {
currentSection++
// we don't want to allocate a new section map that wont necessarily be used, do that once there is
// a non-empty line to process
// do not process line continuations after this
lastKey = ""
continue
} else if currentSection >= len(sections) {
sections = append(sections, make(map[string]string))
}
if line[0] == ' ' {
@@ -30,7 +46,7 @@ func parseJavaManifest(reader io.Reader) (*pkg.JavaManifest, error) {
if lastKey == "" {
return nil, fmt.Errorf("found continuation with no previous key (%s)", line)
}
manifestMap[lastKey] += strings.TrimSpace(line)
sections[currentSection][lastKey] += strings.TrimSpace(line)
} else {
// this is a new key-value pair
idx := strings.Index(line, ":")
@@ -40,9 +56,9 @@ func parseJavaManifest(reader io.Reader) (*pkg.JavaManifest, error) {
key := strings.TrimSpace(line[0:idx])
value := strings.TrimSpace(line[idx+1:])
manifestMap[key] = value
sections[currentSection][key] = value
// keep track of key for future continuations
// keep track of key for potential future continuations
lastKey = key
}
}
@@ -51,14 +67,24 @@ func parseJavaManifest(reader io.Reader) (*pkg.JavaManifest, error) {
return nil, fmt.Errorf("unable to read java manifest: %w", err)
}
if err := mapstructure.Decode(manifestMap, &manifest); err != nil {
return nil, fmt.Errorf("unable to parse java manifest: %w", err)
}
// clean select fields
if strings.Trim(manifest.ImplVersion, " ") != "" {
// transform versions with dates attached to just versions (e.g. "1.3 2244 October 5 2008" --> "1.3")
manifest.ImplVersion = strings.Split(manifest.ImplVersion, " ")[0]
if len(sections) > 0 {
manifest.Main = sections[0]
if len(sections) > 1 {
manifest.NamedSections = make(map[string]map[string]string)
for i, s := range sections[1:] {
name, ok := s["Name"]
if !ok {
// per the manifest spec (https://docs.oracle.com/en/java/javase/11/docs/specs/jar/jar.html#jar-manifest)
// this should never happen. If it does we want to know about it, but not necessarily stop
// cataloging entirely... for this reason we only log.
log.Errorf("java manifest section found without a name: %s", path)
name = strconv.Itoa(i)
} else {
delete(s, "Name")
}
manifest.NamedSections[name] = s
}
}
}
return &manifest, nil
@@ -69,24 +95,21 @@ func selectName(manifest *pkg.JavaManifest, filenameObj archiveFilename) string
switch {
case filenameObj.name() != "":
name = filenameObj.name()
case manifest.Name != "":
case manifest.Main["Name"] != "":
// Manifest original spec...
name = manifest.Name
case manifest.Extra["Bundle-Name"] != "":
name = manifest.Main["Name"]
case manifest.Main["Bundle-Name"] != "":
// BND tooling...
name = manifest.Extra["Bundle-Name"]
case manifest.Extra["Short-Name"] != "":
name = manifest.Main["Bundle-Name"]
case manifest.Main["Short-Name"] != "":
// Jenkins...
name = manifest.Extra["Short-Name"]
case manifest.Extra["Extension-Name"] != "":
name = manifest.Main["Short-Name"]
case manifest.Main["Extension-Name"] != "":
// Jenkins...
name = manifest.Extra["Extension-Name"]
}
// in situations where we hit this point and no name was
// determined, look at the Implementation-Title
if name == "" && manifest.ImplTitle != "" {
name = manifest.ImplTitle
name = manifest.Main["Extension-Name"]
case manifest.Main["Implementation-Title"] != "":
// last ditch effort...
name = manifest.Main["Implementation-Title"]
}
return name
}
@@ -94,14 +117,14 @@ func selectName(manifest *pkg.JavaManifest, filenameObj archiveFilename) string
func selectVersion(manifest *pkg.JavaManifest, filenameObj archiveFilename) string {
var version string
switch {
case manifest.ImplVersion != "":
version = manifest.ImplVersion
case filenameObj.version() != "":
version = filenameObj.version()
case manifest.SpecVersion != "":
version = manifest.SpecVersion
case manifest.Extra["Plugin-Version"] != "":
version = manifest.Extra["Plugin-Version"]
case manifest.Main["Implementation-Version"] != "":
version = manifest.Main["Implementation-Version"]
case manifest.Main["Specification-Version"] != "":
version = manifest.Main["Specification-Version"]
case manifest.Main["Plugin-Version"] != "":
version = manifest.Main["Plugin-Version"]
}
return version
}
+78 -22
View File
@@ -2,10 +2,11 @@ package java
import (
"encoding/json"
"github.com/anchore/syft/syft/pkg"
"github.com/go-test/deep"
"os"
"testing"
"github.com/anchore/syft/syft/pkg"
"github.com/go-test/deep"
)
func TestParseJavaManifest(t *testing.T) {
@@ -16,49 +17,62 @@ func TestParseJavaManifest(t *testing.T) {
{
fixture: "test-fixtures/manifest/small",
expected: pkg.JavaManifest{
ManifestVersion: "1.0",
Main: map[string]string{
"Manifest-Version": "1.0",
},
},
},
{
fixture: "test-fixtures/manifest/standard-info",
expected: pkg.JavaManifest{
ManifestVersion: "1.0",
Name: "the-best-name",
SpecTitle: "the-spec-title",
SpecVersion: "the-spec-version",
SpecVendor: "the-spec-vendor",
ImplTitle: "the-impl-title",
ImplVersion: "the-impl-version",
ImplVendor: "the-impl-vendor",
Main: map[string]string{
"Name": "the-best-name",
"Manifest-Version": "1.0",
"Specification-Title": "the-spec-title",
"Specification-Version": "the-spec-version",
"Specification-Vendor": "the-spec-vendor",
"Implementation-Title": "the-impl-title",
"Implementation-Version": "the-impl-version",
"Implementation-Vendor": "the-impl-vendor",
},
},
},
{
fixture: "test-fixtures/manifest/extra-info",
expected: pkg.JavaManifest{
ManifestVersion: "1.0",
Extra: map[string]string{
Main: map[string]string{
"Manifest-Version": "1.0",
"Archiver-Version": "Plexus Archiver",
"Build-Jdk": "14.0.1",
"Built-By": "?",
"Created-By": "Apache Maven 3.6.3",
"Main-Class": "hello.HelloWorld",
},
NamedSections: map[string]map[string]string{
"thing-1": {
"Built-By": "?",
},
"1": {
"Build-Jdk": "14.0.1",
"Main-Class": "hello.HelloWorld",
},
},
},
},
{
fixture: "test-fixtures/manifest/continuation",
expected: pkg.JavaManifest{
ManifestVersion: "1.0",
Extra: map[string]string{
"Plugin-ScmUrl": "https://github.com/jenkinsci/plugin-pom/example-jenkins-plugin",
Main: map[string]string{
"Manifest-Version": "1.0",
"Plugin-ScmUrl": "https://github.com/jenkinsci/plugin-pom/example-jenkins-plugin",
},
},
},
{
// regression test, we should always keep the full version
fixture: "test-fixtures/manifest/version-with-date",
expected: pkg.JavaManifest{
ManifestVersion: "1.0",
ImplVersion: "1.3", // ensure the date is stripped off during processing
Main: map[string]string{
"Manifest-Version": "1.0",
"Implementation-Version": "1.3 2244 October 5 2005",
},
},
},
}
@@ -70,7 +84,7 @@ func TestParseJavaManifest(t *testing.T) {
t.Fatalf("could not open fixture: %+v", err)
}
actual, err := parseJavaManifest(fixture)
actual, err := parseJavaManifest(test.fixture, fixture)
if err != nil {
t.Fatalf("failed to parse manifest: %+v", err)
}
@@ -91,3 +105,45 @@ func TestParseJavaManifest(t *testing.T) {
})
}
}
func TestSelectName(t *testing.T) {
tests := []struct {
desc string
manifest pkg.JavaManifest
archive archiveFilename
expected string
}{
{
desc: "Get name from Implementation-Title",
archive: archiveFilename{},
manifest: pkg.JavaManifest{
Main: map[string]string{
"Implementation-Title": "maven-wrapper",
},
},
expected: "maven-wrapper",
},
{
desc: "Implementation-Title does not override name from filename",
manifest: pkg.JavaManifest{
Main: map[string]string{
"Name": "foo",
"Implementation-Title": "maven-wrapper",
},
},
archive: newJavaArchiveFilename("/something/omg.jar"),
expected: "omg",
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
result := selectName(&test.manifest, test.archive)
if result != test.expected {
t.Errorf("mismatch in names: '%s' != '%s'", result, test.expected)
}
})
}
}
@@ -2,6 +2,7 @@ Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Apache Maven 3.6.3
Name: thing-1
Built-By: ?
Build-Jdk: 14.0.1
+39 -5
View File
@@ -4,13 +4,47 @@ Package rpmdb provides a concrete Cataloger implementation for RPM "Package" DB
package rpmdb
import (
"github.com/anchore/syft/syft/cataloger/common"
"fmt"
"strings"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/scope"
)
const (
packagesGlob = "**/var/lib/rpm/Packages"
)
type Cataloger struct{}
// NewRpmdbCataloger returns a new RPM DB cataloger object.
func NewRpmdbCataloger() *common.GenericCataloger {
globParsers := map[string]common.ParserFn{
"**/var/lib/rpm/Packages": parseRpmDB,
func NewRpmdbCataloger() *Cataloger {
return &Cataloger{}
}
// Name returns a string that uniquely describes a cataloger
func (c *Cataloger) Name() string {
return "rpmdb-cataloger"
}
// Catalog is given an object to resolve file references and content, this function returns any discovered Packages after analyzing rpm db installation.
func (c *Cataloger) Catalog(resolver scope.Resolver) ([]pkg.Package, error) {
fileMatches, err := resolver.FilesByGlob(packagesGlob)
if err != nil {
return nil, fmt.Errorf("failed to find rpmdb's by glob: %w", err)
}
return common.NewGenericCataloger(nil, globParsers, "rpmdb-cataloger")
var pkgs []pkg.Package
for _, ref := range fileMatches {
dbContents, err := resolver.FileContentsByRef(ref)
if err != nil {
return nil, err
}
pkgs, err = parseRpmDB(resolver, strings.NewReader(dbContents))
if err != nil {
return nil, fmt.Errorf("unable to catalog rpmdb package=%+v: %w", ref.Path, err)
}
}
return pkgs, nil
}
+34 -5
View File
@@ -6,18 +6,18 @@ import (
"io/ioutil"
"os"
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/scope"
rpmdb "github.com/anchore/go-rpmdb/pkg"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/cataloger/common"
"github.com/anchore/syft/syft/pkg"
)
// integrity check
var _ common.ParserFn = parseRpmDB
// parseApkDb parses an "Packages" RPM DB and returns the Packages listed within it.
func parseRpmDB(_ string, reader io.Reader) ([]pkg.Package, error) {
func parseRpmDB(resolver scope.FileResolver, reader io.Reader) ([]pkg.Package, error) {
f, err := ioutil.TempFile("", internal.ApplicationName+"-rpmdb")
if err != nil {
return nil, fmt.Errorf("failed to create temp rpmdb file: %w", err)
@@ -48,6 +48,11 @@ func parseRpmDB(_ string, reader io.Reader) ([]pkg.Package, error) {
allPkgs := make([]pkg.Package, 0)
for _, entry := range pkgList {
records, err := extractRpmdbFileRecords(resolver, entry)
if err != nil {
return nil, err
}
p := pkg.Package{
Name: entry.Name,
Version: fmt.Sprintf("%s-%s", entry.Version, entry.Release), // this is what engine does
@@ -64,6 +69,7 @@ func parseRpmDB(_ string, reader io.Reader) ([]pkg.Package, error) {
Vendor: entry.Vendor,
License: entry.License,
Size: entry.Size,
Files: records,
},
}
@@ -72,3 +78,26 @@ func parseRpmDB(_ string, reader io.Reader) ([]pkg.Package, error) {
return allPkgs, nil
}
func extractRpmdbFileRecords(resolver scope.FileResolver, entry *rpmdb.PackageInfo) ([]pkg.RpmdbFileRecord, error) {
var records = make([]pkg.RpmdbFileRecord, 0)
for _, record := range entry.Files {
refs, err := resolver.FilesByPath(file.Path(record.Path))
if err != nil {
return nil, fmt.Errorf("failed to resolve path=%+v: %w", record.Path, err)
}
//only persist RPMDB file records which exist in the image/directory, otherwise ignore them
if len(refs) == 0 {
continue
}
records = append(records, pkg.RpmdbFileRecord{
Path: record.Path,
Mode: pkg.RpmdbFileMode(record.Mode),
Size: int(record.Size),
SHA256: record.SHA256,
})
}
return records, nil
}
+123 -40
View File
@@ -1,58 +1,141 @@
package rpmdb
import (
"fmt"
"os"
"testing"
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/pkg"
"github.com/go-test/deep"
)
type rpmdbTestFileResolverMock struct {
ignorePaths bool
}
func newTestFileResolver(ignorePaths bool) *rpmdbTestFileResolverMock {
return &rpmdbTestFileResolverMock{
ignorePaths: ignorePaths,
}
}
func (r *rpmdbTestFileResolverMock) FilesByPath(paths ...file.Path) ([]file.Reference, error) {
if r.ignorePaths {
// act as if no paths exist
return nil, nil
}
// act as if all files exist
var refs = make([]file.Reference, len(paths))
for i, p := range paths {
refs[i] = file.NewFileReference(p)
}
return refs, nil
}
func (r *rpmdbTestFileResolverMock) FilesByGlob(...string) ([]file.Reference, error) {
return nil, fmt.Errorf("not implemented")
}
func (r *rpmdbTestFileResolverMock) RelativeFileByPath(file.Reference, string) (*file.Reference, error) {
return nil, fmt.Errorf("not implemented")
}
func TestParseRpmDB(t *testing.T) {
expected := map[string]pkg.Package{
"dive": {
Name: "dive",
Version: "0.9.2-1",
Type: pkg.RpmPkg,
MetadataType: pkg.RpmdbMetadataType,
Metadata: pkg.RpmdbMetadata{
Name: "dive",
Epoch: 0,
Arch: "x86_64",
Release: "1",
Version: "0.9.2",
SourceRpm: "dive-0.9.2-1.src.rpm",
Size: 12406784,
License: "MIT",
Vendor: "",
tests := []struct {
fixture string
expected map[string]pkg.Package
ignorePaths bool
}{
{
fixture: "test-fixtures/Packages",
// we only surface package paths for files that exist (here we DO NOT expect a path)
ignorePaths: true,
expected: map[string]pkg.Package{
"dive": {
Name: "dive",
Version: "0.9.2-1",
Type: pkg.RpmPkg,
MetadataType: pkg.RpmdbMetadataType,
Metadata: pkg.RpmdbMetadata{
Name: "dive",
Epoch: 0,
Arch: "x86_64",
Release: "1",
Version: "0.9.2",
SourceRpm: "dive-0.9.2-1.src.rpm",
Size: 12406784,
License: "MIT",
Vendor: "",
Files: []pkg.RpmdbFileRecord{},
},
},
},
},
{
fixture: "test-fixtures/Packages",
// we only surface package paths for files that exist (here we expect a path)
ignorePaths: false,
expected: map[string]pkg.Package{
"dive": {
Name: "dive",
Version: "0.9.2-1",
Type: pkg.RpmPkg,
MetadataType: pkg.RpmdbMetadataType,
Metadata: pkg.RpmdbMetadata{
Name: "dive",
Epoch: 0,
Arch: "x86_64",
Release: "1",
Version: "0.9.2",
SourceRpm: "dive-0.9.2-1.src.rpm",
Size: 12406784,
License: "MIT",
Vendor: "",
Files: []pkg.RpmdbFileRecord{
{
Path: "/usr/local/bin/dive",
Mode: 33261,
Size: 12406784,
SHA256: "81d29f327ba23096b3c52ff6fe1c425641e618bc87b5c05ee377edc650afaa55",
},
},
},
},
},
},
}
fixture, err := os.Open("test-fixtures/Packages")
if err != nil {
t.Fatalf("failed to open fixture: %+v", err)
}
actual, err := parseRpmDB(fixture.Name(), fixture)
if err != nil {
t.Fatalf("failed to parse rpmdb: %+v", err)
}
if len(actual) != len(expected) {
for _, a := range actual {
t.Log(" ", a)
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(expected))
}
for _, a := range actual {
e := expected[a.Name]
diffs := deep.Equal(a, e)
if len(diffs) > 0 {
for _, d := range diffs {
t.Errorf("diff: %+v", d)
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
fixture, err := os.Open(test.fixture)
if err != nil {
t.Fatalf("failed to open fixture: %+v", err)
}
}
fileResolver := newTestFileResolver(test.ignorePaths)
actual, err := parseRpmDB(fileResolver, fixture)
if err != nil {
t.Fatalf("failed to parse rpmdb: %+v", err)
}
if len(actual) != len(test.expected) {
for _, a := range actual {
t.Log(" ", a)
}
t.Fatalf("unexpected package count: %d!=%d", len(actual), len(test.expected))
}
for _, a := range actual {
e := test.expected[a.Name]
diffs := deep.Equal(a, e)
if len(diffs) > 0 {
for _, d := range diffs {
t.Errorf("diff: %+v", d)
}
}
}
})
}
}
+4 -4
View File
@@ -29,10 +29,10 @@ type ApkMetadata struct {
// ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records).
type ApkFileRecord struct {
Path string `json:"path"`
OwnerUID string `json:"ownerUid"`
OwnerGUI string `json:"ownerGid"`
Permissions string `json:"permissions"`
Checksum string `json:"checksum"`
OwnerUID string `json:"ownerUid,omitempty"`
OwnerGID string `json:"ownerGid,omitempty"`
Permissions string `json:"permissions,omitempty"`
Checksum string `json:"checksum,omitempty"`
}
func (m ApkMetadata) PackageURL() string {
+7 -13
View File
@@ -4,14 +4,15 @@ import "github.com/package-url/packageurl-go"
// JavaMetadata encapsulates all Java ecosystem metadata for a package as well as an (optional) parent relationship.
type JavaMetadata struct {
Manifest *JavaManifest `mapstructure:"Manifest" json:"manifest"`
PomProperties *PomProperties `mapstructure:"PomProperties" json:"pomProperties"`
Parent *Package `json:"parentPackage"` // TODO: should this be included in the json output?
VirtualPath string `json:"virtualPath"`
Manifest *JavaManifest `mapstructure:"Manifest" json:"manifest,omitempty"`
PomProperties *PomProperties `mapstructure:"PomProperties" json:"pomProperties,omitempty"`
Parent *Package `json:"-"`
}
// PomProperties represents the fields of interest extracted from a Java archive's pom.xml file.
type PomProperties struct {
Path string
Path string `mapstructure:"path" json:"path"`
Name string `mapstructure:"name" json:"name"`
GroupID string `mapstructure:"groupId" json:"groupId"`
ArtifactID string `mapstructure:"artifactId" json:"artifactId"`
@@ -21,15 +22,8 @@ type PomProperties struct {
// JavaManifest represents the fields of interest extracted from a Java archive's META-INF/MANIFEST.MF file.
type JavaManifest struct {
Name string `mapstructure:"Name" json:"name"`
ManifestVersion string `mapstructure:"Manifest-Version" json:"manifestVersion"`
SpecTitle string `mapstructure:"Specification-Title" json:"specificationTitle"`
SpecVersion string `mapstructure:"Specification-Version" json:"specificationVersion"`
SpecVendor string `mapstructure:"Specification-Vendor" json:"specificationVendor"`
ImplTitle string `mapstructure:"Implementation-Title" json:"implementationTitle"`
ImplVersion string `mapstructure:"Implementation-Version" json:"implementationVersion"`
ImplVendor string `mapstructure:"Implementation-Vendor" json:"implementationVendor"`
Extra map[string]string `mapstructure:",remain" json:"extraFields"`
Main map[string]string `json:"main,omitempty"`
NamedSections map[string]map[string]string `json:"namedSections,omitempty"`
}
func (m JavaMetadata) PackageURL() string {
+19 -9
View File
@@ -9,17 +9,27 @@ import (
// RpmdbMetadata represents all captured data for a RPM DB package entry.
type RpmdbMetadata struct {
Name string `json:"name"`
Version string `json:"version"`
Epoch int `json:"epoch"`
Arch string `json:"architecture"`
Release string `json:"release"`
SourceRpm string `json:"sourceRpm"`
Size int `json:"size"`
License string `json:"license"`
Vendor string `json:"vendor"`
Name string `json:"name"`
Version string `json:"version"`
Epoch int `json:"epoch"`
Arch string `json:"architecture"`
Release string `json:"release"`
SourceRpm string `json:"sourceRpm"`
Size int `json:"size"`
License string `json:"license"`
Vendor string `json:"vendor"`
Files []RpmdbFileRecord `json:"files"`
}
type RpmdbFileRecord struct {
Path string `json:"path"`
Mode RpmdbFileMode `json:"mode"`
Size int `json:"size"`
SHA256 string `json:"sha256"`
}
type RpmdbFileMode uint16
func (m RpmdbMetadata) PackageURL(d distro.Distro) string {
pURL := packageurl.NewPackageURL(
packageurl.TypeRPM,
+19 -3
View File
@@ -1,16 +1,24 @@
package json
import (
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/scope"
)
type Document struct {
Artifacts []Artifact `json:"artifacts"`
Source Source `json:"source"`
Artifacts []Artifact `json:"artifacts"`
Source Source `json:"source"`
Distro Distribution `json:"distro"`
}
func NewDocument(catalog *pkg.Catalog, s scope.Scope) (Document, error) {
// Distritbution provides information about a detected Linux Distribution
type Distribution struct {
Name string `json:"name"`
Version string `json:"version"`
}
func NewDocument(catalog *pkg.Catalog, s scope.Scope, d distro.Distro) (Document, error) {
doc := Document{
Artifacts: make([]Artifact, 0),
}
@@ -20,6 +28,14 @@ func NewDocument(catalog *pkg.Catalog, s scope.Scope) (Document, error) {
return Document{}, nil
}
doc.Source = src
distroName := d.Name()
if distroName == "UnknownDistroType" {
distroName = ""
}
doc.Distro = Distribution{
Name: distroName,
Version: d.FullVersion(),
}
for _, p := range catalog.Sorted() {
art, err := NewArtifact(p, s)
+3 -1
View File
@@ -1,6 +1,8 @@
package json
import "github.com/anchore/syft/syft/scope"
import (
"github.com/anchore/syft/syft/scope"
)
type Image struct {
Layers []Layer `json:"layers"`
+5 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/scope"
)
@@ -11,17 +12,19 @@ import (
type Presenter struct {
catalog *pkg.Catalog
scope scope.Scope
distro distro.Distro
}
func NewPresenter(catalog *pkg.Catalog, s scope.Scope) *Presenter {
func NewPresenter(catalog *pkg.Catalog, s scope.Scope, d distro.Distro) *Presenter {
return &Presenter{
catalog: catalog,
scope: s,
distro: d,
}
}
func (pres *Presenter) Present(output io.Writer) error {
doc, err := NewDocument(pres.catalog, pres.scope)
doc, err := NewDocument(pres.catalog, pres.scope, pres.distro)
if err != nil {
return err
}
+5 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/anchore/go-testutils"
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/scope"
"github.com/sergi/go-diff/diffmatchpatch"
@@ -39,12 +40,12 @@ func TestJsonDirsPresenter(t *testing.T) {
{Path: "/some/path/pkg1"},
},
})
d := distro.NewUnknownDistro()
s, err := scope.NewScopeFromDir("/some/path")
if err != nil {
t.Fatal(err)
}
pres := NewPresenter(catalog, s)
pres := NewPresenter(catalog, s, d)
// run presenter
err = pres.Present(&buffer)
@@ -100,7 +101,8 @@ func TestJsonImgsPresenter(t *testing.T) {
})
s, err := scope.NewScopeFromImage(img, scope.AllLayersScope)
pres := NewPresenter(catalog, s)
d := distro.NewUnknownDistro()
pres := NewPresenter(catalog, s, d)
// run presenter
err = pres.Present(&buffer)
@@ -26,5 +26,9 @@
"source": {
"type": "directory",
"target": "/some/path"
},
"distro": {
"name": "",
"version": ""
}
}
@@ -35,26 +35,30 @@
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:e158b57d6f5a96ef5fd22f2fe76c70b5ba6ff5b2619f9d83125b2aad0492ac7b",
"digest": "sha256:78783bfc74fef84f899b4977561ad1172f87753f82cc2157b06bf097e56dfbce",
"size": 22
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:da21056e7bf4308ecea0c0836848a7fe92f38fdcf35bc09ee6d98e7ab7beeebf",
"digest": "sha256:54ec7f643dafbf9f27032a5e60afe06248c0e99b50aed54bb0fe28ea4825ccaf",
"size": 16
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:f0e18aa6032c24659a9c741fc36ca56f589782ea132061ccf6f52b952403da94",
"digest": "sha256:ec4775a139c45b1ddf9ea8e1cb43385e92e5c0bf6ec2e3f4192372785b18c106",
"size": 27
}
],
"size": 65,
"digest": "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368",
"digest": "sha256:fedd7bcc0b90f071501b662d8e7c9ac7548b88daba6b3deedfdf33f22ed8d95b",
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"tags": [
"stereoscope-fixture-image-simple:04e16e44161c8888a1a963720fd0443cbf7eef8101434c431de8725cd98cc9f7"
]
}
},
"distro": {
"name": "",
"version": ""
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ type Presenter interface {
func GetPresenter(option Option, s scope.Scope, catalog *pkg.Catalog, d *distro.Distro) Presenter {
switch option {
case JSONPresenter:
return json.NewPresenter(catalog, s)
return json.NewPresenter(catalog, s, *d)
case TextPresenter:
return text.NewPresenter(catalog, s)
case TablePresenter:
+28 -1
View File
@@ -67,12 +67,26 @@ func (r *AllLayersResolver) FilesByPath(paths ...file.Path) ([]file.Reference, e
for _, path := range paths {
for idx, layerIdx := range r.layers {
ref := r.img.Layers[layerIdx].Tree.File(path)
tree := r.img.Layers[layerIdx].Tree
ref := tree.File(path)
if ref == nil {
// no file found, keep looking through layers
continue
}
// don't consider directories (special case: there is no path information for /)
if ref.Path == "/" {
continue
} else if r.img.FileCatalog.Exists(*ref) {
metadata, err := r.img.FileCatalog.Get(*ref)
if err != nil {
return nil, fmt.Errorf("unable to get file metadata for path=%q: %w", ref.Path, err)
}
if metadata.Metadata.IsDir {
continue
}
}
results, err := r.fileByRef(*ref, uniqueFileIDs, idx)
if err != nil {
return nil, err
@@ -97,6 +111,19 @@ func (r *AllLayersResolver) FilesByGlob(patterns ...string) ([]file.Reference, e
}
for _, ref := range refs {
// don't consider directories (special case: there is no path information for /)
if ref.Path == "/" {
continue
} else if r.img.FileCatalog.Exists(ref) {
metadata, err := r.img.FileCatalog.Get(ref)
if err != nil {
return nil, fmt.Errorf("unable to get file metadata for path=%q: %w", ref.Path, err)
}
if metadata.Metadata.IsDir {
continue
}
}
results, err := r.fileByRef(ref, uniqueFileIDs, idx)
if err != nil {
return nil, err
@@ -1,9 +1,10 @@
package resolvers
import (
"github.com/anchore/stereoscope/pkg/imagetest"
"testing"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/stereoscope/pkg/file"
)
@@ -80,6 +81,11 @@ func TestAllLayersResolver_FilesByPath(t *testing.T) {
},
},
},
{
name: "ignore directories",
linkPath: "/bin",
resolutions: []resolution{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -188,6 +194,11 @@ func TestAllLayersResolver_FilesByGlob(t *testing.T) {
},
},
},
{
name: "ignore directories",
glob: "**/bin",
resolutions: []resolution{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
+10 -1
View File
@@ -33,12 +33,18 @@ func (s DirectoryResolver) FilesByPath(userPaths ...file.Path) ([]file.Reference
// a path relative to root should be prefixed with the resolvers directory path, otherwise it should be left as is
userStrPath = path.Join(s.Path, userStrPath)
}
_, err := os.Stat(userStrPath)
fileMeta, err := os.Stat(userStrPath)
if os.IsNotExist(err) {
continue
} else if err != nil {
log.Errorf("path (%s) is not valid: %v", userStrPath, err)
}
// don't consider directories
if fileMeta.IsDir() {
continue
}
references = append(references, file.NewFileReference(file.Path(userStrPath)))
}
@@ -69,9 +75,12 @@ func (s DirectoryResolver) FilesByGlob(patterns ...string) ([]file.Reference, er
if err != nil {
continue
}
// don't consider directories
if fileMeta.IsDir() {
continue
}
matchedPath := file.Path(match)
result = append(result, file.NewFileReference(matchedPath))
}
@@ -48,6 +48,12 @@ func TestDirectoryResolver_FilesByPath(t *testing.T) {
expected: "test-fixtures/image-symlinks/file-1.txt",
refCount: 1,
},
{
name: "directories ignored",
root: "./test-fixtures/",
input: "/image-symlinks",
refCount: 0,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
+30 -1
View File
@@ -26,16 +26,32 @@ func (r *ImageSquashResolver) FilesByPath(paths ...file.Path) ([]file.Reference,
uniqueFiles := make([]file.Reference, 0)
for _, path := range paths {
ref := r.img.SquashedTree().File(path)
tree := r.img.SquashedTree()
ref := tree.File(path)
if ref == nil {
// no file found, keep looking through layers
continue
}
// don't consider directories (special case: there is no path information for /)
if ref.Path == "/" {
continue
} else if r.img.FileCatalog.Exists(*ref) {
metadata, err := r.img.FileCatalog.Get(*ref)
if err != nil {
return nil, fmt.Errorf("unable to get file metadata for path=%q: %w", ref.Path, err)
}
if metadata.Metadata.IsDir {
continue
}
}
// a file may be a symlink, process it as such and resolve it
resolvedRef, err := r.img.ResolveLinkByImageSquash(*ref)
if err != nil {
return nil, fmt.Errorf("failed to resolve link from img (ref=%+v): %w", ref, err)
}
if resolvedRef != nil && !uniqueFileIDs.Contains(*resolvedRef) {
uniqueFileIDs.Add(*resolvedRef)
uniqueFiles = append(uniqueFiles, *resolvedRef)
@@ -57,6 +73,19 @@ func (r *ImageSquashResolver) FilesByGlob(patterns ...string) ([]file.Reference,
}
for _, ref := range refs {
// don't consider directories (special case: there is no path information for /)
if ref.Path == "/" {
continue
} else if r.img.FileCatalog.Exists(ref) {
metadata, err := r.img.FileCatalog.Get(ref)
if err != nil {
return nil, fmt.Errorf("unable to get file metadata for path=%q: %w", ref.Path, err)
}
if metadata.Metadata.IsDir {
continue
}
}
resolvedRefs, err := r.FilesByPath(ref.Path)
if err != nil {
return nil, fmt.Errorf("failed to find files by path (ref=%+v): %w", ref, err)
@@ -1,9 +1,10 @@
package resolvers
import (
"github.com/anchore/stereoscope/pkg/imagetest"
"testing"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/stereoscope/pkg/file"
)
@@ -44,6 +45,11 @@ func TestImageSquashResolver_FilesByPath(t *testing.T) {
resolveLayer: 8,
resolvePath: "/link-dead",
},
{
name: "ignore directories",
linkPath: "/bin",
resolvePath: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -60,10 +66,20 @@ func TestImageSquashResolver_FilesByPath(t *testing.T) {
t.Fatalf("could not use resolver: %+v", err)
}
if len(refs) != 1 {
expectedRefs := 1
if c.resolvePath == "" {
expectedRefs = 0
}
if len(refs) != expectedRefs {
t.Fatalf("unexpected number of resolutions: %d", len(refs))
}
if expectedRefs == 0 {
// nothing else to assert
return
}
actual := refs[0]
if actual.Path != file.Path(c.resolvePath) {
@@ -119,6 +135,11 @@ func TestImageSquashResolver_FilesByGlob(t *testing.T) {
resolveLayer: 8,
resolvePath: "/link-dead",
},
{
name: "ignore directories",
glob: "**/bin",
resolvePath: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -135,10 +156,20 @@ func TestImageSquashResolver_FilesByGlob(t *testing.T) {
t.Fatalf("could not use resolver: %+v", err)
}
if len(refs) != 1 {
expectedRefs := 1
if c.resolvePath == "" {
expectedRefs = 0
}
if len(refs) != expectedRefs {
t.Fatalf("unexpected number of resolutions: %d", len(refs))
}
if expectedRefs == 0 {
// nothing else to assert
return
}
actual := refs[0]
if actual.Path != file.Path(c.resolvePath) {
+7 -1
View File
@@ -61,9 +61,15 @@ func TestDirectoryScope(t *testing.T) {
{
desc: "path detected",
input: "test-fixtures",
inputPaths: []file.Path{file.Path("test-fixtures/path-detected")},
inputPaths: []file.Path{file.Path("test-fixtures/path-detected/.vimrc")},
expRefs: 1,
},
{
desc: "directory ignored",
input: "test-fixtures",
inputPaths: []file.Path{file.Path("test-fixtures/path-detected")},
expRefs: 0,
},
{
desc: "no files-by-path detected",
input: "test-fixtures",
+4 -3
View File
@@ -37,9 +37,10 @@ skopeo --override-os linux copy docker://docker.io/${TEST_IMAGE} docker-archive:
ls -alh ${TEST_IMAGE_TAR}
# run syft
chmod 755 ${DISTDIR}/syft_darwin_amd64/syft
${DISTDIR}/syft_darwin_amd64/syft version
SYFT_CHECK_FOR_APP_UPDATE=0 ${DISTDIR}/syft_darwin_amd64/syft docker-archive://${TEST_IMAGE_TAR} -vv -o json > ${REPORT}
SYFT_PATH="${DISTDIR}/syft-macos_darwin_amd64/syft"
chmod 755 "${SYFT_PATH}"
"${SYFT_PATH}" version
SYFT_CHECK_FOR_APP_UPDATE=0 "${SYFT_PATH}" docker-archive://${TEST_IMAGE_TAR} -vv -o json > "${REPORT}"
# keep the generated report around
mkdir -p ${RESULTSDIR}
+1
View File
@@ -1,2 +1,3 @@
*.json
*.pyc
inline-reports
+1 -1
View File
@@ -2,7 +2,7 @@ ifndef SYFT_CMD
SYFT_CMD = go run ../../main.go
endif
IMAGE_CLEAN = $(shell echo $(COMPARE_IMAGE) | tr ":" "_")
IMAGE_CLEAN = $(shell basename $(COMPARE_IMAGE) | tr ":" "_" )
SYFT_DIR = syft-reports
SYFT_REPORT = $(SYFT_DIR)/$(IMAGE_CLEAN).json
INLINE_DIR = inline-reports
+1 -2
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bash
set -eu
# TODO: Add "alpine:3.12.0" back in when we've figured out how to handle the apk version field w/ and w/o release information (see issue: https://github.com/anchore/syft/pull/195)
images=("debian:10.5" "centos:8.2.2004" "rails:5.0.1")
images=("debian:10.5" "centos:8.2.2004" "rails:5.0.1" "alpine:3.12.0" "anchore/test_images:java" "anchore/test_images:py38" "anchore/anchore-engine:v0.8.2" "jenkins/jenkins:2.249.2-lts-jdk11" )
# gather all image analyses
for img in "${images[@]}"; do
+194 -249
View File
@@ -1,13 +1,22 @@
#!/usr/bin/env python3
import os
import sys
import json
import difflib
import collections
QUALITY_GATE_THRESHOLD = 0.95
INDENT = " "
IMAGE_QUALITY_GATE = collections.defaultdict(lambda: QUALITY_GATE_THRESHOLD, **{
import utils.package
from utils.format import Colors, print_rows
from utils.inline import InlineScan
from utils.syft import Syft
DEFAULT_QUALITY_GATE_THRESHOLD = 0.95
INDENT = " "
PACKAGE_QUALITY_GATE = collections.defaultdict(lambda: DEFAULT_QUALITY_GATE_THRESHOLD, **{})
METADATA_QUALITY_GATE = collections.defaultdict(lambda: DEFAULT_QUALITY_GATE_THRESHOLD, **{
# syft is better at detecting package versions in specific cases, leading to a drop in matching metadata
"anchore/test_images:java": 0.61,
"jenkins/jenkins:2.249.2-lts-jdk11": 0.85,
})
# We additionally fail if an image is above a particular threshold. Why? We expect the lower threshold to be 90%,
@@ -16,271 +25,207 @@ IMAGE_QUALITY_GATE = collections.defaultdict(lambda: QUALITY_GATE_THRESHOLD, **{
# issues/enhancements are done we want to ensure that the lower threshold is bumped up to catch regression. The only way
# to do this is to select an upper threshold for images with known threshold values, so we have a failure that
# loudly indicates the lower threshold should be bumped.
IMAGE_UPPER_THRESHOLD = collections.defaultdict(lambda: 1, **{
PACKAGE_UPPER_THRESHOLD = collections.defaultdict(lambda: 1, **{})
METADATA_UPPER_THRESHOLD = collections.defaultdict(lambda: 1, **{
# syft is better at detecting package versions in specific cases, leading to a drop in matching metadata
"anchore/test_images:java": 0.65,
"jenkins/jenkins:2.249.2-lts-jdk11": 0.9,
})
Metadata = collections.namedtuple("Metadata", "version")
Package = collections.namedtuple("Package", "name type")
class InlineScan:
def report(image, analysis):
if analysis.extra_packages:
rows = []
print(
Colors.bold + "Syft found extra packages:",
Colors.reset,
"Syft discovered packages that Inline did not",
)
for package in sorted(list(analysis.extra_packages)):
rows.append([INDENT, repr(package)])
print_rows(rows)
print()
report_tmpl = "{image}-{report}.json"
if analysis.missing_packages:
rows = []
print(
Colors.bold + "Syft missed packages:",
Colors.reset,
"Inline discovered packages that Syft did not",
)
for package in sorted(list(analysis.missing_packages)):
rows.append([INDENT, repr(package)])
print_rows(rows)
print()
def __init__(self, image, report_dir="./"):
self.report_dir = report_dir
self.image = image
if analysis.missing_metadata:
print(
Colors.bold + "Syft mismatched metadata:",
Colors.reset,
"the packages between Syft and Inline are the same, the metadata is not",
)
for inline_metadata_pair in sorted(list(analysis.missing_metadata)):
pkg, metadata = inline_metadata_pair
if pkg not in analysis.syft_data.metadata[pkg.type]:
continue
syft_metadata_item = analysis.syft_data.metadata[pkg.type][pkg]
def packages(self):
python_packages, python_metadata = self._python_packages()
gem_packages, gem_metadata = self._gem_packages()
os_packages, os_metadata = self._os_packages()
return python_packages | os_packages | gem_packages , {**python_metadata, **os_metadata, **gem_metadata}
diffs = difflib.ndiff([repr(syft_metadata_item)], [repr(metadata)])
def _report_path(self, report):
return os.path.join(
self.report_dir,
self.report_tmpl.format(image=self.image.replace(":", "_"), report=report),
print(INDENT + "for: " + repr(pkg), "(top is syft, bottom is inline)")
print(INDENT+INDENT+("\n"+INDENT+INDENT).join(list(diffs)))
if not analysis.missing_metadata:
print(
INDENT,
"There are mismatches, but only due to packages Syft did not find (but inline did).\n",
)
if analysis.similar_missing_packages:
rows = []
print(
Colors.bold + "Probably pairings of missing/extra packages:",
Colors.reset,
"to aid in troubleshooting missed/extra packages",
)
for similar_packages in analysis.similar_missing_packages:
rows.append(
[
INDENT,
repr(similar_packages.pkg),
"--->",
repr(similar_packages.missed),
]
)
print_rows(rows)
print()
show_probable_mismatches = analysis.unmatched_missing_packages and analysis.extra_packages and len(analysis.unmatched_missing_packages) != len(analysis.missing_packages)
if show_probable_mismatches:
rows = []
print(
Colors.bold + "Probably missed packages:",
Colors.reset,
"a probable pair was not found",
)
for p in analysis.unmatched_missing_packages:
rows.append([INDENT, repr(p)])
print_rows(rows)
print()
print(Colors.bold + "Summary:", Colors.reset, image)
print(" Inline Packages : %d" % len(analysis.inline_data.packages))
print(" Syft Packages : %d" % len(analysis.syft_data.packages))
print(
" (extra) : %d (note: this is ignored by the quality gate!)"
% len(analysis.extra_packages)
)
print(" (missing) : %d" % len(analysis.missing_packages))
print()
if show_probable_mismatches:
print(
" Probable Package Matches : %d (matches not made, but were probably found by both Inline and Syft)"
% len(analysis.similar_missing_packages)
)
print(
" Probable Packages Matched : %2.3f %% (%d/%d packages)"
% (
analysis.percent_probable_overlapping_packages,
len(analysis.overlapping_packages)
+ len(analysis.similar_missing_packages),
len(analysis.inline_data.packages),
)
)
print(
" Probable Packages Missing : %d "
% len(analysis.unmatched_missing_packages)
)
print()
print(
" Baseline Packages Matched : %2.3f %% (%d/%d packages)"
% (
analysis.percent_overlapping_packages,
len(analysis.overlapping_packages),
len(analysis.inline_data.packages),
)
)
print(
" Baseline Metadata Matched : %2.3f %% (%d/%d metadata)"
% (
analysis.percent_overlapping_metadata,
len(analysis.overlapping_metadata),
len(analysis.inline_metadata),
)
)
def enforce_quality_gate(title, actual_value, lower_gate_value, upper_gate_value):
if actual_value < lower_gate_value:
print(
Colors.bold
+ " %s Quality Gate:\t" % title
+ Colors.FG.red
+ "FAIL (is not >= %d %%)" % lower_gate_value,
Colors.reset,
)
return False
elif actual_value > upper_gate_value:
print(
Colors.bold
+ " %s Quality Gate:\t" % title
+ Colors.FG.orange
+ "FAIL (lower threshold is artificially low and should be updated)",
Colors.reset,
)
return False
print(
Colors.bold
+ " %s Quality Gate:\t" % title
+ Colors.FG.green
+ "Pass (>= %d %%)" % lower_gate_value,
Colors.reset,
)
def _enumerate_section(self, report, section):
report_path = self._report_path(report=report)
os_report_path = self._report_path(report="content-os")
if os.path.exists(os_report_path) and not os.path.exists(report_path):
# if the OS report is there but the target report is not, that is engine's way of saying "no findings"
return
with open(report_path) as json_file:
data = json.load(json_file)
for entry in data[section]:
yield entry
def _python_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(
report="content-python", section="content"
):
package = Package(name=entry["package"], type=entry["type"].lower(),)
packages.add(package)
metadata[package.type][package] = Metadata(version=entry["version"])
return packages, metadata
def _gem_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(
report="content-gem", section="content"
):
package = Package(name=entry["package"], type=entry["type"].lower(),)
packages.add(package)
metadata[package.type][package] = Metadata(version=entry["version"])
return packages, metadata
def _os_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(report="content-os", section="content"):
package = Package(name=entry["package"], type=entry["type"].lower())
packages.add(package)
metadata[package.type][package] = Metadata(version=entry["version"])
return packages, metadata
class Syft:
report_tmpl = "{image}.json"
def __init__(self, image, report_dir="./"):
self.report_path = os.path.join(
report_dir, self.report_tmpl.format(image=image.replace(":", "_"))
)
def _enumerate_section(self, section):
with open(self.report_path) as json_file:
data = json.load(json_file)
for entry in data[section]:
yield entry
def packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(section="artifacts"):
# normalize to inline
pkg_type = entry["type"].lower()
if pkg_type in ("wheel", "egg"):
pkg_type = "python"
elif pkg_type in ("deb",):
pkg_type = "dpkg"
elif pkg_type in ("java-archive",):
pkg_type = "java"
elif pkg_type in ("apk",):
pkg_type = "apkg"
package = Package(name=entry["name"], type=pkg_type,)
packages.add(package)
metadata[package.type][package] = Metadata(version=entry["version"])
return packages, metadata
def print_rows(rows):
if not rows:
return
widths = []
for col, _ in enumerate(rows[0]):
width = max(len(row[col]) for row in rows) + 2 # padding
widths.append(width)
for row in rows:
print("".join(word.ljust(widths[col_idx]) for col_idx, word in enumerate(row)))
return True
def main(image):
print(colors.bold+"Image:", image, colors.reset)
cwd = os.path.dirname(os.path.abspath(__file__))
inline = InlineScan(image=image, report_dir="inline-reports")
inline_packages, inline_metadata = inline.packages()
# parse the inline-scan and syft reports on disk
inline = InlineScan(image=image, report_dir=os.path.join(cwd, "inline-reports"))
syft = Syft(image=image, report_dir=os.path.join(cwd, "syft-reports"))
syft = Syft(image=image, report_dir="syft-reports")
syft_packages, syft_metadata = syft.packages()
if len(inline_packages) == 0:
# we are purposefully selecting test images that are guaranteed to have packages, so this should never happen
print(colors.bold + colors.fg.red + "inline found no packages!", colors.reset)
return 1
if len(syft_packages) == 0 and len(inline_packages) == 0:
print("nobody found any packages")
return 0
same_packages = syft_packages & inline_packages
percent_overlap_packages = (
float(len(same_packages)) / float(len(inline_packages))
) * 100.0
bonus_packages = syft_packages - inline_packages
missing_packages = inline_packages - syft_packages
inline_metadata_set = set()
for package in inline_packages:
metadata = inline_metadata[package.type][package]
inline_metadata_set.add((package, metadata))
syft_overlap_metadata_set = set()
for package in syft_packages:
metadata = syft_metadata[package.type][package]
# we only want to really count mismatched metadata for packages that are at least found by inline
if package in inline_metadata.get(package.type, []):
syft_overlap_metadata_set.add((package, metadata))
same_metadata = syft_overlap_metadata_set & inline_metadata_set
percent_overlap_metadata = (
float(len(same_metadata)) / float(len(inline_metadata_set))
) * 100.0
missing_metadata = inline_metadata_set - same_metadata
if bonus_packages:
rows = []
print(colors.bold + "Syft found extra packages:", colors.reset)
for package in sorted(list(bonus_packages)):
rows.append([INDENT, repr(package)])
print_rows(rows)
print()
if missing_packages:
rows = []
print(colors.bold + "Syft missed packages:", colors.reset)
for package in sorted(list(missing_packages)):
rows.append([INDENT, repr(package)])
print_rows(rows)
print()
if missing_metadata:
rows = []
print(colors.bold + "Syft mismatched metadata:", colors.reset)
for inline_metadata_pair in sorted(list(missing_metadata)):
pkg, metadata = inline_metadata_pair
if pkg in syft_metadata[pkg.type]:
syft_metadata_item = syft_metadata[pkg.type][pkg]
else:
syft_metadata_item = "--- MISSING ---"
rows.append([INDENT, "for:", repr(pkg), ":", repr(syft_metadata_item), "!=", repr(metadata)])
print_rows(rows)
print()
print(colors.bold+"Summary:", colors.reset)
print(" Image: %s" % image)
print(" Inline Packages : %d" % len(inline_packages))
print(" Syft Packages : %d" % len(syft_packages))
print(" (extra) : %d" % len(bonus_packages))
print(" (missing) : %d" % len(missing_packages))
print(
" Baseline Packages Matched: %2.3f %% (%d/%d packages)"
% (percent_overlap_packages, len(same_packages), len(inline_packages))
)
print(
" Baseline Metadata Matched: %2.3f %% (%d/%d metadata)"
% (percent_overlap_metadata, len(same_metadata), len(inline_metadata_set))
# analyze the raw data to generate all derivative data for the report and quality gate
analysis = utils.package.Analysis(
syft_data=syft.packages(), inline_data=inline.packages()
)
overall_score = (percent_overlap_packages + percent_overlap_metadata) / 2.0
# show some useful report data for debugging / warm fuzzies
report(image, analysis)
print(colors.bold + " Overall Score: %2.1f %%" % overall_score, colors.reset)
# enforce a quality gate based on the comparison of package values and metadata values
success = True
success &= enforce_quality_gate(
title="Package",
actual_value=analysis.percent_overlapping_packages,
lower_gate_value=PACKAGE_QUALITY_GATE[image] * 100,
upper_gate_value=PACKAGE_UPPER_THRESHOLD[image] * 100
)
success &= enforce_quality_gate(
title="Metadata",
actual_value=analysis.percent_overlapping_metadata,
lower_gate_value=METADATA_QUALITY_GATE[image] * 100,
upper_gate_value=METADATA_UPPER_THRESHOLD[image] * 100
)
upper_gate_value = IMAGE_UPPER_THRESHOLD[image] * 100
lower_gate_value = IMAGE_QUALITY_GATE[image] * 100
if overall_score < lower_gate_value:
print(colors.bold + " Quality Gate: " + colors.fg.red + "FAILED (is not >= %d %%)\n" % lower_gate_value, colors.reset)
if not success:
return 1
elif overall_score > upper_gate_value:
print(colors.bold + " Quality Gate: " + colors.fg.orange + "FAILED (lower threshold is artificially low and should be updated)\n", colors.reset)
return 1
else:
print(colors.bold + " Quality Gate: " + colors.fg.green + "pass (>= %d %%)\n" % lower_gate_value, colors.reset)
return 0
class colors:
reset='\033[0m'
bold='\033[01m'
disable='\033[02m'
underline='\033[04m'
reverse='\033[07m'
strikethrough='\033[09m'
invisible='\033[08m'
class fg:
black='\033[30m'
red='\033[31m'
green='\033[32m'
orange='\033[33m'
blue='\033[34m'
purple='\033[35m'
cyan='\033[36m'
lightgrey='\033[37m'
darkgrey='\033[90m'
lightred='\033[91m'
lightgreen='\033[92m'
yellow='\033[93m'
lightblue='\033[94m'
pink='\033[95m'
lightcyan='\033[96m'
class bg:
black='\033[40m'
red='\033[41m'
green='\033[42m'
orange='\033[43m'
blue='\033[44m'
purple='\033[45m'
cyan='\033[46m'
lightgrey='\033[47m'
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("provide an image")
+46
View File
@@ -0,0 +1,46 @@
class Colors:
reset = "\033[0m"
bold = "\033[01m"
disable = "\033[02m"
underline = "\033[04m"
reverse = "\033[07m"
strikethrough = "\033[09m"
invisible = "\033[08m"
class FG:
black = "\033[30m"
red = "\033[31m"
green = "\033[32m"
orange = "\033[33m"
blue = "\033[34m"
purple = "\033[35m"
cyan = "\033[36m"
lightgrey = "\033[37m"
darkgrey = "\033[90m"
lightred = "\033[91m"
lightgreen = "\033[92m"
yellow = "\033[93m"
lightblue = "\033[94m"
pink = "\033[95m"
lightcyan = "\033[96m"
class BG:
black = "\033[40m"
red = "\033[41m"
green = "\033[42m"
orange = "\033[43m"
blue = "\033[44m"
purple = "\033[45m"
cyan = "\033[46m"
lightgrey = "\033[47m"
def print_rows(rows):
if not rows:
return
widths = []
for col, _ in enumerate(rows[0]):
width = max(len(row[col]) for row in rows) + 2 # padding
widths.append(width)
for row in rows:
print("".join(word.ljust(widths[col_idx]) for col_idx, word in enumerate(row)))
+5
View File
@@ -0,0 +1,5 @@
import os
def clean(image: str) -> str:
return os.path.basename(image.replace(":", "_"))
+142
View File
@@ -0,0 +1,142 @@
import os
import re
import json
import collections
import utils.package
import utils.image
class InlineScan:
"""
Class for parsing inlnie-scan output files into a set of packages and package metadata.
"""
report_tmpl = "{image}-{report}.json"
def __init__(self, image, report_dir):
self.report_dir = report_dir
self.image = image
def packages(self):
python_packages, python_metadata = self._python_packages()
gem_packages, gem_metadata = self._gem_packages()
java_packages, java_metadata = self._java_packages()
npm_packages, npm_metadata = self._npm_packages()
os_packages, os_metadata = self._os_packages()
packages = (
python_packages | os_packages | gem_packages | java_packages | npm_packages
)
metadata = {
**python_metadata,
**os_metadata,
**gem_metadata,
**java_metadata,
**npm_metadata,
}
return utils.package.Info(packages=frozenset(packages), metadata=metadata)
def _report_path(self, report):
return os.path.join(
self.report_dir,
self.report_tmpl.format(image=utils.image.clean(self.image), report=report),
)
def _enumerate_section(self, report, section):
report_path = self._report_path(report=report)
os_report_path = self._report_path(report="content-os")
if os.path.exists(os_report_path) and not os.path.exists(report_path):
# if the OS report is there but the target report is not, that is engine's way of saying "no findings"
return
with open(report_path) as json_file:
data = json.load(json_file)
for entry in data[section]:
yield entry
def _java_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(report="content-java", section="content"):
# normalize to pseudo-inline
pkg_type = entry["type"].lower()
if pkg_type in ("java-jar", "java-war", "java-ear"):
pkg_type = "java-?ar"
elif pkg_type in ("java-jpi", "java-hpi"):
pkg_type = "java-?pi"
# this would usually be "package" but this would not be able to account for duplicate dependencies in
# nested jars of the same name. Fallback to the package name if there is no given location
name = entry["location"]
# replace fields with "N/A" with None
for k, v in dict(entry).items():
if v in ("", "N/A"):
entry[k] = None
pkg = utils.package.Package(
name=name,
type=pkg_type,
)
packages.add(pkg)
metadata[pkg.type][pkg] = utils.package.Metadata(
version=entry["maven-version"],
)
return packages, metadata
def _npm_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(report="content-npm", section="content"):
pkg = utils.package.Package(
name=entry["package"],
type=entry["type"].lower(),
)
packages.add(pkg)
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
return packages, metadata
def _python_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(
report="content-python", section="content"
):
pkg = utils.package.Package(
name=entry["package"],
type=entry["type"].lower(),
)
packages.add(pkg)
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
return packages, metadata
def _gem_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(report="content-gem", section="content"):
pkg = utils.package.Package(
name=entry["package"],
type=entry["type"].lower(),
)
packages.add(pkg)
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
return packages, metadata
def _os_packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(report="content-os", section="content"):
pkg = utils.package.Package(
name=entry["package"], type=entry["type"].lower()
)
packages.add(pkg)
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
return packages, metadata
+146
View File
@@ -0,0 +1,146 @@
import difflib
import collections
import dataclasses
from typing import Set, FrozenSet, Tuple, Any, List
Metadata = collections.namedtuple("Metadata", "version")
Package = collections.namedtuple("Package", "name type")
Info = collections.namedtuple("Info", "packages metadata")
SimilarPackages = collections.namedtuple("SimilarPackages", "pkg missed")
ProbableMatch = collections.namedtuple("ProbableMatch", "pkg ratio")
@dataclasses.dataclass()
class Analysis:
"""
A package metadata analysis class. When given the raw syft and inline data, all necessary derivative information
needed to do a comparison of package and metadata is performed, allowing callers to interpret the results
"""
# all raw data from the inline scan and syft reports
syft_data: Info
inline_data: Info
# all derivative information (derived from the raw data above)
overlapping_packages: FrozenSet[Package] = dataclasses.field(init=False)
extra_packages: FrozenSet[Package] = dataclasses.field(init=False)
missing_packages: FrozenSet[Package] = dataclasses.field(init=False)
inline_metadata: Set[Tuple[Any, Any]] = dataclasses.field(init=False)
missing_metadata: Set[Tuple[Any, Any]] = dataclasses.field(init=False)
overlapping_metadata: Set[Tuple[Any, Any]] = dataclasses.field(init=False)
similar_missing_packages: List[Package] = dataclasses.field(init=False)
unmatched_missing_packages: List[Package] = dataclasses.field(init=False)
def __post_init__(self):
if not self.valid():
raise RuntimeError("invalid data given")
# basic sets derived from package information
self.overlapping_packages = self.syft_data.packages & self.inline_data.packages
self.extra_packages = self.syft_data.packages - self.inline_data.packages
self.missing_packages = self.inline_data.packages - self.syft_data.packages
# basic sets derived from metadata information
self.inline_metadata = self._inline_metadata()
self.overlapping_metadata = self._overlapping_metadata()
self.missing_metadata = self.inline_metadata - self.overlapping_metadata
# try to account for potential false negatives by pairing extra packages discovered only by syft with missing
# packages discovered only by inline scan.
(
similar_missing_packages,
unmatched_missing_packages,
) = self._pair_similar_packages(self.extra_packages, self.missing_packages)
self.similar_missing_packages = similar_missing_packages
self.unmatched_missing_packages = unmatched_missing_packages
def valid(self) -> bool:
# we are purposefully selecting test images that are guaranteed to have packages (this should never happen).
# ... if it does, then this analysis is not valid!
return bool(self.inline_data.packages)
def _inline_metadata(self):
"""
Returns the set of inline scan metadata paired with the corresponding package info.
"""
inline_metadata_set = set()
for package in self.inline_data.packages:
metadata = self.inline_data.metadata[package.type][package]
inline_metadata_set.add((package, metadata))
return inline_metadata_set
def _overlapping_metadata(self):
"""
Returns the metadata which has been found similar between both syft and inline scan.
"""
syft_overlap_metadata_set = set()
for package in self.syft_data.packages:
metadata = self.syft_data.metadata[package.type][package]
# we only want to really count mismatched metadata for packages that are at least found by inline
if package in self.inline_data.metadata.get(package.type, []):
syft_overlap_metadata_set.add((package, metadata))
return syft_overlap_metadata_set & self.inline_metadata
@staticmethod
def _pair_similar_packages(extra_packages, missing_packages, similar_threshold=0.7):
"""
Try to account for potential false negatives by pairing extra packages discovered only by syft with missing
packages discovered only by inline scan.
"""
matches = collections.defaultdict(set)
found = {}
for s in extra_packages:
for i in missing_packages:
ratio = difflib.SequenceMatcher(None, s.name, i.name).ratio()
if ratio >= similar_threshold:
if i in found:
# only allow for an inline package to be paired once
if ratio < found[i]:
continue
else:
matches[s].discard(i)
# persist the result
found[i] = ratio
matches[s].add(i)
results = []
for s, i_set in matches.items():
missed = tuple([ProbableMatch(pkg=i, ratio=found[i]) for i in i_set])
results.append(SimilarPackages(pkg=s, missed=missed))
not_found = [i for i in missing_packages if i not in found]
return sorted(results, key=lambda x: x.pkg), sorted(
not_found, key=lambda x: x.name
)
@property
def percent_overlapping_packages(self):
"""Returns a percentage representing how many packages that were found relative to the number of expected"""
return (
float(len(self.overlapping_packages))
/ float(len(self.inline_data.packages))
) * 100.0
@property
def percent_overlapping_metadata(self):
"""Returns a percentage representing how many matching metdata that were found relative to the number of expected"""
return (
float(len(self.overlapping_metadata)) / float(len(self.inline_metadata))
) * 100.0
@property
def percent_probable_overlapping_packages(self):
"""
Returns a percentage representing how many packages that were found relative to the number of expected after
considering pairing of missing packages with extra packages in a fuzzy match.
"""
return (
float(len(self.overlapping_packages) + len(self.similar_missing_packages))
/ float(len(self.inline_data.packages))
) * 100.0
+68
View File
@@ -0,0 +1,68 @@
import os
import json
import collections
import utils.package
import utils.image
class Syft:
"""
Class for parsing syft output into a set of packages and package metadata.
"""
report_tmpl = "{image}.json"
def __init__(self, image, report_dir):
self.report_path = os.path.join(
report_dir, self.report_tmpl.format(image=utils.image.clean(image))
)
def _enumerate_section(self, section):
with open(self.report_path) as json_file:
data = json.load(json_file)
for entry in data[section]:
yield entry
def packages(self):
packages = set()
metadata = collections.defaultdict(dict)
for entry in self._enumerate_section(section="artifacts"):
# normalize to inline
pkg_type = entry["type"].lower()
if pkg_type in ("wheel", "egg", "python"):
pkg_type = "python"
elif pkg_type in ("deb",):
pkg_type = "dpkg"
elif pkg_type in ("java-archive",):
# normalize to pseudo-inline
pkg_type = "java-?ar"
elif pkg_type in ("jenkins-plugin",):
# normalize to pseudo-inline
pkg_type = "java-?pi"
elif pkg_type in ("apk",):
pkg_type = "apkg"
name = entry["name"]
version = entry["version"]
if "java" in pkg_type:
# we need to use the virtual path instead of the name to account for nested dependencies with the same
# package name (but potentially different metadata)
name = entry.get("metadata", {}).get("virtualPath")
elif pkg_type == "apkg":
# inline scan strips off the release from the version, which should be normalized here
fields = entry["version"].split("-")
version = "-".join(fields[:-1])
pkg = utils.package.Package(
name=name,
type=pkg_type,
)
packages.add(pkg)
metadata[pkg.type][pkg] = utils.package.Metadata(version=version)
return utils.package.Info(packages=frozenset(packages), metadata=metadata)