diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 2111f36dfcf..00a7f0fef6c 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -109,6 +109,7 @@ Alpha features might be changed or removed without prior notice. | `pyroscopeFlameGraph` | Changes flame graph to pyroscope one | | `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | | `authenticationConfigUI` | Enables authentication configuration UI | +| `pluginsAPIManifestKey` | Use grafana.com API to retrieve the public manifest key | | `advancedDataSourcePicker` | Enable a new data source picker with contextual information, recently used order, CSV upload and advanced mode | | `opensearchDetectVersion` | Enable version detection in OpenSearch | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1f33ea090bb..605a663df8f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -96,6 +96,7 @@ export interface FeatureToggles { useCachingService?: boolean; enableElasticsearchBackendQuerying?: boolean; authenticationConfigUI?: boolean; + pluginsAPIManifestKey?: boolean; advancedDataSourcePicker?: boolean; opensearchDetectVersion?: boolean; } diff --git a/pkg/api/plugin_resource_test.go b/pkg/api/plugin_resource_test.go index 596c1456374..ff34565961e 100644 --- a/pkg/api/plugin_resource_test.go +++ b/pkg/api/plugin_resource_test.go @@ -60,12 +60,12 @@ func TestCallResource(t *testing.T) { coreRegistry := coreplugin.ProvideCoreRegistry(nil, &cloudwatch.CloudWatchService{}, nil, nil, nil, nil, nil, nil, nil, nil, testdatasource.ProvideService(cfg, featuremgmt.WithFeatures()), nil, nil, nil, nil, nil, nil) - pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg) + pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg, featuremgmt.WithFeatures()) require.NoError(t, err) reg := registry.ProvideService() l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg), reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(), fakes.NewFakeRoleRegistry(), - assetpath.ProvideService(pluginscdn.ProvideService(pCfg))) + assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(pCfg)) srcs := sources.ProvideService(cfg, pCfg) ps, err := store.ProvideService(reg, srcs, l) require.NoError(t, err) diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index 3b2e1d9c662..505425e6f2f 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -3,6 +3,7 @@ package config import ( "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/setting" ) @@ -34,11 +35,15 @@ type Cfg struct { PluginsCDNURLTemplate string Tracing Tracing + + GrafanaComURL string + + Features plugins.FeatureToggles } func NewCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string, awsAllowedAuthProviders []string, awsAssumeRoleEnabled bool, azure *azsettings.AzureSettings, secureSocksDSProxy setting.SecureSocksDSProxySettings, - grafanaVersion string, logDatasourceRequests bool, pluginsCDNURLTemplate string, tracing Tracing) *Cfg { + grafanaVersion string, logDatasourceRequests bool, pluginsCDNURLTemplate string, tracing Tracing, features plugins.FeatureToggles) *Cfg { return &Cfg{ log: log.New("plugin.cfg"), PluginsPath: pluginsPath, @@ -53,5 +58,7 @@ func NewCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSetti LogDatasourceRequests: logDatasourceRequests, PluginsCDNURLTemplate: pluginsCDNURLTemplate, Tracing: tracing, + GrafanaComURL: "https://grafana.com", + Features: features, } } diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 8c1ae78f34b..6b0d6bc3499 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -136,3 +136,11 @@ type ClientMiddlewareFunc func(next Client) Client func (fn ClientMiddlewareFunc) CreateClientMiddleware(next Client) Client { return fn(next) } + +type FeatureToggles interface { + IsEnabled(flag string) bool +} + +type SignatureCalculator interface { + Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error) +} diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index 9f2d0abcb38..b515a0364a3 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -25,44 +25,45 @@ import ( var _ plugins.ErrorResolver = (*Loader)(nil) type Loader struct { - pluginFinder finder.Finder - processManager process.Service - pluginRegistry registry.Service - roleRegistry plugins.RoleRegistry - pluginInitializer initializer.Initializer - signatureValidator signature.Validator - pluginStorage storage.Manager - assetPath *assetpath.Service - log log.Logger - cfg *config.Cfg - - errs map[string]*plugins.SignatureError + pluginFinder finder.Finder + processManager process.Service + pluginRegistry registry.Service + roleRegistry plugins.RoleRegistry + pluginInitializer initializer.Initializer + signatureValidator signature.Validator + signatureCalculator plugins.SignatureCalculator + pluginStorage storage.Manager + assetPath *assetpath.Service + log log.Logger + cfg *config.Cfg + errs map[string]*plugins.SignatureError } func ProvideService(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer, pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider, pluginFinder finder.Finder, - roleRegistry plugins.RoleRegistry, assetPath *assetpath.Service) *Loader { + roleRegistry plugins.RoleRegistry, assetPath *assetpath.Service, signatureCalculator plugins.SignatureCalculator) *Loader { return New(cfg, license, authorizer, pluginRegistry, backendProvider, process.NewManager(pluginRegistry), storage.FileSystem(log.NewPrettyLogger("loader.fs"), cfg.PluginsPath), roleRegistry, assetPath, - pluginFinder) + pluginFinder, signatureCalculator) } func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer, pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider, processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry, - assetPath *assetpath.Service, pluginFinder finder.Finder) *Loader { + assetPath *assetpath.Service, pluginFinder finder.Finder, signatureCalculator plugins.SignatureCalculator) *Loader { return &Loader{ - pluginFinder: pluginFinder, - pluginRegistry: pluginRegistry, - pluginInitializer: initializer.New(cfg, backendProvider, license), - signatureValidator: signature.NewValidator(authorizer), - processManager: processManager, - pluginStorage: pluginStorage, - errs: make(map[string]*plugins.SignatureError), - log: log.New("plugin.loader"), - roleRegistry: roleRegistry, - cfg: cfg, - assetPath: assetPath, + pluginFinder: pluginFinder, + pluginRegistry: pluginRegistry, + pluginInitializer: initializer.New(cfg, backendProvider, license), + signatureValidator: signature.NewValidator(authorizer), + signatureCalculator: signatureCalculator, + processManager: processManager, + pluginStorage: pluginStorage, + errs: make(map[string]*plugins.SignatureError), + log: log.New("plugin.loader"), + roleRegistry: roleRegistry, + cfg: cfg, + assetPath: assetPath, } } @@ -77,13 +78,14 @@ func (l *Loader) Load(ctx context.Context, src plugins.PluginSource) ([]*plugins func (l *Loader) loadPlugins(ctx context.Context, src plugins.PluginSource, found []*plugins.FoundBundle) ([]*plugins.Plugin, error) { var loadedPlugins []*plugins.Plugin + for _, p := range found { if _, exists := l.pluginRegistry.Plugin(ctx, p.Primary.JSONData.ID); exists { l.log.Warn("Skipping plugin loading as it's a duplicate", "pluginID", p.Primary.JSONData.ID) continue } - sig, err := signature.Calculate(ctx, l.log, src, p.Primary) + sig, err := l.signatureCalculator.Calculate(ctx, src, p.Primary) if err != nil { l.log.Warn("Could not calculate plugin signature state", "pluginID", p.Primary.JSONData.ID, "err", err) continue diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index d922f27fb3c..2cc0663a8e8 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -2,7 +2,10 @@ package loader import ( "context" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "sort" @@ -21,8 +24,10 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/loader/finder" "github.com/grafana/grafana/pkg/plugins/manager/loader/initializer" "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/manifestverifier" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -1117,6 +1122,117 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { }) } +func TestLoader_Load_UseAPIForManifestPublicKey(t *testing.T) { + t.Run("Load plugin using API manifest", func(t *testing.T) { + pluginDir, err := filepath.Abs("../testdata/test-app") + if err != nil { + t.Errorf("could not construct absolute path of plugin dir") + return + } + expected := []*plugins.Plugin{ + { + JSONData: plugins.JSONData{ + ID: "test-app", + Type: "app", + Name: "Test App", + Info: plugins.Info{ + Author: plugins.InfoLink{ + Name: "Test Inc.", + URL: "http://test.com", + }, + Description: "Official Grafana Test App & Dashboard bundle", + Version: "1.0.0", + Links: []plugins.InfoLink{ + {Name: "Project site", URL: "http://project.com"}, + {Name: "License & Terms", URL: "http://license.com"}, + }, + Logos: plugins.Logos{ + Small: "public/plugins/test-app/img/logo_small.png", + Large: "public/plugins/test-app/img/logo_large.png", + }, + Screenshots: []plugins.Screenshots{ + {Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"}, + {Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"}, + }, + Updated: "2015-02-10", + }, + Dependencies: plugins.Dependencies{ + GrafanaVersion: "3.x.x", + Plugins: []plugins.Dependency{ + {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, + {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, + }, + }, + Includes: []*plugins.Includes{ + {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: "Viewer", Slug: "nginx-connections"}, + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: "Viewer", Slug: "nginx-memory"}, + {Name: "Nginx Panel", Type: "panel", Role: "Viewer", Slug: "nginx-panel"}, + {Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Slug: "nginx-datasource"}, + }, + Backend: false, + }, + FS: plugins.NewLocalFS(filesInDir(t, pluginDir), pluginDir), + Class: plugins.External, + Signature: plugins.SignatureValid, + SignatureType: plugins.GrafanaSignature, + SignatureOrg: "Grafana Labs", + Module: "plugins/test-app/module", + BaseURL: "public/plugins/test-app", + }, + } + + reg := fakes.NewFakePluginRegistry() + storage := fakes.NewFakePluginStorage() + procPrvdr := fakes.NewFakeBackendProcessProvider() + procMgr := fakes.NewFakeProcessManager() + apiCalled := false + cfg := &config.Cfg{Features: featuremgmt.WithFeatures([]interface{}{"pluginsAPIManifestKey"}...)} + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/plugins/ci/keys" { + w.WriteHeader(http.StatusOK) + // Use the hardcoded key + k, err := manifestverifier.New(&config.Cfg{}, log.New("test")).GetPublicKey("7e4d0c6a708866e7") + require.NoError(t, err) + data := struct { + Items []manifestverifier.ManifestKeys `json:"items"` + }{ + Items: []manifestverifier.ManifestKeys{{PublicKey: k, KeyID: "7e4d0c6a708866e7"}}, + } + b, err := json.Marshal(data) + require.NoError(t, err) + _, err = w.Write(b) + require.NoError(t, err) + apiCalled = true + return + } + w.WriteHeader(http.StatusNotFound) + })) + cfg.GrafanaComURL = s.URL + l := newLoader(cfg, func(l *Loader) { + l.pluginRegistry = reg + l.pluginStorage = storage + l.processManager = procMgr + l.pluginInitializer = initializer.New(cfg, procPrvdr, fakes.NewFakeLicensingService()) + }) + got, err := l.Load(context.Background(), &fakes.FakePluginSource{ + PluginClassFunc: func(ctx context.Context) plugins.Class { + return plugins.External + }, + PluginURIsFunc: func(ctx context.Context) []string { + return []string{pluginDir} + }, + }) + require.NoError(t, err) + require.True(t, apiCalled) + + if !cmp.Equal(got, expected, compareOpts...) { + t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...)) + } + + verifyState(t, expected, reg, procPrvdr, storage, procMgr) + }) +} + func TestLoader_Load_NestedPlugins(t *testing.T) { rootDir, err := filepath.Abs("../") if err != nil { @@ -1435,7 +1551,7 @@ func Test_setPathsBasedOnApp(t *testing.T) { func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader { l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(), fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakePluginStorage(), - fakes.NewFakeRoleRegistry(), assetpath.ProvideService(pluginscdn.ProvideService(cfg)), finder.NewLocalFinder()) + fakes.NewFakeRoleRegistry(), assetpath.ProvideService(pluginscdn.ProvideService(cfg)), finder.NewLocalFinder(), signature.ProvideService(cfg)) for _, cb := range cbs { cb(l) diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index 56899d80ec4..52ec0f71d09 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -111,13 +111,13 @@ func TestIntegrationPluginManager(t *testing.T) { coreRegistry := coreplugin.ProvideCoreRegistry(am, cw, cm, es, grap, idb, lk, otsdb, pr, tmpo, td, pg, my, ms, graf, phlare, parca) - pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg) + pCfg, err := config.ProvideConfig(setting.ProvideProvider(cfg), cfg, featuremgmt.WithFeatures()) require.NoError(t, err) reg := registry.ProvideService() lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg}) l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg), reg, provider.ProvideService(coreRegistry), finder.NewLocalFinder(), fakes.NewFakeRoleRegistry(), - assetpath.ProvideService(pluginscdn.ProvideService(pCfg))) + assetpath.ProvideService(pluginscdn.ProvideService(pCfg)), signature.ProvideService(pCfg)) srcs := sources.ProvideService(cfg, pCfg) ps, err := store.ProvideService(reg, srcs, l) require.NoError(t, err) diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 604795edd99..7fe263ed9ed 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -1,7 +1,6 @@ package signature import ( - "bytes" "context" "crypto/sha256" "encoding/hex" @@ -16,42 +15,15 @@ import ( "runtime" "strings" - "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/clearsign" - "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/gobwas/glob" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/manager/signature/manifestverifier" "github.com/grafana/grafana/pkg/setting" ) -// Soon we can fetch keys from: -// -// https://grafana.com/api/plugins/ci/keys -const publicKeyText = `-----BEGIN PGP PUBLIC KEY BLOCK----- -Version: OpenPGP.js v4.10.1 -Comment: https://openpgpjs.org - -xpMEXpTXXxMFK4EEACMEIwQBiOUQhvGbDLvndE0fEXaR0908wXzPGFpf0P0Z -HJ06tsq+0higIYHp7WTNJVEZtcwoYLcPRGaa9OQqbUU63BEyZdgAkPTz3RFd -5+TkDWZizDcaVFhzbDd500yTwexrpIrdInwC/jrgs7Zy/15h8KA59XXUkdmT -YB6TR+OA9RKME+dCJozNGUdyYWZhbmEgPGVuZ0BncmFmYW5hLmNvbT7CvAQQ -EwoAIAUCXpTXXwYLCQcIAwIEFQgKAgQWAgEAAhkBAhsDAh4BAAoJEH5NDGpw -iGbnaWoCCQGQ3SQnCkRWrG6XrMkXOKfDTX2ow9fuoErN46BeKmLM4f1EkDZQ -Tpq3SE8+My8B5BIH3SOcBeKzi3S57JHGBdFA+wIJAYWMrJNIvw8GeXne+oUo -NzzACdvfqXAZEp/HFMQhCKfEoWGJE8d2YmwY2+3GufVRTI5lQnZOHLE8L/Vc -1S5MXESjzpcEXpTXXxIFK4EEACMEIwQBtHX/SD5Qm3v4V92qpaIZQgtTX0sT -cFPjYWAHqsQ1iENrYN/vg1wU3ADlYATvydOQYvkTyT/tbDvx2Fse8PL84MQA -YKKQ6AJ3gLVvmeouZdU03YoV4MYaT8KbnJUkZQZkqdz2riOlySNI9CG3oYmv -omjUAtzgAgnCcurfGLZkkMxlmY8DAQoJwqQEGBMKAAkFAl6U118CGwwACgkQ -fk0ManCIZuc0jAIJAVw2xdLr4ZQqPUhubrUyFcqlWoW8dQoQagwO8s8ubmby -KuLA9FWJkfuuRQr+O9gHkDVCez3aism7zmJBqIOi38aNAgjJ3bo6leSS2jR/ -x5NqiKVi83tiXDPncDQYPymOnMhW0l7CVA7wj75HrFvvlRI/4MArlbsZ2tBn -N1c5v9v/4h6qeA== -=DNbR ------END PGP PUBLIC KEY BLOCK----- -` - var ( runningWindows = runtime.GOOS == "windows" @@ -79,9 +51,24 @@ func (m *PluginManifest) isV2() bool { return strings.HasPrefix(m.ManifestVersion, "2.") } -// ReadPluginManifest attempts to read and verify the plugin manifest +type Signature struct { + verifier *manifestverifier.ManifestVerifier + mlog log.Logger +} + +var _ plugins.SignatureCalculator = &Signature{} + +func ProvideService(cfg *config.Cfg) *Signature { + log := log.New("plugin.signature") + return &Signature{ + verifier: manifestverifier.New(cfg, log), + mlog: log, + } +} + +// readPluginManifest attempts to read and verify the plugin manifest // if any error occurs or the manifest is not valid, this will return an error -func ReadPluginManifest(body []byte) (*PluginManifest, error) { +func (s *Signature) readPluginManifest(body []byte) (*PluginManifest, error) { block, _ := clearsign.Decode(body) if block == nil { return nil, errors.New("unable to decode manifest") @@ -94,20 +81,20 @@ func ReadPluginManifest(body []byte) (*PluginManifest, error) { return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err) } - if err = validateManifest(manifest, block); err != nil { + if err = s.validateManifest(manifest, block); err != nil { return nil, err } return &manifest, nil } -func Calculate(ctx context.Context, mlog log.Logger, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { +func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { if defaultSignature, exists := src.DefaultSignature(ctx); exists { return defaultSignature, nil } if len(plugin.FS.Files()) == 0 { - mlog.Warn("No plugin file information in directory", "pluginID", plugin.JSONData.ID) + s.mlog.Warn("No plugin file information in directory", "pluginID", plugin.JSONData.ID) return plugins.Signature{ Status: plugins.SignatureInvalid, }, nil @@ -116,13 +103,13 @@ func Calculate(ctx context.Context, mlog log.Logger, src plugins.PluginSource, p f, err := plugin.FS.Open("MANIFEST.txt") if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { - mlog.Debug("Could not find a MANIFEST.txt", "id", plugin.JSONData.ID, "err", err) + s.mlog.Debug("Could not find a MANIFEST.txt", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureUnsigned, }, nil } - mlog.Debug("Could not open MANIFEST.txt", "id", plugin.JSONData.ID, "err", err) + s.mlog.Debug("Could not open MANIFEST.txt", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureInvalid, }, nil @@ -132,21 +119,21 @@ func Calculate(ctx context.Context, mlog log.Logger, src plugins.PluginSource, p return } if err = f.Close(); err != nil { - mlog.Warn("Failed to close plugin MANIFEST file", "err", err) + s.mlog.Warn("Failed to close plugin MANIFEST file", "err", err) } }() byteValue, err := io.ReadAll(f) if err != nil || len(byteValue) < 10 { - mlog.Debug("MANIFEST.TXT is invalid", "id", plugin.JSONData.ID) + s.mlog.Debug("MANIFEST.TXT is invalid", "id", plugin.JSONData.ID) return plugins.Signature{ Status: plugins.SignatureUnsigned, }, nil } - manifest, err := ReadPluginManifest(byteValue) + manifest, err := s.readPluginManifest(byteValue) if err != nil { - mlog.Debug("Plugin signature invalid", "id", plugin.JSONData.ID, "err", err) + s.mlog.Debug("Plugin signature invalid", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureInvalid, }, nil @@ -168,10 +155,10 @@ func Calculate(ctx context.Context, mlog log.Logger, src plugins.PluginSource, p // Validate that plugin is running within defined root URLs if len(manifest.RootURLs) > 0 { if match, err := urlMatch(manifest.RootURLs, setting.AppUrl, manifest.SignatureType); err != nil { - mlog.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs) + s.mlog.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs) return plugins.Signature{}, err } else if !match { - mlog.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID, + s.mlog.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID, "appUrl", setting.AppUrl, "rootUrls", manifest.RootURLs) return plugins.Signature{ Status: plugins.SignatureInvalid, @@ -183,7 +170,7 @@ func Calculate(ctx context.Context, mlog log.Logger, src plugins.PluginSource, p // Verify the manifest contents for p, hash := range manifest.Files { - err = verifyHash(mlog, plugin, p, hash) + err = verifyHash(s.mlog, plugin, p, hash) if err != nil { return plugins.Signature{ Status: plugins.SignatureModified, @@ -213,13 +200,13 @@ func Calculate(ctx context.Context, mlog log.Logger, src plugins.PluginSource, p } if len(unsignedFiles) > 0 { - mlog.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles) + s.mlog.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles) return plugins.Signature{ Status: plugins.SignatureModified, }, nil } - mlog.Debug("Plugin signature valid", "id", plugin.JSONData.ID) + s.mlog.Debug("Plugin signature valid", "id", plugin.JSONData.ID) return plugins.Signature{ Status: plugins.SignatureValid, Type: manifest.SignatureType, @@ -299,7 +286,7 @@ func (r invalidFieldErr) Error() string { return fmt.Sprintf("valid manifest field %s is required", r.field) } -func validateManifest(m PluginManifest, block *clearsign.Block) error { +func (s *Signature) validateManifest(m PluginManifest, block *clearsign.Block) error { if len(m.Plugin) == 0 { return invalidFieldErr{field: "plugin"} } @@ -326,16 +313,6 @@ func validateManifest(m PluginManifest, block *clearsign.Block) error { return fmt.Errorf("%s is not a valid signature type", m.SignatureType) } } - keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKeyText)) - if err != nil { - return fmt.Errorf("%v: %w", "failed to parse public key", err) - } - if _, err = openpgp.CheckDetachedSignature(keyring, - bytes.NewBuffer(block.Bytes), - block.ArmoredSignature.Body, &packet.Config{}); err != nil { - return fmt.Errorf("%v: %w", "failed to check signature", err) - } - - return nil + return s.verifier.Verify(m.KeyID, block) } diff --git a/pkg/plugins/manager/signature/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go index e3157c1b239..3a1e887ffa8 100644 --- a/pkg/plugins/manager/signature/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/setting" ) @@ -49,7 +49,8 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX -----END PGP SIGNATURE-----` t.Run("valid manifest", func(t *testing.T) { - manifest, err := ReadPluginManifest([]byte(txt)) + s := ProvideService(&config.Cfg{}) + manifest, err := s.readPluginManifest([]byte(txt)) require.NoError(t, err) require.NotNil(t, manifest) @@ -65,7 +66,8 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX t.Run("invalid manifest", func(t *testing.T) { modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx") - _, err := ReadPluginManifest([]byte(modified)) + s := ProvideService(&config.Cfg{}) + _, err := s.readPluginManifest([]byte(modified)) require.Error(t, err) }) } @@ -102,7 +104,8 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI= -----END PGP SIGNATURE-----` t.Run("valid manifest", func(t *testing.T) { - manifest, err := ReadPluginManifest([]byte(txt)) + s := ProvideService(&config.Cfg{}) + manifest, err := s.readPluginManifest([]byte(txt)) require.NoError(t, err) require.NotNil(t, manifest) @@ -155,7 +158,8 @@ func TestCalculate(t *testing.T) { setting.AppUrl = tc.appURL basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin") - sig, err := Calculate(context.Background(), log.NewTestLogger(), &fakes.FakePluginSource{ + s := ProvideService(&config.Cfg{}) + sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.External }, @@ -185,7 +189,8 @@ func TestCalculate(t *testing.T) { basePath := "../testdata/renderer-added-file/plugin" runningWindows = true - sig, err := Calculate(context.Background(), log.NewTestLogger(), &fakes.FakePluginSource{ + s := ProvideService(&config.Cfg{}) + sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.External }, @@ -233,7 +238,8 @@ func TestCalculate(t *testing.T) { basePath := "../testdata/app-with-child/dist" - sig, err := Calculate(context.Background(), log.NewTestLogger(), &fakes.FakePluginSource{ + s := ProvideService(&config.Cfg{}) + sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.External }, @@ -677,7 +683,8 @@ func Test_validateManifest(t *testing.T) { } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - err := validateManifest(*tc.manifest, nil) + s := ProvideService(&config.Cfg{}) + err := s.validateManifest(*tc.manifest, nil) require.Errorf(t, err, tc.expectedErr) }) } diff --git a/pkg/plugins/manager/signature/manifestverifier/verifier.go b/pkg/plugins/manager/signature/manifestverifier/verifier.go new file mode 100644 index 00000000000..a5cd6ce57cb --- /dev/null +++ b/pkg/plugins/manager/signature/manifestverifier/verifier.go @@ -0,0 +1,171 @@ +package manifestverifier + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/log" +) + +// ManifestKeys is the database representation of public keys +// used to verify plugin manifests. +type ManifestKeys struct { + KeyID string `json:"keyId"` + PublicKey string `json:"public"` + Since int64 `json:"since"` +} + +type ManifestVerifier struct { + cfg *config.Cfg + mlog log.Logger + + lock sync.Mutex + cli http.Client + publicKeys map[string]ManifestKeys +} + +func New(cfg *config.Cfg, mlog log.Logger) *ManifestVerifier { + return &ManifestVerifier{ + cfg: cfg, + publicKeys: map[string]ManifestKeys{}, + mlog: mlog, + cli: makeHttpClient(), + } +} + +const publicKeyText = `-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP.js v4.10.1 +Comment: https://openpgpjs.org + +xpMEXpTXXxMFK4EEACMEIwQBiOUQhvGbDLvndE0fEXaR0908wXzPGFpf0P0Z +HJ06tsq+0higIYHp7WTNJVEZtcwoYLcPRGaa9OQqbUU63BEyZdgAkPTz3RFd +5+TkDWZizDcaVFhzbDd500yTwexrpIrdInwC/jrgs7Zy/15h8KA59XXUkdmT +YB6TR+OA9RKME+dCJozNGUdyYWZhbmEgPGVuZ0BncmFmYW5hLmNvbT7CvAQQ +EwoAIAUCXpTXXwYLCQcIAwIEFQgKAgQWAgEAAhkBAhsDAh4BAAoJEH5NDGpw +iGbnaWoCCQGQ3SQnCkRWrG6XrMkXOKfDTX2ow9fuoErN46BeKmLM4f1EkDZQ +Tpq3SE8+My8B5BIH3SOcBeKzi3S57JHGBdFA+wIJAYWMrJNIvw8GeXne+oUo +NzzACdvfqXAZEp/HFMQhCKfEoWGJE8d2YmwY2+3GufVRTI5lQnZOHLE8L/Vc +1S5MXESjzpcEXpTXXxIFK4EEACMEIwQBtHX/SD5Qm3v4V92qpaIZQgtTX0sT +cFPjYWAHqsQ1iENrYN/vg1wU3ADlYATvydOQYvkTyT/tbDvx2Fse8PL84MQA +YKKQ6AJ3gLVvmeouZdU03YoV4MYaT8KbnJUkZQZkqdz2riOlySNI9CG3oYmv +omjUAtzgAgnCcurfGLZkkMxlmY8DAQoJwqQEGBMKAAkFAl6U118CGwwACgkQ +fk0ManCIZuc0jAIJAVw2xdLr4ZQqPUhubrUyFcqlWoW8dQoQagwO8s8ubmby +KuLA9FWJkfuuRQr+O9gHkDVCez3aism7zmJBqIOi38aNAgjJ3bo6leSS2jR/ +x5NqiKVi83tiXDPncDQYPymOnMhW0l7CVA7wj75HrFvvlRI/4MArlbsZ2tBn +N1c5v9v/4h6qeA== +=DNbR +-----END PGP PUBLIC KEY BLOCK----- +` + +// getPublicKey loads public keys from: +// - The hard-coded value if the feature flag is not enabled. +// - A cached value from memory if it has been already retrieved. +// - The Grafana.com API if the database is empty. +func (pmv *ManifestVerifier) GetPublicKey(keyID string) (string, error) { + if pmv.cfg == nil || pmv.cfg.Features == nil || !pmv.cfg.Features.IsEnabled("pluginsAPIManifestKey") { + return publicKeyText, nil + } + + pmv.lock.Lock() + defer pmv.lock.Unlock() + + key, exist := pmv.publicKeys[keyID] + if exist { + return key.PublicKey, nil + } + + // Retrieve the key from the API and store it in the database + var data struct { + Items []ManifestKeys + } + + url, err := url.JoinPath(pmv.cfg.GrafanaComURL, "/api/plugins/ci/keys") // nolint:gosec URL is provided by config + if err != nil { + return "", err + } + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + + resp, err := pmv.cli.Do(req) + if err != nil { + return "", err + } + defer func() { + err := resp.Body.Close() + if err != nil { + pmv.mlog.Warn("error closing response body", "error", err) + } + }() + + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + + if len(data.Items) == 0 { + return "", errors.New("missing public key") + } + + for _, key := range data.Items { + pmv.publicKeys[key.KeyID] = key + } + + key, exist = pmv.publicKeys[keyID] + if exist { + return key.PublicKey, nil + } + + return "", fmt.Errorf("missing public key for %s", keyID) +} + +func (pmv *ManifestVerifier) Verify(keyID string, block *clearsign.Block) error { + publicKey, err := pmv.GetPublicKey(keyID) + if err != nil { + return err + } + + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKey)) + if err != nil { + return fmt.Errorf("%v: %w", "failed to parse public key", err) + } + + if _, err = openpgp.CheckDetachedSignature(keyring, + bytes.NewBuffer(block.Bytes), + block.ArmoredSignature.Body, &packet.Config{}); err != nil { + return fmt.Errorf("%v: %w", "failed to check signature", err) + } + + return nil +} + +// Same configuration as pkg/plugins/repo/client.go +func makeHttpClient() http.Client { + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + return http.Client{ + Timeout: 10 * time.Second, + Transport: tr, + } +} diff --git a/pkg/plugins/manager/signature/manifestverifier/verifier_test.go b/pkg/plugins/manager/signature/manifestverifier/verifier_test.go new file mode 100644 index 00000000000..116d447b3d2 --- /dev/null +++ b/pkg/plugins/manager/signature/manifestverifier/verifier_test.go @@ -0,0 +1,74 @@ +package manifestverifier + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/stretchr/testify/require" +) + +func Test_Verify(t *testing.T) { + t.Run("it should verify a manifest with the default key", func(t *testing.T) { + v := New(&config.Cfg{}, log.New("test")) + + body, err := os.ReadFile("../../testdata/test-app/MANIFEST.txt") + if err != nil { + t.Fatal(err) + } + + block, _ := clearsign.Decode(body) + if block == nil { + t.Fatal("failed to decode") + } + + err = v.Verify("7e4d0c6a708866e7", block) + require.NoError(t, err) + }) + + t.Run("it should verify a manifest with the API key", func(t *testing.T) { + cfg := &config.Cfg{ + Features: featuremgmt.WithFeatures([]interface{}{"pluginsAPIManifestKey"}...), + } + v := New(cfg, log.New("test")) + apiCalled := false + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/plugins/ci/keys" { + w.WriteHeader(http.StatusOK) + data := struct { + Items []ManifestKeys `json:"items"` + }{ + Items: []ManifestKeys{{PublicKey: publicKeyText, KeyID: "7e4d0c6a708866e7"}}, + } + b, err := json.Marshal(data) + require.NoError(t, err) + _, err = w.Write(b) + require.NoError(t, err) + apiCalled = true + return + } + w.WriteHeader(http.StatusNotFound) + })) + cfg.GrafanaComURL = s.URL + + body, err := os.ReadFile("../../testdata/test-app/MANIFEST.txt") + if err != nil { + t.Fatal(err) + } + + block, _ := clearsign.Decode(body) + if block == nil { + t.Fatal("failed to decode") + } + + err = v.Verify("7e4d0c6a708866e7", block) + require.NoError(t, err) + require.Equal(t, true, apiCalled) + }) +} diff --git a/pkg/plugins/repo/service.go b/pkg/plugins/repo/service.go index 73cdf14b824..6cd7dec3739 100644 --- a/pkg/plugins/repo/service.go +++ b/pkg/plugins/repo/service.go @@ -8,6 +8,7 @@ import ( "path" "strings" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" ) @@ -18,9 +19,12 @@ type Manager struct { log log.PrettyLogger } -func ProvideService() *Manager { - defaultBaseURL := "https://grafana.com/api/plugins" - return New(false, defaultBaseURL, log.NewPrettyLogger("plugin.repository")) +func ProvideService(cfg *config.Cfg) (*Manager, error) { + defaultBaseURL, err := url.JoinPath(cfg.GrafanaComURL, "/api/plugins") + if err != nil { + return nil, err + } + return New(false, defaultBaseURL, log.NewPrettyLogger("plugin.repository")), nil } func New(skipTLSVerify bool, baseURL string, logger log.PrettyLogger) *Manager { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bc3da24850c..1b135bfb272 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -518,6 +518,12 @@ var ( State: FeatureStateAlpha, Owner: grafanaAuthnzSquad, }, + { + Name: "pluginsAPIManifestKey", + Description: "Use grafana.com API to retrieve the public manifest key", + State: FeatureStateAlpha, + Owner: grafanaPluginsPlatformSquad, + }, { Name: "advancedDataSourcePicker", Description: "Enable a new data source picker with contextual information, recently used order, CSV upload and advanced mode", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index ed9c0c4aa67..e0b657a6b6e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -77,5 +77,6 @@ dataplaneFrontendFallback,alpha,@grafana/observability-metrics,false,false,false useCachingService,stable,@grafana/grafana-operator-experience-squad,false,false,true,false enableElasticsearchBackendQuerying,beta,@grafana/observability-logs,false,false,false,false authenticationConfigUI,alpha,@grafana/grafana-authnz-team,false,false,false,false +pluginsAPIManifestKey,alpha,@grafana/plugins-platform-backend,false,false,false,false advancedDataSourcePicker,alpha,@grafana/dashboards-squad,false,false,false,true opensearchDetectVersion,alpha,@grafana/aws-plugins,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f24b65e4eb6..b22411130ad 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -319,6 +319,10 @@ const ( // Enables authentication configuration UI FlagAuthenticationConfigUI = "authenticationConfigUI" + // FlagPluginsAPIManifestKey + // Use grafana.com API to retrieve the public manifest key + FlagPluginsAPIManifestKey = "pluginsAPIManifestKey" + // FlagAdvancedDataSourcePicker // Enable a new data source picker with contextual information, recently used order, CSV upload and advanced mode FlagAdvancedDataSourcePicker = "advancedDataSourcePicker" diff --git a/pkg/services/pluginsintegration/config/config.go b/pkg/services/pluginsintegration/config/config.go index 762603b0476..ced64a8895d 100644 --- a/pkg/services/pluginsintegration/config/config.go +++ b/pkg/services/pluginsintegration/config/config.go @@ -5,10 +5,11 @@ import ( "strings" pCfg "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) -func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) (*pCfg.Cfg, error) { +func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg, features *featuremgmt.FeatureManager) (*pCfg.Cfg, error) { plugins := settingProvider.Section("plugins") allowedUnsigned := grafanaCfg.PluginsAllowUnsigned if len(plugins.KeyValue("allow_loading_unsigned_plugins").Value()) > 0 { @@ -25,6 +26,7 @@ func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) (* if err != nil { return nil, fmt.Errorf("new opentelemetry cfg: %w", err) } + return pCfg.NewCfg( settingProvider.KeyValue("", "app_mode").MustBool(grafanaCfg.Env == setting.Dev), grafanaCfg.PluginsPath, @@ -38,6 +40,7 @@ func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) (* grafanaCfg.PluginLogBackendRequests, grafanaCfg.PluginsCDNURLTemplate, tracingCfg, + featuremgmt.ProvideToggles(features), ), nil } diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 0ae3e011d73..bc4ab99eb47 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -66,6 +66,8 @@ var WireSet = wire.NewSet( wire.Bind(new(pluginsettings.Service), new(*pluginSettings.Service)), filestore.ProvideService, wire.Bind(new(plugins.FileStore), new(*filestore.Service)), + wire.Bind(new(plugins.SignatureCalculator), new(*signature.Signature)), + signature.ProvideService, ) // WireExtensionSet provides a wire.ProviderSet of plugin providers that can be