Skip to content

Commit a549131

Browse files
feat(activity): human-readable titles for compliance/intelligence/audit legs (Phase 0) (#616)
system-activity v1.2.0 (C-09, AC-24/25/26). First phase of the activity readability initiative (docs/engineering/activity_readability_plan.md). The unified feed built proper sentences for alerts + monitoring but handed the UI raw machine codes for the other three legs. Now every leg emits a human title/summary, built in Go after the single UNION (the one-Query property is preserved): - Compliance: rule_id -> catalog title via an injected RuleTitleFunc; summary 'Changed: now Fail' / 'First seen: Pass' (transactions retains only the new status, so no fake 'X -> Y'). - Intelligence: event_code -> description ('Package updated'); summary derived generically from the detail JSONB ('curl: 7.64 -> 7.81'). - Audit: '<actor> <predicate>' from actor_label + an action map ('alice@example.com created a host'); the resource UUID is no longer in the headline. Unmapped codes humanize structurally (dots -> spaces) so a new code can never leak as a raw dotted enum. Alert + monitoring legs unchanged. This makes /activity, the dashboard widget, and host-detail Recent Activity readable immediately (they already render title/summary).
1 parent a1b1f26 commit a549131

6 files changed

Lines changed: 631 additions & 14 deletions

File tree

cmd/openwatch/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,13 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
634634
WithConnectivityConfig(cfgStore, liveSvc).
635635
WithDiscovery(discoSvc).
636636
WithEventBus(bus).
637-
WithActivity(activity.NewService(pool)).
637+
WithActivity(activity.NewService(pool).WithRuleTitler(func(ruleID string) (string, bool) {
638+
if ruleCatalog == nil {
639+
return "", false
640+
}
641+
m, ok := ruleCatalog.Get(ruleID)
642+
return m.Title, ok
643+
})).
638644
WithAlerts(alerts.NewService(pool, audit.Emit)).
639645
WithScanQueue(scanQueueKey).
640646
WithScanWorker(scanWorker).

internal/activity/format.go

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
package activity
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
)
7+
8+
// Human-readable rendering for the three feed legs that otherwise emit raw
9+
// machine codes as their title (compliance/transactions, intelligence, and
10+
// audit). The alert and monitoring legs already build sentences in SQL and
11+
// are left untouched. Spec system-activity v1.2.0 (C-09).
12+
//
13+
// Every formatter degrades gracefully: an unmapped code is humanized
14+
// structurally (dots/underscores -> spaces, capitalized) so a new event
15+
// code can never leak to the UI as a raw dotted enum.
16+
17+
// RuleTitleFunc resolves a Kensa rule id to its catalog title. Injected by
18+
// the server from the rule catalog so this package takes no kensa
19+
// dependency. Nil-safe: a nil func (or a miss) falls back to the rule id.
20+
type RuleTitleFunc func(ruleID string) (title string, ok bool)
21+
22+
// formatTransaction renders a compliance state-change row. The transactions
23+
// table records the NEW status + the change_kind (it does not retain the
24+
// prior status), so the summary says "now <Status>", never "X -> Y".
25+
func formatTransaction(ruleID, status, changeKind string, titler RuleTitleFunc) (title, summary string) {
26+
title = ruleID
27+
if titler != nil {
28+
if t, ok := titler(ruleID); ok && t != "" {
29+
title = t
30+
}
31+
}
32+
st := statusWord(status)
33+
switch changeKind {
34+
case "first_seen":
35+
summary = "First seen: " + st
36+
case "severity_changed":
37+
summary = "Severity changed (now " + st + ")"
38+
case "state_changed":
39+
summary = "Changed: now " + st
40+
default:
41+
summary = st
42+
}
43+
return title, summary
44+
}
45+
46+
// formatIntelligence renders an OS-intelligence diff row. The title comes
47+
// from the event-code registry (or the humanized code); the summary is
48+
// extracted generically from the detail JSONB (subject + optional from->to).
49+
func formatIntelligence(eventCode string, detail []byte) (title, summary string) {
50+
title = intelTitles[eventCode]
51+
if title == "" {
52+
title = humanizeCode(eventCode)
53+
}
54+
return title, intelSummary(detail)
55+
}
56+
57+
// formatAudit renders an audit row as "<actor> <predicate>". The actor is
58+
// the recorded actor_label, falling back to a readable actor_type. The raw
59+
// resource_id (a UUID) is intentionally NOT placed in the title; the
60+
// resource_type provides lightweight context in the summary.
61+
func formatAudit(action, actorLabel, actorType, resourceType string) (title, summary string) {
62+
actor := strings.TrimSpace(actorLabel)
63+
if actor == "" {
64+
actor = actorWord(actorType)
65+
}
66+
pred, ok := auditPredicates[action]
67+
if !ok {
68+
pred = strings.ToLower(humanizeCode(action))
69+
}
70+
title = actor + " " + pred
71+
if resourceType != "" {
72+
summary = titleCaseWord(resourceType)
73+
}
74+
return title, summary
75+
}
76+
77+
// ---- helpers ----
78+
79+
func statusWord(status string) string {
80+
switch status {
81+
case "pass":
82+
return "Pass"
83+
case "fail":
84+
return "Fail"
85+
case "skipped":
86+
return "Skipped"
87+
case "error":
88+
return "Error"
89+
default:
90+
return titleCaseWord(status)
91+
}
92+
}
93+
94+
func actorWord(actorType string) string {
95+
switch actorType {
96+
case "system":
97+
return "System"
98+
case "scheduler":
99+
return "The scheduler"
100+
case "api_key", "api_token":
101+
return "An API token"
102+
case "agent":
103+
return "An agent"
104+
case "user":
105+
return "A user"
106+
default:
107+
if actorType == "" {
108+
return "Someone"
109+
}
110+
return titleCaseWord(actorType)
111+
}
112+
}
113+
114+
// humanizeCode turns a dotted/underscored code into a capitalized phrase
115+
// ("account.user.created" -> "Account user created"). The safety net that
116+
// guarantees no raw code ever reaches the UI.
117+
func humanizeCode(code string) string {
118+
s := strings.TrimSpace(strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(code))
119+
if s == "" {
120+
return "Activity"
121+
}
122+
return strings.ToUpper(s[:1]) + s[1:]
123+
}
124+
125+
// titleCaseWord capitalizes a single token, replacing separators with
126+
// spaces ("scan_template" -> "Scan template").
127+
func titleCaseWord(w string) string {
128+
s := strings.TrimSpace(strings.NewReplacer("_", " ", "-", " ").Replace(w))
129+
if s == "" {
130+
return ""
131+
}
132+
return strings.ToUpper(s[:1]) + s[1:]
133+
}
134+
135+
// intelSummary builds a concise phrase from an intelligence event's detail
136+
// JSONB: a subject (the first present of a set of common keys) plus an
137+
// optional "from -> to" transition. Returns "" when nothing useful is found.
138+
func intelSummary(detail []byte) string {
139+
if len(detail) == 0 {
140+
return ""
141+
}
142+
var m map[string]any
143+
if err := json.Unmarshal(detail, &m); err != nil {
144+
return ""
145+
}
146+
subject := firstStringField(m, "name", "package", "service", "unit",
147+
"username", "user", "account", "path", "file", "interface", "port", "rule")
148+
from := stringField(m["from"])
149+
to := stringField(m["to"])
150+
switch {
151+
case subject != "" && from != "" && to != "":
152+
return subject + ": " + from + " → " + to
153+
case subject != "" && to != "":
154+
return subject + " → " + to
155+
case subject != "":
156+
return subject
157+
case from != "" && to != "":
158+
return from + " → " + to
159+
default:
160+
return ""
161+
}
162+
}
163+
164+
func firstStringField(m map[string]any, keys ...string) string {
165+
for _, k := range keys {
166+
if v, ok := m[k]; ok {
167+
if s := stringField(v); s != "" {
168+
return s
169+
}
170+
}
171+
}
172+
return ""
173+
}
174+
175+
// stringField renders a JSON scalar as a short string. Non-scalars (objects,
176+
// arrays) return "" so they never dump structure into a summary line.
177+
func stringField(v any) string {
178+
switch t := v.(type) {
179+
case string:
180+
return t
181+
case bool:
182+
if t {
183+
return "true"
184+
}
185+
return "false"
186+
case float64:
187+
// Integers render without a trailing ".0"; keep it simple.
188+
if t == float64(int64(t)) {
189+
return itoa64(int64(t))
190+
}
191+
b, _ := json.Marshal(t)
192+
return string(b)
193+
default:
194+
return ""
195+
}
196+
}
197+
198+
func itoa64(n int64) string {
199+
if n == 0 {
200+
return "0"
201+
}
202+
neg := n < 0
203+
if neg {
204+
n = -n
205+
}
206+
var buf [24]byte
207+
i := len(buf)
208+
for n > 0 {
209+
i--
210+
buf[i] = byte('0' + n%10)
211+
n /= 10
212+
}
213+
if neg {
214+
i--
215+
buf[i] = '-'
216+
}
217+
return string(buf[i:])
218+
}
219+
220+
// intelTitles maps each host_intelligence_events.event_code to a readable
221+
// headline. Unmapped codes fall back to humanizeCode.
222+
var intelTitles = map[string]string{
223+
"account.user.locked": "User account locked",
224+
"account.user.unlocked": "User account unlocked",
225+
"account.user.created": "User account created",
226+
"account.user.deleted": "User account deleted",
227+
"account.user.privileged_group_added": "User added to a privileged group",
228+
"account.password.expired": "Password expired",
229+
"account.password.expiring": "Password expiring soon",
230+
"account.ssh_key.added": "SSH key added",
231+
"account.ssh_key.removed": "SSH key removed",
232+
"account.sudo.failure_threshold": "Repeated sudo failures",
233+
"security.login.new_source_ip": "Login from a new source IP",
234+
"security.login.failed_threshold": "Repeated failed logins",
235+
"security.selinux.denied": "SELinux denial",
236+
"security.apparmor.denied": "AppArmor denial",
237+
"security.firewall.rule_changed": "Firewall rule changed",
238+
"security.port.opened": "Network port opened",
239+
"system.package.installed": "Package installed",
240+
"system.package.updated": "Package updated",
241+
"system.package.removed": "Package removed",
242+
"system.kernel.updated": "Kernel updated",
243+
"system.reboot.required": "Reboot required",
244+
"system.reboot.completed": "Reboot completed",
245+
"system.config.file_changed": "Config file changed",
246+
"system.service.started": "Service started",
247+
"system.service.stopped": "Service stopped",
248+
"system.service.failed": "Service failed",
249+
"system.filesystem.mounted": "Filesystem mounted",
250+
"system.filesystem.unmounted": "Filesystem unmounted",
251+
}
252+
253+
// auditPredicates maps each audit action code to a verb phrase that reads
254+
// naturally after the actor ("<actor> <predicate>"). Unmapped codes fall
255+
// back to a lowercased humanizeCode.
256+
var auditPredicates = map[string]string{
257+
// auth
258+
"auth.login.success": "signed in",
259+
"auth.login.failure": "failed to sign in",
260+
"auth.logout": "signed out",
261+
"auth.token.issued": "was issued a token",
262+
"auth.token.refreshed": "refreshed a token",
263+
"auth.token.revoked": "revoked a token",
264+
"auth.mfa.enrolled": "enrolled in MFA",
265+
"auth.mfa.validated": "passed MFA",
266+
"auth.mfa.failed": "failed MFA",
267+
"auth.mfa.disabled": "disabled MFA",
268+
"auth.session.created": "started a session",
269+
"auth.session.expired": "session expired",
270+
"auth.session.revoked": "revoked a session",
271+
"auth.password.changed": "changed a password",
272+
"auth.password.policy_failed": "failed the password policy",
273+
"auth.api_key.created": "created an API key",
274+
"auth.api_key.revoked": "revoked an API key",
275+
"auth.policy.updated": "updated the authentication policy",
276+
// authz
277+
"authz.permission.denied": "was denied permission",
278+
"authz.role.assigned": "assigned a role",
279+
"authz.role.removed": "removed a role",
280+
// host
281+
"host.created": "created a host",
282+
"host.updated": "updated a host",
283+
"host.deleted": "deleted a host",
284+
"host.connectivity.checked": "checked host connectivity",
285+
"host.platform.detected": "detected a host platform",
286+
"host.discovery.completed": "completed host discovery",
287+
"host.intelligence.refreshed": "refreshed host intelligence",
288+
"host.bulk_imported": "bulk-imported hosts",
289+
// credential
290+
"credential.created": "created a credential",
291+
"credential.updated": "updated a credential",
292+
"credential.deleted": "deleted a credential",
293+
// scan
294+
"scan.queued": "queued a scan",
295+
"scan.started": "started a scan",
296+
"scan.completed": "completed a scan",
297+
"scan.failed": "reported a failed scan",
298+
"scan.cancelled": "cancelled a scan",
299+
"scan.session.created": "started a scan session",
300+
"scan.session.cancelled": "cancelled a scan session",
301+
"scan.template.created": "created a scan template",
302+
"scan.template.updated": "updated a scan template",
303+
"scan.template.deleted": "deleted a scan template",
304+
// compliance
305+
"compliance.state.changed": "recorded a compliance change",
306+
"finding.persisted": "recorded a finding",
307+
"writer.apply.failed": "failed to write scan results",
308+
"compliance.exception.requested": "requested an exception",
309+
"compliance.exception.approved": "approved an exception",
310+
"compliance.exception.rejected": "rejected an exception",
311+
"compliance.exception.revoked": "revoked an exception",
312+
"compliance.exception.expired": "exception expired",
313+
"compliance.baseline.established": "established a baseline",
314+
"compliance.baseline.cleared": "cleared a baseline",
315+
// account
316+
"account.user.locked": "locked a user account",
317+
"account.user.unlocked": "unlocked a user account",
318+
"account.user.created": "created a user account",
319+
"account.user.deleted": "deleted a user account",
320+
"account.user.privileged_group_added": "added a user to a privileged group",
321+
"account.ssh_key.added": "added an SSH key",
322+
"account.ssh_key.removed": "removed an SSH key",
323+
// remediation
324+
"remediation.requested": "requested remediation",
325+
"remediation.approved": "approved remediation",
326+
"remediation.rejected": "rejected remediation",
327+
"remediation.executed": "executed remediation",
328+
"remediation.rolled_back": "rolled back remediation",
329+
// scheduler
330+
"scheduler.tick.dispatched": "ran a scheduled tick",
331+
"scheduler.schedule.updated": "updated a scan schedule",
332+
// system lifecycle
333+
"system.startup": "started up",
334+
"system.shutdown": "shut down",
335+
"system.package.installed": "installed a package",
336+
"system.package.updated": "updated a package",
337+
"system.package.removed": "removed a package",
338+
"system.kernel.updated": "updated the kernel",
339+
"system.filesystem.mounted": "mounted a filesystem",
340+
"system.filesystem.unmounted": "unmounted a filesystem",
341+
"system.service.started": "started a service",
342+
"system.service.stopped": "stopped a service",
343+
"system.service.failed": "reported a failed service",
344+
"system.config.file_changed": "changed a config file",
345+
"system.reboot.required": "flagged a required reboot",
346+
"system.reboot.completed": "completed a reboot",
347+
"system.config.changed": "changed system configuration",
348+
"system.health.degraded": "reported degraded health",
349+
// security
350+
"security.login.new_source_ip": "logged in from a new source IP",
351+
"security.login.failed_threshold": "hit a failed-login threshold",
352+
"security.selinux.denied": "triggered an SELinux denial",
353+
"security.apparmor.denied": "triggered an AppArmor denial",
354+
"security.firewall.rule_changed": "changed a firewall rule",
355+
"security.port.opened": "opened a network port",
356+
// account
357+
"account.password.expired": "had a password expire",
358+
"account.password.expiring": "has a password expiring",
359+
"account.sudo.failure_threshold": "hit a sudo failure threshold",
360+
// notification / license / policy / admin
361+
"notification.dispatched": "dispatched a notification",
362+
"notification.delivery.failed": "had a notification fail to deliver",
363+
"license.installed": "installed a license",
364+
"license.expired": "reported an expired license",
365+
"policy.loaded": "loaded a policy",
366+
"policy.applied": "applied a policy",
367+
"admin.user.created": "created a user",
368+
"admin.user.deleted": "deleted a user",
369+
"admin.role.changed": "changed a role",
370+
}

0 commit comments

Comments
 (0)