Skip to content

Commit f90d43c

Browse files
committed
feat(pii): pattern detector tier — bounded restricted-regex secret matching
NER is the wrong tool for high-entropy, highly-regular secrets: the privacy model has no API-key class, so it fragments a key (tail -> BITCOINADDRESS, prefix -> VRM) and can leave the secret part exposed. Add a regex tier that plugs in as another detector model alongside NER and reuses the whole pipeline. - core/services/routing/piipattern (leaf; stdlib only): a restricted-regex grammar validated against the RE2 AST (no '.', no capture groups, capped {n,m}; every pattern must carry a >=3-char fixed literal anchor, which admits sk-ant-/ghp_/AKIA shapes but rejects open-ended ones like email or bare \w+), compiled to RE2 (linear, no backtracking) with leftmost-longest so a hit grabs the whole key. Curated built-in catalogue (Anthropic, OpenAI, GitHub, AWS, Google, Slack, Stripe, JWT, PEM private key). - config: PIIDetection.{Builtins,Patterns} + IsPatternDetector(); Validate rejects bad patterns/unknown builtins at load (no model file required). - piidetector.NewPattern: in-process pii.NERDetector (Score 1.0), records a pattern_pii BackendTrace when tracing is on. PIINERResolver branches to it for pattern models; MITM inherits it. Per-pattern action overrides fold into entity_actions. - meta registry: pii_detection.builtins -> pii-builtins-select (options from the catalogue) and pii_detection.patterns -> pii-pattern-list, for the model editor. - gallery: ready-made secret-filter pattern model (builtins on, default block, zero VRAM). Docs: new Pattern detector tier section. Pattern hits union with NER hits and flow through the same policy, events (score 1.0) and DEBUG logs. UI editors + Traces badge follow in a UI commit. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent 1c59a7c commit f90d43c

15 files changed

Lines changed: 875 additions & 0 deletions

File tree

core/application/application.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,24 @@ func (a *Application) PIINERResolver() pii.NERDetectorResolver {
270270
if !ok {
271271
return pii.NERConfig{}, false
272272
}
273+
274+
// Pattern detectors match secrets with the restricted-regex tier
275+
// in-process (no backend load). Build a pattern matcher instead of the
276+
// gRPC token-classifier; on a compile error fail closed with an error
277+
// detector so the request is blocked, not silently unscanned.
278+
if cfg.IsPatternDetector() {
279+
det, err := piidetector.NewPattern(cfg, a.ApplicationConfig())
280+
if err != nil {
281+
det = pii.NewErrNERDetector(err.Error())
282+
}
283+
return pii.NERConfigFromRaw(
284+
det,
285+
0, // patterns are deterministic — no confidence floor
286+
cfg.PIIDetectionDefaultAction(),
287+
patternEntityActions(cfg),
288+
), true
289+
}
290+
273291
det := piidetector.New(a.ModelLoader(), cfg, a.ApplicationConfig())
274292
return pii.NERConfigFromRaw(
275293
det,
@@ -280,6 +298,26 @@ func (a *Application) PIINERResolver() pii.NERDetectorResolver {
280298
}
281299
}
282300

301+
// patternEntityActions merges a pattern detector's per-pattern Action overrides
302+
// into its entity_actions map. A pattern reports matches under its Name, so a
303+
// per-pattern action is just an entity_actions[Name] entry; explicit
304+
// entity_actions still win if both are set.
305+
func patternEntityActions(cfg config.ModelConfig) map[string]string {
306+
out := cfg.PIIDetectionEntityActions()
307+
for _, p := range cfg.PIIDetection.Patterns {
308+
if p.Action == "" || p.Name == "" {
309+
continue
310+
}
311+
if out == nil {
312+
out = map[string]string{}
313+
}
314+
if _, exists := out[p.Name]; !exists {
315+
out[p.Name] = p.Action
316+
}
317+
}
318+
return out
319+
}
320+
283321
// ResolvePIIPolicy resolves the effective request-side PII policy for a
284322
// consuming model, layering the instance-wide default detector
285323
// (PIIDefaultDetectors, set via POST /api/settings) on top of the per-model
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package meta_test
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
"github.com/mudler/LocalAI/core/config/meta"
9+
10+
. "github.com/onsi/ginkgo/v2"
11+
. "github.com/onsi/gomega"
12+
)
13+
14+
func TestMeta(t *testing.T) {
15+
RegisterFailHandler(Fail)
16+
RunSpecs(t, "config/meta suite")
17+
}
18+
19+
var _ = Describe("pattern detector field metadata", func() {
20+
byPath := func() map[string]meta.FieldMeta {
21+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
22+
out := make(map[string]meta.FieldMeta, len(md.Fields))
23+
for _, f := range md.Fields {
24+
out[f.Path] = f
25+
}
26+
return out
27+
}
28+
29+
It("renders builtins as a select with the catalogue as options", func() {
30+
f, ok := byPath()["pii_detection.builtins"]
31+
Expect(ok).To(BeTrue(), "pii_detection.builtins field should exist")
32+
Expect(f.Component).To(Equal("pii-builtins-select"))
33+
Expect(f.Options).NotTo(BeEmpty())
34+
})
35+
36+
It("renders custom patterns with the pattern-list editor", func() {
37+
f, ok := byPath()["pii_detection.patterns"]
38+
Expect(ok).To(BeTrue(), "pii_detection.patterns field should exist")
39+
Expect(f.Component).To(Equal("pii-pattern-list"))
40+
})
41+
})

