mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-24 14:29:07 +02:00
[management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555)
* [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package llm_guardrail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
|
||||
)
|
||||
|
||||
// Config is the JSON-decoded shape accepted by the factory. The
|
||||
// runtime path consumes the normalised allowlist; raw config is not
|
||||
// retained beyond construction.
|
||||
type Config struct {
|
||||
ModelAllowlist []string `json:"model_allowlist"`
|
||||
PromptCapture PromptCapture `json:"prompt_capture"`
|
||||
}
|
||||
|
||||
// PromptCapture toggles the optional prompt capture + redaction step
|
||||
// that emits llm.request_prompt onto the metadata bag.
|
||||
type PromptCapture struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RedactPii bool `json:"redact_pii"`
|
||||
}
|
||||
|
||||
// Factory builds a configured llm_guardrail middleware instance.
|
||||
type Factory struct{}
|
||||
|
||||
// ID returns the registry identifier matching the middleware ID.
|
||||
func (Factory) ID() string { return ID }
|
||||
|
||||
// New decodes the raw JSON config and returns a ready Middleware. An
|
||||
// empty / null / empty-object payload yields a zero-value Config.
|
||||
func (Factory) New(rawConfig []byte) (middleware.Middleware, error) {
|
||||
cfg := Config{}
|
||||
if len(rawConfig) > 0 && !isEmptyJSON(rawConfig) {
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
}
|
||||
return New(cfg), nil
|
||||
}
|
||||
|
||||
// isEmptyJSON reports whether the payload is whitespace, null, or an
|
||||
// empty object/array. The caller skips Unmarshal in that case so the
|
||||
// zero-value Config flows through unchanged.
|
||||
func isEmptyJSON(raw []byte) bool {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
switch trimmed {
|
||||
case "", "null", "{}", "[]":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normaliseConfig lowercases and trims allowlist entries so the runtime
|
||||
// match is case-insensitive. Empty entries are dropped.
|
||||
func normaliseConfig(cfg Config) Config {
|
||||
if len(cfg.ModelAllowlist) == 0 {
|
||||
return cfg
|
||||
}
|
||||
cleaned := make([]string, 0, len(cfg.ModelAllowlist))
|
||||
for _, entry := range cfg.ModelAllowlist {
|
||||
n := normaliseModel(entry)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, n)
|
||||
}
|
||||
cfg.ModelAllowlist = cleaned
|
||||
return cfg
|
||||
}
|
||||
|
||||
// normaliseModel lowercases and trims a single model identifier.
|
||||
func normaliseModel(model string) string {
|
||||
return strings.ToLower(strings.TrimSpace(model))
|
||||
}
|
||||
|
||||
func init() {
|
||||
builtin.Register(Factory{})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package llm_guardrail implements the SlotOnRequest middleware that
|
||||
// enforces the per-target LLM guardrail policy: a model allowlist
|
||||
// check and an opt-in prompt-capture step that may run a PII redactor
|
||||
// before emitting the prompt into the metadata bag.
|
||||
//
|
||||
// The middleware runs after llm_request_parser, which is responsible
|
||||
// for extracting the model and raw prompt onto the metadata side
|
||||
// channel. llm_guardrail consumes those keys, decides allow/deny, and
|
||||
// emits its own decision metadata plus the optional redacted prompt.
|
||||
package llm_guardrail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
// ID is the registry key for this middleware.
|
||||
const ID = "llm_guardrail"
|
||||
|
||||
const (
|
||||
version = "1.0.0"
|
||||
maxPromptBytes = 3500
|
||||
denyCodeModel = "llm_policy.model_blocked"
|
||||
denyReasonModel = "model_blocked"
|
||||
denyMessageModel = "model is not in the policy allowlist"
|
||||
)
|
||||
|
||||
// Middleware enforces the model allowlist and optionally captures the
|
||||
// request prompt with PII redaction.
|
||||
type Middleware struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New constructs a Middleware with the supplied configuration. Model
|
||||
// allowlist entries are normalised so the runtime check is
|
||||
// case-insensitive and trim-tolerant.
|
||||
func New(cfg Config) *Middleware {
|
||||
return &Middleware{cfg: normaliseConfig(cfg)}
|
||||
}
|
||||
|
||||
// ID returns the registry identifier.
|
||||
func (m *Middleware) ID() string { return ID }
|
||||
|
||||
// Version returns the implementation version.
|
||||
func (m *Middleware) Version() string { return version }
|
||||
|
||||
// Slot reports the chain slot the middleware lives in.
|
||||
func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest }
|
||||
|
||||
// AcceptedContentTypes lists the request body content types the
|
||||
// middleware needs. Guardrail consumes metadata produced upstream and
|
||||
// does not touch the body itself, but we keep application/json so the
|
||||
// body policy retains the parsed payload upstream when required.
|
||||
func (m *Middleware) AcceptedContentTypes() []string {
|
||||
return []string{"application/json"}
|
||||
}
|
||||
|
||||
// MetadataKeys is the closed set of metadata keys this middleware may
|
||||
// emit. The accumulator drops anything outside this allowlist.
|
||||
func (m *Middleware) MetadataKeys() []string {
|
||||
return []string{
|
||||
middleware.KeyLLMPolicyDecision,
|
||||
middleware.KeyLLMPolicyReason,
|
||||
middleware.KeyLLMRequestPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
// MutationsSupported reports whether the middleware emits header / body
|
||||
// mutations. Guardrail never mutates the request.
|
||||
func (m *Middleware) MutationsSupported() bool { return false }
|
||||
|
||||
// Invoke runs the policy. The model allowlist is the only deny path;
|
||||
// prompt capture only affects the metadata emitted alongside an allow.
|
||||
func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
||||
model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
||||
|
||||
if denial := m.evaluateAllowlist(model, modelPresent); denial != nil {
|
||||
return denial, nil
|
||||
}
|
||||
|
||||
out := &middleware.Output{
|
||||
Decision: middleware.DecisionAllow,
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMPolicyDecision, Value: "allow"},
|
||||
{Key: middleware.KeyLLMPolicyReason, Value: ""},
|
||||
},
|
||||
}
|
||||
|
||||
if prompt, ok := m.capturePrompt(in.Metadata); ok {
|
||||
out.Metadata = append(out.Metadata, middleware.KV{
|
||||
Key: middleware.KeyLLMRequestPrompt,
|
||||
Value: prompt,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close releases resources owned by the middleware. Stateless, so this
|
||||
// is a no-op.
|
||||
func (m *Middleware) Close() error { return nil }
|
||||
|
||||
// evaluateAllowlist returns a deny Output when the configured allowlist
|
||||
// rejects the model. A nil return means the request should proceed.
|
||||
func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middleware.Output {
|
||||
if len(m.cfg.ModelAllowlist) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !modelPresent {
|
||||
return nil
|
||||
}
|
||||
if m.modelInAllowlist(model) {
|
||||
return nil
|
||||
}
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Code: denyCodeModel,
|
||||
Message: denyMessageModel,
|
||||
Details: map[string]string{"model": model},
|
||||
},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
||||
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// modelInAllowlist reports whether the model matches any allowlist
|
||||
// entry under the case-insensitive, trim-tolerant comparison rule.
|
||||
func (m *Middleware) modelInAllowlist(model string) bool {
|
||||
normalised := normaliseModel(model)
|
||||
if normalised == "" {
|
||||
return false
|
||||
}
|
||||
for _, allowed := range m.cfg.ModelAllowlist {
|
||||
if allowed == normalised {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// capturePrompt returns the prompt to emit and whether it should be
|
||||
// emitted at all. The truncation guarantee is upheld here regardless of
|
||||
// whether redaction grew the string.
|
||||
func (m *Middleware) capturePrompt(meta []middleware.KV) (string, bool) {
|
||||
if !m.cfg.PromptCapture.Enabled {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := lookupMetadata(meta, middleware.KeyLLMRequestPromptRaw)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
prompt := raw
|
||||
if m.cfg.PromptCapture.RedactPii {
|
||||
prompt = redactPII(prompt)
|
||||
}
|
||||
if len(prompt) > maxPromptBytes {
|
||||
// Back off to a UTF-8 rune boundary so we never emit a string
|
||||
// split mid-rune.
|
||||
cut := maxPromptBytes
|
||||
for cut > 0 && !utf8.RuneStart(prompt[cut]) {
|
||||
cut--
|
||||
}
|
||||
prompt = prompt[:cut]
|
||||
}
|
||||
return prompt, true
|
||||
}
|
||||
|
||||
// lookupMetadata finds the first KV with the given key. Returns the
|
||||
// value and true when present; the empty string and false otherwise.
|
||||
func lookupMetadata(meta []middleware.KV, key string) (string, bool) {
|
||||
for _, kv := range meta {
|
||||
if kv.Key == key {
|
||||
return kv.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package llm_guardrail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) {
|
||||
t.Helper()
|
||||
for _, kv := range kvs {
|
||||
if kv.Key == key {
|
||||
return kv.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func newInput(meta ...middleware.KV) *middleware.Input {
|
||||
return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: meta}
|
||||
}
|
||||
|
||||
func TestMiddlewareIdentity(t *testing.T) {
|
||||
mw := New(Config{})
|
||||
assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_guardrail")
|
||||
assert.Equal(t, "1.0.0", mw.Version(), "version must be 1.0.0")
|
||||
assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "guardrail must run in SlotOnRequest")
|
||||
assert.False(t, mw.MutationsSupported(), "guardrail must not mutate requests")
|
||||
assert.Equal(t, []string{"application/json"}, mw.AcceptedContentTypes(), "guardrail accepts application/json bodies")
|
||||
assert.Equal(t,
|
||||
[]string{
|
||||
middleware.KeyLLMPolicyDecision,
|
||||
middleware.KeyLLMPolicyReason,
|
||||
middleware.KeyLLMRequestPrompt,
|
||||
},
|
||||
mw.MetadataKeys(),
|
||||
"metadata key allowlist must match the spec",
|
||||
)
|
||||
require.NoError(t, mw.Close())
|
||||
}
|
||||
|
||||
func TestAllowlistEmptyAllowsAnyModel(t *testing.T) {
|
||||
mw := New(Config{})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "empty allowlist must allow any model")
|
||||
v, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
||||
require.True(t, ok, "decision metadata must be emitted")
|
||||
assert.Equal(t, "allow", v, "decision must be allow")
|
||||
r, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason)
|
||||
require.True(t, ok, "reason metadata must be emitted")
|
||||
assert.Equal(t, "", r, "reason must be empty on allow")
|
||||
}
|
||||
|
||||
func TestAllowlistMatchAllows(t *testing.T) {
|
||||
mw := New(Config{ModelAllowlist: []string{"gpt-4o", "claude-opus-4"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "model in allowlist must be allowed")
|
||||
}
|
||||
|
||||
func TestAllowlistMissDenies(t *testing.T) {
|
||||
mw := New(Config{ModelAllowlist: []string{"gpt-4o"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision, "non-allowlisted model must be denied")
|
||||
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403")
|
||||
require.NotNil(t, out.DenyReason, "deny reason must be populated")
|
||||
assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must match spec")
|
||||
assert.Equal(t, "model is not in the policy allowlist", out.DenyReason.Message, "deny message must match spec")
|
||||
assert.Equal(t, "claude-opus-4", out.DenyReason.Details["model"], "deny details must include the offending model")
|
||||
|
||||
dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
||||
assert.Equal(t, "deny", dec, "decision metadata must be deny")
|
||||
reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason)
|
||||
assert.Equal(t, "model_blocked", reason, "reason metadata must be model_blocked")
|
||||
}
|
||||
|
||||
func TestAllowlistCaseInsensitive(t *testing.T) {
|
||||
mw := New(Config{ModelAllowlist: []string{" GPT-4o ", "Claude-OPUS-4"}})
|
||||
cases := []string{"gpt-4o", "GPT-4O", " claude-opus-4 "}
|
||||
for _, model := range cases {
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: model},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "case/whitespace variants must match: %q", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistMissingModelKeyAllows(t *testing.T) {
|
||||
mw := New(Config{ModelAllowlist: []string{"gpt-4o"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "missing model key must allow even with non-empty allowlist")
|
||||
dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
||||
assert.Equal(t, "allow", dec, "decision must be allow when model key is absent")
|
||||
}
|
||||
|
||||
func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) {
|
||||
mw := New(Config{})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: "hello world"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
_, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt)
|
||||
assert.False(t, ok, "prompt must not be emitted when capture is disabled")
|
||||
}
|
||||
|
||||
func TestPromptCaptureNoRedactionEmitsRaw(t *testing.T) {
|
||||
mw := New(Config{PromptCapture: PromptCapture{Enabled: true}})
|
||||
raw := "hello world from user@example.com"
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: raw},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt)
|
||||
require.True(t, ok, "prompt must be emitted when capture is enabled")
|
||||
assert.Equal(t, raw, prompt, "prompt must pass through unchanged when redaction is off")
|
||||
}
|
||||
|
||||
func TestPromptCaptureWithRedactionRedacts(t *testing.T) {
|
||||
mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}})
|
||||
raw := "contact me at user@example.com or +14155551234"
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: raw},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt)
|
||||
require.True(t, ok, "prompt must be emitted when capture is enabled")
|
||||
assert.Contains(t, prompt, "[REDACTED:email]", "email must be redacted")
|
||||
assert.Contains(t, prompt, "[REDACTED:phone]", "phone must be redacted")
|
||||
assert.NotContains(t, prompt, "user@example.com", "raw email must not leak")
|
||||
}
|
||||
|
||||
func TestPromptCaptureRedactionTruncatesIfGrows(t *testing.T) {
|
||||
mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}})
|
||||
body := strings.Repeat("a", maxPromptBytes-10) + " user@example.com"
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: body},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt)
|
||||
require.True(t, ok, "prompt must be emitted when capture is enabled")
|
||||
assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must be truncated to maxPromptBytes")
|
||||
}
|
||||
|
||||
func TestPromptCaptureMissingRawNoEmit(t *testing.T) {
|
||||
mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}})
|
||||
out, err := mw.Invoke(context.Background(), newInput())
|
||||
require.NoError(t, err)
|
||||
_, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt)
|
||||
assert.False(t, ok, "prompt must not be emitted when raw key is missing")
|
||||
}
|
||||
|
||||
func TestFactoryAcceptsZeroConfigs(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"nil": nil,
|
||||
"empty": []byte(""),
|
||||
"whitespace": []byte(" \n "),
|
||||
"null": []byte("null"),
|
||||
"emptyObject": []byte("{}"),
|
||||
}
|
||||
f := Factory{}
|
||||
for name, raw := range cases {
|
||||
mw, err := f.New(raw)
|
||||
require.NoError(t, err, "case %s must yield a zero-value config", name)
|
||||
require.NotNil(t, mw)
|
||||
assert.Equal(t, ID, mw.ID(), "case %s must build a guardrail middleware", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryDecodesValidConfig(t *testing.T) {
|
||||
cfg := Config{
|
||||
ModelAllowlist: []string{"gpt-4o"},
|
||||
PromptCapture: PromptCapture{Enabled: true, RedactPii: true},
|
||||
}
|
||||
raw, err := json.Marshal(cfg)
|
||||
require.NoError(t, err, "marshalling test config must succeed")
|
||||
mw, err := Factory{}.New(raw)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, mw)
|
||||
}
|
||||
|
||||
func TestFactoryRejectsMalformedJSON(t *testing.T) {
|
||||
mw, err := Factory{}.New([]byte("{not-json"))
|
||||
assert.Error(t, err, "malformed JSON must surface as a factory error")
|
||||
assert.Nil(t, mw, "no middleware must be returned on malformed config")
|
||||
}
|
||||
|
||||
func TestFactoryNormalisesAllowlist(t *testing.T) {
|
||||
raw := []byte(`{"model_allowlist":[" GPT-4o ","",""," Claude-3 "]}`)
|
||||
mw, err := Factory{}.New(raw)
|
||||
require.NoError(t, err)
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "factory must lowercase + trim allowlist entries")
|
||||
out2, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-3"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out2.Decision, "trimmed entry must still match")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package llm_guardrail
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
// PII redactor scope: redact prompt content BEFORE it lands in the metadata
|
||||
// bag. The bearer-with-keyword pass runs first so the keyword is preserved.
|
||||
// We then chain the package-level middleware.Scan to pick up PEM, JWT, AWS
|
||||
// access keys, generic bearer tokens (40+ chars), and Luhn-validated credit
|
||||
// cards — keeping prompt redaction in sync with metadata-value scanning. Email,
|
||||
// SSN (dashed form), phone (E.164 + NA), and IPv4 are prompt-shaped patterns
|
||||
// the metadata scanner intentionally leaves alone.
|
||||
var (
|
||||
emailRegex = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
ssnRegex = regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`)
|
||||
phoneE164 = regexp.MustCompile(`\+\d{8,15}\b`)
|
||||
// phoneNARgx accepts the 3-3-4 North-American shape with any of the common
|
||||
// separators (space, dot, dash, slash) or none at all between the area code
|
||||
// and the body. The optional `\(?...\)?` wraps the area code; the separator
|
||||
// classes use `*` (not `?`) so multi-char separators ("(202) " followed by
|
||||
// space-and-something) and zero-separator runs ("2025550134") both match.
|
||||
// False-positive tradeoff: 10 consecutive digits in a prompt will be
|
||||
// treated as a phone number. For PII redaction that is the correct way to
|
||||
// err — under-redaction leaks; over-redaction is annoying.
|
||||
phoneNARgx = regexp.MustCompile(`\(?\b\d{3}\)?[\s.\-/]*\d{3}[\s.\-/]*\d{4}\b`)
|
||||
bearerRegex = regexp.MustCompile(`(?i)\b(bearer|token|api[_-]?key|authorization)([\s:=]+)(\S{20,})`)
|
||||
ipv4Regex = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b`)
|
||||
)
|
||||
|
||||
// redactPII is the package-private alias kept for internal callers; new code
|
||||
// outside the guardrail middleware should call RedactPII.
|
||||
func redactPII(value string) string { return RedactPII(value) }
|
||||
|
||||
// RedactPII replaces high-signal PII patterns in value with
|
||||
// `[REDACTED:<kind>]`. Non-matching input is returned unchanged. Exported so
|
||||
// the request / response parsers can reuse the same coverage on raw prompts
|
||||
// and completions when the account's redact_pii toggle is on.
|
||||
func RedactPII(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
result := value
|
||||
// Keyword-preserving bearer first so the "bearer "/"token=" prefix survives
|
||||
// before the generic scanner gets at the same content.
|
||||
result = bearerRegex.ReplaceAllStringFunc(result, redactBearer)
|
||||
// Structured secrets shared with metadata-value scanning: PEM, JWT, AWS
|
||||
// keys, generic bearer (40+), and Luhn-validated credit cards.
|
||||
result = middleware.Scan(result)
|
||||
// Prompt-shaped PII the metadata scanner doesn't cover.
|
||||
result = emailRegex.ReplaceAllString(result, "[REDACTED:email]")
|
||||
result = ssnRegex.ReplaceAllString(result, "[REDACTED:ssn]")
|
||||
result = phoneE164.ReplaceAllString(result, "[REDACTED:phone]")
|
||||
result = phoneNARgx.ReplaceAllString(result, "[REDACTED:phone]")
|
||||
result = ipv4Regex.ReplaceAllString(result, "[REDACTED:ip]")
|
||||
return result
|
||||
}
|
||||
|
||||
// redactBearer keeps the leading keyword and its separator, replacing
|
||||
// only the secret payload so the surrounding context is preserved.
|
||||
func redactBearer(match string) string {
|
||||
sub := bearerRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 4 {
|
||||
return "[REDACTED:bearer]"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(sub[1]) + len(sub[2]) + len("[REDACTED:bearer]"))
|
||||
b.WriteString(sub[1])
|
||||
b.WriteString(sub[2])
|
||||
b.WriteString("[REDACTED:bearer]")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package llm_guardrail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRedactPIIEmptyInput(t *testing.T) {
|
||||
assert.Equal(t, "", redactPII(""), "empty input must round-trip unchanged")
|
||||
}
|
||||
|
||||
func TestRedactPIIPlainTextUntouched(t *testing.T) {
|
||||
in := "the quick brown fox jumps over the lazy dog"
|
||||
assert.Equal(t, in, redactPII(in), "non-PII text must pass through unchanged")
|
||||
}
|
||||
|
||||
func TestRedactPIIEmail(t *testing.T) {
|
||||
cases := []string{
|
||||
"contact user@example.com today",
|
||||
"first.last+tag@sub.example.co",
|
||||
"USER_42@EXAMPLE.COM",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:email]", "email must be redacted in %q", in)
|
||||
assert.NotContains(t, strings.ToLower(out), "@example", "raw email host must not survive in %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPIISSN(t *testing.T) {
|
||||
in := "ssn 123-45-6789 should be hidden"
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:ssn]", "SSN must be redacted")
|
||||
assert.NotContains(t, out, "123-45-6789", "raw SSN must not survive")
|
||||
}
|
||||
|
||||
func TestRedactPIIPhoneE164(t *testing.T) {
|
||||
in := "call me at +14155551234 anytime"
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:phone]", "E.164 phone must be redacted")
|
||||
assert.NotContains(t, out, "+14155551234", "raw E.164 phone must not survive")
|
||||
}
|
||||
|
||||
func TestRedactPIIPhoneNorthAmerican(t *testing.T) {
|
||||
cases := []string{
|
||||
"call (415) 555-1234 now",
|
||||
"call 415-555-1234 now",
|
||||
"call 415.555.1234 now",
|
||||
"call 415 555 1234 now",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:phone]", "NA phone must be redacted in %q", in)
|
||||
assert.NotContains(t, out, "555-1234", "raw NA phone must not survive in %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPIIBearerKeepsKeyword(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
keyword string
|
||||
}{
|
||||
{"Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123", "Bearer"},
|
||||
{"token = abcdefghijklmnopqrstuvwxyz", "token"},
|
||||
{"api_key=abcdefghijklmnopqrstuvwxyz0123", "api_key"},
|
||||
{"API-KEY: abcdefghijklmnopqrstuvwxyz0123", "API-KEY"},
|
||||
{"authorization: abcdefghijklmnopqrstuvwxyz0123", "authorization"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out := redactPII(tc.in)
|
||||
assert.Contains(t, out, "[REDACTED:bearer]", "bearer-style secret must be redacted in %q", tc.in)
|
||||
assert.Contains(t, out, tc.keyword, "leading keyword %q must be preserved in %q", tc.keyword, tc.in)
|
||||
assert.NotContains(t, out, "abcdefghijklmnopqrstuvwxyz0123", "raw bearer payload must not survive in %q", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPIIBearerShortValueUntouched(t *testing.T) {
|
||||
in := "token=short"
|
||||
out := redactPII(in)
|
||||
assert.Equal(t, in, out, "short bearer-style values must not be redacted")
|
||||
}
|
||||
|
||||
func TestRedactPIICombined(t *testing.T) {
|
||||
in := "email user@example.com phone +14155551234 ssn 123-45-6789 token abcdefghijklmnopqrstuvwxyz0123"
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:email]", "email must be redacted in combined input")
|
||||
assert.Contains(t, out, "[REDACTED:phone]", "phone must be redacted in combined input")
|
||||
assert.Contains(t, out, "[REDACTED:ssn]", "SSN must be redacted in combined input")
|
||||
assert.Contains(t, out, "[REDACTED:bearer]", "bearer must be redacted in combined input")
|
||||
assert.NotContains(t, out, "user@example.com", "raw email must not survive combined input")
|
||||
assert.NotContains(t, out, "+14155551234", "raw phone must not survive combined input")
|
||||
assert.NotContains(t, out, "123-45-6789", "raw SSN must not survive combined input")
|
||||
}
|
||||
|
||||
func TestRedactPIICreditCard(t *testing.T) {
|
||||
// 4242424242424242 is a well-known Stripe test number (Visa, Luhn-valid).
|
||||
cases := []string{
|
||||
"please charge 4242424242424242 now",
|
||||
"card: 4242-4242-4242-4242",
|
||||
"4242 4242 4242 4242 expires 12/30",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:cc]", "Luhn-valid credit card must be redacted in %q", in)
|
||||
assert.NotContains(t, out, "4242424242424242", "raw card digits must not survive in %q", in)
|
||||
assert.NotContains(t, out, "4242-4242-4242-4242", "raw dashed card must not survive in %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPIIIPv4(t *testing.T) {
|
||||
cases := []string{
|
||||
"connect to 10.0.42.7 over the tunnel",
|
||||
"server 192.168.1.100 down",
|
||||
"public address 203.0.113.42 was hit",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:ip]", "IPv4 must be redacted in %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPIIJWT(t *testing.T) {
|
||||
// No "token "/"bearer " prefix here, so the bearer-with-keyword pass leaves
|
||||
// it alone and the JWT pattern from middleware.Scan must catch it.
|
||||
in := "session eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIn0.signaturepart expires soon"
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:jwt]", "JWT must be redacted when no bearer keyword precedes it")
|
||||
assert.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9", "raw JWT header must not survive")
|
||||
}
|
||||
|
||||
func TestRedactPIIAWSAccessKey(t *testing.T) {
|
||||
in := "the key AKIAIOSFODNN7EXAMPLE belongs to test user"
|
||||
out := redactPII(in)
|
||||
assert.Contains(t, out, "[REDACTED:aws_key]", "AWS access key must be redacted")
|
||||
assert.NotContains(t, out, "AKIAIOSFODNN7EXAMPLE", "raw AWS key must not survive")
|
||||
}
|
||||
|
||||
func TestRedactPIIPlainNumbersUntouched(t *testing.T) {
|
||||
// 1234567890123 is 13 digits but fails Luhn; must NOT trip the CC redactor.
|
||||
// We use a 13-digit value (the CC-candidate range starts at 13) so the only
|
||||
// risk is the CC pattern firing. Phone redaction is 10-digit by design and
|
||||
// would catch 1234567890123 as a phone — that's expected and not what this
|
||||
// test guards against.
|
||||
in := "order number 1234567890123 is queued"
|
||||
out := redactPII(in)
|
||||
assert.NotContains(t, out, "[REDACTED:cc]", "non-Luhn digit sequences must not be redacted as credit cards")
|
||||
}
|
||||
|
||||
// piiFixture mirrors the user-supplied test fixture: each record carries one
|
||||
// email, one SSN, and one phone in a representative format. The test asserts
|
||||
// that EVERY raw token disappears after redaction and the right [REDACTED:*]
|
||||
// markers show up. Names are kept in the input and must survive — names are
|
||||
// not a pattern the redactor tries to catch.
|
||||
type piiFixture struct {
|
||||
name string // person name (must survive redaction)
|
||||
email string
|
||||
ssn string
|
||||
phone string
|
||||
}
|
||||
|
||||
var fixtureRecords = []piiFixture{
|
||||
{"Alice Johnson", "alice.johnson@example.com", "123-45-6789", "(202) 555-0147"},
|
||||
{"Brian Smith", "brian.smith@example.org", "987-65-4321", "202-555-0163"},
|
||||
{"Carla Nguyen", "c.nguyen@test.local", "111-22-3333", "+1-202-555-0188"},
|
||||
{"David Martinez", "david.martinez@example.com", "222-33-4444", "202.555.0199"},
|
||||
{"Evelyn Parker", "evelyn.parker@example.org", "333-44-5555", "1-202-555-0112"},
|
||||
{"Frank O'Connor", "frank.oconnor@test.local", "444-55-6666", "2025550134"},
|
||||
{"Grace Lee", "grace.lee@example.com", "555-66-7777", "(202)555-0156"},
|
||||
{"Hassan Ali", "hassan.ali@example.org", "666-77-8888", "+1 (202) 555-0175"},
|
||||
{"Isabella Rossi", "i.rossi@test.local", "777-88-9999", "202 555 0121"},
|
||||
{"Jamal Thompson", "jamal.thompson@example.com", "888-99-0001", "202/555/0108"},
|
||||
}
|
||||
|
||||
// TestRedactPII_FixtureRecord drives every record through redactPII and
|
||||
// asserts the email, SSN, and phone are all redacted, the name survives, and
|
||||
// the appropriate REDACTED markers are present. This is the spec the redactor
|
||||
// must meet for the kind of prompts operators throw at it.
|
||||
func TestRedactPII_FixtureRecord(t *testing.T) {
|
||||
for _, rec := range fixtureRecords {
|
||||
t.Run(rec.name, func(t *testing.T) {
|
||||
in := "Name: " + rec.name + "\n Email: " + rec.email + "\n SSN: " + rec.ssn + "\n Phone: " + rec.phone
|
||||
out := redactPII(in)
|
||||
|
||||
assert.Contains(t, out, rec.name, "name must survive (not a PII pattern the redactor catches)")
|
||||
assert.Contains(t, out, "[REDACTED:email]", "email marker must appear for %q", rec.email)
|
||||
assert.Contains(t, out, "[REDACTED:ssn]", "ssn marker must appear for %q", rec.ssn)
|
||||
assert.Contains(t, out, "[REDACTED:phone]", "phone marker must appear for %q", rec.phone)
|
||||
|
||||
assert.NotContains(t, out, rec.email, "raw email must not survive: %q", rec.email)
|
||||
assert.NotContains(t, out, rec.ssn, "raw SSN must not survive: %q", rec.ssn)
|
||||
// Phone: assert the local digits (last 7) are gone. Country-code
|
||||
// remnants like "+1 " or "1-" may remain in front of the redaction
|
||||
// because the E.164 pattern needs digits-only after '+' — that's
|
||||
// acceptable, the personally-identifying portion is removed.
|
||||
localDigits := lastSevenDigits(rec.phone)
|
||||
assert.NotContains(t, out, localDigits, "raw phone local digits %q must not survive in redacted output of %q", localDigits, rec.phone)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// lastSevenDigits returns the last 7 digits of a phone number, ignoring
|
||||
// formatting. It's the unique "subscriber" portion that absolutely must be
|
||||
// scrubbed regardless of which prefix the redactor leaves behind.
|
||||
func lastSevenDigits(phone string) string {
|
||||
digits := make([]byte, 0, len(phone))
|
||||
for i := 0; i < len(phone); i++ {
|
||||
if phone[i] >= '0' && phone[i] <= '9' {
|
||||
digits = append(digits, phone[i])
|
||||
}
|
||||
}
|
||||
if len(digits) <= 7 {
|
||||
return string(digits)
|
||||
}
|
||||
return string(digits[len(digits)-7:])
|
||||
}
|
||||
Reference in New Issue
Block a user