grafana/pkg/services/serviceaccounts/secretscan/service.go

162 lines
5.0 KiB
Go

package secretscan
import (
"context"
"fmt"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/setting"
)
const defaultURL = "https://secret-scanning.grafana.net"
type Checker interface {
CheckTokens(ctx context.Context) error
}
type CheckerClient interface {
CheckTokens(ctx context.Context, keyHashes []string) ([]Token, error)
}
type WebHookClient interface {
Notify(ctx context.Context, token *Token, tokenName string, revoked bool) error
}
type SATokenRetriever interface {
ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error)
RevokeServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error
}
// Secret Scan Service is grafana's service for checking leaked keys.
type Service struct {
store SATokenRetriever
client CheckerClient
webHookClient WebHookClient
logger log.Logger
webHookNotify bool
revoke bool // whether to revoke leaked tokens
}
func NewService(store SATokenRetriever, cfg *setting.Cfg) (*Service, error) {
secretscanBaseURL := cfg.SectionWithEnvOverrides("secretscan").Key("base_url").MustString(defaultURL)
// URL to send outgoing webhook when a token is leaked.
oncallURL := cfg.SectionWithEnvOverrides("secretscan").Key("oncall_url").MustString("")
revoke := cfg.SectionWithEnvOverrides("secretscan").Key("revoke").MustBool(true)
client, err := newClient(secretscanBaseURL, cfg.BuildVersion, cfg.Env == setting.Dev)
if err != nil {
return nil, fmt.Errorf("failed to create secretscan client: %w", err)
}
var webHookClient WebHookClient
if oncallURL != "" {
var errWebhook error
webHookClient, errWebhook = newWebHookClient(oncallURL, cfg.BuildVersion, cfg.Env == setting.Dev)
if errWebhook != nil {
return nil, fmt.Errorf("failed to create secretscan webhook client: %w", errWebhook)
}
}
return &Service{
store: store,
client: client,
webHookClient: webHookClient,
logger: log.New("secretscan"),
webHookNotify: oncallURL != "",
revoke: revoke,
}, nil
}
func (s *Service) RetrieveActiveTokens(ctx context.Context) ([]apikey.APIKey, error) {
saTokens, err := s.store.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{})
if err != nil {
return nil, fmt.Errorf("failed to retrieve service account tokens: %w", err)
}
return saTokens, nil
}
// hasExpired returns true if the token has expired.
// Duplicate to SA API. Remerge.
func hasExpired(expiration *int64) bool {
if expiration == nil {
return false
}
v := time.Unix(*expiration, 0)
return (v).Before(time.Now())
}
// CheckTokens checks for leaked tokens.
func (s *Service) CheckTokens(ctx context.Context) error {
// Retrieve all active tokens from the database.
tokens, err := s.RetrieveActiveTokens(ctx)
if err != nil {
return fmt.Errorf("failed to retrieve tokens for checking: %w", err)
}
hashes, hashMap := s.filterCheckableTokens(tokens)
if len(hashes) == 0 {
s.logger.Debug("No active tokens to check")
return nil
}
// Check if any leaked tokens exist.
secretscanTokens, err := s.client.CheckTokens(ctx, hashes)
if err != nil {
return fmt.Errorf("failed to check tokens: %w", err)
}
// Revoke leaked tokens.
// Could be done in bulk but we don't expect more than 1 or 2 tokens to be leaked per check.
for _, secretscanToken := range secretscanTokens {
secretscanToken := secretscanToken
leakedToken := hashMap[secretscanToken.Hash]
if s.revoke {
if err := s.store.RevokeServiceAccountToken(
ctx, leakedToken.OrgID, *leakedToken.ServiceAccountId, leakedToken.ID); err != nil {
s.logger.Error("Failed to delete leaked token. Revoke manually.",
"error", err, "url", secretscanToken.URL, "reported_at", secretscanToken.ReportedAt,
"token_id", leakedToken.ID, "token", leakedToken.Name, "org", leakedToken.OrgID,
"serviceAccount", *leakedToken.ServiceAccountId)
}
}
if s.webHookNotify {
if err := s.webHookClient.Notify(ctx, &secretscanToken, leakedToken.Name, s.revoke); err != nil {
s.logger.Warn("Failed to call token leak webhook", "error", err)
}
}
s.logger.Warn("Found leaked token",
"url", secretscanToken.URL, "reported_at", secretscanToken.ReportedAt,
"token_id", leakedToken.ID, "token", leakedToken.Name, "org", leakedToken.OrgID,
"serviceAccount", *leakedToken.ServiceAccountId, "revoked", s.revoke)
}
return nil
}
// filterCheckableTokens returns a list of tokens that can be checked and a map of tokens to their hashes.
func (*Service) filterCheckableTokens(tokens []apikey.APIKey) ([]string, map[string]apikey.APIKey) {
hashes := make([]string, 0, len(tokens))
hashMap := make(map[string]apikey.APIKey)
for _, token := range tokens {
if hasExpired(token.Expires) || (token.IsRevoked != nil && *token.IsRevoked) {
continue
}
hashes = append(hashes, token.Key)
hashMap[token.Key] = token
}
return hashes, hashMap
}