From 5ea49e044fe89988df91227e95e89d2a7a6fb6ab Mon Sep 17 00:00:00 2001 From: chou-godaddy Date: Fri, 23 Oct 2026 13:57:32 -0700 Subject: [PATCH 1/3] feat(hydrator): add corrected policy adherence output Add opt-in policy decision measurements and a generic Trust Index score. Preserve findings as structured evidence, isolate resource limits per agent, and leave both legacy signal formats and counting rules unchanged. Document registration, weight-zero inspection, migration, and stale-score limitations without changing the Trust Index reference implementation. Refs agentnameservice/ans-openshell#9 Signed-off-by: chou-godaddy --- README.md | 8 + cmd/ans-ocsf-hydrator/activity_test.go | 397 ++++++++++++++++ cmd/ans-ocsf-hydrator/main.go | 88 +++- deploy/deployment_test.go | 17 +- deploy/examples/hydrator-activity.yaml | 20 + deploy/examples/hydrator.yaml | 1 + .../examples/trust-index-score-signals.yaml | 6 + .../ans-openshell/templates/hydrator.yaml | 4 + deploy/helm/ans-openshell/values.yaml | 12 +- deploy/hydrator_activity_test.go | 65 +++ docs/hydrator-activity.md | 119 +++++ internal/hydrator/activity.go | 436 ++++++++++++++++++ internal/hydrator/activity_event.go | 379 +++++++++++++++ internal/hydrator/activity_event_test.go | 311 +++++++++++++ internal/hydrator/activity_limits_test.go | 224 +++++++++ internal/hydrator/activity_review_test.go | 122 +++++ internal/hydrator/activity_score.go | 75 +++ internal/hydrator/activity_test.go | 306 ++++++++++++ internal/hydrator/activity_types.go | 69 +++ internal/hydrator/aggregate.go | 29 +- internal/hydrator/doc.go | 18 +- internal/hydrator/event.go | 18 + internal/hydrator/observation.go | 5 + internal/hydrator/run.go | 50 +- 24 files changed, 2752 insertions(+), 27 deletions(-) create mode 100644 cmd/ans-ocsf-hydrator/activity_test.go create mode 100644 deploy/examples/hydrator-activity.yaml create mode 100644 deploy/examples/trust-index-score-signals.yaml create mode 100644 deploy/hydrator_activity_test.go create mode 100644 docs/hydrator-activity.md create mode 100644 internal/hydrator/activity.go create mode 100644 internal/hydrator/activity_event.go create mode 100644 internal/hydrator/activity_event_test.go create mode 100644 internal/hydrator/activity_limits_test.go create mode 100644 internal/hydrator/activity_review_test.go create mode 100644 internal/hydrator/activity_score.go create mode 100644 internal/hydrator/activity_test.go create mode 100644 internal/hydrator/activity_types.go diff --git a/README.md b/README.md index f322241..432b65a 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ parsed but none mapped to a configured agent, which usually means the sandbox ma `--allow-unmapped` accepts that. The Trust Index admin key comes from `trust_index.admin_key_file` or the `ANS_TRUST_INDEX_ADMIN_KEY` environment variable. +The optional `openshell.behavior.activity` output applies corrected policy-decision +counting and imports directly into the Trust Index reference implementation +(`agent-trust-discovery`). See [policy-adherence setup and migration](docs/hydrator-activity.md) +for its signal registration, a weight-0 rollout, and `--evidence-output` JSONL. +The new output is off by default; legacy outputs retain their existing behavior. +Findings remain evidence and explanation text, without changing the adherence score. +Keep the new signal's weight 0 until the consumer can invalidate stale scores. + The reference callee takes flags only: ```bash diff --git a/cmd/ans-ocsf-hydrator/activity_test.go b/cmd/ans-ocsf-hydrator/activity_test.go new file mode 100644 index 0000000..1c173e9 --- /dev/null +++ b/cmd/ans-ocsf-hydrator/activity_test.go @@ -0,0 +1,397 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/agentnameservice/ans-openshell/internal/config" + "github.com/agentnameservice/ans-openshell/internal/hydrator" +) + +func TestActivitySignalConfiguration(t *testing.T) { + t.Parallel() + tests := []struct { + name string + yaml string + evidence string + wantID string + wantEnabled bool + wantLegacy bool + wantErr string + }{ + {name: "legacy defaults remain", wantLegacy: true}, + {name: "activity explicitly disabled", yaml: "signals:\n activity: \"\"\n", wantLegacy: true}, + {name: "activity only", yaml: "signals:\n policy_violation_rate: \"\"\n protocol_adherence: \"\"\n activity: openshell.behavior.activity\n", wantID: "openshell.behavior.activity", wantEnabled: true}, + {name: "custom activity ID", yaml: "signals:\n activity: acme.behavior.activity\n", wantID: "acme.behavior.activity", wantEnabled: true, wantLegacy: true}, + {name: "all outputs disabled", yaml: "signals:\n policy_violation_rate: \"\"\n protocol_adherence: \"\"\n", wantErr: "every signal is disabled"}, + {name: "evidence only", yaml: "signals:\n policy_violation_rate: \"\"\n protocol_adherence: \"\"\n", evidence: "evidence.jsonl"}, + {name: "negative sample minimum", yaml: "activity:\n min_decisions: -1\n", wantErr: "must be positive"}, + {name: "negative agent UID budget", yaml: "activity:\n max_uids_per_agent: -1\n", wantErr: "must be positive"}, + {name: "negative total UID budget", yaml: "activity:\n max_uids: -1\n", wantErr: "must be positive"}, + {name: "unknown activity setting", yaml: "activity:\n typo: 10\n", wantErr: "field typo not found"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var cfg fileConfig + var opts hydrator.Options + data := "provenance:\n aim_id: ans://v1.0.0.collector.example.com\nagents:\n - agent_id: probe\n sandbox_names: [probe]\n" + tt.yaml + err := config.Parse([]byte(data), &cfg) + if err == nil { + opts, err = buildOptions(cfg, flags{evidenceOutput: tt.evidence}) + } + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("configuration failed: %v", err) + } + if opts.Signals.EmitActivity != tt.wantEnabled { + t.Errorf("EmitActivity = %v, want %v", opts.Signals.EmitActivity, tt.wantEnabled) + } + if tt.wantID != "" && opts.Signals.ActivityID != tt.wantID { + t.Errorf("ActivityID = %q, want %q", opts.Signals.ActivityID, tt.wantID) + } + if opts.Signals.EmitPolicyViolationRate != tt.wantLegacy || opts.Signals.EmitProtocolAdherence != tt.wantLegacy { + t.Errorf("legacy signals = %+v, want enabled=%v", opts.Signals, tt.wantLegacy) + } + }) + } +} + +func TestActivityExampleConfiguration(t *testing.T) { + t.Parallel() + for _, filename := range []string{"../../deploy/examples/hydrator-activity.yaml"} { + t.Run(filepath.Base(filename), func(t *testing.T) { + t.Parallel() + var cfg fileConfig + if err := config.Load(filename, &cfg); err != nil { + t.Fatal(err) + } + encoded, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + var decoded fileConfig + if err := config.Parse(encoded, &decoded); err != nil { + t.Fatal(err) + } + opts, err := buildOptions(decoded, flags{dryRun: true}) + if err != nil { + t.Fatal(err) + } + if !opts.Signals.EmitActivity || opts.Signals.ActivityID != "openshell.behavior.activity" || opts.Signals.EmitPolicyViolationRate || opts.Signals.EmitProtocolAdherence { + t.Fatalf("example must select only activity: %+v", opts.Signals) + } + }) + } +} + +func TestActivityOptions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + minimum, perAgent, max int + }{ + {name: "zero selects defaults"}, + {name: "explicit bounds", minimum: 10, perAgent: 200, max: 500}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var cfg fileConfig + data := []byte("provenance:\n aim_id: ans://v1.0.0.collector.example.com\nagents:\n - agent_id: probe\n sandbox_names: [probe]\n") + if err := config.Parse(data, &cfg); err != nil { + t.Fatal(err) + } + cfg.Activity.MinDecisions, cfg.Activity.MaxUIDsPerAgent, cfg.Activity.MaxUIDs = tt.minimum, tt.perAgent, tt.max + opts, err := buildOptions(cfg, flags{}) + if err != nil { + t.Fatal(err) + } + if opts.Activity.MinDecisions != tt.minimum || opts.Activity.MaxUIDsPerAgent != tt.perAgent || opts.Activity.MaxUIDs != tt.max { + t.Fatalf("activity options not preserved: %+v", opts.Activity) + } + }) + } +} + +func TestHydrateEvidenceOutput(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + dryRun bool + conflict bool + missing bool + legacy bool + canceled bool + noAgents bool + wantErr string + wantOutput bool + }{ + {name: "finding only without a Trust Index", input: `{"class_uid":2004,"container":{"name":"probe"},"severity_id":3,"finding_info":{"uid":"review.finding"}}`, wantOutput: true}, + {name: "dry run with private evidence file", input: `{"class_uid":4001,"activity_id":1,"container":{"name":"probe"},"status_id":1,"message":"Supervisor session established"}`, dryRun: true, wantOutput: true}, + {name: "empty input", input: ""}, + {name: "missing input", missing: true, wantErr: "--input"}, + {name: "existing evidence file", input: `{"class_uid":2004,"container":{"name":"probe"}}`, conflict: true, wantErr: "--evidence-output"}, + {name: "unknown lines remain an input error", input: "not an OCSF record", wantErr: "input had lines"}, + {name: "legacy posting still requires a server", legacy: true, wantErr: "trust_index.base_url"}, + {name: "invalid options", noAgents: true, wantErr: "agent mapping"}, + {name: "canceled run closes evidence", canceled: true, wantErr: "context canceled"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + input := filepath.Join(dir, "input.jsonl") + output := filepath.Join(dir, "evidence.jsonl") + if !tt.missing { + if err := os.WriteFile(input, []byte(tt.input), 0o600); err != nil { + t.Fatal(err) + } + } + if tt.conflict { + if err := os.WriteFile(output, []byte("existing"), 0o600); err != nil { + t.Fatal(err) + } + } + data := "provenance:\n aim_id: ans://v1.0.0.collector.example.com\nagents:\n - agent_id: probe\n sandbox_names: [probe]\n" + if !tt.legacy { + data += "signals:\n policy_violation_rate: \"\"\n protocol_adherence: \"\"\n" + } + var cfg fileConfig + if err := config.Parse([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + if tt.noAgents { + cfg.Agents = nil + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if tt.canceled { + cancel() + } + err := hydrate(ctx, cfg, flags{input: input, evidenceOutput: output, dryRun: tt.dryRun}, slog.New(slog.DiscardHandler)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("hydrate() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if (len(got) > 0) != tt.wantOutput { + t.Fatalf("evidence = %q, want output=%v", got, tt.wantOutput) + } + }) + } +} + +func TestHydrateActivityPosting(t *testing.T) { + t.Parallel() + tests := []struct { + name string + status int + wantErr string + }{ + {name: "generic observation posted directly", status: http.StatusOK}, + {name: "import failure propagated", status: http.StatusServiceUnavailable, wantErr: "HTTP 503"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var posted atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + posted.Add(1) + if r.Method != http.MethodPost || r.URL.Path != hydrator.ImportPath { + t.Errorf("import request = %s %s", r.Method, r.URL.Path) + } + var batch struct { + Observations []struct { + SignalID string `json:"signalId"` + Value json.RawMessage `json:"value"` + } `json:"observations"` + } + if err := json.NewDecoder(r.Body).Decode(&batch); err != nil { + t.Errorf("decode import: %v", err) + } + if len(batch.Observations) != 1 || batch.Observations[0].SignalID != "openshell.behavior.activity" { + t.Errorf("unexpected observations: %+v", batch.Observations) + } else { + var value struct { + Score *int `json:"score"` + Explanation string `json:"explanation"` + RiskCodes []string `json:"riskCodes"` + } + decoder := json.NewDecoder(bytes.NewReader(batch.Observations[0].Value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.Score == nil || *value.Score != 50 || value.Explanation == "" || len(value.RiskCodes) != 0 { + t.Errorf("unsupported generic score value: %s (error %v)", batch.Observations[0].Value, err) + } + } + w.WriteHeader(tt.status) + })) + defer server.Close() + var cfg fileConfig + data := "provenance:\n aim_id: ans://v1.0.0.collector.example.com\nagents:\n - agent_id: probe\n sandbox_names: [probe]\nsignals:\n policy_violation_rate: \"\"\n protocol_adherence: \"\"\n activity: openshell.behavior.activity\n" + if err := config.Parse([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + cfg.TrustIndex.BaseURL = server.URL + input := filepath.Join(t.TempDir(), "input.jsonl") + payload := `{"class_uid":4002,"activity_id":99,"action_id":1,"disposition_id":1,"time":1791996120000,"container":{"name":"probe"},"message":"L7_REQUEST allow GET example.com:443/test reason=rule"} +{"class_uid":4002,"activity_id":99,"action_id":1,"disposition_id":1,"time":1791996121000,"container":{"name":"probe"},"message":"L7_REQUEST audit POST example.com:443/test reason=rule"}` + if err := os.WriteFile(input, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + err := hydrate(context.Background(), cfg, flags{input: input}, slog.New(slog.DiscardHandler)) + if got := posted.Load(); got != 1 { + t.Fatalf("posted %d requests, want 1", got) + } + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("hydrate() error = %v, want %q", err, tt.wantErr) + } + } else if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestOpenEvidence(t *testing.T) { + t.Parallel() + tests := []struct { + name string + path string + dryRun bool + exists bool + wantNil bool + wantErr string + }{ + {name: "disabled", wantNil: true}, + {name: "stdout", path: "-"}, + {name: "stdout conflicts with import JSON", path: "-", dryRun: true, wantErr: "--dry-run"}, + {name: "private new file", path: "evidence.jsonl"}, + {name: "file with dry run", path: "evidence.jsonl", dryRun: true}, + {name: "existing input or config cannot be truncated", path: "existing.jsonl", exists: true, wantErr: "--evidence-output"}, + {name: "missing parent", path: "missing/evidence.jsonl", wantErr: "--evidence-output"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := tt.path + if path != "" && path != "-" { + path = filepath.Join(t.TempDir(), path) + } + original := []byte("keep this input/config intact\n") + if tt.exists { + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + } + writer, closeOutput, err := openEvidence(path, tt.dryRun) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("openEvidence() error = %v, want %q", err, tt.wantErr) + } + if tt.exists { + got, readErr := os.ReadFile(path) + if readErr != nil || !bytes.Equal(got, original) { + t.Fatalf("existing file changed: %q, %v", got, readErr) + } + } + return + } + if err != nil { + t.Fatal(err) + } + if (writer == nil) != tt.wantNil { + t.Fatalf("writer = %v, want nil=%v", writer, tt.wantNil) + } + if path != "" && path != "-" { + if _, err := io.WriteString(writer, "{\"test\":true}\n"); err != nil { + t.Fatal(err) + } + } + if err := closeOutput(); err != nil { + t.Fatal(err) + } + if path == "-" && writer != os.Stdout { + t.Fatalf("stdout writer = %v", writer) + } + if path != "" && path != "-" { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm()&0o077 != 0 { + t.Fatalf("evidence file permissions: %v, %v", info, err) + } + } + }) + } +} + +func TestLogActivity(t *testing.T) { + t.Parallel() + score := 67 + tests := []struct { + name string + score *int + unavailable string + level string + }{ + {name: "numeric score", score: &score, level: "INFO"}, + {name: "no eligible decisions", unavailable: "no_eligible_decisions", level: "INFO"}, + {name: "sample minimum", unavailable: "insufficient_sample", level: "INFO"}, + {name: "deduplication saturated", unavailable: "dedup_saturated", level: "WARN"}, + {name: "unknown policy format", unavailable: "interpretation_incomplete", level: "WARN"}, + {name: "malformed audit feed", unavailable: "unreadable_audit_records", level: "WARN"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, nil)) + logActivity(context.Background(), logger, []hydrator.ActivityEvidence{{AgentID: "probe", Score: tt.score, SampleSize: 6, Unavailable: tt.unavailable}}) + var record map[string]json.RawMessage + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + wantScore := "null" + if tt.score != nil { + wantScore = "67" + } + if string(record["score"]) != wantScore || string(record["sample_size"]) != "6" { + t.Fatalf("score/sample not logged: %s", output.Bytes()) + } + var reason string + if err := json.Unmarshal(record["unavailable_reason"], &reason); err != nil || reason != tt.unavailable { + t.Fatalf("unavailable reason missing: %s", output.Bytes()) + } + var level string + if err := json.Unmarshal(record["level"], &level); err != nil || level != tt.level { + t.Fatalf("activity log level=%s, want %s", record["level"], tt.level) + } + }) + } +} diff --git a/cmd/ans-ocsf-hydrator/main.go b/cmd/ans-ocsf-hydrator/main.go index 3f5a422..c33951d 100644 --- a/cmd/ans-ocsf-hydrator/main.go +++ b/cmd/ans-ocsf-hydrator/main.go @@ -40,7 +40,13 @@ type fileConfig struct { Signals struct { PolicyViolationRate *string `yaml:"policy_violation_rate"` ProtocolAdherence *string `yaml:"protocol_adherence"` + Activity *string `yaml:"activity"` } `yaml:"signals"` + Activity struct { + MinDecisions int `yaml:"min_decisions"` + MaxUIDsPerAgent int `yaml:"max_uids_per_agent"` + MaxUIDs int `yaml:"max_uids"` + } `yaml:"activity"` Signing struct { KeyFile string `yaml:"key_file"` CertFile string `yaml:"cert_file"` @@ -50,6 +56,7 @@ type fileConfig struct { type flags struct { configPath, input, windowStart, windowEnd string + evidenceOutput string windowLast time.Duration dryRun, allowUnmapped bool } @@ -61,6 +68,7 @@ type flags struct { var ( errNoEvents = errors.New("input had lines but no OCSF events; check that ocsf_json_enabled is set and that the collector writes bare lines (set --allow-unmapped to accept this)") errNothingAttributed = errors.New("events were parsed but none of them mapped to a configured agent (set --allow-unmapped to accept this)") + errNoSignals = errors.New("signals: every signal is disabled; nothing would be posted (select --evidence-output for local evidence only)") ) func main() { @@ -75,6 +83,7 @@ func run() error { flag.StringVar(&f.configPath, "config", "", "path to the YAML configuration (required)") flag.StringVar(&f.input, "input", "-", "OCSF JSONL file, or - for stdin") flag.BoolVar(&f.dryRun, "dry-run", false, "print the import request instead of posting it") + flag.StringVar(&f.evidenceOutput, "evidence-output", "", "write corrected structured evidence as JSONL to a new file, or - for stdout (stdout cannot be combined with --dry-run)") flag.BoolVar(&f.allowUnmapped, "allow-unmapped", false, "exit 0 when the input yields no OCSF events or none of them map to an agent") flag.StringVar(&f.windowStart, "window-start", "", "RFC 3339 start of the observation window (default: first event)") flag.StringVar(&f.windowEnd, "window-end", "", "RFC 3339 end of the observation window (default: last event)") @@ -98,23 +107,38 @@ func run() error { if err := config.Load(f.configPath, &cfg); err != nil { return err } - opts, err := buildOptions(cfg, f) - if err != nil { - return err - } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - sink, err := buildSink(ctx, cfg, f.dryRun, logger) + return hydrate(ctx, cfg, f, logger) +} + +func hydrate(ctx context.Context, cfg fileConfig, f flags, logger *slog.Logger) error { + opts, err := buildOptions(cfg, f) if err != nil { return err } + var sink hydrator.Sink = hydrator.NewWriterSink(io.Discard) + if opts.Signals.EmitPolicyViolationRate || opts.Signals.EmitProtocolAdherence || opts.Signals.EmitActivity { + sink, err = buildSink(ctx, cfg, f.dryRun, logger) + if err != nil { + return err + } + } in, closeInput, err := openInput(f.input) if err != nil { return err } defer closeInput() + evidence, closeEvidence, err := openEvidence(f.evidenceOutput, f.dryRun) + if err != nil { + return err + } + opts.Evidence = evidence report, err := hydrator.Run(ctx, in, sink, opts, logger) + if closeErr := closeEvidence(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close evidence output: %w", closeErr)) + } logReport(ctx, logger, cfg.Agents, report, err) if err != nil { return err @@ -154,10 +178,16 @@ func buildOptions(cfg fileConfig, f flags) (hydrator.Options, error) { return hydrator.Options{}, errors.New("provenance.aim_id is required") } signals, err := signalConfig(cfg) - if err != nil { + if err != nil && (!errors.Is(err, errNoSignals) || f.evidenceOutput == "") { + return hydrator.Options{}, err + } + activity := hydrator.ActivityConfig{ + MinDecisions: cfg.Activity.MinDecisions, MaxUIDsPerAgent: cfg.Activity.MaxUIDsPerAgent, MaxUIDs: cfg.Activity.MaxUIDs, + } + if err := activity.Validate(); err != nil { return hydrator.Options{}, err } - opts := hydrator.Options{Agents: agents, Signals: signals, AIMID: cfg.Provenance.AIMID, EvidenceURL: cfg.Provenance.EvidenceURL} + opts := hydrator.Options{Agents: agents, Signals: signals, Activity: activity, AIMID: cfg.Provenance.AIMID, EvidenceURL: cfg.Provenance.EvidenceURL} if opts.Signer, err = batchSigner(cfg); err != nil { return hydrator.Options{}, err } @@ -202,8 +232,12 @@ func signalConfig(cfg fileConfig) (hydrator.SignalConfig, error) { signals.ProtocolAdherenceID = *cfg.Signals.ProtocolAdherence signals.EmitProtocolAdherence = *cfg.Signals.ProtocolAdherence != "" } - if !signals.EmitPolicyViolationRate && !signals.EmitProtocolAdherence { - return hydrator.SignalConfig{}, errors.New("signals: every signal is disabled; nothing would be posted") + if cfg.Signals.Activity != nil { + signals.ActivityID = *cfg.Signals.Activity + signals.EmitActivity = *cfg.Signals.Activity != "" + } + if !signals.EmitPolicyViolationRate && !signals.EmitProtocolAdherence && !signals.EmitActivity { + return signals, errNoSignals } return signals, nil } @@ -285,6 +319,26 @@ func openInput(path string) (io.Reader, func(), error) { return f, func() { _ = f.Close() }, nil } +// openEvidence refuses existing files so evidence cannot truncate the input or +// configuration, including aliases through symlinks or hard links. +func openEvidence(path string, dryRun bool) (io.Writer, func() error, error) { + noop := func() error { return nil } + if path == "" { + return nil, noop, nil + } + if path == "-" { + if dryRun { + return nil, nil, errors.New("--evidence-output - cannot be combined with --dry-run: both write to stdout") + } + return os.Stdout, noop, nil + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, nil, fmt.Errorf("--evidence-output: create new file: %w", err) + } + return f, f.Close, nil +} + func logReport(ctx context.Context, logger *slog.Logger, configured []hydrator.AgentMapping, r hydrator.Report, runErr error) { if runErr != nil { logger.ErrorContext(ctx, "hydration failed", "err", runErr) @@ -305,4 +359,20 @@ func logReport(ctx context.Context, logger *slog.Logger, configured []hydrator.A logger.WarnContext(ctx, "configured agent received no events", "agent_id", m.AgentID, "ans_name", m.AnsName) } } + logActivity(ctx, logger, r.Activity) +} + +func logActivity(ctx context.Context, logger *slog.Logger, evidence []hydrator.ActivityEvidence) { + for _, item := range evidence { + var score any + if item.Score != nil { + score = *item.Score + } + level := slog.LevelWarn + if item.Unavailable == "" || item.Unavailable == "no_eligible_decisions" || item.Unavailable == "insufficient_sample" { + level = slog.LevelInfo + } + logger.Log(ctx, level, "activity summary", "agent_id", item.AgentID, + "score", score, "sample_size", item.SampleSize, "unavailable_reason", item.Unavailable) + } } diff --git a/deploy/deployment_test.go b/deploy/deployment_test.go index a6bf365..50296e5 100644 --- a/deploy/deployment_test.go +++ b/deploy/deployment_test.go @@ -94,6 +94,17 @@ type renderedResource struct { Annotations map[string]string `yaml:"annotations"` } `yaml:"metadata"` } `yaml:"template"` + JobTemplate struct { + Spec struct { + Template struct { + Spec struct { + Containers []struct { + Args []string `yaml:"args"` + } `yaml:"containers"` + } `yaml:"spec"` + } `yaml:"template"` + } `yaml:"spec"` + } `yaml:"jobTemplate"` } `yaml:"spec"` } @@ -142,10 +153,10 @@ func TestJWKSURLChangesRollBothServices(t *testing.T) { } } -func renderChart(t *testing.T, helm, jwksURL string) map[string]renderedResource { +func renderChart(t *testing.T, helm, jwksURL string, extraArgs ...string) map[string]renderedResource { t.Helper() - data, err := exec.CommandContext(t.Context(), helm, "template", "regression", "helm/ans-openshell", - "--set-string", "gateway.jwksURL="+jwksURL).CombinedOutput() + args := append([]string{"template", "regression", "helm/ans-openshell", "--set-string", "gateway.jwksURL=" + jwksURL}, extraArgs...) + data, err := exec.CommandContext(t.Context(), helm, args...).CombinedOutput() if err != nil { t.Fatalf("render Helm chart: %v\n%s", err, data) } diff --git a/deploy/examples/hydrator-activity.yaml b/deploy/examples/hydrator-activity.yaml new file mode 100644 index 0000000..e8fe656 --- /dev/null +++ b/deploy/examples/hydrator-activity.yaml @@ -0,0 +1,20 @@ +# Opt-in policy-adherence output for the Trust Index reference implementation +# (agent-trust-discovery). Register openshell.behavior.activity first and keep +# its profile weight 0 until the consumer can invalidate stale scores. +# Use ANS_TRUST_INDEX_ADMIN_KEY for the existing import route's Bearer key. +trust_index: + base_url: https://trust-index.example.com +provenance: + aim_id: ans://v1.0.0.collector.example.com +signals: + policy_violation_rate: "" + protocol_adherence: "" + activity: openshell.behavior.activity +activity: + min_decisions: 1 + max_uids_per_agent: 100000 + max_uids: 1000000 +agents: + - agent_id: "00000000-0000-4000-8000-000000000001" + ans_name: ans://v1.0.0.probe.example.com + sandbox_names: [probe] diff --git a/deploy/examples/hydrator.yaml b/deploy/examples/hydrator.yaml index 41a1b4e..f9df029 100644 --- a/deploy/examples/hydrator.yaml +++ b/deploy/examples/hydrator.yaml @@ -12,6 +12,7 @@ provenance: signals: policy_violation_rate: openshell.policyViolationRate # set to "" to disable protocol_adherence: openshell.protocolAdherence + activity: "" # opt in with hydrator-activity.yaml after registering the signal signing: # optional; signs each batch (X-Ans-Batch-Signature) key_file: /var/run/secrets/ans-openshell/platform/key.pem cert_file: /var/run/secrets/ans-openshell/platform/cert.pem diff --git a/deploy/examples/trust-index-score-signals.yaml b/deploy/examples/trust-index-score-signals.yaml new file mode 100644 index 0000000..1a4ab41 --- /dev/null +++ b/deploy/examples/trust-index-score-signals.yaml @@ -0,0 +1,6 @@ +# For agent-trust-discovery, referenced by runtime config signals.path. +# Merge with the deployment's existing registrations; do not replace them. +signals: + - id: openshell.behavior.activity + dimension: behavior + threshold: 0 # disable the generic low-score risk code; this output scores adherence only diff --git a/deploy/helm/ans-openshell/templates/hydrator.yaml b/deploy/helm/ans-openshell/templates/hydrator.yaml index 25eb4dd..7406e8d 100644 --- a/deploy/helm/ans-openshell/templates/hydrator.yaml +++ b/deploy/helm/ans-openshell/templates/hydrator.yaml @@ -45,6 +45,10 @@ spec: - --window-last - {{ . | quote }} {{- end }} +{{- with .Values.hydrator.evidenceOutput }} + - --evidence-output + - {{ . | quote }} +{{- end }} {{- if .Values.hydrator.adminKeySecret }} envFrom: - secretRef: {name: {{ .Values.hydrator.adminKeySecret }}} diff --git a/deploy/helm/ans-openshell/values.yaml b/deploy/helm/ans-openshell/values.yaml index a8b057a..00578ab 100644 --- a/deploy/helm/ans-openshell/values.yaml +++ b/deploy/helm/ans-openshell/values.yaml @@ -77,7 +77,8 @@ interceptor: # it may run on (or pin it with nodeSelector), mount it with extraVolumes and extraVolumeMounts and # point input at it. The hydrator signs and posts what it reads, so only the collector may write # that volume. The gateway's ocsf_log JSONL carries gateway-origin events only, not sandbox -# traffic. Each run aggregates the trailing window, so keep window aligned with schedule. +# traffic. The window may be longer than the schedule (for example, 24h every 15m); +# retain enough source logs for the selected window. hydrator: enabled: false image: @@ -86,6 +87,7 @@ hydrator: schedule: "*/15 * * * *" window: 15m # --window-last; empty aggregates the whole input every run input: /var/log/openshell/supervisor-ocsf.jsonl + evidenceOutput: "" # optional corrected JSONL evidence; use "-" for stdout, or a new file on a writable volume nodeSelector: {} adminKeySecret: "" # secret with key ANS_TRUST_INDEX_ADMIN_KEY; empty posts without Authorization extraVolumes: [] @@ -95,6 +97,14 @@ hydrator: base_url: https://trust-index.example.com provenance: aim_id: ans://v1.0.0.platform.example.com + signals: + policy_violation_rate: openshell.policyViolationRate + protocol_adherence: openshell.protocolAdherence + activity: "" # opt in with openshell.behavior.activity after registering it; disable both legacy outputs above + activity: + min_decisions: 1 + max_uids_per_agent: 100000 + max_uids: 1000000 agents: - agent_id: 7a4b2e91-83f6-4c12-9d58-bf1e6a3c9d07 ans_name: ans://v1.0.0.payments.example.com diff --git a/deploy/hydrator_activity_test.go b/deploy/hydrator_activity_test.go new file mode 100644 index 0000000..d7116f8 --- /dev/null +++ b/deploy/hydrator_activity_test.go @@ -0,0 +1,65 @@ +package deploy_test + +import ( + "slices" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestHydratorActivityChart(t *testing.T) { + t.Parallel() + helm := deploymentTool(t, "helm") + tests := []struct { + name string + window string + evidence string + }{ + {name: "no evidence output", window: "15m"}, + {name: "stdout evidence with longer window", window: "24h", evidence: "-"}, + {name: "writable volume evidence", window: "1h", evidence: "/evidence/new-run.jsonl"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resources := renderChart(t, helm, "https://gateway.example/.well-known/jwks.json", + "--set", "hydrator.enabled=true", + "--set-string", "hydrator.window="+tt.window, + "--set-string", "hydrator.evidenceOutput="+tt.evidence, + "--set-string", "hydrator.config.signals.policy_violation_rate=", + "--set-string", "hydrator.config.signals.protocol_adherence=", + "--set-string", "hydrator.config.signals.activity=openshell.behavior.activity") + job := resources["CronJob/regression-hydrator"] + containers := job.Spec.JobTemplate.Spec.Template.Spec.Containers + if len(containers) != 1 { + t.Fatalf("hydrator containers = %d, want one", len(containers)) + } + args := containers[0].Args + windowIndex := slices.Index(args, "--window-last") + if windowIndex < 0 || windowIndex+1 >= len(args) || args[windowIndex+1] != tt.window { + t.Fatalf("window argument missing from %v", args) + } + evidenceIndex := slices.Index(args, "--evidence-output") + if tt.evidence == "" { + if evidenceIndex >= 0 { + t.Fatalf("unexpected evidence flag in %v", args) + } + } else if evidenceIndex < 0 || evidenceIndex+1 >= len(args) || args[evidenceIndex+1] != tt.evidence { + t.Fatalf("evidence argument missing from %v", args) + } + var cfg struct { + Signals struct { + Activity string `yaml:"activity"` + PolicyViolationRate string `yaml:"policy_violation_rate"` + ProtocolAdherence string `yaml:"protocol_adherence"` + } `yaml:"signals"` + } + if err := yaml.Unmarshal([]byte(resources["ConfigMap/regression-hydrator"].Data["config.yaml"]), &cfg); err != nil { + t.Fatal(err) + } + if cfg.Signals.Activity != "openshell.behavior.activity" || cfg.Signals.PolicyViolationRate != "" || cfg.Signals.ProtocolAdherence != "" { + t.Fatalf("activity migration config not preserved: %+v", cfg.Signals) + } + }) + } +} diff --git a/docs/hydrator-activity.md b/docs/hydrator-activity.md new file mode 100644 index 0000000..d01fda5 --- /dev/null +++ b/docs/hydrator-activity.md @@ -0,0 +1,119 @@ +# OpenShell policy adherence output + +The opt-in `openshell.behavior.activity` signal sends a numeric policy-adherence +score directly to the Trust Index reference implementation +(`agent-trust-discovery`). The existing `openshell.policyViolationRate` and +`openshell.protocolAdherence` outputs retain their formats and counting rules. + +The new path separates connection checks, initial HTTP checks, checks after +middleware changes a request, and WebSocket message checks. For each group it +calculates `100 × allowed / (allowed + denied + audited)` and rounds the lowest +eligible rate to an integer. An audit violation is a violation even when OpenShell +allows the request through. Connection failures, supervisor sessions, and health +reports do not become allowed or denied policy decisions. + +`activity.min_decisions` defaults to 1. An allowed-only group below the minimum +is ignored; a group with a denied or audited decision below the minimum prevents +a score. For example, 50 allowed HTTP checks over one allowed connection can +score 100 at minimum 10, but a low-sample denial cannot disappear behind that +score. This measures adherence to the configured policy, not content safety. + +## Configure the reference implementation first + +Merge [the score registration](../deploy/examples/trust-index-score-signals.yaml) +into the deployment's signal registration file and point its runtime configuration +at that file: + +```yaml +signals: + path: score-signals.yaml +``` + +In each profile that will inspect this signal, add the following entry without +changing the profile's existing weights: + +```yaml +signalWeights: + openshell.behavior.activity: 0 +``` + +Register the agent before importing its observations. An unknown agent, unknown +signal, or invalid value causes the reference implementation to reject the whole +batch. The output uses its existing `{score, explanation}` value; provider risk +codes are omitted or empty. The registration's `threshold: 0` also disables the +generic low-score risk code. + +Keep the signal at weight 0: its score is visible but does not affect trust +decisions. **Do not use it in real trust decisions until the consumer can expire +or otherwise invalidate old scores.** If the hydrator sends no new score, the +reference implementation retains its previous score indefinitely, including 100. +A later clean measurement window can replace an earlier low score. This producer +change adds neither consumer expiry nor a new scoreless-report API. + +## Enable the new producer output + +Start with [hydrator-activity.yaml](../deploy/examples/hydrator-activity.yaml), set +the import URL and sandbox-to-agent mappings, and configure the existing import +route's admin key. Disable both legacy outputs explicitly when migrating; leaving +them enabled sends their legacy values in the same batch, which requires their +own compatible registrations. A missing or empty `signals.activity` disables the +new output. + +```sh +./bin/ans-ocsf-hydrator \ + --config deploy/examples/hydrator-activity.yaml \ + --input supervisor-ocsf.jsonl \ + --window-last 24h \ + --evidence-output activity-evidence.jsonl +``` + +The existing `--window-last` flag, or Helm `hydrator.window`, selects the period +to measure. It may be longer than the run schedule, provided source logs are +retained for that period. The reference implementation reads only the latest +observation for each agent/signal, so it does not add overlapping snapshots. + +## Keep structured evidence + +`--evidence-output` writes one JSON object per agent as JSONL. It retains decision +counts, reported status, finding origins and match counts, policy context, and +diagnostics independently of the imported score. Findings do not change the score; +eligible score explanations summarize them. Finding-only windows stay in this +local output without inventing a numeric score. + +Unknown policy syntax, incomplete interpretation, and exhausted deduplication +budgets suppress the affected agent's new score and record the reason. Other +agents continue. Bound and size `activity.max_uids_per_agent` and +`activity.max_uids` for the deployment's volume; their defaults are 100,000 and +1,000,000 respectively. Duplicate event IDs are removed only from the new path, +leaving legacy behavior unchanged. + +When the aggregate UID budget fills, the hydrator marks the largest existing +UID owner unavailable and releases its set; ties use agent ID order. This lets +later agents proceed within the same memory budget. A budget too small for the +whole deployment can still make several agents unavailable. + +DNS refusals are retained separately from TCP policy checks. The hydrator checks +that denied TCP counts cover the DNS refusals for each sandbox and destination; +otherwise it withholds the score. This is a conservative consistency check, not +proof that two records came from the same request. + +Policy context records the first observed load for each sandbox/hash/version, +not every activation or reload. Missing applicable context is marked unknown. +The first and last event timestamps describe the records received; they do not +prove uninterrupted log coverage for the selected window. + +Evidence files are created with private permissions and must not already exist. +This prevents accidental truncation of input or configuration, including symlink +or hard-link aliases. Choose a new filename for another run, or use +`--evidence-output -` to send JSONL to stdout. Stdout evidence cannot be combined +with `--dry-run`, which reserves stdout for the import request. With all three +signal IDs set to `""`, evidence output works without a Trust Index URL or server. + +For scheduled Helm jobs, use `hydrator.evidenceOutput: "-"` so the log collector +retains each run's evidence. A fixed filename on a persistent volume works only +once: later jobs fail because evidence files are create-only. File output needs +a different filename for every run, supplied outside this chart. + +The existing `provenance.evidence_url` is one static URL reused for every +observation. It does not create immutable evidence storage for each agent/window; +the example leaves it unset. diff --git a/internal/hydrator/activity.go b/internal/hydrator/activity.go new file mode 100644 index 0000000..f5bb60d --- /dev/null +++ b/internal/hydrator/activity.go @@ -0,0 +1,436 @@ +package hydrator + +import ( + "cmp" + "encoding/json" + "fmt" + "math" + "slices" + "strconv" + "strings" + "time" +) + +const ( + maxActivityText = 128 + maxActivityDetails = 256 + maxSourceVersions = 32 +) + +type findingKey struct { + origin, middleware, kind string + severity int +} +type policyKey struct{ sandbox, hash, version string } +type destinationKey struct{ sandbox, destination string } +type denialCoverage struct{ dns, tcp int } + +type activityStats struct { + active bool + measurements ActivityMeasurements + reportedStatus map[string]int + versions map[string]int + diagnostics map[string]int + findings map[findingKey]ActivityFinding + policies map[policyKey]PolicyContext + firstDecision map[string]int64 + denialCoverage map[destinationKey]denialCoverage + seen map[string]struct{} + unavailable string + policyUnknown bool + first, last time.Time +} + +func newActivityStats() *activityStats { + return &activityStats{ + reportedStatus: map[string]int{}, versions: map[string]int{}, diagnostics: map[string]int{}, + findings: map[findingKey]ActivityFinding{}, policies: map[policyKey]PolicyContext{}, + firstDecision: map[string]int64{}, denialCoverage: map[destinationKey]denialCoverage{}, seen: map[string]struct{}{}, + } +} + +type activityAggregator struct { + agents AgentMap + window *Window + config ActivityConfig + stats map[string]*activityStats + totalUIDs int +} + +func newActivityAggregator(agents AgentMap, window *Window, config ActivityConfig) *activityAggregator { + return &activityAggregator{agents: agents, window: window, config: config.withDefaults(), stats: map[string]*activityStats{}} +} + +func (a *activityAggregator) add(ev Event) { + mapping, ok := a.agents.Lookup(ev) + if !ok { + return + } + st := a.stats[mapping.AgentID] + if st == nil { + st = newActivityStats() + a.stats[mapping.AgentID] = st + } + // Policy load records can precede the selected traffic window and arrive out of order. + if ev.ClassUID == ClassDeviceConfigChange { + st.addPolicy(ev, a.window) + } + ts, hasTime := ev.Timestamp() + if a.window != nil && (!hasTime || !a.window.Contains(ts)) { + return + } + st.active = true + if !a.acceptUID(st, ev.Metadata.UID) { + return + } + st.observe(ev) +} + +func (a *activityAggregator) acceptUID(st *activityStats, uid string) bool { + if st.unavailable == activityDedupSaturated { + st.diagnostics["after_dedup_saturation"]++ + return false + } + if uid == "" { + st.diagnostics["missing_event_uid"]++ + return true + } + if _, duplicate := st.seen[uid]; duplicate { + st.diagnostics["duplicate_events"]++ + return false + } + if len(uid) > maxActivityText || len(st.seen) >= a.config.MaxUIDsPerAgent { + a.saturate(st) + return false + } + if a.totalUIDs >= a.config.MaxUIDs { + owner := a.largestUIDOwner() + if owner == nil { + a.saturate(st) + return false + } + a.saturate(owner) + if owner == st { + return false + } + } + st.seen[uid] = struct{}{} + a.totalUIDs++ + return true +} + +func (a *activityAggregator) saturate(st *activityStats) { + st.unavailable = activityDedupSaturated + st.diagnostics[activityDedupSaturated]++ + a.totalUIDs -= len(st.seen) + st.seen = nil +} + +// Release the largest existing owner rather than starving every new agent. +// Lexical agent ID breaks ties, making the pressure policy deterministic. +func (a *activityAggregator) largestUIDOwner() *activityStats { + var owner *activityStats + ownerID := "" + for id, candidate := range a.stats { + if len(candidate.seen) == 0 { + continue + } + if owner == nil || len(candidate.seen) > len(owner.seen) || len(candidate.seen) == len(owner.seen) && id < ownerID { + owner, ownerID = candidate, id + } + } + return owner +} + +func (s *activityStats) observe(ev Event) { + if ts, ok := ev.Timestamp(); ok { + if s.first.IsZero() || ts.Before(s.first) { + s.first = ts + } + if ts.After(s.last) { + s.last = ts + } + } + status := "unreported_or_unknown" + if ev.StatusID != 0 { + status = strconv.Itoa(ev.StatusID) + } + // OCSF statuses have a small enum; keep unexpected integers in one diagnostic bucket. + if ev.StatusID < 0 || ev.StatusID > 99 { + status = activityOther + } + s.reportedStatus[status]++ + version := boundedActivityText(ev.Metadata.Product.Version) + if version == "" { + version = "unreported" + } + if _, known := s.versions[version]; !known && len(s.versions) >= maxSourceVersions { + version = activityOther + } + s.versions[version]++ + route := classifyActivity(ev) + if route.Diagnostic != "" { + s.diagnostics[route.Diagnostic]++ + } + if route.AuditFallback { + s.diagnostics["message_decision_fallback"]++ + } + if route.Incomplete { + s.unavailable = activityIncomplete + } + if route.Finding != nil { + s.addFinding(*route.Finding) + } + if route.Group != "" { + s.addDecision(route.Group, route.Decision) + s.recordDecisionTime(ev) + } + if route.Diagnostic == "dns_refused" || route.Group == groupConnections && route.Decision == decisionDenied { + s.recordDenialCoverage(ev, route.Diagnostic == "dns_refused") + } +} + +func (s *activityStats) addDecision(group activityGroup, decision policyDecision) { + var counts *DecisionCounts + switch group { + case groupHTTPInitial: + counts = &s.measurements.HTTPInitial + case groupPostRewrite: + counts = &s.measurements.PostRewriteEvaluations + case groupWebSocket: + counts = &s.measurements.WebSocketEvaluations + case groupConnections: + counts = &s.measurements.Connections + default: + s.unavailable = activityIncomplete + return + } + switch decision { + case decisionAllowed: + counts.Allowed++ + case decisionDenied: + counts.Denied++ + case decisionAudited: + counts.Audited++ + case decisionUnknown: + counts.Unknown++ + default: + counts.Unknown++ + } +} + +func (s *activityStats) recordDecisionTime(ev Event) { + sandbox := activitySandbox(ev) + if len(sandbox) > maxActivityText { + s.unavailable = activityIncomplete + s.diagnostics["oversized_sandbox_id"]++ + return + } + if _, known := s.firstDecision[sandbox]; !known && len(s.firstDecision) >= maxActivityDetails { + s.unavailable = activityIncomplete + s.diagnostics["sandbox_limit"]++ + return + } + if ev.Time <= 0 { + s.unavailable = activityIncomplete + s.diagnostics["missing_decision_time"]++ + return + } + previous := s.firstDecision[sandbox] + if previous == 0 || ev.Time < previous { + s.firstDecision[sandbox] = ev.Time + } +} + +func (s *activityStats) recordDenialCoverage(ev Event, dns bool) { + key := destinationKey{sandbox: activitySandbox(ev), destination: ev.Destination()} + if len(key.sandbox) > maxActivityText || len(key.destination) > maxActivityFieldBytes { + s.unavailable = activityIncomplete + s.diagnostics["denial_coverage_limit"]++ + return + } + if _, known := s.denialCoverage[key]; !known && len(s.denialCoverage) >= maxActivityDetails { + s.unavailable = activityIncomplete + s.diagnostics["denial_coverage_limit"]++ + return + } + counts := s.denialCoverage[key] + if dns { + counts.dns++ + } else { + counts.tcp++ + } + s.denialCoverage[key] = counts +} + +func (s *activityStats) addFinding(f ActivityFinding) { + // The classifier already bounds these to maxActivityFieldBytes and reports + // truncation. Preserve its full keys so distinct detectors are not merged. + key := findingKey{origin: f.Origin, middleware: f.Middleware, kind: f.Type, severity: f.Severity} + if _, known := s.findings[key]; !known && len(s.findings) >= maxActivityDetails { + s.diagnostics["finding_detail_limit"]++ + key = findingKey{origin: activityOther, kind: activityOther} + f.Origin, f.Middleware, f.Type, f.Severity = activityOther, "", activityOther, 0 + } + previous := s.findings[key] + f.Records += previous.Records + f.InvalidCounts += previous.InvalidCounts + if f.Occurrences > math.MaxInt64-previous.Occurrences { + f.Occurrences = math.MaxInt64 + s.diagnostics["finding_count_overflow"]++ + } else { + f.Occurrences += previous.Occurrences + } + s.findings[key] = f +} + +func (s *activityStats) addPolicy(ev Event, window *Window) { + ts, ok := ev.Timestamp() + if !ok || window != nil && ts.After(window.End) { + return + } + var raw map[string]json.RawMessage + if json.Unmarshal(ev.Unmapped, &raw) != nil { + return + } + var hash string + if json.Unmarshal(raw["policy_hash"], &hash) != nil || hash == "" { + return + } + version := "" + if value := raw["version"]; json.Unmarshal(value, &version) != nil { + var number json.Number + if json.Unmarshal(value, &number) == nil { + version = number.String() + } + } + key := policyKey{sandbox: activitySandbox(ev), hash: hash, version: version} + if len(key.sandbox) > maxActivityText || len(hash) > maxActivityText || len(version) > maxActivityText { + s.policyUnknown = true + s.diagnostics["policy_context_limit"]++ + return + } + if _, known := s.policies[key]; !known && len(s.policies) >= maxActivityDetails { + s.policyUnknown = true + s.diagnostics["policy_context_limit"]++ + return + } + // Earliest occurrence of a given policy establishes whether it covered the first decision. + if previous, known := s.policies[key]; !known || ts.Before(previous.ObservedAt) { + s.policies[key] = PolicyContext{Sandbox: key.sandbox, Hash: hash, Version: version, ObservedAt: ts} + } +} + +func (a *activityAggregator) evidence(window Window, unreadable int) []ActivityEvidence { + ids := make([]string, 0, len(a.stats)) + for id, st := range a.stats { + if st.active { + ids = append(ids, id) + } + } + slices.Sort(ids) + out := make([]ActivityEvidence, 0, len(ids)) + for _, id := range ids { + st := a.stats[id] + if unreadable > 0 { + st.unavailable = "unreadable_audit_records" + st.diagnostics["unreadable_audit_records"] = unreadable + } + out = append(out, st.evidence(id, window, a.config.MinDecisions)) + } + return out +} + +func (s *activityStats) evidence(id string, window Window, minimum int) ActivityEvidence { + for key, counts := range s.denialCoverage { + // This is only an aggregate coverage guard, never event pairing or unique-request reconstruction. + if counts.dns > counts.tcp || counts.dns > 0 && key.destination == "" { + if s.unavailable == "" { + s.unavailable = activityIncomplete + } + s.diagnostics["unrepresented_dns_refusals"] += counts.dns + } + } + policies := make([]PolicyContext, 0, len(s.policies)) + for _, p := range s.policies { + policies = append(policies, p) + } + slices.SortFunc(policies, func(a, b PolicyContext) int { + return cmp.Or(cmp.Compare(a.Sandbox, b.Sandbox), a.ObservedAt.Compare(b.ObservedAt), cmp.Compare(a.Hash, b.Hash), cmp.Compare(a.Version, b.Version)) + }) + findings := make([]ActivityFinding, 0, len(s.findings)) + for _, f := range s.findings { + findings = append(findings, f) + } + slices.SortFunc(findings, func(a, b ActivityFinding) int { + return cmp.Or(cmp.Compare(a.Origin, b.Origin), cmp.Compare(a.Middleware, b.Middleware), cmp.Compare(a.Type, b.Type), cmp.Compare(a.Severity, b.Severity)) + }) + score, reason, sample := assessActivity(s.measurements, minimum, s.unavailable) + ev := ActivityEvidence{SchemaVersion: 1, AgentID: id, WindowStart: formatTime(window.Start), WindowEnd: formatTime(window.End), Measurements: s.measurements, ReportedStatus: s.reportedStatus, SourceVersions: s.versions, Findings: findings, Diagnostics: s.diagnostics, Policies: policies, PolicyUnknown: !s.hasPolicyContext(policies), SampleSize: sample, Score: score, Unavailable: reason} + if !s.first.IsZero() { + ev.FirstEventAt = formatTime(s.first) + ev.LastEventAt = formatTime(s.last) + } + return ev +} + +func (s *activityStats) hasPolicyContext(policies []PolicyContext) bool { + if s.policyUnknown || len(s.firstDecision) == 0 { + return false + } + for sandbox, first := range s.firstDecision { + covered := slices.ContainsFunc(policies, func(p PolicyContext) bool { + return p.Sandbox == sandbox && p.ObservedAt.UnixMilli() <= first + }) + if !covered { + return false + } + } + return true +} + +func activitySandbox(ev Event) string { + if ev.Container == nil { + return "" + } + if ev.Container.UID != "" { + return ev.Container.UID + } + return ev.Container.Name +} + +func boundedActivityText(s string) string { + if len(s) <= maxActivityText { + return s + } + return strings.ToValidUTF8(s[:maxActivityText], "") +} + +func activityExplanation(ev ActivityEvidence) string { + var out strings.Builder + fmt.Fprintf(&out, "Policy adherence %d/100; window %s to %s; samples are policy evaluations by phase, not unique requests.", *ev.Score, ev.WindowStart, ev.WindowEnd) + groups := []struct { + name string + counts DecisionCounts + }{ + {name: "httpInitial", counts: ev.Measurements.HTTPInitial}, + {name: "postRewrite", counts: ev.Measurements.PostRewriteEvaluations}, + {name: "websocket", counts: ev.Measurements.WebSocketEvaluations}, + {name: "connections", counts: ev.Measurements.Connections}, + } + for _, group := range groups { + fmt.Fprintf(&out, " %s allowed=%d denied=%d audited=%d.", group.name, group.counts.Allowed, group.counts.Denied, group.counts.Audited) + } + if ev.PolicyUnknown { + out.WriteString(" Policy context unknown.") + } + // Bound the explanation; full finding details remain in the separate structured output. + const summaryLimit = 8 + for _, f := range ev.Findings[:min(len(ev.Findings), summaryLimit)] { + fmt.Fprintf(&out, " Finding source=%s/%s type=%s severity=%d records=%d matches=%d; evidence only.", f.Origin, boundedActivityText(f.Middleware), boundedActivityText(f.Type), f.Severity, f.Records, f.Occurrences) + } + if len(ev.Findings) > summaryLimit { + fmt.Fprintf(&out, " %d additional finding groups in local evidence.", len(ev.Findings)-summaryLimit) + } + return out.String() +} diff --git a/internal/hydrator/activity_event.go b/internal/hydrator/activity_event.go new file mode 100644 index 0000000..b79e788 --- /dev/null +++ b/internal/hydrator/activity_event.go @@ -0,0 +1,379 @@ +package hydrator + +import ( + "bytes" + "encoding/json" + "strings" + "unicode/utf8" +) + +type activityGroup string + +const ( + groupHTTPInitial activityGroup = "httpInitial" + groupPostRewrite activityGroup = "postRewriteEvaluations" + groupWebSocket activityGroup = "websocketEvaluations" //nolint:gosec // Measurement name, not a credential. + groupConnections activityGroup = "connections" +) + +type policyDecision string + +const ( + decisionAllowed policyDecision = "allowed" + decisionDenied policyDecision = "denied" + decisionAudited policyDecision = "audited" + decisionUnknown policyDecision = "unknown" +) + +// These fields become grouping keys in local evidence. Bound each key as well +// as the number of keys retained by the aggregator. +const maxActivityFieldBytes = 1024 + +const ( + networkActivityOpen = 1 + networkActivityFail = 4 + networkActivityTraffic = 5 + policyActivityOther = 99 + observedActionID = 3 + middlewareTokenCount = 2 + minimumL7Tokens = 4 + minimumJSONRPCTokens = 3 +) + +type classifiedActivity struct { + Group activityGroup + Decision policyDecision + Diagnostic string + Incomplete bool + Finding *ActivityFinding + AuditFallback bool +} + +func classifyActivity(ev Event) classifiedActivity { + unmapped, valid := activityUnmapped(ev.Unmapped) + if ev.ClassUID == ClassDetectionFinding { + return classifyActivityFinding(ev, unmapped, valid) + } + if !valid { + return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true} + } + if raw, present := unmapped["coverage_state"]; present { + var state string + if json.Unmarshal(raw, &state) != nil || state == "" { + return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true} + } + return classifiedActivity{Diagnostic: "coverage"} + } + if result, ok := classifyActivityDiagnostic(ev, unmapped); ok { + return result + } + if result, ok := classifyL7Activity(ev); ok { + return result + } + return classifyConnectionActivity(ev) +} + +func activityUnmapped(raw json.RawMessage) (map[string]json.RawMessage, bool) { + if len(raw) == 0 { + return nil, true + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, false + } + return fields, true +} + +func classifyActivityDiagnostic(ev Event, unmapped map[string]json.RawMessage) (classifiedActivity, bool) { + switch ev.StatusDetail { + case "credential_endpoint_mismatch", "request_authority_mismatch": + return classifiedActivity{Diagnostic: "unclassified_guard_denial", Incomplete: true}, true + case "credential_unavailable", "assembly_capacity_exhausted", "policy_dns_trusted_gateway_unavailable": + return classifiedActivity{Diagnostic: "operational_failure"}, true + } + if result, ok := classifyMiddlewareActivity(ev, unmapped); ok { + return result, true + } + if ev.ClassUID == ClassHTTPActivity && strings.HasPrefix(ev.Message, "HTTP_REQUEST ") { + if ev.ActionID == deniedID || ev.DispositionID == deniedID { + return unknownActivity(groupHTTPInitial), true + } + return classifiedActivity{Diagnostic: "passthrough_http"}, true + } + if ev.ClassUID != ClassNetworkActivity { + return classifiedActivity{}, false + } + return classifyNetworkDiagnostic(ev) +} + +func classifyNetworkDiagnostic(ev Event) (classifiedActivity, bool) { + if strings.HasPrefix(ev.Message, "WEBSOCKET_MIDDLEWARE_COVERAGE ") { + return classifiedActivity{Diagnostic: "coverage"}, true + } + if strings.HasPrefix(ev.Message, "WEBSOCKET_CREDENTIAL_REWRITE ") { + return classifiedActivity{Diagnostic: "operational_failure"}, true + } + if ev.ActivityID == networkActivityOpen && ev.ActionID == 0 && ev.DispositionID == 0 { + message := strings.ToLower(ev.Message) + if message == "supervisor session established" || strings.HasPrefix(message, "supervisor session established (") { + return classifiedActivity{Diagnostic: "session"}, true + } + } + if ev.ActivityID == networkActivityFail && ev.ActionID == 0 && ev.DispositionID == 0 { + return classifiedActivity{Diagnostic: "connection_failure"}, true + } + if ev.ActivityID == networkActivityTraffic { + if ev.StatusDetail == "policy_dns_ineligible" && strings.HasPrefix(ev.Message, "Policy DNS refused ") && + (ev.ActionID == deniedID || ev.DispositionID == deniedID) && decisionEnumsMatch(ev, decisionDenied) { + return classifiedActivity{Diagnostic: "dns_refused"}, true + } + return classifiedActivity{Diagnostic: "unknown_dns_decision", Incomplete: true}, true + } + return classifiedActivity{}, false +} + +func classifyMiddlewareActivity(ev Event, unmapped map[string]json.RawMessage) (classifiedActivity, bool) { + if ev.ClassUID == ClassHTTPActivity && strings.HasPrefix(ev.Message, "MIDDLEWARE ") { + failed, valid := activityFailed(unmapped) + if !valid { + return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}, true + } + if failed { + return classifiedActivity{Diagnostic: "middleware_failure"}, true + } + decision := activityMessageValue(ev.Message, "decision") + if decision != "Allow" && decision != "Deny" { + return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true + } + if decision == "Deny" || ev.ActionID == deniedID || ev.DispositionID == deniedID { + return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true + } + return classifiedActivity{Diagnostic: "middleware_stage"}, true + } + if ev.ClassUID != ClassNetworkActivity || !strings.HasPrefix(ev.Message, "WEBSOCKET_MIDDLEWARE ") { + return classifiedActivity{}, false + } + parts := strings.Fields(ev.Message) + if len(parts) < middlewareTokenCount { + return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true + } + switch parts[1] { + case "fail_open", "fail_closed": + return classifiedActivity{Diagnostic: "middleware_failure"}, true + case "deny": + return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true + case "inspect", "voluntary_skip", "allow": + if ev.ActionID == deniedID || ev.DispositionID == deniedID { + return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true + } + return classifiedActivity{Diagnostic: "middleware_stage"}, true + default: + return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true + } +} + +func activityFailed(unmapped map[string]json.RawMessage) (bool, bool) { + raw, present := unmapped["failed"] + if !present { + return false, true + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("true")) { + return true, true + } + return false, bytes.Equal(bytes.TrimSpace(raw), []byte("false")) +} + +func activityMessageValue(message, key string) string { + var value string + for _, field := range strings.Fields(message) { + name, candidate, ok := strings.Cut(field, "=") + if ok && name == key { + if value != "" || candidate == "" { + return "" + } + value = candidate + } + } + return value +} + +func classifyL7Activity(ev Event) (classifiedActivity, bool) { + parts := strings.Fields(ev.Message) + if len(parts) == 0 { + return classifiedActivity{}, false + } + group, class := groupHTTPInitial, ClassHTTPActivity + switch parts[0] { + case "L7_REQUEST", "GRAPHQL_L7_REQUEST", "JSONRPC_L7_REQUEST": + case "L7_REQUEST_TRANSFORMED": + group = groupPostRewrite + case "WEBSOCKET_L7_REQUEST": + group, class = groupWebSocket, ClassNetworkActivity + default: + return classifiedActivity{}, false + } + if ev.ClassUID != class || ev.ActivityID != policyActivityOther || len(parts) < minimumJSONRPCTokens { + return unknownActivity(group), true + } + token := parts[1] + if parts[0] == "JSONRPC_L7_REQUEST" { + if !strings.HasPrefix(token, "decision=") || activityMessageValue(ev.Message, "http_method") == "" || activityMessageValue(ev.Message, "endpoint") == "" { + return unknownActivity(group), true + } + token = strings.TrimPrefix(token, "decision=") + } else if len(parts) < minimumL7Tokens { + return unknownActivity(group), true + } + decision := activityDecision(token) + if decision == decisionUnknown || !decisionEnumsMatch(ev, decision) { + return unknownActivity(group), true + } + return classifiedActivity{Group: group, Decision: decision, AuditFallback: decision == decisionAudited}, true +} + +func activityDecision(token string) policyDecision { + switch token { + case "allow": + return decisionAllowed + case "deny": + return decisionDenied + case "audit": + return decisionAudited + default: + return decisionUnknown + } +} + +func decisionEnumsMatch(ev Event, decision policyDecision) bool { + if decision == decisionAudited { + return (ev.ActionID == 0 || ev.ActionID == 1 || ev.ActionID == observedActionID) && (ev.DispositionID == 0 || ev.DispositionID == 1) + } + id := 1 + if decision == decisionDenied { + id = deniedID + } + return (ev.ActionID == 0 || ev.ActionID == id) && (ev.DispositionID == 0 || ev.DispositionID == id) +} + +func classifyConnectionActivity(ev Event) classifiedActivity { + if ev.ClassUID == ClassHTTPActivity { + return unknownActivity(groupHTTPInitial) + } + if ev.ClassUID != ClassNetworkActivity { + return classifiedActivity{Diagnostic: "other"} + } + if ev.ActivityID == policyActivityOther { + return unknownActivity(groupWebSocket) + } + if ev.ActivityID != networkActivityOpen { + if ev.ActionID == 0 && ev.DispositionID == 0 { + return classifiedActivity{Diagnostic: "network_no_policy_decision"} + } + return unknownActivity(groupConnections) + } + parts := strings.Fields(ev.Message) + decision := decisionUnknown + if len(parts) >= minimumJSONRPCTokens && (parts[0] == "CONNECT" || parts[0] == "CONNECT_L7") { + switch parts[1] { + case "allowed": + decision = decisionAllowed + case "denied": + decision = decisionDenied + } + } else if strings.HasPrefix(ev.Message, "Transparent TCP ") { + decision = transparentPolicyDecision(ev) + } + if decision != decisionUnknown && decisionEnumsMatch(ev, decision) { + return classifiedActivity{Group: groupConnections, Decision: decision} + } + if ev.ActionID == 0 && ev.DispositionID == 0 && !strings.HasPrefix(ev.Message, "CONNECT") && !strings.HasPrefix(ev.Message, "Transparent TCP ") { + return classifiedActivity{Diagnostic: "network_no_policy_decision"} + } + return unknownActivity(groupConnections) +} + +func transparentPolicyDecision(ev Event) policyDecision { + if ev.StatusDetail == "transparent_tcp_allowed" && !strings.HasPrefix(ev.Message, "Transparent TCP denied") { + return decisionAllowed + } + if strings.HasPrefix(ev.Message, "Transparent TCP denied") { + switch ev.StatusDetail { + case "transparent_tcp_policy_denied", "transparent_tcp_identity_denied", "transparent_tcp_destination_denied": + return decisionDenied + } + } + return decisionUnknown +} + +func unknownActivity(group activityGroup) classifiedActivity { + return classifiedActivity{Group: group, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true} +} + +func classifyActivityFinding(ev Event, unmapped map[string]json.RawMessage, valid bool) classifiedActivity { + finding := ActivityFinding{Origin: "unclassified", Type: "unknown", Severity: ev.SeverityID, Records: 1, Occurrences: 1} + var info struct { + UID string `json:"uid"` + } + diagnostic := "" + validInfo := len(ev.FindingInfo) == 0 || json.Unmarshal(ev.FindingInfo, &info) == nil + if !valid || !validInfo { + diagnostic = "invalid_finding_metadata" + } else if info.UID == "" { + diagnostic = "finding_type_unknown" + } + if info.UID != "" { + finding.Type = info.UID + } + if raw, present := unmapped["middleware"]; present { + if json.Unmarshal(raw, &finding.Middleware) != nil || finding.Middleware == "" { + finding.Middleware = "" + diagnostic = "invalid_finding_metadata" + } + } + if raw, present := unmapped["count"]; present { + finding.Occurrences = 0 + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &finding.Occurrences) != nil || finding.Occurrences < 0 { + finding.Occurrences = 0 + finding.InvalidCounts = 1 + diagnostic = "invalid_finding_count" + } + if finding.Middleware != "" { + finding.Origin = "plugin" + } + } + if finding.Origin != "plugin" { + finding.Origin = nativeFindingOrigin(finding.Type, unmapped) + } + if len(finding.Type) > maxActivityFieldBytes || len(finding.Middleware) > maxActivityFieldBytes { + finding.Type = boundActivityField(finding.Type) + finding.Middleware = boundActivityField(finding.Middleware) + diagnostic = "finding_metadata_truncated" + } + return classifiedActivity{Finding: &finding, Diagnostic: diagnostic} +} + +func nativeFindingOrigin(kind string, unmapped map[string]json.RawMessage) string { + if _, present := unmapped["coverage_state"]; present { + return "coverage" + } + switch kind { + case "openshell.middleware.body_unavailable", "openshell.middleware.traffic_uninspectable": + return "coverage" + case "openshell.middleware.admission_saturated", "openshell.middleware.admission_exhausted", "openshell.middleware.session_capacity_exhausted", + "openshell.middleware.websocket_failure", "openshell.middleware.websocket_stage_disabled", "openshell.middleware.failure", "openshell.middleware.policy_evaluation_failure": + return "operational" + default: + return "unclassified" + } +} + +func boundActivityField(value string) string { + if len(value) <= maxActivityFieldBytes { + return value + } + value = value[:maxActivityFieldBytes] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + return value +} diff --git a/internal/hydrator/activity_event_test.go b/internal/hydrator/activity_event_test.go new file mode 100644 index 0000000..a9679e4 --- /dev/null +++ b/internal/hydrator/activity_event_test.go @@ -0,0 +1,311 @@ +package hydrator + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + "unicode/utf8" +) + +func TestClassifyActivityDecisions(t *testing.T) { + t.Parallel() + formats := []struct { + name, format string + class int + group activityGroup + }{ + {"rest", "L7_REQUEST %s POST example.com:443/test reason=rule", ClassHTTPActivity, groupHTTPInitial}, + {"graphql", "GRAPHQL_L7_REQUEST %s POST example.com:443/graphql reason=rule", ClassHTTPActivity, groupHTTPInitial}, + {"jsonrpc", "JSONRPC_L7_REQUEST decision=%s rule_methods=tools/call tools=test http_method=POST endpoint=/mcp policy_version=1 reason=rule", ClassHTTPActivity, groupHTTPInitial}, + {"transformed", "L7_REQUEST_TRANSFORMED %s POST example.com:443/mcp reason=rule", ClassHTTPActivity, groupPostRewrite}, + {"websocket", "WEBSOCKET_L7_REQUEST %s WEBSOCKET_TEXT example.com:443/ws reason=rule", ClassNetworkActivity, groupWebSocket}, + } + decisions := []struct { + token string + decision policyDecision + id int + }{ + {"allow", decisionAllowed, 1}, + {"deny", decisionDenied, 2}, + {"audit", decisionAudited, 1}, + } + for _, format := range formats { + for _, decision := range decisions { + for _, version := range []string{"0.1.5", "0.1.6-pre.3"} { + t.Run(format.name+"/"+decision.token+"/"+version, func(t *testing.T) { + t.Parallel() + ev := Event{ClassUID: format.class, ActivityID: 99, ActionID: decision.id, DispositionID: decision.id, + StatusID: 1, Message: fmt.Sprintf(format.format, decision.token), Metadata: Metadata{Product: Product{Version: version}}} + got := classifyActivity(ev) + want := classifiedActivity{Group: format.group, Decision: decision.decision, AuditFallback: decision.token == "audit"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("classifyActivity() = %+v, want %+v", got, want) + } + }) + } + } + } +} + +func TestClassifyActivityMalformedDecisions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + message string + class int + activity, action, disposition int + wantGroup activityGroup + }{ + {"unknown token", "L7_REQUEST permit POST example.com:443/test", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"past tense token is not L7 grammar", "L7_REQUEST allowed POST example.com:443/test", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"missing token", "L7_REQUEST", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"missing request", "L7_REQUEST allow", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"incorrect jsonrpc grammar", "JSONRPC_L7_REQUEST audit POST example.com", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"missing jsonrpc method", "JSONRPC_L7_REQUEST decision=allow endpoint=/mcp", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"missing jsonrpc endpoint", "JSONRPC_L7_REQUEST decision=allow http_method=POST", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"allow with denied action", "L7_REQUEST allow POST example.com:443/test", ClassHTTPActivity, 99, 2, 1, groupHTTPInitial}, + {"deny with allowed disposition", "L7_REQUEST deny POST example.com:443/test", ClassHTTPActivity, 99, 2, 1, groupHTTPInitial}, + {"audit with denied action", "L7_REQUEST audit POST example.com:443/test", ClassHTTPActivity, 99, 2, 2, groupHTTPInitial}, + {"unknown action enum", "L7_REQUEST allow POST example.com:443/test", ClassHTTPActivity, 99, 99, 1, groupHTTPInitial}, + {"unknown disposition enum", "L7_REQUEST allow POST example.com:443/test", ClassHTTPActivity, 99, 1, 99, groupHTTPInitial}, + {"quarantine is not observed", "L7_REQUEST audit POST example.com:443/test", ClassHTTPActivity, 99, 3, 3, groupHTTPInitial}, + {"wrong class", "WEBSOCKET_L7_REQUEST audit WEBSOCKET_TEXT example.com:443/ws", ClassHTTPActivity, 99, 1, 1, groupWebSocket}, + {"wrong activity", "L7_REQUEST allow POST example.com:443/test", ClassHTTPActivity, 1, 1, 1, groupHTTPInitial}, + {"future http format", "FUTURE_L7_FORMAT permitted=true", ClassHTTPActivity, 99, 1, 1, groupHTTPInitial}, + {"future websocket format", "FUTURE_WEBSOCKET permitted=true", ClassNetworkActivity, 99, 1, 1, groupWebSocket}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ev := Event{ClassUID: tt.class, ActivityID: tt.activity, ActionID: tt.action, DispositionID: tt.disposition, Message: tt.message} + got := classifyActivity(ev) + if got.Group != tt.wantGroup || got.Decision != decisionUnknown || !got.Incomplete || got.Diagnostic != "unknown_policy_decision" { + t.Fatalf("classifyActivity() = %+v", got) + } + }) + } +} + +func TestClassifyActivitySpecialEnums(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ev Event + want policyDecision + fallback bool + }{ + {"absent enum fields", Event{ClassUID: ClassHTTPActivity, ActivityID: 99, Message: "L7_REQUEST allow GET example.com:443/test"}, decisionAllowed, false}, + {"observed audit action", Event{ClassUID: ClassHTTPActivity, ActivityID: 99, ActionID: 3, DispositionID: 1, Message: "L7_REQUEST audit GET example.com:443/test"}, decisionAudited, true}, + {"failed status does not reverse explicit allow", Event{ClassUID: ClassHTTPActivity, ActivityID: 99, ActionID: 1, DispositionID: 1, StatusID: 2, Message: "L7_REQUEST allow GET example.com:443/test"}, decisionAllowed, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := classifyActivity(tt.ev) + if got.Decision != tt.want || got.Incomplete || got.AuditFallback != tt.fallback { + t.Fatalf("classifyActivity() = %+v", got) + } + }) + } +} + +func TestClassifyActivityRouting(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ev Event + want classifiedActivity + }{ + {"connect allow", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Message: "CONNECT allowed example.com:443"}, classifiedActivity{Group: groupConnections, Decision: decisionAllowed}}, + {"connect L7 allow", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Message: "CONNECT_L7 allowed example.com:443"}, classifiedActivity{Group: groupConnections, Decision: decisionAllowed}}, + {"connect allowed transport failed", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, StatusID: 2, Message: "CONNECT allowed example.com:443"}, classifiedActivity{Group: groupConnections, Decision: decisionAllowed}}, + {"connect denied", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "CONNECT denied example.com:443"}, classifiedActivity{Group: groupConnections, Decision: decisionDenied}}, + {"transparent policy denial", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "Transparent TCP denied before relay: outside policy", StatusDetail: "transparent_tcp_policy_denied"}, classifiedActivity{Group: groupConnections, Decision: decisionDenied}}, + {"transparent OPA identity denial", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "Transparent TCP denied example.com:443", StatusDetail: "transparent_tcp_identity_denied"}, classifiedActivity{Group: groupConnections, Decision: decisionDenied}}, + {"transparent destination denial", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "Transparent TCP denied example.com:443", StatusDetail: "transparent_tcp_destination_denied"}, classifiedActivity{Group: groupConnections, Decision: decisionDenied}}, + {"transparent allowed", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Message: "Transparent TCP mapping_id=placeholder synthetic=example real=example", StatusDetail: "transparent_tcp_allowed"}, classifiedActivity{Group: groupConnections, Decision: decisionAllowed}}, + {"DNS refusal is not another TCP check", Event{ClassUID: 4001, ActivityID: 5, ActionID: 2, DispositionID: 2, Message: "Policy DNS refused a name that is not eligible in the active policy", StatusDetail: "policy_dns_ineligible"}, classifiedActivity{Diagnostic: "dns_refused"}}, + {"unknown DNS event is incomplete", Event{ClassUID: 4001, ActivityID: 5, ActionID: 1, DispositionID: 1, Message: "DNS lookup"}, classifiedActivity{Diagnostic: "unknown_dns_decision", Incomplete: true}}, + {"missing DNS decision", Event{ClassUID: 4001, ActivityID: 5, Message: "Policy DNS refused a name that is not eligible in the active policy", StatusDetail: "policy_dns_ineligible"}, classifiedActivity{Diagnostic: "unknown_dns_decision", Incomplete: true}}, + {"DNS gateway unavailable", Event{ClassUID: 4001, ActivityID: 5, ActionID: 2, DispositionID: 2, Message: "Policy DNS refused a reserved host-gateway alias because no trusted gateway is configured", StatusDetail: "policy_dns_trusted_gateway_unavailable"}, classifiedActivity{Diagnostic: "operational_failure"}}, + {"proxy connection failure", Event{ClassUID: 4001, ActivityID: 4, StatusID: 2, Message: "Proxy connection error: connection refused"}, classifiedActivity{Diagnostic: "connection_failure"}}, + {"transparent connection failure", Event{ClassUID: 4001, ActivityID: 4, StatusID: 2, Message: "Transparent TCP connection error: timeout"}, classifiedActivity{Diagnostic: "connection_failure"}}, + {"network failure without message", Event{ClassUID: 4001, ActivityID: 4, StatusID: 2}, classifiedActivity{Diagnostic: "connection_failure"}}, + {"supervisor session", Event{ClassUID: 4001, ActivityID: 1, StatusID: 1, Message: "Supervisor session established"}, classifiedActivity{Diagnostic: "session"}}, + {"real supervisor session", Event{ClassUID: 4001, ActivityID: 1, StatusID: 1, Message: "supervisor session established (session_id=placeholder, heartbeat_secs=15)"}, classifiedActivity{Diagnostic: "session"}}, + {"non-policy network record", Event{ClassUID: 4001, ActivityID: 1, StatusID: 1, Message: "control plane event"}, classifiedActivity{Diagnostic: "network_no_policy_decision"}}, + {"other network lifecycle record", Event{ClassUID: 4001, ActivityID: 3, StatusID: 1, Message: "connection lifecycle"}, classifiedActivity{Diagnostic: "network_no_policy_decision"}}, + {"unknown failed policy record", Event{ClassUID: 4001, ActivityID: 4, ActionID: 2, Message: "Future failed policy decision"}, classifiedActivity{Group: groupConnections, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"unknown denied network record", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "Future guard denied"}, classifiedActivity{Group: groupConnections, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"unknown allowed network record", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Message: "Future connection allowed"}, classifiedActivity{Group: groupConnections, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"connection enum contradiction", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 1, Message: "CONNECT allowed example.com:443"}, classifiedActivity{Group: groupConnections, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"missing CONNECT endpoint", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Message: "CONNECT allowed"}, classifiedActivity{Group: groupConnections, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"unsupported transparent guard", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "Transparent TCP denied: stale mapping", StatusDetail: "transparent_tcp_mapping_expired"}, classifiedActivity{Group: groupConnections, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"wrong CONNECT class", Event{ClassUID: 4002, ActivityID: 1, ActionID: 1, DispositionID: 1, Message: "CONNECT allowed example.com:443"}, classifiedActivity{Group: groupHTTPInitial, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"authority guard", Event{ClassUID: 4002, ActivityID: 3, ActionID: 2, DispositionID: 2, StatusDetail: "request_authority_mismatch"}, classifiedActivity{Diagnostic: "unclassified_guard_denial", Incomplete: true}}, + {"credential guard", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, StatusDetail: "credential_endpoint_mismatch"}, classifiedActivity{Diagnostic: "unclassified_guard_denial", Incomplete: true}}, + {"credential unavailable", Event{ClassUID: 4002, ActivityID: 3, ActionID: 2, DispositionID: 2, StatusDetail: "credential_unavailable"}, classifiedActivity{Diagnostic: "operational_failure"}}, + {"websocket capacity", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, StatusDetail: "assembly_capacity_exhausted"}, classifiedActivity{Diagnostic: "operational_failure"}}, + {"passthrough HTTP", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "HTTP_REQUEST GET example.com:443/ credentials_injected=false request_num=1"}, classifiedActivity{Diagnostic: "passthrough_http"}}, + {"passthrough denial cannot hide", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "HTTP_REQUEST GET example.com:443/ credentials_injected=false request_num=1"}, classifiedActivity{Group: groupHTTPInitial, Decision: decisionUnknown, Diagnostic: "unknown_policy_decision", Incomplete: true}}, + {"websocket coverage", Event{ClassUID: 4001, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected"}, classifiedActivity{Diagnostic: "coverage"}}, + {"coverage metadata before class routing", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Unmapped: json.RawMessage(`{"coverage_state":"binding_not_selected"}`)}, classifiedActivity{Diagnostic: "coverage"}}, + {"invalid coverage metadata", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Unmapped: json.RawMessage(`{"coverage_state":[]}`)}, classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}}, + {"middleware allow", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "MIDDLEWARE content remote decision=Allow", Unmapped: json.RawMessage(`{"failed":false}`)}, classifiedActivity{Diagnostic: "middleware_stage"}}, + {"middleware denied", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "MIDDLEWARE content remote decision=Deny", Unmapped: json.RawMessage(`{"failed":false}`)}, classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}}, + {"middleware failed closed", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "MIDDLEWARE content remote decision=Deny", Unmapped: json.RawMessage(`{"failed":true}`)}, classifiedActivity{Diagnostic: "middleware_failure"}}, + {"middleware failure metadata malformed", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "MIDDLEWARE content remote decision=Deny", Unmapped: json.RawMessage(`{"failed":"true"}`)}, classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}}, + {"middleware unknown decision", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "MIDDLEWARE content remote decision=Observe"}, classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}}, + {"middleware duplicate decision", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "MIDDLEWARE content remote decision=Allow decision=Deny"}, classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}}, + {"middleware empty duplicate decision", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "MIDDLEWARE content remote decision= decision=Allow"}, classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}}, + {"websocket middleware deny", Event{ClassUID: 4001, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "WEBSOCKET_MIDDLEWARE deny config=guard implementation=remote"}, classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}}, + {"websocket middleware failed closed", Event{ClassUID: 4001, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "WEBSOCKET_MIDDLEWARE fail_closed config=guard implementation=remote"}, classifiedActivity{Diagnostic: "middleware_failure"}}, + {"websocket middleware failed open", Event{ClassUID: 4001, ActivityID: 99, ActionID: 99, DispositionID: 1, StatusID: 2, Message: "WEBSOCKET_MIDDLEWARE fail_open config=guard implementation=remote"}, classifiedActivity{Diagnostic: "middleware_failure"}}, + {"websocket middleware inspect", Event{ClassUID: 4001, ActivityID: 99, ActionID: 99, DispositionID: 1, Message: "WEBSOCKET_MIDDLEWARE inspect config=guard implementation=remote"}, classifiedActivity{Diagnostic: "middleware_stage"}}, + {"websocket middleware empty", Event{ClassUID: 4001, ActivityID: 99, ActionID: 1, Message: "WEBSOCKET_MIDDLEWARE "}, classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}}, + {"websocket middleware unknown", Event{ClassUID: 4001, ActivityID: 99, ActionID: 1, Message: "WEBSOCKET_MIDDLEWARE observe config=guard"}, classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}}, + {"websocket middleware contradictory allow", Event{ClassUID: 4001, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "WEBSOCKET_MIDDLEWARE allow config=guard"}, classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}}, + {"websocket rewrite failure", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "WEBSOCKET_CREDENTIAL_REWRITE closed ambiguous client frame [host:example.com port:443]"}, classifiedActivity{Diagnostic: "operational_failure"}}, + {"non-object unmapped metadata", Event{ClassUID: 4002, ActivityID: 99, Message: "L7_REQUEST allow GET example.com:443/test", Unmapped: json.RawMessage(`[]`)}, classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}}, + {"irrelevant class", Event{ClassUID: ClassProcessActivity, ActivityID: 1, Message: "process start"}, classifiedActivity{Diagnostic: "other"}}, + {"empty event", Event{}, classifiedActivity{Diagnostic: "other"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := classifyActivity(tt.ev); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("classifyActivity() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestClassifyActivityFindings(t *testing.T) { + t.Parallel() + tests := []struct { + name, info, unmapped string + want ActivityFinding + diagnostic string + }{ + {"builtin detector", `{"uid":"regex.api_key"}`, `{"middleware":"content-guard","count":7}`, ActivityFinding{Origin: "plugin", Middleware: "content-guard", Type: "regex.api_key", Records: 1, Occurrences: 7}, ""}, + {"normalized remote", `{"uid":"remote-detector.finding"}`, `{"middleware":"remote-detector","count":3}`, ActivityFinding{Origin: "plugin", Middleware: "remote-detector", Type: "remote-detector.finding", Records: 1, Occurrences: 3}, ""}, + {"zero matches", `{"uid":"remote.finding"}`, `{"middleware":"remote","count":0}`, ActivityFinding{Origin: "plugin", Middleware: "remote", Type: "remote.finding", Records: 1}, ""}, + {"native health with middleware only", `{"uid":"openshell.middleware.failure"}`, `{"middleware":"remote"}`, ActivityFinding{Origin: "operational", Middleware: "remote", Type: "openshell.middleware.failure", Records: 1, Occurrences: 1}, ""}, + {"unknown native", `{"uid":"native.finding"}`, `{}`, ActivityFinding{Origin: "unclassified", Type: "native.finding", Records: 1, Occurrences: 1}, ""}, + {"count without middleware", `{"uid":"native.finding"}`, `{"count":5}`, ActivityFinding{Origin: "unclassified", Type: "native.finding", Records: 1, Occurrences: 5}, ""}, + {"empty info", `{}`, `{}`, ActivityFinding{Origin: "unclassified", Type: "unknown", Records: 1, Occurrences: 1}, "finding_type_unknown"}, + {"null info", `null`, `null`, ActivityFinding{Origin: "unclassified", Type: "unknown", Records: 1, Occurrences: 1}, "finding_type_unknown"}, + {"malformed info shape", `[]`, `{}`, ActivityFinding{Origin: "unclassified", Type: "unknown", Records: 1, Occurrences: 1}, "invalid_finding_metadata"}, + {"info UID wrong type", `{"uid":7}`, `{}`, ActivityFinding{Origin: "unclassified", Type: "unknown", Records: 1, Occurrences: 1}, "invalid_finding_metadata"}, + {"invalid unmapped shape", `{"uid":"native.finding"}`, `[]`, ActivityFinding{Origin: "unclassified", Type: "native.finding", Records: 1, Occurrences: 1}, "invalid_finding_metadata"}, + {"invalid middleware type", `{"uid":"remote.finding"}`, `{"middleware":7,"count":1}`, ActivityFinding{Origin: "unclassified", Type: "remote.finding", Records: 1, Occurrences: 1}, "invalid_finding_metadata"}, + {"empty middleware", `{"uid":"remote.finding"}`, `{"middleware":"","count":1}`, ActivityFinding{Origin: "unclassified", Type: "remote.finding", Records: 1, Occurrences: 1}, "invalid_finding_metadata"}, + {"negative count", `{"uid":"remote.finding"}`, `{"middleware":"remote","count":-1}`, ActivityFinding{Origin: "plugin", Middleware: "remote", Type: "remote.finding", Records: 1, InvalidCounts: 1}, "invalid_finding_count"}, + {"string count", `{"uid":"remote.finding"}`, `{"middleware":"remote","count":"7"}`, ActivityFinding{Origin: "plugin", Middleware: "remote", Type: "remote.finding", Records: 1, InvalidCounts: 1}, "invalid_finding_count"}, + {"fractional count", `{"uid":"remote.finding"}`, `{"middleware":"remote","count":1.5}`, ActivityFinding{Origin: "plugin", Middleware: "remote", Type: "remote.finding", Records: 1, InvalidCounts: 1}, "invalid_finding_count"}, + {"null count", `{"uid":"remote.finding"}`, `{"middleware":"remote","count":null}`, ActivityFinding{Origin: "plugin", Middleware: "remote", Type: "remote.finding", Records: 1, InvalidCounts: 1}, "invalid_finding_count"}, + {"overflow count", `{"uid":"remote.finding"}`, `{"middleware":"remote","count":9223372036854775808}`, ActivityFinding{Origin: "plugin", Middleware: "remote", Type: "remote.finding", Records: 1, InvalidCounts: 1}, "invalid_finding_count"}, + {"coverage metadata", `{"uid":"native.finding"}`, `{"coverage_state":"body_unavailable"}`, ActivityFinding{Origin: "coverage", Type: "native.finding", Records: 1, Occurrences: 1}, ""}, + {"body unavailable", `{"uid":"openshell.middleware.body_unavailable"}`, `{}`, ActivityFinding{Origin: "coverage", Type: "openshell.middleware.body_unavailable", Records: 1, Occurrences: 1}, ""}, + {"traffic uninspectable", `{"uid":"openshell.middleware.traffic_uninspectable"}`, `{}`, ActivityFinding{Origin: "coverage", Type: "openshell.middleware.traffic_uninspectable", Records: 1, Occurrences: 1}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.want.Severity = 3 + ev := Event{ClassUID: ClassDetectionFinding, SeverityID: 3, FindingInfo: json.RawMessage(tt.info), Unmapped: json.RawMessage(tt.unmapped)} + got := classifyActivity(ev) + if got.Finding == nil || !reflect.DeepEqual(*got.Finding, tt.want) || got.Diagnostic != tt.diagnostic || got.Incomplete { + t.Fatalf("classifyActivity() = %+v, finding %+v; want %+v / %q", got, got.Finding, tt.want, tt.diagnostic) + } + }) + } +} + +func TestClassifyActivityOperationalFindings(t *testing.T) { + t.Parallel() + types := []string{"admission_saturated", "admission_exhausted", "session_capacity_exhausted", "websocket_failure", "websocket_stage_disabled", "failure", "policy_evaluation_failure"} + for _, suffix := range types { + t.Run(suffix, func(t *testing.T) { + t.Parallel() + ev := Event{ClassUID: ClassDetectionFinding, FindingInfo: json.RawMessage(`{"uid":"openshell.middleware.` + suffix + `"}`)} + got := classifyActivity(ev) + if got.Finding == nil || got.Finding.Origin != "operational" || got.Incomplete { + t.Fatalf("classifyActivity() = %+v", got) + } + }) + } +} + +func TestClassifyActivityFindingTextBounds(t *testing.T) { + t.Parallel() + tests := []struct{ name, kind, middleware string }{ + {"ascii", strings.Repeat("a", maxActivityFieldBytes+1), strings.Repeat("a", maxActivityFieldBytes+1)}, + {"unicode", strings.Repeat("界", maxActivityFieldBytes), strings.Repeat("界", maxActivityFieldBytes)}, + {"type only", strings.Repeat("a", maxActivityFieldBytes+1), "content-guard"}, + {"middleware only", "regex.api_key", strings.Repeat("a", maxActivityFieldBytes+1)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + info, err := json.Marshal(map[string]string{"uid": tt.kind}) + if err != nil { + t.Fatal(err) + } + unmapped, err := json.Marshal(map[string]any{"middleware": tt.middleware, "count": 1}) + if err != nil { + t.Fatal(err) + } + got := classifyActivity(Event{ClassUID: ClassDetectionFinding, FindingInfo: info, Unmapped: unmapped}) + if got.Finding == nil || len(got.Finding.Type) > maxActivityFieldBytes || len(got.Finding.Middleware) > maxActivityFieldBytes || !utf8.ValidString(got.Finding.Type) || !utf8.ValidString(got.Finding.Middleware) || got.Diagnostic != "finding_metadata_truncated" || got.Incomplete { + t.Fatalf("bounded finding = %+v / %+v", got, got.Finding) + } + }) + } +} + +func TestActivityMetadataReaderCompatibility(t *testing.T) { + t.Parallel() + tests := []struct{ name, value string }{{"object", `{}`}, {"array", `[]`}, {"string", `"opaque"`}, {"null", `null`}, {"boolean", `true`}, {"integer", `123`}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + line := `{"class_uid":4001,"action_id":1,"disposition_id":1,"status_id":2,"unmapped":` + tt.value + `,"finding_info":` + tt.value + `}` + ev, ok := decodeLine([]byte(line)) + if !ok || !ev.Denied() || string(ev.Unmapped) != tt.value || string(ev.FindingInfo) != tt.value { + t.Fatalf("decodeLine() = %+v, %v", ev, ok) + } + }) + } +} + +func TestActivityReaderInvalidOCSF(t *testing.T) { + t.Parallel() + tests := []struct { + name, line string + want int + }{ + {"plain stderr", "INFO a normal supervisor message", 0}, + {"blank", " ", 0}, + {"shorthand", "FINDING: detector count=1", 0}, + {"valid audit", `{"class_uid":4001}`, 0}, + {"valid stderr audit", `timestamp OCSF-JSON {"class_uid":4001}`, 0}, + {"malformed object", `{"class_uid":`, 1}, + {"malformed indented object", ` {not JSON}`, 1}, + {"wrong type in object", `{"class_uid":"4001"}`, 1}, + {"bad stderr payload", `timestamp OCSF-JSON []`, 1}, + {"missing stderr payload", `timestamp OCSF-JSON`, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := NewReader(strings.NewReader(tt.line + "\n")) + _, _ = r.Read() + if r.invalidOCSF != tt.want || r.Stats().Lines != 1 { + t.Fatalf("invalid OCSF = %d; stats %+v", r.invalidOCSF, r.Stats()) + } + }) + } +} diff --git a/internal/hydrator/activity_limits_test.go b/internal/hydrator/activity_limits_test.go new file mode 100644 index 0000000..b812933 --- /dev/null +++ b/internal/hydrator/activity_limits_test.go @@ -0,0 +1,224 @@ +package hydrator + +import ( + "encoding/json" + "math" + "strconv" + "strings" + "testing" + "time" + "unicode/utf8" +) + +func TestActivityFindingBounds(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + count int + overflow bool + }{ + {name: "normal", count: 2}, + {name: "detail bound", count: maxActivityDetails + 2}, + {name: "occurrence overflow", count: 2, overflow: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + for i := range tc.count { + f := ActivityFinding{Origin: "plugin", Middleware: "detector", Type: strconv.Itoa(i), Severity: 3, Records: 1, Occurrences: 1} + if tc.overflow { + f.Type = "same" + f.Occurrences = math.MaxInt64 + } + st.addFinding(f) + } + if len(st.findings) > maxActivityDetails+1 { + t.Fatal("unbounded findings") + } + if tc.overflow && st.diagnostics["finding_count_overflow"] != 1 { + t.Fatal("overflow not reported") + } + if tc.count > maxActivityDetails && st.diagnostics["finding_detail_limit"] != 2 { + t.Fatal("detail limit not reported") + } + }) + } +} + +func TestActivityDenialCoverage(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + dns, tcp int + destination string + wantUnavailable bool + }{ + {name: "paired shapes", dns: 1, tcp: 1, destination: "example.com"}, + {name: "long valid domain", dns: 1, tcp: 1, destination: strings.Repeat("a", 60) + "." + strings.Repeat("b", 60) + ".example.com"}, + {name: "raw IP", tcp: 1, destination: "192.0.2.1"}, + {name: "orphan DNS", dns: 1, destination: "example.com", wantUnavailable: true}, + {name: "extra DNS", dns: 2, tcp: 1, destination: "example.com", wantUnavailable: true}, + {name: "missing destination", dns: 1, tcp: 1, wantUnavailable: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + st.measurements.HTTPInitial.Allowed = 10 + ev := Event{Container: &Container{Name: "probe"}, DstEndpoint: &Endpoint{Domain: tc.destination}} + for range tc.dns { + st.recordDenialCoverage(ev, true) + } + for range tc.tcp { + st.recordDenialCoverage(ev, false) + } + evidence := st.evidence("agent", Window{}, 1) + if (evidence.Score == nil) != tc.wantUnavailable { + t.Fatalf("evidence=%+v", evidence) + } + }) + } +} + +func TestActivityBoundedMetadata(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + mutate func(*activityStats) + }{ + {name: "sandbox id", mutate: func(st *activityStats) { + st.recordDecisionTime(Event{Container: &Container{UID: strings.Repeat("x", maxActivityText+1)}, Time: 1}) + }}, + {name: "sandbox cardinality", mutate: func(st *activityStats) { + for i := range maxActivityDetails + 1 { + st.recordDecisionTime(Event{Container: &Container{Name: strconv.Itoa(i)}, Time: 1}) + } + }}, + {name: "missing time", mutate: func(st *activityStats) { st.recordDecisionTime(Event{Container: &Container{Name: "probe"}}) }}, + {name: "destination length", mutate: func(st *activityStats) { + st.recordDenialCoverage(Event{DstEndpoint: &Endpoint{Domain: strings.Repeat("x", maxActivityFieldBytes+1)}}, true) + }}, + {name: "destination cardinality", mutate: func(st *activityStats) { + for i := range maxActivityDetails + 1 { + st.recordDenialCoverage(Event{DstEndpoint: &Endpoint{Domain: strconv.Itoa(i)}}, true) + } + }}, + {name: "unexpected group", mutate: func(st *activityStats) { st.addDecision("future", "allow") }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + tc.mutate(st) + if st.unavailable == "" { + t.Fatal("unsafe metadata accepted") + } + }) + } +} + +func TestActivityPolicyBounds(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + data string + count int + future bool + sandbox string + wantPolicies int + wantUnknown bool + }{ + {name: "invalid JSON", data: `[]`}, + {name: "missing hash", data: `{"version":1}`}, + {name: "string version", data: `{"policy_hash":"hash","version":"v1"}`, wantPolicies: 1}, + {name: "numeric version", data: `{"policy_hash":"hash","version":1}`, wantPolicies: 1}, + {name: "other version", data: `{"policy_hash":"hash","version":{}}`, wantPolicies: 1}, + {name: "future", data: `{"policy_hash":"hash"}`, future: true}, + {name: "long sandbox", data: `{"policy_hash":"hash"}`, sandbox: strings.Repeat("x", maxActivityText+1), wantUnknown: true}, + {name: "long hash", data: `{"policy_hash":"` + strings.Repeat("x", maxActivityText+1) + `"}`, wantUnknown: true}, + {name: "policy cardinality", data: `{"policy_hash":"hash"}`, count: maxActivityDetails + 1, wantPolicies: maxActivityDetails, wantUnknown: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + w := Window{Start: time.UnixMilli(1), End: time.UnixMilli(10)} + count := max(tc.count, 1) + for i := range count { + ev := Event{Time: 2, Unmapped: json.RawMessage(tc.data), Container: &Container{Name: tc.sandbox}} + if tc.future { + ev.Time = 11 + } + if tc.count > 1 { + ev.Container.Name = strconv.Itoa(i) + } + st.addPolicy(ev, &w) + // Duplicate/reordered policy loads must not move the first known timestamp forward. + ev.Time = 3 + if !tc.future { + st.addPolicy(ev, &w) + } + } + if len(st.policies) != tc.wantPolicies || st.policyUnknown != tc.wantUnknown { + t.Fatalf("policies=%d unknown=%v", len(st.policies), st.policyUnknown) + } + }) + } +} + +func TestActivityMetadataAndExplanation(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + findingCount int + status int + }{ + {name: "no findings", status: 0}, + {name: "bounded summary", findingCount: 10, status: 1000}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + for i := range maxSourceVersions + 2 { + st.observe(Event{Metadata: Metadata{Product: Product{Version: strconv.Itoa(i)}}, StatusID: tc.status}) + } + for i := range tc.findingCount { + st.addFinding(ActivityFinding{Origin: "plugin", Type: strconv.Itoa(i), Records: 1, Occurrences: 1}) + } + st.measurements.HTTPInitial.Allowed = 1 + evidence := st.evidence("agent", Window{}, 1) + if len(st.versions) > maxSourceVersions+1 { + t.Fatal("versions unbounded") + } + explanation := activityExplanation(evidence) + if tc.findingCount > 8 && !strings.Contains(explanation, "additional finding groups") { + t.Fatal("truncated finding summary not explained") + } + if !utf8.ValidString(boundedActivityText(strings.Repeat("界", maxActivityText))) { + t.Fatal("split UTF-8 character") + } + if activitySandbox(Event{}) != "" { + t.Fatal("nil sandbox") + } + }) + } +} + +func TestActivityUnreadableAuditRecords(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, extra, reason string }{ + {name: "ordinary stderr", extra: "ordinary supervisor log\n"}, + {name: "malformed audit", extra: "OCSF-JSON {broken\n", reason: "unreadable_audit_records"}, + {name: "oversized audit", extra: "{" + strings.Repeat(" ", MaxLineBytes) + "}\n", reason: "unreadable_audit_records"}, + {name: "oversized ordinary stderr", extra: "ordinary log " + strings.Repeat("x", MaxLineBytes) + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + input := activityInput(t, "request", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + "\n" + tc.extra + report, err := Run(t.Context(), strings.NewReader(input), &fakeSink{}, activityOptions(t), nil) + if err != nil { + t.Fatal(err) + } + if report.Activity[0].Unavailable != tc.reason { + t.Fatalf("evidence=%+v", report.Activity[0]) + } + }) + } +} diff --git a/internal/hydrator/activity_review_test.go b/internal/hydrator/activity_review_test.go new file mode 100644 index 0000000..9233c76 --- /dev/null +++ b/internal/hydrator/activity_review_test.go @@ -0,0 +1,122 @@ +package hydrator + +import ( + "strings" + "testing" +) + +func TestActivityPreservesFindingKeys(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + middleware bool + }{ + {name: "type"}, {name: "middleware", middleware: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + for _, suffix := range []string{"a", "b"} { + f := ActivityFinding{Origin: "plugin", Type: strings.Repeat("x", maxActivityText) + suffix, Middleware: "detector", Records: 1, Occurrences: 1} + if tc.middleware { + f.Type = "same" + f.Middleware = strings.Repeat("x", maxActivityText) + suffix + } + st.addFinding(f) + } + if len(st.findings) != 2 { + t.Fatalf("distinct finding identifiers merged: %+v", st.findings) + } + }) + } +} + +func TestActivityGlobalBudgetReleasesOwner(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + limit int + wantUnavailable []bool + }{ + {name: "grouped owner", limit: 2, wantUnavailable: []bool{true, false, false}}, + {name: "single entry", limit: 1, wantUnavailable: []bool{true, true, false}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + agg := newActivityAggregator(mustAgentMap(t, AgentMapping{AgentID: "a", SandboxUIDs: []string{"a"}}, AgentMapping{AgentID: "b", SandboxUIDs: []string{"b"}}, AgentMapping{AgentID: "c", SandboxUIDs: []string{"c"}}), nil, ActivityConfig{MaxUIDs: tc.limit}) + for _, record := range []struct{ agent, uid string }{{"a", "a1"}, {"a", "a2"}, {"b", "b1"}, {"c", "c1"}} { + agg.add(Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: record.agent}, Metadata: Metadata{UID: record.uid}, Message: "L7_REQUEST allow GET example.com:443/test reason=rule"}) + } + for i, evidence := range agg.evidence(Window{}, 0) { + if (evidence.Score == nil) != tc.wantUnavailable[i] { + t.Fatalf("evidence=%+v, want unavailable=%v", evidence, tc.wantUnavailable[i]) + } + } + if agg.totalUIDs > tc.limit { + t.Fatal("global budget exceeded") + } + }) + } +} + +func TestActivityOnlyDoesNotAccumulateLegacyPayload(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + legacy bool + }{ + {name: "new only"}, {name: "legacy kept", legacy: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + agg := newAggregator(sampleAgents(t), nil) + agg.activity = newActivityAggregator(sampleAgents(t), nil, ActivityConfig{MaxUIDsPerAgent: 1}) + agg.skipLegacyStats = !tc.legacy + for _, uid := range []string{"1", "2", "3"} { + agg.add(Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: sandboxAUID}, Metadata: Metadata{UID: uid}, DstEndpoint: &Endpoint{Domain: uid + ".example.com"}, Message: "CONNECT allowed example.com:443"}) + } + st := agg.stats["agent-a"] + if st.events != 3 { + t.Fatal("summary attribution lost") + } + want := 0 + if tc.legacy { + want = 3 + } + if len(st.destinations) != want { + t.Fatalf("legacy destinations=%d want %d", len(st.destinations), want) + } + }) + } +} + +func TestActivityFailureStatusKeepsLegacyBehavior(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + legacy bool + }{ + {name: "new only"}, {name: "legacy alongside", legacy: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + if tc.legacy { + opts.Signals = DefaultSignalConfig() + opts.Signals.EmitActivity = true + } + input := activityInput(t, "allowed", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + "\n" + `{"class_uid":4001,"activity_id":4,"status_id":2,"time":1791996121000,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"},"message":"Proxy connection error: connection refused"}` + sink := &fakeSink{} + report, err := Run(t.Context(), strings.NewReader(input), sink, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Activity[0].Score == nil || *report.Activity[0].Score != 100 { + t.Fatal("connection failure penalized corrected score") + } + if tc.legacy && report.Agents[0].Denied != 1 { + t.Fatal("legacy status-failure counting changed") + } + }) + } +} diff --git a/internal/hydrator/activity_score.go b/internal/hydrator/activity_score.go new file mode 100644 index 0000000..f20b9e7 --- /dev/null +++ b/internal/hydrator/activity_score.go @@ -0,0 +1,75 @@ +package hydrator + +import ( + "errors" + "math" +) + +const ( + activityIncomplete = "interpretation_incomplete" + activityDedupSaturated = "dedup_saturated" + activityOther = "other" + defaultMinDecisions = 1 + defaultMaxUIDsPerAgent = 100_000 + defaultMaxUIDs = 1_000_000 + fullAdherence = 100 +) + +// Validate rejects invalid explicitly configured limits. Zero selects defaults. +func (c ActivityConfig) Validate() error { + if c.MinDecisions < 0 || c.MaxUIDsPerAgent < 0 || c.MaxUIDs < 0 { + return errors.New("hydrator: activity sample minimum and UID limits must be positive") + } + return nil +} + +func (c ActivityConfig) withDefaults() ActivityConfig { + if c.MinDecisions == 0 { + c.MinDecisions = defaultMinDecisions + } + if c.MaxUIDsPerAgent == 0 { + c.MaxUIDsPerAgent = defaultMaxUIDsPerAgent + } + if c.MaxUIDs == 0 { + c.MaxUIDs = defaultMaxUIDs + } + return c +} + +func (m ActivityMeasurements) groups() []DecisionCounts { + return []DecisionCounts{m.HTTPInitial, m.PostRewriteEvaluations, m.WebSocketEvaluations, m.Connections} +} + +func assessActivity(m ActivityMeasurements, minimum int, unavailable string) (*int, string, int) { + sample := 0 + for _, counts := range m.groups() { + sample += counts.Allowed + counts.Denied + counts.Audited + if counts.Unknown > 0 && unavailable == "" { + unavailable = activityIncomplete + } + } + if unavailable != "" { + return nil, unavailable, sample + } + lowest := float64(fullAdherence) + eligible := false + for _, counts := range m.groups() { + n := counts.Allowed + counts.Denied + counts.Audited + if n == 0 { + continue + } + if n < minimum { + if counts.Denied+counts.Audited > 0 { + return nil, "insufficient_sample", sample + } + continue + } + eligible = true + lowest = min(lowest, fullAdherence*float64(counts.Allowed)/float64(n)) + } + if !eligible { + return nil, "no_eligible_decisions", sample + } + score := int(math.Round(lowest)) + return &score, "", sample +} diff --git a/internal/hydrator/activity_test.go b/internal/hydrator/activity_test.go new file mode 100644 index 0000000..06f61c1 --- /dev/null +++ b/internal/hydrator/activity_test.go @@ -0,0 +1,306 @@ +package hydrator + +import ( + "bytes" + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + "testing/iotest" + "time" +) + +func TestActivityConfig(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + cfg ActivityConfig + bad bool + }{ + {name: "defaults"}, + {name: "custom", cfg: ActivityConfig{MinDecisions: 10, MaxUIDsPerAgent: 2, MaxUIDs: 1}}, + {name: "negative sample", cfg: ActivityConfig{MinDecisions: -1}, bad: true}, + {name: "negative agent budget", cfg: ActivityConfig{MaxUIDsPerAgent: -1}, bad: true}, + {name: "negative total budget", cfg: ActivityConfig{MaxUIDs: -1}, bad: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if err := tc.cfg.Validate(); (err != nil) != tc.bad { + t.Fatalf("Validate = %v", err) + } + }) + } +} + +func TestActivityReferenceAssessment(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + http, conn, rewrite, ws DecisionCounts + minimum int + want int + reason string + }{ + {name: "baseline allowed", http: DecisionCounts{Allowed: 6}, conn: DecisionCounts{Allowed: 6}, minimum: 1, want: 100}, + {name: "baseline method", http: DecisionCounts{Allowed: 4, Denied: 2}, conn: DecisionCounts{Allowed: 6}, minimum: 1, want: 67}, + {name: "baseline hosts", http: DecisionCounts{Allowed: 3}, conn: DecisionCounts{Allowed: 3, Denied: 3}, minimum: 1, want: 50}, + {name: "baseline raw IP", http: DecisionCounts{Allowed: 3}, conn: DecisionCounts{Allowed: 3, Denied: 5}, minimum: 1, want: 38}, + {name: "all audit", http: DecisionCounts{Audited: 6}, minimum: 1, want: 0}, + {name: "post rewrite", http: DecisionCounts{Allowed: 1}, rewrite: DecisionCounts{Denied: 1}, minimum: 1, want: 0}, + {name: "websocket", http: DecisionCounts{Allowed: 1}, ws: DecisionCounts{Audited: 1}, minimum: 1, want: 0}, + {name: "keep alive", http: DecisionCounts{Allowed: 50}, conn: DecisionCounts{Allowed: 1}, minimum: 10, want: 100}, + {name: "low sample denied", http: DecisionCounts{Allowed: 50}, conn: DecisionCounts{Denied: 1}, minimum: 10, reason: "insufficient_sample"}, + {name: "low sample audited", http: DecisionCounts{Audited: 1}, minimum: 10, reason: "insufficient_sample"}, + {name: "low sample allowed", http: DecisionCounts{Allowed: 1}, minimum: 10, reason: "no_eligible_decisions"}, + {name: "unknown", http: DecisionCounts{Allowed: 50, Unknown: 1}, minimum: 1, reason: "interpretation_incomplete"}, + {name: "empty", minimum: 1, reason: "no_eligible_decisions"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := ActivityMeasurements{HTTPInitial: tc.http, Connections: tc.conn, PostRewriteEvaluations: tc.rewrite, WebSocketEvaluations: tc.ws} + score, reason, _ := assessActivity(m, tc.minimum, "") + if reason != tc.reason { + t.Fatalf("reason=%q, want %q", reason, tc.reason) + } + if tc.reason != "" { + if score != nil { + t.Fatalf("unexpected score %v", *score) + } + return + } + if score == nil || *score != tc.want { + t.Fatalf("score=%v, want %d", score, tc.want) + } + }) + } +} + +func activityInput(t *testing.T, uid, message string, class, activity, action int) string { + t.Helper() + return jsonLine(t, map[string]any{"class_uid": class, "activity_id": activity, "action_id": action, "disposition_id": action, "time": sampleBaseMs, "container": map[string]any{"uid": sandboxAUID}, "metadata": map[string]any{"uid": uid, "product": map[string]any{"version": "0.1.5"}}, "message": message}) +} + +func activityOptions(t *testing.T) Options { + t.Helper() + return Options{Agents: sampleAgents(t), Signals: SignalConfig{EmitActivity: true}, Clock: fixedClock} +} + +func TestActivityRunAndLegacyIsolation(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + legacy bool + }{{name: "activity only"}, {name: "both paths", legacy: true}} { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + if tc.legacy { + opts.Signals = DefaultSignalConfig() + opts.Signals.EmitActivity = true + } + var evidence bytes.Buffer + opts.Evidence = &evidence + lines := []string{ + activityInput(t, "a1", "CONNECT_L7 allowed example.com:443", 4001, 1, 1), + activityInput(t, "a2", "L7_REQUEST audit POST example.com:443/test reason=rule", 4002, 99, 1), + activityInput(t, "a3", "Proxy connection error: connection refused", 4001, 4, 0), + activityInput(t, "a4", "Supervisor session established", 4001, 1, 0), + } + sink := &fakeSink{} + report, err := Run(t.Context(), strings.NewReader(strings.Join(lines, "\n")), sink, opts, nil) + if err != nil { + t.Fatal(err) + } + if len(report.Activity) != 1 || report.Activity[0].Score == nil || *report.Activity[0].Score != 0 { + t.Fatalf("evidence=%+v", report.Activity) + } + if evidence.Len() == 0 { + t.Fatal("missing local evidence") + } + wantObs := 1 + if tc.legacy { + wantObs = 3 + } + if report.Observations != wantObs || len(sink.batches) != 1 { + t.Fatalf("report=%+v", report) + } + found := false + for _, row := range sink.batches[0].Observations { + var value map[string]any + if err := json.Unmarshal(row.Value, &value); err != nil { + t.Fatal(err) + } + if row.SignalID == DefaultActivitySignal { + found = true + if value["score"] != float64(0) || len(value) != 2 { + t.Fatalf("generic value=%v", value) + } + } + if row.SignalID == DefaultPolicyViolationRateSignal && value["denied"] != float64(0) { + t.Fatalf("legacy changed: %v", value) + } + } + if !found { + t.Fatal("activity observation missing") + } + }) + } +} + +func TestActivityFindingOnlyAndFailures(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, input, reason string + writerError bool + inputError bool + invalidConfig bool + }{ + {name: "finding only", input: `{"class_uid":2004,"time":1791996120000,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"},"finding_info":{"uid":"detector.finding"},"severity_id":3,"unmapped":{"middleware":"detector","count":7}}`, reason: "no_eligible_decisions"}, + {name: "unknown message", input: `{"class_uid":4002,"activity_id":99,"time":1791996120000,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"},"message":"FUTURE_L7_FORMAT permitted=true","action_id":1}`, reason: "interpretation_incomplete"}, + {name: "evidence write failure", input: `{"class_uid":2004,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"}}`, writerError: true}, + {name: "input failure", inputError: true}, + {name: "bad config", invalidConfig: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + var output bytes.Buffer + opts.Evidence = &output + if tc.writerError { + opts.Evidence = failingWriter{} + } + if tc.invalidConfig { + opts.Activity.MinDecisions = -1 + } + input := strings.NewReader(tc.input) + sink := &fakeSink{} + var report Report + var err error + if tc.inputError { + report, err = Run(t.Context(), iotest.ErrReader(errors.New("read failed")), sink, opts, nil) + } else { + report, err = Run(t.Context(), input, sink, opts, nil) + } + if tc.writerError || tc.inputError || tc.invalidConfig { + if err == nil { + t.Fatal("expected error") + } + if len(sink.batches) != 0 { + t.Fatal("posted despite failure") + } + return + } + if err != nil { + t.Fatal(err) + } + if len(report.Activity) != 1 || report.Activity[0].Unavailable != tc.reason || report.Activity[0].Score != nil { + t.Fatalf("evidence=%+v", report.Activity) + } + if len(sink.batches) != 0 || output.Len() == 0 { + t.Fatal("finding/unknown evidence was lost or invented a score") + } + }) + } +} + +func TestActivityDedupIsolation(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + cfg ActivityConfig + count int + reason string + }{ + {name: "deduplicate", count: 1}, + {name: "agent saturated", cfg: ActivityConfig{MaxUIDsPerAgent: 1}, count: 2, reason: "dedup_saturated"}, + {name: "global saturated releases owner", cfg: ActivityConfig{MaxUIDs: 1}, count: 2, reason: "dedup_saturated"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + opts.Activity = tc.cfg + var lines []string + for i := range tc.count { + line := activityInput(t, strconv.Itoa(i), "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + lines = append(lines, line, line) + } + // Agent b follows after a's working set has been released. + lines = append(lines, jsonLine(t, map[string]any{"class_uid": 4002, "activity_id": 99, "container": map[string]any{"name": sandboxBName}, "metadata": map[string]any{"uid": "b"}, "time": sampleBaseMs, "action_id": 1, "disposition_id": 1, "message": "L7_REQUEST allow GET example.com:443/test reason=rule"})) + report, err := Run(t.Context(), strings.NewReader(strings.Join(lines, "\n")), &fakeSink{}, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Activity[0].Unavailable != tc.reason { + t.Fatalf("a=%+v", report.Activity[0]) + } + if tc.reason == "" && report.Activity[0].Measurements.HTTPInitial.Allowed != 1 { + t.Fatal("duplicate counted") + } + if tc.reason != "" && (report.Activity[1].Score == nil || *report.Activity[1].Score != 100) { + t.Fatalf("unrelated b lost score: %+v", report.Activity[1]) + } + }) + } +} + +func TestActivityPolicyContext(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + policyTime int64 + wantUnknown bool + }{ + {name: "policy before window", policyTime: sampleBaseMs - 1000}, + {name: "policy after decision", policyTime: sampleBaseMs + 1000, wantUnknown: true}, + {name: "missing policy", wantUnknown: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + opts.Window = &Window{Start: time.UnixMilli(sampleBaseMs), End: time.UnixMilli(sampleBaseMs + 2000)} + input := activityInput(t, "request", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + if tc.policyTime != 0 { + input += "\n" + jsonLine(t, map[string]any{"class_uid": 5019, "time": tc.policyTime, "container": map[string]any{"uid": sandboxAUID}, "unmapped": map[string]any{"policy_hash": "test-policy", "version": 1}}) + } + report, err := Run(t.Context(), strings.NewReader(input), &fakeSink{}, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Activity[0].PolicyUnknown != tc.wantUnknown { + t.Fatalf("policy=%+v", report.Activity[0]) + } + if tc.policyTime != 0 && len(report.Activity[0].Policies) != 1 { + t.Fatal("policy context lost") + } + }) + } +} + +func TestActivityFindingsDoNotChangeScore(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + withFinding bool + }{{name: "no finding"}, {name: "finding", withFinding: true}} { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + input := activityInput(t, "request", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + if tc.withFinding { + input += "\n" + `{"class_uid":2004,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"},"finding_info":{"uid":"detector.finding"},"severity_id":3,"unmapped":{"middleware":"detector","count":7}}` + } + sink := &fakeSink{} + report, err := Run(t.Context(), strings.NewReader(input), sink, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Activity[0].Score == nil || *report.Activity[0].Score != 100 { + t.Fatalf("score=%v", report.Activity[0].Score) + } + if tc.withFinding && !strings.Contains(string(sink.batches[0].Observations[0].Value), "detector") { + t.Fatal("finding explanation missing") + } + }) + } +} diff --git a/internal/hydrator/activity_types.go b/internal/hydrator/activity_types.go new file mode 100644 index 0000000..b0a9136 --- /dev/null +++ b/internal/hydrator/activity_types.go @@ -0,0 +1,69 @@ +package hydrator + +import "time" + +// DefaultActivitySignal is the opt-in generic provider score signal. +const DefaultActivitySignal = "openshell.behavior.activity" + +// ActivityConfig bounds the corrected evidence path. Zero values select defaults. +type ActivityConfig struct { + MinDecisions int + MaxUIDsPerAgent int + MaxUIDs int +} + +// DecisionCounts reports policy decisions for one enforcement phase. +type DecisionCounts struct { + Allowed int `json:"allowed"` + Denied int `json:"denied"` + Audited int `json:"audited"` + Unknown int `json:"unknown"` +} + +// ActivityMeasurements keeps different policy-check units separate. +type ActivityMeasurements struct { + HTTPInitial DecisionCounts `json:"httpInitial"` + PostRewriteEvaluations DecisionCounts `json:"postRewriteEvaluations"` + WebSocketEvaluations DecisionCounts `json:"websocketEvaluations"` + Connections DecisionCounts `json:"connections"` +} + +// ActivityFinding preserves source and occurrence counts without scoring findings. +type ActivityFinding struct { + Origin string `json:"origin"` + Middleware string `json:"middleware,omitempty"` + Type string `json:"type"` + Severity int `json:"severity"` + Records int `json:"records"` + Occurrences int64 `json:"occurrences"` + InvalidCounts int `json:"invalidCounts,omitempty"` +} + +// PolicyContext identifies an observed sandbox/hash/version. ObservedAt is its +// earliest recorded load in the input, not a complete policy-activation timeline. +type PolicyContext struct { + Sandbox string `json:"sandbox"` + Hash string `json:"hash,omitempty"` + Version string `json:"version,omitempty"` + ObservedAt time.Time `json:"observedAt"` +} + +// ActivityEvidence is local structured evidence, not a Trust Index value schema. +type ActivityEvidence struct { + SchemaVersion int `json:"schemaVersion"` + AgentID string `json:"agentId"` + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` + Measurements ActivityMeasurements `json:"measurements"` + ReportedStatus map[string]int `json:"reportedStatus"` + SourceVersions map[string]int `json:"sourceVersions"` + FirstEventAt string `json:"firstEventAt,omitempty"` + LastEventAt string `json:"lastEventAt,omitempty"` + Findings []ActivityFinding `json:"findings"` + Diagnostics map[string]int `json:"diagnostics"` + Policies []PolicyContext `json:"policies"` + PolicyUnknown bool `json:"policyUnknown"` + SampleSize int `json:"sampleSize"` + Score *int `json:"score"` + Unavailable string `json:"unavailableReason,omitempty"` +} diff --git a/internal/hydrator/aggregate.go b/internal/hydrator/aggregate.go index f9d0bf7..d0e0261 100644 --- a/internal/hydrator/aggregate.go +++ b/internal/hydrator/aggregate.go @@ -92,15 +92,17 @@ func (s *agentStats) rate() float64 { // aggregator attributes events to agents and tracks the input window. type aggregator struct { - agents AgentMap - window *Window - earliest time.Time - latest time.Time - stats map[string]*agentStats - unmapped int - noContainer int - outOfWindow int - attributed int + activity *activityAggregator + skipLegacyStats bool + agents AgentMap + window *Window + earliest time.Time + latest time.Time + stats map[string]*agentStats + unmapped int + noContainer int + outOfWindow int + attributed int } func newAggregator(agents AgentMap, window *Window) *aggregator { @@ -108,6 +110,9 @@ func newAggregator(agents AgentMap, window *Window) *aggregator { } func (a *aggregator) add(ev Event) { + if a.activity != nil { + a.activity.add(ev) + } ts, hasTime := ev.Timestamp() if a.window != nil { if !hasTime || !a.window.Contains(ts) { @@ -131,7 +136,11 @@ func (a *aggregator) add(ev Event) { st = newAgentStats(mapping) a.stats[mapping.AgentID] = st } - st.add(ev) + if a.skipLegacyStats { + st.events++ + } else { + st.add(ev) + } } func (a *aggregator) extend(ts time.Time) { diff --git a/internal/hydrator/doc.go b/internal/hydrator/doc.go index b006ee7..dcb4b6b 100644 --- a/internal/hydrator/doc.go +++ b/internal/hydrator/doc.go @@ -16,8 +16,9 @@ // Per agent and input window the package aggregates Network Activity (4001) // and HTTP Activity (4002) events into two observations. An event counts as // denied when any of action_id, disposition_id or status_id equals 2 (Denied, -// Blocked, Failure). A failed-but-allowed HTTP exchange therefore counts as -// denied, which errs toward over-reporting. +// Blocked, Failure). A record with status_id Failure therefore counts as denied +// even when its action says Allowed. This is the legacy over-reporting rule; +// an HTTP response code such as 503 does not itself supply status_id Failure. // // The signal identifiers for these observations are not yet registered in the // Trust Index. They are configurable through SignalConfig and default to @@ -25,6 +26,19 @@ // Index deployment must register signals under those identifiers (or the // configured ones) before it accepts the import. // +// The optional openshell.behavior.activity output uses corrected policy +// decisions and the reference implementation's generic numeric score value. +// It keeps connection, initial HTTP, post-rewrite and WebSocket checks separate; +// execution status, sessions, operational failures and findings are not policy +// decisions. Findings remain evidence and never change this score. Unknown +// policy syntax or exhausted evidence limits suppress the affected new score. +// Options.Evidence writes structured JSONL even without a numeric assessment. +// The legacy signals retain their existing formats and behavior. +// +// Register the new signal before enabling its output and keep its profile +// weight at zero: the reference implementation retains old scores when a new +// report is skipped, without automatic expiry. See docs/hydrator-activity.md. +// // Batches are posted to the Trust Index import API // (POST /v1/internal/observations/import) with the admin key presented as // "Authorization: Bearer ". A batch may additionally be signed with the diff --git a/internal/hydrator/event.go b/internal/hydrator/event.go index 54c4b79..00d7cc8 100644 --- a/internal/hydrator/event.go +++ b/internal/hydrator/event.go @@ -53,6 +53,10 @@ type Event struct { DstEndpoint *Endpoint `json:"dst_endpoint"` Actor *Actor `json:"actor"` FirewallRule *FirewallRule `json:"firewall_rule"` + // Raw metadata preserves legacy decoding even when the optional fields + // have a shape the corrected evidence path does not understand. + Unmapped json.RawMessage `json:"unmapped"` + FindingInfo json.RawMessage `json:"finding_info"` } // Metadata is the OCSF metadata object. @@ -147,6 +151,10 @@ type Reader struct { br *bufio.Reader line []byte stats ParseStats + // invalidOCSF is deliberately separate from the legacy public counters. + // The corrected evidence path cannot safely attribute a discarded audit line. + invalidOCSF int + oversizedOCSF int } // NewReader returns a Reader over r. @@ -171,6 +179,9 @@ func (r *Reader) Read() (Event, error) { } ev, ok := decodeLine(line) if !ok { + if looksLikeAudit(line) { + r.invalidOCSF++ + } r.stats.Skipped++ continue } @@ -196,6 +207,9 @@ func (r *Reader) readLine() ([]byte, bool, error) { } if !oversized { if len(r.line)+len(chunk) > MaxLineBytes { + if looksLikeAudit(r.line) || looksLikeAudit(chunk) { + r.oversizedOCSF++ + } oversized = true r.line = r.line[:0] } else { @@ -212,6 +226,10 @@ func (r *Reader) readLine() ([]byte, bool, error) { } } +func looksLikeAudit(line []byte) bool { + return bytes.HasPrefix(bytes.TrimSpace(line), []byte("{")) || bytes.Contains(line, []byte("OCSF-JSON")) +} + // decodeLine extracts the JSON payload from a JSONL or stderr line and // decodes it. It reports false for blank, shorthand and malformed lines. func decodeLine(line []byte) (Event, bool) { diff --git a/internal/hydrator/observation.go b/internal/hydrator/observation.go index fa2b851..3f00738 100644 --- a/internal/hydrator/observation.go +++ b/internal/hydrator/observation.go @@ -17,6 +17,8 @@ const ( // SignalConfig names the signals observations are posted under and selects // which to emit. Empty identifiers take the defaults. type SignalConfig struct { + ActivityID string + EmitActivity bool PolicyViolationRateID string ProtocolAdherenceID string EmitPolicyViolationRate bool @@ -34,6 +36,9 @@ func DefaultSignalConfig() SignalConfig { } func (c SignalConfig) withDefaults() SignalConfig { + if c.ActivityID == "" { + c.ActivityID = DefaultActivitySignal + } if c.PolicyViolationRateID == "" { c.PolicyViolationRateID = DefaultPolicyViolationRateSignal } diff --git a/internal/hydrator/run.go b/internal/hydrator/run.go index 9e5f739..d3943f9 100644 --- a/internal/hydrator/run.go +++ b/internal/hydrator/run.go @@ -16,6 +16,11 @@ import ( // Options configures Run. type Options struct { + // Activity configures the optional corrected evidence and score path. + Activity ActivityConfig + // Evidence receives one local ActivityEvidence JSON object per agent. It + // is independent of score emission, including finding-only windows. + Evidence io.Writer // Agents attributes sandboxes to agents. Agents AgentMap // Signals selects the signals to emit and names them. @@ -47,7 +52,9 @@ type AgentSummary struct { // Report describes one Run. type Report struct { - Parse ParseStats + // Activity is populated only when corrected evidence or scores were requested. + Activity []ActivityEvidence + Parse ParseStats // Attributed counts events that resolved to a configured agent and fell // inside the window, whatever their class. Attributed int @@ -74,12 +81,19 @@ func Run(ctx context.Context, in io.Reader, sink Sink, opts Options, logger *slo if logger == nil { logger = slog.New(slog.DiscardHandler) } + if err := opts.Activity.Validate(); err != nil { + return Report{}, err + } clock := opts.Clock if clock == nil { clock = time.Now } reader := NewReader(in) agg := newAggregator(opts.Agents, opts.Window) + if opts.Signals.EmitActivity || opts.Evidence != nil { + agg.activity = newActivityAggregator(opts.Agents, opts.Window, opts.Activity) + agg.skipLegacyStats = !opts.Signals.EmitPolicyViolationRate && !opts.Signals.EmitProtocolAdherence + } err := consume(ctx, reader, agg) report := Report{ Parse: reader.Stats(), @@ -92,6 +106,17 @@ func Run(ctx context.Context, in io.Reader, sink Sink, opts Options, logger *slo if err != nil { return report, err } + if agg.activity != nil { + report.Activity = agg.activity.evidence(report.Window, reader.invalidOCSF+reader.oversizedOCSF) + if opts.Evidence != nil { + encoder := json.NewEncoder(opts.Evidence) + for _, evidence := range report.Activity { + if err := encoder.Encode(evidence); err != nil { + return report, fmt.Errorf("hydrator: write activity evidence: %w", err) + } + } + } + } logger.DebugContext(ctx, "input parsed", "lines", report.Parse.Lines, "events", report.Parse.Events, "skipped", report.Parse.Skipped, "oversized", report.Parse.Oversized) @@ -148,6 +173,10 @@ func buildObservations(agg *aggregator, window Window, opts Options, report *Rep window: window, } ids := slices.Sorted(maps.Keys(agg.stats)) + activity := make(map[string]ActivityEvidence, len(report.Activity)) + for _, evidence := range report.Activity { + activity[evidence.AgentID] = evidence + } observations := make([]Observation, 0, len(ids)) report.Agents = make([]AgentSummary, 0, len(ids)) for _, id := range ids { @@ -156,12 +185,29 @@ func buildObservations(agg *aggregator, window Window, opts Options, report *Rep if err != nil { return nil, err } + if evidence, ok := activity[id]; ok && builder.signals.EmitActivity && evidence.Score != nil { + value := struct { + Score int `json:"score"` + Explanation string `json:"explanation"` + }{Score: *evidence.Score, Explanation: activityExplanation(evidence)} + row, err := builder.observation(id, builder.signals.ActivityID, value) + if err != nil { + return nil, err + } + obs = append(obs, row) + } observations = append(observations, obs...) + denied := st.denied + if agg.skipLegacyStats { + for _, counts := range activity[id].Measurements.groups() { + denied += counts.Denied + } + } report.Agents = append(report.Agents, AgentSummary{ AgentID: id, AnsName: st.mapping.AnsName, Events: st.events, - Denied: st.denied, + Denied: denied, Observations: len(obs), }) } From 49374923bdf9d1730efa1b2796dc228f7c42ef2f Mon Sep 17 00:00:00 2001 From: chou-godaddy Date: Mon, 26 Oct 2026 10:33:27 -0700 Subject: [PATCH 2/3] fix(hydrator): harden policy adherence output after review Stop a half-written final log line or an ordinary log line that mentions the OCSF-JSON marker from withholding every agent's score, and stop coverage, operational and connection-failure records from hiding a stated policy decision by enforcing that once after classification instead of in each branch. Reject Helm evidence file paths, which would fail every scheduled run after the first; make evidence assembly read-only; and feed the activity and legacy aggregators as separate event consumers. Document the remaining limits before the signal weight is raised above zero. Refs agentnameservice/ans-openshell#9 Signed-off-by: chou-godaddy --- cmd/ans-ocsf-hydrator/activity_test.go | 7 +- cmd/ans-ocsf-hydrator/main.go | 9 +- .../ans-openshell/templates/hydrator.yaml | 3 + deploy/helm/ans-openshell/values.yaml | 2 +- deploy/hydrator_activity_test.go | 18 +- docs/hydrator-activity.md | 45 ++- internal/hydrator/activity.go | 76 +++-- internal/hydrator/activity_event.go | 274 ++++++++++++------ internal/hydrator/activity_event_test.go | 93 +++++- internal/hydrator/activity_limits_test.go | 140 ++++++++- internal/hydrator/activity_review_test.go | 122 -------- internal/hydrator/activity_score.go | 28 +- internal/hydrator/activity_test.go | 195 ++++++++++++- internal/hydrator/activity_types.go | 12 + internal/hydrator/aggregate.go | 6 +- internal/hydrator/event.go | 46 ++- internal/hydrator/observation.go | 17 ++ internal/hydrator/run.go | 86 ++++-- 18 files changed, 884 insertions(+), 295 deletions(-) delete mode 100644 internal/hydrator/activity_review_test.go diff --git a/cmd/ans-ocsf-hydrator/activity_test.go b/cmd/ans-ocsf-hydrator/activity_test.go index 1c173e9..576eb13 100644 --- a/cmd/ans-ocsf-hydrator/activity_test.go +++ b/cmd/ans-ocsf-hydrator/activity_test.go @@ -372,7 +372,8 @@ func TestLogActivity(t *testing.T) { t.Parallel() var output bytes.Buffer logger := slog.New(slog.NewJSONHandler(&output, nil)) - logActivity(context.Background(), logger, []hydrator.ActivityEvidence{{AgentID: "probe", Score: tt.score, SampleSize: 6, Unavailable: tt.unavailable}}) + measurements := hydrator.ActivityMeasurements{HTTPInitial: hydrator.DecisionCounts{Allowed: 4, Denied: 1}, Connections: hydrator.DecisionCounts{Denied: 1}} + logActivity(context.Background(), logger, []hydrator.ActivityEvidence{{AgentID: "probe", Score: tt.score, SampleSize: 6, Measurements: measurements, Unavailable: tt.unavailable}}) var record map[string]json.RawMessage if err := json.Unmarshal(output.Bytes(), &record); err != nil { t.Fatal(err) @@ -381,8 +382,8 @@ func TestLogActivity(t *testing.T) { if tt.score != nil { wantScore = "67" } - if string(record["score"]) != wantScore || string(record["sample_size"]) != "6" { - t.Fatalf("score/sample not logged: %s", output.Bytes()) + if string(record["score"]) != wantScore || string(record["sample_size"]) != "6" || string(record["denied"]) != "2" { + t.Fatalf("score/sample/denied not logged: %s", output.Bytes()) } var reason string if err := json.Unmarshal(record["unavailable_reason"], &reason); err != nil || reason != tt.unavailable { diff --git a/cmd/ans-ocsf-hydrator/main.go b/cmd/ans-ocsf-hydrator/main.go index c33951d..9e8eb28 100644 --- a/cmd/ans-ocsf-hydrator/main.go +++ b/cmd/ans-ocsf-hydrator/main.go @@ -368,11 +368,12 @@ func logActivity(ctx context.Context, logger *slog.Logger, evidence []hydrator.A if item.Score != nil { score = *item.Score } - level := slog.LevelWarn - if item.Unavailable == "" || item.Unavailable == "no_eligible_decisions" || item.Unavailable == "insufficient_sample" { - level = slog.LevelInfo + level := slog.LevelInfo + if item.Degraded() { + level = slog.LevelWarn } logger.Log(ctx, level, "activity summary", "agent_id", item.AgentID, - "score", score, "sample_size", item.SampleSize, "unavailable_reason", item.Unavailable) + "score", score, "sample_size", item.SampleSize, "denied", item.Measurements.Denied(), + "unavailable_reason", item.Unavailable) } } diff --git a/deploy/helm/ans-openshell/templates/hydrator.yaml b/deploy/helm/ans-openshell/templates/hydrator.yaml index 7406e8d..86caffe 100644 --- a/deploy/helm/ans-openshell/templates/hydrator.yaml +++ b/deploy/helm/ans-openshell/templates/hydrator.yaml @@ -46,6 +46,9 @@ spec: - {{ . | quote }} {{- end }} {{- with .Values.hydrator.evidenceOutput }} +{{- if ne . "-" }} +{{- fail "hydrator.evidenceOutput must be empty or \"-\": the CronJob reruns every schedule and the hydrator refuses to overwrite an existing evidence file" }} +{{- end }} - --evidence-output - {{ . | quote }} {{- end }} diff --git a/deploy/helm/ans-openshell/values.yaml b/deploy/helm/ans-openshell/values.yaml index 00578ab..adc0314 100644 --- a/deploy/helm/ans-openshell/values.yaml +++ b/deploy/helm/ans-openshell/values.yaml @@ -87,7 +87,7 @@ hydrator: schedule: "*/15 * * * *" window: 15m # --window-last; empty aggregates the whole input every run input: /var/log/openshell/supervisor-ocsf.jsonl - evidenceOutput: "" # optional corrected JSONL evidence; use "-" for stdout, or a new file on a writable volume + evidenceOutput: "" # "-" writes corrected JSONL evidence to stdout; file paths are rejected because each scheduled run would collide with the last nodeSelector: {} adminKeySecret: "" # secret with key ANS_TRUST_INDEX_ADMIN_KEY; empty posts without Authorization extraVolumes: [] diff --git a/deploy/hydrator_activity_test.go b/deploy/hydrator_activity_test.go index d7116f8..0a752f7 100644 --- a/deploy/hydrator_activity_test.go +++ b/deploy/hydrator_activity_test.go @@ -1,7 +1,9 @@ package deploy_test import ( + "os/exec" "slices" + "strings" "testing" "gopkg.in/yaml.v3" @@ -17,7 +19,6 @@ func TestHydratorActivityChart(t *testing.T) { }{ {name: "no evidence output", window: "15m"}, {name: "stdout evidence with longer window", window: "24h", evidence: "-"}, - {name: "writable volume evidence", window: "1h", evidence: "/evidence/new-run.jsonl"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -63,3 +64,18 @@ func TestHydratorActivityChart(t *testing.T) { }) } } + +func TestHydratorEvidenceFileRejected(t *testing.T) { + t.Parallel() + helm := deploymentTool(t, "helm") + output, err := exec.CommandContext(t.Context(), helm, "template", "regression", "helm/ans-openshell", + "--set-string", "gateway.jwksURL=https://gateway.example/.well-known/jwks.json", + "--set", "hydrator.enabled=true", + "--set-string", "hydrator.evidenceOutput=/evidence/run.jsonl").CombinedOutput() + if err == nil { + t.Fatal("chart rendered a file evidence path that fails on the second scheduled run") + } + if !strings.Contains(string(output), "hydrator.evidenceOutput must be empty") { + t.Fatalf("unexpected render error: %s", output) + } +} diff --git a/docs/hydrator-activity.md b/docs/hydrator-activity.md index d01fda5..f6c5c17 100644 --- a/docs/hydrator-activity.md +++ b/docs/hydrator-activity.md @@ -82,7 +82,11 @@ local output without inventing a numeric score. Unknown policy syntax, incomplete interpretation, and exhausted deduplication budgets suppress the affected agent's new score and record the reason. Other -agents continue. Bound and size `activity.max_uids_per_agent` and +agents continue. A record whose message states a policy decision but that is +not counted as one (for example because it also carries a coverage or +operational-failure status) also counts as incomplete, so a conflicting record +cannot hide a denial. The first recorded reason is kept; later problems appear +only as diagnostics. Bound and size `activity.max_uids_per_agent` and `activity.max_uids` for the deployment's volume; their defaults are 100,000 and 1,000,000 respectively. Duplicate event IDs are removed only from the new path, leaving legacy behavior unchanged. @@ -92,6 +96,21 @@ UID owner unavailable and releases its set; ties use agent ID order. This lets later agents proceed within the same memory budget. A budget too small for the whole deployment can still make several agents unavailable. +An audit-shaped line that cannot be decoded or exceeds 1 MiB (a JSON object, +or a stderr line whose `OCSF-JSON ` marker is followed by an object or by +nothing) cannot be attributed to a sandbox, so it withholds every agent's score +with `unreadable_audit_records`. Ordinary log lines that merely mention the +marker do not. Such a line has no readable timestamp, so the window cannot +exclude it: it withholds scores on every run until the input file rotates. Keep +the input to OpenShell audit records only; JSON logs from other programs whose +fields do not match the OCSF types count as unreadable. + +A final line without a trailing newline is treated as a record still being +written: it is counted as `truncated_final_line` and does not withhold scores. +The next run counts it only if its timestamp falls inside that run's window, so +set the window longer than the schedule interval (for example `window: 1h` with +a 15-minute schedule). + DNS refusals are retained separately from TCP policy checks. The hydrator checks that denied TCP counts cover the DNS refusals for each sandbox and destination; otherwise it withholds the score. This is a conservative consistency check, not @@ -110,10 +129,28 @@ with `--dry-run`, which reserves stdout for the import request. With all three signal IDs set to `""`, evidence output works without a Trust Index URL or server. For scheduled Helm jobs, use `hydrator.evidenceOutput: "-"` so the log collector -retains each run's evidence. A fixed filename on a persistent volume works only -once: later jobs fail because evidence files are create-only. File output needs -a different filename for every run, supplied outside this chart. +retains each run's evidence. The chart refuses to render any other value: a fixed +filename would make every run after the first fail before posting, including the +legacy outputs, because evidence files are create-only. The existing `provenance.evidence_url` is one static URL reused for every observation. It does not create immutable evidence storage for each agent/window; the example leaves it unset. + +## Known limits before raising the weight + +- A sandbox writes its own audit records, so an agent that can emit an + unrecognized or conflicting decision record can withhold its own new score. + Together with the consumer retaining the last score, a previously clean agent + can keep 100 after later violations. This is one more reason the weight stays + at 0 until scores expire. +- If a sandbox can cause one unreadable or oversized audit line in the shared + input (for example through an unbounded request field), it withholds every + agent's new score, not only its own, until the file rotates. All agents then + keep their previous scores in the reference implementation. +- The score is rounded, so a single violation among 200 or more decisions still + reports 100. The evidence output keeps the exact counts. +- Rollout behavior (weight 0 has no effect on trust decisions, retained scores, + whole-batch rejection) belongs to the consumer and is not tested in this + repository. Verify it against the deployed `agent-trust-discovery` before + changing the weight. diff --git a/internal/hydrator/activity.go b/internal/hydrator/activity.go index f5bb60d..d8da288 100644 --- a/internal/hydrator/activity.go +++ b/internal/hydrator/activity.go @@ -4,6 +4,7 @@ import ( "cmp" "encoding/json" "fmt" + "maps" "math" "slices" "strconv" @@ -99,7 +100,15 @@ func (a *activityAggregator) acceptUID(st *activityStats, uid string) bool { st.diagnostics["duplicate_events"]++ return false } - if len(uid) > maxActivityText || len(st.seen) >= a.config.MaxUIDsPerAgent { + if len(uid) > maxActivityText { + // An unbounded UID cannot be deduplicated within the memory budget. + st.diagnostics["oversized_event_uid"]++ + if st.unavailable == "" { + st.unavailable = activityIncomplete + } + return false + } + if len(st.seen) >= a.config.MaxUIDsPerAgent { a.saturate(st) return false } @@ -185,8 +194,8 @@ func (s *activityStats) observe(ev Event) { s.addDecision(route.Group, route.Decision) s.recordDecisionTime(ev) } - if route.Diagnostic == "dns_refused" || route.Group == groupConnections && route.Decision == decisionDenied { - s.recordDenialCoverage(ev, route.Diagnostic == "dns_refused") + if route.Diagnostic == activityDNSRefused || route.Group == groupConnections && route.Decision == decisionDenied { + s.recordDenialCoverage(ev, route.Diagnostic == activityDNSRefused) } } @@ -321,7 +330,9 @@ func (s *activityStats) addPolicy(ev Event, window *Window) { } } -func (a *activityAggregator) evidence(window Window, unreadable int) []ActivityEvidence { +// evidence summarizes every active agent. It does not modify the aggregator, +// so repeated calls return the same result. +func (a *activityAggregator) evidence(window Window, input auditInputHealth) []ActivityEvidence { ids := make([]string, 0, len(a.stats)) for id, st := range a.stats { if st.active { @@ -331,26 +342,38 @@ func (a *activityAggregator) evidence(window Window, unreadable int) []ActivityE slices.Sort(ids) out := make([]ActivityEvidence, 0, len(ids)) for _, id := range ids { - st := a.stats[id] - if unreadable > 0 { - st.unavailable = "unreadable_audit_records" - st.diagnostics["unreadable_audit_records"] = unreadable - } - out = append(out, st.evidence(id, window, a.config.MinDecisions)) + out = append(out, a.stats[id].evidence(id, window, a.config.MinDecisions, input)) } return out } -func (s *activityStats) evidence(id string, window Window, minimum int) ActivityEvidence { +// finalState returns the unavailable reason and diagnostics after the +// end-of-input checks, without changing the accumulated state. The first +// recorded reason wins, so a later check never hides an earlier one. +func (s *activityStats) finalState(input auditInputHealth) (string, map[string]int) { + unavailable := s.unavailable + diagnostics := maps.Clone(s.diagnostics) for key, counts := range s.denialCoverage { // This is only an aggregate coverage guard, never event pairing or unique-request reconstruction. if counts.dns > counts.tcp || counts.dns > 0 && key.destination == "" { - if s.unavailable == "" { - s.unavailable = activityIncomplete - } - s.diagnostics["unrepresented_dns_refusals"] += counts.dns + unavailable = cmp.Or(unavailable, activityIncomplete) + diagnostics["unrepresented_dns_refusals"] += counts.dns } } + // An unreadable audit line cannot be attributed to an agent, so it withholds every new score. + if input.unreadable > 0 { + unavailable = cmp.Or(unavailable, activityUnreadableRecords) + diagnostics[activityUnreadableRecords] = input.unreadable + } + // A final line without a newline is still being written; the next run reads it whole. + if input.truncatedTail > 0 { + diagnostics["truncated_final_line"] = input.truncatedTail + } + return unavailable, diagnostics +} + +func (s *activityStats) evidence(id string, window Window, minimum int, input auditInputHealth) ActivityEvidence { + unavailable, diagnostics := s.finalState(input) policies := make([]PolicyContext, 0, len(s.policies)) for _, p := range s.policies { policies = append(policies, p) @@ -365,8 +388,23 @@ func (s *activityStats) evidence(id string, window Window, minimum int) Activity slices.SortFunc(findings, func(a, b ActivityFinding) int { return cmp.Or(cmp.Compare(a.Origin, b.Origin), cmp.Compare(a.Middleware, b.Middleware), cmp.Compare(a.Type, b.Type), cmp.Compare(a.Severity, b.Severity)) }) - score, reason, sample := assessActivity(s.measurements, minimum, s.unavailable) - ev := ActivityEvidence{SchemaVersion: 1, AgentID: id, WindowStart: formatTime(window.Start), WindowEnd: formatTime(window.End), Measurements: s.measurements, ReportedStatus: s.reportedStatus, SourceVersions: s.versions, Findings: findings, Diagnostics: s.diagnostics, Policies: policies, PolicyUnknown: !s.hasPolicyContext(policies), SampleSize: sample, Score: score, Unavailable: reason} + score, reason, sample := assessActivity(s.measurements, minimum, unavailable) + ev := ActivityEvidence{ + SchemaVersion: 1, + AgentID: id, + WindowStart: formatTime(window.Start), + WindowEnd: formatTime(window.End), + Measurements: s.measurements, + ReportedStatus: maps.Clone(s.reportedStatus), + SourceVersions: maps.Clone(s.versions), + Findings: findings, + Diagnostics: diagnostics, + Policies: policies, + PolicyUnknown: !s.hasPolicyContext(policies), + SampleSize: sample, + Score: score, + Unavailable: reason, + } if !s.first.IsZero() { ev.FirstEventAt = formatTime(s.first) ev.LastEventAt = formatTime(s.last) @@ -426,8 +464,10 @@ func activityExplanation(ev ActivityEvidence) string { } // Bound the explanation; full finding details remain in the separate structured output. const summaryLimit = 8 + // Middleware and type come from audit records; quoting keeps them from reading as explanation text. for _, f := range ev.Findings[:min(len(ev.Findings), summaryLimit)] { - fmt.Fprintf(&out, " Finding source=%s/%s type=%s severity=%d records=%d matches=%d; evidence only.", f.Origin, boundedActivityText(f.Middleware), boundedActivityText(f.Type), f.Severity, f.Records, f.Occurrences) + fmt.Fprintf(&out, " Finding origin=%s middleware=%q type=%q severity=%d records=%d matches=%d; evidence only.", + f.Origin, boundedActivityText(f.Middleware), boundedActivityText(f.Type), f.Severity, f.Records, f.Occurrences) } if len(ev.Findings) > summaryLimit { fmt.Fprintf(&out, " %d additional finding groups in local evidence.", len(ev.Findings)-summaryLimit) diff --git a/internal/hydrator/activity_event.go b/internal/hydrator/activity_event.go index b79e788..f9cbe9f 100644 --- a/internal/hydrator/activity_event.go +++ b/internal/hydrator/activity_event.go @@ -49,6 +49,17 @@ type classifiedActivity struct { AuditFallback bool } +// activityRecord is a policy-relevant event with its unmapped fields decoded +// and its message split into tokens once. +type activityRecord struct { + Event + unmapped map[string]json.RawMessage + tokens []string +} + +// activityRule classifies a record it recognizes and reports false otherwise. +type activityRule func(activityRecord) (classifiedActivity, bool) + func classifyActivity(ev Event) classifiedActivity { unmapped, valid := activityUnmapped(ev.Unmapped) if ev.ClassUID == ClassDetectionFinding { @@ -57,20 +68,61 @@ func classifyActivity(ev Event) classifiedActivity { if !valid { return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true} } - if raw, present := unmapped["coverage_state"]; present { - var state string - if json.Unmarshal(raw, &state) != nil || state == "" { - return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true} + record := activityRecord{Event: ev, unmapped: unmapped, tokens: strings.Fields(ev.Message)} + result := classifyPolicyRecord(record) + if result.Group == "" && !result.Incomplete && record.statesDecision() { + return classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true} + } + return result +} + +// classifyPolicyRecord applies the first rule that recognizes the record. +// Record metadata comes first because OpenShell sets it on records that are not +// policy evaluations. Message families that never state a decision follow, then +// network records identified by activity, then decision messages, and finally +// connection decisions with a class-based fallback. classifyActivity rejects +// any uncounted result whose message states a decision, so rule order can make +// an agent incomplete but cannot hide a decision. +func classifyPolicyRecord(record activityRecord) classifiedActivity { + rules := [...]activityRule{ + classifyCoverageMetadata, + classifyStatusDetail, + classifyHTTPMiddleware, + classifyWebSocketMiddleware, + classifyPassthroughHTTP, + classifyWebSocketNotice, + classifySession, + classifyConnectionFailure, + classifyDNSTraffic, + classifyL7Decision, + } + for _, rule := range rules { + if result, ok := rule(record); ok { + return result } - return classifiedActivity{Diagnostic: "coverage"} } - if result, ok := classifyActivityDiagnostic(ev, unmapped); ok { - return result + return classifyConnectionActivity(record) +} + +// statesDecision reports whether the message has the shape of an L7 or +// connection policy decision, judged by its leading tokens. +func (r activityRecord) statesDecision() bool { + if _, isL7 := l7DecisionGroup(r.tokens); isL7 { + return true } - if result, ok := classifyL7Activity(ev); ok { - return result + if len(r.tokens) >= 2 && (r.tokens[0] == "CONNECT" || r.tokens[0] == "CONNECT_L7") { + return r.tokens[1] == "allowed" || r.tokens[1] == "denied" } - return classifyConnectionActivity(ev) + return strings.HasPrefix(r.Message, "Transparent TCP denied") || + strings.HasPrefix(r.Message, "Transparent TCP ") && r.StatusDetail == "transparent_tcp_allowed" +} + +func (r activityRecord) withoutDecisionEnums() bool { + return r.ActionID == 0 && r.DispositionID == 0 +} + +func (r activityRecord) deniedEnum() bool { + return r.ActionID == deniedID || r.DispositionID == deniedID } func activityUnmapped(raw json.RawMessage) (map[string]json.RawMessage, bool) { @@ -84,86 +136,64 @@ func activityUnmapped(raw json.RawMessage) (map[string]json.RawMessage, bool) { return fields, true } -func classifyActivityDiagnostic(ev Event, unmapped map[string]json.RawMessage) (classifiedActivity, bool) { - switch ev.StatusDetail { +func classifyCoverageMetadata(record activityRecord) (classifiedActivity, bool) { + raw, present := record.unmapped["coverage_state"] + if !present { + return classifiedActivity{}, false + } + var state string + if json.Unmarshal(raw, &state) != nil || state == "" { + return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}, true + } + return classifiedActivity{Diagnostic: "coverage"}, true +} + +func classifyStatusDetail(record activityRecord) (classifiedActivity, bool) { + switch record.StatusDetail { case "credential_endpoint_mismatch", "request_authority_mismatch": return classifiedActivity{Diagnostic: "unclassified_guard_denial", Incomplete: true}, true case "credential_unavailable", "assembly_capacity_exhausted", "policy_dns_trusted_gateway_unavailable": return classifiedActivity{Diagnostic: "operational_failure"}, true - } - if result, ok := classifyMiddlewareActivity(ev, unmapped); ok { - return result, true - } - if ev.ClassUID == ClassHTTPActivity && strings.HasPrefix(ev.Message, "HTTP_REQUEST ") { - if ev.ActionID == deniedID || ev.DispositionID == deniedID { - return unknownActivity(groupHTTPInitial), true - } - return classifiedActivity{Diagnostic: "passthrough_http"}, true - } - if ev.ClassUID != ClassNetworkActivity { + default: return classifiedActivity{}, false } - return classifyNetworkDiagnostic(ev) } -func classifyNetworkDiagnostic(ev Event) (classifiedActivity, bool) { - if strings.HasPrefix(ev.Message, "WEBSOCKET_MIDDLEWARE_COVERAGE ") { - return classifiedActivity{Diagnostic: "coverage"}, true +func classifyHTTPMiddleware(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassHTTPActivity || !strings.HasPrefix(record.Message, "MIDDLEWARE ") { + return classifiedActivity{}, false } - if strings.HasPrefix(ev.Message, "WEBSOCKET_CREDENTIAL_REWRITE ") { - return classifiedActivity{Diagnostic: "operational_failure"}, true + failed, valid := activityFailed(record.unmapped) + if !valid { + return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}, true } - if ev.ActivityID == networkActivityOpen && ev.ActionID == 0 && ev.DispositionID == 0 { - message := strings.ToLower(ev.Message) - if message == "supervisor session established" || strings.HasPrefix(message, "supervisor session established (") { - return classifiedActivity{Diagnostic: "session"}, true - } + if failed { + return classifiedActivity{Diagnostic: "middleware_failure"}, true } - if ev.ActivityID == networkActivityFail && ev.ActionID == 0 && ev.DispositionID == 0 { - return classifiedActivity{Diagnostic: "connection_failure"}, true + decision := activityMessageValue(record.Message, "decision") + if decision != "Allow" && decision != "Deny" { + return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true } - if ev.ActivityID == networkActivityTraffic { - if ev.StatusDetail == "policy_dns_ineligible" && strings.HasPrefix(ev.Message, "Policy DNS refused ") && - (ev.ActionID == deniedID || ev.DispositionID == deniedID) && decisionEnumsMatch(ev, decisionDenied) { - return classifiedActivity{Diagnostic: "dns_refused"}, true - } - return classifiedActivity{Diagnostic: "unknown_dns_decision", Incomplete: true}, true + if decision == "Deny" || record.deniedEnum() { + return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true } - return classifiedActivity{}, false + return classifiedActivity{Diagnostic: "middleware_stage"}, true } -func classifyMiddlewareActivity(ev Event, unmapped map[string]json.RawMessage) (classifiedActivity, bool) { - if ev.ClassUID == ClassHTTPActivity && strings.HasPrefix(ev.Message, "MIDDLEWARE ") { - failed, valid := activityFailed(unmapped) - if !valid { - return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}, true - } - if failed { - return classifiedActivity{Diagnostic: "middleware_failure"}, true - } - decision := activityMessageValue(ev.Message, "decision") - if decision != "Allow" && decision != "Deny" { - return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true - } - if decision == "Deny" || ev.ActionID == deniedID || ev.DispositionID == deniedID { - return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true - } - return classifiedActivity{Diagnostic: "middleware_stage"}, true - } - if ev.ClassUID != ClassNetworkActivity || !strings.HasPrefix(ev.Message, "WEBSOCKET_MIDDLEWARE ") { +func classifyWebSocketMiddleware(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassNetworkActivity || !strings.HasPrefix(record.Message, "WEBSOCKET_MIDDLEWARE ") { return classifiedActivity{}, false } - parts := strings.Fields(ev.Message) - if len(parts) < middlewareTokenCount { + if len(record.tokens) < middlewareTokenCount { return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true } - switch parts[1] { + switch record.tokens[1] { case "fail_open", "fail_closed": return classifiedActivity{Diagnostic: "middleware_failure"}, true case "deny": return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true case "inspect", "voluntary_skip", "allow": - if ev.ActionID == deniedID || ev.DispositionID == deniedID { + if record.deniedEnum() { return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true } return classifiedActivity{Diagnostic: "middleware_stage"}, true @@ -172,6 +202,59 @@ func classifyMiddlewareActivity(ev Event, unmapped map[string]json.RawMessage) ( } } +func classifyPassthroughHTTP(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassHTTPActivity || !strings.HasPrefix(record.Message, "HTTP_REQUEST ") { + return classifiedActivity{}, false + } + if record.deniedEnum() { + return unknownActivity(groupHTTPInitial), true + } + return classifiedActivity{Diagnostic: "passthrough_http"}, true +} + +func classifyWebSocketNotice(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassNetworkActivity { + return classifiedActivity{}, false + } + switch { + case strings.HasPrefix(record.Message, "WEBSOCKET_MIDDLEWARE_COVERAGE "): + return classifiedActivity{Diagnostic: "coverage"}, true + case strings.HasPrefix(record.Message, "WEBSOCKET_CREDENTIAL_REWRITE "): + return classifiedActivity{Diagnostic: "operational_failure"}, true + default: + return classifiedActivity{}, false + } +} + +func classifySession(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassNetworkActivity || record.ActivityID != networkActivityOpen || !record.withoutDecisionEnums() { + return classifiedActivity{}, false + } + message := strings.ToLower(record.Message) + if message == "supervisor session established" || strings.HasPrefix(message, "supervisor session established (") { + return classifiedActivity{Diagnostic: "session"}, true + } + return classifiedActivity{}, false +} + +func classifyConnectionFailure(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassNetworkActivity || record.ActivityID != networkActivityFail || !record.withoutDecisionEnums() { + return classifiedActivity{}, false + } + return classifiedActivity{Diagnostic: "connection_failure"}, true +} + +func classifyDNSTraffic(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassNetworkActivity || record.ActivityID != networkActivityTraffic { + return classifiedActivity{}, false + } + if record.StatusDetail == "policy_dns_ineligible" && strings.HasPrefix(record.Message, "Policy DNS refused ") && + record.deniedEnum() && decisionEnumsMatch(record.Event, decisionDenied) { + return classifiedActivity{Diagnostic: activityDNSRefused}, true + } + return classifiedActivity{Diagnostic: "unknown_dns_decision", Incomplete: true}, true +} + func activityFailed(unmapped map[string]json.RawMessage) (bool, bool) { raw, present := unmapped["failed"] if !present { @@ -197,27 +280,40 @@ func activityMessageValue(message, key string) string { return value } -func classifyL7Activity(ev Event) (classifiedActivity, bool) { - parts := strings.Fields(ev.Message) - if len(parts) == 0 { - return classifiedActivity{}, false +// l7DecisionGroup reports the measurement group of a known L7 policy-decision +// message, judged by its first token only. +func l7DecisionGroup(tokens []string) (activityGroup, bool) { + if len(tokens) == 0 { + return "", false } - group, class := groupHTTPInitial, ClassHTTPActivity - switch parts[0] { + switch tokens[0] { case "L7_REQUEST", "GRAPHQL_L7_REQUEST", "JSONRPC_L7_REQUEST": + return groupHTTPInitial, true case "L7_REQUEST_TRANSFORMED": - group = groupPostRewrite + return groupPostRewrite, true case "WEBSOCKET_L7_REQUEST": - group, class = groupWebSocket, ClassNetworkActivity + return groupWebSocket, true default: + return "", false + } +} + +func classifyL7Decision(record activityRecord) (classifiedActivity, bool) { + group, ok := l7DecisionGroup(record.tokens) + if !ok { return classifiedActivity{}, false } - if ev.ClassUID != class || ev.ActivityID != policyActivityOther || len(parts) < minimumJSONRPCTokens { + class := ClassHTTPActivity + if group == groupWebSocket { + class = ClassNetworkActivity + } + parts := record.tokens + if record.ClassUID != class || record.ActivityID != policyActivityOther || len(parts) < minimumJSONRPCTokens { return unknownActivity(group), true } token := parts[1] if parts[0] == "JSONRPC_L7_REQUEST" { - if !strings.HasPrefix(token, "decision=") || activityMessageValue(ev.Message, "http_method") == "" || activityMessageValue(ev.Message, "endpoint") == "" { + if !strings.HasPrefix(token, "decision=") || activityMessageValue(record.Message, "http_method") == "" || activityMessageValue(record.Message, "endpoint") == "" { return unknownActivity(group), true } token = strings.TrimPrefix(token, "decision=") @@ -225,7 +321,7 @@ func classifyL7Activity(ev Event) (classifiedActivity, bool) { return unknownActivity(group), true } decision := activityDecision(token) - if decision == decisionUnknown || !decisionEnumsMatch(ev, decision) { + if decision == decisionUnknown || !decisionEnumsMatch(record.Event, decision) { return unknownActivity(group), true } return classifiedActivity{Group: group, Decision: decision, AuditFallback: decision == decisionAudited}, true @@ -255,23 +351,25 @@ func decisionEnumsMatch(ev Event, decision policyDecision) bool { return (ev.ActionID == 0 || ev.ActionID == id) && (ev.DispositionID == 0 || ev.DispositionID == id) } -func classifyConnectionActivity(ev Event) classifiedActivity { - if ev.ClassUID == ClassHTTPActivity { +// classifyConnectionActivity is the last rule: it counts connection decisions +// and gives every other record a class-based fallback. +func classifyConnectionActivity(record activityRecord) classifiedActivity { + if record.ClassUID == ClassHTTPActivity { return unknownActivity(groupHTTPInitial) } - if ev.ClassUID != ClassNetworkActivity { + if record.ClassUID != ClassNetworkActivity { return classifiedActivity{Diagnostic: "other"} } - if ev.ActivityID == policyActivityOther { + if record.ActivityID == policyActivityOther { return unknownActivity(groupWebSocket) } - if ev.ActivityID != networkActivityOpen { - if ev.ActionID == 0 && ev.DispositionID == 0 { + if record.ActivityID != networkActivityOpen { + if record.withoutDecisionEnums() { return classifiedActivity{Diagnostic: "network_no_policy_decision"} } return unknownActivity(groupConnections) } - parts := strings.Fields(ev.Message) + parts := record.tokens decision := decisionUnknown if len(parts) >= minimumJSONRPCTokens && (parts[0] == "CONNECT" || parts[0] == "CONNECT_L7") { switch parts[1] { @@ -280,13 +378,13 @@ func classifyConnectionActivity(ev Event) classifiedActivity { case "denied": decision = decisionDenied } - } else if strings.HasPrefix(ev.Message, "Transparent TCP ") { - decision = transparentPolicyDecision(ev) + } else if strings.HasPrefix(record.Message, "Transparent TCP ") { + decision = transparentPolicyDecision(record.Event) } - if decision != decisionUnknown && decisionEnumsMatch(ev, decision) { + if decision != decisionUnknown && decisionEnumsMatch(record.Event, decision) { return classifiedActivity{Group: groupConnections, Decision: decision} } - if ev.ActionID == 0 && ev.DispositionID == 0 && !strings.HasPrefix(ev.Message, "CONNECT") && !strings.HasPrefix(ev.Message, "Transparent TCP ") { + if record.withoutDecisionEnums() && !strings.HasPrefix(record.Message, "CONNECT") && !strings.HasPrefix(record.Message, "Transparent TCP ") { return classifiedActivity{Diagnostic: "network_no_policy_decision"} } return unknownActivity(groupConnections) diff --git a/internal/hydrator/activity_event_test.go b/internal/hydrator/activity_event_test.go index a9679e4..776ba3c 100644 --- a/internal/hydrator/activity_event_test.go +++ b/internal/hydrator/activity_event_test.go @@ -153,6 +153,13 @@ func TestClassifyActivityRouting(t *testing.T) { {"websocket coverage", Event{ClassUID: 4001, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected"}, classifiedActivity{Diagnostic: "coverage"}}, {"coverage metadata before class routing", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Unmapped: json.RawMessage(`{"coverage_state":"binding_not_selected"}`)}, classifiedActivity{Diagnostic: "coverage"}}, {"invalid coverage metadata", Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Unmapped: json.RawMessage(`{"coverage_state":[]}`)}, classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}}, + {"coverage metadata on a denied decision", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "L7_REQUEST deny POST example.com:443/upload reason=rule", Unmapped: json.RawMessage(`{"coverage_state":"binding_not_selected"}`)}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"operational status on a websocket decision", Event{ClassUID: 4001, ActivityID: 99, ActionID: 2, DispositionID: 2, StatusDetail: "assembly_capacity_exhausted", Message: "WEBSOCKET_L7_REQUEST deny WEBSOCKET_TEXT example.com:443/ws reason=rule"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"operational status on a rewrite decision", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, StatusDetail: "credential_unavailable", Message: "L7_REQUEST_TRANSFORMED allow POST example.com:443/mcp reason=rule"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"coverage metadata on a connection denial", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, Message: "CONNECT denied example.com:443 reason=rule", Unmapped: json.RawMessage(`{"coverage_state":"binding_not_selected"}`)}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"operational status on a transparent denial", Event{ClassUID: 4001, ActivityID: 1, ActionID: 2, DispositionID: 2, StatusDetail: "assembly_capacity_exhausted", Message: "Transparent TCP denied example.com:443"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"connection failure carrying a websocket denial", Event{ClassUID: 4001, ActivityID: 4, Message: "WEBSOCKET_L7_REQUEST deny WEBSOCKET_TEXT example.com:443/ws reason=rule"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"connection failure carrying a connect decision", Event{ClassUID: 4001, ActivityID: 4, Message: "CONNECT allowed example.com:443"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, {"middleware allow", Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Message: "MIDDLEWARE content remote decision=Allow", Unmapped: json.RawMessage(`{"failed":false}`)}, classifiedActivity{Diagnostic: "middleware_stage"}}, {"middleware denied", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "MIDDLEWARE content remote decision=Deny", Unmapped: json.RawMessage(`{"failed":false}`)}, classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}}, {"middleware failed closed", Event{ClassUID: 4002, ActivityID: 99, ActionID: 2, DispositionID: 2, Message: "MIDDLEWARE content remote decision=Deny", Unmapped: json.RawMessage(`{"failed":true}`)}, classifiedActivity{Diagnostic: "middleware_failure"}}, @@ -171,6 +178,8 @@ func TestClassifyActivityRouting(t *testing.T) { {"non-object unmapped metadata", Event{ClassUID: 4002, ActivityID: 99, Message: "L7_REQUEST allow GET example.com:443/test", Unmapped: json.RawMessage(`[]`)}, classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}}, {"irrelevant class", Event{ClassUID: ClassProcessActivity, ActivityID: 1, Message: "process start"}, classifiedActivity{Diagnostic: "other"}}, {"empty event", Event{}, classifiedActivity{Diagnostic: "other"}}, + {"lifecycle record carrying a connect denial", Event{ClassUID: 4001, ActivityID: 3, Message: "CONNECT denied example.com:443"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, + {"irrelevant class carrying a transparent denial", Event{ClassUID: ClassProcessActivity, ActivityID: 1, Message: "Transparent TCP denied example.com:443"}, classifiedActivity{Diagnostic: "conflicting_decision_metadata", Incomplete: true}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -182,6 +191,85 @@ func TestClassifyActivityRouting(t *testing.T) { } } +// fuzzActivityEvent builds a classifier input from fuzzed choices, biased +// toward the classes, activities, statuses and message families the rules read. +func fuzzActivityEvent(class, activity, action, disposition, status, family, metadata uint8, suffix string) Event { + classes := [...]int{ClassHTTPActivity, ClassNetworkActivity, ClassProcessActivity, ClassDetectionFinding, 0} + activities := [...]int{0, networkActivityOpen, 2, 3, networkActivityFail, networkActivityTraffic, policyActivityOther} + enums := [...]int{0, 1, deniedID, observedActionID, policyActivityOther} + statuses := [...]string{"", "credential_endpoint_mismatch", "request_authority_mismatch", "credential_unavailable", + "assembly_capacity_exhausted", "policy_dns_trusted_gateway_unavailable", "policy_dns_ineligible", + "transparent_tcp_allowed", "transparent_tcp_policy_denied", "transparent_tcp_mapping_expired"} + families := [...]string{"", "L7_REQUEST ", "GRAPHQL_L7_REQUEST ", "JSONRPC_L7_REQUEST ", "JSONRPC_L7_REQUEST decision=", + "L7_REQUEST_TRANSFORMED ", "WEBSOCKET_L7_REQUEST ", "CONNECT ", "CONNECT_L7 ", "CONNECT denied ", "Transparent TCP ", + "Transparent TCP denied ", "MIDDLEWARE ", "WEBSOCKET_MIDDLEWARE ", "WEBSOCKET_MIDDLEWARE_COVERAGE ", + "WEBSOCKET_CREDENTIAL_REWRITE ", "HTTP_REQUEST ", "Policy DNS refused ", "Supervisor session established", + "Proxy connection error: "} + metadatas := [...]string{"", `{"coverage_state":"binding_not_selected"}`, `{"coverage_state":""}`, `{"failed":true}`, + `{"failed":"maybe"}`, `{"middleware":"guard","count":1}`, `not json`} + return Event{ + ClassUID: classes[int(class)%len(classes)], + ActivityID: activities[int(activity)%len(activities)], + ActionID: enums[int(action)%len(enums)], + DispositionID: enums[int(disposition)%len(enums)], + StatusDetail: statuses[int(status)%len(statuses)], + Message: families[int(family)%len(families)] + suffix, + Unmapped: json.RawMessage(metadatas[int(metadata)%len(metadatas)]), + } +} + +// statedDecision reports whether a message states an L7 or connection policy +// decision, and which one. It is written independently of the classifier. +func statedDecision(ev Event) (policyDecision, bool) { + tokens := strings.Fields(ev.Message) + if len(tokens) > 0 { + switch tokens[0] { + case "L7_REQUEST", "GRAPHQL_L7_REQUEST", "JSONRPC_L7_REQUEST", "L7_REQUEST_TRANSFORMED", "WEBSOCKET_L7_REQUEST": + if len(tokens) == 1 { + return decisionUnknown, true + } + return activityDecision(strings.TrimPrefix(tokens[1], "decision=")), true + case "CONNECT", "CONNECT_L7": + if len(tokens) > 1 && (tokens[1] == "allowed" || tokens[1] == "denied") { + return policyDecision(tokens[1]), true + } + } + } + switch { + case strings.HasPrefix(ev.Message, "Transparent TCP denied"): + return decisionDenied, true + case strings.HasPrefix(ev.Message, "Transparent TCP ") && ev.StatusDetail == "transparent_tcp_allowed": + return decisionAllowed, true + default: + return "", false + } +} + +func FuzzClassifyActivityKeepsStatedDecisions(f *testing.F) { + f.Add(uint8(1), uint8(1), uint8(2), uint8(2), uint8(0), uint8(9), uint8(1), "example.com:443") + f.Add(uint8(1), uint8(4), uint8(0), uint8(0), uint8(0), uint8(6), uint8(0), "deny WEBSOCKET_TEXT example.com:443/ws reason=rule") + f.Add(uint8(0), uint8(6), uint8(2), uint8(2), uint8(3), uint8(1), uint8(0), "deny POST example.com:443/upload reason=rule") + f.Add(uint8(1), uint8(3), uint8(0), uint8(0), uint8(0), uint8(11), uint8(0), "example.com:443") + f.Add(uint8(2), uint8(1), uint8(0), uint8(0), uint8(7), uint8(10), uint8(0), "mapping_id=placeholder") + f.Fuzz(func(t *testing.T, class, activity, action, disposition, status, family, metadata uint8, suffix string) { + ev := fuzzActivityEvent(class, activity, action, disposition, status, family, metadata, suffix) + if ev.ClassUID == ClassDetectionFinding { + return + } + stated, states := statedDecision(ev) + if !states { + return + } + got := classifyActivity(ev) + if got.Group == "" && !got.Incomplete { + t.Fatalf("stated %q decision dropped: %+v -> %+v", stated, ev, got) + } + if stated == decisionDenied && got.Group != "" && got.Decision != decisionDenied && got.Decision != decisionUnknown { + t.Fatalf("stated denial counted as %s: %+v", got.Decision, ev) + } + }) +} + func TestClassifyActivityFindings(t *testing.T) { t.Parallel() tests := []struct { @@ -295,8 +383,11 @@ func TestActivityReaderInvalidOCSF(t *testing.T) { {"malformed object", `{"class_uid":`, 1}, {"malformed indented object", ` {not JSON}`, 1}, {"wrong type in object", `{"class_uid":"4001"}`, 1}, - {"bad stderr payload", `timestamp OCSF-JSON []`, 1}, + {"bad stderr payload", `timestamp OCSF-JSON {"class_uid":"4001"}`, 1}, {"missing stderr payload", `timestamp OCSF-JSON`, 1}, + {"non-object stderr payload", `timestamp OCSF-JSON []`, 0}, + {"marker text inside plain stderr", `CONNECT allowed OCSF-JSON.attacker.example:443`, 0}, + {"marker as a word in plain stderr", `WARN failed to encode OCSF-JSON payload`, 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/hydrator/activity_limits_test.go b/internal/hydrator/activity_limits_test.go index b812933..7bed11d 100644 --- a/internal/hydrator/activity_limits_test.go +++ b/internal/hydrator/activity_limits_test.go @@ -3,6 +3,7 @@ package hydrator import ( "encoding/json" "math" + "reflect" "strconv" "strings" "testing" @@ -45,6 +46,60 @@ func TestActivityFindingBounds(t *testing.T) { } } +func TestActivityPreservesFindingKeys(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + middleware bool + }{ + {name: "type"}, {name: "middleware", middleware: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + st := newActivityStats() + for _, suffix := range []string{"a", "b"} { + f := ActivityFinding{Origin: "plugin", Type: strings.Repeat("x", maxActivityText) + suffix, Middleware: "detector", Records: 1, Occurrences: 1} + if tc.middleware { + f.Type = "same" + f.Middleware = strings.Repeat("x", maxActivityText) + suffix + } + st.addFinding(f) + } + if len(st.findings) != 2 { + t.Fatalf("distinct finding identifiers merged: %+v", st.findings) + } + }) + } +} + +func TestActivityGlobalBudgetReleasesOwner(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + limit int + wantUnavailable []bool + }{ + {name: "grouped owner", limit: 2, wantUnavailable: []bool{true, false, false}}, + {name: "single entry", limit: 1, wantUnavailable: []bool{true, true, false}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + agg := newActivityAggregator(mustAgentMap(t, AgentMapping{AgentID: "a", SandboxUIDs: []string{"a"}}, AgentMapping{AgentID: "b", SandboxUIDs: []string{"b"}}, AgentMapping{AgentID: "c", SandboxUIDs: []string{"c"}}), nil, ActivityConfig{MaxUIDs: tc.limit}) + for _, record := range []struct{ agent, uid string }{{"a", "a1"}, {"a", "a2"}, {"b", "b1"}, {"c", "c1"}} { + agg.add(Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: record.agent}, Metadata: Metadata{UID: record.uid}, Message: "L7_REQUEST allow GET example.com:443/test reason=rule"}) + } + for i, evidence := range agg.evidence(Window{}, auditInputHealth{}) { + if (evidence.Score == nil) != tc.wantUnavailable[i] { + t.Fatalf("evidence=%+v, want unavailable=%v", evidence, tc.wantUnavailable[i]) + } + } + if agg.totalUIDs > tc.limit { + t.Fatal("global budget exceeded") + } + }) + } +} + func TestActivityDenialCoverage(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -71,10 +126,16 @@ func TestActivityDenialCoverage(t *testing.T) { for range tc.tcp { st.recordDenialCoverage(ev, false) } - evidence := st.evidence("agent", Window{}, 1) + evidence := st.evidence("agent", Window{}, 1, auditInputHealth{}) if (evidence.Score == nil) != tc.wantUnavailable { t.Fatalf("evidence=%+v", evidence) } + if again := st.evidence("agent", Window{}, 1, auditInputHealth{}); !reflect.DeepEqual(again, evidence) { + t.Fatalf("evidence is not repeatable: first=%+v second=%+v", evidence, again) + } + if _, mutated := st.diagnostics["unrepresented_dns_refusals"]; mutated || st.unavailable != "" { + t.Fatal("evidence modified the accumulated state") + } }) } } @@ -183,7 +244,7 @@ func TestActivityMetadataAndExplanation(t *testing.T) { st.addFinding(ActivityFinding{Origin: "plugin", Type: strconv.Itoa(i), Records: 1, Occurrences: 1}) } st.measurements.HTTPInitial.Allowed = 1 - evidence := st.evidence("agent", Window{}, 1) + evidence := st.evidence("agent", Window{}, 1, auditInputHealth{}) if len(st.versions) > maxSourceVersions+1 { t.Fatal("versions unbounded") } @@ -203,11 +264,17 @@ func TestActivityMetadataAndExplanation(t *testing.T) { func TestActivityUnreadableAuditRecords(t *testing.T) { t.Parallel() - for _, tc := range []struct{ name, extra, reason string }{ + for _, tc := range []struct { + name, extra, reason string + truncatedTail int + }{ {name: "ordinary stderr", extra: "ordinary supervisor log\n"}, - {name: "malformed audit", extra: "OCSF-JSON {broken\n", reason: "unreadable_audit_records"}, - {name: "oversized audit", extra: "{" + strings.Repeat(" ", MaxLineBytes) + "}\n", reason: "unreadable_audit_records"}, + {name: "malformed audit", extra: "OCSF-JSON {broken\n", reason: activityUnreadableRecords}, + {name: "malformed audit before valid records", extra: "{\"class_uid\":4002,\n" + `{"class_uid":4001}` + "\n", reason: activityUnreadableRecords}, + {name: "oversized audit", extra: "{" + strings.Repeat(" ", MaxLineBytes) + "}\n", reason: activityUnreadableRecords}, {name: "oversized ordinary stderr", extra: "ordinary log " + strings.Repeat("x", MaxLineBytes) + "\n"}, + {name: "final line still being written", extra: `{"class_uid":4002,"activity_id":99,"container":{"na`, truncatedTail: 1}, + {name: "marker text inside an ordinary log line", extra: "CONNECT allowed OCSF-JSON.attacker.example:443\n"}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -216,9 +283,68 @@ func TestActivityUnreadableAuditRecords(t *testing.T) { if err != nil { t.Fatal(err) } - if report.Activity[0].Unavailable != tc.reason { - t.Fatalf("evidence=%+v", report.Activity[0]) + evidence := report.Activity[0] + if evidence.Unavailable != tc.reason || evidence.Diagnostics["truncated_final_line"] != tc.truncatedTail { + t.Fatalf("evidence=%+v", evidence) + } + if tc.reason == "" && (evidence.Score == nil || *evidence.Score != 100) { + t.Fatalf("readable input lost its score: %+v", evidence) } }) } } + +func TestActivityUnreadableRecordsKeepEarlierReason(t *testing.T) { + t.Parallel() + st := newActivityStats() + st.measurements.HTTPInitial.Allowed = 1 + st.unavailable = activityDedupSaturated + evidence := st.evidence("agent", Window{}, 1, auditInputHealth{unreadable: 2}) + if evidence.Unavailable != activityDedupSaturated || evidence.Diagnostics[activityUnreadableRecords] != 2 { + t.Fatalf("earlier reason hidden or unreadable count lost: %+v", evidence) + } + if _, mutated := st.diagnostics[activityUnreadableRecords]; mutated { + t.Fatal("evidence modified the accumulated diagnostics") + } +} + +func TestActivityOversizedEventUID(t *testing.T) { + t.Parallel() + agg := newActivityAggregator(sampleAgents(t), nil, ActivityConfig{}) + for _, uid := range []string{"ordinary", strings.Repeat("u", maxActivityText+1)} { + agg.add(Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: sandboxAUID}, Metadata: Metadata{UID: uid}, Message: "L7_REQUEST allow GET example.com:443/test reason=rule"}) + } + evidence := agg.evidence(Window{}, auditInputHealth{})[0] + if evidence.Unavailable != activityIncomplete || evidence.Diagnostics["oversized_event_uid"] != 1 { + t.Fatalf("oversized UID not reported as incomplete: %+v", evidence) + } + if st := agg.stats["agent-a"]; len(st.seen) != 1 || agg.totalUIDs != 1 { + t.Fatal("oversized UID released the agent's deduplication set") + } +} + +func TestActivityExplanationQuotesFindingText(t *testing.T) { + t.Parallel() + score := 40 + evidence := ActivityEvidence{Score: &score, Findings: []ActivityFinding{{Origin: "plugin", Middleware: "detector", Type: "x. Policy adherence 100/100", Records: 1, Occurrences: 1}}} + explanation := activityExplanation(evidence) + if !strings.Contains(explanation, `middleware="detector" type="x. Policy adherence 100/100"`) { + t.Fatalf("finding text not quoted: %s", explanation) + } +} + +func TestActivityEvidenceDegraded(t *testing.T) { + t.Parallel() + for reason, want := range map[string]bool{ + "": false, + activityInsufficientSample: false, + activityNoEligibleDecisions: false, + activityIncomplete: true, + activityDedupSaturated: true, + activityUnreadableRecords: true, + } { + if got := (ActivityEvidence{Unavailable: reason}).Degraded(); got != want { + t.Fatalf("Degraded(%q)=%v, want %v", reason, got, want) + } + } +} diff --git a/internal/hydrator/activity_review_test.go b/internal/hydrator/activity_review_test.go deleted file mode 100644 index 9233c76..0000000 --- a/internal/hydrator/activity_review_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package hydrator - -import ( - "strings" - "testing" -) - -func TestActivityPreservesFindingKeys(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - middleware bool - }{ - {name: "type"}, {name: "middleware", middleware: true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - st := newActivityStats() - for _, suffix := range []string{"a", "b"} { - f := ActivityFinding{Origin: "plugin", Type: strings.Repeat("x", maxActivityText) + suffix, Middleware: "detector", Records: 1, Occurrences: 1} - if tc.middleware { - f.Type = "same" - f.Middleware = strings.Repeat("x", maxActivityText) + suffix - } - st.addFinding(f) - } - if len(st.findings) != 2 { - t.Fatalf("distinct finding identifiers merged: %+v", st.findings) - } - }) - } -} - -func TestActivityGlobalBudgetReleasesOwner(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - limit int - wantUnavailable []bool - }{ - {name: "grouped owner", limit: 2, wantUnavailable: []bool{true, false, false}}, - {name: "single entry", limit: 1, wantUnavailable: []bool{true, true, false}}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - agg := newActivityAggregator(mustAgentMap(t, AgentMapping{AgentID: "a", SandboxUIDs: []string{"a"}}, AgentMapping{AgentID: "b", SandboxUIDs: []string{"b"}}, AgentMapping{AgentID: "c", SandboxUIDs: []string{"c"}}), nil, ActivityConfig{MaxUIDs: tc.limit}) - for _, record := range []struct{ agent, uid string }{{"a", "a1"}, {"a", "a2"}, {"b", "b1"}, {"c", "c1"}} { - agg.add(Event{ClassUID: 4002, ActivityID: 99, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: record.agent}, Metadata: Metadata{UID: record.uid}, Message: "L7_REQUEST allow GET example.com:443/test reason=rule"}) - } - for i, evidence := range agg.evidence(Window{}, 0) { - if (evidence.Score == nil) != tc.wantUnavailable[i] { - t.Fatalf("evidence=%+v, want unavailable=%v", evidence, tc.wantUnavailable[i]) - } - } - if agg.totalUIDs > tc.limit { - t.Fatal("global budget exceeded") - } - }) - } -} - -func TestActivityOnlyDoesNotAccumulateLegacyPayload(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - legacy bool - }{ - {name: "new only"}, {name: "legacy kept", legacy: true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - agg := newAggregator(sampleAgents(t), nil) - agg.activity = newActivityAggregator(sampleAgents(t), nil, ActivityConfig{MaxUIDsPerAgent: 1}) - agg.skipLegacyStats = !tc.legacy - for _, uid := range []string{"1", "2", "3"} { - agg.add(Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: sandboxAUID}, Metadata: Metadata{UID: uid}, DstEndpoint: &Endpoint{Domain: uid + ".example.com"}, Message: "CONNECT allowed example.com:443"}) - } - st := agg.stats["agent-a"] - if st.events != 3 { - t.Fatal("summary attribution lost") - } - want := 0 - if tc.legacy { - want = 3 - } - if len(st.destinations) != want { - t.Fatalf("legacy destinations=%d want %d", len(st.destinations), want) - } - }) - } -} - -func TestActivityFailureStatusKeepsLegacyBehavior(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - legacy bool - }{ - {name: "new only"}, {name: "legacy alongside", legacy: true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - opts := activityOptions(t) - if tc.legacy { - opts.Signals = DefaultSignalConfig() - opts.Signals.EmitActivity = true - } - input := activityInput(t, "allowed", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + "\n" + `{"class_uid":4001,"activity_id":4,"status_id":2,"time":1791996121000,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"},"message":"Proxy connection error: connection refused"}` - sink := &fakeSink{} - report, err := Run(t.Context(), strings.NewReader(input), sink, opts, nil) - if err != nil { - t.Fatal(err) - } - if report.Activity[0].Score == nil || *report.Activity[0].Score != 100 { - t.Fatal("connection failure penalized corrected score") - } - if tc.legacy && report.Agents[0].Denied != 1 { - t.Fatal("legacy status-failure counting changed") - } - }) - } -} diff --git a/internal/hydrator/activity_score.go b/internal/hydrator/activity_score.go index f20b9e7..0ebaeb9 100644 --- a/internal/hydrator/activity_score.go +++ b/internal/hydrator/activity_score.go @@ -5,9 +5,20 @@ import ( "math" ) +// Unavailable reasons recorded in ActivityEvidence.Unavailable. +const ( + activityIncomplete = "interpretation_incomplete" + activityDedupSaturated = "dedup_saturated" + activityUnreadableRecords = "unreadable_audit_records" + activityInsufficientSample = "insufficient_sample" + activityNoEligibleDecisions = "no_eligible_decisions" +) + +// activityDNSRefused is the diagnostic that feeds the DNS-to-TCP denial +// coverage check, so producer and consumer must agree on it. +const activityDNSRefused = "dns_refused" + const ( - activityIncomplete = "interpretation_incomplete" - activityDedupSaturated = "dedup_saturated" activityOther = "other" defaultMinDecisions = 1 defaultMaxUIDsPerAgent = 100_000 @@ -40,6 +51,15 @@ func (m ActivityMeasurements) groups() []DecisionCounts { return []DecisionCounts{m.HTTPInitial, m.PostRewriteEvaluations, m.WebSocketEvaluations, m.Connections} } +// Denied returns the denied policy decisions across all measurement groups. +func (m ActivityMeasurements) Denied() int { + denied := 0 + for _, counts := range m.groups() { + denied += counts.Denied + } + return denied +} + func assessActivity(m ActivityMeasurements, minimum int, unavailable string) (*int, string, int) { sample := 0 for _, counts := range m.groups() { @@ -60,7 +80,7 @@ func assessActivity(m ActivityMeasurements, minimum int, unavailable string) (*i } if n < minimum { if counts.Denied+counts.Audited > 0 { - return nil, "insufficient_sample", sample + return nil, activityInsufficientSample, sample } continue } @@ -68,7 +88,7 @@ func assessActivity(m ActivityMeasurements, minimum int, unavailable string) (*i lowest = min(lowest, fullAdherence*float64(counts.Allowed)/float64(n)) } if !eligible { - return nil, "no_eligible_decisions", sample + return nil, activityNoEligibleDecisions, sample } score := int(math.Round(lowest)) return &score, "", sample diff --git a/internal/hydrator/activity_test.go b/internal/hydrator/activity_test.go index 06f61c1..d271ae3 100644 --- a/internal/hydrator/activity_test.go +++ b/internal/hydrator/activity_test.go @@ -148,6 +148,199 @@ func TestActivityRunAndLegacyIsolation(t *testing.T) { } } +func TestActivityOnlyDoesNotAccumulateLegacyPayload(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + legacy bool + }{ + {name: "new only"}, {name: "legacy kept", legacy: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + if tc.legacy { + opts.Signals = DefaultSignalConfig() + opts.Signals.EmitActivity = true + } + agg, activity := newAggregators(opts) + if activity == nil { + t.Fatal("activity aggregator not built") + } + consumers := eventConsumers(agg, activity) + for _, uid := range []string{"1", "2", "3"} { + for _, consumer := range consumers { + consumer.add(Event{ClassUID: 4001, ActivityID: 1, ActionID: 1, DispositionID: 1, Time: 1, Container: &Container{UID: sandboxAUID}, Metadata: Metadata{UID: uid}, DstEndpoint: &Endpoint{Domain: uid + ".example.com"}, Message: "CONNECT allowed example.com:443"}) + } + } + st := agg.stats["agent-a"] + if st.events != 3 { + t.Fatal("summary attribution lost") + } + want := 0 + if tc.legacy { + want = 3 + } + if len(st.destinations) != want { + t.Fatalf("legacy destinations=%d want %d", len(st.destinations), want) + } + if got := activity.stats["agent-a"].measurements.Connections.Allowed; got != 3 { + t.Fatalf("activity connections allowed=%d, want 3", got) + } + }) + } +} + +func TestActivityAggregatorOnlyWhenRequested(t *testing.T) { + t.Parallel() + agg, activity := newAggregators(Options{Agents: sampleAgents(t), Signals: DefaultSignalConfig()}) + if activity != nil || agg.skipLegacyStats { + t.Fatal("legacy-only run built the activity path") + } + if consumers := eventConsumers(agg, activity); len(consumers) != 1 { + t.Fatalf("consumers=%d, want the legacy aggregator only", len(consumers)) + } + _, activity = newAggregators(Options{Agents: sampleAgents(t), Evidence: &bytes.Buffer{}}) + if activity == nil { + t.Fatal("evidence-only run did not build the activity path") + } +} + +func TestActivityFailureStatusKeepsLegacyBehavior(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + legacy bool + }{ + {name: "new only"}, {name: "legacy alongside", legacy: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + if tc.legacy { + opts.Signals = DefaultSignalConfig() + opts.Signals.EmitActivity = true + } + input := activityInput(t, "allowed", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + "\n" + `{"class_uid":4001,"activity_id":4,"status_id":2,"time":1791996121000,"container":{"uid":"3949f6fc-2b84-45b2-a631-46debe41d84f"},"message":"Proxy connection error: connection refused"}` + sink := &fakeSink{} + report, err := Run(t.Context(), strings.NewReader(input), sink, opts, nil) + if err != nil { + t.Fatal(err) + } + if report.Activity[0].Score == nil || *report.Activity[0].Score != 100 { + t.Fatal("connection failure penalized corrected score") + } + wantLegacyDenied := 0 + if tc.legacy { + wantLegacyDenied = 1 + } + if report.Agents[0].Denied != wantLegacyDenied { + t.Fatalf("legacy denied=%d, want %d", report.Agents[0].Denied, wantLegacyDenied) + } + }) + } +} + +func TestActivityConnectionOnlyTraffic(t *testing.T) { + t.Parallel() + lines := []string{ + activityInput(t, "c1", "CONNECT allowed example.com:443", 4001, 1, 1), + activityInput(t, "c2", "CONNECT allowed example.com:443", 4001, 1, 1), + activityInput(t, "c3", "CONNECT allowed example.com:443", 4001, 1, 1), + activityInput(t, "c4", "CONNECT denied blocked.example.com:443", 4001, 1, 2), + } + sink := &fakeSink{} + report, err := Run(t.Context(), strings.NewReader(strings.Join(lines, "\n")), sink, activityOptions(t), nil) + if err != nil { + t.Fatal(err) + } + evidence := report.Activity[0] + want := ActivityMeasurements{Connections: DecisionCounts{Allowed: 3, Denied: 1}} + if evidence.Measurements != want || evidence.Score == nil || *evidence.Score != 75 { + t.Fatalf("connection-only evidence=%+v", evidence) + } + if len(sink.batches) != 1 || len(sink.batches[0].Observations) != 1 { + t.Fatalf("connection-only score not posted: %+v", sink.batches) + } +} + +func TestActivityDegradedAgentSkippedFromBatch(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + legacy bool + }{ + {name: "activity only"}, {name: "legacy alongside", legacy: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := activityOptions(t) + if tc.legacy { + opts.Signals = DefaultSignalConfig() + opts.Signals.EmitActivity = true + } + clean := activityInput(t, "a1", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) + degraded := jsonLine(t, map[string]any{"class_uid": 4002, "activity_id": 99, "action_id": 1, "disposition_id": 1, "time": sampleBaseMs, "container": map[string]any{"name": sandboxBName}, "metadata": map[string]any{"uid": "b1"}, "message": "FUTURE_L7_FORMAT permitted=true"}) + sink := &fakeSink{} + report, err := Run(t.Context(), strings.NewReader(clean+"\n"+degraded), sink, opts, nil) + if err != nil { + t.Fatal(err) + } + if len(report.Activity) != 2 { + t.Fatalf("evidence=%+v, want one record per agent", report.Activity) + } + activityRows, legacyRows := 0, 0 + for _, row := range sink.batches[0].Observations { + if row.SignalID == DefaultActivitySignal { + activityRows++ + if row.AgentID != "agent-a" { + t.Fatalf("degraded agent posted a score: %+v", row) + } + } else { + legacyRows++ + } + } + wantLegacy := 0 + if tc.legacy { + wantLegacy = 4 + } + if activityRows != 1 || legacyRows != wantLegacy { + t.Fatalf("activity rows=%d legacy rows=%d, want 1 and %d", activityRows, legacyRows, wantLegacy) + } + }) + } +} + +func TestActivityMeasurementsDenied(t *testing.T) { + t.Parallel() + measurements := ActivityMeasurements{ + HTTPInitial: DecisionCounts{Allowed: 9, Denied: 1}, + PostRewriteEvaluations: DecisionCounts{Denied: 2, Audited: 5}, + WebSocketEvaluations: DecisionCounts{Denied: 3, Unknown: 7}, + Connections: DecisionCounts{Denied: 4}, + } + if got := measurements.Denied(); got != 10 { + t.Fatalf("Denied()=%d, want 10", got) + } +} + +func TestActivityObservationRequiresScore(t *testing.T) { + t.Parallel() + builder := observationBuilder{signals: SignalConfig{EmitActivity: true}.withDefaults()} + if _, err := builder.activity("agent", ActivityEvidence{Unavailable: activityIncomplete}); err == nil { + t.Fatal("expected an error for evidence without a score") + } + score := 67 + row, err := builder.activity("agent", ActivityEvidence{Score: &score}) + if err != nil { + t.Fatal(err) + } + var value ActivityScoreValue + if err := json.Unmarshal(row.Value, &value); err != nil || value.Score != 67 || row.SignalID != DefaultActivitySignal { + t.Fatalf("row=%+v value=%+v err=%v", row, value, err) + } +} + func TestActivityFindingOnlyAndFailures(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -220,7 +413,7 @@ func TestActivityDedupIsolation(t *testing.T) { t.Parallel() opts := activityOptions(t) opts.Activity = tc.cfg - var lines []string + lines := make([]string, 0, 2*tc.count+1) for i := range tc.count { line := activityInput(t, strconv.Itoa(i), "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) lines = append(lines, line, line) diff --git a/internal/hydrator/activity_types.go b/internal/hydrator/activity_types.go index b0a9136..0d51f81 100644 --- a/internal/hydrator/activity_types.go +++ b/internal/hydrator/activity_types.go @@ -67,3 +67,15 @@ type ActivityEvidence struct { Score *int `json:"score"` Unavailable string `json:"unavailableReason,omitempty"` } + +// Degraded reports whether the score was withheld because the agent's records +// could not be interpreted or counted, as opposed to there being too few +// policy decisions to measure. +func (e ActivityEvidence) Degraded() bool { + switch e.Unavailable { + case "", activityInsufficientSample, activityNoEligibleDecisions: + return false + default: + return true + } +} diff --git a/internal/hydrator/aggregate.go b/internal/hydrator/aggregate.go index d0e0261..93e0109 100644 --- a/internal/hydrator/aggregate.go +++ b/internal/hydrator/aggregate.go @@ -92,7 +92,8 @@ func (s *agentStats) rate() float64 { // aggregator attributes events to agents and tracks the input window. type aggregator struct { - activity *activityAggregator + // skipLegacyStats counts attributed events without accumulating the legacy + // observation payload, when no legacy signal will be emitted. skipLegacyStats bool agents AgentMap window *Window @@ -110,9 +111,6 @@ func newAggregator(agents AgentMap, window *Window) *aggregator { } func (a *aggregator) add(ev Event) { - if a.activity != nil { - a.activity.add(ev) - } ts, hasTime := ev.Timestamp() if a.window != nil { if !hasTime || !a.window.Contains(ts) { diff --git a/internal/hydrator/event.go b/internal/hydrator/event.go index 00d7cc8..4753a74 100644 --- a/internal/hydrator/event.go +++ b/internal/hydrator/event.go @@ -155,6 +155,16 @@ type Reader struct { // The corrected evidence path cannot safely attribute a discarded audit line. invalidOCSF int oversizedOCSF int + truncatedTail int +} + +// auditInputHealth counts audit lines the corrected evidence path could not use. +type auditInputHealth struct { + // unreadable lines are malformed or oversized audit records in the body of the input. + unreadable int + // truncatedTail counts an undecodable final line without a newline, which + // a collector is usually still writing. + truncatedTail int } // NewReader returns a Reader over r. @@ -162,6 +172,10 @@ func NewReader(r io.Reader) *Reader { return &Reader{br: bufio.NewReaderSize(r, readChunkBytes)} } +func (r *Reader) auditHealth() auditInputHealth { + return auditInputHealth{unreadable: r.invalidOCSF + r.oversizedOCSF, truncatedTail: r.truncatedTail} +} + // Read returns the next event. It returns io.EOF once the input is exhausted // and any other error only when the underlying reader fails; lines that do // not hold an event are skipped and counted. @@ -179,9 +193,7 @@ func (r *Reader) Read() (Event, error) { } ev, ok := decodeLine(line) if !ok { - if looksLikeAudit(line) { - r.invalidOCSF++ - } + r.countUndecodable(line) r.stats.Skipped++ continue } @@ -207,7 +219,7 @@ func (r *Reader) readLine() ([]byte, bool, error) { } if !oversized { if len(r.line)+len(chunk) > MaxLineBytes { - if looksLikeAudit(r.line) || looksLikeAudit(chunk) { + if looksLikeAudit(r.line) { r.oversizedOCSF++ } oversized = true @@ -226,8 +238,32 @@ func (r *Reader) readLine() ([]byte, bool, error) { } } +// countUndecodable records an audit-looking line that did not decode. Only the +// final line can lack its newline, since readLine splits on newlines. +func (r *Reader) countUndecodable(line []byte) { + if !looksLikeAudit(line) { + return + } + if !bytes.HasSuffix(line, []byte("\n")) { + r.truncatedTail++ + return + } + r.invalidOCSF++ +} + +// looksLikeAudit reports whether decodeLine would have tried to parse the line +// as an audit record: a JSON object, or a stderr marker followed by an object +// or by nothing at all. func looksLikeAudit(line []byte) bool { - return bytes.HasPrefix(bytes.TrimSpace(line), []byte("{")) || bytes.Contains(line, []byte("OCSF-JSON")) + trimmed := bytes.TrimSpace(line) + if bytes.HasPrefix(trimmed, []byte("{")) { + return true + } + if _, payload, found := bytes.Cut(trimmed, []byte(ocsfJSONMarker)); found { + payload = bytes.TrimSpace(payload) + return len(payload) == 0 || payload[0] == '{' + } + return bytes.HasSuffix(trimmed, bytes.TrimSpace([]byte(ocsfJSONMarker))) } // decodeLine extracts the JSON payload from a JSONL or stderr line and diff --git a/internal/hydrator/observation.go b/internal/hydrator/observation.go index 3f00738..ad62040 100644 --- a/internal/hydrator/observation.go +++ b/internal/hydrator/observation.go @@ -93,6 +93,14 @@ type ProtocolAdherenceValue struct { SampleSize int `json:"sampleSize"` } +// ActivityScoreValue is the value of the activity signal: the generic numeric +// score format of the Trust Index reference implementation. It carries no +// provider risk codes. +type ActivityScoreValue struct { + Score int `json:"score"` + Explanation string `json:"explanation"` +} + func newProvenance(aimID, evidenceURL string) *Provenance { if aimID == "" && evidenceURL == "" { return nil @@ -145,6 +153,15 @@ func (b observationBuilder) observation(agentID, signalID string, value any) (Ob }, nil } +// activity returns the activity observation for evidence that carries a score. +func (b observationBuilder) activity(agentID string, evidence ActivityEvidence) (Observation, error) { + if evidence.Score == nil { + return Observation{}, fmt.Errorf("hydrator: activity evidence for agent %s has no score", agentID) + } + value := ActivityScoreValue{Score: *evidence.Score, Explanation: activityExplanation(evidence)} + return b.observation(agentID, b.signals.ActivityID, value) +} + func (b observationBuilder) policyViolationRate(st *agentStats) PolicyViolationRateValue { return PolicyViolationRateValue{ WindowStart: formatTime(b.window.Start), diff --git a/internal/hydrator/run.go b/internal/hydrator/run.go index d3943f9..244a611 100644 --- a/internal/hydrator/run.go +++ b/internal/hydrator/run.go @@ -43,9 +43,11 @@ type Options struct { // AgentSummary reports one agent's share of the input. type AgentSummary struct { - AgentID string - AnsName string - Events int + AgentID string + AnsName string + Events int + // Denied counts events under the legacy rule. It stays zero when only the + // activity output is enabled; Report.Activity carries corrected counts. Denied int Observations int } @@ -89,12 +91,8 @@ func Run(ctx context.Context, in io.Reader, sink Sink, opts Options, logger *slo clock = time.Now } reader := NewReader(in) - agg := newAggregator(opts.Agents, opts.Window) - if opts.Signals.EmitActivity || opts.Evidence != nil { - agg.activity = newActivityAggregator(opts.Agents, opts.Window, opts.Activity) - agg.skipLegacyStats = !opts.Signals.EmitPolicyViolationRate && !opts.Signals.EmitProtocolAdherence - } - err := consume(ctx, reader, agg) + agg, activity := newAggregators(opts) + err := consume(ctx, reader, eventConsumers(agg, activity)...) report := Report{ Parse: reader.Stats(), Attributed: agg.attributed, @@ -106,15 +104,10 @@ func Run(ctx context.Context, in io.Reader, sink Sink, opts Options, logger *slo if err != nil { return report, err } - if agg.activity != nil { - report.Activity = agg.activity.evidence(report.Window, reader.invalidOCSF+reader.oversizedOCSF) - if opts.Evidence != nil { - encoder := json.NewEncoder(opts.Evidence) - for _, evidence := range report.Activity { - if err := encoder.Encode(evidence); err != nil { - return report, fmt.Errorf("hydrator: write activity evidence: %w", err) - } - } + if activity != nil { + report.Activity = activity.evidence(report.Window, reader.auditHealth()) + if err := writeActivityEvidence(opts.Evidence, report.Activity); err != nil { + return report, err } } logger.DebugContext(ctx, "input parsed", @@ -150,7 +143,44 @@ func Run(ctx context.Context, in io.Reader, sink Sink, opts Options, logger *slo return report, nil } -func consume(ctx context.Context, reader *Reader, agg *aggregator) error { +// eventConsumer receives every decoded event in input order. +type eventConsumer interface { + add(ev Event) +} + +// newAggregators returns the legacy aggregator, which also drives attribution +// and the report window, and the activity aggregator when corrected evidence +// or scores are requested (nil otherwise). +func newAggregators(opts Options) (*aggregator, *activityAggregator) { + agg := newAggregator(opts.Agents, opts.Window) + if !opts.Signals.EmitActivity && opts.Evidence == nil { + return agg, nil + } + agg.skipLegacyStats = !opts.Signals.EmitPolicyViolationRate && !opts.Signals.EmitProtocolAdherence + return agg, newActivityAggregator(opts.Agents, opts.Window, opts.Activity) +} + +func eventConsumers(agg *aggregator, activity *activityAggregator) []eventConsumer { + if activity == nil { + return []eventConsumer{agg} + } + return []eventConsumer{agg, activity} +} + +func writeActivityEvidence(writer io.Writer, evidence []ActivityEvidence) error { + if writer == nil { + return nil + } + encoder := json.NewEncoder(writer) + for _, item := range evidence { + if err := encoder.Encode(item); err != nil { + return fmt.Errorf("hydrator: write activity evidence: %w", err) + } + } + return nil +} + +func consume(ctx context.Context, reader *Reader, consumers ...eventConsumer) error { for { if err := ctx.Err(); err != nil { return fmt.Errorf("hydrator: %w", err) @@ -162,7 +192,9 @@ func consume(ctx context.Context, reader *Reader, agg *aggregator) error { if err != nil { return err } - agg.add(ev) + for _, consumer := range consumers { + consumer.add(ev) + } } } @@ -186,28 +218,18 @@ func buildObservations(agg *aggregator, window Window, opts Options, report *Rep return nil, err } if evidence, ok := activity[id]; ok && builder.signals.EmitActivity && evidence.Score != nil { - value := struct { - Score int `json:"score"` - Explanation string `json:"explanation"` - }{Score: *evidence.Score, Explanation: activityExplanation(evidence)} - row, err := builder.observation(id, builder.signals.ActivityID, value) + row, err := builder.activity(id, evidence) if err != nil { return nil, err } obs = append(obs, row) } observations = append(observations, obs...) - denied := st.denied - if agg.skipLegacyStats { - for _, counts := range activity[id].Measurements.groups() { - denied += counts.Denied - } - } report.Agents = append(report.Agents, AgentSummary{ AgentID: id, AnsName: st.mapping.AnsName, Events: st.events, - Denied: denied, + Denied: st.denied, Observations: len(obs), }) } From 5b774d7e329897fb971a83defb3f31deb210b999 Mon Sep 17 00:00:00 2001 From: chou-godaddy Date: Mon, 26 Oct 2026 11:34:52 -0700 Subject: [PATCH 3/3] fix(hydrator): close gaps found in live policy adherence run A local run against OpenShell v0.1.5 and agent-trust-discovery showed that the activity log hid audited violations, that an allowed upstream 503 had no regression test, and that a mid-window policy change was invisible to the Trust Index because policy context stayed in local evidence. Evidence now reports policyChanged, and score explanations name the policies behind the counts. Also clears goconst findings reported by a clean golangci-lint cache and documents that rerunning the same window does not replace a stored score. Signed-off-by: chou-godaddy --- cmd/ans-ocsf-hydrator/activity_test.go | 6 +- cmd/ans-ocsf-hydrator/main.go | 2 +- docs/hydrator-activity.md | 13 ++++- internal/hydrator/activity.go | 33 +++++++++++ internal/hydrator/activity_event.go | 46 +++++++++------ internal/hydrator/activity_score.go | 15 ++++- internal/hydrator/activity_test.go | 78 ++++++++++++++++++++++---- internal/hydrator/activity_types.go | 1 + 8 files changed, 159 insertions(+), 35 deletions(-) diff --git a/cmd/ans-ocsf-hydrator/activity_test.go b/cmd/ans-ocsf-hydrator/activity_test.go index 576eb13..9749c2a 100644 --- a/cmd/ans-ocsf-hydrator/activity_test.go +++ b/cmd/ans-ocsf-hydrator/activity_test.go @@ -372,7 +372,7 @@ func TestLogActivity(t *testing.T) { t.Parallel() var output bytes.Buffer logger := slog.New(slog.NewJSONHandler(&output, nil)) - measurements := hydrator.ActivityMeasurements{HTTPInitial: hydrator.DecisionCounts{Allowed: 4, Denied: 1}, Connections: hydrator.DecisionCounts{Denied: 1}} + measurements := hydrator.ActivityMeasurements{HTTPInitial: hydrator.DecisionCounts{Allowed: 4, Denied: 1, Audited: 3}, Connections: hydrator.DecisionCounts{Denied: 1}} logActivity(context.Background(), logger, []hydrator.ActivityEvidence{{AgentID: "probe", Score: tt.score, SampleSize: 6, Measurements: measurements, Unavailable: tt.unavailable}}) var record map[string]json.RawMessage if err := json.Unmarshal(output.Bytes(), &record); err != nil { @@ -382,8 +382,8 @@ func TestLogActivity(t *testing.T) { if tt.score != nil { wantScore = "67" } - if string(record["score"]) != wantScore || string(record["sample_size"]) != "6" || string(record["denied"]) != "2" { - t.Fatalf("score/sample/denied not logged: %s", output.Bytes()) + if string(record["score"]) != wantScore || string(record["sample_size"]) != "6" || string(record["denied"]) != "2" || string(record["audited"]) != "3" { + t.Fatalf("score/sample/denied/audited not logged: %s", output.Bytes()) } var reason string if err := json.Unmarshal(record["unavailable_reason"], &reason); err != nil || reason != tt.unavailable { diff --git a/cmd/ans-ocsf-hydrator/main.go b/cmd/ans-ocsf-hydrator/main.go index 9e8eb28..02c2692 100644 --- a/cmd/ans-ocsf-hydrator/main.go +++ b/cmd/ans-ocsf-hydrator/main.go @@ -374,6 +374,6 @@ func logActivity(ctx context.Context, logger *slog.Logger, evidence []hydrator.A } logger.Log(ctx, level, "activity summary", "agent_id", item.AgentID, "score", score, "sample_size", item.SampleSize, "denied", item.Measurements.Denied(), - "unavailable_reason", item.Unavailable) + "audited", item.Measurements.Audited(), "unavailable_reason", item.Unavailable) } } diff --git a/docs/hydrator-activity.md b/docs/hydrator-activity.md index f6c5c17..94d2949 100644 --- a/docs/hydrator-activity.md +++ b/docs/hydrator-activity.md @@ -47,7 +47,11 @@ Keep the signal at weight 0: its score is visible but does not affect trust decisions. **Do not use it in real trust decisions until the consumer can expire or otherwise invalidate old scores.** If the hydrator sends no new score, the reference implementation retains its previous score indefinitely, including 100. -A later clean measurement window can replace an earlier low score. This producer +A later clean measurement window can replace an earlier low score. Each +observation is stamped with its window end, and the reference implementation +ignores a repeat for the same agent, signal, and timestamp, so rerunning the same +window (for example after a configuration fix) does not replace the stored score; +only a later window does. This producer change adds neither consumer expiry nor a new scoreless-report API. ## Enable the new producer output @@ -80,6 +84,13 @@ diagnostics independently of the imported score. Findings do not change the scor eligible score explanations summarize them. Finding-only windows stay in this local output without inventing a numeric score. +Policy context lists each policy hash and version seen for the agent's +sandboxes. `policyChanged` is true when a sandbox loaded a policy after its +first counted decision, so the counts may span more than one policy; the score +is still sent. Score explanations name up to four policies by their first 12 +hash characters and say when a policy changed, so scores are compared only under +the same policy. OpenShell's reload record carries a hash but no version. + Unknown policy syntax, incomplete interpretation, and exhausted deduplication budgets suppress the affected agent's new score and record the reason. Other agents continue. A record whose message states a policy decision but that is diff --git a/internal/hydrator/activity.go b/internal/hydrator/activity.go index d8da288..6b6290e 100644 --- a/internal/hydrator/activity.go +++ b/internal/hydrator/activity.go @@ -401,6 +401,7 @@ func (s *activityStats) evidence(id string, window Window, minimum int, input au Diagnostics: diagnostics, Policies: policies, PolicyUnknown: !s.hasPolicyContext(policies), + PolicyChanged: s.policyChanged(policies), SampleSize: sample, Score: score, Unavailable: reason, @@ -427,6 +428,15 @@ func (s *activityStats) hasPolicyContext(policies []PolicyContext) bool { return true } +// policyChanged reports whether a sandbox loaded a policy after its first +// counted decision, so the counts may span more than one policy. +func (s *activityStats) policyChanged(policies []PolicyContext) bool { + return slices.ContainsFunc(policies, func(p PolicyContext) bool { + first, counted := s.firstDecision[p.Sandbox] + return counted && p.ObservedAt.UnixMilli() > first + }) +} + func activitySandbox(ev Event) string { if ev.Container == nil { return "" @@ -462,6 +472,7 @@ func activityExplanation(ev ActivityEvidence) string { if ev.PolicyUnknown { out.WriteString(" Policy context unknown.") } + out.WriteString(activityPolicySummary(ev)) // Bound the explanation; full finding details remain in the separate structured output. const summaryLimit = 8 // Middleware and type come from audit records; quoting keeps them from reading as explanation text. @@ -474,3 +485,25 @@ func activityExplanation(ev ActivityEvidence) string { } return out.String() } + +// activityPolicySummary names the policies behind the counts, so consumers +// compare scores only under the same policy. +func activityPolicySummary(ev ActivityEvidence) string { + const ( + policyLimit = 4 + shortHashLength = 12 + ) + var out strings.Builder + if ev.PolicyChanged { + out.WriteString(" A policy was loaded after counting began; counts may span more than one policy.") + } + // Hashes and versions come from audit records; quoting keeps them from reading as explanation text. + for _, p := range ev.Policies[:min(len(ev.Policies), policyLimit)] { + hash := strings.ToValidUTF8(p.Hash[:min(len(p.Hash), shortHashLength)], "") + fmt.Fprintf(&out, " Policy hash=%q version=%q.", hash, p.Version) + } + if len(ev.Policies) > policyLimit { + fmt.Fprintf(&out, " %d additional policies in local evidence.", len(ev.Policies)-policyLimit) + } + return out.String() +} diff --git a/internal/hydrator/activity_event.go b/internal/hydrator/activity_event.go index f9cbe9f..2928f5f 100644 --- a/internal/hydrator/activity_event.go +++ b/internal/hydrator/activity_event.go @@ -40,6 +40,13 @@ const ( minimumJSONRPCTokens = 3 ) +const ( + connectKeyword = "CONNECT" + connectL7Keyword = "CONNECT_L7" + jsonRPCRequestKeyword = "JSONRPC_L7_REQUEST" + transparentTCPAllowed = "transparent_tcp_allowed" +) + type classifiedActivity struct { Group activityGroup Decision policyDecision @@ -110,11 +117,11 @@ func (r activityRecord) statesDecision() bool { if _, isL7 := l7DecisionGroup(r.tokens); isL7 { return true } - if len(r.tokens) >= 2 && (r.tokens[0] == "CONNECT" || r.tokens[0] == "CONNECT_L7") { - return r.tokens[1] == "allowed" || r.tokens[1] == "denied" + if connectDecision(r.tokens) != decisionUnknown { + return true } return strings.HasPrefix(r.Message, "Transparent TCP denied") || - strings.HasPrefix(r.Message, "Transparent TCP ") && r.StatusDetail == "transparent_tcp_allowed" + strings.HasPrefix(r.Message, "Transparent TCP ") && r.StatusDetail == transparentTCPAllowed } func (r activityRecord) withoutDecisionEnums() bool { @@ -287,7 +294,7 @@ func l7DecisionGroup(tokens []string) (activityGroup, bool) { return "", false } switch tokens[0] { - case "L7_REQUEST", "GRAPHQL_L7_REQUEST", "JSONRPC_L7_REQUEST": + case "L7_REQUEST", "GRAPHQL_L7_REQUEST", jsonRPCRequestKeyword: return groupHTTPInitial, true case "L7_REQUEST_TRANSFORMED": return groupPostRewrite, true @@ -312,7 +319,7 @@ func classifyL7Decision(record activityRecord) (classifiedActivity, bool) { return unknownActivity(group), true } token := parts[1] - if parts[0] == "JSONRPC_L7_REQUEST" { + if parts[0] == jsonRPCRequestKeyword { if !strings.HasPrefix(token, "decision=") || activityMessageValue(record.Message, "http_method") == "" || activityMessageValue(record.Message, "endpoint") == "" { return unknownActivity(group), true } @@ -369,29 +376,36 @@ func classifyConnectionActivity(record activityRecord) classifiedActivity { } return unknownActivity(groupConnections) } - parts := record.tokens decision := decisionUnknown - if len(parts) >= minimumJSONRPCTokens && (parts[0] == "CONNECT" || parts[0] == "CONNECT_L7") { - switch parts[1] { - case "allowed": - decision = decisionAllowed - case "denied": - decision = decisionDenied - } - } else if strings.HasPrefix(record.Message, "Transparent TCP ") { + if len(record.tokens) >= minimumJSONRPCTokens { + decision = connectDecision(record.tokens) + } + if decision == decisionUnknown && strings.HasPrefix(record.Message, "Transparent TCP ") { decision = transparentPolicyDecision(record.Event) } if decision != decisionUnknown && decisionEnumsMatch(record.Event, decision) { return classifiedActivity{Group: groupConnections, Decision: decision} } - if record.withoutDecisionEnums() && !strings.HasPrefix(record.Message, "CONNECT") && !strings.HasPrefix(record.Message, "Transparent TCP ") { + if record.withoutDecisionEnums() && !strings.HasPrefix(record.Message, connectKeyword) && !strings.HasPrefix(record.Message, "Transparent TCP ") { return classifiedActivity{Diagnostic: "network_no_policy_decision"} } return unknownActivity(groupConnections) } +// connectDecision returns the decision stated by a CONNECT or CONNECT_L7 +// message, or decisionUnknown for any other message. +func connectDecision(tokens []string) policyDecision { + if len(tokens) < 2 || (tokens[0] != connectKeyword && tokens[0] != connectL7Keyword) { + return decisionUnknown + } + if decision := policyDecision(tokens[1]); decision == decisionAllowed || decision == decisionDenied { + return decision + } + return decisionUnknown +} + func transparentPolicyDecision(ev Event) policyDecision { - if ev.StatusDetail == "transparent_tcp_allowed" && !strings.HasPrefix(ev.Message, "Transparent TCP denied") { + if ev.StatusDetail == transparentTCPAllowed && !strings.HasPrefix(ev.Message, "Transparent TCP denied") { return decisionAllowed } if strings.HasPrefix(ev.Message, "Transparent TCP denied") { diff --git a/internal/hydrator/activity_score.go b/internal/hydrator/activity_score.go index 0ebaeb9..363f888 100644 --- a/internal/hydrator/activity_score.go +++ b/internal/hydrator/activity_score.go @@ -53,11 +53,20 @@ func (m ActivityMeasurements) groups() []DecisionCounts { // Denied returns the denied policy decisions across all measurement groups. func (m ActivityMeasurements) Denied() int { - denied := 0 + return m.sum(func(counts DecisionCounts) int { return counts.Denied }) +} + +// Audited returns the audited policy decisions across all measurement groups. +func (m ActivityMeasurements) Audited() int { + return m.sum(func(counts DecisionCounts) int { return counts.Audited }) +} + +func (m ActivityMeasurements) sum(count func(DecisionCounts) int) int { + total := 0 for _, counts := range m.groups() { - denied += counts.Denied + total += count(counts) } - return denied + return total } func assessActivity(m ActivityMeasurements, minimum int, unavailable string) (*int, string, int) { diff --git a/internal/hydrator/activity_test.go b/internal/hydrator/activity_test.go index d271ae3..cece961 100644 --- a/internal/hydrator/activity_test.go +++ b/internal/hydrator/activity_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "strconv" "strings" "testing" @@ -264,6 +265,28 @@ func TestActivityConnectionOnlyTraffic(t *testing.T) { } } +// OpenShell v0.1.5 logs the L7 decision before forwarding, so an upstream 503 +// appears only as an allowed request with no status or response fields. +func TestActivityAllowedUpstreamFailure(t *testing.T) { + t.Parallel() + requests := []string{"r1", "r2", "r3"} + lines := make([]string, 0, 2*len(requests)) + for _, uid := range requests { + lines = append(lines, + activityInput(t, uid+"-connect", "CONNECT_L7 allowed httpbingo.org:443", 4001, 1, 1), + activityInput(t, uid+"-request", "L7_REQUEST allow GET httpbingo.org:443/status/503 reason=", 4002, 99, 1)) + } + report, err := Run(t.Context(), strings.NewReader(strings.Join(lines, "\n")), &fakeSink{}, activityOptions(t), nil) + if err != nil { + t.Fatal(err) + } + evidence := report.Activity[0] + want := ActivityMeasurements{HTTPInitial: DecisionCounts{Allowed: 3}, Connections: DecisionCounts{Allowed: 3}} + if evidence.Measurements != want || evidence.Score == nil || *evidence.Score != 100 { + t.Fatalf("allowed upstream failures counted against the agent: %+v", evidence) + } +} + func TestActivityDegradedAgentSkippedFromBatch(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -311,7 +334,7 @@ func TestActivityDegradedAgentSkippedFromBatch(t *testing.T) { } } -func TestActivityMeasurementsDenied(t *testing.T) { +func TestActivityMeasurementsTotals(t *testing.T) { t.Parallel() measurements := ActivityMeasurements{ HTTPInitial: DecisionCounts{Allowed: 9, Denied: 1}, @@ -322,6 +345,9 @@ func TestActivityMeasurementsDenied(t *testing.T) { if got := measurements.Denied(); got != 10 { t.Fatalf("Denied()=%d, want 10", got) } + if got := measurements.Audited(); got != 5 { + t.Fatalf("Audited()=%d, want 5", got) + } } func TestActivityObservationRequiresScore(t *testing.T) { @@ -441,35 +467,65 @@ func TestActivityPolicyContext(t *testing.T) { t.Parallel() for _, tc := range []struct { name string - policyTime int64 + policyTimes []int64 wantUnknown bool + wantChanged bool }{ - {name: "policy before window", policyTime: sampleBaseMs - 1000}, - {name: "policy after decision", policyTime: sampleBaseMs + 1000, wantUnknown: true}, + {name: "policy before window", policyTimes: []int64{sampleBaseMs - 1000}}, + {name: "policy after decision", policyTimes: []int64{sampleBaseMs + 1000}, wantUnknown: true, wantChanged: true}, + {name: "policy changed mid window", policyTimes: []int64{sampleBaseMs - 1000, sampleBaseMs + 1000}, wantChanged: true}, {name: "missing policy", wantUnknown: true}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() opts := activityOptions(t) opts.Window = &Window{Start: time.UnixMilli(sampleBaseMs), End: time.UnixMilli(sampleBaseMs + 2000)} - input := activityInput(t, "request", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1) - if tc.policyTime != 0 { - input += "\n" + jsonLine(t, map[string]any{"class_uid": 5019, "time": tc.policyTime, "container": map[string]any{"uid": sandboxAUID}, "unmapped": map[string]any{"policy_hash": "test-policy", "version": 1}}) + lines := make([]string, 0, 1+len(tc.policyTimes)) + lines = append(lines, activityInput(t, "request", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1)) + for i, policyTime := range tc.policyTimes { + lines = append(lines, jsonLine(t, map[string]any{"class_uid": 5019, "time": policyTime, "container": map[string]any{"uid": sandboxAUID}, "unmapped": map[string]any{"policy_hash": "policy-" + strconv.Itoa(i), "version": 1}})) } - report, err := Run(t.Context(), strings.NewReader(input), &fakeSink{}, opts, nil) + report, err := Run(t.Context(), strings.NewReader(strings.Join(lines, "\n")), &fakeSink{}, opts, nil) if err != nil { t.Fatal(err) } - if report.Activity[0].PolicyUnknown != tc.wantUnknown { - t.Fatalf("policy=%+v", report.Activity[0]) + evidence := report.Activity[0] + if evidence.PolicyUnknown != tc.wantUnknown || evidence.PolicyChanged != tc.wantChanged { + t.Fatalf("policy=%+v", evidence) } - if tc.policyTime != 0 && len(report.Activity[0].Policies) != 1 { + if len(evidence.Policies) != len(tc.policyTimes) { t.Fatal("policy context lost") } + explanation := activityExplanation(evidence) + if strings.Contains(explanation, "loaded after counting began") != tc.wantChanged { + t.Fatalf("policy change not explained: %s", explanation) + } + for _, policy := range evidence.Policies { + if !strings.Contains(explanation, fmt.Sprintf("hash=%q version=%q", policy.Hash, policy.Version)) { + t.Fatalf("policy %q missing from explanation: %s", policy.Hash, explanation) + } + } }) } } +func TestActivityPolicySummaryBounds(t *testing.T) { + t.Parallel() + const policyCount = 5 + policies := make([]PolicyContext, 0, policyCount) + for i := range policyCount { + policies = append(policies, PolicyContext{Hash: strings.Repeat("a", 64) + strconv.Itoa(i), Version: strconv.Itoa(i)}) + } + score := 100 + explanation := activityExplanation(ActivityEvidence{Score: &score, Policies: policies}) + if strings.Count(explanation, " Policy hash=") != 4 || !strings.Contains(explanation, "1 additional policies in local evidence") { + t.Fatalf("policy summary not bounded: %s", explanation) + } + if !strings.Contains(explanation, `hash="aaaaaaaaaaaa" version="0"`) { + t.Fatalf("policy hash not shortened: %s", explanation) + } +} + func TestActivityFindingsDoNotChangeScore(t *testing.T) { t.Parallel() for _, tc := range []struct { diff --git a/internal/hydrator/activity_types.go b/internal/hydrator/activity_types.go index 0d51f81..fc308b1 100644 --- a/internal/hydrator/activity_types.go +++ b/internal/hydrator/activity_types.go @@ -63,6 +63,7 @@ type ActivityEvidence struct { Diagnostics map[string]int `json:"diagnostics"` Policies []PolicyContext `json:"policies"` PolicyUnknown bool `json:"policyUnknown"` + PolicyChanged bool `json:"policyChanged"` SampleSize int `json:"sampleSize"` Score *int `json:"score"` Unavailable string `json:"unavailableReason,omitempty"`