From 7e22933dcc174365f8affcd5742dde235e451bd3 Mon Sep 17 00:00:00 2001 From: Marco Deicas Date: Fri, 29 Mar 2024 14:43:01 +0000 Subject: [PATCH 1/5] client testing support Signed-off-by: Marco Deicas --- internal/testing/graphqlClients/guacdata.go | 441 +++++++++++++++++++ internal/testing/graphqlClients/testsetup.go | 101 +++++ 2 files changed, 542 insertions(+) create mode 100644 internal/testing/graphqlClients/guacdata.go create mode 100644 internal/testing/graphqlClients/testsetup.go diff --git a/internal/testing/graphqlClients/guacdata.go b/internal/testing/graphqlClients/guacdata.go new file mode 100644 index 0000000000..88f9a990d3 --- /dev/null +++ b/internal/testing/graphqlClients/guacdata.go @@ -0,0 +1,441 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package clients + +import ( + "context" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" + _ "github.com/guacsec/guac/pkg/assembler/backends/keyvalue" + gql "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/assembler/helpers" +) + +const ( + // nouns + defaultHashAlgorithm = "sha256" + defaultSourceType = "test-type" + defaultSourceNamespace = "test-namespace" + + // IsOccurrence + defaultIsOccurrenceJustification = "test-justification" + defaultIsOccurrenceOrigin = "test-origin" + defaultIsOccurrenceCollector = "test-collector" + + // HasSbom + defaultHasSbomOrigin = "test-origin" + defaultHasSbomCollector = "test-collector" + defaultHasSbomUri = "test-uri" + defaultHasSbomDownloadLocation = "test-download-loc" + defaultHasSbomDigest = "test-digest" + + // IsDependency + defaultIsDependencyDependencyType = gql.DependencyTypeUnknown + defaultIsDependencyJustification = "test-justification" + defaultIsDependencyOrigin = "test-origin" + defaultIsDependencyCollector = "test-collector" + + // HashEquals + defaultHashEqualJustification = "test-justification" + defaultHashEqualOrigin = "test-origin" + defaultHashEqualCollector = "test-collector" + + // HasSlsa + defaultHasSlsaBuildType = "test-builder=type" + defaultHasSlsaVersion = "test-slsa-version" + defaultHasSlsaOrigin = "test-origin" + defaultHasSlsaCollector = "test-collector" + defaultHasSlsaPredicateKey = "test-predicate-key" + defaultHasSlsaPredicateValue = "test-predicate-value" +) + +// Defines the Guac graph, to test clients of the Graphql server. +// +// This type, along with the Ingest function, is similar to the backend IngestPredicates +// type and the corresponding assembler function, but allows for significantly less verbose +// tests by specifying each noun with a single string and making the various InputSpec +// structs optional. Additionally, t.Fatalf is called upon any errors. However, this also means +// that a few inputs aren't supported, such as finer-grained definition of nouns. +// +// All verbs are currently attached to packge version nodes, but a configuration for this +// could be added if needed. +type GuacData struct { + /** the nouns need to be specified here in order to be referenced from a verb **/ + Packages []string // packages are specified by purl + Artifacts []string // artifacts are specified by digest + Sources []string // sources are specified by the name in the SourceName node + Builders []string // builders are specified by URI + + /** verbs **/ + HasSboms []HasSbom + IsOccurrences []IsOccurrence + IsDependencies []IsDependency + HashEquals []HashEqual + HasSlsas []HasSlsa + + // Other graphql verbs still need to be added here +} + +type IsDependency struct { + DependentPkg string // a previously ingested purl + DependencyPkg string // a previously ingested purl + Spec *gql.IsDependencyInputSpec // if nil, a default will be used +} + +type IsOccurrence struct { + Subject string // a previously ingested purl or source + Artifact string // a previously ingested digest + Spec *gql.IsOccurrenceInputSpec // if nil, a default will be used +} + +type HasSbom struct { + Subject string // a previously ingested purl or digest + IncludedSoftware []string // a list of previously ingested purls and digests + IncludedIsDependencies []IsDependency + IncludedIsOccurrences []IsOccurrence + Spec *gql.HasSBOMInputSpec // if nil, a default will be used +} + +type HashEqual struct { + ArtifactA string // a previously ingested digest + ArtifactB string // a previously ingested digest + Spec *gql.HashEqualInputSpec // if nil, a default will be used +} + +type HasSlsa struct { + Subject string // a previously ingested digest + BuiltFrom []string // a list of previously ingested digests + BuiltBy string // a previously ingested builder + Spec *gql.SLSAInputSpec // if nil, a default will be used +} + +// maintains the ids of nouns, to use when ingesting verbs +type nounIds struct { + PackageIds map[string]string // map from purls to IDs of PackageName nodes + ArtifactIds map[string]string // map from digest to IDs of Artifact nodes + SourceIds map[string]string // map from source names to IDs of SourceName nodes + BuilderIds map[string]string // map from URI to IDs of Builder nodes + +} + +func Ingest(ctx context.Context, t *testing.T, gqlClient graphql.Client, data GuacData) nounIds { + packageIds := map[string]string{} + for _, pkg := range data.Packages { + packageIds[pkg] = ingestPackage(ctx, t, gqlClient, pkg) + } + + artifactIds := map[string]string{} + for _, artifact := range data.Artifacts { + artifactIds[artifact] = ingestArtifact(ctx, t, gqlClient, artifact) + } + + sourceIds := map[string]string{} + for _, source := range data.Sources { + sourceIds[source] = ingestSource(ctx, t, gqlClient, source) + } + + builderIds := map[string]string{} + for _, builder := range data.Builders { + builderIds[builder] = ingestBuilder(ctx, t, gqlClient, builder) + } + + i := nounIds{ + PackageIds: packageIds, + ArtifactIds: artifactIds, + SourceIds: sourceIds, + BuilderIds: builderIds, + } + + for _, sbom := range data.HasSboms { + i.ingestHasSbom(ctx, t, gqlClient, sbom) + } + + for _, isDependency := range data.IsDependencies { + i.ingestIsDependency(ctx, t, gqlClient, isDependency) + } + + for _, isOccurrence := range data.IsOccurrences { + i.ingestIsOccurrence(ctx, t, gqlClient, isOccurrence) + } + + for _, hashEqual := range data.HashEquals { + i.ingestHashEqual(ctx, t, gqlClient, hashEqual) + } + + for _, hasSlsa := range data.HasSlsas { + i.ingestHasSlsa(ctx, t, gqlClient, hasSlsa) + } + + return i +} + +func (i nounIds) ingestHasSlsa(ctx context.Context, t *testing.T, gqlClient graphql.Client, hasSlsa HasSlsa) { + slsaSpec := hasSlsa.Spec + if slsaSpec == nil { + slsaSpec = &gql.SLSAInputSpec{ + BuildType: defaultHasSlsaBuildType, + SlsaPredicate: []gql.SLSAPredicateInputSpec{ + {Key: defaultHasSlsaPredicateKey, Value: defaultHasSlsaPredicateValue}, + }, + SlsaVersion: defaultHasSlsaVersion, + Origin: defaultHasSlsaOrigin, + Collector: defaultHasSlsaCollector, + } + } + + subjectId, ok := i.ArtifactIds[hasSlsa.Subject] + if !ok { + t.Fatalf("The digest %s has not been ingested", hasSlsa.Subject) + } + subjectSpec := gql.IDorArtifactInput{ArtifactID: &subjectId} + + builtFromSpecs := []gql.IDorArtifactInput{} + for _, buildMaterial := range hasSlsa.BuiltFrom { + buildMaterialId, ok := i.ArtifactIds[buildMaterial] + if !ok { + t.Fatalf("The digest %s has not been ingested", buildMaterial) + } + builtFromSpec := gql.IDorArtifactInput{ArtifactID: &buildMaterialId} + builtFromSpecs = append(builtFromSpecs, builtFromSpec) + } + + builderId, ok := i.BuilderIds[hasSlsa.BuiltBy] + if !ok { + t.Fatalf("The builder %s has not been ingested", hasSlsa.BuiltBy) + } + builtBySpec := gql.IDorBuilderInput{BuilderID: &builderId} + + _, err := gql.IngestSLSAForArtifact(ctx, gqlClient, subjectSpec, builtFromSpecs, builtBySpec, *slsaSpec) + if err != nil { + t.Fatalf("Error ingesting HasSlsa when setting up test: %s", err) + } +} + +func (i nounIds) ingestHashEqual(ctx context.Context, t *testing.T, gqlClient graphql.Client, hashEqual HashEqual) { + spec := hashEqual.Spec + if spec == nil { + spec = &gql.HashEqualInputSpec{ + Justification: defaultHashEqualJustification, + Origin: defaultHashEqualOrigin, + Collector: defaultHashEqualCollector, + } + } + + artifactAId, ok := i.ArtifactIds[hashEqual.ArtifactA] + if !ok { + t.Fatalf("The digest %s has not been ingested", hashEqual.ArtifactA) + } + artifactBId, ok := i.ArtifactIds[hashEqual.ArtifactB] + if !ok { + t.Fatalf("The digest %s has not been ingested", hashEqual.ArtifactB) + } + + artifactASpec := gql.IDorArtifactInput{ArtifactID: &artifactAId} + artifactBSpec := gql.IDorArtifactInput{ArtifactID: &artifactBId} + _, err := gql.IngestHashEqual(ctx, gqlClient, artifactASpec, artifactBSpec, *spec) + if err != nil { + t.Fatalf("Error ingesting HashEqual when setting up test: %s", err) + } +} + +// Returns the id of the IsDependency node +func (i nounIds) ingestIsDependency(ctx context.Context, t *testing.T, gqlClient graphql.Client, isDependency IsDependency) string { + spec := isDependency.Spec + if spec == nil { + spec = &gql.IsDependencyInputSpec{ + DependencyType: defaultIsDependencyDependencyType, + Justification: defaultIsDependencyJustification, + Origin: defaultIsDependencyOrigin, + Collector: defaultIsDependencyCollector, + } + } + + dependentId, ok := i.PackageIds[isDependency.DependentPkg] + if !ok { + t.Fatalf("The purl %s has not been ingested", isDependency.DependentPkg) + } + dependencyId := i.PackageIds[isDependency.DependencyPkg] + if !ok { + t.Fatalf("The purl %s has not been ingested", isDependency.DependencyPkg) + } + + // The IsDependency is attached to the package version node + flags := gql.MatchFlags{Pkg: gql.PkgMatchTypeSpecificVersion} + dependentSpec := gql.IDorPkgInput{PackageVersionID: &dependentId} + dependencySpec := gql.IDorPkgInput{PackageVersionID: &dependencyId} + + res, err := gql.IngestIsDependency(ctx, gqlClient, dependentSpec, dependencySpec, flags, *spec) + if err != nil { + t.Fatalf("Error ingesting IsDependency when setting up test: %s", err) + } + return res.GetIngestDependency() +} + +// Returns the ID of the IsOccurrence node. +func (i nounIds) ingestIsOccurrence(ctx context.Context, t *testing.T, gqlClient graphql.Client, isOccurrence IsOccurrence) string { + spec := isOccurrence.Spec + if spec == nil { + spec = &gql.IsOccurrenceInputSpec{ + Justification: defaultIsOccurrenceJustification, + Origin: defaultIsOccurrenceOrigin, + Collector: defaultIsOccurrenceCollector, + } + } + + artifactId, ok := i.ArtifactIds[isOccurrence.Artifact] + if !ok { + t.Fatalf("The digest %s has not been ingested", isOccurrence.Artifact) + } + artifactSpec := gql.IDorArtifactInput{ArtifactID: &artifactId} + + // the subject can be either a package or a source + if v, ok := i.PackageIds[isOccurrence.Subject]; ok { + pkgSpec := gql.IDorPkgInput{PackageVersionID: &v} + res, err := gql.IngestIsOccurrencePkg(ctx, gqlClient, pkgSpec, artifactSpec, *spec) + if err != nil { + t.Fatalf("Error ingesting IsOccurrence: %s", err) + } + return res.GetIngestOccurrence() + + } else if v, ok := i.SourceIds[isOccurrence.Subject]; ok { + sourceSpec := gql.IDorSourceInput{SourceNameID: &v} + res, err := gql.IngestIsOccurrenceSrc(ctx, gqlClient, sourceSpec, artifactSpec, *spec) + if err != nil { + t.Fatalf("Error ingesting IsOccurrence: %s", err) + } + return res.GetIngestOccurrence() + } + + t.Fatalf("The purl or source %s has not been ingested", isOccurrence.Subject) + return "" +} + +func (i nounIds) ingestHasSbom(ctx context.Context, t *testing.T, gqlClient graphql.Client, hasSbom HasSbom) { + isDependencyIds := []string{} + for _, dependency := range hasSbom.IncludedIsDependencies { + id := i.ingestIsDependency(ctx, t, gqlClient, dependency) + isDependencyIds = append(isDependencyIds, id) + } + + isOccurrenceIds := []string{} + for _, occurrence := range hasSbom.IncludedIsOccurrences { + id := i.ingestIsOccurrence(ctx, t, gqlClient, occurrence) + isOccurrenceIds = append(isOccurrenceIds, id) + } + + includedPackageIds := []string{} + includedArtifactIds := []string{} + for _, software := range hasSbom.IncludedSoftware { + if id, ok := i.PackageIds[software]; ok { + includedPackageIds = append(includedPackageIds, id) + } else if id, ok := i.ArtifactIds[software]; ok { + includedArtifactIds = append(includedArtifactIds, id) + } else { + t.Fatalf("The purl or digest %s has not been ingested", software) + } + } + + sbomSpec := hasSbom.Spec + if hasSbom.Spec == nil { + sbomSpec = &gql.HasSBOMInputSpec{ + Uri: defaultHasSbomUri, + Algorithm: defaultHashAlgorithm, + Digest: defaultHasSbomDigest, + DownloadLocation: defaultHasSbomDownloadLocation, + Origin: defaultHasSbomOrigin, + Collector: defaultHasSbomCollector, + KnownSince: time.Now(), + } + } + includesSpec := gql.HasSBOMIncludesInputSpec{ + Packages: includedPackageIds, + Artifacts: includedArtifactIds, + Dependencies: isDependencyIds, + Occurrences: isOccurrenceIds, + } + + // the subject can be either a package or an artifact + if v, ok := i.PackageIds[hasSbom.Subject]; ok { + pkgSpec := gql.IDorPkgInput{PackageVersionID: &v} + _, err := gql.IngestHasSBOMPkg(ctx, gqlClient, pkgSpec, *sbomSpec, includesSpec) + if err != nil { + t.Fatalf("Error ingesting sbom when setting up test: %s", err) + } + } else if v, ok := i.ArtifactIds[hasSbom.Subject]; ok { + artifactSpec := gql.IDorArtifactInput{ArtifactID: &v} + _, err := gql.IngestHasSBOMArtifact(ctx, gqlClient, artifactSpec, *sbomSpec, includesSpec) + if err != nil { + t.Fatalf("Error ingesting sbom when setting up test: %s", err) + } + } else { + t.Fatalf("The purl or digest %s has not been ingested", hasSbom.Subject) + } +} + +// Returns the ID of the version node in the package trie +func ingestPackage(ctx context.Context, t *testing.T, gqlClient graphql.Client, purl string) string { + spec, err := helpers.PurlToPkg(purl) + if err != nil { + t.Fatalf("Could not create a package input spec from a purl: %s", err) + } + idOrInputSpec := gql.IDorPkgInput{PackageInput: spec} + res, err := gql.IngestPackage(ctx, gqlClient, idOrInputSpec) + if err != nil { + t.Fatalf("Error ingesting package when setting up test: %s", err) + } + return res.IngestPackage.PackageVersionID +} + +func ingestArtifact(ctx context.Context, t *testing.T, gqlClient graphql.Client, digest string) string { + spec := gql.ArtifactInputSpec{ + Algorithm: defaultHashAlgorithm, + Digest: digest, + } + idOrInputSpec := gql.IDorArtifactInput{ArtifactInput: &spec} + res, err := gql.IngestArtifact(ctx, gqlClient, idOrInputSpec) + if err != nil { + t.Fatalf("Error ingesting artifact when setting up test: %s", err) + } + return res.GetIngestArtifact() +} + +// Returns the ID of the SourceName node in the trie. +func ingestSource(ctx context.Context, t *testing.T, gqlClient graphql.Client, name string) string { + spec := gql.SourceInputSpec{ + Type: defaultSourceType, + Namespace: defaultSourceNamespace, + } + idorInputSpec := gql.IDorSourceInput{SourceInput: &spec} + res, err := gql.IngestSource(ctx, gqlClient, idorInputSpec) + if err != nil { + t.Fatalf("Error ingesting source when setting up test: %s", err) + } + return res.GetIngestSource().SourceNameID +} + +func ingestBuilder(ctx context.Context, t *testing.T, gqlClient graphql.Client, uri string) string { + spec := gql.BuilderInputSpec{ + Uri: defaultSourceType, + } + idorInputSpec := gql.IDorBuilderInput{BuilderInput: &spec} + res, err := gql.IngestBuilder(ctx, gqlClient, idorInputSpec) + if err != nil { + t.Fatalf("Error ingesting builder when setting up test: %s", err) + } + return res.GetIngestBuilder() +} diff --git a/internal/testing/graphqlClients/testsetup.go b/internal/testing/graphqlClients/testsetup.go new file mode 100644 index 0000000000..a22be0fc5d --- /dev/null +++ b/internal/testing/graphqlClients/testsetup.go @@ -0,0 +1,101 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// package clients helps set up the graphql backend for testing graphql clients +package clients + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + "testing" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/Khan/genqlient/graphql" + "github.com/guacsec/guac/pkg/assembler/backends" + _ "github.com/guacsec/guac/pkg/assembler/backends/keyvalue" + assembler "github.com/guacsec/guac/pkg/assembler/graphql/generated" + "github.com/guacsec/guac/pkg/assembler/graphql/resolvers" +) + +// SetupTest starts the graphql server and returns a client for it. The parameter +// t is used to register a function to close the server and to fail the test upon +// any errors. +func SetupTest(t *testing.T) graphql.Client { + gqlHandler := getGraphqlHandler(t) + port := startGraphqlServer(t, gqlHandler) + serverAddr := fmt.Sprintf("http://localhost:%s", port) + client := graphql.NewClient(serverAddr, nil) + return client +} + +// startGraphqlServer starts up up the graphql server, registers a function to close it when the test completes, +// and returns the port it is listening on. +func startGraphqlServer(t *testing.T, gqlHandler *handler.Server) string { + srv := http.Server{Handler: gqlHandler} + + // Create the listener explicitely in order to find the port it listens on + listener, err := net.Listen("tcp", "") + if err != nil { + t.Fatalf("Error initializing listener for graphql server: %v", err) + return "" + } + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatalf("Error getting post from server address: %v", err) + return "" + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + t.Logf("Starting graphql server on: %v", listener.Addr()) + wg.Done() + // this thread could still be preempted here, but I don't think we can do better? + err := srv.Serve(listener) + if err != http.ErrServerClosed { + t.Logf("Graphql server finished with error: %v", srv.Serve(listener)) + } + }() + wg.Wait() + + closeFunc := func() { + err := srv.Close() + if err != nil { + t.Logf("Error closing graphql server listener") + } else { + t.Logf("Graphql server shut down") + } + } + t.Cleanup(closeFunc) + + return port +} + +// Gets the handler for the graphql server with the inmem backend resolver. +func getGraphqlHandler(t *testing.T) *handler.Server { + ctx := context.Background() + backend, err := backends.Get("keyvalue", ctx, struct{}{}) + if err != nil { + t.Fatalf("Error getting the keyvalue backend") + } + resolver := resolvers.Resolver{Backend: backend} + + config := assembler.Config{Resolvers: &resolver} + config.Directives.Filter = resolvers.Filter + return handler.NewDefaultServer(assembler.NewExecutableSchema(config)) +} From 841ed21de44bb27ff1a5e4577fc54c1664f90633 Mon Sep 17 00:00:00 2001 From: Marco Deicas Date: Fri, 26 Apr 2024 16:09:48 +0000 Subject: [PATCH 2/5] Add HashEquals and IsOccurrences queries to gql client Signed-off-by: Marco Deicas --- pkg/assembler/clients/generated/operations.go | 393 ++++++++++++++++++ .../clients/operations/hashEqual.graphql | 6 + .../clients/operations/isOccurrence.graphql | 6 + 3 files changed, 405 insertions(+) diff --git a/pkg/assembler/clients/generated/operations.go b/pkg/assembler/clients/generated/operations.go index 38c98bc8bf..bd5ec02990 100644 --- a/pkg/assembler/clients/generated/operations.go +++ b/pkg/assembler/clients/generated/operations.go @@ -8177,6 +8177,128 @@ func (v *HashEqualInputSpec) GetCollector() string { return v.Collector } // GetDocumentRef returns HashEqualInputSpec.DocumentRef, and is useful for accessing the field via an interface. func (v *HashEqualInputSpec) GetDocumentRef() string { return v.DocumentRef } +// HashEqualSpec allows filtering the list of artifact equality statements to +// return in a query. +// +// Specifying just one artifact allows to query for all similar artifacts (if any +// exists). +type HashEqualSpec struct { + Id *string `json:"id"` + Artifacts []*ArtifactSpec `json:"artifacts"` + Justification *string `json:"justification"` + Origin *string `json:"origin"` + Collector *string `json:"collector"` + DocumentRef *string `json:"documentRef"` +} + +// GetId returns HashEqualSpec.Id, and is useful for accessing the field via an interface. +func (v *HashEqualSpec) GetId() *string { return v.Id } + +// GetArtifacts returns HashEqualSpec.Artifacts, and is useful for accessing the field via an interface. +func (v *HashEqualSpec) GetArtifacts() []*ArtifactSpec { return v.Artifacts } + +// GetJustification returns HashEqualSpec.Justification, and is useful for accessing the field via an interface. +func (v *HashEqualSpec) GetJustification() *string { return v.Justification } + +// GetOrigin returns HashEqualSpec.Origin, and is useful for accessing the field via an interface. +func (v *HashEqualSpec) GetOrigin() *string { return v.Origin } + +// GetCollector returns HashEqualSpec.Collector, and is useful for accessing the field via an interface. +func (v *HashEqualSpec) GetCollector() *string { return v.Collector } + +// GetDocumentRef returns HashEqualSpec.DocumentRef, and is useful for accessing the field via an interface. +func (v *HashEqualSpec) GetDocumentRef() *string { return v.DocumentRef } + +// HashEqualsHashEqual includes the requested fields of the GraphQL type HashEqual. +// The GraphQL type's documentation follows. +// +// HashEqual is an attestation that a set of artifacts are identical. +type HashEqualsHashEqual struct { + AllHashEqualTree `json:"-"` +} + +// GetId returns HashEqualsHashEqual.Id, and is useful for accessing the field via an interface. +func (v *HashEqualsHashEqual) GetId() string { return v.AllHashEqualTree.Id } + +// GetJustification returns HashEqualsHashEqual.Justification, and is useful for accessing the field via an interface. +func (v *HashEqualsHashEqual) GetJustification() string { return v.AllHashEqualTree.Justification } + +// GetArtifacts returns HashEqualsHashEqual.Artifacts, and is useful for accessing the field via an interface. +func (v *HashEqualsHashEqual) GetArtifacts() []AllHashEqualTreeArtifactsArtifact { + return v.AllHashEqualTree.Artifacts +} + +// GetOrigin returns HashEqualsHashEqual.Origin, and is useful for accessing the field via an interface. +func (v *HashEqualsHashEqual) GetOrigin() string { return v.AllHashEqualTree.Origin } + +// GetCollector returns HashEqualsHashEqual.Collector, and is useful for accessing the field via an interface. +func (v *HashEqualsHashEqual) GetCollector() string { return v.AllHashEqualTree.Collector } + +func (v *HashEqualsHashEqual) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *HashEqualsHashEqual + graphql.NoUnmarshalJSON + } + firstPass.HashEqualsHashEqual = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AllHashEqualTree) + if err != nil { + return err + } + return nil +} + +type __premarshalHashEqualsHashEqual struct { + Id string `json:"id"` + + Justification string `json:"justification"` + + Artifacts []AllHashEqualTreeArtifactsArtifact `json:"artifacts"` + + Origin string `json:"origin"` + + Collector string `json:"collector"` +} + +func (v *HashEqualsHashEqual) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *HashEqualsHashEqual) __premarshalJSON() (*__premarshalHashEqualsHashEqual, error) { + var retval __premarshalHashEqualsHashEqual + + retval.Id = v.AllHashEqualTree.Id + retval.Justification = v.AllHashEqualTree.Justification + retval.Artifacts = v.AllHashEqualTree.Artifacts + retval.Origin = v.AllHashEqualTree.Origin + retval.Collector = v.AllHashEqualTree.Collector + return &retval, nil +} + +// HashEqualsResponse is returned by HashEquals on success. +type HashEqualsResponse struct { + // Returns all artifact equality statements matching a filter. + HashEqual []HashEqualsHashEqual `json:"HashEqual"` +} + +// GetHashEqual returns HashEqualsResponse.HashEqual, and is useful for accessing the field via an interface. +func (v *HashEqualsResponse) GetHashEqual() []HashEqualsHashEqual { return v.HashEqual } + // IDorArtifactInput allows for specifying either the artifact ID or the ArtifactInputSpec. // // Either the ID or the ArtifactInputSpec must be specified. Both cannot be nil. @@ -9236,6 +9358,119 @@ func (v *IsOccurrenceSpec) GetCollector() *string { return v.Collector } // GetDocumentRef returns IsOccurrenceSpec.DocumentRef, and is useful for accessing the field via an interface. func (v *IsOccurrenceSpec) GetDocumentRef() *string { return v.DocumentRef } +// IsOccurrencesIsOccurrence includes the requested fields of the GraphQL type IsOccurrence. +// The GraphQL type's documentation follows. +// +// IsOccurrence is an attestation to link an artifact to a package or source. +// +// Attestation must occur at the PackageVersion or at the SourceName. +type IsOccurrencesIsOccurrence struct { + AllIsOccurrencesTree `json:"-"` +} + +// GetId returns IsOccurrencesIsOccurrence.Id, and is useful for accessing the field via an interface. +func (v *IsOccurrencesIsOccurrence) GetId() string { return v.AllIsOccurrencesTree.Id } + +// GetSubject returns IsOccurrencesIsOccurrence.Subject, and is useful for accessing the field via an interface. +func (v *IsOccurrencesIsOccurrence) GetSubject() AllIsOccurrencesTreeSubjectPackageOrSource { + return v.AllIsOccurrencesTree.Subject +} + +// GetArtifact returns IsOccurrencesIsOccurrence.Artifact, and is useful for accessing the field via an interface. +func (v *IsOccurrencesIsOccurrence) GetArtifact() AllIsOccurrencesTreeArtifact { + return v.AllIsOccurrencesTree.Artifact +} + +// GetJustification returns IsOccurrencesIsOccurrence.Justification, and is useful for accessing the field via an interface. +func (v *IsOccurrencesIsOccurrence) GetJustification() string { + return v.AllIsOccurrencesTree.Justification +} + +// GetOrigin returns IsOccurrencesIsOccurrence.Origin, and is useful for accessing the field via an interface. +func (v *IsOccurrencesIsOccurrence) GetOrigin() string { return v.AllIsOccurrencesTree.Origin } + +// GetCollector returns IsOccurrencesIsOccurrence.Collector, and is useful for accessing the field via an interface. +func (v *IsOccurrencesIsOccurrence) GetCollector() string { return v.AllIsOccurrencesTree.Collector } + +func (v *IsOccurrencesIsOccurrence) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *IsOccurrencesIsOccurrence + graphql.NoUnmarshalJSON + } + firstPass.IsOccurrencesIsOccurrence = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AllIsOccurrencesTree) + if err != nil { + return err + } + return nil +} + +type __premarshalIsOccurrencesIsOccurrence struct { + Id string `json:"id"` + + Subject json.RawMessage `json:"subject"` + + Artifact AllIsOccurrencesTreeArtifact `json:"artifact"` + + Justification string `json:"justification"` + + Origin string `json:"origin"` + + Collector string `json:"collector"` +} + +func (v *IsOccurrencesIsOccurrence) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *IsOccurrencesIsOccurrence) __premarshalJSON() (*__premarshalIsOccurrencesIsOccurrence, error) { + var retval __premarshalIsOccurrencesIsOccurrence + + retval.Id = v.AllIsOccurrencesTree.Id + { + + dst := &retval.Subject + src := v.AllIsOccurrencesTree.Subject + var err error + *dst, err = __marshalAllIsOccurrencesTreeSubjectPackageOrSource( + &src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal IsOccurrencesIsOccurrence.AllIsOccurrencesTree.Subject: %w", err) + } + } + retval.Artifact = v.AllIsOccurrencesTree.Artifact + retval.Justification = v.AllIsOccurrencesTree.Justification + retval.Origin = v.AllIsOccurrencesTree.Origin + retval.Collector = v.AllIsOccurrencesTree.Collector + return &retval, nil +} + +// IsOccurrencesResponse is returned by IsOccurrences on success. +type IsOccurrencesResponse struct { + // Returns all artifacts-source/package mappings that match a filter. + IsOccurrence []IsOccurrencesIsOccurrence `json:"IsOccurrence"` +} + +// GetIsOccurrence returns IsOccurrencesResponse.IsOccurrence, and is useful for accessing the field via an interface. +func (v *IsOccurrencesResponse) GetIsOccurrence() []IsOccurrencesIsOccurrence { return v.IsOccurrence } + // LicenseInputSpec specifies an license for mutations. One of inline or // listVersion should be empty or missing. type LicenseInputSpec struct { @@ -22694,6 +22929,14 @@ type __HasSBOMsInput struct { // GetFilter returns __HasSBOMsInput.Filter, and is useful for accessing the field via an interface. func (v *__HasSBOMsInput) GetFilter() HasSBOMSpec { return v.Filter } +// __HashEqualsInput is used internally by genqlient +type __HashEqualsInput struct { + Filter HashEqualSpec `json:"filter"` +} + +// GetFilter returns __HashEqualsInput.Filter, and is useful for accessing the field via an interface. +func (v *__HashEqualsInput) GetFilter() HashEqualSpec { return v.Filter } + // __IngestArtifactInput is used internally by genqlient type __IngestArtifactInput struct { Artifact IDorArtifactInput `json:"artifact"` @@ -23764,6 +24007,14 @@ type __IngestVulnerabilityInput struct { // GetVuln returns __IngestVulnerabilityInput.Vuln, and is useful for accessing the field via an interface. func (v *__IngestVulnerabilityInput) GetVuln() IDorVulnerabilityInput { return v.Vuln } +// __IsOccurrencesInput is used internally by genqlient +type __IsOccurrencesInput struct { + Filter IsOccurrenceSpec `json:"filter"` +} + +// GetFilter returns __IsOccurrencesInput.Filter, and is useful for accessing the field via an interface. +func (v *__IsOccurrencesInput) GetFilter() IsOccurrenceSpec { return v.Filter } + // __LicensesInput is used internally by genqlient type __LicensesInput struct { Filter LicenseSpec `json:"filter"` @@ -24414,6 +24665,55 @@ func HasSBOMs( return &data_, err_ } +// The query or mutation executed by HashEquals. +const HashEquals_Operation = ` +query HashEquals ($filter: HashEqualSpec!) { + HashEqual(hashEqualSpec: $filter) { + ... AllHashEqualTree + } +} +fragment AllHashEqualTree on HashEqual { + id + justification + artifacts { + ... AllArtifactTree + } + origin + collector +} +fragment AllArtifactTree on Artifact { + id + algorithm + digest +} +` + +func HashEquals( + ctx_ context.Context, + client_ graphql.Client, + filter HashEqualSpec, +) (*HashEqualsResponse, error) { + req_ := &graphql.Request{ + OpName: "HashEquals", + Query: HashEquals_Operation, + Variables: &__HashEqualsInput{ + Filter: filter, + }, + } + var err_ error + + var data_ HashEqualsResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} + // The query or mutation executed by IngestArtifact. const IngestArtifact_Operation = ` mutation IngestArtifact ($artifact: IDorArtifactInput!) { @@ -26960,6 +27260,99 @@ func IngestVulnerability( return &data_, err_ } +// The query or mutation executed by IsOccurrences. +const IsOccurrences_Operation = ` +query IsOccurrences ($filter: IsOccurrenceSpec!) { + IsOccurrence(isOccurrenceSpec: $filter) { + ... AllIsOccurrencesTree + } +} +fragment AllIsOccurrencesTree on IsOccurrence { + id + subject { + __typename + ... on Package { + ... AllPkgTree + } + ... on Source { + ... AllSourceTree + } + } + artifact { + ... AllArtifactTree + } + justification + origin + collector +} +fragment AllPkgTree on Package { + id + type + namespaces { + id + namespace + names { + id + name + versions { + id + version + qualifiers { + key + value + } + subpath + } + } + } +} +fragment AllSourceTree on Source { + id + type + namespaces { + id + namespace + names { + id + name + tag + commit + } + } +} +fragment AllArtifactTree on Artifact { + id + algorithm + digest +} +` + +func IsOccurrences( + ctx_ context.Context, + client_ graphql.Client, + filter IsOccurrenceSpec, +) (*IsOccurrencesResponse, error) { + req_ := &graphql.Request{ + OpName: "IsOccurrences", + Query: IsOccurrences_Operation, + Variables: &__IsOccurrencesInput{ + Filter: filter, + }, + } + var err_ error + + var data_ IsOccurrencesResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} + // The query or mutation executed by Licenses. const Licenses_Operation = ` query Licenses ($filter: LicenseSpec!) { diff --git a/pkg/assembler/clients/operations/hashEqual.graphql b/pkg/assembler/clients/operations/hashEqual.graphql index a252b02d7e..9d7176efb9 100644 --- a/pkg/assembler/clients/operations/hashEqual.graphql +++ b/pkg/assembler/clients/operations/hashEqual.graphql @@ -15,6 +15,12 @@ # NOTE: This is experimental and might change in the future! +query HashEquals($filter: HashEqualSpec!) { + HashEqual(hashEqualSpec: $filter) { + ...AllHashEqualTree + } +} + # Defines the GraphQL operations to certify that two artifacts are identical mutation IngestHashEqual( diff --git a/pkg/assembler/clients/operations/isOccurrence.graphql b/pkg/assembler/clients/operations/isOccurrence.graphql index 9a7ab19b45..0d21ce2744 100644 --- a/pkg/assembler/clients/operations/isOccurrence.graphql +++ b/pkg/assembler/clients/operations/isOccurrence.graphql @@ -17,6 +17,12 @@ # Defines the GraphQL operations to ingest occurrence information into GUAC +query IsOccurrences($filter: IsOccurrenceSpec!) { + IsOccurrence(isOccurrenceSpec: $filter) { + ...AllIsOccurrencesTree + } +} + mutation IngestIsOccurrencePkg( $pkg: IDorPkgInput! $artifact: IDorArtifactInput! From 507186315f519bad7f05cf9562435818796e3a9b Mon Sep 17 00:00:00 2001 From: Marco Deicas Date: Fri, 26 Apr 2024 16:10:25 +0000 Subject: [PATCH 3/5] Change Paginate to take pointer to PaginationSpec Signed-off-by: Marco Deicas --- pkg/guacrest/pagination/pagination.go | 11 ++--- pkg/guacrest/pagination/pagination_test.go | 49 ++++++++++++---------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/pkg/guacrest/pagination/pagination.go b/pkg/guacrest/pagination/pagination.go index 35851966e6..3221173602 100644 --- a/pkg/guacrest/pagination/pagination.go +++ b/pkg/guacrest/pagination/pagination.go @@ -32,7 +32,7 @@ const ( // Returns the result of Paginate with a page size of DefaultPageSize func DefaultPaginate[T any](ctx context.Context, lst []T) ([]T, models.PaginationInfo) { logger := logging.FromContext(ctx) - page, info, err := Paginate(ctx, lst, models.PaginationSpec{PageSize: PointerOf(DefaultPageSize)}) + page, info, err := Paginate(ctx, lst, &models.PaginationSpec{PageSize: PointerOf(DefaultPageSize)}) if err != nil { // should not occur with the default pagination spec, see contract of Paginate logger.Fatalf("Pagate returned err: %s, but should not have", err) @@ -43,19 +43,20 @@ func DefaultPaginate[T any](ctx context.Context, lst []T) ([]T, models.Paginatio // Returns a single page from the input, selected using the given // pagination spec, along with a struct describing the pagination of the // returned page. The input result set should be the same for every call that -// uses chained PaginationSpecs and PaginationInfos. +// uses chained PaginationSpecs and PaginationInfos. If spec is nil, a default +// page size is used. // // Errors are suitable to directly return to clients. An error is returned only if: // - the cursor is the empty string // - the cursor decodes to an out of bounds index in the input // - the cursor can't be decoded // - PageSize < 0 -func Paginate[T any](ctx context.Context, lst []T, spec models.PaginationSpec) ([]T, +func Paginate[T any](ctx context.Context, lst []T, spec *models.PaginationSpec) ([]T, models.PaginationInfo, error) { logger := logging.FromContext(ctx) var pagesize int = DefaultPageSize - if spec.PageSize != nil { + if spec != nil && spec.PageSize != nil { pagesize = *spec.PageSize } if pagesize < 0 { @@ -66,7 +67,7 @@ func Paginate[T any](ctx context.Context, lst []T, spec models.PaginationSpec) ( var inputLength uint64 = uint64(len(lst)) var start uint64 = 0 - if spec.Cursor != nil { + if spec != nil && spec.Cursor != nil { if *spec.Cursor == "" { return nil, models.PaginationInfo{}, fmt.Errorf("Pagination error: The cursor is the empty string") diff --git a/pkg/guacrest/pagination/pagination_test.go b/pkg/guacrest/pagination/pagination_test.go index a9bb4b25f2..6248e14a1c 100644 --- a/pkg/guacrest/pagination/pagination_test.go +++ b/pkg/guacrest/pagination/pagination_test.go @@ -31,7 +31,7 @@ func Test_Cursors(t *testing.T) { for i := range inputList { spec := models.PaginationSpec{PageSize: pagination.PointerOf(i)} - _, info, err := pagination.Paginate(ctx, inputList, spec) + _, info, err := pagination.Paginate(ctx, inputList, &spec) if err != nil { t.Fatalf("Unexpected error when calling Paginate to retrieve a cursor: %s", err) } @@ -44,7 +44,7 @@ func Test_Cursors(t *testing.T) { PageSize: pagination.PointerOf(100), Cursor: info.NextCursor, } - page, _, err := pagination.Paginate(ctx, inputList, spec) + page, _, err := pagination.Paginate(ctx, inputList, &spec) if err != nil { t.Fatalf("Unexpected error (%s) when calling Paginate to retrieve an element"+ " at index %d.", err, i) @@ -56,8 +56,8 @@ func Test_Cursors(t *testing.T) { } } -// Paginate is black-box by first retrieving some cursors, and then using them -// to test different combinations of PaginationSpec. This avoids testing +// Paginate is black-box tested by first retrieving some cursors, and then using +// them to test different combinations of PaginationSpec. This avoids testing // the implementation of the cursors. func Test_Paginate(t *testing.T) { ctx := context.Background() @@ -68,7 +68,7 @@ func Test_Paginate(t *testing.T) { cursors := []string{} for i := range inputList { spec := models.PaginationSpec{PageSize: pagination.PointerOf(i)} - _, info, err := pagination.Paginate(ctx, inputList, spec) + _, info, err := pagination.Paginate(ctx, inputList, &spec) if err != nil { t.Fatalf("Unexpected error when calling Paginate to set up the tests: %s", err) } @@ -81,7 +81,7 @@ func Test_Paginate(t *testing.T) { // generate a cursor that is out of range of inputList longerInputList := append(inputList, 18) spec := models.PaginationSpec{PageSize: pagination.PointerOf(6)} - _, info, err := pagination.Paginate(ctx, longerInputList, spec) + _, info, err := pagination.Paginate(ctx, longerInputList, &spec) if err != nil { t.Fatalf("Unexpected error when calling Paginate to set up the"+ " out-of-range cursor test: %s", err) @@ -95,14 +95,21 @@ func Test_Paginate(t *testing.T) { tests := []struct { name string - inputSpec models.PaginationSpec + inputSpec *models.PaginationSpec expectedPage []int expectedPaginationInfo models.PaginationInfo wantErr bool }{ + { + name: "PaginationSpec is nil, default is used", + expectedPage: inputList, + expectedPaginationInfo: models.PaginationInfo{ + TotalCount: pagination.PointerOf(6), + }, + }, { name: "Only PageSize specified", - inputSpec: models.PaginationSpec{PageSize: pagination.PointerOf(3)}, + inputSpec: &models.PaginationSpec{PageSize: pagination.PointerOf(3)}, expectedPage: []int{12, 13, 14}, expectedPaginationInfo: models.PaginationInfo{ TotalCount: pagination.PointerOf(6), @@ -111,7 +118,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is greater than the number of entries", - inputSpec: models.PaginationSpec{PageSize: pagination.PointerOf(10)}, + inputSpec: &models.PaginationSpec{PageSize: pagination.PointerOf(10)}, expectedPage: inputList, expectedPaginationInfo: models.PaginationInfo{ TotalCount: pagination.PointerOf(6), @@ -119,7 +126,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is equal to the number of entries", - inputSpec: models.PaginationSpec{PageSize: pagination.PointerOf(6)}, + inputSpec: &models.PaginationSpec{PageSize: pagination.PointerOf(6)}, expectedPage: inputList, expectedPaginationInfo: models.PaginationInfo{ TotalCount: pagination.PointerOf(6), @@ -127,7 +134,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is 0", - inputSpec: models.PaginationSpec{PageSize: pagination.PointerOf(0)}, + inputSpec: &models.PaginationSpec{PageSize: pagination.PointerOf(0)}, expectedPage: []int{}, expectedPaginationInfo: models.PaginationInfo{ TotalCount: pagination.PointerOf(6), @@ -136,12 +143,12 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is negative", - inputSpec: models.PaginationSpec{PageSize: pagination.PointerOf(-1)}, + inputSpec: &models.PaginationSpec{PageSize: pagination.PointerOf(-1)}, wantErr: true, }, { name: "PageSize is in range, Cursor is valid", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(2), Cursor: pagination.PointerOf(cursors[2]), }, @@ -153,7 +160,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is 1, Cursor is valid", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(1), Cursor: pagination.PointerOf(cursors[5]), }, @@ -164,7 +171,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize + Cursor is greater than number of entries", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(3), Cursor: pagination.PointerOf(cursors[4]), }, @@ -175,7 +182,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is in range, Cursor is empty string", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(3), Cursor: pagination.PointerOf(""), }, @@ -183,7 +190,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is in range, Cursor is invalid base64", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(3), Cursor: pagination.PointerOf("$%^"), }, @@ -191,7 +198,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is in range, Cursor is too large", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(3), Cursor: pagination.PointerOf("ABCDABCDABCD"), }, @@ -199,7 +206,7 @@ func Test_Paginate(t *testing.T) { }, { name: "PageSize is in range, Cursor is too small", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ PageSize: pagination.PointerOf(3), Cursor: pagination.PointerOf("ABC"), }, @@ -207,14 +214,14 @@ func Test_Paginate(t *testing.T) { }, { name: "Cursor is out of range", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ Cursor: outOfRangeCursor, }, wantErr: true, }, { name: "PageSize is not specified", - inputSpec: models.PaginationSpec{ + inputSpec: &models.PaginationSpec{ Cursor: pagination.PointerOf(cursors[1]), }, expectedPage: []int{13, 14, 15, 16, 17}, From 7292a290c8fabd7ea69bcc8aa595b0f0b350d73c Mon Sep 17 00:00:00 2001 From: Marco Deicas Date: Fri, 26 Apr 2024 16:10:53 +0000 Subject: [PATCH 4/5] Add better logging Signed-off-by: Marco Deicas --- cmd/guacrest/cmd/server.go | 11 +++++------ pkg/guacrest/server/server.go | 29 +++++++++++++++++++++++++++++ pkg/logging/logger.go | 8 +++++++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/cmd/guacrest/cmd/server.go b/cmd/guacrest/cmd/server.go index 1cc8d866f6..5a44e14abb 100644 --- a/cmd/guacrest/cmd/server.go +++ b/cmd/guacrest/cmd/server.go @@ -33,9 +33,6 @@ import ( "github.com/guacsec/guac/pkg/logging" ) -// TODO: add logging middleware -// TODO: add context propagation middleware - func startServer() { ctx := logging.WithLogger(context.Background()) logger := logging.FromContext(ctx) @@ -47,10 +44,12 @@ func startServer() { httpClient := &http.Client{Transport: transport} gqlClient := getGraphqlServerClientOrExit(ctx, httpClient) - handler := server.NewDefaultServer(gqlClient) - handlerWrapper := gen.NewStrictHandler(handler, nil) + + restApiHandler := gen.Handler(gen.NewStrictHandler(server.NewDefaultServer(gqlClient), nil)) + router := chi.NewRouter() - router.Mount("/", gen.Handler(handlerWrapper)) + router.Use(server.AddLoggerToCtxMiddleware, server.LogRequestsMiddleware) + router.Mount("/", restApiHandler) server := http.Server{ Addr: fmt.Sprintf(":%d", flags.restAPIServerPort), Handler: router, diff --git a/pkg/guacrest/server/server.go b/pkg/guacrest/server/server.go index 0a778caf24..142953f17e 100644 --- a/pkg/guacrest/server/server.go +++ b/pkg/guacrest/server/server.go @@ -18,9 +18,12 @@ package server import ( "context" "fmt" + "net/http" + "time" "github.com/Khan/genqlient/graphql" gen "github.com/guacsec/guac/pkg/guacrest/generated" + "github.com/guacsec/guac/pkg/logging" ) // DefaultServer implements the API, backed by the GraphQL Server @@ -32,6 +35,32 @@ func NewDefaultServer(gqlClient graphql.Client) *DefaultServer { return &DefaultServer{gqlClient: gqlClient} } +// Adds the logger to the http request context +func AddLoggerToCtxMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + newCtx := logging.WithLogger(r.Context()) + newReq := r.WithContext(newCtx) + next.ServeHTTP(w, newReq) + }) +} + +// Logs data for a request and its response. The request context should already contain +// the logger. +func LogRequestsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + newCtx := logging.WithLogger(r.Context(), + "method", r.Method, + "path", r.URL.Path, + ) + newReq := r.WithContext(newCtx) + next.ServeHTTP(w, newReq) + + logger := logging.FromContext(newReq.Context()) + logger.Infow("Request handled successfully", "latency", time.Since(start)) + }) +} + func (s *DefaultServer) HealthCheck(ctx context.Context, request gen.HealthCheckRequestObject) (gen.HealthCheckResponseObject, error) { return gen.HealthCheck200JSONResponse("Server is healthy"), nil } diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go index 5da2707050..9182db2a95 100644 --- a/pkg/logging/logger.go +++ b/pkg/logging/logger.go @@ -99,14 +99,20 @@ func ParseLevel(level string) (LogLevel, error) { } } -func WithLogger(ctx context.Context) context.Context { +// Attaches the logger to the input context, optionally adding a number of fields +// to the logging context. The fields should be key-value pairs. +func WithLogger(ctx context.Context, fields ...interface{}) context.Context { if logger == nil { // defaults to Debug if InitLogger has not been called. // all cli commands should call InitLogger, so this should mostly be for unit tests InitLogger(Debug) logger.Debugf("InitLogger has not been called. Defaulting to debug log level") } + if len(fields) > 0 { + return context.WithValue(ctx, loggerKey{}, logger.With(fields...)) + } return context.WithValue(ctx, loggerKey{}, logger) + } func FromContext(ctx context.Context) *zap.SugaredLogger { From aa2dcb21572be7cf3283acfb5937c64e15f04121 Mon Sep 17 00:00:00 2001 From: Marco Deicas Date: Tue, 23 Apr 2024 16:46:50 +0000 Subject: [PATCH 5/5] Implement transitive dependency search Signed-off-by: Marco Deicas --- pkg/assembler/helpers/purl.go | 37 + pkg/guacrest/client/client.go | 56 +- pkg/guacrest/client/models.go | 23 +- pkg/guacrest/generated/models.go | 23 +- pkg/guacrest/generated/server.go | 39 +- pkg/guacrest/generated/spec.go | 36 +- pkg/guacrest/helpers/artifact.go | 47 ++ pkg/guacrest/helpers/artifact_test.go | 54 ++ pkg/guacrest/helpers/errors.go | 25 + pkg/guacrest/helpers/package.go | 71 ++ pkg/guacrest/helpers/package_test.go | 225 +++++++ pkg/guacrest/openapi.yaml | 31 +- pkg/guacrest/server/errors.go | 48 ++ pkg/guacrest/server/retrieveDependencies.go | 394 +++++++++++ .../server/retrieveDependencies_test.go | 632 ++++++++++++++++++ pkg/guacrest/server/server.go | 4 - 16 files changed, 1688 insertions(+), 57 deletions(-) create mode 100644 pkg/guacrest/helpers/artifact.go create mode 100644 pkg/guacrest/helpers/artifact_test.go create mode 100644 pkg/guacrest/helpers/errors.go create mode 100644 pkg/guacrest/helpers/package.go create mode 100644 pkg/guacrest/helpers/package_test.go create mode 100644 pkg/guacrest/server/errors.go create mode 100644 pkg/guacrest/server/retrieveDependencies.go create mode 100644 pkg/guacrest/server/retrieveDependencies_test.go diff --git a/pkg/assembler/helpers/purl.go b/pkg/assembler/helpers/purl.go index b8327322d2..b935425058 100644 --- a/pkg/assembler/helpers/purl.go +++ b/pkg/assembler/helpers/purl.go @@ -22,6 +22,7 @@ import ( "sort" "strings" + "github.com/guacsec/guac/internal/testing/ptrfrom" model "github.com/guacsec/guac/pkg/assembler/clients/generated" purl "github.com/package-url/packageurl-go" ) @@ -42,6 +43,35 @@ func PurlToPkg(purlUri string) (*model.PkgInputSpec, error) { return purlConvert(p) } +// PurlToPkgFilter converts a purl URI string into a graphql package filter spec. +// The result will only match the input purl, and no other packages. The filter +// is created with Guac's purl special casing. +func PurlToPkgFilter(purl string) (model.PkgSpec, error) { + inputSpec, err := PurlToPkg(purl) + if err != nil { + return model.PkgSpec{}, err + } + + var qualifiers []model.PackageQualifierSpec + for _, q := range inputSpec.Qualifiers { + q := q + qualifiers = append(qualifiers, model.PackageQualifierSpec{ + Key: q.Key, + Value: &q.Value, + }) + } + + return model.PkgSpec{ + Type: &inputSpec.Type, + Namespace: ptrfrom.String(nilToEmpty(inputSpec.Namespace)), + Name: &inputSpec.Name, + Version: ptrfrom.String(nilToEmpty(inputSpec.Version)), + Qualifiers: qualifiers, + Subpath: ptrfrom.String(nilToEmpty(inputSpec.Subpath)), + MatchOnlyEmptyQualifiers: ptrfrom.Bool(len(qualifiers) == 0), + }, nil +} + // AllPkgTreeToPurl takes one package trie evaluation and converts it into a PURL // it will only do this for one PURL, and will ignore other pkg tries in the fragment func AllPkgTreeToPurl(v *model.AllPkgTree) string { @@ -258,3 +288,10 @@ func GuacGenericPurl(s string) string { return fmt.Sprintf("pkg:guac/generic/%s", sanitizedString) } } + +func nilToEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/pkg/guacrest/client/client.go b/pkg/guacrest/client/client.go index 0475f86fd6..aa0db03ec9 100644 --- a/pkg/guacrest/client/client.go +++ b/pkg/guacrest/client/client.go @@ -158,7 +158,7 @@ func NewAnalyzeDependenciesRequest(server string, params *AnalyzeDependenciesPar if params.PaginationSpec != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "PaginationSpec", runtime.ParamLocationQuery, *params.PaginationSpec); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "paginationSpec", runtime.ParamLocationQuery, *params.PaginationSpec); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -246,7 +246,7 @@ func NewRetrieveDependenciesRequest(server string, params *RetrieveDependenciesP if params.PaginationSpec != nil { - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "PaginationSpec", runtime.ParamLocationQuery, *params.PaginationSpec); err != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "paginationSpec", runtime.ParamLocationQuery, *params.PaginationSpec); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -260,16 +260,52 @@ func NewRetrieveDependenciesRequest(server string, params *RetrieveDependenciesP } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "purl", runtime.ParamLocationQuery, params.Purl); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { - return nil, err - } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) + if params.LinkCondition != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "linkCondition", runtime.ParamLocationQuery, *params.LinkCondition); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } } } + + } + + if params.Purl != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "purl", runtime.ParamLocationQuery, *params.Purl); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Digest != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "digest", runtime.ParamLocationQuery, *params.Digest); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + } queryURL.RawQuery = queryValues.Encode() diff --git a/pkg/guacrest/client/models.go b/pkg/guacrest/client/models.go index 547e8b1183..0175aebc65 100644 --- a/pkg/guacrest/client/models.go +++ b/pkg/guacrest/client/models.go @@ -9,6 +9,12 @@ const ( Scorecard AnalyzeDependenciesParamsSort = "scorecard" ) +// Defines values for RetrieveDependenciesParamsLinkCondition. +const ( + Digest RetrieveDependenciesParamsLinkCondition = "digest" + Name RetrieveDependenciesParamsLinkCondition = "name" +) + // Error defines model for Error. type Error struct { Message string `json:"Message"` @@ -50,7 +56,7 @@ type AnalyzeDependenciesParams struct { // PaginationSpec The pagination configuration for the query. // * 'PageSize' specifies the number of results returned // * 'Cursor' is returned by previous calls and specifies what page to return - PaginationSpec *PaginationSpec `form:"PaginationSpec,omitempty" json:"PaginationSpec,omitempty"` + PaginationSpec *PaginationSpec `form:"paginationSpec,omitempty" json:"paginationSpec,omitempty"` // Sort The sort order of the packages // * 'frequency' - The packages with the highest number of dependents @@ -66,8 +72,17 @@ type RetrieveDependenciesParams struct { // PaginationSpec The pagination configuration for the query. // * 'PageSize' specifies the number of results returned // * 'Cursor' is returned by previous calls and specifies what page to return - PaginationSpec *PaginationSpec `form:"PaginationSpec,omitempty" json:"PaginationSpec,omitempty"` + PaginationSpec *PaginationSpec `form:"paginationSpec,omitempty" json:"paginationSpec,omitempty"` - // Purl the purl of the dependent package - Purl string `form:"purl" json:"purl"` + // LinkCondition Whether links between nouns must be made by digest or if they can be made just by name (i.e. purl). Specify 'name' to allow using SBOMs that don't provide the digest of the subject. The default is 'digest'. To search by purl, 'name' must be specified. + LinkCondition *RetrieveDependenciesParamsLinkCondition `form:"linkCondition,omitempty" json:"linkCondition,omitempty"` + + // Purl The purl of the dependent package. + Purl *string `form:"purl,omitempty" json:"purl,omitempty"` + + // Digest The digest of the dependent package. + Digest *string `form:"digest,omitempty" json:"digest,omitempty"` } + +// RetrieveDependenciesParamsLinkCondition defines parameters for RetrieveDependencies. +type RetrieveDependenciesParamsLinkCondition string diff --git a/pkg/guacrest/generated/models.go b/pkg/guacrest/generated/models.go index d43d70e795..0578c97155 100644 --- a/pkg/guacrest/generated/models.go +++ b/pkg/guacrest/generated/models.go @@ -9,6 +9,12 @@ const ( Scorecard AnalyzeDependenciesParamsSort = "scorecard" ) +// Defines values for RetrieveDependenciesParamsLinkCondition. +const ( + Digest RetrieveDependenciesParamsLinkCondition = "digest" + Name RetrieveDependenciesParamsLinkCondition = "name" +) + // Error defines model for Error. type Error struct { Message string `json:"Message"` @@ -50,7 +56,7 @@ type AnalyzeDependenciesParams struct { // PaginationSpec The pagination configuration for the query. // * 'PageSize' specifies the number of results returned // * 'Cursor' is returned by previous calls and specifies what page to return - PaginationSpec *PaginationSpec `form:"PaginationSpec,omitempty" json:"PaginationSpec,omitempty"` + PaginationSpec *PaginationSpec `form:"paginationSpec,omitempty" json:"paginationSpec,omitempty"` // Sort The sort order of the packages // * 'frequency' - The packages with the highest number of dependents @@ -66,8 +72,17 @@ type RetrieveDependenciesParams struct { // PaginationSpec The pagination configuration for the query. // * 'PageSize' specifies the number of results returned // * 'Cursor' is returned by previous calls and specifies what page to return - PaginationSpec *PaginationSpec `form:"PaginationSpec,omitempty" json:"PaginationSpec,omitempty"` + PaginationSpec *PaginationSpec `form:"paginationSpec,omitempty" json:"paginationSpec,omitempty"` - // Purl the purl of the dependent package - Purl string `form:"purl" json:"purl"` + // LinkCondition Whether links between nouns must be made by digest or if they can be made just by name (i.e. purl). Specify 'name' to allow using SBOMs that don't provide the digest of the subject. The default is 'digest'. To search by purl, 'name' must be specified. + LinkCondition *RetrieveDependenciesParamsLinkCondition `form:"linkCondition,omitempty" json:"linkCondition,omitempty"` + + // Purl The purl of the dependent package. + Purl *string `form:"purl,omitempty" json:"purl,omitempty"` + + // Digest The digest of the dependent package. + Digest *string `form:"digest,omitempty" json:"digest,omitempty"` } + +// RetrieveDependenciesParamsLinkCondition defines parameters for RetrieveDependencies. +type RetrieveDependenciesParamsLinkCondition string diff --git a/pkg/guacrest/generated/server.go b/pkg/guacrest/generated/server.go index 87271d0c15..2b51f7a2db 100644 --- a/pkg/guacrest/generated/server.go +++ b/pkg/guacrest/generated/server.go @@ -22,7 +22,7 @@ type ServerInterface interface { // Health check the server // (GET /healthz) HealthCheck(w http.ResponseWriter, r *http.Request) - // Retrieve the dependencies of a package + // Retrieve transitive dependencies // (GET /query/dependencies) RetrieveDependencies(w http.ResponseWriter, r *http.Request, params RetrieveDependenciesParams) } @@ -43,7 +43,7 @@ func (_ Unimplemented) HealthCheck(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Retrieve the dependencies of a package +// Retrieve transitive dependencies // (GET /query/dependencies) func (_ Unimplemented) RetrieveDependencies(w http.ResponseWriter, r *http.Request, params RetrieveDependenciesParams) { w.WriteHeader(http.StatusNotImplemented) @@ -67,11 +67,11 @@ func (siw *ServerInterfaceWrapper) AnalyzeDependencies(w http.ResponseWriter, r // Parameter object where we will unmarshal all parameters from the context var params AnalyzeDependenciesParams - // ------------- Optional query parameter "PaginationSpec" ------------- + // ------------- Optional query parameter "paginationSpec" ------------- - err = runtime.BindQueryParameter("form", true, false, "PaginationSpec", r.URL.Query(), ¶ms.PaginationSpec) + err = runtime.BindQueryParameter("form", true, false, "paginationSpec", r.URL.Query(), ¶ms.PaginationSpec) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "PaginationSpec", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "paginationSpec", Err: err}) return } @@ -125,29 +125,38 @@ func (siw *ServerInterfaceWrapper) RetrieveDependencies(w http.ResponseWriter, r // Parameter object where we will unmarshal all parameters from the context var params RetrieveDependenciesParams - // ------------- Optional query parameter "PaginationSpec" ------------- + // ------------- Optional query parameter "paginationSpec" ------------- - err = runtime.BindQueryParameter("form", true, false, "PaginationSpec", r.URL.Query(), ¶ms.PaginationSpec) + err = runtime.BindQueryParameter("form", true, false, "paginationSpec", r.URL.Query(), ¶ms.PaginationSpec) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "PaginationSpec", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "paginationSpec", Err: err}) return } - // ------------- Required query parameter "purl" ------------- + // ------------- Optional query parameter "linkCondition" ------------- - if paramValue := r.URL.Query().Get("purl"); paramValue != "" { - - } else { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "purl"}) + err = runtime.BindQueryParameter("form", true, false, "linkCondition", r.URL.Query(), ¶ms.LinkCondition) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "linkCondition", Err: err}) return } - err = runtime.BindQueryParameter("form", true, true, "purl", r.URL.Query(), ¶ms.Purl) + // ------------- Optional query parameter "purl" ------------- + + err = runtime.BindQueryParameter("form", true, false, "purl", r.URL.Query(), ¶ms.Purl) if err != nil { siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "purl", Err: err}) return } + // ------------- Optional query parameter "digest" ------------- + + err = runtime.BindQueryParameter("form", true, false, "digest", r.URL.Query(), ¶ms.Digest) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "digest", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.RetrieveDependencies(w, r, params) })) @@ -413,7 +422,7 @@ type StrictServerInterface interface { // Health check the server // (GET /healthz) HealthCheck(ctx context.Context, request HealthCheckRequestObject) (HealthCheckResponseObject, error) - // Retrieve the dependencies of a package + // Retrieve transitive dependencies // (GET /query/dependencies) RetrieveDependencies(ctx context.Context, request RetrieveDependenciesRequestObject) (RetrieveDependenciesResponseObject, error) } diff --git a/pkg/guacrest/generated/spec.go b/pkg/guacrest/generated/spec.go index f80c3c7d92..f8713cf96d 100644 --- a/pkg/guacrest/generated/spec.go +++ b/pkg/guacrest/generated/spec.go @@ -18,22 +18,26 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+RWS2/jNhD+KwO2QIBCjYNte/Gtm74C9BFsctvdA02NLG6ooTIk4zqB/nsxlGTLXrnr", - "HnLqzSJnON88vm/8ooxvWk9IMajli2o16wYjcv661WtLOlpPdy0aOSkxGLatHKmluq8R2p0NGE+VXSfu", - "vyrPEGuEx4S8vfxAAN/Axa1e4519xgsILRpbWQzZiFKzQgZfAWNILgZgjIkJy8HxOnHwfAF2fwOrLbSM", - "T9anAEY7F0BTOXl4U+so+BCiH7w+kCqUFewZlioU6QbV8jjVQgVTY6NzTdi3yNFirkkPRH7FbSueIbKl", - "teoKNSY3ubQUcY2suq4Yj/zqE5qoOjliDK2n0L/8Vpe/6ogbvZUv4ykiRfmp29ZZk8EtPgWp/MsE3teM", - "lVqqrxb7Ti7627D4mdlzH+rzzgXkJ2RAMj5RRMYSNAGKi7SS0ERLa6mddKjUUcNKmwekUpJ9q8t3+Jgw", - "xNdH+1aXwH2wAkIyNegAFfsGLD1pZ0vwDI0NQfBORrgr1I1kRtrd5WT7CK+OdwwKfVQYDAt1m9j9bv9j", - "yQ7nbz+oN1T5L0E8sj6CYCM24YtPJHZqP76aWW9VP7yPyTKWavn+GNUkzMfZwT+s14/gbIjC/jaxC/n1", - "Ibyg23XtsBJ/YAh6jTNUPAI3Gn4OpZgp5yG0a09RW+pVymTuD2rCFp8QGs9ZAzFcwk0lVoygGYF8visA", - "/sS/Y68asLHOwQqBrLvMUnSY0d5yVl/ufdTuWsh6nsL0XZirTyciKOlScq5QvkXSrVVL9d3l1eWV4NKx", - "zpAWmrTbBhsWJbZIJZIZwK4xwxD8ff1K6aRYP+NPU9viYKu8n5+2vcniSIq7Ym7tBM8RPJf90oh5EZkH", - "6cOwMKosGGS2F/At3E/uYWNjnT1qu64xxMnyGXOM4yvBeEajuTz9ivMbeeSvFunu7hfYefS/Ti4cSUBN", - "5zRywunaQUqNTO8ukbyUhscns7xr6sejffLm6uoUt3d2ix1Pu0J9f47DRPe7Qv1wjsucBmffN2eFG5di", - "VoXUNJq3orHSJlttcw8aHyLYpvUcNUU4GFVxW9SoXayfT87tb/n+ukbzoObLeLZcz3DteDuU4j388xm2", - "sA3QYzzOs0cGRqBNHPq08kydx8x3g2K9LjUzERO7kZQ7Qo3EOcEG8flXNvy/h31s3kFNpYNSZ72rbdd1", - "3T8BAAD//+nLpofSCwAA", + "H4sIAAAAAAAC/+RWzW4bNxB+lQFbQG2xlYy0vfgWu0ljIGmMyEAPSQ40d6RlzB2uh6RUJdC7F0Nq5ZWy", + "atyDT73tkjOcb/6+mS/K+LbzhBSDOv+iOs26xYic/6710pKO1tO8QyMnNQbDtpMjda5uGoRuLwPG08Iu", + "E5e/hWeIDcJ9Qt5MPxDATzC51kuc2884gdChsQuLIQtRam+RwS+AMSQXAzDGxIT1TvEycfA8AftwA7cb", + "6BhX1qcARjsXQFM9eHjd6Cj4EKLfaX0gVSkr2DMsVSnSLapz1R26WqlgGmx1jgn7DjlazDEpQOQrbjrR", + "DJEtLdW2Ur1zg0tLEZfIarut+iN/+wlNVFs5Ygydp1BevtD1HzriWm/kz3iKSFE+ddc5azK42acgkf8y", + "gPc940Kdq+9mD5mcldswe8HsuZj6OnMBeYUMSMYnishYgyZAUZFUEppoaSmxkwzVOmq41eYOqRZnL3T9", + "Du8Thvj0aC90DVyMVRCSaUAHWLBvwdJKO1uDZ2htCIJ3UMLbSl2JZ6TdPDtbLDw53t4oFKuwE6zUdWL3", + "2v7HkB3W30NPXtHCfwvikfQRBBuxDd98IrFTD+WrmfVGleK9T5axVufvj1ENzHwcLfzDeD0HZ0OU7u8S", + "u5Bf35kXdPusHUbiDYaglzjSikfgesGvoVQj4TyEdukpakuFpUzu/R2bsMUVQus5cyCGKVwtRIoRNCOQ", + "z3cVwJ/4dyysAWvrHNwikHXTTEWHHj1IjvLLjY/aXUqzPo5hShbG4rMVEhR3KTlXKd8h6c6qc/XL9Gx6", + "Jrh0bDKkmSbtNsGGWY0dUo1kdmCXmGEI/hK/WjIp0p/x96FsdTBV3o9X24PI7GjqbKuxsRM8R/Bcl6ER", + "8yAyd5KH3cBYZMIgs5nAz3AzuIe1jU3WaOyywRAHw6f3MfavBOMZjeb69CvOr+WRtx3SfP4S9hrl6+TA", + "EQfUsE4jJxyOHaTUSvXuHclDaff4oJb3Sf14NE+enZ2d6u293Gzfp9tK/foYhQHvbyv122NUxjg46z57", + "lLl+KGZWSG2reSMcK2myi03OQetDBNt2nqOmCAelKmqzBrWLzeeTdfsq3182aO7UeBgfTdcjvXY8HWrR", + "3m0+uylsAxSMx34WZGAE2kChuJVr6lRnHlp9aanO+pE1BRvtCg/i1LeRpS7Faa71VzrML96+yVuVfL+e", + "P5eFq0cvLJeCbA3Fk/1rG2B0OTyhsV1Wl/ur8NaYxIxkMB/C9d3yxX3SbvTV6GEhmI2nYKXPpVNW2iHF", + "fRcWEj1M5bsdNT8tB/3VoHA9OEt3AW4xrhEJyCcK0KYQheVbXaMsqbVdCkN4BptjvAEwmvYSn7L4BoQY", + "4Ac7xWkegz9OYZ5X2Q1M5GoiEdHO+TWkvOhIbiTyOkLtaRKhY7+yNZZk7GyWpIaUR0JJa40LnVyUkoNJ", + "kZtM4cZDQM2myWt1Ylf1Znt3+sW6np4kNYnGpafa5iiNsVmx12uMsNgo2wue3pc9R/dVMD210svwq/6l", + "M0dNHQbu0cb2fp029//i574NT/GNMOP2nwAAAP///dINuX8OAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/pkg/guacrest/helpers/artifact.go b/pkg/guacrest/helpers/artifact.go new file mode 100644 index 0000000000..e8748f9b28 --- /dev/null +++ b/pkg/guacrest/helpers/artifact.go @@ -0,0 +1,47 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "fmt" + + "github.com/Khan/genqlient/graphql" + gql "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/logging" +) + +// Queries for an artifact by digest and returns its id. The digest must uniquely +// identify an artifact, otherwise an error is returned. +func FindArtifactWithDigest(ctx context.Context, gqlClient graphql.Client, + digest string) (gql.AllArtifactTree, error) { + logger := logging.FromContext(ctx) + filter := gql.ArtifactSpec{Digest: &digest} + + artifacts, err := gql.Artifacts(ctx, gqlClient, filter) + if err != nil { + logger.Errorf(fmt.Sprintf("Artifacts query returned error: %v", err)) + return gql.AllArtifactTree{}, Err502 + } + if len(artifacts.GetArtifacts()) == 0 { + return gql.AllArtifactTree{}, fmt.Errorf("no artifacts matched the digest") + } + if len(artifacts.GetArtifacts()) > 1 { + logger.Errorf("More than one artifact was found with digest %s", digest) + return gql.AllArtifactTree{}, Err500 + } + return artifacts.GetArtifacts()[0].AllArtifactTree, nil +} diff --git a/pkg/guacrest/helpers/artifact_test.go b/pkg/guacrest/helpers/artifact_test.go new file mode 100644 index 0000000000..deaea9754b --- /dev/null +++ b/pkg/guacrest/helpers/artifact_test.go @@ -0,0 +1,54 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers_test + +import ( + "context" + "testing" + + test_helpers "github.com/guacsec/guac/internal/testing/graphqlClients" + gql "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/guacrest/helpers" + "github.com/guacsec/guac/pkg/logging" + "github.com/stretchr/testify/assert" +) + +func Test_FindArtifactWithDigest_ArtifactNotFound(t *testing.T) { + ctx := logging.WithLogger(context.Background()) + gqlClient := test_helpers.SetupTest(t) + + _, err := helpers.FindArtifactWithDigest(ctx, gqlClient, "xyz") + assert.ErrorContains(t, err, "no artifacts matched the digest") +} + +func Test_FindArtifactWithDigest_ArtifactFound(t *testing.T) { + ctx := logging.WithLogger(context.Background()) + gqlClient := test_helpers.SetupTest(t) + + idOrArtifactSpec := gql.IDorArtifactInput{ArtifactInput: &gql.ArtifactInputSpec{ + Algorithm: "sha256", + Digest: "abc", + }} + _, err := gql.IngestArtifact(ctx, gqlClient, idOrArtifactSpec) + if err != nil { + t.Fatalf("Error ingesting test data") + } + + res, err := helpers.FindArtifactWithDigest(ctx, gqlClient, "abc") + assert.NoError(t, err) + assert.Equal(t, res.Algorithm, "sha256") + assert.Equal(t, res.Digest, "abc") +} diff --git a/pkg/guacrest/helpers/errors.go b/pkg/guacrest/helpers/errors.go new file mode 100644 index 0000000000..3d5dbca4b3 --- /dev/null +++ b/pkg/guacrest/helpers/errors.go @@ -0,0 +1,25 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import "errors" + +// Err502 is used to surface errors from the graphql server. +var Err502 error = errors.New("Error querying the graphql server. The error message should have been logged") + +// Err500 is used to surface generic internal errors, such as an unexpected response +// from Graphql server. +var Err500 error = errors.New("Internal Error. The error message should have been logged") diff --git a/pkg/guacrest/helpers/package.go b/pkg/guacrest/helpers/package.go new file mode 100644 index 0000000000..a35187bf19 --- /dev/null +++ b/pkg/guacrest/helpers/package.go @@ -0,0 +1,71 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "fmt" + + "github.com/Khan/genqlient/graphql" + gql "github.com/guacsec/guac/pkg/assembler/clients/generated" + assembler_helpers "github.com/guacsec/guac/pkg/assembler/helpers" + "github.com/guacsec/guac/pkg/logging" +) + +// Returns all of the version nodes of the AllPkgTree fragment input +func GetVersionsOfAllPackageTree(trie gql.AllPkgTree) []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion { + res := []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{} + for _, namespace := range trie.GetNamespaces() { + for _, name := range namespace.GetNames() { + res = append(res, name.GetVersions()...) + } + } + return res +} + +// Returns all of the version nodes of the Packages query result +func GetVersionsOfPackagesResponse(packages []gql.PackagesPackagesPackage) []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion { + res := []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{} + for _, pkg := range packages { + res = append(res, GetVersionsOfAllPackageTree(pkg.AllPkgTree)...) + } + return res +} + +// Returns the version node of the package that matches the input purl. The purl +// must uniquely identify a package, otherwise an error is returned. +func FindPackageWithPurl(ctx context.Context, gqlClient graphql.Client, + purl string) (gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion, error) { + logger := logging.FromContext(ctx) + + filter, err := assembler_helpers.PurlToPkgFilter(purl) + if err != nil { + // return the error message, to indicate unparseable purls + return gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{}, err + } + response, err := gql.Packages(ctx, gqlClient, filter) + if err != nil { + logger.Errorf(fmt.Sprintf("Packages query returned error: %v", err)) + return gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{}, Err502 + } + + versions := GetVersionsOfPackagesResponse(response.GetPackages()) + if len(versions) == 0 { + return gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{}, fmt.Errorf("no packages matched the input purl") + } + // The filter should have matched at most one package + return response.GetPackages()[0].AllPkgTree.GetNamespaces()[0].GetNames()[0].GetVersions()[0], nil +} diff --git a/pkg/guacrest/helpers/package_test.go b/pkg/guacrest/helpers/package_test.go new file mode 100644 index 0000000000..7b8e287e2a --- /dev/null +++ b/pkg/guacrest/helpers/package_test.go @@ -0,0 +1,225 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + test_helpers "github.com/guacsec/guac/internal/testing/graphqlClients" + gql "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/guacrest/helpers" + "github.com/guacsec/guac/pkg/guacrest/pagination" + "github.com/guacsec/guac/pkg/logging" +) + +func Test_GetVersionsOfPackagesResponse(t *testing.T) { + tests := []struct { + testName string + input []gql.PackagesPackagesPackage + expected []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion + }{ + { + testName: "No package version nodes", + input: []gql.PackagesPackagesPackage{{AllPkgTree: gql.AllPkgTree{ + Id: "0", + Type: "golang", + Namespaces: []gql.AllPkgTreeNamespacesPackageNamespace{{ + Id: "1", + Namespace: "", + Names: []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageName{{ + Id: "2", + Name: "foo", + }}, + }}, + }}}, + }, + { + testName: "Returns all package version nodes nested in one name node", + input: []gql.PackagesPackagesPackage{{AllPkgTree: gql.AllPkgTree{ + Id: "0", + Type: "golang", + Namespaces: []gql.AllPkgTreeNamespacesPackageNamespace{{ + Id: "1", + Namespace: "", + Names: []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageName{{ + Id: "2", + Name: "foo", + Versions: []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{ + { + Id: "3", + Version: "v1", + }, + { + Id: "4", + Version: "v2", + }, + }, + }}, + }}, + }}}, + expected: []gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion{ + { + Id: "3", + Version: "v1", + }, + { + Id: "4", + Version: "v2", + }, + }, + }, + } + + for _, tt := range tests { + actual := helpers.GetVersionsOfPackagesResponse(tt.input) + if !cmp.Equal(tt.expected, actual, cmpopts.EquateEmpty()) { + t.Errorf("Got %v, wanted %v", actual, tt.expected) + } + } +} + +func Test_FindPackageWithPurl(t *testing.T) { + ctx := logging.WithLogger(context.Background()) + + tests := []struct { + testName string + input string + wantErr bool + + ingestedPkgToExpect *gql.PkgInputSpec // this package is ingested, and its ID is the expected output + otherPackageToIngest *gql.PkgInputSpec // other packages to ingest + }{ + { + testName: "Matched ID of version node, package does not have version", + input: "pkg:guac/bar", + ingestedPkgToExpect: &gql.PkgInputSpec{Type: "guac", Name: "bar"}, + }, + { + testName: "Matched ID of version node, package has version", + input: "pkg:guac/bar@v1", + ingestedPkgToExpect: &gql.PkgInputSpec{Type: "guac", Name: "bar", Version: pagination.PointerOf("v1")}, + }, + { + testName: "Matched the less specific (without version) package", + input: "pkg:guac/bar", + ingestedPkgToExpect: &gql.PkgInputSpec{Type: "guac", Name: "bar"}, + otherPackageToIngest: &gql.PkgInputSpec{Type: "guac", Name: "bar", Version: pagination.PointerOf("v1")}, + }, + { + testName: "Matched the less specific (without qualifiers) package", + input: "pkg:guac/bar", + ingestedPkgToExpect: &gql.PkgInputSpec{Type: "guac", Name: "bar"}, + otherPackageToIngest: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "key", Value: "val"}}}, + }, + { + testName: "Matched the more specific (with version) package", + input: "pkg:guac/bar@v1", + ingestedPkgToExpect: &gql.PkgInputSpec{Type: "guac", Name: "bar", Version: pagination.PointerOf("v1")}, + otherPackageToIngest: &gql.PkgInputSpec{Type: "guac", Name: "bar"}, + }, + { + testName: "Matched the more specific (with qualifiers) package", + input: "pkg:guac/bar?key=val", + ingestedPkgToExpect: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "key", Value: "val"}}, + }, + otherPackageToIngest: &gql.PkgInputSpec{Type: "guac", Name: "bar"}, + }, + { + testName: "Same qualifier key in both packages", + input: "pkg:guac/bar?key=val-1", + ingestedPkgToExpect: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "key", Value: "val-1"}}, + }, + otherPackageToIngest: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "key", Value: "val-2"}}}, + }, + { + testName: "Common qualifiers in both packages", + input: "pkg:guac/bar?a=b&c=d", + ingestedPkgToExpect: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "a", Value: "b"}, {Key: "c", Value: "d"}}, + }, + otherPackageToIngest: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "a", Value: "b"}, {Key: "c", Value: "e"}}}, + }, + { + testName: "Input without qualifiers does not match package with qualifiers", + input: "pkg:guac/bar", + otherPackageToIngest: &gql.PkgInputSpec{ + Type: "guac", Name: "bar", + Qualifiers: []gql.PackageQualifierInputSpec{{Key: "a", Value: "b"}}}, + wantErr: true, + }, + { + testName: "Input with qualifiers does not match package without qualifiers", + input: "pkg:guac/bar?a=b", + otherPackageToIngest: &gql.PkgInputSpec{Type: "guac", Name: "bar"}, + wantErr: true, + }, + { + testName: "Input without version does not match package with version", + input: "pkg:guac/bar", + otherPackageToIngest: &gql.PkgInputSpec{Type: "guac", Name: "bar", Version: pagination.PointerOf("v1")}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + gqlClient := test_helpers.SetupTest(t) + + // ingest the expected output package and get its ID + var expected *gql.IngestPackageResponse + var err error + if tt.ingestedPkgToExpect != nil { + inputSpec := gql.IDorPkgInput{PackageInput: tt.ingestedPkgToExpect} + expected, err = gql.IngestPackage(ctx, gqlClient, inputSpec) + if err != nil { + t.Errorf("Error setting up test: %s", err) + } + } + + // ingest the other package + if tt.otherPackageToIngest != nil { + inputSpec := gql.IDorPkgInput{PackageInput: tt.otherPackageToIngest} + _, err = gql.IngestPackage(ctx, gqlClient, inputSpec) + if err != nil { + t.Errorf("Error setting up test: %s", err) + } + } + + // call the endpoint and check the output + res, err := helpers.FindPackageWithPurl(ctx, gqlClient, tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("tt.wantErr is %v, but err is %s", tt.wantErr, err) + } + if !tt.wantErr && res.GetId() != expected.IngestPackage.PackageVersionID { + t.Errorf("Got %s, but expected %s", res, expected.IngestPackage.PackageVersionID) + } + }) + } +} diff --git a/pkg/guacrest/openapi.yaml b/pkg/guacrest/openapi.yaml index 259254ac3d..0da2117abb 100644 --- a/pkg/guacrest/openapi.yaml +++ b/pkg/guacrest/openapi.yaml @@ -30,14 +30,37 @@ paths: type: string "/query/dependencies": get: - summary: Retrieve the dependencies of a package + summary: Retrieve transitive dependencies + description: > + Find the transitive dependencies of the input. The HasSBOM and HasSLSA + predicates are used as the dependency relationship and the IsOccurrence and + PkgEqual predicates are used to find consider equivalent packages. operationId: retrieveDependencies parameters: - $ref: "#/components/parameters/PaginationSpec" + - name: linkCondition + description: > + Whether links between nouns must be made by digest or if they + can be made just by name (i.e. purl). Specify 'name' to allow using + SBOMs that don't provide the digest of the subject. The default is + 'digest'. To search by purl, 'name' must be specified. + in: query + required: false + schema: + type: string + enum: + - digest + - name - name: purl - description: the purl of the dependent package + description: The purl of the dependent package. in: query - required: true + required: false + schema: + type: string + - name: digest + description: The digest of the dependent package. + in: query + required: false schema: type: string responses: @@ -81,7 +104,7 @@ paths: components: parameters: PaginationSpec: - name: PaginationSpec + name: paginationSpec in: query description: > The pagination configuration for the query. diff --git a/pkg/guacrest/server/errors.go b/pkg/guacrest/server/errors.go new file mode 100644 index 0000000000..b90aea45ac --- /dev/null +++ b/pkg/guacrest/server/errors.go @@ -0,0 +1,48 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + + gen "github.com/guacsec/guac/pkg/guacrest/generated" + "github.com/guacsec/guac/pkg/guacrest/helpers" +) + +// Maps helpers.Err502 and helpers.Err500 to the corresponding OpenAPI response type. +// Other errors are returned as Client errors. +func handleErr(ctx context.Context, err error) gen.RetrieveDependenciesResponseObject { + if err == nil { + return nil + } + switch err { + case helpers.Err502: + return gen.RetrieveDependencies502JSONResponse{ + BadGatewayJSONResponse: gen.BadGatewayJSONResponse{ + Message: err.Error(), + }} + case helpers.Err500: + return gen.RetrieveDependencies500JSONResponse{ + InternalServerErrorJSONResponse: gen.InternalServerErrorJSONResponse{ + Message: err.Error(), + }} + default: + return gen.RetrieveDependencies400JSONResponse{ + BadRequestJSONResponse: gen.BadRequestJSONResponse{ + Message: err.Error(), + }} + } +} diff --git a/pkg/guacrest/server/retrieveDependencies.go b/pkg/guacrest/server/retrieveDependencies.go new file mode 100644 index 0000000000..db09f8e367 --- /dev/null +++ b/pkg/guacrest/server/retrieveDependencies.go @@ -0,0 +1,394 @@ +package server + +import ( + "context" + "fmt" + + "github.com/Khan/genqlient/graphql" + gql "github.com/guacsec/guac/pkg/assembler/clients/generated" + assembler_helpers "github.com/guacsec/guac/pkg/assembler/helpers" + gen "github.com/guacsec/guac/pkg/guacrest/generated" + "github.com/guacsec/guac/pkg/guacrest/helpers" + "github.com/guacsec/guac/pkg/guacrest/pagination" + "github.com/guacsec/guac/pkg/logging" + "golang.org/x/exp/maps" +) + +// node is implemented by all graphQL client types +type node interface { + GetId() string +} + +// edgeGen defines the edges used by the transitive dependencies graph traversal. +type edgeGen interface { + // getDirectDependencies returns the nouns that are direct dependencies of the input noun. + getDirectDependencies(ctx context.Context, v node) ([]node, error) + + // getEquivalentNodes returns the nouns that are considered equivalent to the input noun. + getEquivalentNodes(ctx context.Context, v node) ([]node, error) +} + +// byDigest is an edgeGen that observes relationships between nouns when they are +// linked by digest. +// +// The dependency edges are: +// - artifact -> sbom -> package +// - artifact -> sbom -> artifact +// - artifact -> slsa -> artifact +// +// And the equivalence edges are: +// - artifact -> IsOccurrence -> package +// - artifact -> HashEquals -> artifact +// +// byDigest lazily generates edges using calls to the GraphQL server, instead +// of precomputing the graph. +type byDigest struct { + gqlClient graphql.Client +} + +func newByDigest(gqlClient graphql.Client) byDigest { + return byDigest{gqlClient: gqlClient} +} + +// byName is a edgeGen that respects all relationships between nouns, whether they +// are linked by hash or by name. It is useful when SBOMs don't provide the digest +// of the subject. +// +// It implements all edges defined by byDigest, in addition to the following: +// dependency edges: +// - package -> sbom -> package +// - package -> sbom -> artifact +// +// equivalence edges: +// - package -> IsOccurrence -> artifact +// +// byName lazily generates edges using calls to the GraphQL server, instead of +// precomputing the graph. +type byName struct { + gqlClient graphql.Client + bd byDigest +} + +func newByName(gqlClient graphql.Client) byName { + return byName{gqlClient: gqlClient, bd: newByDigest(gqlClient)} +} + +/********* The graph traversal *********/ + +func getTransitiveDependencies( + ctx context.Context, + gqlClient graphql.Client, + start node, + edges edgeGen) ([]node, error) { + + // As the queue in this function is essentially a list of IO actions, this + // function could be optimized by running through the queue and executing + // all of them concurrently. + + queue := []node{start} + visited := map[string]node{} + + // maintain the set of nodes are equivalent to the start node, including the start node + nodesEquivalentToStart := map[node]any{start: struct{}{}} + + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + + if _, ok := visited[node.GetId()]; ok { + continue + } + visited[node.GetId()] = node + + adjacent, err := edges.getDirectDependencies(ctx, node) + if err != nil { + return nil, err + } + queue = append(queue, adjacent...) + + adjacent, err = edges.getEquivalentNodes(ctx, node) + if err != nil { + return nil, err + } + queue = append(queue, adjacent...) + + if _, ok := nodesEquivalentToStart[node]; ok { + for _, equivalentNode := range adjacent { + nodesEquivalentToStart[equivalentNode] = struct{}{} + } + } + } + + // Nodes equivalent to the start node are not dependencies + for node := range nodesEquivalentToStart { + delete(visited, node.GetId()) + } + return maps.Values(visited), nil +} + +/********* Implementations of the interface *********/ + +func (eg byDigest) getDirectDependencies(ctx context.Context, v node) ([]node, error) { + edgesToPredicates := []gql.Edge{ + gql.EdgeArtifactHasSbom, + gql.EdgeArtifactHasSlsa, + } + edgesFromPredicates := []gql.Edge{ + gql.EdgeHasSbomIncludedSoftware, + gql.EdgeHasSlsaMaterials, + } + return neighborsTwoHops(ctx, eg.gqlClient, v, edgesToPredicates, edgesFromPredicates) +} + +func (eg byDigest) getEquivalentNodes(ctx context.Context, v node) ([]node, error) { + edgesToPredicates := []gql.Edge{ + gql.EdgeArtifactIsOccurrence, + gql.EdgeArtifactHashEqual, + } + edgesFromPredicates := []gql.Edge{ + gql.EdgeIsOccurrenceArtifact, + gql.EdgeIsOccurrencePackage, + gql.EdgeHashEqualArtifact, + } + return neighborsTwoHops(ctx, eg.gqlClient, v, edgesToPredicates, edgesFromPredicates) +} + +func (eg byName) getDirectDependencies(ctx context.Context, v node) ([]node, error) { + edgesToPredicates := []gql.Edge{ + gql.EdgePackageHasSbom, + gql.EdgeArtifactHasSbom, + gql.EdgeArtifactHasSlsa, + } + edgesFromPredicates := []gql.Edge{ + gql.EdgeHasSbomIncludedSoftware, + gql.EdgeHasSlsaMaterials, + } + return neighborsTwoHops(ctx, eg.gqlClient, v, edgesToPredicates, edgesFromPredicates) +} + +func (eg byName) getEquivalentNodes(ctx context.Context, v node) ([]node, error) { + edgesToPredicates := []gql.Edge{ + gql.EdgePackageIsOccurrence, + gql.EdgeArtifactIsOccurrence, + gql.EdgeArtifactHashEqual, + } + edgesFromPredicates := []gql.Edge{ + gql.EdgeIsOccurrenceArtifact, + gql.EdgeIsOccurrencePackage, + gql.EdgeHashEqualArtifact, + } + return neighborsTwoHops(ctx, eg.gqlClient, v, edgesToPredicates, edgesFromPredicates) +} + +/********* Graphql helper functions *********/ + +// neighborsTwoHops calls the GraphQL Neighbors endpoint once with edgesToPredicates, and +// then again on the result with edgesFromPredicates. +func neighborsTwoHops(ctx context.Context, gqlClient graphql.Client, v node, + edgesToPredicates []gql.Edge, edgesFromPredicates []gql.Edge) ([]node, error) { + predicates, err := neighbors(ctx, gqlClient, v, edgesToPredicates) + if err != nil { + return nil, err + } + + res := []node{} + for _, predicate := range predicates { + nodes, err := neighbors(ctx, gqlClient, predicate, edgesFromPredicates) + if err != nil { + return nil, err + } + res = append(res, nodes...) + } + return res, nil +} + +// neighbors calls the GraphQL Neighbors endpoint. +func neighbors(ctx context.Context, gqlClient graphql.Client, v node, edges []gql.Edge) ([]node, error) { + logger := logging.FromContext(ctx) + neighborsResponse, err := gql.Neighbors(ctx, gqlClient, v.GetId(), edges) + if err != nil { + logger.Errorf("Neighbors query returned err: ", err) + return nil, helpers.Err502 + } + if neighborsResponse == nil { + logger.Errorf("Neighbors query returned nil") + return nil, helpers.Err500 + } + return transformWithError(ctx, neighborsResponse.GetNeighbors(), neighborToNode) +} + +// Maps a list of As to a list of Bs +func transformWithError[A any, B any](ctx context.Context, lst []A, f func(context.Context, A) (B, error)) ([]B, error) { + res := []B{} + for _, x := range lst { + transformed, err := f(ctx, x) + if err != nil { + return nil, err + } + res = append(res, transformed) + } + return res, nil +} + +// Returns the graphQL type that is nested in the neighbors response node. For package tries, +// the leaf version node is returned. Only the types relevant to the retrieveDependecies +// graph traversal are implemented. +func neighborToNode(ctx context.Context, neighborsNode gql.NeighborsNeighborsNode) (node, error) { + logger := logging.FromContext(ctx) + switch val := neighborsNode.(type) { + case *gql.NeighborsNeighborsArtifact: + if val == nil { + logger.Errorf("neighbors node is nil") + return nil, helpers.Err500 + } + return &val.AllArtifactTree, nil + case *gql.NeighborsNeighborsPackage: + if val == nil { + logger.Errorf("neighbors node is nil") + return nil, helpers.Err500 + } + packageVersions := helpers.GetVersionsOfAllPackageTree(val.AllPkgTree) + if len(packageVersions) > 1 { + logger.Errorf("NeighborsNeighborsPackage value contains more than one package version node") + return nil, helpers.Err500 + } + + // this will occur if the neighbors response is a package name node + if len(packageVersions) == 0 { + return nil, nil + } + + return &packageVersions[0], nil + case *gql.NeighborsNeighborsHasSBOM: + if val == nil { + logger.Errorf("neighbors node is nil") + return nil, helpers.Err500 + } + return &val.AllHasSBOMTree, nil + case *gql.NeighborsNeighborsIsOccurrence: + if val == nil { + logger.Errorf("neighbors node is nil") + return nil, helpers.Err500 + } + return &val.AllIsOccurrencesTree, nil + case *gql.NeighborsNeighborsHashEqual: + if val == nil { + logger.Errorf("neighbors node is nil") + return nil, helpers.Err500 + } + return &val.AllHashEqualTree, nil + case *gql.NeighborsNeighborsHasSLSA: + if val == nil { + logger.Errorf("neighbors node is nil") + return nil, helpers.Err500 + } + return &val.AllSLSATree, nil + } + logger.Errorf("neighborsResponseToNode received an unexpected node type: %T", neighborsNode) + return nil, helpers.Err500 +} + +// Maps nodes in the input to purls, ignoring nodes that are not package version +// nodes. +func mapPkgNodesToPurls(ctx context.Context, gqlClient graphql.Client, + nodes []node) ([]string, error) { + logger := logging.FromContext(ctx) + + // get the IDs of the package nodes + pkgIds := []string{} + for _, node := range nodes { + if v, ok := node.(*gql.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion); ok { + if v == nil { + logger.Warnf("An gql version node is unexpectedly nil") + continue + } + pkgIds = append(pkgIds, node.GetId()) + } + } + + // Call Nodes to get the entire package trie for each node + gqlNodes, err := gql.Nodes(ctx, gqlClient, pkgIds) + if err != nil { + logger.Errorf("Nodes query returned err: ", err) + return nil, helpers.Err502 + } + if gqlNodes == nil { + logger.Errorf("The Nodes query returned a nil result.") + return nil, helpers.Err500 + } + if len(gqlNodes.GetNodes()) != len(pkgIds) { + logger.Warnf("GQL query \"nodes\" did not return the expected number of results") + } + + // map the package tries to purls + purls := make([]string, 0, len(gqlNodes.GetNodes())) + for _, gqlNode := range gqlNodes.GetNodes() { + if v, ok := gqlNode.(*gql.NodesNodesPackage); ok { + purl := assembler_helpers.AllPkgTreeToPurl(&v.AllPkgTree) + purls = append(purls, purl) + } else { + logger.Warnf("Nodes query returned an unexpected type: %T", *gqlNode.GetTypename()) + } + } + return purls, nil +} + +/********* The endpoint handler *********/ +func (s *DefaultServer) RetrieveDependencies( + ctx context.Context, + request gen.RetrieveDependenciesRequestObject, +) (gen.RetrieveDependenciesResponseObject, error) { + // Find the start node + var start node + if request.Params.Purl != nil { + pkg, err := helpers.FindPackageWithPurl(ctx, s.gqlClient, *request.Params.Purl) + if err != nil { + return handleErr(ctx, err), nil + } + start = &pkg + } else if request.Params.Digest != nil { + artifact, err := helpers.FindArtifactWithDigest(ctx, s.gqlClient, *request.Params.Digest) + if err != nil { + return handleErr(ctx, err), nil + } + start = &artifact + } else { + return gen.RetrieveDependencies400JSONResponse{ + BadRequestJSONResponse: gen.BadRequestJSONResponse{ + Message: "Neither a purl or a digest argument was provided", + }}, nil + } + + // Select the edgeGen. The default is byDigest + var edgeGenerator edgeGen + cond := request.Params.LinkCondition + if cond == nil { + edgeGenerator = newByDigest(s.gqlClient) + } else if *cond == gen.Name { + edgeGenerator = newByName(s.gqlClient) + } else if *cond == gen.Digest { + edgeGenerator = newByDigest(s.gqlClient) + } else { + err := fmt.Errorf("Unrecognized linkCondition: %s", *request.Params.LinkCondition) + return handleErr(ctx, err), nil + } + + // Compute the result and map to purls + deps, err := getTransitiveDependencies(ctx, s.gqlClient, start, edgeGenerator) + if err != nil { + return handleErr(ctx, err), nil + } + purls, err := mapPkgNodesToPurls(ctx, s.gqlClient, deps) + if err != nil { + return handleErr(ctx, err), nil + } + + page, pageInfo, err := pagination.Paginate(ctx, purls, request.Params.PaginationSpec) + if err != nil { + return handleErr(ctx, err), nil + } + return gen.RetrieveDependencies200JSONResponse{PurlListJSONResponse: gen.PurlListJSONResponse{ + PurlList: page, + PaginationInfo: pageInfo, + }}, nil +} diff --git a/pkg/guacrest/server/retrieveDependencies_test.go b/pkg/guacrest/server/retrieveDependencies_test.go new file mode 100644 index 0000000000..3fe0b71076 --- /dev/null +++ b/pkg/guacrest/server/retrieveDependencies_test.go @@ -0,0 +1,632 @@ +// +// Copyright 2024 The GUAC Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server_test + +import ( + stdcmp "cmp" + "context" + "testing" + + cmp "github.com/google/go-cmp/cmp" + + "github.com/google/go-cmp/cmp/cmpopts" + . "github.com/guacsec/guac/internal/testing/graphqlClients" + "github.com/guacsec/guac/internal/testing/ptrfrom" + _ "github.com/guacsec/guac/pkg/assembler/backends/keyvalue" + api "github.com/guacsec/guac/pkg/guacrest/generated" + "github.com/guacsec/guac/pkg/guacrest/server" + "github.com/guacsec/guac/pkg/logging" +) + +// Tests the edges in ByName that are not in ByDigest +func Test_RetrieveDependencies(t *testing.T) { + ctx := logging.WithLogger(context.Background()) + tests := []struct { + name string + data GuacData + + // Only specify Purl or Digest. The test will set linkCondition, because both are tested + input api.RetrieveDependenciesParams + + expectedByName []string + expectedByDigest []string + }{ + /******* + * Tests of specific edges. + * + * The test case name is the edge or edges intended to be tested, not necessarily all + * of the edges in the graph. Equivalence edges (e.g. IsOccurrence) can't be + * tested without some dependency edges, so some edges / graphs can't be tested in + * isolation. + *******/ + { + name: "Package -> SBOM -> package", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{}, + }, + { + name: "Package -> SBOM -> package, package", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar", "pkg:guac/baz"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar", "pkg:guac/baz"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar", "pkg:guac/baz"}, + expectedByDigest: []string{}, + }, + { + name: "Artifact -> SBOM -> package", + data: GuacData{ + Packages: []string{"pkg:guac/bar"}, + Artifacts: []string{"sha-xyz"}, + HasSboms: []HasSbom{ + {Subject: "sha-xyz", IncludedSoftware: []string{"pkg:guac/bar"}}, + }, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-xyz")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/bar"}, + }, + { + name: "Package -> SBOM -> package -> SBOM -> package", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar", "pkg:guac/baz"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar"}}, + {Subject: "pkg:guac/bar", IncludedSoftware: []string{"pkg:guac/baz"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar", "pkg:guac/baz"}, + expectedByDigest: []string{}, + }, + { + name: "Artifact -> SBOM -> artifact -> SBOM -> package", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-xyz", "sha-123"}, + HasSboms: []HasSbom{ + {Subject: "sha-xyz", IncludedSoftware: []string{"sha-123"}}, + {Subject: "sha-123", IncludedSoftware: []string{"pkg:guac/foo"}}, + }, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-xyz")}, + expectedByName: []string{"pkg:guac/foo"}, + expectedByDigest: []string{"pkg:guac/foo"}, + }, + { + name: "Artifact -> SBOM -> package -> SBOM -> package", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + Artifacts: []string{"sha-xyz"}, + HasSboms: []HasSbom{ + {Subject: "sha-xyz", IncludedSoftware: []string{"pkg:guac/bar"}}, + {Subject: "pkg:guac/bar", IncludedSoftware: []string{"pkg:guac/foo"}}, + }, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-xyz")}, + expectedByName: []string{"pkg:guac/foo", "pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/bar"}, + }, + { + name: "artifact -> occurrence -> package", + data: GuacData{ + Packages: []string{"pkg:guac/bar"}, + Artifacts: []string{"sha-123", "sha-xyz"}, + HasSboms: []HasSbom{{Subject: "sha-xyz", IncludedSoftware: []string{"sha-123"}}}, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/bar", Artifact: "sha-123"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-xyz")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/bar"}, + }, + { + name: "Package -> occurrence -> artifact", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + Artifacts: []string{"sha-xyz"}, + HasSboms: []HasSbom{{Subject: "sha-xyz", IncludedSoftware: []string{"pkg:guac/bar"}}}, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-xyz"}}, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{}, + }, + { + name: "package -> occurrence -> artifact, artifact", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar", "pkg:guac/baz"}, + Artifacts: []string{"sha-xyz", "sha-123"}, + HasSboms: []HasSbom{ + {Subject: "sha-xyz", IncludedSoftware: []string{"pkg:guac/bar"}}, + {Subject: "sha-123", IncludedSoftware: []string{"pkg:guac/baz"}}, + }, + IsOccurrences: []IsOccurrence{ + {Subject: "pkg:guac/foo", Artifact: "sha-xyz"}, + {Subject: "pkg:guac/foo", Artifact: "sha-123"}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar", "pkg:guac/baz"}, + expectedByDigest: []string{}, + }, + { + name: "Artifact -> hashEqual -> artifact", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123", "sha-456"}, + HasSboms: []HasSbom{{Subject: "sha-456", IncludedSoftware: []string{"pkg:guac/foo"}}}, + HashEquals: []HashEqual{{ArtifactA: "sha-123", ArtifactB: "sha-456"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo"}, + expectedByDigest: []string{"pkg:guac/foo"}, + }, + { + name: "Artifact -> hashEqual -> artifact, artifact", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + Artifacts: []string{"sha-123", "sha-456", "sha-789"}, + HasSboms: []HasSbom{ + {Subject: "sha-456", IncludedSoftware: []string{"pkg:guac/foo"}}, + {Subject: "sha-789", IncludedSoftware: []string{"pkg:guac/bar"}}, + }, + HashEquals: []HashEqual{ + {ArtifactA: "sha-123", ArtifactB: "sha-456"}, + {ArtifactA: "sha-123", ArtifactB: "sha-789"}, + }, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo", "pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/foo", "pkg:guac/bar"}, + }, + { + name: "Artifact -> hashEqual -> artifact -> hashEqual -> artifact", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + Artifacts: []string{"sha-123", "sha-456", "sha-789"}, + HasSboms: []HasSbom{ + {Subject: "sha-456", IncludedSoftware: []string{"pkg:guac/foo"}}, + {Subject: "sha-789", IncludedSoftware: []string{"pkg:guac/bar"}}, + }, + HashEquals: []HashEqual{ + {ArtifactA: "sha-123", ArtifactB: "sha-456"}, + {ArtifactA: "sha-456", ArtifactB: "sha-789"}, + }, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo", "pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/foo", "pkg:guac/bar"}, + }, + { + name: "artifact -> SLSA -> artifact -> occurrence -> package", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123", "sha-xyz"}, + Builders: []string{"GHA"}, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-xyz"}}, + HasSlsas: []HasSlsa{{Subject: "sha-123", BuiltBy: "GHA", BuiltFrom: []string{"sha-xyz"}}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo"}, + expectedByDigest: []string{"pkg:guac/foo"}, + }, + { + name: "artifact -> SLSA -> artifact, artifact", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + Artifacts: []string{"sha-123", "sha-xyz", "sha-abc"}, + Builders: []string{"GHA"}, + IsOccurrences: []IsOccurrence{ + {Subject: "pkg:guac/foo", Artifact: "sha-xyz"}, + {Subject: "pkg:guac/bar", Artifact: "sha-abc"}, + }, + HasSlsas: []HasSlsa{{Subject: "sha-123", BuiltBy: "GHA", BuiltFrom: []string{"sha-xyz", "sha-abc"}}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo", "pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/foo", "pkg:guac/bar"}, + }, + /******* + * Test some edge cases + *******/ + { + name: "Both Package and occurrence artifact in SBOM does not lead to duplicate packages", + data: GuacData{ + Packages: []string{"pkg:guac/bar"}, + Artifacts: []string{"sha-123", "sha-xyz"}, + HasSboms: []HasSbom{{ + Subject: "sha-xyz", + IncludedSoftware: []string{"pkg:guac/bar", "sha-123"}, + IncludedIsOccurrences: []IsOccurrence{{Subject: "pkg:guac/bar", Artifact: "sha-123"}}, + }}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-xyz")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{"pkg:guac/bar"}, + }, + { + name: "Dependent is not considered a dependency", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar", "pkg:guac/baz"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar"}}, + {Subject: "pkg:guac/baz", IncludedSoftware: []string{"pkg:guac/bar"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{}, + }, + { + name: "Transitive dependents are not considered dependencies", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar", "pkg:guac/baz"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar"}}, + {Subject: "pkg:guac/bar", IncludedSoftware: []string{"pkg:guac/baz"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/baz")}, + expectedByName: []string{}, + expectedByDigest: []string{}, + }, + { + name: "Packages with same names but different digests", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar", "pkg:guac/baz"}, + Artifacts: []string{"sha-123", "sha-xyz"}, + HasSboms: []HasSbom{ + // foo's sbom contains bar with a digest of sha-123 + { + Subject: "pkg:guac/foo", + IncludedSoftware: []string{"pkg:guac/bar"}, + IncludedIsOccurrences: []IsOccurrence{{Subject: "pkg:guac/bar", Artifact: "sha-123"}}, + }, + // an artifact with digest sha-xyz depends on baz + { + Subject: "sha-xyz", + IncludedSoftware: []string{"pkg:guac/baz"}, + }, + }, + // sha-xyz is an occurrence of bar + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/bar", Artifact: "sha-xyz"}}, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + // foo depends on baz + expectedByName: []string{"pkg:guac/bar", "pkg:guac/baz"}, + expectedByDigest: []string{}, + }, + /******* + * Test that equivalent packages aren't considered to be dependencies + *******/ + { + name: "Package IsOccurrence is not considered a dependency", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123"}, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-123"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{}, + expectedByDigest: []string{}, + }, + { + name: "Artifact IsOccurrence is not considered a dependency", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + Artifacts: []string{"sha-123"}, + IsOccurrences: []IsOccurrence{ + {Subject: "pkg:guac/foo", Artifact: "sha-123"}, + {Subject: "pkg:guac/bar", Artifact: "sha-123"}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{}, + expectedByDigest: []string{}, + }, + { + name: "Artifact HashEqual is not considered a dependency", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123", "sha-456"}, + HashEquals: []HashEqual{{ArtifactA: "sha-123", ArtifactB: "sha-456"}}, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-456"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{}, + expectedByDigest: []string{}, + }, + /******* + * Test that cycles in the graph are handled correctly + *******/ + { + name: "Equivalence cycle including start node", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123", "sha-456", "sha-789"}, + HashEquals: []HashEqual{ + {ArtifactA: "sha-123", ArtifactB: "sha-456"}, + {ArtifactA: "sha-456", ArtifactB: "sha-789"}, + {ArtifactA: "sha-789", ArtifactB: "sha-123"}, + }, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-789"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{}, + expectedByDigest: []string{}, + }, + { + name: "Equivalence cycle not including start node", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123", "sha-456", "sha-789"}, + HasSboms: []HasSbom{{Subject: "sha-123", IncludedSoftware: []string{"sha-456"}}}, + HashEquals: []HashEqual{ + {ArtifactA: "sha-456", ArtifactB: "sha-789"}, + {ArtifactA: "sha-789", ArtifactB: "sha-456"}, + }, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-789"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo"}, + expectedByDigest: []string{"pkg:guac/foo"}, + }, + { + name: "Dependency cycle", + data: GuacData{ + Packages: []string{"pkg:guac/foo"}, + Artifacts: []string{"sha-123", "sha-456", "sha-789"}, + HasSboms: []HasSbom{ + {Subject: "sha-123", IncludedSoftware: []string{"sha-456"}}, + {Subject: "sha-456", IncludedSoftware: []string{"sha-789"}}, + {Subject: "sha-789", IncludedSoftware: []string{"sha-123"}}, + }, + IsOccurrences: []IsOccurrence{{Subject: "pkg:guac/foo", Artifact: "sha-789"}}, + }, + input: api.RetrieveDependenciesParams{Digest: ptrfrom.String("sha-123")}, + expectedByName: []string{"pkg:guac/foo"}, + expectedByDigest: []string{"pkg:guac/foo"}, + }, + /******* + * Test packages with versions + *******/ + { + name: "Package with version is not confused for package without version", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/foo@v1", "pkg:guac/bar", "pkg:guac/bar@v1"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar@v1"}}, + {Subject: "pkg:guac/foo@v1", IncludedSoftware: []string{"pkg:guac/bar"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar@v1"}, + expectedByDigest: []string{}, + }, + { + name: "Package without version is not confused for package with version", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/foo@v1", "pkg:guac/bar", "pkg:guac/bar@v1"}, + HasSboms: []HasSbom{ + {Subject: "pkg:guac/foo", IncludedSoftware: []string{"pkg:guac/bar"}}, + {Subject: "pkg:guac/foo@v1", IncludedSoftware: []string{"pkg:guac/bar@v1"}}, + }, + }, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:guac/bar"}, + expectedByDigest: []string{}, + }, + /******* + * Test that the Guac purl special-casing is handled correctly + *******/ + { + name: "Endpoint works for OCI purl", + data: GuacData{ + Packages: []string{ + "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=ghcr.io&tag=bullseye", + "pkg:oci/static@sha256%3A244fd47e07d10?repository_url=gcr.io%2Fdistroless&tag=latest", + }, + HasSboms: []HasSbom{{ + Subject: "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=ghcr.io&tag=bullseye", + IncludedSoftware: []string{"pkg:oci/static@sha256%3A244fd47e07d10?repository_url=gcr.io%2Fdistroless&tag=latest"}}, + }}, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=ghcr.io&tag=bullseye")}, + expectedByName: []string{"pkg:oci/static@sha256%3A244fd47e07d10?repository_url=gcr.io%2Fdistroless&tag=latest"}, + expectedByDigest: []string{}, + }, + /******* + * A test to record that purls are canonicalized upon ingestion, and so they + * may not round-trip. + *******/ + { + name: "Non-canonical purl may not round trip", + data: GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:github/Package-url/purl-Spec"}, + HasSboms: []HasSbom{{ + Subject: "pkg:guac/foo", + IncludedSoftware: []string{"pkg:github/Package-url/purl-Spec"}}, + }}, + input: api.RetrieveDependenciesParams{Purl: ptrfrom.String("pkg:guac/foo")}, + expectedByName: []string{"pkg:github/package-url/purl-spec"}, // lowercased + expectedByDigest: []string{}, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + /******** set up the test ********/ + gqlClient := SetupTest(t) + Ingest(ctx, t, gqlClient, tt.data) + + restApi := server.NewDefaultServer(gqlClient) + + /******** call the endpoint with byName link condition ********/ + inputByName := tt.input + inputByName.LinkCondition = ptrfrom.Any(api.Name) + resByName, err := restApi.RetrieveDependencies(ctx, api.RetrieveDependenciesRequestObject{Params: inputByName}) + if err != nil { + t.Fatalf("Endpoint returned unexpected error: %v", err) + } + /******** check the output ********/ + switch v := resByName.(type) { + case api.RetrieveDependencies200JSONResponse: + if !cmp.Equal(v.PurlList, tt.expectedByName, cmpopts.EquateEmpty(), cmpopts.SortSlices(stdcmp.Less[string])) { + t.Errorf("RetrieveDependencies with byName returned %v, but wanted %v", v.PurlList, tt.expectedByName) + } + default: + t.Errorf("RetrieveDependencies with byName returned unexpected error: %v", v) + } + + /******** call the endpoint with byDigest link condition ********/ + inputByDigest := tt.input + inputByDigest.LinkCondition = ptrfrom.Any(api.Digest) + resByDigest, err := restApi.RetrieveDependencies(ctx, api.RetrieveDependenciesRequestObject{Params: inputByDigest}) + if err != nil { + t.Fatalf("Endpoint returned unexpected error: %v", err) + } + /******** check the output ********/ + switch v := resByDigest.(type) { + case api.RetrieveDependencies200JSONResponse: + if !cmp.Equal(v.PurlList, tt.expectedByDigest, cmpopts.EquateEmpty(), cmpopts.SortSlices(stdcmp.Less[string])) { + t.Errorf("RetrieveDependencies with byDigest returned %v, but wanted %v", v.PurlList, tt.expectedByDigest) + } + default: + t.Errorf("RetrieveDependencies with byDigest returned unexpected error: %v", v) + } + }) + } +} + +func Test_ClientErrors(t *testing.T) { + ctx := logging.WithLogger(context.Background()) + tests := []struct { + name string + data GuacData + input api.RetrieveDependenciesParams + }{{ + name: "Package not found", + input: api.RetrieveDependenciesParams{ + Purl: ptrfrom.String("pkg:guac/foo"), + LinkCondition: ptrfrom.Any(api.Name), + }, + }, { + name: "Package not found because version was specified", + data: GuacData{Packages: []string{"pkg:guac/foo"}}, + input: api.RetrieveDependenciesParams{ + Purl: ptrfrom.String("pkg:guac/foo@v1"), + LinkCondition: ptrfrom.Any(api.Name), + }, + }, { + name: "Package not found because version was not specified", + data: GuacData{Packages: []string{"pkg:guac/foo@v1"}}, + input: api.RetrieveDependenciesParams{ + Purl: ptrfrom.String("pkg:guac/foo"), + LinkCondition: ptrfrom.Any(api.Name), + }, + }, { + name: "Package not found due to missing qualifiers", + data: GuacData{Packages: []string{"pkg:guac/foo?a=b"}}, + input: api.RetrieveDependenciesParams{ + Purl: ptrfrom.String("pkg:guac/foo"), + LinkCondition: ptrfrom.Any(api.Name), + }, + }, { + name: "Package not found due to providing qualifiers", + data: GuacData{Packages: []string{"pkg:guac/foo"}}, + input: api.RetrieveDependenciesParams{ + Purl: ptrfrom.String("pkg:guac/foo?a=b"), + LinkCondition: ptrfrom.Any(api.Name), + }, + }, { + name: "Artifact not found because version was not specified", + input: api.RetrieveDependenciesParams{ + Digest: ptrfrom.String("sha-abc"), + LinkCondition: ptrfrom.Any(api.Name), + }, + }, { + name: "Neither Purl nor Digest provided", + }, { + name: "Unrecognized link condition", + input: api.RetrieveDependenciesParams{ + Digest: ptrfrom.String("sha-abc"), + LinkCondition: ptrfrom.Any(api.RetrieveDependenciesParamsLinkCondition("foo")), + }, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gqlClient := SetupTest(t) + Ingest(ctx, t, gqlClient, tt.data) + restApi := server.NewDefaultServer(gqlClient) + + res, err := restApi.RetrieveDependencies(ctx, api.RetrieveDependenciesRequestObject{Params: tt.input}) + if err != nil { + t.Fatalf("RetrieveDependencies returned unexpected error: %v", err) + } + if _, ok := res.(api.RetrieveDependencies400JSONResponse); !ok { + t.Fatalf("Did not receive a 400 Response: recieved %v of type %T", res, res) + } + + }) + } +} + +func Test_DefaultLinkCondition(t *testing.T) { + /******** set up the test ********/ + ctx := logging.WithLogger(context.Background()) + gqlClient := SetupTest(t) + restApi := server.NewDefaultServer(gqlClient) + data := GuacData{ + Packages: []string{"pkg:guac/foo", "pkg:guac/bar"}, + HasSboms: []HasSbom{{ + Subject: "pkg:guac/foo", + IncludedSoftware: []string{"pkg:guac/bar"}}, + }} + Ingest(ctx, t, gqlClient, data) + + input := api.RetrieveDependenciesParams{ + Purl: ptrfrom.String("pkg:guac/foo"), + } + + /******** call the endpoint ********/ + res, err := restApi.RetrieveDependencies(ctx, api.RetrieveDependenciesRequestObject{Params: input}) + if err != nil { + t.Fatalf("RetrieveDependencies returned unexpected error: %v", err) + } + + /******** check the output ********/ + switch v := res.(type) { + case api.RetrieveDependencies200JSONResponse: + // that the default is byDigest is tested by asserting that no edges only in byName are used + if len(v.PurlList) != 0 { + t.Errorf("RetrieveDependencies returned %v, but no dependencies were expected", v) + } + default: + t.Errorf("RetrieveDependencies returned unexpected error: %v", v) + } + +} diff --git a/pkg/guacrest/server/server.go b/pkg/guacrest/server/server.go index 142953f17e..0176623b34 100644 --- a/pkg/guacrest/server/server.go +++ b/pkg/guacrest/server/server.go @@ -68,7 +68,3 @@ func (s *DefaultServer) HealthCheck(ctx context.Context, request gen.HealthCheck func (s *DefaultServer) AnalyzeDependencies(ctx context.Context, request gen.AnalyzeDependenciesRequestObject) (gen.AnalyzeDependenciesResponseObject, error) { return nil, fmt.Errorf("Unimplemented") } - -func (s *DefaultServer) RetrieveDependencies(ctx context.Context, request gen.RetrieveDependenciesRequestObject) (gen.RetrieveDependenciesResponseObject, error) { - return nil, fmt.Errorf("Unimplemented") -}