Skip to content

Commit f5b0bea

Browse files
committed
docs: add docs
1 parent 1611e00 commit f5b0bea

14 files changed

Lines changed: 855 additions & 33 deletions

config/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ agent:
197197

198198
catalog:
199199
persist_interval: 30s
200-
auto_promote_after: 50 # in detect mode, this many sightings = "known"
200+
auto_promote_after: 100 # in detect mode, this many sightings = "known"
201201
# Spike detection: a known pattern is re-flagged when its tick-level
202202
# frequency suddenly exceeds the EWMA (Exponentially Weighted Moving Average) baseline by `spike_multiplier`.
203203
# Two safety floors keep noise out:

pkg/agent/brain_log.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,15 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b
174174
prevCount := postCount - tickFreq // recover the pre-fold count
175175
prevVerdict := p.Verdict // Upsert never mutates Verdict, so == pre-fold
176176

177+
// AutoPromoteAfter ≤ 0 disables count-based promotion entirely ("0 disables
178+
// the promotion"): a pattern is never marked "known" by sighting count
179+
// alone, so it keeps flowing to detect-AI however often it is seen. The 100
180+
// default for an UNSET key is supplied by the embedded default_config layer
181+
// (loaded as the base before user overrides), so an omitted key arrives here
182+
// as 100 — only an explicit 0 (or negative) reaches the disabled branch. A
183+
// pattern already promoted to "known" stays known regardless of threshold.
177184
threshold := b.cat.AutoPromoteAfter
178-
if threshold <= 0 {
179-
threshold = 100
180-
}
181-
isKnown := prevVerdict == "known" || postCount >= threshold
185+
isKnown := prevVerdict == "known" || (threshold > 0 && postCount >= threshold)
182186
if isKnown {
183187
if prevVerdict != "known" {
184188
b.catalog.MarkKnown(obs.Key)

pkg/agent/brain_log_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,135 @@ func TestLogBrain_ClassifyLifecycle(t *testing.T) {
229229
t.Errorf("spike baseline = %v, want the pre-fold baseline > 0", v.Baseline)
230230
}
231231
}
232+
233+
// --- auto_promote_after threshold semantics (QA-028) --------------------------
234+
// Spike is left disabled (SpikeMultiplier == 0) throughout so it can never mask
235+
// the count-based promotion verdict under test.
236+
237+
// (a) The shipped default (100, supplied by the embedded default_config layer
238+
// for an unset key) still promotes exactly at the 100th sighting.
239+
func TestLogBrain_AutoPromoteAfter_DefaultPromotesAt100(t *testing.T) {
240+
b, c := newLogBrainForTest(t, config.AgentCatalogConfig{AutoPromoteAfter: 100})
241+
242+
for i := 0; i < 99; i++ {
243+
classifyOnce(t, b, logObs("p", 1))
244+
}
245+
if got := c.Get("p").Count; got != 99 {
246+
t.Fatalf("count = %d, want 99", got)
247+
}
248+
if c.Get("p").Verdict == "known" {
249+
t.Fatalf("promoted early at count=99 (threshold 100)")
250+
}
251+
252+
v := classifyOnce(t, b, logObs("p", 1)) // 100th sighting crosses the threshold
253+
if got := c.Get("p").Count; got != 100 {
254+
t.Fatalf("count = %d, want 100", got)
255+
}
256+
if v.Class != core.VerdictKnownPattern {
257+
t.Fatalf("at-threshold verdict = %v, want known", v.Class)
258+
}
259+
if c.Get("p").Verdict != "known" {
260+
t.Fatalf("catalog verdict = %q, want known (MarkKnown must fire at 100)", c.Get("p").Verdict)
261+
}
262+
}
263+
264+
// (b) A positive custom threshold promotes exactly at that count, not at 100.
265+
func TestLogBrain_AutoPromoteAfter_CustomThresholdPromotes(t *testing.T) {
266+
b, c := newLogBrainForTest(t, config.AgentCatalogConfig{AutoPromoteAfter: 50})
267+
268+
for i := 0; i < 49; i++ {
269+
classifyOnce(t, b, logObs("p", 1))
270+
}
271+
if c.Get("p").Verdict == "known" {
272+
t.Fatalf("promoted before custom threshold 50 (count=%d)", c.Get("p").Count)
273+
}
274+
275+
v := classifyOnce(t, b, logObs("p", 1)) // 50th sighting
276+
if got := c.Get("p").Count; got != 50 {
277+
t.Fatalf("count = %d, want 50", got)
278+
}
279+
if v.Class != core.VerdictKnownPattern {
280+
t.Fatalf("at-custom-threshold verdict = %v, want known", v.Class)
281+
}
282+
if c.Get("p").Verdict != "known" {
283+
t.Fatalf("catalog verdict = %q, want known at the custom threshold", c.Get("p").Verdict)
284+
}
285+
}
286+
287+
// (c) QA-028: auto_promote_after: 0 DISABLES count-based promotion — a pattern
288+
// is never marked "known" no matter how many times it is seen. Drives well past
289+
// the old 100 fallback to prove the explicit 0 is honoured, not re-mapped.
290+
func TestLogBrain_AutoPromoteAfter_ZeroDisablesPromotion(t *testing.T) {
291+
cat := config.AgentCatalogConfig{
292+
AutoPromoteAfter: 0, // documented: disables promotion
293+
SpikeMultiplier: 0, // disable spike so it can't mask the verdict
294+
}
295+
b, c := newLogBrainForTest(t, cat)
296+
297+
var v core.TypedVerdict
298+
for i := 0; i < 12; i++ {
299+
v = classifyOnce(t, b, logObs("p", 10)) // 120 sightings, well past 100
300+
}
301+
if got := c.Get("p").Count; got != 120 {
302+
t.Fatalf("count = %d, want 120", got)
303+
}
304+
if c.Get("p").Verdict == "known" {
305+
t.Fatalf("auto_promote_after=0 promoted pattern to %q; 0 must disable promotion (QA-028)", c.Get("p").Verdict)
306+
}
307+
if v.Class != core.VerdictUnknown {
308+
t.Fatalf("verdict at count=120 = %v, want unknown (0 disables promotion)", v.Class)
309+
}
310+
}
311+
312+
// (d) A negative threshold (any value ≤ 0) also disables promotion.
313+
func TestLogBrain_AutoPromoteAfter_NegativeDisablesPromotion(t *testing.T) {
314+
cat := config.AgentCatalogConfig{
315+
AutoPromoteAfter: -1,
316+
SpikeMultiplier: 0,
317+
}
318+
b, c := newLogBrainForTest(t, cat)
319+
320+
var v core.TypedVerdict
321+
for i := 0; i < 12; i++ {
322+
v = classifyOnce(t, b, logObs("p", 10)) // 120 sightings
323+
}
324+
if c.Get("p").Verdict == "known" {
325+
t.Fatalf("auto_promote_after=-1 promoted pattern to %q; any value ≤0 must disable promotion", c.Get("p").Verdict)
326+
}
327+
if v.Class != core.VerdictUnknown {
328+
t.Fatalf("verdict = %v, want unknown (negative disables promotion)", v.Class)
329+
}
330+
}
331+
332+
// (e) An operator-labelled "known" pattern STAYS known even when count-based
333+
// promotion is disabled (threshold 0). This bites the `prevVerdict == "known"`
334+
// clause independently of the count clause: were the guard rewritten as
335+
// `threshold > 0 && (prevVerdict == "known" || postCount >= threshold)`, a
336+
// disabled threshold would silently un-suppress a hand-labelled pattern.
337+
func TestLogBrain_AutoPromoteAfter_AlreadyKnownStaysKnown(t *testing.T) {
338+
cat := config.AgentCatalogConfig{
339+
AutoPromoteAfter: 0, // count-based promotion disabled
340+
SpikeMultiplier: 0, // disable spike so it can't mask the verdict
341+
}
342+
b, c := newLogBrainForTest(t, cat)
343+
344+
// Seed the pattern (stays Unknown — 0 disables count-based promotion), then
345+
// label it known by hand as an operator would.
346+
classifyOnce(t, b, logObs("p", 5))
347+
if !c.MarkKnown("p") {
348+
t.Fatalf("MarkKnown(p) did not mark the seeded pattern")
349+
}
350+
if c.Get("p").Verdict != "known" {
351+
t.Fatalf("precondition: catalog verdict = %q, want known", c.Get("p").Verdict)
352+
}
353+
354+
// Further sightings must keep it known — the prior "known" verdict wins even
355+
// though the count threshold is disabled.
356+
v := classifyOnce(t, b, logObs("p", 5))
357+
if v.Class != core.VerdictKnownPattern {
358+
t.Fatalf("already-known verdict = %v, want known (prior known must win with threshold 0)", v.Class)
359+
}
360+
if c.Get("p").Verdict != "known" {
361+
t.Fatalf("catalog verdict = %q, want known (an already-known pattern must stay known)", c.Get("p").Verdict)
362+
}
363+
}

pkg/agent/raw_leak_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package agent
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
"github.com/VersusControl/versus-incident/pkg/agent/ai/detect"
9+
"github.com/VersusControl/versus-incident/pkg/core"
10+
"github.com/VersusControl/versus-incident/pkg/storage"
11+
)
12+
13+
// rawOnlySecret is a sentinel that lives ONLY in Signal.Raw — never in the
14+
// redacted Message/Fields. No default redaction rule matches it, so ANY
15+
// appearance downstream is an unambiguous leak: it means a builder started
16+
// serializing Signal.Raw (the deliberately-unredacted original the worker
17+
// keeps for admin-only debug) into a prompt/log/persisted payload, exposing
18+
// the very secrets the redactor scrubs out of Message/Fields.
19+
//
20+
// These tests are the guard that a future snapshot / admin-dump /
21+
// enterprise-brain path cannot silently start emitting Signal.Raw. They
22+
// exercise the REAL builders (detect.BuildPrompt, ShadowLog, DetectLog) and
23+
// their real serializers, not a hand-rolled marshal.
24+
const rawOnlySecret = "RAW-ONLY-SENTINEL-DO-NOT-LEAK-42"
25+
26+
// leakSignal returns a Signal in the exact post-redaction shape the worker
27+
// produces: Message/Fields carry no secret (already scrubbed), while Raw holds
28+
// the original secret-bearing document (left alone for admin debug).
29+
func leakSignal() core.Signal {
30+
return core.Signal{
31+
Source: "es:prod",
32+
Message: "service=api request failed id=<REDACTED:uuid>",
33+
Fields: map[string]interface{}{
34+
core.FieldService: "api",
35+
core.FieldSignal: "request failed",
36+
},
37+
Raw: map[string]interface{}{
38+
"raw_line": "service=api request failed token=" + rawOnlySecret,
39+
"headers": map[string]interface{}{"authorization": "Bearer " + rawOnlySecret},
40+
},
41+
}
42+
}
43+
44+
func TestSignalRaw_NeverLeaksIntoModelPrompt(t *testing.T) {
45+
sig := leakSignal()
46+
result := core.AgentResult{
47+
Verdict: core.VerdictUnknown,
48+
PatternID: "p1",
49+
Template: "service=api request failed id=<*>",
50+
SampleSignals: []core.Signal{sig},
51+
Frequency: 1,
52+
}
53+
// The worker feeds BuildPrompt the samples produced by sampleMessages,
54+
// which reads .Message (redacted) — exercise that exact builder chain.
55+
samples := sampleMessages(result.SampleSignals, 3)
56+
system, user := detect.BuildPrompt(result, sig.Source, "api", samples)
57+
if strings.Contains(system+user, rawOnlySecret) {
58+
t.Fatalf("Signal.Raw leaked into the model prompt:\nsystem=%s\nuser=%s", system, user)
59+
}
60+
}
61+
62+
func TestSignalRaw_NeverLeaksIntoShadowLog(t *testing.T) {
63+
sig := leakSignal()
64+
store := storage.NewMemory()
65+
sl, err := LoadShadowLog(store, 0)
66+
if err != nil {
67+
t.Fatalf("LoadShadowLog: %v", err)
68+
}
69+
// The worker records the redacted Message as the shadow sample.
70+
sl.Record(sig.Source, "p1", "tmpl", sig.Message, "default", "unknown", 1)
71+
if err := sl.Persist(); err != nil {
72+
t.Fatalf("Persist: %v", err)
73+
}
74+
assertBlobHasNoRawSecret(t, store, "shadow")
75+
// The in-memory snapshot the admin API serves must be clean too.
76+
assertJSONHasNoRawSecret(t, "shadow.All", sl.All())
77+
}
78+
79+
func TestSignalRaw_NeverLeaksIntoDetectLog(t *testing.T) {
80+
sig := leakSignal()
81+
store := storage.NewMemory()
82+
dl, err := LoadDetectLog(store, 0)
83+
if err != nil {
84+
t.Fatalf("LoadDetectLog: %v", err)
85+
}
86+
// Build the DetectEvent exactly as emitDetect does: Samples come from
87+
// sampleMessages (redacted Message), never from Raw.
88+
evt := &DetectEvent{
89+
Source: sig.Source,
90+
PatternID: "p1",
91+
Template: "tmpl",
92+
Service: "api",
93+
Verdict: "unknown",
94+
Frequency: 1,
95+
Samples: sampleMessages([]core.Signal{sig}, 3),
96+
Outcome: "emitted",
97+
}
98+
dl.Record(evt)
99+
if err := dl.Persist(); err != nil {
100+
t.Fatalf("Persist: %v", err)
101+
}
102+
assertBlobHasNoRawSecret(t, store, "detect")
103+
assertJSONHasNoRawSecret(t, "detect.All", dl.All())
104+
}
105+
106+
func assertBlobHasNoRawSecret(t *testing.T, store storage.Provider, blob string) {
107+
t.Helper()
108+
data, err := store.ReadBlob(blob)
109+
if err != nil {
110+
t.Fatalf("ReadBlob(%s): %v", blob, err)
111+
}
112+
if strings.Contains(string(data), rawOnlySecret) {
113+
t.Fatalf("Signal.Raw leaked into persisted %q blob:\n%s", blob, data)
114+
}
115+
}
116+
117+
func assertJSONHasNoRawSecret(t *testing.T, what string, v interface{}) {
118+
t.Helper()
119+
data, err := json.Marshal(v)
120+
if err != nil {
121+
t.Fatalf("marshal %s: %v", what, err)
122+
}
123+
if strings.Contains(string(data), rawOnlySecret) {
124+
t.Fatalf("Signal.Raw leaked into %s payload:\n%s", what, data)
125+
}
126+
}

