feat(ssf): 实现安全事件一键连接与凭据托管
新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。 增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user