新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。 增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
187 lines
6.3 KiB
Go
187 lines
6.3 KiB
Go
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)
|
|
}
|