diff --git a/cmd/guacone/cmd/analyze.go b/cmd/guacone/cmd/analyze.go new file mode 100644 index 0000000000..56f8105f28 --- /dev/null +++ b/cmd/guacone/cmd/analyze.go @@ -0,0 +1,266 @@ +// +// 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 cmd + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/Khan/genqlient/graphql" + "github.com/dominikbraun/graph" + analyzer "github.com/guacsec/guac/pkg/analyzer" + model "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/cli" + "github.com/guacsec/guac/pkg/logging" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type AnalyzeOpts struct { + Metadata bool + InclSoft bool + InclDeps bool + InclOccur bool + Namespaces bool + URI bool + PURL bool +} + +var analyzeCmd = &cobra.Command{ + Use: "analyze [flags] ", + Short: "analyze is a CLI tool tailored for comparing, intersecting, and merging Software Bill of Materials (SBOMs) within GUAC", + Long: `Diff Analysis: Compare two SBOMs to identify differences in their software components, versions, and dependencies. + Intersection Analysis: Determine the intersection of two SBOMs, highlighting common software components shared between them. + Union Analysis: Combine two SBOMs to create a unified SBOM, merging software component lists while maintaining version integrity.`, + Example: ` + Ingest the SBOMs to analyze: + $ guacone collect files guac-data-main/docs/spdx/syft-spdx-k8s.gcr.io-kube-apiserver.v1.24.4.json + $ guacone collect files guac-data-main/docs/spdx/spdx_vuln.json + + Difference + $ guacone analyze diff --analyze-uri-input --analyze-sboms=https://anchore.com/syft/image/ghcr.io/guacsec/vul-image-latest-6fd9de7b-9bec-4ae7-99d9-4b5e5ef6b869,https://anchore.com/syft/image/k8s.gcr.io/kube-apiserver-v1.24.4-b15339bc-a146-476e-a789-6a65e4e22e54 + + Union + $ guacone analyze union --analyze-uri-input --analyze-sboms=https://anchore.com/syft/image/ghcr.io/guacsec/vul-image-latest-6fd9de7b-9bec-4ae7-99d9-4b5e5ef6b869,https://anchore.com/syft/image/k8s.gcr.io/kube-apiserver-v1.24.4-b15339bc-a146-476e-a789-6a65e4e22e54 + + Intersection + $ guacone analyze intersect --analyze-uri-input --analyze-sboms=https://anchore.com/syft/image/ghcr.io/guacsec/vul-image-latest-6fd9de7b-9bec-4ae7-99d9-4b5e5ef6b869,https://anchore.com/syft/image/k8s.gcr.io/kube-apiserver-v1.24.4-b15339bc-a146-476e-a789-6a65e4e22e54 + `, + Run: func(cmd *cobra.Command, args []string) { + + if len(args) < 1 || len(args) > 1 { + fmt.Println("required 1 positional arguments, got", len(args)) + os.Exit(1) + } + + if args[0] != "intersect" && args[0] != "union" && args[0] != "diff" { + fmt.Println("invalid positional argument. Must be one of: intersect, union or diff.") + os.Exit(1) + } + + ctx := logging.WithLogger(context.Background()) + logger := logging.FromContext(ctx) + httpClient := http.Client{} + gqlclient := graphql.NewClient(viper.GetString("gql-addr"), &httpClient) + + slsas := viper.GetStringSlice("analyze-slsa") + sboms := viper.GetStringSlice("analyze-sboms") + uri := viper.GetBool("analyze-uri-input") + purl := viper.GetBool("analyze-purl-input") + + metadata := viper.GetBool("analyze-metadata") + inclSoft := viper.GetBool("analyze-incl-soft") + inclDeps := viper.GetBool("analyze-incl-deps") + inclOccur := viper.GetBool("analyze-incl-occur") + namespaces := viper.GetBool("analyze-namespaces") + + var graphs []graph.Graph[string, *analyzer.Node] + var err error + + if err = validateAnalyzeFlags(slsas, sboms, uri, purl); err != nil { + fmt.Fprintf(os.Stderr, "error: %s", err) + _ = cmd.Help() + os.Exit(1) + } + + graphs, err = hasSBOMToGraph(ctx, gqlclient, sboms, AnalyzeOpts{ + Metadata: metadata, InclSoft: inclSoft, InclDeps: inclDeps, InclOccur: inclOccur, + Namespaces: namespaces, URI: uri, PURL: purl}) + + if err != nil { + logger.Fatalf("Unable to generate graphs: %v", err) + } + + if args[0] == "diff" { + gOne, gTwo, err := analyzer.CompressGraphs(graphs[0], graphs[1]) + if err != nil { + logger.Fatalf("compress graphs fail: %v", err) + } + + analysisOne, analysisTwo, err := analyzer.HighlightAnalysis(gOne, gTwo, analyzer.Difference) + if err != nil { + logger.Fatalf("unable to generate diff analysis: %v", err) + } + + diffs, err := analyzer.CompareAllPaths(analysisOne, analysisTwo) + if err != nil { + logger.Fatalf("unable to generate diff analysis: %v", err) + } + + if err = analyzer.PrintAnalysis(diffs); err != nil { + logger.Fatalf("unable to print diff analysis: %v", err) + } + + } else if args[0] == "intersect" { + analysisOne, analysisTwo, err := analyzer.HighlightAnalysis(graphs[0], graphs[1], analyzer.Intersection) + if err != nil { + logger.Fatalf("Unable to generate intersect analysis: %v", err) + } + if err = analyzer.PrintPathTable("Common Paths", analysisOne, analysisTwo); err != nil { + logger.Fatalf("unable to print intersect analysis: %v", err) + } + } else if args[0] == "union" { + analysisOne, analysisTwo, err := analyzer.HighlightAnalysis(graphs[0], graphs[1], analyzer.Union) + if err != nil { + logger.Fatalf("unable to generate union analysis: %v", err) + } + if err = analyzer.PrintPathTable("All Paths", analysisOne, analysisTwo); err != nil { + logger.Fatalf("unable to print union analysis: %v", err) + } + } + }, +} + +func hasSBOMToGraph(ctx context.Context, gqlclient graphql.Client, sboms []string, opts AnalyzeOpts) ([]graph.Graph[string, *analyzer.Node], error) { + + var hasSBOMResponseOne *model.HasSBOMsResponse + var hasSBOMResponseTwo *model.HasSBOMsResponse + var err error + logger := logging.FromContext(ctx) + + if opts.URI { + hasSBOMResponseOne, err = analyzer.FindHasSBOMBy(model.HasSBOMSpec{}, sboms[0], "", "", ctx, gqlclient) + if err != nil { + return []graph.Graph[string, *analyzer.Node]{}, fmt.Errorf("(uri)failed to lookup sbom: %v %v", sboms[0], err) + } + hasSBOMResponseTwo, err = analyzer.FindHasSBOMBy(model.HasSBOMSpec{}, sboms[1], "", "", ctx, gqlclient) + if err != nil { + return []graph.Graph[string, *analyzer.Node]{}, fmt.Errorf("(uri)failed to lookup sbom: %v %v", sboms[1], err) + } + } else if opts.PURL { + hasSBOMResponseOne, err = analyzer.FindHasSBOMBy(model.HasSBOMSpec{}, "", sboms[0], "", ctx, gqlclient) + if err != nil { + return []graph.Graph[string, *analyzer.Node]{}, fmt.Errorf("(purl)failed to lookup sbom: %v %v", sboms[0], err) + } + hasSBOMResponseTwo, err = analyzer.FindHasSBOMBy(model.HasSBOMSpec{}, "", sboms[1], "", ctx, gqlclient) + if err != nil { + return []graph.Graph[string, *analyzer.Node]{}, fmt.Errorf("(purl)failed to lookup sbom: %v %v", sboms[1], err) + } + } + + if hasSBOMResponseOne == nil || hasSBOMResponseTwo == nil { + return []graph.Graph[string, *analyzer.Node]{}, fmt.Errorf("failed to lookup sboms: nil") + } + + if len(hasSBOMResponseOne.HasSBOM) == 0 || len(hasSBOMResponseTwo.HasSBOM) == 0 { + return []graph.Graph[string, *analyzer.Node]{}, fmt.Errorf("failed to lookup sboms, one endpoint may not have sboms") + } + + if len(hasSBOMResponseOne.HasSBOM) != 1 || len(hasSBOMResponseTwo.HasSBOM) != 1 { + logger.Infof("multiple sboms found for given purl, id or uri. Using first one") + } + hasSBOMOne := hasSBOMResponseOne.HasSBOM[0] + hasSBOMTwo := hasSBOMResponseTwo.HasSBOM[0] + + gOne, err := analyzer.MakeGraph(hasSBOMOne, opts.Metadata, opts.InclSoft, opts.InclDeps, opts.InclOccur, opts.Namespaces) + if err != nil { + logger.Fatalf(err.Error()) + } + gTwo, err := analyzer.MakeGraph(hasSBOMTwo, opts.Metadata, opts.InclSoft, opts.InclDeps, opts.InclOccur, opts.Namespaces) + if err != nil { + logger.Fatalf(err.Error()) + } + return []graph.Graph[string, *analyzer.Node]{ + gOne, + gTwo, + }, nil +} + +func validateAnalyzeFlags(slsas, sboms []string, uri, purl bool) error { + + if len(slsas) == 0 && len(sboms) == 0 { + return fmt.Errorf("must specify slsa or sboms") + } + + if len(slsas) > 0 && len(sboms) > 0 { + return fmt.Errorf("must either specify slsa or sbom") + } + + if (len(slsas) <= 1 || len(slsas) > 2) && len(sboms) == 0 { + return fmt.Errorf("must specify exactly two slsas to analyze, specified %v", len(slsas)) + } + + if (len(sboms) <= 1 || len(sboms) > 2) && len(slsas) == 0 { + return fmt.Errorf("must specify exactly two sboms to analyze, specified %v", len(sboms)) + } + + if len(slsas) == 2 { + return fmt.Errorf("slsa diff to be implemented") + } + + if sboms[0] == "" || sboms[1] == "" { + return fmt.Errorf("expected sbom received \"\"") + } + + if !uri && !purl { + return fmt.Errorf("must provide one of --uri or --purl") + } + + if uri && purl { + return fmt.Errorf("must provide only one of --uri or --purl") + } + + return nil +} + +func init() { + set, err := cli.BuildFlags([]string{ + "analyze-sboms", + "analyze-slsa", + "analyze-uri-input", + "analyze-purl-input", + "analyze-id-input", + "analyze-metadata", + "analyze-incl-soft", + "analyze-incl-deps", + "analyze-incl-occur", + "analyze-namespaces", + }) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to setup flag: %v", err) + os.Exit(1) + } + analyzeCmd.PersistentFlags().AddFlagSet(set) + if err := viper.BindPFlags(analyzeCmd.PersistentFlags()); err != nil { + fmt.Fprintf(os.Stderr, "failed to bind flags: %v", err) + os.Exit(1) + } + + rootCmd.AddCommand(analyzeCmd) + +} diff --git a/go.mod b/go.mod index 972da9229d..f0be34b9bd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/fsouza/fake-gcs-server v1.50.2 github.com/in-toto/in-toto-golang v0.9.0 github.com/neo4j/neo4j-go-driver/v4 v4.4.7 + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 github.com/spf13/cobra v1.8.1 go.uber.org/zap v1.27.0 @@ -202,7 +203,6 @@ require ( github.com/nats-io/jwt/v2 v2.5.8 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/nats-io/nuid v1.0.1 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/onsi/ginkgo/v2 v2.13.1 // indirect github.com/onsi/gomega v1.29.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc5 // indirect @@ -232,7 +232,7 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/shurcooL/githubv4 v0.0.0-20201206200315-234843c633fa // indirect github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -356,3 +356,14 @@ require ( golang.org/x/time v0.8.0 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/gdamore/encoding v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect +) + +require ( + github.com/dominikbraun/graph v0.23.0 + github.com/gdamore/tcell/v2 v2.7.4 + github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 +) diff --git a/go.sum b/go.sum index ded3d17889..e69b6b222a 100644 --- a/go.sum +++ b/go.sum @@ -271,6 +271,8 @@ github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqY github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= +github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= @@ -312,6 +314,10 @@ github.com/fsouza/fake-gcs-server v1.50.2 h1:ulrS1pavCOCbMZfN5ZPgBRMFWclON9xDsuL github.com/fsouza/fake-gcs-server v1.50.2/go.mod h1:VU6Zgei4647KuT4XER8WHv5Hcj2NIySndyG8gfvwckA= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= +github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4= github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8= @@ -562,6 +568,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -576,6 +584,7 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= @@ -718,7 +727,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6O github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rhysd/actionlint v1.6.26 h1:zi7jPZf3Ks14gCXYAAL47uBziyFlX7+Xwilqhexct9g= github.com/rhysd/actionlint v1.6.26/go.mod h1:TIj1DlCgtYLOv5CH9wCK+WJTOr1qAdnFzkGi0IgSCO4= +github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 h1:YIJ+B1hePP6AgynC5TcqpO0H9k3SSoZa2BGyL6vDUzM= +github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -1034,6 +1046,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -1046,6 +1059,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go new file mode 100644 index 0000000000..5f0fd0d949 --- /dev/null +++ b/pkg/analyzer/analyzer.go @@ -0,0 +1,1163 @@ +// +// 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 analyzer + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "sync" + + "sort" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/dominikbraun/graph" + model "github.com/guacsec/guac/pkg/assembler/clients/generated" + "github.com/guacsec/guac/pkg/assembler/helpers" + "github.com/sergi/go-diff/diffmatchpatch" +) + +type NodeType int +type Action int + +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorGreen = "\033[32m" + ColorWhite = "\033[37m" +) + + + +const ( + Pkg NodeType = iota + DepPkg +) + +func (n NodeType) String() string { + return [...]string{"Pkg", "DepPkg"}[n] +} + +const ( + Difference Action = iota + Intersection + Union +) + +func (a Action) String() string { + return [...]string{"Difference", "Intersection", "Union"}[a] +} + +type Node struct { + ID string + Attributes map[string]string + Pkg model.AllIsDependencyTreePackage + NodeType string + DepPkg model.AllIsDependencyTreeDependencyPackage + Color string +} + +type DiffResult struct { + Paths []DiffedPath + Nodes map[string]DiffedNodePair +} + +type EqualDifferencesPaths struct { + Diffs [][]string + Path []*Node + Index int +} + +type DiffedNodePair struct { + NodeOne *Node + NodeTwo *Node + Count int +} + +type DiffedPath struct { + PathOne []*Node + PathTwo []*Node + Diffs [][]string + NodeDiffs []Node + Index int + DiffNum int +} + +type packageNameSpaces []model.AllPkgTreeNamespacesPackageNamespace + +type packageNameSpacesNames []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageName + +type packageNameSpacesNamesVersions []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion + +type packageNameSpacesNamesVersionsQualifiers []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersionQualifiersPackageQualifier + +func (a packageNameSpaces) Len() int { return len(a) } +func (a packageNameSpaces) Less(i, j int) bool { return a[i].Namespace < a[j].Namespace } +func (a packageNameSpaces) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a packageNameSpacesNames) Len() int { return len(a) } +func (a packageNameSpacesNames) Less(i, j int) bool { return a[i].Name < a[j].Name } +func (a packageNameSpacesNames) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a packageNameSpacesNamesVersions) Len() int { return len(a) } +func (a packageNameSpacesNamesVersions) Less(i, j int) bool { return a[i].Version < a[j].Version } +func (a packageNameSpacesNamesVersions) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a packageNameSpacesNamesVersionsQualifiers) Len() int { return len(a) } +func (a packageNameSpacesNamesVersionsQualifiers) Less(i, j int) bool { return a[i].Key < a[j].Key } +func (a packageNameSpacesNamesVersionsQualifiers) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func NodeHash(n *Node) string { + return n.ID +} + +func getPkgResponseFromPurl(ctx context.Context, gqlclient graphql.Client, purl string) (*model.PackagesResponse, error) { + pkgInput, err := helpers.PurlToPkg(purl) + if err != nil { + return nil, fmt.Errorf("failed to parse PURL: %v", err) + } + + pkgQualifierFilter := []model.PackageQualifierSpec{} + for _, qualifier := range pkgInput.Qualifiers { + // to prevent https://github.com/golang/go/discussions/56010 + qualifier := qualifier + pkgQualifierFilter = append(pkgQualifierFilter, model.PackageQualifierSpec{ + Key: qualifier.Key, + Value: &qualifier.Value, + }) + } + + pkgFilter := &model.PkgSpec{ + Type: &pkgInput.Type, + Namespace: pkgInput.Namespace, + Name: &pkgInput.Name, + Version: pkgInput.Version, + Subpath: pkgInput.Subpath, + Qualifiers: pkgQualifierFilter, + } + pkgResponse, err := model.Packages(ctx, gqlclient, *pkgFilter) + if err != nil { + return nil, fmt.Errorf("error querying for package: %v", err) + } + if len(pkgResponse.Packages) != 1 { + return nil, fmt.Errorf("failed to located package based on purl") + } + return pkgResponse, nil +} + +func FindHasSBOMBy(filter model.HasSBOMSpec, uri, purl, id string, ctx context.Context, gqlclient graphql.Client) (*model.HasSBOMsResponse, error) { + var foundHasSBOMPkg *model.HasSBOMsResponse + var err error + if purl != "" { + pkgResponse, err := getPkgResponseFromPurl(ctx, gqlclient, purl) + if err != nil { + return nil, fmt.Errorf("getPkgResponseFromPurl - error: %v", err) + } + foundHasSBOMPkg, err = model.HasSBOMs(ctx, gqlclient, model.HasSBOMSpec{Subject: &model.PackageOrArtifactSpec{Package: &model.PkgSpec{Id: &pkgResponse.Packages[0].Namespaces[0].Names[0].Versions[0].Id}}}) + if err != nil { + return nil, fmt.Errorf("(purl)failed getting hasSBOM with error :%v", err) + } + } else if uri != "" { + foundHasSBOMPkg, err = model.HasSBOMs(ctx, gqlclient, model.HasSBOMSpec{Uri: &uri}) + if err != nil { + return nil, fmt.Errorf("(uri)failed getting hasSBOM with error: %v", err) + } + } else if id != "" { + foundHasSBOMPkg, err = model.HasSBOMs(ctx, gqlclient, model.HasSBOMSpec{Id: &id}) + if err != nil { + return nil, fmt.Errorf("(id)failed getting hasSBOM with error: %v", err) + } + } else { + foundHasSBOMPkg, err = model.HasSBOMs(ctx, gqlclient, filter) + if err != nil { + return nil, fmt.Errorf("(filter)failed getting hasSBOM with error: %v", err) + } + } + return foundHasSBOMPkg, nil +} + +func dfsFindPaths(nodeID string, allNodeEdges map[string]map[string]graph.Edge[string], currentPath []string, allPaths *[][]string) { + currentPath = append(currentPath, nodeID) + + // Check if the current node has any outgoing edges + if val, ok := allNodeEdges[nodeID]; ok && len(val) == 0 { + // If not, add the current path to the list of all paths + *allPaths = append(*allPaths, currentPath) + return + } + + // Iterate over the adjacent nodes of the current node + for target := range allNodeEdges[nodeID] { + // Recursively explore the adjacent node + dfsFindPaths(target, allNodeEdges, currentPath, allPaths) + } +} + +func FindPathsFromHasSBOMNode(g graph.Graph[string, *Node]) ([][]string, error) { + + var paths [][]string + var currentPath []string + allNodeEdges, err := g.AdjacencyMap() + if err != nil { + return paths, fmt.Errorf("error getting adjacency map") + } + if len(allNodeEdges) == 0 { + return paths, nil + } + for nodeID := range allNodeEdges { + if nodeID == "HasSBOM" { + continue + } + + node, err := g.Vertex(nodeID) + + if err != nil { + return paths, fmt.Errorf("error getting node type") + } + + if node.NodeType == "Package" { + //now start dfs + dfsFindPaths(nodeID, allNodeEdges, currentPath, &paths) + } + } + if len(paths) == 0 && len(allNodeEdges) > 1 { + return paths, fmt.Errorf("paths 0, nodes > 1") + } + return paths, nil +} + +func HighlightAnalysis(gOne, gTwo graph.Graph[string, *Node], action Action) ([][]*Node, [][]*Node, error) { + pathsOne, errOne := FindPathsFromHasSBOMNode(gOne) + pathsTwo, errTwo := FindPathsFromHasSBOMNode(gTwo) + + if errOne != nil || errTwo != nil { + return [][]*Node{}, [][]*Node{}, fmt.Errorf("error getting graph paths errOne-%v, errTwo-%v", errOne.Error(), errTwo.Error()) + } + + pathsOneStrings := concatenateLists(pathsOne) + pathsTwoStrings := concatenateLists(pathsTwo) + + pathsOneMap := make(map[string][]*Node) + pathsTwoMap := make(map[string][]*Node) + + var analysisOne, analysisTwo [][]*Node + + //create a map so that we are only using unique paths. + for i := range pathsOne { + nodes, err := nodeIDListToNodeList(gOne, pathsOne[i]) + if err != nil { + return analysisOne, analysisTwo, err + } + _, ok := pathsOneMap[pathsOneStrings[i]] + if !ok { + pathsOneMap[pathsOneStrings[i]] = nodes + } + } + + for i := range pathsTwo { + nodes, err := nodeIDListToNodeList(gTwo, pathsTwo[i]) + if err != nil { + return analysisOne, analysisTwo, err + } + _, ok := pathsTwoMap[pathsTwoStrings[i]] + if !ok { + pathsTwoMap[pathsTwoStrings[i]] = nodes + } + } + + switch action { + + case Difference: + for key, val := range pathsOneMap { + _, ok := pathsTwoMap[key] + if !ok { + + analysisOne = append(analysisOne, val) + } + } + + for key, val := range pathsTwoMap { + _, ok := pathsOneMap[key] + if !ok { + + analysisTwo = append(analysisTwo, val) + } + } + + case Intersection: + for key := range pathsOneMap { + val, ok := pathsTwoMap[key] + if ok { + analysisOne = append(analysisOne, val) + } + } + + case Union: + + for _, val := range pathsOneMap { + analysisOne = append(analysisOne, val) + } + + for key, val := range pathsTwoMap { + _, ok := pathsOneMap[key] + if !ok { + analysisTwo = append(analysisTwo, val) + } + } + + } + + return analysisOne, analysisTwo, nil +} + +func MakeGraph(hasSBOM model.HasSBOMsHasSBOM, metadata, inclSoft, inclDeps, inclOccur, namespaces bool) (graph.Graph[string, *Node], error) { + + g := graph.New(NodeHash, graph.Directed()) + + //create HasSBOM node + AddGraphNode(g, "HasSBOM", "black") + + compareAll := !metadata && !inclSoft && !inclDeps && !inclOccur && !namespaces + + if metadata || compareAll { + //add metadata + node, err := g.Vertex("HasSBOM") + if err != nil { + return g, fmt.Errorf("hasSBOM node not found") + } + node.Attributes = map[string]string{} + node.Attributes["Algorithm"] = hasSBOM.Algorithm + node.Attributes["Digest"] = hasSBOM.Digest + node.Attributes["Uri"] = hasSBOM.Uri + } + + if inclDeps || compareAll { + //add included dependencies + //sort dependencies here + for _, dependency := range hasSBOM.IncludedDependencies { + //package node + //sort namespaces + sort.Sort(packageNameSpaces(dependency.Package.Namespaces)) + message := dependency.Package.Type + for _, namespace := range dependency.Package.Namespaces { + message += namespace.Namespace + sort.Sort(packageNameSpacesNames(namespace.Names)) + for _, name := range namespace.Names { + message += name.Name + sort.Sort(packageNameSpacesNamesVersions(name.Versions)) + for _, version := range name.Versions { + message += version.Version + message += version.Subpath + sort.Sort(packageNameSpacesNamesVersionsQualifiers(version.Qualifiers)) + for _, outlier := range version.Qualifiers { + message += outlier.Key + message += outlier.Value + } + } + } + } + + if message == "" { + return g, fmt.Errorf("encountered empty message for hashing") + } + + hashValPackage := nodeHasher([]byte(message)) + _, err := g.Vertex(hashValPackage) + + if err != nil { //node does not exist + AddGraphNode(g, hashValPackage, "black") // so, create a node + AddGraphEdge(g, "HasSBOM", hashValPackage, "black") + //set attributes here + node, err := g.Vertex(hashValPackage) + if err != nil { + return g, fmt.Errorf("newly created node not found in graph") + } + node.NodeType = "Package" + node.Pkg = dependency.Package + } + + //dependencyPackage node + sort.Sort(packageNameSpaces(dependency.DependencyPackage.Namespaces)) + message = dependency.DependencyPackage.Type + for _, namespace := range dependency.DependencyPackage.Namespaces { + message += namespace.Namespace + sort.Sort(packageNameSpacesNames(namespace.Names)) + for _, name := range namespace.Names { + message += name.Name + sort.Sort(packageNameSpacesNamesVersions(name.Versions)) + for _, version := range name.Versions { + message += version.Version + message += version.Subpath + sort.Sort(packageNameSpacesNamesVersionsQualifiers(version.Qualifiers)) + for _, outlier := range version.Qualifiers { + message += outlier.Key + message += outlier.Value + } + } + } + } + + hashValDependencyPackage := nodeHasher([]byte(message)) + _, err = g.Vertex(hashValDependencyPackage) + + if err != nil { //node does not exist + AddGraphNode(g, hashValDependencyPackage, "black") + node, err := g.Vertex(hashValDependencyPackage) + if err != nil { + return g, fmt.Errorf("newly created node not found in graph") + } + node.NodeType = "DependencyPackage" + node.DepPkg = dependency.DependencyPackage + } + + AddGraphEdge(g, hashValPackage, hashValDependencyPackage, "black") + } + } + return g, nil +} +func nodeHasher(value []byte) string { + hash := sha256.Sum256(value) + return hex.EncodeToString(hash[:]) +} + +func AddGraphNode(g graph.Graph[string, *Node], id, color string) { + var err error + if _, err = g.Vertex(id); err == nil { + return + } + + newNode := &Node{ + ID: id, + Color: color, + } + + err = g.AddVertex(newNode, graph.VertexAttribute("color", color)) + if err != nil { + return + } +} + +func AddGraphEdge(g graph.Graph[string, *Node], from, to, color string) { + AddGraphNode(g, from, "black") + AddGraphNode(g, to, "black") + + _, err := g.Edge(from, to) + if err == nil { + return + } + + if g.AddEdge(from, to, graph.EdgeAttribute("color", color)) != nil { + return + } +} + +func GraphEqual(graphOne, graphTwo graph.Graph[string, *Node]) (bool, error) { + gOneMap, errOne := graphOne.AdjacencyMap() + + gTwoMap, errTwo := graphTwo.AdjacencyMap() + + if errOne != nil || errTwo != nil { + return false, fmt.Errorf("error getting graph nodes") + } + + if len(gTwoMap) != len(gOneMap) { + return false, fmt.Errorf("number of nodes not equal") + } + + for key := range gOneMap { + _, ok := gTwoMap[key] + if !ok { + return false, fmt.Errorf("missing key in map") + } + } + + edgesOne, errOne := graphOne.Edges() + edgesTwo, errTwo := graphTwo.Edges() + if errOne != nil || errTwo != nil { + return false, fmt.Errorf("error getting edges") + } + + if len(edgesOne) != len(edgesTwo) { + return false, fmt.Errorf("edges not equal") + } + + for _, edge := range edgesOne { + _, err := graphTwo.Edge(edge.Source, edge.Target) + if err != nil { + return false, fmt.Errorf("edge not found Source - %s Target - %s", edge.Source, edge.Target) + } + } + return true, nil + +} + +func GraphEdgesEqual(graphOne, graphTwo graph.Graph[string, *Node]) (bool, error) { + + pathsOne, errOne := FindPathsFromHasSBOMNode(graphOne) + pathsTwo, errTwo := FindPathsFromHasSBOMNode(graphTwo) + if errOne != nil || errTwo != nil { + return false, fmt.Errorf("error getting graph paths errOne-%v, errTwo-%v", errOne.Error(), errTwo.Error()) + } + + if len(pathsOne) != len(pathsTwo) { + return false, fmt.Errorf("paths not of equal length %v %v", len(pathsOne), len(pathsTwo)) + } + + pathsOneStrings := concatenateLists(pathsOne) + pathsTwoStrings := concatenateLists(pathsTwo) + + sort.Strings(pathsTwoStrings) + sort.Strings(pathsOneStrings) + + for i := range pathsOneStrings { + if pathsOneStrings[i] != pathsTwoStrings[i] { + return false, fmt.Errorf("paths differ %v", fmt.Sprintf("%v", i)) + } + } + + return true, nil +} + +func concatenateLists(list [][]string) []string { + var concatenated []string + for _, l := range list { + concatenated = append(concatenated, strings.Join(l, "")) + } + return concatenated +} + +func nodeIDListToNodeList(g graph.Graph[string, *Node], list []string) ([]*Node, error) { + + var nodeList []*Node + for _, item := range list { + nd, err := g.Vertex(item) + if err != nil { + return nodeList, err + } + nodeList = append(nodeList, nd) + } + return nodeList, nil +} + +func DiffMissingName(dmp *diffmatchpatch.DiffMatchPatch, name model.AllPkgTreeNamespacesPackageNamespaceNamesPackageName) model.AllPkgTreeNamespacesPackageNamespaceNamesPackageName { + name.Name = ComputeStringDiffs(dmp, name.Name, "") + for k, version := range name.Versions { + name.Versions[k].Version = ComputeStringDiffs(dmp, version.Version, "") + for l, qualifier := range version.Qualifiers { + name.Versions[k].Qualifiers[l].Key = ComputeStringDiffs(dmp, qualifier.Key, "") + name.Versions[k].Qualifiers[l].Value = ComputeStringDiffs(dmp, qualifier.Value, "") + } + } + return name +} + +func DiffMissingVersion(dmp *diffmatchpatch.DiffMatchPatch, version model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion) model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion { + version.Version = ComputeStringDiffs(dmp, version.Version, "") + for l, qualifier := range version.Qualifiers { + version.Qualifiers[l].Key = ComputeStringDiffs(dmp, qualifier.Key, "") + version.Qualifiers[l].Value = ComputeStringDiffs(dmp, qualifier.Value, "") + } + return version +} + +func DiffMissingQualifier(dmp *diffmatchpatch.DiffMatchPatch, qualifier model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersionQualifiersPackageQualifier) model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersionQualifiersPackageQualifier { + qualifier.Key = ComputeStringDiffs(dmp, qualifier.Key, "") + qualifier.Value = ComputeStringDiffs(dmp, qualifier.Value, "") + return qualifier +} + +func DiffMissingNamespace(dmp *diffmatchpatch.DiffMatchPatch, namespace model.AllPkgTreeNamespacesPackageNamespace) model.AllPkgTreeNamespacesPackageNamespace { + namespace.Namespace = ComputeStringDiffs(dmp, namespace.Namespace, "") + for j, name := range namespace.Names { + namespace.Names[j].Name = ComputeStringDiffs(dmp, name.Name, "") + for k, version := range name.Versions { + namespace.Names[j].Versions[k].Version = ComputeStringDiffs(dmp, version.Version, "") + for l, qualifier := range version.Qualifiers { + namespace.Names[j].Versions[k].Qualifiers[l].Key = ComputeStringDiffs(dmp, qualifier.Key, "") + namespace.Names[j].Versions[k].Qualifiers[l].Value = ComputeStringDiffs(dmp, qualifier.Value, "") + } + } + } + return namespace +} + +func ComputeStringDiffs(dmp *diffmatchpatch.DiffMatchPatch, text1, text2 string) string { + + // Enable line mode for faster processing on large texts + diffs := dmp.DiffMain(text1, text2, true) + diffs = dmp.DiffCleanupSemantic(diffs) // Optional: Clean up diff for better readability + diffString := FormatDiffs(diffs) + return diffString +} + +func FormatDiffsTableWriter(diffs []diffmatchpatch.Diff) string { + + var parts []string + // Precompile color codes into variables + colorGreen := ColorGreen + colorRed := ColorRed + colorWhite := ColorWhite + colorReset := ColorReset + + for _, diff := range diffs { + var prefix string + switch diff.Type { + case diffmatchpatch.DiffInsert: + prefix = colorGreen + "+" + CheckEmptyTrim(diff.Text) + colorReset + case diffmatchpatch.DiffDelete: + prefix = colorRed + "-" + CheckEmptyTrim(diff.Text) + colorReset + case diffmatchpatch.DiffEqual: + prefix = colorWhite + CheckEmptyTrim(diff.Text) + colorReset + } + parts = append(parts, prefix) + } + diffString := strings.Join(parts, "") + return diffString +} + +func FormatDiffs(diffs []diffmatchpatch.Diff) string { + + var parts []string + // Precompile color codes into variables + colorGreen := "[green]" + colorRed := "[red]" + colorWhite := "[white]" + + for _, diff := range diffs { + var prefix string + switch diff.Type { + case diffmatchpatch.DiffInsert: + prefix = colorGreen + "+" + CheckEmptyTrim(diff.Text) + case diffmatchpatch.DiffDelete: + prefix = colorRed + "-" + CheckEmptyTrim(diff.Text) + case diffmatchpatch.DiffEqual: + prefix = colorWhite + CheckEmptyTrim(diff.Text) + } + parts = append(parts, prefix) + } + diffString := strings.Join(parts, "") + return diffString +} + +func compareNodes(dmp *diffmatchpatch.DiffMatchPatch, nodeOne, nodeTwo Node) (Node, []string, error) { + var diffs []string + var namespaceBig, namespaceSmall []model.AllPkgTreeNamespacesPackageNamespace + + var namesBig, namesSmall []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageName + var versionBig, versionSmall []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion + var qualifierBig, qualifierSmall []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersionQualifiersPackageQualifier + + diffedNode := Node{} + + switch nodeOne.NodeType { + + case "Package": + diffedNode.NodeType = "Package" + + nOne := nodeOne.Pkg + + nTwo := nodeTwo.Pkg + + if nodeOne.ID == nodeTwo.ID { + return diffedNode, []string{}, nil + } + + if nOne.Type != nTwo.Type { + diffs = append(diffs, "Type: "+nOne.Type+" != "+nTwo.Type) + diffedNode.Pkg.Type = ComputeStringDiffs(dmp, nOne.Type, nTwo.Type) + } + + sort.Sort(packageNameSpaces(nOne.Namespaces)) + sort.Sort(packageNameSpaces(nTwo.Namespaces)) + + if len(nTwo.Namespaces) > len(nOne.Namespaces) { + namespaceBig = nTwo.Namespaces + namespaceSmall = nOne.Namespaces + } else if len(nTwo.Namespaces) < len(nOne.Namespaces) { + namespaceBig = nOne.Namespaces + namespaceSmall = nTwo.Namespaces + } else { + namespaceBig = nTwo.Namespaces + namespaceSmall = nOne.Namespaces + } + + diffedNode.Pkg.Namespaces = namespaceSmall + + // Compare namespaces + for i, namespace1 := range namespaceBig { + if i >= len(namespaceSmall) { + + diffs = append(diffs, fmt.Sprintf("Namespace %s not present", namespace1.Namespace)) + diffedNode.Pkg.Namespaces = append(diffedNode.Pkg.Namespaces, DiffMissingNamespace(dmp, namespace1)) + continue + } + namespace2 := namespaceSmall[i] + + sort.Sort(packageNameSpacesNames(namespace1.Names)) + sort.Sort(packageNameSpacesNames(namespace2.Names)) + + // Compare namespace fields + if namespace1.Namespace != namespace2.Namespace { + diffs = append(diffs, fmt.Sprintf("Namespace %s != %s", namespace1.Namespace, namespace2.Namespace)) + diffedNode.Pkg.Namespaces[i].Namespace = ComputeStringDiffs(dmp, namespace1.Namespace, namespace2.Namespace) + } + + if len(namespace1.Names) > len(namespace2.Names) { + namesBig = namespace1.Names + namesSmall = namespace2.Names + } else if len(namespace1.Names) < len(namespace2.Names) { + namesBig = namespace2.Names + namesSmall = namespace1.Names + } else { + namesBig = namespace1.Names + namesSmall = namespace2.Names + } + + diffedNode.Pkg.Namespaces[i].Names = namesSmall + + // Compare names + for j, name1 := range namesBig { + + if j >= len(namesSmall) { + diffs = append(diffs, fmt.Sprintf("Name %s not present in namespace %s", name1.Name, namespace1.Namespace)) + diffedNode.Pkg.Namespaces[i].Names = append(diffedNode.Pkg.Namespaces[i].Names, DiffMissingName(dmp, name1)) + continue + } + + name2 := namesSmall[j] + + sort.Sort(packageNameSpacesNamesVersions(name1.Versions)) + sort.Sort(packageNameSpacesNamesVersions(name2.Versions)) + + // Compare name fields + if name1.Name != name2.Name { + diffs = append(diffs, fmt.Sprintf("Name %s != %s in Namespace %s", name1.Name, name2.Name, namespace1.Namespace)) + diffedNode.Pkg.Namespaces[i].Names[j].Name = ComputeStringDiffs(dmp, name1.Name, name2.Name) + } + + if len(name1.Versions) > len(name2.Versions) { + versionBig = name1.Versions + versionSmall = name2.Versions + } else if len(name1.Versions) < len(name2.Versions) { + versionBig = name2.Versions + versionSmall = name1.Versions + } else { + versionBig = name1.Versions + versionSmall = name2.Versions + } + + diffedNode.Pkg.Namespaces[i].Names[j].Versions = versionSmall + + // Compare versions + for k, version1 := range versionBig { + + if k >= len(versionSmall) { + diffs = append(diffs, fmt.Sprintf("Version %s not present for name %s in namespace %s,", version1.Version, name1.Name, namespace1.Namespace)) + diffedNode.Pkg.Namespaces[i].Names[j].Versions = append(diffedNode.Pkg.Namespaces[i].Names[j].Versions, DiffMissingVersion(dmp, version1)) + continue + } + + version2 := versionSmall[k] + sort.Sort(packageNameSpacesNamesVersionsQualifiers(version1.Qualifiers)) + sort.Sort(packageNameSpacesNamesVersionsQualifiers(version2.Qualifiers)) + + if version1.Version != version2.Version { + diffs = append(diffs, fmt.Sprintf("Version %s != %s for name %s in namespace %s", version1.Version, version2.Version, name1.Name, namespace1.Namespace)) + diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Version = ComputeStringDiffs(dmp, version1.Version, version2.Version) + } + + if version1.Subpath != version2.Subpath { + diffs = append(diffs, fmt.Sprintf("Subpath %s != %s for version %s for name %s in namespace %s", version1.Subpath, version2.Subpath, version1.Version, name1.Name, namespace1.Namespace)) + diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Subpath = ComputeStringDiffs(dmp, version1.Subpath, version2.Subpath) + } + + if len(version1.Qualifiers) > len(version2.Qualifiers) { + qualifierBig = version1.Qualifiers + qualifierSmall = version2.Qualifiers + } else if len(version1.Qualifiers) < len(version2.Qualifiers) { + qualifierBig = version2.Qualifiers + qualifierSmall = version1.Qualifiers + } else { + qualifierBig = version1.Qualifiers + qualifierSmall = version2.Qualifiers + } + + diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Qualifiers = qualifierSmall + + for l, qualifier1 := range qualifierBig { + + if l >= len(qualifierSmall) { + diffs = append(diffs, fmt.Sprintf("Qualifier %s:%s not present for version %s in name %s in namespace %s,", qualifier1.Key, qualifier1.Value, version1.Version, name1.Name, namespace1.Namespace)) + diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Qualifiers = append(diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Qualifiers, DiffMissingQualifier(dmp, qualifier1)) + continue + } + + qualifier2 := qualifierSmall[l] + + if qualifier2.Key != qualifier1.Key { + diffs = append(diffs, fmt.Sprintf("Qualifier key unequal for version %s in name %s in namespace %s: %s:%s | %s:%s", version1.Version, name1.Name, namespace1.Namespace, qualifier1.Key, qualifier1.Value, qualifier2.Key, qualifier2.Value)) + diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Qualifiers[l].Key = ComputeStringDiffs(dmp, qualifier1.Key, qualifier2.Key) + } + + if qualifier1.Value != qualifier2.Value { + diffs = append(diffs, fmt.Sprintf("Qualifier value unequal for version %s in name %s in namespace %s: %s:%s | %s:%s", version1.Version, name1.Name, namespace1.Namespace, qualifier1.Key, qualifier1.Value, qualifier2.Key, qualifier2.Value)) + diffedNode.Pkg.Namespaces[i].Names[j].Versions[k].Qualifiers[l].Value = ComputeStringDiffs(dmp, qualifier1.Value, qualifier2.Value) + } + } + } + } + } + case "DependencyPackage": + + diffedNode.NodeType = "DependencyPackage" + nOne := nodeOne.DepPkg + + nTwo := nodeTwo.DepPkg + + if nodeOne.ID == nodeTwo.ID { + return diffedNode, []string{}, nil + } + + if nOne.Type != nTwo.Type { + diffs = append(diffs, "Type: "+nOne.Type+" != "+nTwo.Type) + diffedNode.DepPkg.Type = ComputeStringDiffs(dmp, nOne.Type, nTwo.Type) + } + + sort.Sort(packageNameSpaces(nOne.Namespaces)) + sort.Sort(packageNameSpaces(nTwo.Namespaces)) + + if len(nTwo.Namespaces) > len(nOne.Namespaces) { + namespaceBig = nTwo.Namespaces + namespaceSmall = nOne.Namespaces + } else if len(nTwo.Namespaces) < len(nOne.Namespaces) { + namespaceBig = nOne.Namespaces + namespaceSmall = nTwo.Namespaces + } else { + namespaceBig = nTwo.Namespaces + namespaceSmall = nOne.Namespaces + } + + diffedNode.DepPkg.Namespaces = namespaceSmall + + // Compare namespaces + for i, namespace1 := range namespaceBig { + if i >= len(namespaceSmall) { + diffs = append(diffs, fmt.Sprintf("Namespace %s not present", namespace1.Namespace)) + diffedNode.DepPkg.Namespaces = append(diffedNode.DepPkg.Namespaces, DiffMissingNamespace(dmp, namespace1)) + continue + } + namespace2 := namespaceSmall[i] + + sort.Sort(packageNameSpacesNames(namespace1.Names)) + sort.Sort(packageNameSpacesNames(namespace2.Names)) + + // Compare namespace fields + if namespace1.Namespace != namespace2.Namespace { + diffs = append(diffs, fmt.Sprintf("Namespace %s != %s", namespace1.Namespace, namespace2.Namespace)) + diffedNode.DepPkg.Namespaces[i].Namespace = ComputeStringDiffs(dmp, namespace1.Namespace, namespace2.Namespace) + } + + if len(namespace1.Names) > len(namespace2.Names) { + namesBig = namespace1.Names + namesSmall = namespace2.Names + } else if len(namespace1.Names) < len(namespace2.Names) { + namesBig = namespace2.Names + namesSmall = namespace1.Names + } else { + namesBig = namespace1.Names + namesSmall = namespace2.Names + } + + diffedNode.DepPkg.Namespaces[i].Names = namesSmall + + // Compare names + for j, name1 := range namesBig { + + if j >= len(namesSmall) { + diffs = append(diffs, fmt.Sprintf("Name %s not present in namespace %s", name1.Name, namespace1.Namespace)) + diffedNode.DepPkg.Namespaces[i].Names = append(diffedNode.DepPkg.Namespaces[i].Names, DiffMissingName(dmp, name1)) + continue + } + name2 := namesSmall[j] + + sort.Sort(packageNameSpacesNamesVersions(name1.Versions)) + sort.Sort(packageNameSpacesNamesVersions(name2.Versions)) + + // Compare name fields + if name1.Name != name2.Name { + diffs = append(diffs, fmt.Sprintf("Name %s != %s in Namespace %s", name1.Name, name2.Name, namespace1.Namespace)) + diffedNode.DepPkg.Namespaces[i].Names[j].Name = ComputeStringDiffs(dmp, name1.Name, name2.Name) + } + + if len(name1.Versions) > len(name2.Versions) { + versionBig = name1.Versions + versionSmall = name2.Versions + } else if len(name1.Versions) < len(name2.Versions) { + versionBig = name2.Versions + versionSmall = name1.Versions + } else { + versionBig = name1.Versions + versionSmall = name2.Versions + } + + diffedNode.DepPkg.Namespaces[i].Names[j].Versions = versionSmall + + // Compare versions + for k, version1 := range versionBig { + if k >= len(versionSmall) { + diffs = append(diffs, fmt.Sprintf("Version %s not present for name %s in namespace %s,", version1.Version, name1.Name, namespace1.Namespace)) + diffedNode.DepPkg.Namespaces[i].Names[j].Versions = append(diffedNode.DepPkg.Namespaces[i].Names[j].Versions, DiffMissingVersion(dmp, version1)) + continue + } + + version2 := versionSmall[k] + sort.Sort(packageNameSpacesNamesVersionsQualifiers(version1.Qualifiers)) + sort.Sort(packageNameSpacesNamesVersionsQualifiers(version2.Qualifiers)) + + if version1.Version != version2.Version { + diffs = append(diffs, fmt.Sprintf("Version %s != %s for name %s in namespace %s", version1.Version, version2.Version, name1.Name, namespace1.Namespace)) + diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Version = ComputeStringDiffs(dmp, version1.Version, version2.Version) + } + + if version1.Subpath != version2.Subpath { + diffs = append(diffs, fmt.Sprintf("Subpath %s != %s for version %s for name %s in namespace %s", version1.Subpath, version2.Subpath, version1.Version, name1.Name, namespace1.Namespace)) + diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Subpath = ComputeStringDiffs(dmp, version1.Subpath, version2.Subpath) + } + + if len(version1.Qualifiers) > len(version2.Qualifiers) { + qualifierBig = version1.Qualifiers + qualifierSmall = version2.Qualifiers + } else if len(version1.Qualifiers) < len(version2.Qualifiers) { + qualifierBig = version2.Qualifiers + qualifierSmall = version1.Qualifiers + } else { + qualifierBig = version1.Qualifiers + qualifierSmall = version2.Qualifiers + } + + diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Qualifiers = qualifierSmall + + for l, qualifier1 := range qualifierBig { + if l >= len(qualifierSmall) { + diffs = append(diffs, fmt.Sprintf("Qualifier %s:%s not present for version %s in name %s in namespace %s,", qualifier1.Key, qualifier1.Value, version1.Version, name1.Name, namespace1.Namespace)) + diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Qualifiers = append(diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Qualifiers, DiffMissingQualifier(dmp, qualifier1)) + continue + } + + qualifier2 := qualifierSmall[l] + + if qualifier2.Key != qualifier1.Key { + diffs = append(diffs, fmt.Sprintf("Qualifier key unequal for version %s in name %s in namespace %s: %s:%s | %s:%s", version1.Version, name1.Name, namespace1.Namespace, qualifier1.Key, qualifier1.Value, qualifier2.Key, qualifier2.Value)) + diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Qualifiers[l].Key = ComputeStringDiffs(dmp, qualifier1.Key, qualifier2.Key) + } + + if qualifier1.Value != qualifier2.Value { + diffs = append(diffs, fmt.Sprintf("Qualifier value unequal for version %s in name %s in namespace %s: %s:%s | %s:%s", version1.Version, name1.Name, namespace1.Namespace, qualifier1.Key, qualifier1.Value, qualifier2.Key, qualifier2.Value)) + diffedNode.DepPkg.Namespaces[i].Names[j].Versions[k].Qualifiers[l].Value = ComputeStringDiffs(dmp, qualifier1.Value, qualifier2.Value) + } + } + } + } + } + } + return diffedNode, diffs, nil +} +func CompareTwoPaths(dmp *diffmatchpatch.DiffMatchPatch, analysisListOne, analysisListTwo []*Node) ([]Node, [][]string, int, error) { + var longerPath, shorterPath []*Node + + if len(analysisListOne) > len(analysisListTwo) { + longerPath = analysisListOne + shorterPath = analysisListTwo + } else { + longerPath = analysisListTwo + shorterPath = analysisListOne + } + + pathDiff := make([][]string, len(longerPath)) + nodesDiff := make([]Node, len(longerPath)) + var diffCount int + + var wg sync.WaitGroup + var mu sync.Mutex + errChan := make(chan error, len(longerPath)) // Buffer channel to hold errors + + for i, node := range longerPath { + wg.Add(1) + go func(i int, node *Node) { + defer wg.Done() + + var diffs []string + var diffNode Node + var err error + + if i >= len(shorterPath) { + dumnode := &Node{} + if node.NodeType == "Package" { + dumnode.NodeType = "Package" + dumnode.Pkg = model.AllIsDependencyTreePackage{} + } else if node.NodeType == "DependencyPackage" { + dumnode.NodeType = "DependencyPackage" + dumnode.DepPkg = model.AllIsDependencyTreeDependencyPackage{} + } + + diffNode, diffs, err = compareNodes(dmp, *node, *dumnode) + } else { + diffNode, diffs, err = compareNodes(dmp, *node, *shorterPath[i]) + } + + if err != nil { + errChan <- err + return + } + + mu.Lock() + pathDiff[i] = diffs + nodesDiff[i] = diffNode + diffCount += len(diffs) + mu.Unlock() + }(i, node) + } + + wg.Wait() + close(errChan) // Close the channel after all goroutines are done + + // Check for errors + if len(errChan) != 0 { + return nodesDiff, pathDiff, 0, fmt.Errorf("could not diff node") + } + + return nodesDiff, pathDiff, diffCount, nil +} + +func CompareAllPaths(listOne, listTwo [][]*Node) (DiffResult, error) { + + var small, big [][]*Node + if len(listOne) > len(listTwo) { + small = listTwo + big = listOne + } else if len(listTwo) > len(listOne) { + small = listOne + big = listTwo + } else { + small = listTwo + big = listOne + } + + var pathResults []DiffedPath + nodeResults := make(map[string]DiffedNodePair) + + used := make(map[int]bool) + + for _, pathOne := range small { + + var pathDiff DiffedPath + + pathDiff.PathOne = pathOne + min := math.MaxInt32 + var index int + diffIndices := []EqualDifferencesPaths{} + + for i, pathTwo := range big { + _, ok := used[i] + if ok { + continue + } + dmp := diffmatchpatch.New() + + nodeDiffs, diffs, diffNum, err := CompareTwoPaths(dmp, pathOne, pathTwo) + + if err != nil { + return DiffResult{}, fmt.Errorf("error comparing paths %v", err.Error()) + } + + if diffNum < min { + pathDiff.PathTwo = pathTwo + min = diffNum + pathDiff.Diffs = diffs + pathDiff.NodeDiffs = nodeDiffs + index = i + diffIndices = []EqualDifferencesPaths{{Diffs: diffs, Path: pathTwo, Index: i}} + } else if diffNum == min { + diffIndices = append(diffIndices, EqualDifferencesPaths{Diffs: diffs, Path: pathTwo, Index: i}) + } + } + + if len(diffIndices) == 1 { + used[index] = true + } + + count := 0 + seenNodeIndex := -1 + + //find if there is only one node causing the paths to differ. If yes, then mark it in the seen map. + for k, list := range pathDiff.Diffs { + if len(list) > 0 { + count++ + seenNodeIndex = k + } + } + + if count == 1 { + key := "" + if _, exists := nodeResults[pathDiff.PathOne[seenNodeIndex].ID+pathDiff.PathTwo[seenNodeIndex].ID]; exists { + key = pathDiff.PathOne[seenNodeIndex].ID + pathDiff.PathTwo[seenNodeIndex].ID + nodeResults[key] = DiffedNodePair{NodeOne: pathDiff.PathOne[seenNodeIndex], NodeTwo: pathDiff.PathTwo[seenNodeIndex], Count: nodeResults[key].Count + 1} + + } else if _, exists := nodeResults[pathDiff.PathTwo[seenNodeIndex].ID+pathDiff.PathOne[seenNodeIndex].ID]; exists { + key = pathDiff.PathTwo[seenNodeIndex].ID + pathDiff.PathOne[seenNodeIndex].ID + nodeResults[key] = DiffedNodePair{NodeOne: pathDiff.PathOne[seenNodeIndex], NodeTwo: pathDiff.PathTwo[seenNodeIndex], Count: nodeResults[key].Count + 1} + + } else { + key = pathDiff.PathTwo[seenNodeIndex].ID + pathDiff.PathOne[seenNodeIndex].ID + nodeResults[key] = DiffedNodePair{NodeOne: pathDiff.PathOne[seenNodeIndex], NodeTwo: pathDiff.PathTwo[seenNodeIndex], Count: 1} + } + continue + } + + pathResults = append(pathResults, pathDiff) + } + + for i, val := range big { + _, ok := used[i] + if !ok { + + //diff each missing path and append to result + var missingPath []Node + for _, node := range val { + dumnode := &Node{} + if node.NodeType == "Package" { + dumnode.NodeType = "Package" + dumnode.Pkg = model.AllIsDependencyTreePackage{} + } else if node.NodeType == "DependencyPackage" { + dumnode.NodeType = "DependencyPackage" + dumnode.DepPkg = model.AllIsDependencyTreeDependencyPackage{} + } + dmp := diffmatchpatch.New() + diffNode, _, err := compareNodes(dmp, *node, *dumnode) + if err != nil { + return DiffResult{}, fmt.Errorf("error comparing nodes %v", err.Error()) + } + missingPath = append(missingPath, diffNode) + + } + pathResults = append(pathResults, DiffedPath{PathOne: val, NodeDiffs: missingPath}) + } + } + return DiffResult{Paths: pathResults, Nodes: nodeResults}, nil +} diff --git a/pkg/analyzer/analyzer_path_explosion.go b/pkg/analyzer/analyzer_path_explosion.go new file mode 100644 index 0000000000..c2ed585907 --- /dev/null +++ b/pkg/analyzer/analyzer_path_explosion.go @@ -0,0 +1,187 @@ +package analyzer + +import ( + "fmt" + + "github.com/dominikbraun/graph" +) + +func GetLeafNodes(adjMap map[string]map[string]graph.Edge[string]) map[string]bool { + leafNodes := make(map[string]bool) + for i, nodeMap := range adjMap { + if len(nodeMap) == 0 { + leafNodes[i] = true + } + } + return leafNodes +} + +func GetPredecessor(adjMap map[string]map[string]graph.Edge[string], id string) map[string]bool { + predecessors := make(map[string]bool) + for i, nodeMap := range adjMap { + _, ok := nodeMap[id] + if ok { + predecessors[i] = true + } + } + return predecessors +} + +func recursiveSubgraphIncrease(adjMapOne, adjMapTwo map[string]map[string]graph.Edge[string], currentNodeId string) []string { + predecessorsOne := GetPredecessor(adjMapOne, currentNodeId) + predecessorsTwo := GetPredecessor(adjMapTwo, currentNodeId) + nodesToRemove := []string{} + + if len(predecessorsOne) == 1 && len(predecessorsTwo) == 1 { + nodesToRemove = append(nodesToRemove, currentNodeId) + } + + if len(predecessorsOne) != len(predecessorsTwo) { + //cannot proceed ahead with recursion + return nodesToRemove + } + + predecessorsToRemove := []string{} + + //now check all nodes are the same and start recursion + for val := range predecessorsOne { + _, ok := predecessorsTwo[val] + if !ok { + return nodesToRemove + } + if val == "HasSBOM" { + continue + } + predecessorsToRemove = append(predecessorsToRemove, recursiveSubgraphIncrease(adjMapOne, adjMapTwo, val)...) + } + + //add currentNode predecessors + if len(predecessorsOne) != 1 && len(predecessorsToRemove) != 0 { + for predecessor := range predecessorsOne { + nodesToRemove = append(nodesToRemove, predecessor) + } + } + + nodesToRemove = append(nodesToRemove, predecessorsToRemove...) + return nodesToRemove +} + +func CompressGraphs(g1, g2 graph.Graph[string, *Node]) (graph.Graph[string, *Node], graph.Graph[string, *Node], error) { + gOneAdjacencyMap, errOne := g1.AdjacencyMap() + + gTwoAdjacencyMap, errTwo := g2.AdjacencyMap() + + if errOne != nil || errTwo != nil { + return g1, g2, fmt.Errorf("error getting graph adjacency list") + } + + gOneLeafNodes := GetLeafNodes(gOneAdjacencyMap) + gTwoLeafNodes := GetLeafNodes(gTwoAdjacencyMap) + + var small, big map[string]bool + var smallMap, bigMap map[string]map[string]graph.Edge[string] + var nodesToRemove []string + if len(gOneLeafNodes) < len(gTwoLeafNodes) { + small = gOneLeafNodes + big = gTwoLeafNodes + smallMap = gOneAdjacencyMap + bigMap = gTwoAdjacencyMap + } else if len(gOneLeafNodes) > len(gTwoLeafNodes) { + big = gOneLeafNodes + small = gTwoLeafNodes + + bigMap = gOneAdjacencyMap + smallMap = gTwoAdjacencyMap + } else { + small = gOneLeafNodes + big = gTwoLeafNodes + + smallMap = gOneAdjacencyMap + bigMap = gTwoAdjacencyMap + } + + for smallId := range small { + _, ok := big[smallId] + if ok { + //go upwards + nodesToRemove = append(nodesToRemove, recursiveSubgraphIncrease(smallMap, bigMap, smallId)...) + } + } + + //REMOVE OUTGOING EDGES & REMOVE INCOMING EDGES & REMOVE NODES + for _, val := range nodesToRemove { + + nodeMapOne, ok := gOneAdjacencyMap[val] + if !ok { + return g1, g2, fmt.Errorf("node to delete not found in nodeMapOne") + } + + nodeMapTwo, ok := gTwoAdjacencyMap[val] + if !ok { + return g1, g2, fmt.Errorf("node to delete not found in nodeMapTwo") + } + + //delete incoming edges to node one + + for _, nodeMapOne := range gOneAdjacencyMap { + + if len(nodeMapOne) == 0 { + continue + } + + for to, edge := range nodeMapOne { + if to == val { + errOne := g1.RemoveEdge(edge.Source, edge.Target) + if errOne != nil { + return g1, g2, fmt.Errorf("(in1) unable to delete edge from graph %v", errOne) + } + } + } + } + + //delete incoming edges to node two + for _, nodeMapTwo := range gTwoAdjacencyMap { + + if len(nodeMapTwo) == 0 { + continue + } + + for to, edge := range nodeMapTwo { + if to == val { + errTwo := g2.RemoveEdge(edge.Source, edge.Target) + if errTwo != nil { + return g1, g2, fmt.Errorf("(in2) unable to delete edge from graph %v", errTwo) + } + } + } + } + + //delete outgoing nodes from graph one + if len(nodeMapOne) != 0 { + for _, edge := range nodeMapOne { + errOne := g1.RemoveEdge(edge.Source, edge.Target) + if errOne != nil { + return g1, g2, fmt.Errorf("(out1) unable to delete edge from graph %v", errOne) + } + } + } + + //delete outgoing nodes from graph two + if len(nodeMapTwo) != 0 { + for _, edge := range nodeMapTwo { + errTwo := g2.RemoveEdge(edge.Source, edge.Target) + if errTwo != nil { + return g1, g2, fmt.Errorf("(out2) unable to delete edge from graph %v", errTwo) + } + } + } + + errOne := g1.RemoveVertex(val) + errTwo := g2.RemoveVertex(val) + + if errOne != nil || errTwo != nil { + return g1, g2, fmt.Errorf("unable to delete node from graph %v %v", errOne, errTwo) + } + } + return g1, g2, nil +} diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go new file mode 100644 index 0000000000..5346d5e87c --- /dev/null +++ b/pkg/analyzer/analyzer_test.go @@ -0,0 +1,133 @@ +// +// 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 analyzer_test + +import ( + "encoding/json" + + "io" + "net/http" + + "testing" + + "github.com/dominikbraun/graph" + analyzer "github.com/guacsec/guac/pkg/analyzer" + model "github.com/guacsec/guac/pkg/assembler/clients/generated" +) + +var diffTestFile = "https://raw.githubusercontent.com/guacsec/guac-test/main/hasSbom-pairs/hasSBOM-syft-spdx-k8s.gcr.io-kube-apiserver.v1.24.1.json" + +func readTestFileFromHub(fileUrl string) ([]byte, error) { + + resp, err := http.Get(fileUrl) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil +} + +func TestHighlightAnalysis(t *testing.T) { + + data, err := readTestFileFromHub(diffTestFile) + if err != nil { + t.Errorf("error reading test JSON") + } + + var sboms []model.HasSBOMsHasSBOM + + err = json.Unmarshal(data, &sboms) + if err != nil { + t.Errorf("error unmarshaling JSON") + } + + graphOne, errOne := analyzer.MakeGraph(sboms[0], false, false, false, false, false) + + graphTwo, errTwo := analyzer.MakeGraph(sboms[1], false, false, false, false, false) + + if errOne != nil || errTwo != nil { + t.Errorf("error making graph %v %v", errOne.Error(), errTwo.Error()) + } + + one, two, err := analyzer.HighlightAnalysis(graphOne, graphTwo, 0) + + if err != nil { + t.Errorf("error highlighting diff %v", err.Error()) + } + if len(one) == 0 || len(two) == 0 { + t.Errorf("error highlighting diff, wanted diffs got 0") + } +} + +func TestAddGraphNode(t *testing.T) { + g := graph.New(analyzer.NodeHash, graph.Directed()) + analyzer.AddGraphNode(g, "id", "black") + _, err := g.Vertex("id") + if err != nil { + t.Errorf("error adding node with id 'id': %v", err) + } +} + +func TestAddGraphEdge(t *testing.T) { + g := graph.New(analyzer.NodeHash, graph.Directed()) + analyzer.AddGraphEdge(g, "from", "to", "black") + + _, err := g.Edge("from", "to") + if err != nil { + t.Errorf("error getting edge from %s to %s: %v", "from", "to", err) + } +} + +func TestEquivalence(t *testing.T) { + data, err := readTestFileFromHub(diffTestFile) + if err != nil { + t.Errorf("error reading test file %v", err.Error()) + } + + var sboms []model.HasSBOMsHasSBOM + + err = json.Unmarshal(data, &sboms) + if err != nil { + t.Errorf("Error unmarshaling JSON") + } + + for _, val := range sboms { + graphOne, errOne := analyzer.MakeGraph(val, false, false, false, false, false) + + graphTwo, errTwo := analyzer.MakeGraph(val, false, false, false, false, false) + + if errOne != nil || errTwo != nil { + t.Errorf("error making graph %v %v", errOne.Error(), errTwo.Error()) + } + + ok, err := analyzer.GraphEqual(graphOne, graphTwo) + if !ok { + t.Errorf("reconstructed graph not equal %v", err.Error()) + } + + ok, err = analyzer.GraphEdgesEqual(graphOne, graphTwo) + if !ok { + t.Errorf("reconstructed graph edges not equal %v", err.Error()) + } + } + +} diff --git a/pkg/analyzer/print_analysis.go b/pkg/analyzer/print_analysis.go new file mode 100644 index 0000000000..3bb2487a0e --- /dev/null +++ b/pkg/analyzer/print_analysis.go @@ -0,0 +1,352 @@ +package analyzer + +import ( + "fmt" + "sort" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const ( + colMinWidth = 50 + trimLength = 150 +) + +func GetNodeString(node Node) (string, error) { + switch node.NodeType { + + case "Package": + pkg := node.Pkg + sort.Sort(packageNameSpaces(pkg.Namespaces)) + message := "Type:" + pkg.Type + "\n" + for _, namespace := range pkg.Namespaces { + if namespace.Namespace == "" { + continue + } + message += "Namespace: " + namespace.Namespace + "\n" + + for _, name := range namespace.Names { + if name.Name == "" { + continue + } + message += "\t" + message += "Name: " + name.Name + message += "\n" + + for _, version := range name.Versions { + if version.Version == "" { + continue + } + message += "\t\t" + message += "Version: " + version.Version + "\n" + message += "\t\t" + message += "Subpath: " + version.Subpath + "\n" + message += "\t\tQualifiers: {\n" + + for _, outlier := range version.Qualifiers { + if outlier.Key == "" { + continue + } + message += "\t\t\t" + message += outlier.Key + ": " + outlier.Value + "\n" + } + message += "\t\t}\n" + } + } + message += "\n" + } + return message, nil + case "DependencyPackage": + depPkg := node.DepPkg + message := "Type:" + depPkg.Type + "\n" + for _, namespace := range depPkg.Namespaces { + if namespace.Namespace == "" { + continue + } + message += "Namespace: " + namespace.Namespace + "\n" + + for _, name := range namespace.Names { + if name.Name == "" { + continue + } + message += "\t" + message += "Name: " + name.Name + message += "\n" + + for _, version := range name.Versions { + if version.Version == "" { + continue + } + message += "\t\t" + message += "Version: " + version.Version + "\n" + message += "\t\t" + message += "Subpath: " + version.Subpath + "\n" + message += "\t\tQualifiers: {\n" + + for _, outlier := range version.Qualifiers { + if outlier.Key == "" { + continue + } + message += "\t\t\t" + message += outlier.Key + ": " + outlier.Value + "\n" + } + message += "\t\t}\n" + } + } + + message += "\n" + } + return message, nil + + } + return "", nil +} + +func CheckEmptyTrim(value string) string { + if len(value) > trimLength { + return value[:trimLength] + "..." + } + if value == "" { + return "\"\"" + } + return value +} + +func PrintPathTable(header string, analysisOne, analysisTwo [][]*Node) error { + app := tview.NewApplication() + + // Create a scrollable Table + table := tview.NewTable(). + SetBorders(true) // Add borders to the table for readability + + // Add Header Row + table.SetCell(0, 0, tview.NewTableCell(header). + SetTextColor(tcell.ColorYellow). + SetAlign(tview.AlignCenter). + SetSelectable(false)) // Header is not selectable + + rowIndex := 1 // Start adding rows below the header + + // Function to add paths to the table + addPathsToTable := func(paths [][]*Node, startRowIndex int) (int, error) { + rowIndex := startRowIndex + + for _, path := range paths { + columnIndex := 0 + rowStart := rowIndex + for i, node := range path { + s, err := GetNodeString(*node) + if err != nil { + return rowIndex, fmt.Errorf("unable to process node: %v", err) + } + + // Split the string by newline and handle tabs + lines := strings.Split(s, "\n") + for _, line := range lines { + // Replace tabs with spaces for better alignment + formattedLine := strings.ReplaceAll(line, "\t", " ") + table.SetCell(rowIndex, columnIndex, tview.NewTableCell(formattedLine). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignLeft). + SetSelectable(true)) + rowIndex++ + } + + if i != len(path)-1 { + table.SetCell(rowStart, columnIndex+1, tview.NewTableCell("--->"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + columnIndex++ + } + + if i != len(path)-1 { + rowIndex = rowStart + } + + columnIndex++ + } + + } + return rowIndex, nil + } + + // Add Analysis One Paths + var err error + rowIndex, err = addPathsToTable(analysisOne, rowIndex) + if err != nil { + return err + } + + // Add Analysis Two Paths + _, err = addPathsToTable(analysisTwo, rowIndex) + if err != nil { + return err + } + + // Enable both horizontal and vertical scrolling + table.SetFixed(1, 0).SetSelectable(true, true) + + // Handle quit events + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEsc, tcell.KeyCtrlC: // Exit on Esc or Ctrl+C + app.Stop() + } + return event + }) + + // Set up the application root with the table + if err := app.SetRoot(table, true).Run(); err != nil { + return fmt.Errorf("error running table application: %v", err) + } + + return nil +} + +func PrintAnalysis(diffs DiffResult) error { + app := tview.NewApplication() + table := tview.NewTable(). + SetBorders(true) + + rowIndex := 0 + + // Add Node Differences if present + if len(diffs.Nodes) > 0 { + table.SetCell(rowIndex, 0, tview.NewTableCell("Node Analysis"). + SetTextColor(tcell.ColorYellow). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + rowIndex++ + + // Add Node Differences Data + for _, diff := range diffs.Nodes { + nodeOneStr, err := GetNodeString(*diff.NodeOne) + if err != nil { + return fmt.Errorf("error processing node one: %v", err) + } + + nodeTwoStr, err := GetNodeString(*diff.NodeTwo) + if err != nil { + return fmt.Errorf("error processing node two: %v", err) + } + + nodeOneLines := strings.Split(nodeOneStr, "\n") + nodeTwoLines := strings.Split(nodeTwoStr, "\n") + + // Determine the maximum lines for alignment + maxLines := len(nodeOneLines) + if len(nodeTwoLines) > maxLines { + maxLines = len(nodeTwoLines) + } + + // Print each line, padding shorter sections with empty strings + for i := 0; i < maxLines; i++ { + // Get the current line or an empty string if out of bounds + nodeOneLine := "" + if i < len(nodeOneLines) { + nodeOneLine = strings.ReplaceAll(nodeOneLines[i], "\t", " ") + } + + nodeTwoLine := "" + if i < len(nodeTwoLines) { + nodeTwoLine = strings.ReplaceAll(nodeTwoLines[i], "\t", " ") + } + + // Add cells for the row + table.SetCell(rowIndex, 0, tview.NewTableCell(nodeOneLine). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignLeft). + SetSelectable(false)) + if i == 0 { + table.SetCell(rowIndex, 1, tview.NewTableCell("<-->"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + } else { + table.SetCell(rowIndex, 1, tview.NewTableCell(""). + SetSelectable(false)) // Empty for alignment + } + table.SetCell(rowIndex, 2, tview.NewTableCell(nodeTwoLine). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignLeft). + SetSelectable(false)) + rowIndex++ + } + + // Add the count on the same row as the connector, only once + table.SetCell(rowIndex-maxLines, 3, tview.NewTableCell(fmt.Sprintf("%v", diff.Count)). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + } + } + + // Add Path Differences if present + if len(diffs.Paths) > 0 { + if rowIndex > 0 { + rowIndex++ // Add spacing between sections + } + table.SetCell(rowIndex, 0, tview.NewTableCell("Path Analysis"). + SetTextColor(tcell.ColorYellow). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + rowIndex++ + + // Add Path Differences Data + for _, diff := range diffs.Paths { + columnIndex := 0 + rowStart := rowIndex + + for i, node := range diff.NodeDiffs { + nodeStr, err := GetNodeString(node) + if err != nil { + return fmt.Errorf("error processing path node: %v", err) + } + + // Split the content into lines + lines := strings.Split(nodeStr, "\n") + for _, line := range lines { + // Replace tabs with spaces + formattedLine := strings.ReplaceAll(line, "\t", " ") + table.SetCell(rowIndex, columnIndex, tview.NewTableCell(formattedLine). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignLeft). + SetSelectable(true)) + rowIndex++ + } + + // Reset row index after multi-line node content + if i != len(diff.NodeDiffs)-1 { + table.SetCell(rowStart, columnIndex+1, tview.NewTableCell("--->"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + columnIndex++ + rowIndex = rowStart + } + columnIndex++ + } + + rowIndex++ // Move to the next row after the path + } + } + + // Enable scrolling and quit handling + table.SetFixed(1, 0).SetSelectable(true, true) + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc || event.Key() == tcell.KeyCtrlC { + app.Stop() + } + return event + }) + + // Run the application + if err := app.SetRoot(table, true).Run(); err != nil { + return fmt.Errorf("error running table application: %v", err) + } + + return nil +} diff --git a/pkg/cli/store.go b/pkg/cli/store.go index 20745123bb..b29a7f840c 100644 --- a/pkg/cli/store.go +++ b/pkg/cli/store.go @@ -137,6 +137,18 @@ func init() { set.String("header-file", "", "a text file containing HTTP headers to send to the GQL server, in RFC 822 format") + // SBOM Analyzer flags + set.StringSlice("analyze-sboms", []string{}, "two sboms to analyze") + set.StringSlice("analyze-slsa", []string{}, "two slsa to analyze") + set.Bool("analyze-uri-input", false, "input is a URI") + set.Bool("analyze-purl-input", false, "input is a pURL") + set.Bool("analyze-id-input", false, "input is an Id") + set.Bool("analyze-metadata", false, "Compare SBOM metadata") + set.Bool("analyze-incl-soft", false, "Compare Included Softwares") + set.Bool("analyze-incl-deps", false, "Compare Included Dependencies") + set.Bool("analyze-incl-occur", false, "Compare Included Occurrences") + set.Bool("analyze-namespaces", false, "Compare Package Namespaces") + set.VisitAll(func(f *pflag.Flag) { flagStore[f.Name] = f })