mirror of
https://github.com/anchore/grype
synced 2024-11-10 06:34:13 +00:00
Fix hang when running as a subprocess (#484)
* use named pipe bit on stdin as indicator for piped input Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * ensure stdin is ignored when the CLI hints are present Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * add CLI test to cover subprocess integration behavior Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * added test case for java regression Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * remove extra line in makefile Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
parent
9349060765
commit
afc9de6058
17 changed files with 318 additions and 493 deletions
11
.github/workflows/static-unit-integration.yaml
vendored
11
.github/workflows/static-unit-integration.yaml
vendored
|
@ -74,7 +74,7 @@ jobs:
|
|||
- name: Validate grype output against the CycloneDX schema
|
||||
run: make validate-cyclonedx-schema
|
||||
|
||||
- name: Build key for tar cache
|
||||
- name: Build key for integration tar cache
|
||||
run: make integration-fingerprint
|
||||
|
||||
- name: Restore integration test cache
|
||||
|
@ -83,6 +83,15 @@ jobs:
|
|||
path: ${{ github.workspace }}/test/integration/test-fixtures/cache
|
||||
key: ${{ runner.os }}-integration-test-cache-${{ hashFiles('test/integration/test-fixtures/cache.fingerprint') }}
|
||||
|
||||
- name: Build key for CLI tar cache
|
||||
run: make cli-fingerprint
|
||||
|
||||
- name: Restore cli test cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ github.workspace }}/test/cli/test-fixtures/cache
|
||||
key: ${{ runner.os }}-cli-test-cache-${{ hashFiles('test/cli/test-fixtures/cache.fingerprint') }}
|
||||
|
||||
- name: Run integration tests
|
||||
run: make integration
|
||||
|
||||
|
|
5
Makefile
5
Makefile
|
@ -149,6 +149,11 @@ integration: ## Run integration tests
|
|||
integration-fingerprint:
|
||||
find test/integration/*.go test/integration/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | md5sum | tee test/integration/test-fixtures/cache.fingerprint
|
||||
|
||||
# note: this is used by CI to determine if the cli test fixture cache (docker image tars) should be busted
|
||||
.PHONY: cli-fingerprint
|
||||
cli-fingerprint:
|
||||
find test/cli/*.go test/cli/test-fixtures/image-* -type f -exec md5sum {} + | awk '{print $1}' | sort | md5sum | tee test/cli/test-fixtures/cache.fingerprint
|
||||
|
||||
.PHONY: cli
|
||||
cli: $(SNAPSHOTDIR) ## Run CLI tests
|
||||
chmod 755 "$(SNAPSHOT_CMD)"
|
||||
|
|
1
go.mod
1
go.mod
|
@ -14,6 +14,7 @@ require (
|
|||
github.com/docker/docker v17.12.0-ce-rc1.0.20200309214505-aa6a9891b09c+incompatible
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/facebookincubator/nvdtools v0.1.4
|
||||
github.com/gabriel-vasile/mimetype v1.3.0
|
||||
github.com/go-test/deep v1.0.7
|
||||
github.com/google/go-cmp v0.4.1
|
||||
github.com/google/uuid v1.2.0
|
||||
|
|
|
@ -1,82 +1,21 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/grype/internal/log"
|
||||
|
||||
"github.com/anchore/grype/internal"
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
var errDoesNotProvide = fmt.Errorf("cannot provide packages from the given source")
|
||||
|
||||
type providerConfig struct {
|
||||
userInput string
|
||||
scopeOpt source.Scope
|
||||
reader io.Reader
|
||||
registryOptions *image.RegistryOptions
|
||||
}
|
||||
|
||||
type provider func(cfg providerConfig) ([]Package, Context, error)
|
||||
|
||||
// Provide a set of packages and context metadata describing where they were sourced from.
|
||||
func Provide(userInput string, scopeOpt source.Scope, registryOptions *image.RegistryOptions) ([]Package, Context, error) {
|
||||
providers := []provider{
|
||||
syftJSONProvider,
|
||||
syftProvider, // important: we should try syft last
|
||||
packages, ctx, err := syftSBOMProvider(userInput)
|
||||
if !errors.Is(err, errDoesNotProvide) {
|
||||
return packages, ctx, err
|
||||
}
|
||||
|
||||
// capture stdin bytes, so they can be used across multiple providers
|
||||
capturedStdin := bytesFromStdin()
|
||||
|
||||
for _, provide := range providers {
|
||||
config := determineProviderConfig(userInput, scopeOpt, registryOptions, capturedStdin)
|
||||
|
||||
packages, ctx, err := provide(config)
|
||||
if !errors.Is(err, errDoesNotProvide) {
|
||||
return packages, ctx, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, Context{}, errDoesNotProvide
|
||||
}
|
||||
|
||||
func bytesFromStdin() []byte {
|
||||
isPipedInput, err := internal.IsPipedInput()
|
||||
if err != nil {
|
||||
log.Warnf("unable to determine if there is piped input: %+v", err)
|
||||
isPipedInput = false
|
||||
}
|
||||
|
||||
if isPipedInput {
|
||||
capturedStdin, err := ioutil.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return capturedStdin
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func determineProviderConfig(userInput string, scopeOpt source.Scope, registryOptions *image.RegistryOptions, stdin []byte) providerConfig {
|
||||
config := providerConfig{
|
||||
userInput: userInput,
|
||||
scopeOpt: scopeOpt,
|
||||
registryOptions: registryOptions,
|
||||
}
|
||||
|
||||
if len(stdin) > 0 {
|
||||
config.reader = bytes.NewReader(stdin)
|
||||
}
|
||||
|
||||
return config
|
||||
return syftProvider(userInput, scopeOpt, registryOptions)
|
||||
}
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/syft/syft/source"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type providerTestConfig struct {
|
||||
userInput string
|
||||
scopeOpt source.Scope
|
||||
readerBytes []byte
|
||||
}
|
||||
|
||||
func TestDetermineProviderConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
userInput string
|
||||
scopeOpt source.Scope
|
||||
stdin []byte
|
||||
expected providerTestConfig
|
||||
}{
|
||||
{
|
||||
"explicit sbom path",
|
||||
"sbom:/Users/bob/sbom.json",
|
||||
source.SquashedScope,
|
||||
nil,
|
||||
providerTestConfig{
|
||||
"sbom:/Users/bob/sbom.json",
|
||||
source.SquashedScope,
|
||||
nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
"explicit stdin",
|
||||
"",
|
||||
source.SquashedScope,
|
||||
[]byte("{some json}"),
|
||||
providerTestConfig{
|
||||
"",
|
||||
source.SquashedScope,
|
||||
[]byte("{some json}"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"stdin and userInput",
|
||||
"some-value",
|
||||
source.SquashedScope,
|
||||
[]byte("{some json}"),
|
||||
providerTestConfig{
|
||||
"some-value",
|
||||
source.SquashedScope,
|
||||
[]byte("{some json}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rawConfig := determineProviderConfig(tc.userInput, tc.scopeOpt, nil, tc.stdin)
|
||||
|
||||
actual := mapToProviderTestConfig(t, rawConfig)
|
||||
assert.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mapToProviderTestConfig(t *testing.T, rawConfig providerConfig) providerTestConfig {
|
||||
t.Helper()
|
||||
|
||||
var readerBytes []byte
|
||||
|
||||
if rawConfig.reader != nil {
|
||||
readerBytes, _ = ioutil.ReadAll(rawConfig.reader)
|
||||
}
|
||||
|
||||
return providerTestConfig{
|
||||
rawConfig.userInput,
|
||||
rawConfig.scopeOpt,
|
||||
readerBytes,
|
||||
}
|
||||
}
|
|
@ -1,307 +0,0 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/grype/internal/log"
|
||||
|
||||
"github.com/anchore/grype/grype/cpe"
|
||||
"github.com/anchore/syft/syft/distro"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
)
|
||||
|
||||
type syftSource struct {
|
||||
Type string `json:"type"`
|
||||
Target interface{} `json:"target"`
|
||||
}
|
||||
|
||||
// syftSourceUnpacker is used to unmarshal Source objects
|
||||
type syftSourceUnpacker struct {
|
||||
Type string `json:"type"`
|
||||
Target json.RawMessage `json:"target"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON populates a source object from JSON bytes.
|
||||
func (s *syftSource) UnmarshalJSON(b []byte) error {
|
||||
var unpacker syftSourceUnpacker
|
||||
if err := json.Unmarshal(b, &unpacker); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Type = unpacker.Type
|
||||
|
||||
switch s.Type {
|
||||
case "directory":
|
||||
s.Target = string(unpacker.Target[:])
|
||||
case "image":
|
||||
var payload source.ImageMetadata
|
||||
if err := json.Unmarshal(unpacker.Target, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Target = payload
|
||||
default:
|
||||
return fmt.Errorf("unsupported package metadata type: %+v", s.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToSourceMetadata takes a syftSource object represented from JSON and creates a source.Metadata object.
|
||||
func (s *syftSource) toSourceMetadata() source.Metadata {
|
||||
var m source.Metadata
|
||||
switch s.Type {
|
||||
case "directory":
|
||||
m.Scheme = source.DirectoryScheme
|
||||
m.Path = s.Target.(string)
|
||||
case "image":
|
||||
m.Scheme = source.ImageScheme
|
||||
m.ImageMetadata = s.Target.(source.ImageMetadata)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
type syftDistribution struct {
|
||||
Name string `json:"name"` // Name of the Linux syftDistribution
|
||||
Version string `json:"version"` // Version of the Linux syftDistribution (major or major.minor version)
|
||||
IDLike string `json:"idLike"` // the ID_LIKE field found within the /etc/os-release file
|
||||
}
|
||||
|
||||
// partialSyftDoc is the final package shape for a select elements from a syft JSON document.
|
||||
type partialSyftDoc struct {
|
||||
Source syftSource `json:"source"`
|
||||
Artifacts []partialSyftPackage `json:"artifacts"`
|
||||
Distro syftDistribution `json:"distro"`
|
||||
}
|
||||
|
||||
// partialSyftPackage is the final package shape for a select elements from a syft JSON package.
|
||||
type partialSyftPackage struct {
|
||||
packageBasicMetadata
|
||||
packageCustomMetadata
|
||||
}
|
||||
|
||||
// packageBasicMetadata contains non-ambiguous values (type-wise) from pkg.Package.
|
||||
type packageBasicMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type pkg.Type `json:"type"`
|
||||
Locations []source.Location `json:"locations"`
|
||||
Licenses []string `json:"licenses"`
|
||||
Language pkg.Language `json:"language"`
|
||||
CPEs []string `json:"cpes"`
|
||||
PURL string `json:"purl"`
|
||||
}
|
||||
|
||||
// packageCustomMetadata contains ambiguous values (type-wise) from pkg.Package.
|
||||
type packageCustomMetadata struct {
|
||||
MetadataType pkg.MetadataType `json:"metadataType"`
|
||||
Metadata interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// packageMetadataUnpacker is all values needed from Package to disambiguate ambiguous fields during json unmarshaling.
|
||||
type packageMetadataUnpacker struct {
|
||||
MetadataType pkg.MetadataType `json:"metadataType"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
func (p *packageMetadataUnpacker) String() string {
|
||||
return fmt.Sprintf("metadataType: %s, metadata: %s", p.MetadataType, string(p.Metadata))
|
||||
}
|
||||
|
||||
// partialSyftJavaMetadata encapsulates all Java ecosystem metadata for a package as well as an (optional) parent relationship.
|
||||
type partialSyftJavaMetadata struct {
|
||||
VirtualPath string `mapstructure:"VirtualPath" json:"virtualPath"`
|
||||
Manifest *partialSyftJavaManifest `mapstructure:"Manifest" json:"manifest,omitempty"`
|
||||
PomProperties *partialSyftPomProperties `mapstructure:"PomProperties" json:"pomProperties,omitempty"`
|
||||
}
|
||||
|
||||
// partialSyftPomProperties represents the fields of interest extracted from a Java archive's pom.xml file.
|
||||
type partialSyftPomProperties struct {
|
||||
GroupID string `mapstructure:"groupId" json:"groupId"`
|
||||
ArtifactID string `mapstructure:"artifactId" json:"artifactId"`
|
||||
}
|
||||
|
||||
// partialSyftJavaManifest represents the fields of interest extracted from a Java archive's META-INF/MANIFEST.MF file.
|
||||
type partialSyftJavaManifest struct {
|
||||
Main map[string]string `json:"main,omitempty"`
|
||||
}
|
||||
|
||||
// String returns the stringer representation for a syft package.
|
||||
func (p partialSyftPackage) String() string {
|
||||
return fmt.Sprintf("Pkg(type=%s, name=%s, version=%s)", p.Type, p.Name, p.Version)
|
||||
}
|
||||
|
||||
// UnmarshalJSON is a custom unmarshaller for handling basic values and values with ambiguous types.
|
||||
func (p *partialSyftPackage) UnmarshalJSON(b []byte) error {
|
||||
var basic packageBasicMetadata
|
||||
if err := json.Unmarshal(b, &basic); err != nil {
|
||||
return err
|
||||
}
|
||||
p.packageBasicMetadata = basic
|
||||
|
||||
var unpacker packageMetadataUnpacker
|
||||
if err := json.Unmarshal(b, &unpacker); err != nil {
|
||||
log.Warnf("failed to unmarshall into packageMetadataUnpacker: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
p.MetadataType = unpacker.MetadataType
|
||||
|
||||
switch p.MetadataType {
|
||||
case pkg.ApkMetadataType:
|
||||
var payload ApkMetadata
|
||||
if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Metadata = payload
|
||||
case pkg.RpmdbMetadataType:
|
||||
var payload RpmdbMetadata
|
||||
if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Metadata = payload
|
||||
case pkg.DpkgMetadataType:
|
||||
var payload DpkgMetadata
|
||||
if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Metadata = payload
|
||||
case pkg.JavaMetadataType:
|
||||
var partialPayload partialSyftJavaMetadata
|
||||
if err := json.Unmarshal(unpacker.Metadata, &partialPayload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var artifact, group, name string
|
||||
if partialPayload.PomProperties != nil {
|
||||
artifact = partialPayload.PomProperties.ArtifactID
|
||||
group = partialPayload.PomProperties.GroupID
|
||||
}
|
||||
|
||||
if partialPayload.Manifest != nil {
|
||||
if n, ok := partialPayload.Manifest.Main["Name"]; ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
p.Metadata = JavaMetadata{
|
||||
VirtualPath: partialPayload.VirtualPath,
|
||||
PomArtifactID: artifact,
|
||||
PomGroupID: group,
|
||||
ManifestName: name,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSyftJSON attempts to loosely parse the available JSON for only the fields needed, not the exact syft JSON shape.
|
||||
// This allows for some resiliency as the syft document shape changes over time (but not fool-proof).
|
||||
func parseSyftJSON(reader io.Reader) ([]Package, Context, error) {
|
||||
var doc partialSyftDoc
|
||||
decoder := json.NewDecoder(reader)
|
||||
if err := decoder.Decode(&doc); err != nil {
|
||||
return nil, Context{}, errDoesNotProvide
|
||||
}
|
||||
|
||||
var packages = make([]Package, len(doc.Artifacts))
|
||||
for i, a := range doc.Artifacts {
|
||||
cpes, err := cpe.NewSlice(a.CPEs...)
|
||||
if err != nil {
|
||||
return nil, Context{}, err
|
||||
}
|
||||
|
||||
packages[i] = Package{
|
||||
ID: ID(a.ID),
|
||||
Name: a.Name,
|
||||
Version: a.Version,
|
||||
Locations: a.Locations,
|
||||
Language: a.Language,
|
||||
Licenses: a.Licenses,
|
||||
Type: a.Type,
|
||||
CPEs: cpes,
|
||||
PURL: a.PURL,
|
||||
Metadata: a.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
var theDistro *distro.Distro
|
||||
if doc.Distro.Name != "" {
|
||||
d, err := distro.NewDistro(distro.Type(doc.Distro.Name), doc.Distro.Version, doc.Distro.IDLike)
|
||||
if err != nil {
|
||||
return nil, Context{}, err
|
||||
}
|
||||
theDistro = &d
|
||||
}
|
||||
|
||||
srcMetadata := doc.Source.toSourceMetadata()
|
||||
|
||||
return packages, Context{
|
||||
Source: &srcMetadata,
|
||||
Distro: theDistro,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// syftJSONProvider extracts the necessary package and package context from syft JSON output. Note that this process carves out
|
||||
// only the necessary data needed and does not require unmarshalling the entire syft JSON data shape so this function is somewhat
|
||||
// resilient to multiple syft JSON schemas (to a degree).
|
||||
// TODO: add version detection and multi-parser support (when needed in the future)
|
||||
func syftJSONProvider(config providerConfig) ([]Package, Context, error) {
|
||||
reader, err := getSyftJSON(config)
|
||||
if err != nil {
|
||||
return nil, Context{}, err
|
||||
}
|
||||
|
||||
return parseSyftJSON(reader)
|
||||
}
|
||||
|
||||
func getSyftJSON(config providerConfig) (io.Reader, error) {
|
||||
if config.reader != nil {
|
||||
// the caller has explicitly indicated to use the given reader as input
|
||||
return config.reader, nil
|
||||
}
|
||||
|
||||
if explicitlySpecifyingSBOM(config.userInput) {
|
||||
filepath := strings.TrimPrefix(config.userInput, "sbom:")
|
||||
|
||||
sbom, err := openSbom(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to use specified SBOM: %w", err)
|
||||
}
|
||||
|
||||
return sbom, nil
|
||||
}
|
||||
|
||||
// as a last resort, see if the raw user input specified an SBOM file
|
||||
sbom, err := openSbom(config.userInput)
|
||||
if err == nil {
|
||||
return sbom, nil
|
||||
}
|
||||
|
||||
// no usable SBOM is available
|
||||
return nil, errDoesNotProvide
|
||||
}
|
||||
|
||||
func openSbom(path string) (*os.File, error) {
|
||||
expandedPath, err := homedir.Expand(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open SBOM: %w", err)
|
||||
}
|
||||
|
||||
sbom, err := os.Open(expandedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open SBOM: %w", err)
|
||||
}
|
||||
|
||||
return sbom, nil
|
||||
}
|
||||
|
||||
func explicitlySpecifyingSBOM(userInput string) bool {
|
||||
return strings.HasPrefix(userInput, "sbom:")
|
||||
}
|
|
@ -1,22 +1,23 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func syftProvider(config providerConfig) ([]Package, Context, error) {
|
||||
if config.scopeOpt == "" {
|
||||
func syftProvider(userInput string, scopeOpt source.Scope, registryOptions *image.RegistryOptions) ([]Package, Context, error) {
|
||||
if scopeOpt == "" {
|
||||
return nil, Context{}, errDoesNotProvide
|
||||
}
|
||||
|
||||
src, cleanup, err := source.New(config.userInput, config.registryOptions)
|
||||
src, cleanup, err := source.New(userInput, registryOptions)
|
||||
if err != nil {
|
||||
return nil, Context{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
catalog, theDistro, err := syft.CatalogPackages(src, config.scopeOpt)
|
||||
catalog, theDistro, err := syft.CatalogPackages(src, scopeOpt)
|
||||
if err != nil {
|
||||
return nil, Context{}, err
|
||||
}
|
||||
|
|
129
grype/pkg/syft_sbom_provider.go
Normal file
129
grype/pkg/syft_sbom_provider.go
Normal file
|
@ -0,0 +1,129 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
|
||||
"github.com/anchore/grype/internal"
|
||||
"github.com/anchore/grype/internal/log"
|
||||
|
||||
"github.com/anchore/syft/syft/format"
|
||||
|
||||
"github.com/anchore/syft/syft"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
)
|
||||
|
||||
func syftSBOMProvider(userInput string) ([]Package, Context, error) {
|
||||
reader, err := getSBOMReader(userInput)
|
||||
if err != nil {
|
||||
return nil, Context{}, err
|
||||
}
|
||||
|
||||
catalog, srcMetadata, theDistro, _, formatOption, err := syft.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, Context{}, fmt.Errorf("unable to decode sbom: %w", err)
|
||||
}
|
||||
if formatOption == format.UnknownFormatOption {
|
||||
return nil, Context{}, errDoesNotProvide
|
||||
}
|
||||
|
||||
return FromCatalog(catalog), Context{
|
||||
Source: srcMetadata,
|
||||
Distro: theDistro,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getSBOMReader(userInput string) (io.Reader, error) {
|
||||
if userInput == "" {
|
||||
// we only want to attempt reading in from stdin if the user has not specified other
|
||||
// options from the CLI, otherwise we should not assume there is any valid input from stdin.
|
||||
return stdinReader(), nil
|
||||
}
|
||||
|
||||
if explicitlySpecifyingSBOM(userInput) {
|
||||
filepath := strings.TrimPrefix(userInput, "sbom:")
|
||||
|
||||
sbom, err := openSbom(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to use specified SBOM: %w", err)
|
||||
}
|
||||
|
||||
return sbom, nil
|
||||
}
|
||||
|
||||
// as a last resort, see if the raw user input specified an SBOM file
|
||||
if isPossibleSBOM(userInput) {
|
||||
sbom, err := openSbom(userInput)
|
||||
if err == nil {
|
||||
return sbom, nil
|
||||
}
|
||||
}
|
||||
|
||||
// no usable SBOM is available
|
||||
return nil, errDoesNotProvide
|
||||
}
|
||||
|
||||
func stdinReader() io.Reader {
|
||||
isPipedInput, err := internal.IsPipedInput()
|
||||
if err != nil {
|
||||
log.Warnf("unable to determine if there is piped input: %+v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isPipedInput {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Stdin
|
||||
}
|
||||
|
||||
func openSbom(path string) (*os.File, error) {
|
||||
expandedPath, err := homedir.Expand(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open SBOM: %w", err)
|
||||
}
|
||||
|
||||
sbom, err := os.Open(expandedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open SBOM: %w", err)
|
||||
}
|
||||
|
||||
return sbom, nil
|
||||
}
|
||||
|
||||
func isPossibleSBOM(userInput string) bool {
|
||||
f, err := os.Open(userInput)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mType, err := mimetype.DetectReader(f)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
log.Warnf("unable to seek to the start of the possible SBOM file=%q: %w", userInput, err)
|
||||
}
|
||||
|
||||
// we expect application/json, application/xml, and text/plain input documents. All of these are either
|
||||
// text/plain or a descendant of text/plain. Anything else cannot be an input SBOM document.
|
||||
return isAncestorOfMimetype(mType, "text/plain")
|
||||
}
|
||||
|
||||
func isAncestorOfMimetype(mType *mimetype.MIME, expected string) bool {
|
||||
for cur := mType; cur != nil; cur = cur.Parent() {
|
||||
if cur.Is(expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func explicitlySpecifyingSBOM(userInput string) bool {
|
||||
return strings.HasPrefix(userInput, "sbom:")
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
package pkg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
@ -134,12 +134,7 @@ func TestParseSyftJSON(t *testing.T) {
|
|||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Fixture, func(t *testing.T) {
|
||||
fh, err := os.Open(test.Fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to open fixture: %+v", err)
|
||||
}
|
||||
|
||||
pkgs, context, err := parseSyftJSON(fh)
|
||||
pkgs, context, err := syftSBOMProvider(test.Fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to parse: %+v", err)
|
||||
}
|
||||
|
@ -148,6 +143,12 @@ func TestParseSyftJSON(t *testing.T) {
|
|||
context.Source.ImageMetadata.RawManifest = nil
|
||||
|
||||
for _, d := range deep.Equal(test.Packages, pkgs) {
|
||||
if strings.Contains(d, ".ID: ") {
|
||||
// today ID's get assigned by the catalog, which will change in the future. But in the meantime
|
||||
// that means that these IDs are random and should not be counted as a difference we care about in
|
||||
// this test.
|
||||
continue
|
||||
}
|
||||
t.Errorf("pkg diff: %s", d)
|
||||
}
|
||||
|
||||
|
@ -159,13 +160,7 @@ func TestParseSyftJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseSyftJSON_BadCPEs(t *testing.T) {
|
||||
const testFixture = "test-fixtures/syft-java-bad-cpes.json"
|
||||
fh, err := os.Open(testFixture)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to open fixture: %+v", err)
|
||||
}
|
||||
|
||||
pkgs, _, err := parseSyftJSON(fh)
|
||||
pkgs, _, err := syftSBOMProvider("test-fixtures/syft-java-bad-cpes.json")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, pkgs, 1)
|
||||
}
|
|
@ -12,5 +12,9 @@ func IsPipedInput() (bool, error) {
|
|||
return false, fmt.Errorf("unable to determine if there is piped input: %w", err)
|
||||
}
|
||||
|
||||
return fi.Mode()&os.ModeCharDevice == 0, nil
|
||||
// note: we should NOT use the absence of a character device here as the hint that there may be input expected
|
||||
// on stdin, as running grype as a subprocess you would expect no character device to be present but input can
|
||||
// be from either stdin or indicated by the CLI. Checking if stdin is a pipe is the most direct way to determine
|
||||
// if there *may* be bytes that will show up on stdin that should be used for the analysis source.
|
||||
return fi.Mode()&os.ModeNamedPipe != 0, nil
|
||||
}
|
||||
|
|
61
test/cli/subprocess_test.go
Normal file
61
test/cli/subprocess_test.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/imagetest"
|
||||
)
|
||||
|
||||
func TestSubprocessStdin(t *testing.T) {
|
||||
binDir := path.Dir(getGrypeSnapshotLocation(t, "linux"))
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
env map[string]string
|
||||
assertions []traitAssertion
|
||||
}{
|
||||
{
|
||||
// regression
|
||||
name: "ensure can be used by node subprocess (without hanging)",
|
||||
args: []string{"-v", fmt.Sprintf("%s:%s:ro", binDir, "/app/bin"), imagetest.LoadFixtureImageIntoDocker(t, "image-node-subprocess"), "node", "/app.js"},
|
||||
env: map[string]string{
|
||||
"GRYPE_CHECK_FOR_APP_UPDATE": "false",
|
||||
},
|
||||
assertions: []traitAssertion{
|
||||
assertSucceedingReturnCode,
|
||||
},
|
||||
},
|
||||
{
|
||||
// regression: https://github.com/anchore/grype/issues/267
|
||||
name: "ensure can be used by java subprocess (without hanging)",
|
||||
args: []string{"-v", fmt.Sprintf("%s:%s:ro", binDir, "/app/bin"), imagetest.LoadFixtureImageIntoDocker(t, "image-java-subprocess"), "java", "/app.java"},
|
||||
env: map[string]string{
|
||||
"GRYPE_CHECK_FOR_APP_UPDATE": "false",
|
||||
},
|
||||
assertions: []traitAssertion{
|
||||
assertSucceedingReturnCode,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
testFn := func(t *testing.T) {
|
||||
cmd := getDockerRunCommand(t, test.args...)
|
||||
stdout, stderr := runCommand(cmd, test.env)
|
||||
for _, traitAssertionFn := range test.assertions {
|
||||
traitAssertionFn(t, stdout, stderr, cmd.ProcessState.ExitCode())
|
||||
}
|
||||
if t.Failed() {
|
||||
t.Log("STDOUT:\n", stdout)
|
||||
t.Log("STDERR:\n", stderr)
|
||||
t.Log("COMMAND:", strings.Join(cmd.Args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
testWithTimeout(t, test.name, 60*time.Second, testFn)
|
||||
}
|
||||
}
|
4
test/cli/test-fixtures/image-java-subprocess/Dockerfile
Normal file
4
test/cli/test-fixtures/image-java-subprocess/Dockerfile
Normal file
|
@ -0,0 +1,4 @@
|
|||
FROM openjdk:15-slim-buster
|
||||
COPY app.java /
|
||||
ENV PATH="/app/bin:${PATH}"
|
||||
WORKDIR /
|
18
test/cli/test-fixtures/image-java-subprocess/app.java
Normal file
18
test/cli/test-fixtures/image-java-subprocess/app.java
Normal file
|
@ -0,0 +1,18 @@
|
|||
import java.io.IOException;
|
||||
|
||||
public class GrypeExecutionTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
ProcessBuilder builder = new ProcessBuilder("grype", "registry:busybox:latest", "-vv");
|
||||
|
||||
builder.inheritIO();
|
||||
Process process = builder.start();
|
||||
|
||||
process.waitFor();
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
4
test/cli/test-fixtures/image-node-subprocess/Dockerfile
Normal file
4
test/cli/test-fixtures/image-node-subprocess/Dockerfile
Normal file
|
@ -0,0 +1,4 @@
|
|||
FROM node:16-stretch
|
||||
COPY app.js /
|
||||
ENV PATH="/app/bin:${PATH}"
|
||||
WORKDIR /
|
10
test/cli/test-fixtures/image-node-subprocess/app.js
Normal file
10
test/cli/test-fixtures/image-node-subprocess/app.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
require("child_process").spawn("grype", [
|
||||
"-vv",
|
||||
"registry:busybox:latest",
|
||||
], {
|
||||
// we want to see any output from stdout/stderr which is why they are inherited from the parent process.
|
||||
// The real test is to make certain that piped input will not hang forever when nothing is provided on stdin
|
||||
// and there is input from the user to not use stdin. That is --make certain that we don't use "stdin is a pipe"
|
||||
// as the only indicator to expect analysis input from stdin.
|
||||
stdio: ["pipe", "inherit", "inherit"]
|
||||
});
|
|
@ -25,3 +25,10 @@ func assertFailingReturnCode(tb testing.TB, _, _ string, rc int) {
|
|||
tb.Errorf("expected a failure but got rc=%d", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSucceedingReturnCode(tb testing.TB, _, _ string, rc int) {
|
||||
tb.Helper()
|
||||
if rc != 0 {
|
||||
tb.Errorf("expected a failure but got rc=%d", rc)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/imagetest"
|
||||
)
|
||||
|
@ -25,24 +26,8 @@ func getFixtureImage(tb testing.TB, fixtureImageName string) string {
|
|||
func getGrypeCommand(tb testing.TB, args ...string) *exec.Cmd {
|
||||
tb.Helper()
|
||||
|
||||
var binaryLocation string
|
||||
if os.Getenv("GRYPE_BINARY_LOCATION") != "" {
|
||||
// GRYPE_BINARY_LOCATION is the absolute path to the snapshot binary
|
||||
binaryLocation = os.Getenv("GRYPE_BINARY_LOCATION")
|
||||
} else {
|
||||
// note: there is a subtle - vs _ difference between these versions
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
binaryLocation = path.Join(repoRoot(tb), fmt.Sprintf("snapshot/grype-macos_darwin_%s/grype", runtime.GOARCH))
|
||||
case "linux":
|
||||
binaryLocation = path.Join(repoRoot(tb), fmt.Sprintf("snapshot/grype_linux_%s/grype", runtime.GOARCH))
|
||||
default:
|
||||
tb.Fatalf("unsupported OS: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
}
|
||||
return exec.Command(
|
||||
binaryLocation,
|
||||
getGrypeSnapshotLocation(tb, runtime.GOOS),
|
||||
append(
|
||||
[]string{"-c", "../grype-test-config.yaml"},
|
||||
args...,
|
||||
|
@ -50,6 +35,36 @@ func getGrypeCommand(tb testing.TB, args ...string) *exec.Cmd {
|
|||
)
|
||||
}
|
||||
|
||||
func getGrypeSnapshotLocation(tb testing.TB, goOS string) string {
|
||||
if os.Getenv("GRYPE_BINARY_LOCATION") != "" {
|
||||
// GRYPE_BINARY_LOCATION is the absolute path to the snapshot binary
|
||||
return os.Getenv("GRYPE_BINARY_LOCATION")
|
||||
}
|
||||
|
||||
// note: there is a subtle - vs _ difference between these versions
|
||||
switch goOS {
|
||||
case "darwin":
|
||||
return path.Join(repoRoot(tb), fmt.Sprintf("snapshot/grype-macos_darwin_%s/grype", runtime.GOARCH))
|
||||
case "linux":
|
||||
return path.Join(repoRoot(tb), fmt.Sprintf("snapshot/grype_linux_%s/grype", runtime.GOARCH))
|
||||
default:
|
||||
tb.Fatalf("unsupported OS: %s", runtime.GOOS)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getDockerRunCommand(tb testing.TB, args ...string) *exec.Cmd {
|
||||
tb.Helper()
|
||||
|
||||
return exec.Command(
|
||||
"docker",
|
||||
append(
|
||||
[]string{"run"},
|
||||
args...,
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func runGrype(tb testing.TB, env map[string]string, args ...string) (*exec.Cmd, string, string) {
|
||||
tb.Helper()
|
||||
|
||||
|
@ -134,3 +149,17 @@ func assertCommandExecutionSuccess(t testing.TB, cmd *exec.Cmd) {
|
|||
t.Fatalf("unable to run command %q: %v", cmd, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testWithTimeout(t *testing.T, name string, timeout time.Duration, test func(*testing.T)) {
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
t.Run(name, test)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("test timed out")
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue