Plugins: Add Subresource Integrity checks (#93024)
* Plugins: Pass hashes for SRI to frontend
* Add SRI hashes to frontendsettings DTOs
* Add docstring
* TestSriHashes
* Fix typo
* Changed SriHashes to ModuleHash
* update loader_test compareOpts
* update ModuleHash error message
* Add TestModuleHash/no_module.js
* Add omitEmpty to moduleHash
* Add ModuleHash to api/plugins/${pluginId}/settings
* moved ModuleHash field
* feat(plugins): add moduleHash to bootData and plugin types
* feat(plugins): if moduleHash is available apply it to systemjs importmap
* Calculate ModuleHash for CDN provisioned plugins
* Add ModuleHash tests for TestCalculate
* adjust test case name
* removed .envrc
* Fix signature verification failing for internal plugins
* fix tests
* Add pluginsFilesystemSriChecks feature togglemk
* renamed FilesystemSriChecksEnabled
* refactor(plugin_loader): prefer extending type declaration over ts-error
* added a couple more tests
* Removed unused features
* Removed unused argument from signature.DefaultCalculator call
* Removed unused argument from bootstrap.DefaultConstructFunc
* Moved ModuleHash to pluginassets service
* update docstring
* lint
* Removed cdn dependency from manifest.Signature
* add tests
* fix extra parameters in tests
* "fix" tests
* removed outdated test
* removed unused cdn dependency in signature.DefaultCalculator
* reduce diff
* Cache returned values
* Add support for deeply nested plugins (more than 1 hierarchy level)
* simplify cache usage
* refactor TestService_ModuleHash_Cache
* removed unused testdata
* re-generate feature toggles
* use version for module hash cache
* Renamed feature toggle to pluginsSriChecks and use it for both cdn and filesystem
* Removed app/types/system-integrity.d.ts
* re-generate feature toggles
* re-generate feature toggles
* feat(plugins): put systemjs integrity hash behind feature flag
---------
Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
co-authored by
Jack Westbrook
parent
153036be2e
commit
0db65d229e
@@ -2,14 +2,21 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,18 +28,24 @@ var (
|
||||
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
|
||||
)
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, cdn *pluginscdn.Service) *Service {
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
log: log.New("pluginassets"),
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
signature: sig,
|
||||
store: store,
|
||||
log: log.New("pluginassets"),
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *setting.Cfg
|
||||
cdn *pluginscdn.Service
|
||||
log log.Logger
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
signature *signature.Signature
|
||||
store pluginstore.Store
|
||||
log log.Logger
|
||||
|
||||
moduleHashCache sync.Map
|
||||
}
|
||||
|
||||
// LoadingStrategy calculates the loading strategy for a plugin.
|
||||
@@ -69,6 +82,86 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
|
||||
return plugins.LoadingStrategyFetch
|
||||
}
|
||||
|
||||
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's MANIFEST.txt file.
|
||||
// The plugin can also be a nested plugin.
|
||||
// If the plugin is unsigned, an empty string is returned.
|
||||
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
|
||||
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
|
||||
k := s.moduleHashCacheKey(p)
|
||||
cachedValue, ok := s.moduleHashCache.Load(k)
|
||||
if ok {
|
||||
return cachedValue.(string)
|
||||
}
|
||||
mh, err := s.moduleHash(ctx, p, "")
|
||||
if err != nil {
|
||||
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
|
||||
}
|
||||
s.moduleHashCache.Store(k, mh)
|
||||
return mh
|
||||
}
|
||||
|
||||
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
|
||||
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
|
||||
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
|
||||
// module.js file, rather than for the provided plugin.
|
||||
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
|
||||
if !s.cfg.Features.SriChecksEnabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ignore unsigned plugins
|
||||
if !p.Signature.IsValid() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
// Nested plugin
|
||||
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
|
||||
}
|
||||
|
||||
// The module hash is contained within the parent's MANIFEST.txt file.
|
||||
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
|
||||
//
|
||||
// ```
|
||||
// "datasource/module.js": "1234567890abcdef..."
|
||||
// ```
|
||||
//
|
||||
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
|
||||
// to get the correct module hash for the nested plugin.
|
||||
if childFSBase == "" {
|
||||
childFSBase = p.Base()
|
||||
}
|
||||
return s.moduleHash(ctx, parent, childFSBase)
|
||||
}
|
||||
|
||||
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if !manifest.IsV2() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var childPath string
|
||||
if childFSBase != "" {
|
||||
// Calculate the relative path of the child plugin folder from the parent plugin folder.
|
||||
childPath, err = p.FS.Rel(childFSBase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// MANIFETS.txt uses forward slashes as path separators.
|
||||
childPath = filepath.ToSlash(childPath)
|
||||
}
|
||||
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return convertHashForSRI(moduleHash)
|
||||
}
|
||||
|
||||
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
|
||||
createPluginVer, err := semver.NewVersion(cpv)
|
||||
@@ -86,3 +179,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
func (s *Service) cdnEnabled(pluginID string, class plugins.Class) bool {
|
||||
return s.cdn.PluginSupported(pluginID) || class == plugins.ClassCDN
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) (string, error) {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex decode string: %w", err)
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
|
||||
}
|
||||
|
||||
// moduleHashCacheKey returns a unique key for the module hash cache.
|
||||
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
|
||||
return p.ID + ":" + p.Info.Version
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -34,7 +38,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings(pluginID, map[string]string{
|
||||
CreatePluginVersionCfgKey: compatVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false),
|
||||
plugin: newPlugin(pluginID, withAngular(false)),
|
||||
expected: plugins.LoadingStrategyScript,
|
||||
},
|
||||
{
|
||||
@@ -42,7 +46,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings("parent-datasource", map[string]string{
|
||||
CreatePluginVersionCfgKey: compatVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
|
||||
return p
|
||||
}),
|
||||
@@ -53,7 +57,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings(pluginID, map[string]string{
|
||||
CreatePluginVersionCfgKey: futureVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false),
|
||||
plugin: newPlugin(pluginID, withAngular(false)),
|
||||
expected: plugins.LoadingStrategyScript,
|
||||
},
|
||||
{
|
||||
@@ -61,7 +65,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings(pluginID, map[string]string{
|
||||
// NOTE: cdn key is not set
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false),
|
||||
plugin: newPlugin(pluginID, withAngular(false)),
|
||||
expected: plugins.LoadingStrategyScript,
|
||||
},
|
||||
{
|
||||
@@ -70,7 +74,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
CreatePluginVersionCfgKey: incompatVersion,
|
||||
// NOTE: cdn key is not set
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Class = plugins.ClassExternal
|
||||
return p
|
||||
}),
|
||||
@@ -83,7 +87,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
|
||||
return p
|
||||
}),
|
||||
@@ -96,8 +100,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Angular.Detected = true
|
||||
plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
|
||||
return p
|
||||
}),
|
||||
@@ -106,8 +109,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
{
|
||||
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular",
|
||||
pluginSettings: setting.PluginSettings{},
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Angular.Detected = true
|
||||
plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
|
||||
return p
|
||||
}),
|
||||
@@ -119,7 +121,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
"cdn": "true",
|
||||
CreatePluginVersionCfgKey: incompatVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Class = plugins.ClassExternal
|
||||
return p
|
||||
}),
|
||||
@@ -130,7 +132,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings(pluginID, map[string]string{
|
||||
CreatePluginVersionCfgKey: incompatVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, true),
|
||||
plugin: newPlugin(pluginID, withAngular(true)),
|
||||
expected: plugins.LoadingStrategyFetch,
|
||||
},
|
||||
{
|
||||
@@ -139,7 +141,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
"cdn": "true",
|
||||
CreatePluginVersionCfgKey: incompatVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false),
|
||||
plugin: newPlugin(pluginID, withAngular(false)),
|
||||
expected: plugins.LoadingStrategyFetch,
|
||||
},
|
||||
{
|
||||
@@ -147,7 +149,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings(pluginID, map[string]string{
|
||||
CreatePluginVersionCfgKey: incompatVersion,
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Class = plugins.ClassCDN
|
||||
return p
|
||||
}),
|
||||
@@ -158,7 +160,7 @@ func TestService_Calculate(t *testing.T) {
|
||||
pluginSettings: newPluginSettings(pluginID, map[string]string{
|
||||
CreatePluginVersionCfgKey: "invalidSemver",
|
||||
}),
|
||||
plugin: newPlugin(pluginID, false),
|
||||
plugin: newPlugin(pluginID, withAngular(false)),
|
||||
expected: plugins.LoadingStrategyScript,
|
||||
},
|
||||
}
|
||||
@@ -179,12 +181,305 @@ func TestService_Calculate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
|
||||
func TestService_ModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
features *config.Features
|
||||
store []pluginstore.Plugin
|
||||
plugin pluginstore.Plugin
|
||||
cdn bool
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "unsigned should not return module hash",
|
||||
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "feature flag on with cdn on should return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
name: "feature flag on with cdn off should return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
name: "feature flag off with cdn on should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "feature flag off with cdn off should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
"grand-parent-app",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
|
||||
),
|
||||
newPlugin(
|
||||
"parent-datasource",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
|
||||
withParent("grand-parent-app"),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
"child-panel",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
|
||||
withParent("parent-datasource"),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash from parent if it's not registered in the store",
|
||||
store: []pluginstore.Plugin{},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pluginSettings setting.PluginSettings
|
||||
if tc.cdn {
|
||||
pluginSettings = newPluginSettings(pluginID, map[string]string{
|
||||
"cdn": "true",
|
||||
})
|
||||
}
|
||||
features := tc.features
|
||||
if features == nil {
|
||||
features = &config.Features{}
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "http://cdn.example.com",
|
||||
PluginSettings: pluginSettings,
|
||||
Features: *features,
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(tc.store...),
|
||||
)
|
||||
mh := svc.ModuleHash(context.Background(), tc.plugin)
|
||||
require.Equal(t, tc.expModuleHash, mh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash_Cache(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginSettings: setting.PluginSettings{},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
const pluginID = "grafana-test-datasource"
|
||||
|
||||
t.Run("cache key", func(t *testing.T) {
|
||||
t.Run("with version", func(t *testing.T) {
|
||||
const pluginVersion = "1.0.0"
|
||||
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
|
||||
})
|
||||
|
||||
t.Run("without version", func(t *testing.T) {
|
||||
p := newPlugin(pluginID)
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":", k, "cache key should be correct")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ModuleHash usage", func(t *testing.T) {
|
||||
pV1 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "1.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
)
|
||||
k := svc.moduleHashCacheKey(pV1)
|
||||
|
||||
_, ok := svc.moduleHashCache.Load(k)
|
||||
require.False(t, ok, "cache should initially be empty")
|
||||
|
||||
mhV1 := svc.ModuleHash(context.Background(), pV1)
|
||||
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
|
||||
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
|
||||
|
||||
cachedMh, ok := svc.moduleHashCache.Load(k)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
|
||||
|
||||
t.Run("different version uses different cache key", func(t *testing.T) {
|
||||
pV2 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "2.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
// different fs for different hash
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
)
|
||||
mhV2 := svc.ModuleHash(context.Background(), pV2)
|
||||
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
|
||||
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
|
||||
})
|
||||
|
||||
t.Run("cache should be used", func(t *testing.T) {
|
||||
// edit cache directly
|
||||
svc.moduleHashCache.Store(k, "hax")
|
||||
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertHashFromSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r, err := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
|
||||
p := pluginstore.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: pluginID,
|
||||
},
|
||||
Angular: plugins.AngularMeta{Detected: angular},
|
||||
}
|
||||
for _, cb := range cbs {
|
||||
p = cb(p)
|
||||
@@ -192,8 +487,43 @@ func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin)
|
||||
return p
|
||||
}
|
||||
|
||||
func newCfg(ps setting.PluginSettings) *setting.Cfg {
|
||||
return &setting.Cfg{
|
||||
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Info = info
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.FS = fs
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Signature = status
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Angular = plugins.AngularMeta{Detected: angular}
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func newCfg(ps setting.PluginSettings) *config.PluginManagementCfg {
|
||||
return &config.PluginManagementCfg{
|
||||
PluginSettings: ps,
|
||||
}
|
||||
}
|
||||
@@ -203,3 +533,9 @@ func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSett
|
||||
pluginID: kv,
|
||||
}
|
||||
}
|
||||
|
||||
func newSRIHash(t *testing.T, s string) string {
|
||||
r, err := convertHashForSRI(s)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
hello parent
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "app",
|
||||
"name": "Test",
|
||||
"id": "test-app",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "grafana",
|
||||
"signedByOrg": "grafana",
|
||||
"signedByOrgName": "Grafana Labs",
|
||||
"plugin": "test-app",
|
||||
"version": "1.0.0",
|
||||
"time": 1726230812215,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c",
|
||||
"something.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.11
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wrkEARMKAAYFAmbkMRwAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
|
||||
cIhm53UWAgkBE2oxqyzBji86eCOzLmCT7IgQaoSMMF48tu+XdgwFS5/NU5su
|
||||
deKad3taDnSU9a7GkCaisRVQOWy/UtFS1FNQTtkCCQBc1cZ6JsPWh2Pd60h0
|
||||
9U5aviYde6g1DCKO1riaUzHzrruBiHmHWjzr2aYwACb89vs2XcZqvue1Byb+
|
||||
y2inBDhHvQ==
|
||||
=qMej
|
||||
-----END PGP SIGNATURE-----
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "app",
|
||||
"name": "Test",
|
||||
"id": "test-app",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
hello parent
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "grafana",
|
||||
"signedByOrg": "grafana",
|
||||
"signedByOrgName": "Grafana Labs",
|
||||
"plugin": "test-app",
|
||||
"version": "1.0.0",
|
||||
"time": 1726234125061,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
|
||||
"datasource/plugin.json": "3fd712717a21617cc76f9043efcd43d4ebf5564dd155a28e4e3c736739f6931e",
|
||||
"datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
|
||||
"datasource/panels/one/plugin.json": "b9b4556a7220ea77650ffd228da6d441e68df3405d50dab5773c10f4afae5ad3",
|
||||
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
|
||||
"plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.11
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wrkEARMKAAYFAmbkPg0AIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
|
||||
cIhm5xTlAgkB3mG37KEdlP34nC69NbmriMpDH6PyyJ0IUwXB/SMTr4Gc2SvG
|
||||
cVHvih/0WqVjYKxxQI0QHoYpBQW2jPx0YJLFof8CCQBHpdEEXNTYOOZWG6Cg
|
||||
M3wB3AdCO+ChjXkKosbWqiMDfVqHFoLoLurwWxwOjvk/xTvX5GFbOxSfISyU
|
||||
8iW03F5/Sw==
|
||||
=wobV
|
||||
-----END PGP SIGNATURE-----
|
||||
+1
@@ -0,0 +1 @@
|
||||
hello datasource
|
||||
+1
@@ -0,0 +1 @@
|
||||
hello panel
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Test Panel",
|
||||
"id": "test-panel",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test Datasource",
|
||||
"id": "test-datasource",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
hello parent
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "app",
|
||||
"name": "Test",
|
||||
"id": "test-app",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "grafana",
|
||||
"signedByOrg": "grafana",
|
||||
"signedByOrgName": "Grafana Labs",
|
||||
"plugin": "test-app",
|
||||
"version": "1.0.0",
|
||||
"time": 1726230803822,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
|
||||
"plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c",
|
||||
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
|
||||
"datasource/plugin.json": "3fd712717a21617cc76f9043efcd43d4ebf5564dd155a28e4e3c736739f6931e",
|
||||
"panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
|
||||
"panels/one/plugin.json": "b9b4556a7220ea77650ffd228da6d441e68df3405d50dab5773c10f4afae5ad3"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.11
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wrkEARMKAAYFAmbkMRQAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
|
||||
cIhm50C8AgkAmzQpeYPnCgYimLGp5UGnCTrkbUEEqW+qXESrhi5T5ZuM+SzT
|
||||
BcRlC5pP6+wuyXAIdfppzWQ/umkkoaTIuub0TXQCCQHVcpWKy4acRL9TlORQ
|
||||
1VzVEV9PW0+x606HsDDHkterKQZgr5X6I/sTbSpBDMWPCMxqAk9fZn3G4iuq
|
||||
MyS+hwUZDQ==
|
||||
=7/Rd
|
||||
-----END PGP SIGNATURE-----
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
hello datasource
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test Datasource",
|
||||
"id": "test-datasource",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
hello parent
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
hello panel
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Test Panel",
|
||||
"id": "test-panel",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "app",
|
||||
"name": "Test",
|
||||
"id": "test-app",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "private",
|
||||
"signedByOrg": "giuseppeguerra",
|
||||
"signedByOrgName": "giuseppeguerra",
|
||||
"rootUrls": [
|
||||
"http://127.0.0.1:3000/"
|
||||
],
|
||||
"plugin": "test-datasource",
|
||||
"version": "1.0.0",
|
||||
"time": 1725959570435,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
|
||||
"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.11
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wrkEARMKAAYFAmbgDZIAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq
|
||||
cIhm5wbfAgkAXmKJcM8uAKb3TepYW/oyGhRLR8L6eM9mCoYwKkatITKJ6bRe
|
||||
Wnz37AMcPx0DahgfCzCXRLo4CspPJylr2JV8DagCCQCfCjHgLFhKGpBP71Y1
|
||||
mgcQ1/CJefb6B2H45G25MwUFTlSTGLDqW4QMi2kQvXnnUMjXquv2+iVd6qyz
|
||||
0Rqvpou/QQ==
|
||||
=QNmr
|
||||
-----END PGP SIGNATURE-----
|
||||
+1
@@ -0,0 +1 @@
|
||||
hello
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test-datasource",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Giuseppe Guerra"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user