Chore: Use Grafana API to retrieve the public key to validate plugins (#66439)

This commit is contained in:
Andres Martinez Gotor 2023-04-18 16:12:05 +02:00 committed by GitHub
parent f612a72f96
commit 98c695c68f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 487 additions and 103 deletions

View File

@ -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 |

View File

@ -96,6 +96,7 @@ export interface FeatureToggles {
useCachingService?: boolean;
enableElasticsearchBackendQuerying?: boolean;
authenticationConfigUI?: boolean;
pluginsAPIManifestKey?: boolean;
advancedDataSourcePicker?: boolean;
opensearchDetectVersion?: boolean;
}

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
})
}

View File

@ -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,
}
}

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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",

View File

@ -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

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
77 useCachingService stable @grafana/grafana-operator-experience-squad false false true false
78 enableElasticsearchBackendQuerying beta @grafana/observability-logs false false false false
79 authenticationConfigUI alpha @grafana/grafana-authnz-team false false false false
80 pluginsAPIManifestKey alpha @grafana/plugins-platform-backend false false false false
81 advancedDataSourcePicker alpha @grafana/dashboards-squad false false false true
82 opensearchDetectVersion alpha @grafana/aws-plugins false false false true

View File

@ -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"

View File

@ -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
}

View File

@ -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