core/config/meta/registry.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
package meta
22

3+
import "github.com/mudler/LocalAI/core/services/routing/piipattern"
4+
5+
// builtinPatternOptions turns the piipattern built-in catalogue into select
6+
// options for the editor's built-in-patterns checklist, keeping the catalogue
7+
// the single source of truth.
8+
func builtinPatternOptions() []FieldOption {
9+
cat := piipattern.BuiltinCatalogue()
10+
out := make([]FieldOption, 0, len(cat))
11+
for _, b := range cat {
12+
out = append(out, FieldOption{Value: b.Name, Label: b.Name + " — " + b.Description})
13+
}
14+
return out
15+
}
16+
317
// DefaultRegistry returns enrichment overrides for the ~30 most commonly used
418
// config fields. Fields not listed here still appear with auto-generated
519
// labels and type-inferred components.
@@ -423,6 +437,21 @@ func DefaultRegistry() map[string]FieldMetaOverride {
423437
Component: "entity-action-list",
424438
Order: 212,
425439
},
440+
"pii_detection.builtins": {
441+
Section: "pii",
442+
Label: "Built-in Secret Patterns",
443+
Description: "Built-in regex patterns for common credentials (API keys, tokens, private keys). Turning any on makes this a pattern detector — it matches high-entropy secrets the NER tier can't, in-process with no model load.",
444+
Component: "pii-builtins-select",
445+
Options: builtinPatternOptions(),
446+
Order: 213,
447+
},
448+
"pii_detection.patterns": {
449+
Section: "pii",
450+
Label: "Custom Secret Patterns",
451+
Description: "Operator-defined patterns in a restricted regex subset (e.g. \"sk-prefix-\\w+\"). Each must contain a fixed literal anchor of ≥3 chars; open-ended shapes like emails are rejected (leave those to NER). Matches report under the pattern name as the entity group.",
452+
Component: "pii-pattern-list",
453+
Order: 214,
454+
},
426455

427456
// --- Cloud passthrough proxy ---
428457
// These only have an effect when Backend is set to

core/config/model_config.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"text/template"
1111

