package securityevents import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" ) const ( defaultKubernetesAPIServer = "https://kubernetes.default.svc" defaultServiceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token" defaultServiceAccountCA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" ) var kubernetesDNSNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`) type KubernetesSecretStoreConfig struct { Namespace string SecretName string APIServer string TokenFile string CAFile string HTTPClient *http.Client } // KubernetesSecretStore keeps all generated Push Bearers as keys in one // pre-provisioned Secret. It intentionally has no permission to create or // delete Kubernetes Secret resources. type KubernetesSecretStore struct { endpoint string tokenFile string client *http.Client } func NewKubernetesSecretStore(config KubernetesSecretStoreConfig) (*KubernetesSecretStore, error) { if !validKubernetesDNSName(config.Namespace) || !validKubernetesDNSName(config.SecretName) { return nil, errors.New("Kubernetes security event Secret namespace or name is invalid") } if config.APIServer == "" { config.APIServer = defaultKubernetesAPIServer } if config.TokenFile == "" { config.TokenFile = defaultServiceAccountToken } parsed, err := url.Parse(strings.TrimRight(config.APIServer, "/")) if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "https" && config.HTTPClient == nil) { return nil, errors.New("Kubernetes API server URL is invalid") } client := config.HTTPClient if client == nil { if config.CAFile == "" { config.CAFile = defaultServiceAccountCA } certificate, readErr := os.ReadFile(config.CAFile) if readErr != nil { return nil, fmt.Errorf("read Kubernetes service account CA: %w", readErr) } roots := x509.NewCertPool() if !roots.AppendCertsFromPEM(certificate) { return nil, errors.New("Kubernetes service account CA is invalid") } transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = nil transport.DisableCompression = true transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots} client = &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } endpoint := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", strings.TrimRight(config.APIServer, "/"), config.Namespace, config.SecretName) return &KubernetesSecretStore{endpoint: endpoint, tokenFile: config.TokenFile, client: client}, nil } func (s *KubernetesSecretStore) Put(ctx context.Context, reference string, value []byte) error { if !secretReferencePattern.MatchString(reference) { return errors.New("security event secret reference is invalid") } if len(value) < 32 || len(value) > 4096 { return errors.New("security event secret length is invalid") } return s.patch(ctx, map[string]any{reference: base64.StdEncoding.EncodeToString(value)}) } func (s *KubernetesSecretStore) Get(ctx context.Context, reference string) ([]byte, error) { if !secretReferencePattern.MatchString(reference) { return nil, errors.New("security event secret reference is invalid") } request, err := s.request(ctx, http.MethodGet, nil) if err != nil { return nil, err } response, err := s.client.Do(request) if err != nil { return nil, fmt.Errorf("read Kubernetes security event Secret: %w", err) } defer response.Body.Close() if response.StatusCode == http.StatusNotFound { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) return nil, ErrSecretNotFound } if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) return nil, fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode) } var payload struct { Data map[string]string `json:"data"` } if err := decodeLimitedJSON(response.Body, &payload); err != nil { return nil, err } encoded, ok := payload.Data[reference] if !ok { return nil, ErrSecretNotFound } value, err := base64.StdEncoding.DecodeString(encoded) if err != nil || len(value) < 32 || len(value) > 4096 { clear(value) return nil, errors.New("Kubernetes security event Secret value is invalid") } return value, nil } func (s *KubernetesSecretStore) Delete(ctx context.Context, reference string) error { if !secretReferencePattern.MatchString(reference) { return errors.New("security event secret reference is invalid") } return s.patch(ctx, map[string]any{reference: nil}) } func (s *KubernetesSecretStore) patch(ctx context.Context, data map[string]any) error { payload, err := json.Marshal(map[string]any{"data": data}) if err != nil { return err } request, err := s.request(ctx, http.MethodPatch, bytes.NewReader(payload)) if err != nil { return err } request.Header.Set("Content-Type", "application/merge-patch+json") response, err := s.client.Do(request) if err != nil { return fmt.Errorf("update Kubernetes security event Secret: %w", err) } defer response.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) if response.StatusCode == http.StatusNotFound { return errors.New("Kubernetes security event Secret is not provisioned") } if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode) } return nil } func (s *KubernetesSecretStore) request(ctx context.Context, method string, body io.Reader) (*http.Request, error) { token, err := os.ReadFile(s.tokenFile) if err != nil || strings.TrimSpace(string(token)) == "" { clear(token) return nil, errors.New("Kubernetes service account token is unavailable") } request, err := http.NewRequestWithContext(ctx, method, s.endpoint, body) if err != nil { clear(token) return nil, err } request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token))) request.Header.Set("Accept", "application/json") clear(token) return request, nil } func validKubernetesDNSName(value string) bool { return len(value) > 0 && len(value) <= 253 && kubernetesDNSNamePattern.MatchString(value) }