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..9749c2a --- /dev/null +++ b/cmd/ans-ocsf-hydrator/activity_test.go @@ -0,0 +1,398 @@ +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)) + 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 { + t.Fatal(err) + } + wantScore := "null" + if tt.score != nil { + wantScore = "67" + } + 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 { + 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..02c2692 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,21 @@ 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.LevelInfo + if item.Degraded() { + level = slog.LevelWarn + } + logger.Log(ctx, level, "activity summary", "agent_id", item.AgentID, + "score", score, "sample_size", item.SampleSize, "denied", item.Measurements.Denied(), + "audited", item.Measurements.Audited(), "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..86caffe 100644 --- a/deploy/helm/ans-openshell/templates/hydrator.yaml +++ b/deploy/helm/ans-openshell/templates/hydrator.yaml @@ -45,6 +45,13 @@ spec: - --window-last - {{ . | 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 }} {{- 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..adc0314 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: "" # "-" 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: [] @@ -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..0a752f7 --- /dev/null +++ b/deploy/hydrator_activity_test.go @@ -0,0 +1,81 @@ +package deploy_test + +import ( + "os/exec" + "slices" + "strings" + "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: "-"}, + } + 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) + } + }) + } +} + +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 new file mode 100644 index 0000000..94d2949 --- /dev/null +++ b/docs/hydrator-activity.md @@ -0,0 +1,167 @@ +# 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. 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 + +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. + +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 +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. + +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. + +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 +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. 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 new file mode 100644 index 0000000..6b6290e --- /dev/null +++ b/internal/hydrator/activity.go @@ -0,0 +1,509 @@ +package hydrator + +import ( + "cmp" + "encoding/json" + "fmt" + "maps" + "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 { + // 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 + } + 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 == activityDNSRefused || route.Group == groupConnections && route.Decision == decisionDenied { + s.recordDenialCoverage(ev, route.Diagnostic == activityDNSRefused) + } +} + +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} + } +} + +// 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 { + ids = append(ids, id) + } + } + slices.Sort(ids) + out := make([]ActivityEvidence, 0, len(ids)) + for _, id := range ids { + out = append(out, a.stats[id].evidence(id, window, a.config.MinDecisions, input)) + } + return out +} + +// 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 == "" { + 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) + } + 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, 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), + PolicyChanged: s.policyChanged(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 +} + +// 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 "" + } + 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.") + } + 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. + for _, f := range ev.Findings[:min(len(ev.Findings), summaryLimit)] { + 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) + } + 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 new file mode 100644 index 0000000..2928f5f --- /dev/null +++ b/internal/hydrator/activity_event.go @@ -0,0 +1,491 @@ +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 +) + +const ( + connectKeyword = "CONNECT" + connectL7Keyword = "CONNECT_L7" + jsonRPCRequestKeyword = "JSONRPC_L7_REQUEST" + transparentTCPAllowed = "transparent_tcp_allowed" +) + +type classifiedActivity struct { + Group activityGroup + Decision policyDecision + Diagnostic string + Incomplete bool + Finding *ActivityFinding + 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 { + return classifyActivityFinding(ev, unmapped, valid) + } + if !valid { + 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 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 connectDecision(r.tokens) != decisionUnknown { + return true + } + return strings.HasPrefix(r.Message, "Transparent TCP denied") || + strings.HasPrefix(r.Message, "Transparent TCP ") && r.StatusDetail == transparentTCPAllowed +} + +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) { + 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 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 + default: + return classifiedActivity{}, false + } +} + +func classifyHTTPMiddleware(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassHTTPActivity || !strings.HasPrefix(record.Message, "MIDDLEWARE ") { + return classifiedActivity{}, false + } + failed, valid := activityFailed(record.unmapped) + if !valid { + return classifiedActivity{Diagnostic: "invalid_metadata", Incomplete: true}, true + } + if failed { + return classifiedActivity{Diagnostic: "middleware_failure"}, true + } + decision := activityMessageValue(record.Message, "decision") + if decision != "Allow" && decision != "Deny" { + return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true + } + if decision == "Deny" || record.deniedEnum() { + return classifiedActivity{Diagnostic: "unclassified_middleware_denial", Incomplete: true}, true + } + return classifiedActivity{Diagnostic: "middleware_stage"}, true +} + +func classifyWebSocketMiddleware(record activityRecord) (classifiedActivity, bool) { + if record.ClassUID != ClassNetworkActivity || !strings.HasPrefix(record.Message, "WEBSOCKET_MIDDLEWARE ") { + return classifiedActivity{}, false + } + if len(record.tokens) < middlewareTokenCount { + return classifiedActivity{Diagnostic: "unknown_middleware_decision", Incomplete: true}, true + } + 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 record.deniedEnum() { + 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 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 { + 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 +} + +// 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 + } + switch tokens[0] { + case "L7_REQUEST", "GRAPHQL_L7_REQUEST", jsonRPCRequestKeyword: + return groupHTTPInitial, true + case "L7_REQUEST_TRANSFORMED": + return groupPostRewrite, true + case "WEBSOCKET_L7_REQUEST": + return groupWebSocket, true + default: + return "", false + } +} + +func classifyL7Decision(record activityRecord) (classifiedActivity, bool) { + group, ok := l7DecisionGroup(record.tokens) + if !ok { + return classifiedActivity{}, false + } + 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] == jsonRPCRequestKeyword { + if !strings.HasPrefix(token, "decision=") || activityMessageValue(record.Message, "http_method") == "" || activityMessageValue(record.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(record.Event, 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) +} + +// 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 record.ClassUID != ClassNetworkActivity { + return classifiedActivity{Diagnostic: "other"} + } + if record.ActivityID == policyActivityOther { + return unknownActivity(groupWebSocket) + } + if record.ActivityID != networkActivityOpen { + if record.withoutDecisionEnums() { + return classifiedActivity{Diagnostic: "network_no_policy_decision"} + } + return unknownActivity(groupConnections) + } + decision := decisionUnknown + 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, 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 == transparentTCPAllowed && !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..776ba3c --- /dev/null +++ b/internal/hydrator/activity_event_test.go @@ -0,0 +1,402 @@ +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}}, + {"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"}}, + {"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"}}, + {"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) { + t.Parallel() + if got := classifyActivity(tt.ev); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("classifyActivity() = %+v, want %+v", got, tt.want) + } + }) + } +} + +// 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 { + 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 {"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) { + 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..7bed11d --- /dev/null +++ b/internal/hydrator/activity_limits_test.go @@ -0,0 +1,350 @@ +package hydrator + +import ( + "encoding/json" + "math" + "reflect" + "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 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 { + 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, 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") + } + }) + } +} + +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, auditInputHealth{}) + 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 + truncatedTail int + }{ + {name: "ordinary stderr", extra: "ordinary supervisor log\n"}, + {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() + 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) + } + 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_score.go b/internal/hydrator/activity_score.go new file mode 100644 index 0000000..363f888 --- /dev/null +++ b/internal/hydrator/activity_score.go @@ -0,0 +1,104 @@ +package hydrator + +import ( + "errors" + "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 ( + 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} +} + +// Denied returns the denied policy decisions across all measurement groups. +func (m ActivityMeasurements) Denied() int { + 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() { + total += count(counts) + } + return total +} + +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, activityInsufficientSample, sample + } + continue + } + eligible = true + lowest = min(lowest, fullAdherence*float64(counts.Allowed)/float64(n)) + } + if !eligible { + 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 new file mode 100644 index 0000000..cece961 --- /dev/null +++ b/internal/hydrator/activity_test.go @@ -0,0 +1,555 @@ +package hydrator + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "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 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) + } +} + +// 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 { + 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 TestActivityMeasurementsTotals(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) + } + if got := measurements.Audited(); got != 5 { + t.Fatalf("Audited()=%d, want 5", 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 { + 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 + 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) + } + // 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 + policyTimes []int64 + wantUnknown bool + wantChanged bool + }{ + {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)} + 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(strings.Join(lines, "\n")), &fakeSink{}, opts, nil) + if err != nil { + t.Fatal(err) + } + evidence := report.Activity[0] + if evidence.PolicyUnknown != tc.wantUnknown || evidence.PolicyChanged != tc.wantChanged { + t.Fatalf("policy=%+v", evidence) + } + 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 { + 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..fc308b1 --- /dev/null +++ b/internal/hydrator/activity_types.go @@ -0,0 +1,82 @@ +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"` + PolicyChanged bool `json:"policyChanged"` + SampleSize int `json:"sampleSize"` + 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 f9d0bf7..93e0109 100644 --- a/internal/hydrator/aggregate.go +++ b/internal/hydrator/aggregate.go @@ -92,15 +92,18 @@ 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 + // skipLegacyStats counts attributed events without accumulating the legacy + // observation payload, when no legacy signal will be emitted. + 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 { @@ -131,7 +134,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..4753a74 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,20 @@ 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 + 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. @@ -154,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. @@ -171,6 +193,7 @@ func (r *Reader) Read() (Event, error) { } ev, ok := decodeLine(line) if !ok { + r.countUndecodable(line) r.stats.Skipped++ continue } @@ -196,6 +219,9 @@ func (r *Reader) readLine() ([]byte, bool, error) { } if !oversized { if len(r.line)+len(chunk) > MaxLineBytes { + if looksLikeAudit(r.line) { + r.oversizedOCSF++ + } oversized = true r.line = r.line[:0] } else { @@ -212,6 +238,34 @@ 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 { + 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 // 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..ad62040 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 } @@ -88,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 @@ -140,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 9e5f739..244a611 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. @@ -38,16 +43,20 @@ 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 } // 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,13 +83,16 @@ 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) - err := consume(ctx, reader, agg) + agg, activity := newAggregators(opts) + err := consume(ctx, reader, eventConsumers(agg, activity)...) report := Report{ Parse: reader.Stats(), Attributed: agg.attributed, @@ -92,6 +104,12 @@ func Run(ctx context.Context, in io.Reader, sink Sink, opts Options, logger *slo if err != nil { return report, 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", "lines", report.Parse.Lines, "events", report.Parse.Events, "skipped", report.Parse.Skipped, "oversized", report.Parse.Oversized) @@ -125,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) @@ -137,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) + } } } @@ -148,6 +205,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,6 +217,13 @@ 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 { + row, err := builder.activity(id, evidence) + if err != nil { + return nil, err + } + obs = append(obs, row) + } observations = append(observations, obs...) report.Agents = append(report.Agents, AgentSummary{ AgentID: id,