diff --git a/cmd/openwatch/main.go b/cmd/openwatch/main.go index b8afb253..33934eee 100644 --- a/cmd/openwatch/main.go +++ b/cmd/openwatch/main.go @@ -634,7 +634,13 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int { WithConnectivityConfig(cfgStore, liveSvc). WithDiscovery(discoSvc). WithEventBus(bus). - WithActivity(activity.NewService(pool)). + WithActivity(activity.NewService(pool).WithRuleTitler(func(ruleID string) (string, bool) { + if ruleCatalog == nil { + return "", false + } + m, ok := ruleCatalog.Get(ruleID) + return m.Title, ok + })). WithAlerts(alerts.NewService(pool, audit.Emit)). WithScanQueue(scanQueueKey). WithScanWorker(scanWorker). diff --git a/internal/activity/format.go b/internal/activity/format.go new file mode 100644 index 00000000..da165b4f --- /dev/null +++ b/internal/activity/format.go @@ -0,0 +1,370 @@ +package activity + +import ( + "encoding/json" + "strings" +) + +// Human-readable rendering for the three feed legs that otherwise emit raw +// machine codes as their title (compliance/transactions, intelligence, and +// audit). The alert and monitoring legs already build sentences in SQL and +// are left untouched. Spec system-activity v1.2.0 (C-09). +// +// Every formatter degrades gracefully: an unmapped code is humanized +// structurally (dots/underscores -> spaces, capitalized) so a new event +// code can never leak to the UI as a raw dotted enum. + +// RuleTitleFunc resolves a Kensa rule id to its catalog title. Injected by +// the server from the rule catalog so this package takes no kensa +// dependency. Nil-safe: a nil func (or a miss) falls back to the rule id. +type RuleTitleFunc func(ruleID string) (title string, ok bool) + +// formatTransaction renders a compliance state-change row. The transactions +// table records the NEW status + the change_kind (it does not retain the +// prior status), so the summary says "now ", never "X -> Y". +func formatTransaction(ruleID, status, changeKind string, titler RuleTitleFunc) (title, summary string) { + title = ruleID + if titler != nil { + if t, ok := titler(ruleID); ok && t != "" { + title = t + } + } + st := statusWord(status) + switch changeKind { + case "first_seen": + summary = "First seen: " + st + case "severity_changed": + summary = "Severity changed (now " + st + ")" + case "state_changed": + summary = "Changed: now " + st + default: + summary = st + } + return title, summary +} + +// formatIntelligence renders an OS-intelligence diff row. The title comes +// from the event-code registry (or the humanized code); the summary is +// extracted generically from the detail JSONB (subject + optional from->to). +func formatIntelligence(eventCode string, detail []byte) (title, summary string) { + title = intelTitles[eventCode] + if title == "" { + title = humanizeCode(eventCode) + } + return title, intelSummary(detail) +} + +// formatAudit renders an audit row as " ". The actor is +// the recorded actor_label, falling back to a readable actor_type. The raw +// resource_id (a UUID) is intentionally NOT placed in the title; the +// resource_type provides lightweight context in the summary. +func formatAudit(action, actorLabel, actorType, resourceType string) (title, summary string) { + actor := strings.TrimSpace(actorLabel) + if actor == "" { + actor = actorWord(actorType) + } + pred, ok := auditPredicates[action] + if !ok { + pred = strings.ToLower(humanizeCode(action)) + } + title = actor + " " + pred + if resourceType != "" { + summary = titleCaseWord(resourceType) + } + return title, summary +} + +// ---- helpers ---- + +func statusWord(status string) string { + switch status { + case "pass": + return "Pass" + case "fail": + return "Fail" + case "skipped": + return "Skipped" + case "error": + return "Error" + default: + return titleCaseWord(status) + } +} + +func actorWord(actorType string) string { + switch actorType { + case "system": + return "System" + case "scheduler": + return "The scheduler" + case "api_key", "api_token": + return "An API token" + case "agent": + return "An agent" + case "user": + return "A user" + default: + if actorType == "" { + return "Someone" + } + return titleCaseWord(actorType) + } +} + +// humanizeCode turns a dotted/underscored code into a capitalized phrase +// ("account.user.created" -> "Account user created"). The safety net that +// guarantees no raw code ever reaches the UI. +func humanizeCode(code string) string { + s := strings.TrimSpace(strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(code)) + if s == "" { + return "Activity" + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// titleCaseWord capitalizes a single token, replacing separators with +// spaces ("scan_template" -> "Scan template"). +func titleCaseWord(w string) string { + s := strings.TrimSpace(strings.NewReplacer("_", " ", "-", " ").Replace(w)) + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// intelSummary builds a concise phrase from an intelligence event's detail +// JSONB: a subject (the first present of a set of common keys) plus an +// optional "from -> to" transition. Returns "" when nothing useful is found. +func intelSummary(detail []byte) string { + if len(detail) == 0 { + return "" + } + var m map[string]any + if err := json.Unmarshal(detail, &m); err != nil { + return "" + } + subject := firstStringField(m, "name", "package", "service", "unit", + "username", "user", "account", "path", "file", "interface", "port", "rule") + from := stringField(m["from"]) + to := stringField(m["to"]) + switch { + case subject != "" && from != "" && to != "": + return subject + ": " + from + " → " + to + case subject != "" && to != "": + return subject + " → " + to + case subject != "": + return subject + case from != "" && to != "": + return from + " → " + to + default: + return "" + } +} + +func firstStringField(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + if s := stringField(v); s != "" { + return s + } + } + } + return "" +} + +// stringField renders a JSON scalar as a short string. Non-scalars (objects, +// arrays) return "" so they never dump structure into a summary line. +func stringField(v any) string { + switch t := v.(type) { + case string: + return t + case bool: + if t { + return "true" + } + return "false" + case float64: + // Integers render without a trailing ".0"; keep it simple. + if t == float64(int64(t)) { + return itoa64(int64(t)) + } + b, _ := json.Marshal(t) + return string(b) + default: + return "" + } +} + +func itoa64(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [24]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// intelTitles maps each host_intelligence_events.event_code to a readable +// headline. Unmapped codes fall back to humanizeCode. +var intelTitles = map[string]string{ + "account.user.locked": "User account locked", + "account.user.unlocked": "User account unlocked", + "account.user.created": "User account created", + "account.user.deleted": "User account deleted", + "account.user.privileged_group_added": "User added to a privileged group", + "account.password.expired": "Password expired", + "account.password.expiring": "Password expiring soon", + "account.ssh_key.added": "SSH key added", + "account.ssh_key.removed": "SSH key removed", + "account.sudo.failure_threshold": "Repeated sudo failures", + "security.login.new_source_ip": "Login from a new source IP", + "security.login.failed_threshold": "Repeated failed logins", + "security.selinux.denied": "SELinux denial", + "security.apparmor.denied": "AppArmor denial", + "security.firewall.rule_changed": "Firewall rule changed", + "security.port.opened": "Network port opened", + "system.package.installed": "Package installed", + "system.package.updated": "Package updated", + "system.package.removed": "Package removed", + "system.kernel.updated": "Kernel updated", + "system.reboot.required": "Reboot required", + "system.reboot.completed": "Reboot completed", + "system.config.file_changed": "Config file changed", + "system.service.started": "Service started", + "system.service.stopped": "Service stopped", + "system.service.failed": "Service failed", + "system.filesystem.mounted": "Filesystem mounted", + "system.filesystem.unmounted": "Filesystem unmounted", +} + +// auditPredicates maps each audit action code to a verb phrase that reads +// naturally after the actor (" "). Unmapped codes fall +// back to a lowercased humanizeCode. +var auditPredicates = map[string]string{ + // auth + "auth.login.success": "signed in", + "auth.login.failure": "failed to sign in", + "auth.logout": "signed out", + "auth.token.issued": "was issued a token", + "auth.token.refreshed": "refreshed a token", + "auth.token.revoked": "revoked a token", + "auth.mfa.enrolled": "enrolled in MFA", + "auth.mfa.validated": "passed MFA", + "auth.mfa.failed": "failed MFA", + "auth.mfa.disabled": "disabled MFA", + "auth.session.created": "started a session", + "auth.session.expired": "session expired", + "auth.session.revoked": "revoked a session", + "auth.password.changed": "changed a password", + "auth.password.policy_failed": "failed the password policy", + "auth.api_key.created": "created an API key", + "auth.api_key.revoked": "revoked an API key", + "auth.policy.updated": "updated the authentication policy", + // authz + "authz.permission.denied": "was denied permission", + "authz.role.assigned": "assigned a role", + "authz.role.removed": "removed a role", + // host + "host.created": "created a host", + "host.updated": "updated a host", + "host.deleted": "deleted a host", + "host.connectivity.checked": "checked host connectivity", + "host.platform.detected": "detected a host platform", + "host.discovery.completed": "completed host discovery", + "host.intelligence.refreshed": "refreshed host intelligence", + "host.bulk_imported": "bulk-imported hosts", + // credential + "credential.created": "created a credential", + "credential.updated": "updated a credential", + "credential.deleted": "deleted a credential", + // scan + "scan.queued": "queued a scan", + "scan.started": "started a scan", + "scan.completed": "completed a scan", + "scan.failed": "reported a failed scan", + "scan.cancelled": "cancelled a scan", + "scan.session.created": "started a scan session", + "scan.session.cancelled": "cancelled a scan session", + "scan.template.created": "created a scan template", + "scan.template.updated": "updated a scan template", + "scan.template.deleted": "deleted a scan template", + // compliance + "compliance.state.changed": "recorded a compliance change", + "finding.persisted": "recorded a finding", + "writer.apply.failed": "failed to write scan results", + "compliance.exception.requested": "requested an exception", + "compliance.exception.approved": "approved an exception", + "compliance.exception.rejected": "rejected an exception", + "compliance.exception.revoked": "revoked an exception", + "compliance.exception.expired": "exception expired", + "compliance.baseline.established": "established a baseline", + "compliance.baseline.cleared": "cleared a baseline", + // account + "account.user.locked": "locked a user account", + "account.user.unlocked": "unlocked a user account", + "account.user.created": "created a user account", + "account.user.deleted": "deleted a user account", + "account.user.privileged_group_added": "added a user to a privileged group", + "account.ssh_key.added": "added an SSH key", + "account.ssh_key.removed": "removed an SSH key", + // remediation + "remediation.requested": "requested remediation", + "remediation.approved": "approved remediation", + "remediation.rejected": "rejected remediation", + "remediation.executed": "executed remediation", + "remediation.rolled_back": "rolled back remediation", + // scheduler + "scheduler.tick.dispatched": "ran a scheduled tick", + "scheduler.schedule.updated": "updated a scan schedule", + // system lifecycle + "system.startup": "started up", + "system.shutdown": "shut down", + "system.package.installed": "installed a package", + "system.package.updated": "updated a package", + "system.package.removed": "removed a package", + "system.kernel.updated": "updated the kernel", + "system.filesystem.mounted": "mounted a filesystem", + "system.filesystem.unmounted": "unmounted a filesystem", + "system.service.started": "started a service", + "system.service.stopped": "stopped a service", + "system.service.failed": "reported a failed service", + "system.config.file_changed": "changed a config file", + "system.reboot.required": "flagged a required reboot", + "system.reboot.completed": "completed a reboot", + "system.config.changed": "changed system configuration", + "system.health.degraded": "reported degraded health", + // security + "security.login.new_source_ip": "logged in from a new source IP", + "security.login.failed_threshold": "hit a failed-login threshold", + "security.selinux.denied": "triggered an SELinux denial", + "security.apparmor.denied": "triggered an AppArmor denial", + "security.firewall.rule_changed": "changed a firewall rule", + "security.port.opened": "opened a network port", + // account + "account.password.expired": "had a password expire", + "account.password.expiring": "has a password expiring", + "account.sudo.failure_threshold": "hit a sudo failure threshold", + // notification / license / policy / admin + "notification.dispatched": "dispatched a notification", + "notification.delivery.failed": "had a notification fail to deliver", + "license.installed": "installed a license", + "license.expired": "reported an expired license", + "policy.loaded": "loaded a policy", + "policy.applied": "applied a policy", + "admin.user.created": "created a user", + "admin.user.deleted": "deleted a user", + "admin.role.changed": "changed a role", +} diff --git a/internal/activity/format_test.go b/internal/activity/format_test.go new file mode 100644 index 00000000..8ba70d50 --- /dev/null +++ b/internal/activity/format_test.go @@ -0,0 +1,100 @@ +// @spec system-activity +// +// Unit coverage for the human-readable formatters (no DB). +// AC-24 TestFormatters_HumanReadable +// AC-25 TestFormatters_GracefulFallback + +package activity + +import "testing" + +// @ac AC-24 +func TestFormatters_HumanReadable(t *testing.T) { + t.Run("system-activity/AC-24", func(t *testing.T) { + titler := func(id string) (string, bool) { + if id == "auditd_enabled" { + return "Ensure auditd is enabled", true + } + return "", false + } + + // --- compliance / transaction --- + title, summary := formatTransaction("auditd_enabled", "fail", "state_changed", titler) + if title != "Ensure auditd is enabled" { + t.Errorf("txn title = %q, want the catalog title", title) + } + if summary != "Changed: now Fail" { + t.Errorf("txn summary = %q, want %q", summary, "Changed: now Fail") + } + if _, s := formatTransaction("r", "pass", "first_seen", nil); s != "First seen: Pass" { + t.Errorf("first_seen summary = %q", s) + } + + // --- intelligence --- + title, summary = formatIntelligence("system.package.updated", + []byte(`{"name":"curl","from":"7.64","to":"7.81"}`)) + if title != "Package updated" { + t.Errorf("intel title = %q, want %q", title, "Package updated") + } + if summary != "curl: 7.64 → 7.81" { + t.Errorf("intel summary = %q, want %q", summary, "curl: 7.64 → 7.81") + } + // subject-only detail. + if _, s := formatIntelligence("account.user.created", []byte(`{"username":"alice"}`)); s != "alice" { + t.Errorf("user-created summary = %q, want alice", s) + } + + // --- audit --- + title, summary = formatAudit("host.created", "alice@example.com", "user", "host") + if title != "alice@example.com created a host" { + t.Errorf("audit title = %q", title) + } + if summary != "Host" { + t.Errorf("audit summary = %q, want Host", summary) + } + // actor_label empty -> readable actor_type; no UUID anywhere. + title, _ = formatAudit("authz.permission.denied", "", "system", "") + if title != "System was denied permission" { + t.Errorf("audit fallback title = %q", title) + } + }) +} + +// @ac AC-25 +// AC-25: an unmapped code never leaks as a raw dotted enum — it is humanized +// (no '.' separators) for every leg. +func TestFormatters_GracefulFallback(t *testing.T) { + t.Run("system-activity/AC-25", func(t *testing.T) { + // Unknown intelligence code. + title, _ := formatIntelligence("system.future.thing", nil) + if title != "System future thing" { + t.Errorf("unmapped intel title = %q, want humanized", title) + } + if containsDot(title) { + t.Errorf("intel title %q still contains a raw dotted code", title) + } + + // Unknown audit action. + title, _ = formatAudit("widget.frobnicated", "bob", "user", "") + if containsDot(title) { + t.Errorf("audit title %q still contains a raw dotted code", title) + } + if title != "bob widget frobnicated" { + t.Errorf("unmapped audit title = %q", title) + } + + // Unknown transaction change_kind degrades to the status word. + if _, s := formatTransaction("r", "error", "weird_kind", nil); s != "Error" { + t.Errorf("unmapped change_kind summary = %q, want Error", s) + } + }) +} + +func containsDot(s string) bool { + for _, r := range s { + if r == '.' { + return true + } + } + return false +} diff --git a/internal/activity/service.go b/internal/activity/service.go index fcd2de24..32223824 100644 --- a/internal/activity/service.go +++ b/internal/activity/service.go @@ -12,7 +12,8 @@ import ( // Service serves Activity feeds via a single UNION query. type Service struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + titler RuleTitleFunc // optional; resolves rule_id -> title for the compliance leg } // NewService binds a Service to a pgxpool. @@ -20,6 +21,14 @@ func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } +// WithRuleTitler injects the rule-id -> title resolver used to render the +// compliance (transaction) leg's headline. Nil-safe; without it the leg +// falls back to the raw rule id. Returns the Service for chaining. +func (s *Service) WithRuleTitler(f RuleTitleFunc) *Service { + s.titler = f + return s +} + // List returns a page of activity rows and the count hidden by RBAC. // Spec C-01..C-06; AC-01..AC-10. func (s *Service) List(ctx context.Context, f Filter, c Caller) ([]Row, int, string, error) { @@ -153,36 +162,51 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu return " AND " + strings.Join(parts, " AND ") } + // Every leg emits the same column shape. The last five columns are + // carriers for the Go enrichment pass (enrichRows): `code` plus three + // text contexts and a jsonb detail. The alert + monitoring legs already + // build their title/summary in SQL, so they leave the carriers empty + // and the enrichment pass skips them. Spec system-activity C-09. + const emptyCarriers = `, '' AS code, '' AS ctx_a, '' AS ctx_b, '' AS ctx_c, NULL::jsonb AS detail` legs := []string{} if includeAlerts { legs = append(legs, ` SELECT id::text AS id, 'alert' AS source, severity, host_id, title AS title, COALESCE(body, '') AS summary, - occurred_at + occurred_at`+emptyCarriers+` FROM alerts WHERE state != 'dismissed'`+ commonWhere("severity", "occurred_at", "host_id", true /* hasHostCol */)) } if includeTxn { + // title/summary are rebuilt in Go from code(rule_id) + ctx_a(status) + // + ctx_b(change_kind) via the rule titler. The SQL title/summary + // here are placeholders the enrichment pass overwrites. legs = append(legs, ` SELECT id::text AS id, 'transaction' AS source, COALESCE(severity, 'info') AS severity, host_id, rule_id AS title, COALESCE(change_kind, '') AS summary, - occurred_at + occurred_at, + rule_id AS code, status AS ctx_a, + COALESCE(change_kind, '') AS ctx_b, '' AS ctx_c, + NULL::jsonb AS detail FROM transactions WHERE 1=1`+ commonWhere("COALESCE(severity, 'info')", "occurred_at", "host_id", true /* hasHostCol */)) } if includeIntel { + // title/summary rebuilt in Go from code(event_code) + detail JSONB. legs = append(legs, ` SELECT id::text AS id, 'intelligence' AS source, severity, host_id, event_code AS title, '' AS summary, - occurred_at + occurred_at, + event_code AS code, '' AS ctx_a, '' AS ctx_b, '' AS ctx_c, + detail AS detail FROM host_intelligence_events WHERE 1=1`+ commonWhere("severity", "occurred_at", "host_id", true /* hasHostCol */)) @@ -195,13 +219,19 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu WHEN 'error' THEN 'high' ELSE COALESCE(severity, 'info') END` + // title/summary rebuilt in Go from code(action) + ctx_a(actor_label) + // + ctx_b(actor_type) + ctx_c(resource_type). resource_id (a UUID) + // is deliberately not surfaced in the headline. legs = append(legs, ` SELECT id::text AS id, 'audit' AS source, `+auditSev+` AS severity, NULL::uuid AS host_id, action AS title, COALESCE(resource_id, '') AS summary, - occurred_at + occurred_at, + action AS code, COALESCE(actor_label, '') AS ctx_a, + actor_type AS ctx_b, COALESCE(resource_type, '') AS ctx_c, + NULL::jsonb AS detail FROM audit_events WHERE 1=1`+ commonWhere(auditSev, "occurred_at", "", false)) @@ -254,7 +284,7 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu host_id, `+monTitle+` AS title, `+monSummary+` AS summary, - check_time AS occurred_at + check_time AS occurred_at`+emptyCarriers+` FROM host_monitoring_history WHERE (previous_state IS NULL OR monitoring_state <> previous_state)`+ commonWhere(monSev, "check_time", "host_id", true)) @@ -277,13 +307,17 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu out := []Row{} for pgRows.Next() { var ( - r Row - idStr string - source string - severity string - hostID *uuid.UUID + r Row + idStr string + source string + severity string + hostID *uuid.UUID + code, ctxA, ctxB, ctxC string + detail []byte ) - if err := pgRows.Scan(&idStr, &source, &severity, &hostID, &r.Title, &r.Summary, &r.OccurredAt); err != nil { + if err := pgRows.Scan(&idStr, &source, &severity, &hostID, + &r.Title, &r.Summary, &r.OccurredAt, + &code, &ctxA, &ctxB, &ctxC, &detail); err != nil { return nil, fmt.Errorf("activity: scan: %w", err) } id, _ := uuid.Parse(idStr) @@ -291,6 +325,17 @@ func (s *Service) queryUnion(ctx context.Context, f Filter, includeAlerts, inclu r.Source = Source(source) r.Severity = Severity(severity) r.HostID = hostID + // Rebuild title/summary in Go for the three legs that carry raw + // codes (the alert + monitoring legs leave the carriers empty and + // keep their SQL-built text). Spec C-09. + switch r.Source { + case SourceTransaction: + r.Title, r.Summary = formatTransaction(code, ctxA, ctxB, s.titler) + case SourceIntelligence: + r.Title, r.Summary = formatIntelligence(code, detail) + case SourceAudit: + r.Title, r.Summary = formatAudit(code, ctxA, ctxB, ctxC) + } out = append(out, r) } return out, pgRows.Err() diff --git a/internal/activity/service_db_test.go b/internal/activity/service_db_test.go index ea991bfb..8df08c41 100644 --- a/internal/activity/service_db_test.go +++ b/internal/activity/service_db_test.go @@ -16,6 +16,7 @@ package activity import ( "context" "encoding/json" + "strings" "testing" "time" @@ -468,6 +469,85 @@ func TestList_MonitoringLeg_TransitionsOnly(t *testing.T) { }) } +// @ac AC-26 +// AC-26 (v1.2.0): the feed renders human-readable title/summary for the +// three previously-raw legs end-to-end through the UNION — compliance +// (rule title via the injected titler + "now "), intelligence +// (event-code headline + detail-derived summary), and audit (actor + +// predicate, no raw action code, no resource UUID). No row title contains +// a raw dotted code. +func TestList_HumanReadableTitles(t *testing.T) { + t.Run("system-activity/AC-26", func(t *testing.T) { + pool, creator := freshDB(t) + host := seedHost(t, pool, creator) + base := time.Now().UTC() + ctx := context.Background() + + // compliance: rule_id resolves to a catalog title via the titler. + txnID, _ := uuid.NewV7() + scanID, _ := uuid.NewV7() + if _, err := pool.Exec(ctx, + `INSERT INTO transactions (id, host_id, rule_id, scan_id, status, severity, + change_kind, evidence, framework_refs, occurred_at) + VALUES ($1,$2,'auditd_enabled',$3,'fail','high','state_changed','{}'::jsonb,'{}'::jsonb,$4)`, + txnID, host, scanID, base.Add(-3*time.Minute)); err != nil { + t.Fatalf("seed txn: %v", err) + } + // intelligence: package update with from->to detail. + intelID, _ := uuid.NewV7() + if _, err := pool.Exec(ctx, + `INSERT INTO host_intelligence_events + (id, host_id, event_code, severity, detail, occurred_at, detected_at, correlation_id) + VALUES ($1,$2,'system.package.updated','medium',$3::jsonb,$4,$4,'corr')`, + intelID, host, `{"name":"curl","from":"7.64","to":"7.81"}`, base.Add(-2*time.Minute)); err != nil { + t.Fatalf("seed intel: %v", err) + } + // audit: actor_label present, action mapped to a predicate. + auditID, _ := uuid.NewV7() + if _, err := pool.Exec(ctx, + `INSERT INTO audit_events (id, correlation_id, actor_type, actor_label, action, + resource_type, resource_id, severity, occurred_at, detail) + VALUES ($1,'corr','user','alice@example.com','host.created','host',$2,'info',$3,'{}'::jsonb)`, + auditID, uuid.NewString(), base.Add(-1*time.Minute)); err != nil { + t.Fatalf("seed audit: %v", err) + } + + svc := NewService(pool).WithRuleTitler(func(id string) (string, bool) { + if id == "auditd_enabled" { + return "Ensure auditd is enabled", true + } + return "", false + }) + rows, _, _, err := svc.List(ctx, Filter{Limit: 50}, + Caller{CanReadAlerts: true, CanReadHosts: true, CanReadAudit: true}) + if err != nil { + t.Fatalf("List: %v", err) + } + bySource := map[Source]Row{} + rawCodes := []string{"system.package.updated", "host.created"} + for _, r := range rows { + bySource[r.Source] = r + // No row title may leak the raw event/action code (an email's + // dots in actor_label are fine — we check for the actual codes). + for _, code := range rawCodes { + if strings.Contains(r.Title, code) { + t.Errorf("source %s title %q leaks the raw code %q", r.Source, r.Title, code) + } + } + } + + if got := bySource[SourceTransaction]; got.Title != "Ensure auditd is enabled" || got.Summary != "Changed: now Fail" { + t.Errorf("transaction = {%q, %q}, want {Ensure auditd is enabled, Changed: now Fail}", got.Title, got.Summary) + } + if got := bySource[SourceIntelligence]; got.Title != "Package updated" || got.Summary != "curl: 7.64 → 7.81" { + t.Errorf("intelligence = {%q, %q}, want {Package updated, curl: 7.64 → 7.81}", got.Title, got.Summary) + } + if got := bySource[SourceAudit]; got.Title != "alice@example.com created a host" { + t.Errorf("audit title = %q, want %q", got.Title, "alice@example.com created a host") + } + }) +} + // stringFromInt avoids importing strconv just for a tiny helper. func stringFromInt(n int) string { if n == 0 { diff --git a/specs/system/activity.spec.yaml b/specs/system/activity.spec.yaml index 55dc4371..febb2605 100644 --- a/specs/system/activity.spec.yaml +++ b/specs/system/activity.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-activity title: Activity unified-feed service - version: "1.1.0" + version: "1.2.0" status: approved tier: 2 @@ -100,6 +100,10 @@ spec: description: 'v1.1.0 — A fifth leg projects host_monitoring_history into the unified Row shape: source=monitoring, host_id from the column, occurred_at from check_time, gated by CanReadHosts (same as transactions / intelligence — monitoring is host-scoped per-host operational data). The leg MUST filter to transition rows only (previous_state IS NULL OR monitoring_state <> previous_state) — every probe writes a history row, but only state changes are interesting on the operator-facing feed. Severity maps the band onto the closed severity enum: down→critical, critical→high, degraded→medium, online/maintenance→info. Title is operator-readable ("Host became unreachable" for down, "Host degraded" for degraded, "Host recovered" for online when previous_state was non-null, etc.). Summary surfaces error_message when present, falling back to " fail". Because host_monitoring_history.id is a bigint sequence and Row.ID is a uuid, the leg synthesizes a stable uuid from the bigint via the deterministic prefix "00000000-0000-7000-8000-" + lpad(to_hex(id), 12, "0").' type: technical enforcement: error + - id: C-09 + description: 'v1.2.0 — The feed MUST emit a human-readable title + summary for the three legs that previously carried raw machine codes, so no surface renders a raw dotted code, enum, or resource UUID. (a) Compliance/transaction: title = the rule''s catalog title resolved via an injected RuleTitleFunc (falling back to the raw rule_id only when the catalog has no entry); summary describes the change as "Changed: now " / "First seen: " / "Severity changed (now )" — the transactions table retains only the NEW status, so the summary MUST NOT claim a " -> " transition. (b) Intelligence: title = an event-code description (e.g. system.package.updated -> "Package updated"); summary derived generically from the detail JSONB (a subject from common keys + an optional "from -> to"). (c) Audit: title = " " where actor is actor_label (falling back to a readable actor_type word) and predicate maps the action code (e.g. host.created -> "created a host"); the raw resource_id UUID MUST NOT appear in the title (resource_type may provide short summary context). Every formatter MUST degrade gracefully: an unmapped code is humanized structurally (dots/underscores -> spaces, capitalized) so a newly-added code can never leak as a raw dotted enum. The alert + monitoring legs (which already build sentences in SQL) are unchanged. Enrichment happens in Go after the single UNION query; the one-Query / UNION ALL property (C-01) is preserved.' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -158,3 +162,15 @@ spec: description: 'v1.1.0 — Service.List against host_monitoring_history rows with at least one band transition (e.g. online→degraded with failed_layer="ssh" and error_message="SSH refused") returns a Row with source=monitoring, severity=medium, host_id set, title="Host degraded", summary="SSH refused". A second history row whose monitoring_state equals previous_state (no transition) MUST NOT appear in the result. The leg gates on CanReadHosts — a caller missing host:read sees zero monitoring rows and the count flows into hiddenByRBAC.' priority: critical references_constraints: [C-08] + - id: AC-24 + description: 'v1.2.0 — Unit: formatTransaction("auditd_enabled","fail","state_changed", titler) where titler maps the id returns title="Ensure auditd is enabled", summary="Changed: now Fail"; first_seen yields "First seen: Pass". formatIntelligence("system.package.updated", {"name":"curl","from":"7.64","to":"7.81"}) returns title="Package updated", summary="curl: 7.64 → 7.81"; a subject-only detail yields just the subject. formatAudit("host.created","alice@example.com","user","host") returns title="alice@example.com created a host"; with an empty actor_label and actor_type="system" the title is "System was denied permission" for authz.permission.denied.' + priority: critical + references_constraints: [C-09] + - id: AC-25 + description: 'v1.2.0 — Graceful fallback: an unmapped intelligence event_code ("system.future.thing") humanizes to "System future thing" (no "." separators); an unmapped audit action ("widget.frobnicated") with actor "bob" yields "bob widget frobnicated" (no raw dotted code); an unknown transaction change_kind degrades the summary to the bare status word. No formatter output for an unmapped code contains a "." from the original dotted code.' + priority: high + references_constraints: [C-09] + - id: AC-26 + description: 'v1.2.0 — Integration: Service.List (with an injected RuleTitleFunc) over a seeded transaction (rule_id resolving to "Ensure auditd is enabled", status=fail, change_kind=state_changed), a system.package.updated intelligence event with from/to detail, and a host.created audit event with actor_label set, returns the transaction Row with title="Ensure auditd is enabled" + summary="Changed: now Fail", the intelligence Row with title="Package updated" + summary="curl: 7.64 → 7.81", and the audit Row with title="alice@example.com created a host". No returned title leaks the seeded raw codes ("system.package.updated", "host.created").' + priority: critical + references_constraints: [C-09]