1212
"github.com/mudler/LocalAI/core/schema"
13+
"github.com/mudler/LocalAI/core/services/routing/piipattern"
1314
"github.com/mudler/LocalAI/pkg/downloader"
1415
"github.com/mudler/LocalAI/pkg/functions"
1516
"github.com/mudler/LocalAI/pkg/reasoning"
@@ -415,6 +416,27 @@ type PIIDetectionConfig struct {
415416
// This is where an operator says which PII to block vs mask vs
416417
// allow-log.
417418
EntityActions map[string]string `yaml:"entity_actions,omitempty" json:"entity_actions,omitempty"`
419+
420+
// Builtins names the built-in pattern groups this (pattern) detector
421+
// enables, e.g. "anthropic_api_key", "github_token". Pattern detectors
422+
// match high-entropy structured secrets the NER tier can't; see
423+
// core/services/routing/piipattern.
424+
Builtins []string `yaml:"builtins,omitempty" json:"builtins,omitempty"`
425+
// Patterns lists operator-defined secret patterns in the restricted-regex
426+
// subset (validated at load). Each match is reported under its Name as the
427+
// entity group, so EntityActions/DefaultAction apply by Name.
428+
Patterns []PIIPattern `yaml:"patterns,omitempty" json:"patterns,omitempty"`
429+
}
430+
431+
// PIIPattern is one operator-defined pattern on a pattern detector model. Name
432+
// is the entity group reported for matches (and the EntityActions key). Match
433+
// is the restricted-regex source. Action optionally overrides DefaultAction for
434+
// this pattern. MinLen drops matches shorter than N bytes (0 = no floor).
435+
type PIIPattern struct {
436+
Name string `yaml:"name" json:"name"`
437+
Match string `yaml:"match" json:"match"`
438+
Action string `yaml:"action,omitempty" json:"action,omitempty"`
439+
MinLen int `yaml:"min_len,omitempty" json:"min_len,omitempty"`
418440
}
419441

420442
// PIIIsEnabled returns the resolved PII state for this model. Single
@@ -483,6 +505,15 @@ func (c *ModelConfig) PIIDetectionEntityActions() map[string]string {
483505
return out
484506
}
485507

508+
// IsPatternDetector reports whether this detector model matches secrets with
509+
// regex patterns (built-in and/or operator-defined) rather than a neural NER
510+
// model. Such a model runs entirely in-process (no backend / GGUF / VRAM); the
511+
// PII resolver builds an in-process pattern matcher for it instead of loading a
512+
// gRPC token-classifier.
513+
func (c *ModelConfig) IsPatternDetector() bool {
514+
return len(c.PIIDetection.Builtins) > 0 || len(c.PIIDetection.Patterns) > 0
515+
}
516+
486517
// @Description MCP configuration
487518
type MCPConfig struct {
488519
Servers string `yaml:"remote,omitempty" json:"remote,omitempty"`
@@ -1070,6 +1101,26 @@ func (c *ModelConfig) Validate() (bool, error) {
10701101
"with chat/completion — split into separate model configs")
10711102
}
10721103

1104+
// Pattern detector: validate built-in names and that each operator-defined
1105+
// pattern is a well-formed, anchored, bounded restricted-regex. Reject at
1106+
// load so a bad pattern surfaces as a clear config error rather than a
1107+
// silent no-op (or a fail-closed block) at request time.
1108+
if c.IsPatternDetector() {
1109+
for _, name := range c.PIIDetection.Builtins {
1110+
if _, ok := piipattern.LookupBuiltin(name); !ok {
1111+
return false, fmt.Errorf("pii_detection: unknown built-in pattern %q", name)
1112+
}
1113+
}
1114+
for _, p := range c.PIIDetection.Patterns {
1115+
if p.Name == "" {
1116+
return false, fmt.Errorf("pii_detection: pattern is missing a name")
1117+
}
1118+
if err := piipattern.ValidatePattern(p.Match); err != nil {
1119+
return false, fmt.Errorf("pii_detection: pattern %q: %w", p.Name, err)
1120+
}
1121+
}
1122+
}
1123+
10731124
// router.score_normalization is consumed lazily by the score
10741125
// classifier at first-request time; without load-time validation
10751126
// a typo wouldn't surface until the first router request panicked

