mirror of
https://github.com/anchore/syft.git
synced 2026-10-24 03:52:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
773581704c | ||
|
|
6e98752c6b | ||
|
|
e2593cd6b7 | ||
|
|
232cd13035 | ||
|
|
2532928afa | ||
|
|
03dbfb8dfb | ||
|
|
a5cba13ddf | ||
|
|
a4f22e65fc | ||
|
|
ab45be98b8 | ||
|
|
fc991bc62e | ||
|
|
1230650771 | ||
|
|
452426d1d6 | ||
|
|
2675891110 | ||
|
|
bffc4713a7 | ||
|
|
62f6146c37 | ||
|
|
8bcba7d830 | ||
|
|
76c8f24090 | ||
|
|
cc466e47da | ||
|
|
f9407d0ce4 |
@@ -14,7 +14,8 @@ env:
|
||||
|
||||
jobs:
|
||||
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/setup-go@v2
|
||||
@@ -79,7 +80,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 +131,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
|
||||
|
||||
@@ -29,3 +29,6 @@ coverage.txt
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# macOS Finder metadata
|
||||
.DS_STORE
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
*.json
|
||||
*.pyc
|
||||
inline-reports
|
||||
@@ -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,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
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
|
||||
def clean(image: str) -> str:
|
||||
return os.path.basename(image.replace(":", "_"))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user