pkg/agent/redact.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,47 @@ type Redactor struct {
2525
type redactRule struct {
2626
name string
2727
re *regexp.Regexp
28+
// repl is an optional replacement template ($1 / ${1} capture refs). When
29+
// empty the whole match is replaced by the fixed `<REDACTED:name>` token;
30+
// when set it lets a rule preserve surrounding delimiters it had to match
31+
// for context (e.g. basic_auth keeps the `://` and `@host` boundary).
32+
repl string
2833
}
2934

3035
// Default redaction patterns. Order matters — longer / more specific patterns
3136
// run first so an email isn't partially eaten by an IP rule.
3237
var defaultRedactors = []struct {
3338
name string
3439
pattern string
40+
repl string // optional replacement template; empty ⇒ whole-match token
3541
}{
3642
// JSON web tokens (header.payload.signature)
37-
{"jwt", `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`},
43+
{name: "jwt", pattern: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`},
3844
// AWS access keys (AKIA... / ASIA...)
39-
{"aws_key", `\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`},
45+
{name: "aws_key", pattern: `\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`},
46+
// OpenAI-style API keys (sk-..., incl. project keys sk-proj-...). The
47+
// {20,} floor keeps short "sk-foo" identifiers from matching.
48+
{name: "openai_key", pattern: `\bsk-[A-Za-z0-9_\-]{20,}`},
49+
// Slack tokens: bot (xoxb), user (xoxp), app (xoxa), refresh (xoxr),
50+
// config (xoxc/xoxs), export (xoxe), legacy/cookie (xoxd). The letter
51+
// class deliberately EXCLUDES "o" so a near-miss like "xoxo-…" stays
52+
// untouched. The {10,} floor avoids matching a bare prefix.
53+
{name: "slack_token", pattern: `\bxox[abcdeprs]-[A-Za-z0-9\-]{10,}`},
54+
// Basic-auth credentials embedded in a URL (scheme://user:pass@host).
55+
// The `://` and the trailing `@` are matched only for context and put back
56+
// via the replacement template, so the credentials are fully scrubbed while
57+
// the separators survive: `://user:pass@host` → `://<REDACTED:basic_auth>@host`.
58+
{name: "basic_auth", pattern: `(://)[^/\s:@]+:[^/\s@]+(@)`, repl: `${1}<REDACTED:basic_auth>${2}`},
4059
// Bearer / Authorization headers
41-
{"bearer", `(?i)Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+`},
60+
{name: "bearer", pattern: `(?i)Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+`},
4261
// Generic password=... / token=...
43-
{"password", `(?i)\b(?:password|passwd|pwd|secret|token|api[_-]?key)\s*[=:]\s*\S+`},
62+
{name: "password", pattern: `(?i)\b(?:password|passwd|pwd|secret|token|api[_-]?key)\s*[=:]\s*\S+`},
4463
// Email addresses
45-
{"email", `[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`},
64+
{name: "email", pattern: `[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`},
4665
// UUIDs
47-
{"uuid", `\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`},
66+
{name: "uuid", pattern: `\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`},
4867
// User-Agent strings
49-
{"user_agent", `Mozilla/[0-9.]+\s*\([^)]*\)(?:[^"\n]*?(?:Gecko|Chrome|Safari|Firefox|Edg|Trident|OPR|MSIE)[^"\n]*)?`},
68+
{name: "user_agent", pattern: `Mozilla/[0-9.]+\s*\([^)]*\)(?:[^"\n]*?(?:Gecko|Chrome|Safari|Firefox|Edg|Trident|OPR|MSIE)[^"\n]*)?`},
5069
}
5170

5271
// IPv4/IPv6 redactor — opt-in because it removes a lot of useful context.
@@ -72,7 +91,7 @@ func NewRedactor(redactIPs bool, extra []string) (*Redactor, []error) {
7291
errs = append(errs, fmt.Errorf("default redactor %q: %w", d.name, err))
7392
continue
7493
}
75-
r.rules = append(r.rules, redactRule{name: d.name, re: re})
94+
r.rules = append(r.rules, redactRule{name: d.name, re: re, repl: d.repl})
7695
}
7796
if redactIPs {
7897
for _, d := range ipRedactors {
@@ -96,14 +115,18 @@ func NewRedactor(redactIPs bool, extra []string) (*Redactor, []error) {
96115
}
97116

98117
// Scrub returns s with every match of every rule replaced by
99-
// `<REDACTED:<rule>>`.
118+
// `<REDACTED:<rule>>` (or the rule's own replacement template, when set, so a
119+
// rule like basic_auth can keep the delimiters it only matched for context).
100120
func (r *Redactor) Scrub(s string) string {
101121
if r == nil || len(r.rules) == 0 || s == "" {
102122
return s
103123
}
104124
for _, rule := range r.rules {
105-
token := "<REDACTED:" + rule.name + ">"
106-
s = rule.re.ReplaceAllString(s, token)
125+
repl := rule.repl
126+
if repl == "" {
127+
repl = "<REDACTED:" + rule.name + ">"
128+
}
129+
s = rule.re.ReplaceAllString(s, repl)
107130
}
108131
return s
109132
}

0 commit comments

Comments
 (0)