core/config/model_config_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,3 +699,37 @@ var _ = Describe("PIIFilterApplies (Middleware admin list scoping)", func() {
699699
Expect(withUsecases("whisper", FLAG_TRANSCRIPT).PIIFilterApplies()).To(BeFalse())
700700
})
701701
})
702+
703+
var _ = Describe("pattern detector config", func() {
704+
patternCfg := func() *ModelConfig {
705+
c := &ModelConfig{Name: "secret-filter", Backend: "pattern"}
706+
c.PIIDetection.Builtins = []string{"anthropic_api_key"}
707+
c.PIIDetection.Patterns = []PIIPattern{{Name: "INTERNAL", Match: `tok-[A-Za-z0-9]{20,}`}}
708+
return c
709+
}
710+
711+
It("IsPatternDetector keys off builtins/patterns", func() {
712+
Expect(patternCfg().IsPatternDetector()).To(BeTrue())
713+
Expect((&ModelConfig{Name: "ner", Backend: "llama-cpp"}).IsPatternDetector()).To(BeFalse())
714+
})
715+
716+
It("Validate accepts a well-formed pattern detector (no model file needed)", func() {
717+
ok, err := patternCfg().Validate()
718+
Expect(err).NotTo(HaveOccurred())
719+
Expect(ok).To(BeTrue())
720+
})
721+
722+
It("Validate rejects an unknown built-in", func() {
723+
c := &ModelConfig{Name: "x", Backend: "pattern"}
724+
c.PIIDetection.Builtins = []string{"does_not_exist"}
725+
_, err := c.Validate()
726+
Expect(err).To(MatchError(ContainSubstring("unknown built-in")))
727+
})
728+
729+
It("Validate rejects an unanchored custom pattern", func() {
730+
c := &ModelConfig{Name: "x", Backend: "pattern"}
731+
c.PIIDetection.Patterns = []PIIPattern{{Name: "EMAILish", Match: `[\w.]+@[\w.]+\.\w+`}}
732+
_, err := c.Validate()
733+
Expect(err).To(MatchError(ContainSubstring("pattern \"EMAILish\"")))
734+
})
735+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package piidetector
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/mudler/LocalAI/core/backend"
8+
"github.com/mudler/LocalAI/core/config"
9+
"github.com/mudler/LocalAI/core/services/routing/pii"
10+
"github.com/mudler/LocalAI/core/services/routing/piipattern"
11+
"github.com/mudler/LocalAI/core/trace"
12+
)
13+
14+
// NewPattern builds a pii.NERDetector that matches secrets with the restricted
15+
// regex tier (built-ins + operator-defined patterns) instead of a neural model.
16+
// It runs entirely in-process — no backend, GGUF, or VRAM — and the patterns
17+
// compile once here, so an invalid pattern is reported now (the resolver fails
18+
// closed) rather than per request. Matches are reported under their group with
19+
// a deterministic Score of 1.0.
20+
func NewPattern(modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (pii.NERDetector, error) {
21+
custom := make([]piipattern.Pattern, 0, len(modelConfig.PIIDetection.Patterns))
22+
for _, p := range modelConfig.PIIDetection.Patterns {
23+
custom = append(custom, piipattern.Pattern{Group: p.Name, Pattern: p.Match, MinLen: p.MinLen})
24+
}
25+
m, err := piipattern.NewMatcher(modelConfig.PIIDetection.Builtins, custom)
26+
if err != nil {
27+
return nil, err
28+
}
29+
return &patternDetector{matcher: m, modelName: modelConfig.Name, appConfig: appConfig}, nil
30+
}
31+
32+
type patternDetector struct {
33+
matcher *piipattern.Matcher
34+
modelName string
35+
appConfig *config.ApplicationConfig
36+
}
37+
38+
// Detect runs the compiled patterns and maps each match onto a pii.NEREntity.
39+
// When tracing is enabled it records a pattern_pii BackendTrace so the matches
40+
// (group, byte range, text) show in the Traces UI alongside NER detections.
41+
func (d *patternDetector) Detect(_ context.Context, text string) ([]pii.NEREntity, error) {
42+
var start time.Time
43+
if d.appConfig != nil && d.appConfig.EnableTracing {
44+
trace.InitBackendTracingIfEnabled(d.appConfig.TracingMaxItems, d.appConfig.TracingMaxBodyBytes)
45+
start = time.Now()
46+
}
47+
48+
matches := d.matcher.Find(text)
49+
out := make([]pii.NEREntity, 0, len(matches))
50+
var traceEnts []backend.TokenEntity
51+
for _, mt := range matches {
52+
out = append(out, pii.NEREntity{Group: mt.Group, Start: mt.Start, End: mt.End, Score: 1.0, Text: mt.Text})
53+
if d.appConfig != nil && d.appConfig.EnableTracing {
54+
traceEnts = append(traceEnts, backend.TokenEntity{Group: mt.Group, Start: mt.Start, End: mt.End, Score: 1.0, Text: mt.Text})
55+
}
56+
}
57+
58+
if d.appConfig != nil && d.appConfig.EnableTracing {
59+
trace.RecordBackendTrace(patternPIITrace(d.modelName, text, traceEnts, start))
60+
}
61+
return out, nil
62+
}
63+
64+
// patternPIITrace assembles the Traces-UI row for one pattern-detector run.
65+
// Split out so the Data assembly is unit-testable without a request.
66+
func patternPIITrace(modelName, text string, entities []backend.TokenEntity, start time.Time) trace.BackendTrace {
67+
return trace.BackendTrace{
68+
Timestamp: start,
69+
Duration: time.Since(start),
70+
Type: trace.BackendTracePatternPII,
71+
ModelName: modelName,
72+
Backend: "pattern",
73+
Summary: trace.TruncateString(text, 200),
74+
Data: map[string]any{
75+
"input_chars": len(text),
76+
"matches": len(entities),
77+
"entities": entities,
78+
},
79+
}
80+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package piidetector_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
"github.com/mudler/LocalAI/core/services/routing/pii"
9+
"github.com/mudler/LocalAI/core/services/routing/piidetector"
10+
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
)
14+
15+
func TestPiidetector(t *testing.T) {
16+
RegisterFailHandler(Fail)
17+
RunSpecs(t, "piidetector suite")
18+
}
19+
20+
func patternModel() config.ModelConfig {
21+
c := config.ModelConfig{Name: "secret-filter", Backend: "pattern"}
22+
c.PIIDetection.Builtins = []string{"anthropic_api_key"}
23+
c.PIIDetection.Patterns = []config.PIIPattern{{Name: "INTERNAL_TOKEN", Match: `tok-[A-Za-z0-9]{8,}`}}
24+
return c
25+
}
26+
27+
var _ = Describe("pattern detector", func() {
28+
It("matches built-in and custom secrets as whole-span deterministic hits", func() {
29+
det, err := piidetector.NewPattern(patternModel(), &config.ApplicationConfig{})
30+
Expect(err).NotTo(HaveOccurred())
31+
32+
ents, err := det.Detect(context.Background(), "use sk-ant-api03-AAAABBBBCCCCDDDDEEEE and tok-ABCD1234 ok")
33+
Expect(err).NotTo(HaveOccurred())
34+
35+
byGroup := map[string]pii.NEREntity{}
36+
for _, e := range ents {
37+
byGroup[e.Group] = e
38+
Expect(e.Score).To(BeEquivalentTo(float32(1.0)), "pattern matches are deterministic")
39+
}
40+
Expect(byGroup).To(HaveKey("ANTHROPIC_KEY"))
41+
Expect(byGroup["INTERNAL_TOKEN"].Text).To(Equal("tok-ABCD1234"))
42+
})
43+
44+
It("still detects (and exercises the trace path) with tracing enabled", func() {
45+
det, err := piidetector.NewPattern(patternModel(), &config.ApplicationConfig{
46+
EnableTracing: true, TracingMaxItems: 8,
47+
})
48+
Expect(err).NotTo(HaveOccurred())
49+
ents, err := det.Detect(context.Background(), "sk-ant-api03-AAAABBBBCCCCDDDDEEEE")
50+
Expect(err).NotTo(HaveOccurred())
51+
Expect(ents).To(HaveLen(1))
52+
Expect(ents[0].Group).To(Equal("ANTHROPIC_KEY"))
53+
})
54+
55+
It("fails to build on an invalid (unanchored) custom pattern", func() {
56+
c := config.ModelConfig{Name: "bad", Backend: "pattern"}
57+
c.PIIDetection.Patterns = []config.PIIPattern{{Name: "X", Match: `.*`}}
58+
_, err := piidetector.NewPattern(c, &config.ApplicationConfig{})
59+
Expect(err).To(HaveOccurred())
60+
})
61+
})

0 commit comments

Comments
 (0)