diff --git a/go/cmd/website/main.go b/go/cmd/website/main.go index 9f671f60645..7e910419da6 100644 --- a/go/cmd/website/main.go +++ b/go/cmd/website/main.go @@ -14,8 +14,13 @@ import ( "syscall" "time" + "cloud.google.com/go/compute/metadata" + "cloud.google.com/go/datastore" + "cloud.google.com/go/storage" + db "github.com/google/osv.dev/go/internal/database/datastore" "github.com/google/osv.dev/go/internal/website" "github.com/google/osv.dev/go/logger" + "github.com/google/osv.dev/go/osv/clients" ) func main() { @@ -59,9 +64,56 @@ func run() error { return fmt.Errorf("failed to load docs filesystem %q: %w", *docsDir, err) } + project := os.Getenv("GOOGLE_CLOUD_PROJECT") + if project == "" { + // Fallback to metadata server for Cloud Run + var err error + project, err = metadata.ProjectIDWithContext(ctx) + if err != nil { + logger.ErrorContext(ctx, "GOOGLE_CLOUD_PROJECT environment variable is not set") + return errors.New("GOOGLE_CLOUD_PROJECT environment variable is not set") + } + } + datastoreID := os.Getenv("DATASTORE_DATABASE_ID") // empty string is the (default) database + dbClient, err := datastore.NewClientWithDatabase(ctx, project, datastoreID, datastore.WithIgnoreFieldMismatch()) + if err != nil { + logger.ErrorContext(ctx, "Failed to create datastore client", slog.Any("error", err)) + return err + } + defer dbClient.Close() + gcsClient, err := storage.NewClient(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to create storage client", slog.Any("error", err)) + return err + } + defer gcsClient.Close() + vulnBucket := os.Getenv("OSV_VULNERABILITIES_BUCKET") + if vulnBucket == "" { + logger.ErrorContext(ctx, "OSV_VULNERABILITIES_BUCKET environment variable is not set") + return errors.New("OSV_VULNERABILITIES_BUCKET environment variable is not set") + } + stores := website.Stores{ + Vuln: db.NewVulnerabilityStore(db.VulnStoreConfig{ + Client: dbClient, + GCS: clients.NewGCSClient(gcsClient, vulnBucket), + }), + Relations: db.NewRelationsStore(dbClient), + SourceRepo: db.NewSourceRepositoryStore(dbClient), + } + + apiURL := os.Getenv("OSV_API_URL") + if apiURL == "" { + apiURL = os.Getenv("API_URL") + } + if apiURL == "" { + apiURL = "api.osv.dev" + } + srv, err := website.NewServer(website.Config{ StaticFS: staticFiles, DocsFS: docsFiles, + Stores: stores, + APIURL: apiURL, }) if err != nil { logger.ErrorContext(ctx, "Failed to create website server", slog.Any("error", err)) diff --git a/go/go.mod b/go/go.mod index ce1ad8923a5..6d7c037f97b 100644 --- a/go/go.mod +++ b/go/go.mod @@ -20,6 +20,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 github.com/klauspost/compress v1.19.0 github.com/microcosm-cc/bluemonday v1.0.27 + github.com/nikolalohinski/gonja/v2 v2.9.0 github.com/ossf/osv-schema/bindings/go v0.0.0-20260806060209-f3f826310aec github.com/package-url/packageurl-go v0.1.6 github.com/pandatix/go-cvss v0.6.2 @@ -83,15 +84,20 @@ require ( github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect @@ -105,6 +111,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/go/go.sum b/go/go.sum index 63d414d4bef..e0a64591889 100644 --- a/go/go.sum +++ b/go/go.sum @@ -38,6 +38,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.58.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.58.0 h1:SBZzZCiPmDrUV7NSCWY54OnKikO/oTydPCvyEyYaDDE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.58.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= @@ -120,6 +122,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -144,10 +148,13 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/osv-scalibr v0.4.5 h1:fiJWZg0jXKzFmJiYKs/BhIzUMYUGs0HT2oUZOoKSL+Q= github.com/google/osv-scalibr v0.4.5/go.mod h1:cNGl//rZ1OcOiFkLXY5DNrhFN7JKMGf4ieQrENUfEZw= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -165,6 +172,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= @@ -188,8 +197,20 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/nikolalohinski/gonja/v2 v2.9.0 h1:QICtNWj0siM3PF5xUjVod/l+C9XnGfI+wrfSIBGKQ6o= +github.com/nikolalohinski/gonja/v2 v2.9.0/go.mod h1:UIzXPVuOsr5h7dZ5DUbqk3/Z7oFA/NLGQGMjqT4L2aU= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/ossf/osv-schema/bindings/go v0.0.0-20260806060209-f3f826310aec h1:A92d74F0MP8hOiPXRnUnuWaluaf3G3sGJfMEcjkZfjA= github.com/ossf/osv-schema/bindings/go v0.0.0-20260806060209-f3f826310aec/go.mod h1:IrUa4QzZUi03J3WXDzZYXVawYipHownNfqqZrqeGXfg= github.com/package-url/packageurl-go v0.1.6 h1:YO3p6u1XmCUliivUg/qWphaY8vI6hxSnnPv7Bfg3m5M= @@ -198,6 +219,8 @@ github.com/pandatix/go-cvss v0.6.2 h1:TFiHlzUkT67s6UkelHmK6s1INKVUG7nlKYiWWDTITG github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -210,6 +233,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -217,6 +242,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -262,6 +288,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -300,6 +328,7 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= @@ -315,6 +344,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= diff --git a/go/internal/api/get_vuln_by_id_test.go b/go/internal/api/get_vuln_by_id_test.go index a2c888a7819..6bd6300dee5 100644 --- a/go/internal/api/get_vuln_by_id_test.go +++ b/go/internal/api/get_vuln_by_id_test.go @@ -35,6 +35,8 @@ func (m *mockVulnerabilityStore) GetFull(_ context.Context, _ string) (*osvschem } type mockRelationsStore struct { + models.UnimplementedRelationsStore + aliases *models.GetAliasResult err error } @@ -50,14 +52,6 @@ func (m *mockRelationsStore) GetAliases(_ context.Context, _ string) (*models.Ge return m.aliases, nil } -func (m *mockRelationsStore) GetRelated(_ context.Context, _ string) (*models.GetRelatedResult, error) { - panic("unimplemented") -} - -func (m *mockRelationsStore) GetUpstream(_ context.Context, _ string) (*models.GetUpstreamResult, error) { - panic("unimplemented") -} - func TestGetVulnById(t *testing.T) { ctx := context.Background() diff --git a/go/internal/database/datastore/relations.go b/go/internal/database/datastore/relations.go index 5a2593fe82f..f7645756ac3 100644 --- a/go/internal/database/datastore/relations.go +++ b/go/internal/database/datastore/relations.go @@ -2,6 +2,7 @@ package datastore import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -67,14 +68,24 @@ func (s *RelationsStore) GetRelated(ctx context.Context, id string) (*models.Get }, nil } -func (s *RelationsStore) GetUpstream(ctx context.Context, id string) (*models.GetUpstreamResult, error) { - var upstreamGroup UpstreamGroup - err := s.client.Get(ctx, datastore.NameKey("UpstreamGroup", id, nil), &upstreamGroup) - if errors.Is(err, datastore.ErrNoSuchEntity) { +func (s *RelationsStore) getUpstreamGroup(ctx context.Context, id string) (*UpstreamGroup, error) { + var groups []UpstreamGroup + q := datastore.NewQuery("UpstreamGroup").FilterField("db_id", "=", id).Limit(1) + _, err := s.client.GetAll(ctx, q, &groups) + if err != nil { + return nil, fmt.Errorf("failed to get upstream group: %w", err) + } + if len(groups) == 0 { return nil, models.ErrNotFound } + + return &groups[0], nil +} + +func (s *RelationsStore) GetUpstream(ctx context.Context, id string) (*models.GetUpstreamResult, error) { + upstreamGroup, err := s.getUpstreamGroup(ctx, id) if err != nil { - return nil, fmt.Errorf("failed to get upstream group: %w", err) + return nil, err } upstream := make([]string, len(upstreamGroup.UpstreamIDs)) copy(upstream, upstreamGroup.UpstreamIDs) @@ -85,3 +96,192 @@ func (s *RelationsStore) GetUpstream(ctx context.Context, id string) (*models.Ge Modified: upstreamGroup.Modified, }, nil } + +func (s *RelationsStore) GetUpstreamHierarchy(ctx context.Context, id string) (*models.Hierarchy, error) { + upstreamGroup, err := s.getUpstreamGroup(ctx, id) + if err != nil { + return nil, err + } + + if len(upstreamGroup.UpstreamHierarchy) == 0 { + return nil, models.ErrNotFound + } + + var rawHierarchy map[string][]string + if err := json.Unmarshal(upstreamGroup.UpstreamHierarchy, &rawHierarchy); err != nil { + return nil, fmt.Errorf("failed to unmarshal upstream hierarchy JSON: %w", err) + } + + return ComputeUpstreamHierarchy(id, rawHierarchy) +} + +func (s *RelationsStore) GetDownstreamHierarchy(ctx context.Context, id string) (*models.Hierarchy, error) { + var groups []UpstreamGroup + q := datastore.NewQuery("UpstreamGroup").FilterField("upstream_ids", "=", id) + _, err := s.client.GetAll(ctx, q, &groups) + if err != nil { + return nil, fmt.Errorf("failed to query downstream groups: %w", err) + } + if len(groups) == 0 { + return nil, models.ErrNotFound + } + + downstreams := make(map[string][]string, len(groups)) + for _, g := range groups { + vulnID := g.VulnID + if vulnID == "" && g.Key != nil { + vulnID = g.Key.Name + } + if vulnID != "" { + downstreams[vulnID] = g.UpstreamIDs + } + } + + return ComputeDownstreamHierarchy(id, downstreams) +} + +func reverseTree(graph map[string][]string) map[string][]string { + reversed := make(map[string][]string) + for node, children := range graph { + for _, child := range children { + reversed[child] = append(reversed[child], node) + } + } + for k := range reversed { + slices.Sort(reversed[k]) + } + + return reversed +} + +func hasCycle(graph map[string][]string) bool { + visited := make(map[string]bool) + recStack := make(map[string]bool) + + var dfs func(node string) bool + dfs = func(node string) bool { + visited[node] = true + recStack[node] = true + + for _, neighbor := range graph[node] { + if recStack[neighbor] { + return true + } + if !visited[neighbor] { + if dfs(neighbor) { + return true + } + } + } + + recStack[node] = false + + return false + } + + for node := range graph { + if !visited[node] { + if dfs(node) { + return true + } + } + } + + return false +} + +// ComputeUpstreamHierarchy computes a directed upstream hierarchy from a raw parent-to-children graph. +func ComputeUpstreamHierarchy(targetID string, rawHierarchy map[string][]string) (*models.Hierarchy, error) { + if len(rawHierarchy) == 0 { + return nil, models.ErrNotFound + } + + reversed := reverseTree(rawHierarchy) + if hasCycle(reversed) { + return nil, fmt.Errorf("cycle detected in upstream hierarchy for %s", targetID) + } + + allChildren := make(map[string]bool) + for _, children := range rawHierarchy { + for _, c := range children { + allChildren[c] = true + } + } + + var rootNodes []string + for child := range allChildren { + if _, exists := rawHierarchy[child]; !exists { + rootNodes = append(rootNodes, child) + } + } + slices.Sort(rootNodes) + + return &models.Hierarchy{ + Roots: rootNodes, + Graph: reversed, + }, nil +} + +// ComputeDownstreamHierarchy computes a directed downstream hierarchy given downstream bug IDs and their upstream lists. +func ComputeDownstreamHierarchy(targetID string, downstreams map[string][]string) (*models.Hierarchy, error) { + if len(downstreams) == 0 { + return nil, models.ErrNotFound + } + + downstreamMap := make(map[string][]string) + hasIntermediateParent := make(map[string]bool) + + // Find direct (parent -> child) relationships via transitive reduction. + for parent := range downstreams { + for child, upstreams := range downstreams { + if parent == child { + continue + } + // Check if 'parent' is upstream of 'child' + if slices.Contains(upstreams, parent) { + hasIntermediateParent[child] = true + + // Check if there is an intermediate node 'm' between parent and child + isDirect := true + for m, mUpstreams := range downstreams { + if m == parent || m == child { + continue + } + if slices.Contains(mUpstreams, parent) && slices.Contains(upstreams, m) { + isDirect = false + break + } + } + + if isDirect { + downstreamMap[parent] = append(downstreamMap[parent], child) + } + } + } + } + + // Sort child lists for deterministic output + for k := range downstreamMap { + slices.Sort(downstreamMap[k]) + } + + // Roots are all downstreams that have no intermediate parent in this set + var roots []string + for id := range downstreams { + if !hasIntermediateParent[id] { + roots = append(roots, id) + } + } + slices.Sort(roots) + + downstreamMap[targetID] = roots + + if hasCycle(downstreamMap) { + return nil, fmt.Errorf("cycle detected in downstream hierarchy for %s", targetID) + } + + return &models.Hierarchy{ + Roots: roots, + Graph: downstreamMap, + }, nil +} diff --git a/go/internal/database/datastore/relations_test.go b/go/internal/database/datastore/relations_test.go index 3a00b3424fc..9184c48a4d3 100644 --- a/go/internal/database/datastore/relations_test.go +++ b/go/internal/database/datastore/relations_test.go @@ -193,11 +193,12 @@ func TestRelationsStore_GetUpstream(t *testing.T) { now := time.Now().Truncate(time.Second) upstreamGroup := UpstreamGroup{ + VulnID: "VULN-A", UpstreamIDs: []string{"UPSTREAM-1", "UPSTREAM-2"}, Modified: now, } - key := datastore.NameKey("UpstreamGroup", "VULN-A", nil) + key := datastore.IncompleteKey("UpstreamGroup", nil) if _, err := dsClient.Put(ctx, key, &upstreamGroup); err != nil { t.Fatalf("Failed to setup test data: %v", err) @@ -243,3 +244,284 @@ func TestRelationsStore_GetUpstream(t *testing.T) { }) } } + +func TestComputeUpstreamHierarchy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetID string + rawHierarchy map[string][]string + want *models.Hierarchy + wantErr error + }{ + { + name: "Empty", + targetID: "VULN-1", + rawHierarchy: nil, + want: nil, + wantErr: models.ErrNotFound, + }, + { + name: "Simple hierarchy", + targetID: "VULN-1", + rawHierarchy: map[string][]string{ + "VULN-1": {"UPSTREAM-1"}, + }, + want: &models.Hierarchy{ + Roots: []string{"UPSTREAM-1"}, + Graph: map[string][]string{ + "UPSTREAM-1": {"VULN-1"}, + }, + }, + wantErr: nil, + }, + { + name: "Multi-level multi-root hierarchy", + targetID: "VULN-1", + rawHierarchy: map[string][]string{ + "VULN-1": {"INTERMEDIATE-A", "INTERMEDIATE-B"}, + "INTERMEDIATE-A": {"ROOT-1"}, + "INTERMEDIATE-B": {"ROOT-2"}, + }, + want: &models.Hierarchy{ + Roots: []string{"ROOT-1", "ROOT-2"}, + Graph: map[string][]string{ + "ROOT-1": {"INTERMEDIATE-A"}, + "ROOT-2": {"INTERMEDIATE-B"}, + "INTERMEDIATE-A": {"VULN-1"}, + "INTERMEDIATE-B": {"VULN-1"}, + }, + }, + wantErr: nil, + }, + { + name: "Cycle detected", + targetID: "VULN-1", + rawHierarchy: map[string][]string{ + "VULN-1": {"VULN-2"}, + "VULN-2": {"VULN-1"}, + }, + want: nil, + wantErr: errors.New("cycle detected in upstream hierarchy for VULN-1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ComputeUpstreamHierarchy(tt.targetID, tt.rawHierarchy) + if tt.wantErr != nil { + if err == nil { + t.Fatalf("ComputeUpstreamHierarchy() expected error, got nil") + } + if errors.Is(tt.wantErr, models.ErrNotFound) && !errors.Is(err, models.ErrNotFound) { + t.Fatalf("ComputeUpstreamHierarchy() error = %v, wantErr %v", err, tt.wantErr) + } + } else if err != nil { + t.Fatalf("ComputeUpstreamHierarchy() unexpected error: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ComputeUpstreamHierarchy() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestComputeDownstreamHierarchy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetID string + downstreams map[string][]string + want *models.Hierarchy + wantErr error + }{ + { + name: "Empty", + targetID: "ROOT-1", + downstreams: nil, + want: nil, + wantErr: models.ErrNotFound, + }, + { + name: "Single downstream", + targetID: "ROOT-1", + downstreams: map[string][]string{ + "DOWN-1": {"ROOT-1"}, + }, + want: &models.Hierarchy{ + Roots: []string{"DOWN-1"}, + Graph: map[string][]string{ + "ROOT-1": {"DOWN-1"}, + }, + }, + wantErr: nil, + }, + { + name: "Transitive downstream chain", + targetID: "ROOT-1", + downstreams: map[string][]string{ + "DOWN-1": {"ROOT-1"}, + "DOWN-2": {"ROOT-1", "DOWN-1"}, + }, + want: &models.Hierarchy{ + Roots: []string{"DOWN-1"}, + Graph: map[string][]string{ + "DOWN-1": {"DOWN-2"}, + "ROOT-1": {"DOWN-1"}, + }, + }, + wantErr: nil, + }, + { + name: "3-level transitive downstream chain", + targetID: "ROOT-1", + downstreams: map[string][]string{ + "DOWN-1": {"ROOT-1"}, + "DOWN-2": {"ROOT-1", "DOWN-1"}, + "DOWN-3": {"ROOT-1", "DOWN-1", "DOWN-2"}, + }, + want: &models.Hierarchy{ + Roots: []string{"DOWN-1"}, + Graph: map[string][]string{ + "DOWN-1": {"DOWN-2"}, + "DOWN-2": {"DOWN-3"}, + "ROOT-1": {"DOWN-1"}, + }, + }, + wantErr: nil, + }, + { + name: "Multi-root branching downstream hierarchy", + targetID: "ROOT-1", + downstreams: map[string][]string{ + "DOWN-A": {"ROOT-1"}, + "DOWN-B": {"ROOT-1"}, + "DOWN-C": {"ROOT-1", "DOWN-A"}, + }, + want: &models.Hierarchy{ + Roots: []string{"DOWN-A", "DOWN-B"}, + Graph: map[string][]string{ + "DOWN-A": {"DOWN-C"}, + "ROOT-1": {"DOWN-A", "DOWN-B"}, + }, + }, + wantErr: nil, + }, + { + name: "Cycle detected", + targetID: "ROOT-1", + downstreams: map[string][]string{ + "DOWN-1": {"ROOT-1", "DOWN-2"}, + "DOWN-2": {"ROOT-1", "DOWN-1"}, + }, + want: nil, + wantErr: errors.New("cycle detected in downstream hierarchy for ROOT-1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ComputeDownstreamHierarchy(tt.targetID, tt.downstreams) + if tt.wantErr != nil { + if err == nil { + t.Fatalf("ComputeDownstreamHierarchy() expected error, got nil") + } + if errors.Is(tt.wantErr, models.ErrNotFound) && !errors.Is(err, models.ErrNotFound) { + t.Fatalf("ComputeDownstreamHierarchy() error = %v, wantErr %v", err, tt.wantErr) + } + } else if err != nil { + t.Fatalf("ComputeDownstreamHierarchy() unexpected error: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ComputeDownstreamHierarchy() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestRelationsStore_GetUpstreamHierarchy(t *testing.T) { + ctx := context.Background() + dsClient := testutils.MustNewDatastoreClientForTesting(t) + store := NewRelationsStore(dsClient) + + upstreamGroup := UpstreamGroup{ + VulnID: "VULN-1", + UpstreamIDs: []string{"ROOT-1"}, + UpstreamHierarchy: []byte(`{"VULN-1": ["ROOT-1"]}`), + Modified: time.Now().Truncate(time.Second), + } + + key := datastore.IncompleteKey("UpstreamGroup", nil) + if _, err := dsClient.Put(ctx, key, &upstreamGroup); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + got, err := store.GetUpstreamHierarchy(ctx, "VULN-1") + if err != nil { + t.Fatalf("GetUpstreamHierarchy() unexpected error: %v", err) + } + want := &models.Hierarchy{ + Roots: []string{"ROOT-1"}, + Graph: map[string][]string{ + "ROOT-1": {"VULN-1"}, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("GetUpstreamHierarchy() mismatch (-want +got):\n%s", diff) + } + + // Missing entity should return ErrNotFound + missing, err := store.GetUpstreamHierarchy(ctx, "NON-EXISTENT") + if !errors.Is(err, models.ErrNotFound) || missing != nil { + t.Errorf("expected ErrNotFound for non-existent; got %v, %v", missing, err) + } +} + +func TestRelationsStore_GetDownstreamHierarchy(t *testing.T) { + ctx := context.Background() + dsClient := testutils.MustNewDatastoreClientForTesting(t) + store := NewRelationsStore(dsClient) + + group1 := UpstreamGroup{ + VulnID: "DOWN-1", + UpstreamIDs: []string{"TARGET-ROOT"}, + Modified: time.Now().Truncate(time.Second), + } + group2 := UpstreamGroup{ + VulnID: "DOWN-2", + UpstreamIDs: []string{"TARGET-ROOT", "DOWN-1"}, + Modified: time.Now().Truncate(time.Second), + } + + if _, err := dsClient.Put(ctx, datastore.NameKey("UpstreamGroup", "DOWN-1", nil), &group1); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + if _, err := dsClient.Put(ctx, datastore.NameKey("UpstreamGroup", "DOWN-2", nil), &group2); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + got, err := store.GetDownstreamHierarchy(ctx, "TARGET-ROOT") + if err != nil { + t.Fatalf("GetDownstreamHierarchy() unexpected error: %v", err) + } + want := &models.Hierarchy{ + Roots: []string{"DOWN-1"}, + Graph: map[string][]string{ + "DOWN-1": {"DOWN-2"}, + "TARGET-ROOT": {"DOWN-1"}, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("GetDownstreamHierarchy() mismatch (-want +got):\n%s", diff) + } + + // Non-existent target should return ErrNotFound + missing, err := store.GetDownstreamHierarchy(ctx, "NON-EXISTENT") + if !errors.Is(err, models.ErrNotFound) || missing != nil { + t.Errorf("expected ErrNotFound for non-existent; got %v, %v", missing, err) + } +} diff --git a/go/internal/database/datastore/vulnerability.go b/go/internal/database/datastore/vulnerability.go index 732f31f1b5b..7ab6ec1d412 100644 --- a/go/internal/database/datastore/vulnerability.go +++ b/go/internal/database/datastore/vulnerability.go @@ -195,6 +195,19 @@ func (s *VulnerabilityStore) batchGetModified(ctx context.Context, ids []string) return results } +func (s *VulnerabilityStore) Exists(ctx context.Context, id string) (bool, error) { + path := fmt.Sprintf("all/pb/%s.pb", id) + _, err := s.gcsStore.ReadObjectAttrs(ctx, path) + if err == nil { + return true, nil + } + if errors.Is(err, clients.ErrNotFound) { + return false, nil + } + + return false, err +} + func (s *VulnerabilityStore) GetWithMetadata(ctx context.Context, id string) (*osvschema.Vulnerability, *models.VulnSourceRef, error) { key := datastore.NameKey("Vulnerability", id, nil) var dv Vulnerability diff --git a/go/internal/models/relations.go b/go/internal/models/relations.go index 9a0c2eaa293..a853369b563 100644 --- a/go/internal/models/relations.go +++ b/go/internal/models/relations.go @@ -21,6 +21,12 @@ type GetUpstreamResult struct { Modified time.Time } +// Hierarchy represents a computed Directed Acyclic Graph of upstream or downstream vulnerability relationships. +type Hierarchy struct { + Roots []string // Root nodes where hierarchy rendering starts + Graph map[string][]string // Adjacency map: Parent ID -> Child IDs +} + type RelationsStore interface { // GetAliases retrieves the computed aliases for a vulnerability. // Returns ErrNotFound if no aliased vulnerabilities are known. @@ -31,4 +37,34 @@ type RelationsStore interface { // GetUpstream retrieves the computed upstream vulnerabilities for a vulnerability. // Returns ErrNotFound if no upstream vulnerabilities are known. GetUpstream(ctx context.Context, id string) (*GetUpstreamResult, error) + // GetUpstreamHierarchy retrieves the computed upstream DAG for a vulnerability. + // Returns ErrNotFound if no upstream hierarchy is available. + GetUpstreamHierarchy(ctx context.Context, id string) (*Hierarchy, error) + // GetDownstreamHierarchy retrieves the computed downstream DAG for a vulnerability. + // Returns ErrNotFound if no downstream hierarchy is available. + GetDownstreamHierarchy(ctx context.Context, id string) (*Hierarchy, error) +} + +type UnimplementedRelationsStore struct{} + +var _ RelationsStore = UnimplementedRelationsStore{} + +func (s UnimplementedRelationsStore) GetAliases(_ context.Context, _ string) (*GetAliasResult, error) { + panic("not implemented") +} + +func (s UnimplementedRelationsStore) GetRelated(_ context.Context, _ string) (*GetRelatedResult, error) { + panic("not implemented") +} + +func (s UnimplementedRelationsStore) GetUpstream(_ context.Context, _ string) (*GetUpstreamResult, error) { + panic("not implemented") +} + +func (s UnimplementedRelationsStore) GetUpstreamHierarchy(_ context.Context, _ string) (*Hierarchy, error) { + panic("not implemented") +} + +func (s UnimplementedRelationsStore) GetDownstreamHierarchy(_ context.Context, _ string) (*Hierarchy, error) { + panic("not implemented") } diff --git a/go/internal/models/vulnerability.go b/go/internal/models/vulnerability.go index 36b4a0c521f..7792be8f402 100644 --- a/go/internal/models/vulnerability.go +++ b/go/internal/models/vulnerability.go @@ -84,6 +84,9 @@ type VulnerabilityStore interface { // GetModified returns the modified time of a vulnerability in the OSV.dev database. GetModified(ctx context.Context, id string) (time.Time, error) + // Exists returns true if the vulnerability exists in the OSV.dev database. + Exists(ctx context.Context, id string) (bool, error) + // GetSourceModified returns the modified time of a vulnerability according to the source. // Returns ErrNotFound if the vulnerability is not found. GetSourceModified(ctx context.Context, id string) (time.Time, error) @@ -127,6 +130,10 @@ func (s UnimplementedVulnerabilityStore) GetModified(_ context.Context, _ string panic("not implemented") } +func (s UnimplementedVulnerabilityStore) Exists(_ context.Context, _ string) (bool, error) { + panic("not implemented") +} + func (s UnimplementedVulnerabilityStore) GetSourceModified(_ context.Context, _ string) (time.Time, error) { panic("not implemented") } diff --git a/go/internal/website/server.go b/go/internal/website/server.go index 374270a9028..0c237198511 100644 --- a/go/internal/website/server.go +++ b/go/internal/website/server.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/google/osv.dev/go/internal/models" "github.com/google/osv.dev/go/logger" ) @@ -23,6 +24,14 @@ type Config struct { StaticFS fs.FS DocsFS fs.FS TemplateDir string + Stores Stores + APIURL string +} + +type Stores struct { + Vuln models.VulnerabilityStore + Relations models.RelationsStore + SourceRepo models.SourceRepositoryStore } // Server handles website routing and HTTP requests. @@ -30,6 +39,8 @@ type Server struct { config Config mux *http.ServeMux handler http.Handler + + stores Stores } type responseLogger struct { @@ -55,7 +66,7 @@ func (r *responseLogger) Write(b []byte) (int, error) { } // NewServer creates and initializes a new website Server. -// It returns an error if cfg.StaticFS or cfg.DocsFS is nil. +// It returns an error if cfg.StaticFS, cfg.DocsFS, or any of the cfg.Stores are nil. func NewServer(cfg Config) (*Server, error) { if cfg.StaticFS == nil { return nil, errors.New("StaticFS is required") @@ -63,10 +74,24 @@ func NewServer(cfg Config) (*Server, error) { if cfg.DocsFS == nil { return nil, errors.New("DocsFS is required") } + if cfg.Stores.Vuln == nil { + return nil, errors.New("Stores.Vuln is required") + } + if cfg.Stores.Relations == nil { + return nil, errors.New("Stores.Relations is required") + } + if cfg.Stores.SourceRepo == nil { + return nil, errors.New("Stores.SourceRepo is required") + } + + if cfg.APIURL == "" { + cfg.APIURL = "api.osv.dev" + } s := &Server{ config: cfg, mux: http.NewServeMux(), + stores: cfg.Stores, } s.registerRoutes() diff --git a/go/internal/website/server_test.go b/go/internal/website/server_test.go index 9e6109aeae8..24fb8ebfe34 100644 --- a/go/internal/website/server_test.go +++ b/go/internal/website/server_test.go @@ -1,6 +1,8 @@ package website_test import ( + "context" + "iter" "net/http" "net/http/httptest" "os" @@ -9,17 +11,97 @@ import ( "testing" "testing/fstest" + "github.com/google/osv.dev/go/internal/models" "github.com/google/osv.dev/go/internal/website" + "github.com/ossf/osv-schema/bindings/go/osvschema" ) +type mockVulnStore struct { + models.UnimplementedVulnerabilityStore +} + +func (m mockVulnStore) GetFull(_ context.Context, id string) (*osvschema.Vulnerability, error) { + return &osvschema.Vulnerability{Id: id}, nil +} + +func (m mockVulnStore) GetWithMetadata(_ context.Context, id string) (*osvschema.Vulnerability, *models.VulnSourceRef, error) { + if id == "UNKNOWN" || id == "UNKNOWN-1234" || id == "ALIAS-1234" { + return nil, nil, models.ErrNotFound + } + + return &osvschema.Vulnerability{Id: id}, &models.VulnSourceRef{ID: id, Source: "test", Path: id + ".json"}, nil +} + +func (m mockVulnStore) Exists(_ context.Context, id string) (bool, error) { + return id != "UNKNOWN" && id != "UNKNOWN-1234" && id != "ALIAS-1234", nil +} + +type mockRelationsStore struct { + models.UnimplementedRelationsStore +} + +func (m mockRelationsStore) GetAliases(_ context.Context, id string) (*models.GetAliasResult, error) { + if id == "ALIAS-1234" { + return &models.GetAliasResult{ + Aliases: []string{"GHSA-1234"}, + }, nil + } + + return nil, models.ErrNotFound +} + +func (m mockRelationsStore) GetRelated(_ context.Context, _ string) (*models.GetRelatedResult, error) { + return nil, models.ErrNotFound +} + +func (m mockRelationsStore) GetUpstream(_ context.Context, _ string) (*models.GetUpstreamResult, error) { + return nil, models.ErrNotFound +} + +func (m mockRelationsStore) GetUpstreamHierarchy(_ context.Context, _ string) (*models.Hierarchy, error) { + return nil, models.ErrNotFound +} + +func (m mockRelationsStore) GetDownstreamHierarchy(_ context.Context, _ string) (*models.Hierarchy, error) { + return nil, models.ErrNotFound +} + +type mockSourceRepoStore struct{} + +func (m mockSourceRepoStore) Get(_ context.Context, _ string) (*models.SourceRepository, error) { + return &models.SourceRepository{ + Link: "https://example.com/source/", + }, nil +} + +func (m mockSourceRepoStore) Update(_ context.Context, _ string, _ *models.SourceRepository) error { + return nil +} + +func (m mockSourceRepoStore) All(_ context.Context) iter.Seq2[*models.SourceRepository, error] { + return func(_ func(*models.SourceRepository, error) bool) {} +} + func newTestServer(t *testing.T, cfg website.Config) *website.Server { t.Helper() if cfg.StaticFS == nil { - cfg.StaticFS = fstest.MapFS{} + cfg.StaticFS = fstest.MapFS{ + "go/base.html": &fstest.MapFile{Data: []byte(`{{ block "content" . }}{{ end }}`)}, + "go/404.html": &fstest.MapFile{Data: []byte(`{{ define "content" }}404{{ end }}`)}, + } } if cfg.DocsFS == nil { cfg.DocsFS = fstest.MapFS{} } + if cfg.Stores.Vuln == nil { + cfg.Stores.Vuln = mockVulnStore{} + } + if cfg.Stores.Relations == nil { + cfg.Stores.Relations = mockRelationsStore{} + } + if cfg.Stores.SourceRepo == nil { + cfg.Stores.SourceRepo = mockSourceRepoStore{} + } srv, err := website.NewServer(cfg) if err != nil { t.Fatalf("failed creating test server: %v", err) @@ -28,17 +110,51 @@ func newTestServer(t *testing.T, cfg website.Config) *website.Server { return srv } -func TestNewServer_NilFS(t *testing.T) { +func TestNewServer_NilConfig(t *testing.T) { t.Parallel() + validConfig := website.Config{ + StaticFS: fstest.MapFS{}, + DocsFS: fstest.MapFS{}, + Stores: website.Stores{ + Vuln: mockVulnStore{}, + Relations: mockRelationsStore{}, + SourceRepo: mockSourceRepoStore{}, + }, + } + if _, err := website.NewServer(website.Config{}); err == nil { - t.Errorf("expected error when StaticFS and DocsFS are nil, got nil") + t.Errorf("expected error when config is empty, got nil") } - if _, err := website.NewServer(website.Config{StaticFS: fstest.MapFS{}}); err == nil { + + noStatic := validConfig + noStatic.StaticFS = nil + if _, err := website.NewServer(noStatic); err == nil { + t.Errorf("expected error when StaticFS is nil, got nil") + } + + noDocs := validConfig + noDocs.DocsFS = nil + if _, err := website.NewServer(noDocs); err == nil { t.Errorf("expected error when DocsFS is nil, got nil") } - if _, err := website.NewServer(website.Config{DocsFS: fstest.MapFS{}}); err == nil { - t.Errorf("expected error when StaticFS is nil, got nil") + + noVuln := validConfig + noVuln.Stores.Vuln = nil + if _, err := website.NewServer(noVuln); err == nil { + t.Errorf("expected error when Stores.Vuln is nil, got nil") + } + + noRelations := validConfig + noRelations.Stores.Relations = nil + if _, err := website.NewServer(noRelations); err == nil { + t.Errorf("expected error when Stores.Relations is nil, got nil") + } + + noSourceRepo := validConfig + noSourceRepo.Stores.SourceRepo = nil + if _, err := website.NewServer(noSourceRepo); err == nil { + t.Errorf("expected error when Stores.SourceRepo is nil, got nil") } } @@ -267,6 +383,20 @@ func TestStaticFiles(t *testing.T) { } }) + t.Run("Vulnerability_details_single_alias_redirect", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/vulnerability/ALIAS-1234", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/vulnerability/GHSA-1234" { + t.Errorf("expected Location '/vulnerability/GHSA-1234', got %q", loc) + } + }) + t.Run("Triage_page", func(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodGet, "/triage", nil) @@ -423,6 +553,112 @@ func TestStaticFiles(t *testing.T) { }) } +func TestPotentialVulnerability(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, website.Config{}) + + t.Run("Existing vuln redirects to /vulnerability/{id}", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/GHSA-1234", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/vulnerability/GHSA-1234" { + t.Errorf("expected Location '/vulnerability/GHSA-1234', got %q", loc) + } + }) + + t.Run("Single alias vuln redirects to /vulnerability/{canonical_id}", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/ALIAS-1234", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/vulnerability/GHSA-1234" { + t.Errorf("expected Location '/vulnerability/GHSA-1234', got %q", loc) + } + }) + + t.Run("Non-existent vuln falls back to /list?q={id}", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/UNKNOWN-1234", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/list?q=UNKNOWN-1234" { + t.Errorf("expected Location '/list?q=UNKNOWN-1234', got %q", loc) + } + }) + + t.Run("Invalid vuln ID returns 404 Not Found", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/$invalid_id$", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected status 404 Not Found, got %d", rec.Code) + } + }) +} + +func TestVulnerabilityJSON(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, website.Config{ + APIURL: "api.osv.dev", + }) + + t.Run("Existing vuln from /vulnerability/{id}.json redirects to api.osv.dev", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/vulnerability/GHSA-1234.json", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "https://api.osv.dev/v1/vulns/GHSA-1234" { + t.Errorf("expected Location 'https://api.osv.dev/v1/vulns/GHSA-1234', got %q", loc) + } + }) + + t.Run("Existing vuln from /{id}.json redirects to api.osv.dev", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/GHSA-1234.json", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "https://api.osv.dev/v1/vulns/GHSA-1234" { + t.Errorf("expected Location 'https://api.osv.dev/v1/vulns/GHSA-1234', got %q", loc) + } + }) + + t.Run("Non-existent vuln returns 404", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/vulnerability/UNKNOWN-1234.json", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected status 404 Not Found, got %d", rec.Code) + } + }) +} + func TestEndpointRegistration(t *testing.T) { t.Parallel() @@ -432,9 +668,6 @@ func TestEndpointRegistration(t *testing.T) { method string path string }{ - {http.MethodGet, "/GHSA-1234"}, - {http.MethodGet, "/vulnerability/GHSA-1234.json"}, - {http.MethodGet, "/GHSA-1234.json"}, {http.MethodPost, "/triage/proxy"}, {http.MethodGet, "/login"}, {http.MethodGet, "/auth/callback"}, diff --git a/go/internal/website/vulnerability.go b/go/internal/website/vulnerability.go index 1915b4ad5ba..b0067b03349 100644 --- a/go/internal/website/vulnerability.go +++ b/go/internal/website/vulnerability.go @@ -1,15 +1,23 @@ package website import ( + "context" + "errors" "fmt" - "io" + "log/slog" "net/http" + "net/url" + "regexp" "strings" + "sync" - "github.com/ossf/osv-schema/bindings/go/osvschema" - "google.golang.org/protobuf/proto" + "github.com/google/osv.dev/go/internal/models" + "github.com/google/osv.dev/go/logger" + "golang.org/x/sync/errgroup" ) +var vulnIDRegex = regexp.MustCompile(`^[a-zA-Z0-9:_.-]+$`) + // handleVulnerabilityDetails handles rendering the vulnerability details page or raw JSON for /vulnerability/{vuln_id}. func (s *Server) handleVulnerabilityDetails(w http.ResponseWriter, r *http.Request) { vulnID := r.PathValue("vuln_id") @@ -24,62 +32,187 @@ func (s *Server) handleVulnerabilityDetails(w http.ResponseWriter, r *http.Reque return } - // TODO: Fetch vulnerability metadata from Datastore/GCS. - // For now, use a static pb file. - resp, err := http.Get("https://storage.googleapis.com/osv-vulnerabilities/all/pb/CVE-2023-51775.pb") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + // Helper to handle fallback: redirect to search if valid vulnID regex, otherwise render 404 + redirectToListOr404 := func() { + if vulnIDRegex.MatchString(vulnID) { + targetURL := "/list?" + url.Values{"q": {vulnID}}.Encode() + http.Redirect(w, r, targetURL, http.StatusFound) + + return + } + s.RenderNotFound(w, r) + } + + vuln, ref, err := s.stores.Vuln.GetWithMetadata(r.Context(), vulnID) + if errors.Is(err, models.ErrNotFound) { + resolvedID, resolveErr := s.resolveVulnID(r.Context(), vulnID) + if resolveErr == nil && resolvedID != vulnID { + targetURL, err := url.JoinPath("/vulnerability", resolvedID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + http.Redirect(w, r, targetURL, http.StatusFound) + + return + } + if resolveErr != nil && !errors.Is(resolveErr, models.ErrNotFound) { + http.Error(w, resolveErr.Error(), http.StatusInternalServerError) + + return + } + + redirectToListOr404() + return } - defer resp.Body.Close() - blob, err := io.ReadAll(resp.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) + return } - vuln := &osvschema.Vulnerability{} - if err := proto.Unmarshal(blob, vuln); err != nil { + + var sourceRepo *models.SourceRepository + sourceRepo, err = s.stores.SourceRepo.Get(r.Context(), ref.Source) + if errors.Is(err, models.ErrNotFound) { + // Generally should only happen with oss-fuzz, since we're explicitly hiding it from datastore + sourceRepo = &models.SourceRepository{} + ref.Path = "" // set this so we don't try render an Import Source as a relative link on the osv.dev website. + if ref.Source != "oss-fuzz" { + logger.ErrorContext(r.Context(), "source repo not found", slog.String("source", ref.Source), slog.String("id", vulnID)) + } else { + // Hardcode the link in the oss-fuzz source repo as a workaround for Import Source. + // TODO(michaelkedar): turn oss-fuzz into a regular data source so we don't need this bespoke logic + var project string + for _, aff := range vuln.GetAffected() { + if aff.GetPackage().GetEcosystem() != "OSS-Fuzz" { + continue + } + project = aff.GetPackage().GetName() + + break + } + if project != "" { + sourceRepo.Link = fmt.Sprintf("https://github.com/google/oss-fuzz-vulns/blob/main/vulns/%s/%s.yaml", project, vulnID) + } + } + } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - knownIDs := map[string]bool{ - "CVE-2014-0114": true, - "CVE-2019-10086": true, - "UBUNTU-CVE-2014-0114": true, - "UBUNTU-CVE-2019-10086": true, + upstreamHierarchy, err := s.stores.Relations.GetUpstreamHierarchy(r.Context(), vulnID) + if err != nil && !errors.Is(err, models.ErrNotFound) { + logger.ErrorContext(r.Context(), "failed to get upstream hierarchy", slog.String("id", vulnID), slog.Any("error", err)) + } + downstreamHierarchy, err := s.stores.Relations.GetDownstreamHierarchy(r.Context(), vulnID) + if err != nil && !errors.Is(err, models.ErrNotFound) { + logger.ErrorContext(r.Context(), "failed to get downstream hierarchy", slog.String("id", vulnID), slog.Any("error", err)) } - mockUpstream := ComputedHierarchy{ - RootNodes: []string{"CVE-2014-0114", "CVE-2019-10086"}, - Graph: map[string][]string{ - "CVE-2014-0114": {"UBUNTU-CVE-2014-0114"}, - "UBUNTU-CVE-2014-0114": {vuln.GetId()}, - "CVE-2019-10086": {"UBUNTU-CVE-2019-10086"}, - "UBUNTU-CVE-2019-10086": {vuln.GetId()}, - }, + // Collect all the vuln IDs mentioned by this vuln to check the database (in parallel) for existence. + candidateIDs := make(map[string]struct{}) + for _, id := range vuln.GetAliases() { + candidateIDs[id] = struct{}{} + } + for _, id := range vuln.GetRelated() { + candidateIDs[id] = struct{}{} + } + for _, id := range vuln.GetUpstream() { + candidateIDs[id] = struct{}{} + } + if upstreamHierarchy != nil { + for k, children := range upstreamHierarchy.Graph { + candidateIDs[k] = struct{}{} + for _, c := range children { + candidateIDs[c] = struct{}{} + } + } + } + if downstreamHierarchy != nil { + for k, children := range downstreamHierarchy.Graph { + candidateIDs[k] = struct{}{} + for _, c := range children { + candidateIDs[c] = struct{}{} + } + } } - mockDownstream := ComputedHierarchy{} + var mu sync.Mutex + knownIDs := make(map[string]bool) + g, ctx := errgroup.WithContext(r.Context()) + for id := range candidateIDs { + g.Go(func() error { + known, err := s.stores.Vuln.Exists(ctx, id) + if err != nil { + return err + } + if known { + mu.Lock() + knownIDs[id] = true + mu.Unlock() + } + + return nil + }) + } + + if err = g.Wait(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + humanLink, err := RenderHumanLink(sourceRepo.HumanLink, vuln) + if err != nil { + logger.ErrorContext(r.Context(), "failed to render human link", slog.String("id", vuln.GetId()), slog.Any("error", err)) + humanLink = "" + } data := VulnerabilityPageData{ BasePageData: BasePageData{ ActiveSection: "vulnerabilities", }, Vulnerability: vuln, - APIURL: "api.osv.dev", - HumanSourceLink: "https://ubuntu.com/security/notices/" + vuln.GetId(), - SourceLink: "https://github.com/canonical/ubuntu-security-notices/blob/main/osv/usn/" + vuln.GetId() + ".json", + APIURL: s.config.APIURL, + HumanSourceLink: humanLink, + SourceLink: sourceRepo.Link + ref.Path, KnownIDs: knownIDs, - UpstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), mockUpstream, knownIDs), - DownstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), mockDownstream, knownIDs), + UpstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), upstreamHierarchy, knownIDs), + DownstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), downstreamHierarchy, knownIDs), } s.render(w, r, "vulnerability.html", http.StatusOK, &data) } -// handlePotentialVulnerability handles requests for /{potential_vuln_id} (redirects or vulnerability pages). +// resolveVulnID checks if the given vulnID exists, or if a single alias exists, returning the canonical ID. +func (s *Server) resolveVulnID(ctx context.Context, id string) (string, error) { + exists, err := s.stores.Vuln.Exists(ctx, id) + if err != nil { + return "", err + } + if exists { + return id, nil + } + + aliases, aliasErr := s.stores.Relations.GetAliases(ctx, id) + if aliasErr == nil && len(aliases.Aliases) == 1 { + alias := aliases.Aliases[0] + aliasExists, err := s.stores.Vuln.Exists(ctx, alias) + if err != nil { + return "", err + } + if aliasExists { + return alias, nil + } + } + + return "", models.ErrNotFound +} + +// handlePotentialVulnerability handles requests for /{potential_vuln_id} (redirects or search fallback). func (s *Server) handlePotentialVulnerability(w http.ResponseWriter, r *http.Request) { potentialID := r.PathValue("potential_vuln_id") if potentialID == "" { @@ -93,11 +226,39 @@ func (s *Server) handlePotentialVulnerability(w http.ResponseWriter, r *http.Req return } - // TODO: Validate ID, resolve canonical ID from Datastore, and render page or redirect - http.Error(w, fmt.Sprintf("Potential vulnerability handler stub (potentialID=%q)", potentialID), http.StatusNotImplemented) + if unescaped, err := url.PathUnescape(potentialID); err == nil { + potentialID = unescaped + } + + if !vulnIDRegex.MatchString(potentialID) { + s.RenderNotFound(w, r) + + return + } + + resolvedID, err := s.resolveVulnID(r.Context(), potentialID) + if err == nil { + targetURL, err := url.JoinPath("/vulnerability", resolvedID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + http.Redirect(w, r, targetURL, http.StatusFound) + + return + } + if errors.Is(err, models.ErrNotFound) { + targetURL := "/list?" + url.Values{"q": {potentialID}}.Encode() + http.Redirect(w, r, targetURL, http.StatusFound) + + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) } -// handleVulnerabilityJSON handles redirecting /{potential_vuln_id}.json and /vulnerability/{potential_vuln_id}.json +// handleVulnerabilityJSON handles redirecting /{potential_vuln_id}.json and /vulnerability/{vuln_id}.json // to https://api.osv.dev/v1/vulns/{canonical_id}. func (s *Server) handleVulnerabilityJSON(w http.ResponseWriter, r *http.Request) { potentialID := strings.TrimSuffix(r.PathValue("potential_vuln_id"), ".json") @@ -110,6 +271,33 @@ func (s *Server) handleVulnerabilityJSON(w http.ResponseWriter, r *http.Request) return } - // TODO: Validate ID, resolve canonical ID from Datastore, and redirect to https://api.osv.dev/v1/vulns/{canonical_id} - http.Error(w, fmt.Sprintf("Vulnerability JSON redirector stub (id=%q)", potentialID), http.StatusNotImplemented) + if unescaped, err := url.PathUnescape(potentialID); err == nil { + potentialID = unescaped + } + + if !vulnIDRegex.MatchString(potentialID) { + http.NotFound(w, r) + + return + } + + resolvedID, err := s.resolveVulnID(r.Context(), potentialID) + if err == nil { + redirectURL, err := url.JoinPath("https://"+s.config.APIURL, "v1", "vulns", resolvedID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + http.Redirect(w, r, redirectURL, http.StatusFound) + + return + } + if errors.Is(err, models.ErrNotFound) { + http.NotFound(w, r) + + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/go/internal/website/vulnerability_helpers.go b/go/internal/website/vulnerability_helpers.go index 90895c989bf..802ee4fbd64 100644 --- a/go/internal/website/vulnerability_helpers.go +++ b/go/internal/website/vulnerability_helpers.go @@ -9,6 +9,7 @@ import ( "slices" "strings" + "github.com/google/osv.dev/go/internal/models" "github.com/google/osv.dev/go/logger" "github.com/google/osv.dev/go/osv/ecosystem" "github.com/microcosm-cc/bluemonday" @@ -109,16 +110,25 @@ func ParseDatabaseSpecificKVs(s *structpb.Struct) []DatabaseSpecificKV { return kvs } -// ConstructHierarchyHTML formats a ComputedHierarchy into a template.HTML tree string. -func ConstructHierarchyHTML(targetID string, hierarchy ComputedHierarchy, knownIDs map[string]bool) template.HTML { - if len(hierarchy.RootNodes) == 0 { +// ConstructHierarchyHTML formats a models.Hierarchy into a template.HTML tree string. +func ConstructHierarchyHTML(targetID string, hierarchy *models.Hierarchy, knownIDs map[string]bool) template.HTML { + if hierarchy == nil || len(hierarchy.Roots) == 0 { return "" } var sb strings.Builder + visited := make(map[string]bool) var printSubtree func(vulnID string) printSubtree = func(vulnID string) { + if visited[vulnID] { + return + } + visited[vulnID] = true + defer func() { + delete(visited, vulnID) + }() + if vulnID != targetID { escapedID := template.HTMLEscapeString(vulnID) if knownIDs[vulnID] { @@ -133,7 +143,7 @@ func ConstructHierarchyHTML(targetID string, hierarchy ComputedHierarchy, knownI slices.Sort(sortedChildren) for _, child := range sortedChildren { - if child != targetID { + if child != targetID && !visited[child] { sb.WriteString(``) @@ -142,7 +152,7 @@ func ConstructHierarchyHTML(targetID string, hierarchy ComputedHierarchy, knownI } } - sortedRoots := slices.Clone(hierarchy.RootNodes) + sortedRoots := slices.Clone(hierarchy.Roots) slices.Sort(sortedRoots) for _, root := range sortedRoots { diff --git a/go/internal/website/vulnerability_models.go b/go/internal/website/vulnerability_models.go index 74511f63a9f..74e3c9f56ad 100644 --- a/go/internal/website/vulnerability_models.go +++ b/go/internal/website/vulnerability_models.go @@ -3,6 +3,7 @@ package website import ( "html/template" + "github.com/google/osv.dev/go/internal/models" "github.com/ossf/osv-schema/bindings/go/osvschema" ) @@ -69,10 +70,7 @@ type AffectedEcosystemGroup struct { } // ComputedHierarchy represents a graph of upstream or downstream vulnerability relationships. -type ComputedHierarchy struct { - RootNodes []string - Graph map[string][]string -} +type ComputedHierarchy = models.Hierarchy // VulnerabilityPageData represents the data context passed to vulnerability.html template. type VulnerabilityPageData struct { diff --git a/go/internal/website/vulnerability_view.go b/go/internal/website/vulnerability_view.go index ee9f8503c39..d3d3ada1852 100644 --- a/go/internal/website/vulnerability_view.go +++ b/go/internal/website/vulnerability_view.go @@ -11,6 +11,8 @@ import ( "time" "github.com/google/osv.dev/go/logger" + "github.com/nikolalohinski/gonja/v2" + "github.com/nikolalohinski/gonja/v2/exec" "github.com/ossf/osv-schema/bindings/go/osvschema" gocvss20 "github.com/pandatix/go-cvss/20" gocvss30 "github.com/pandatix/go-cvss/30" @@ -156,6 +158,16 @@ func (v VulnerabilityPageData) Severities() []SeverityDisplay { for _, sev := range severities { if display, ok := ParseSeverityDisplay(sev.GetType(), sev.GetScore(), id); ok { displays = append(displays, display) + } else { + // This probably only happens if the CVSS score itself is wrong. + // Display it, but show that it's invalid. + displays = append(displays, SeverityDisplay{ + IsCVSS: true, + Level: "invalid", + Type: sev.GetType().String(), + Score: sev.GetScore(), + Rating: "Invalid Severity Rating", + }) } } @@ -399,3 +411,25 @@ func (v VulnerabilityPageData) EcosystemGroups() []AffectedEcosystemGroup { return groups } + +// RenderHumanLink renders the human link template with the vulnerability data. +// TODO(michaelkedar): This currently depends on jinja templates in source.yaml. +// We're using gonja to render these, but we should move away from Python-based +// templates and use a Go-based templating solution instead. +func RenderHumanLink(tmplStr string, vuln *osvschema.Vulnerability) (string, error) { + ecosystems := make([]string, 0, len(vuln.GetAffected())) + for _, a := range vuln.GetAffected() { + ecosystems = append(ecosystems, a.GetPackage().GetEcosystem()) + } + + tpl, err := gonja.FromString(tmplStr) + if err != nil { + return "", err + } + data := exec.NewContext(map[string]any{ + "BUG_ID": vuln.GetId(), + "ECOSYSTEMS": ecosystems, + }) + + return tpl.ExecuteToString(data) +} diff --git a/go/internal/website/vulnerability_view_test.go b/go/internal/website/vulnerability_view_test.go index bf0cc6fc980..4e9593bbb44 100644 --- a/go/internal/website/vulnerability_view_test.go +++ b/go/internal/website/vulnerability_view_test.go @@ -48,10 +48,12 @@ func TestSeverities(t *testing.T) { wantCount: 1, }, { - name: "CVSS v3 unsupported/invalid prefix", - sevType: osvschema.Severity_CVSS_V3, - score: "CVSS:3.2/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - wantCount: 0, + name: "CVSS v3 unsupported/invalid prefix", + sevType: osvschema.Severity_CVSS_V3, + score: "CVSS:3.2/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + wantCalcURL: "", + wantLevel: "invalid", + wantCount: 1, }, } diff --git a/go/internal/worker/engine_test.go b/go/internal/worker/engine_test.go index 46354d2bc44..f014e313089 100644 --- a/go/internal/worker/engine_test.go +++ b/go/internal/worker/engine_test.go @@ -23,7 +23,9 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" ) -type mockRelationsStore struct{} +type mockRelationsStore struct { + models.UnimplementedRelationsStore +} func (m mockRelationsStore) GetAliases(_ context.Context, _ string) (*models.GetAliasResult, error) { return nil, models.ErrNotFound diff --git a/go/internal/worker/pipeline/relations/relations_test.go b/go/internal/worker/pipeline/relations/relations_test.go index d6e3bf371e2..54868dda18c 100644 --- a/go/internal/worker/pipeline/relations/relations_test.go +++ b/go/internal/worker/pipeline/relations/relations_test.go @@ -15,6 +15,8 @@ import ( ) type mockRelationsStore struct { + models.UnimplementedRelationsStore + aliases *models.GetAliasResult related *models.GetRelatedResult upstream *models.GetUpstreamResult