2025-04-01 10:38:02 +09:00

288 lines
6.9 KiB
Go

package secretscan
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/setting"
)
func TestService_CheckTokens(t *testing.T) {
type testCase struct {
desc string
retrievedTokens []apikey.APIKey
wantHashes []string
leakedTokens []Token
notify bool
revoke bool
}
ctx := context.Background()
falseBool := false
trueBool := true
testCases := []testCase{
{
desc: "no tokens",
retrievedTokens: []apikey.APIKey{},
leakedTokens: []Token{},
notify: false,
revoke: false,
},
{
desc: "one token leaked - no revoke, no notify",
retrievedTokens: []apikey.APIKey{{
ID: 1,
OrgID: 2,
Name: "test",
Key: "test-hash-1",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: &falseBool,
}},
wantHashes: []string{"test-hash-1"},
leakedTokens: []Token{{Hash: "test-hash-1"}},
notify: false,
revoke: false,
},
{
desc: "one token leaked - revoke, no notify",
retrievedTokens: []apikey.APIKey{{
ID: 1,
OrgID: 2,
Name: "test",
Key: "test-hash-1",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: &falseBool,
}},
wantHashes: []string{"test-hash-1"},
leakedTokens: []Token{{Hash: "test-hash-1"}},
notify: false,
revoke: true,
},
{
desc: "two tokens - one revoke, notify",
retrievedTokens: []apikey.APIKey{{
ID: 1,
OrgID: 2,
Name: "test",
Key: "test-hash-1",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: &falseBool,
}, {
ID: 2,
OrgID: 4,
Name: "test-2",
Key: "test-hash-2",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: &falseBool,
}},
wantHashes: []string{"test-hash-1", "test-hash-2"},
leakedTokens: []Token{{Hash: "test-hash-2"}},
notify: true,
revoke: true,
},
{
desc: "one token already revoked should not be checked",
retrievedTokens: []apikey.APIKey{{
ID: 1,
OrgID: 2,
Name: "test",
Key: "test-hash-1",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: &trueBool,
}},
wantHashes: []string{},
leakedTokens: []Token{},
notify: false,
revoke: true,
},
{
desc: "one token expired should not be checked",
retrievedTokens: []apikey.APIKey{{
ID: 1,
OrgID: 2,
Name: "test",
Key: "test-hash-1",
Role: "Viewer",
Expires: new(int64),
ServiceAccountId: new(int64),
IsRevoked: &falseBool,
}},
wantHashes: []string{},
leakedTokens: []Token{},
notify: false,
revoke: true,
},
}
for _, tt := range testCases {
t.Run(tt.desc, func(t *testing.T) {
tokenStore := &MockTokenRetriever{keys: tt.retrievedTokens}
client := &MockSecretScanClient{tokens: tt.leakedTokens}
notifier := &MockSecretScanNotifier{}
service := &Service{
store: tokenStore,
client: client,
webHookClient: notifier,
logger: log.New("secretscan"),
webHookNotify: tt.notify,
revoke: tt.revoke,
}
err := service.CheckTokens(ctx)
require.NoError(t, err)
if len(tt.wantHashes) > 0 {
assert.Equal(t, tt.wantHashes, client.checkCalls[0].([]string))
} else {
assert.Empty(t, client.checkCalls)
}
if len(tt.leakedTokens) > 0 {
if tt.revoke {
assert.Len(t, tokenStore.revokeCalls, len(tt.leakedTokens))
} else {
assert.Empty(t, tokenStore.revokeCalls)
}
}
if tt.notify {
assert.Len(t, notifier.notifyCalls, len(tt.leakedTokens))
} else {
assert.Empty(t, notifier.notifyCalls)
}
})
}
}
func TestService(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// Fake Secret Scanner + Webhook.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.RequestURI, "/tokens") {
_, err := io.Copy(io.Discard, r.Body)
require.NoError(t, err)
defer func() {
_ = r.Body.Close()
}()
_, _ = w.Write([]byte(`[
{"type": "token_type", "hash": "test-hash-1", "url": "http://example.com", "reported_at": "2006-01-20T01:02:03Z" }
]`))
}
if strings.Contains(r.RequestURI, "/oncall") {
var webhookReq struct {
State string `json:"state"`
Message string `json:"message"`
}
err := json.NewDecoder(r.Body).Decode(&webhookReq)
require.NoError(t, err)
defer func() {
_ = r.Body.Close()
}()
require.Equal(t, "alerting", webhookReq.State)
require.Contains(t, webhookReq.Message, "test-1")
}
}))
t.Cleanup(server.Close)
unixZero := time.Unix(0, 0).Unix()
revoked := true
tokenRetriever := &MockTokenRetriever{keys: []apikey.APIKey{
// Valid
{
ID: 1,
OrgID: 1,
Name: "test-1",
Key: "test-hash-1",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: new(bool),
},
// Expired
{
ID: 2,
OrgID: 1,
Name: "test-2",
Key: "test-hash-2",
Role: "Viewer",
Expires: &unixZero,
ServiceAccountId: new(int64),
IsRevoked: new(bool),
},
// Revoked
{
ID: 3,
OrgID: 1,
Name: "test-3",
Key: "test-hash-3",
Role: "Viewer",
Expires: nil,
ServiceAccountId: new(int64),
IsRevoked: &revoked,
},
// Revoked + Expired
{
ID: 4,
OrgID: 1,
Name: "test-4",
Key: "test-hash-4",
Role: "Viewer",
Expires: &unixZero,
ServiceAccountId: new(int64),
IsRevoked: &revoked,
},
}}
cfg := setting.NewCfg()
section := cfg.Raw.Section("secretscan")
baseURL := section.Key("base_url")
baseURL.SetValue(server.URL)
oncallURL := section.Key("oncall_url")
oncallURL.SetValue(server.URL + "/oncall")
revoke := section.Key("revoke")
revoke.SetValue("true")
service, err := NewService(tokenRetriever, cfg)
require.NoError(t, err)
require.NotNil(t, service)
err = service.CheckTokens(ctx)
require.NoError(t, err)
}