feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package auditrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderKeyID = "X-YoVision-Key-Id"
|
||||
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||
HeaderNonce = "X-YoVision-Nonce"
|
||||
HeaderSignature = "X-YoVision-Signature"
|
||||
RelayPath = "/internal/v1/audit-events:batch"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
endpoint *url.URL
|
||||
keyID string
|
||||
secret []byte
|
||||
httpClient *http.Client
|
||||
now func() time.Time
|
||||
nonce func() (string, error)
|
||||
}
|
||||
|
||||
func NewClient(rawURL, keyID string, secret []byte, client *http.Client) (*Client, error) {
|
||||
endpoint, err := ValidateEndpoint(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keyID == "" || len(secret) < 32 {
|
||||
return nil, errors.New("audit relay key ID and 32-byte secret are required")
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
return &Client{endpoint: endpoint, keyID: keyID, secret: append([]byte(nil), secret...), httpClient: client, now: time.Now, nonce: randomNonce}, nil
|
||||
}
|
||||
|
||||
func ValidateEndpoint(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" || parsed.Path != RelayPath || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
return nil, errors.New("invalid Bell audit relay URL")
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
|
||||
return nil, errors.New("Bell audit relay URL requires HTTPS outside loopback")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func CanonicalString(method, path, timestamp, nonce string, body []byte) string {
|
||||
digest := sha256.Sum256(body)
|
||||
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
|
||||
}
|
||||
|
||||
func Signature(secret []byte, canonical string) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func randomNonce() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func (c *Client) Send(ctx context.Context, events []Envelope) ([]Result, error) {
|
||||
if len(events) < 1 || len(events) > MaxBatchSize {
|
||||
return nil, errors.New("audit relay batch must contain 1 to 100 events")
|
||||
}
|
||||
body, err := json.Marshal(BatchRequest{Events: events})
|
||||
if err != nil || len(body) > MaxBodyBytes {
|
||||
return nil, errors.New("encode audit relay batch")
|
||||
}
|
||||
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
timestamp := strconv.FormatInt(c.now().UTC().Unix(), 10)
|
||||
nonce, err := c.nonce()
|
||||
if err != nil {
|
||||
return nil, errors.New("generate audit relay nonce")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, errors.New("create audit relay request")
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set(HeaderKeyID, c.keyID)
|
||||
request.Header.Set(HeaderTimestamp, timestamp)
|
||||
request.Header.Set(HeaderNonce, nonce)
|
||||
request.Header.Set(HeaderSignature, Signature(c.secret, CanonicalString(http.MethodPost, RelayPath, timestamp, nonce, body)))
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("send audit relay request")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
return nil, fmt.Errorf("Bell audit relay returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, MaxBodyBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
var decoded BatchResponse
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
return nil, errors.New("decode audit relay response")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("audit relay response contains trailing data")
|
||||
}
|
||||
return decoded.Results, nil
|
||||
}
|
||||
Reference in New Issue
Block a user