141 lines
6.1 KiB
TypeScript
141 lines
6.1 KiB
TypeScript
/*
|
|
* Audit event types, presented.
|
|
*
|
|
* The page used to hold a map of eleven event types to labels and a second map
|
|
* of seven to colours. The server emits forty-seven. Everything unmapped fell
|
|
* through to the raw string, so one row read "Key Assigned" in green and the
|
|
* next "workflow.schedule_updated" in grey - the same kind of fact in two
|
|
* different formats, which made the column look like it carried a meaning it
|
|
* did not.
|
|
*
|
|
* So this derives rather than enumerates. Event types are named
|
|
* `<category>.<action>` consistently by every call site, which is a convention
|
|
* worth leaning on: the category names the subsystem, the action is
|
|
* humanised, and the tone comes from the verb. A type added to the server
|
|
* tomorrow gets a sensible label and colour here today, with no second list to
|
|
* remember. OVERRIDES exists only for the handful the rule reads badly for.
|
|
*/
|
|
|
|
export type AuditTone = "danger" | "warning" | "success" | "neutral";
|
|
|
|
/*
|
|
* The categories, in the order the sidebar presents their subsystems. Names
|
|
* are what the operator calls them, so `secretgroup` and `secret` collapse to
|
|
* one entry and `auth_provider` is "Single sign-on" rather than its identifier.
|
|
*/
|
|
export const AUDIT_CATEGORIES: { value: string; label: string }[] = [
|
|
{ value: "server", label: "Servers" },
|
|
{ value: "key", label: "SSH keys" },
|
|
{ value: "workflow", label: "Workflows" },
|
|
{ value: "monitor", label: "Monitors" },
|
|
{ value: "secret", label: "Secrets" },
|
|
{ value: "secretgroup", label: "Secret groups" },
|
|
{ value: "secrets", label: "Secrets access" },
|
|
{ value: "vuln", label: "Vulnerabilities" },
|
|
{ value: "workload", label: "Workloads" },
|
|
{ value: "console", label: "Console" },
|
|
{ value: "agent", label: "Agents" },
|
|
{ value: "updates", label: "OS updates" },
|
|
{ value: "auth_provider", label: "Single sign-on" },
|
|
{ value: "settings", label: "Settings" },
|
|
{ value: "token", label: "API tokens" },
|
|
{ value: "license", label: "Licence" },
|
|
{ value: "instance", label: "Instance" },
|
|
];
|
|
|
|
const CATEGORY_LABELS = new Map(AUDIT_CATEGORIES.map((c) => [c.value, c.label]));
|
|
|
|
/*
|
|
* Tone is taken from the action verb, not the category: deleting a key and
|
|
* deleting a workflow are the same weight of act.
|
|
*
|
|
* Stems match with or without their past tense, because the two spellings both
|
|
* occur - `auth_provider.delete` beside `key.deleted`, `workload.stop` beside
|
|
* `workflow.schedule_disabled`. Matching only the past tense left half the
|
|
* destructive events drawn in the same grey as a settings change.
|
|
*
|
|
* `accepted` is anchored because `unaccepted` contains it: unanchored, the
|
|
* negation matched its own root and withdrawing an acceptance was drawn as the
|
|
* same caution as granting one.
|
|
*/
|
|
const TONE_RULES: [RegExp, AuditTone][] = [
|
|
[/(delete|revoke|fail|offline|reap|cancel|destroy|remove)/, "danger"],
|
|
// Accepting a finding is a decision to live with a known risk, so it reads
|
|
// as a caution rather than an achievement. Withdrawing one falls through to
|
|
// neutral: it puts the finding back where it started.
|
|
[/(skip|disable|expire|stop|(^|_)accepted)/, "warning"],
|
|
[/(create|upload|assign|appl|open|enable|import|start|restart|sync)/, "success"],
|
|
];
|
|
|
|
/*
|
|
* Only where the derived text is wrong or reads clumsily. Anything absent is
|
|
* derived, which is the point - this list should stay short.
|
|
*/
|
|
const OVERRIDES: Record<string, string> = {
|
|
"key.generation_dispatched": "Key generation requested",
|
|
// These three arrive as `"workload." + action`, so the action really is a
|
|
// bare imperative rather than a name anyone chose.
|
|
"workload.start": "Workload started",
|
|
"workload.stop": "Workload stopped",
|
|
"workload.restart": "Workload restarted",
|
|
// The provider events are named in the imperative where every other
|
|
// subsystem uses the past tense; say what happened, like the rest.
|
|
"auth_provider.create": "Provider added",
|
|
"auth_provider.update": "Provider updated",
|
|
"auth_provider.delete": "Provider removed",
|
|
"agent.update_dispatched": "Agent update sent",
|
|
"updates.applied": "OS updates applied",
|
|
"secrets.token_rotated": "Read token rotated",
|
|
"secret.revealed": "Secret revealed",
|
|
"secretgroup.deleted": "Secret group deleted",
|
|
"workflow.defaults_synced": "Default steps synced",
|
|
"workflow.run_triggered": "Run started",
|
|
"workflow.run_cancelled": "Run cancelled",
|
|
"workflow.scheduled_run": "Scheduled run started",
|
|
"workflow.schedule_skipped": "Scheduled run skipped",
|
|
"auth_provider.ack_notice": "Callback change acknowledged",
|
|
"console.proxy_failed": "Console relay failed",
|
|
"console.proxy_opened": "Console relay opened",
|
|
"instance.reaped": "Instance deleted",
|
|
"vuln.rescan": "Rescan requested",
|
|
"workload.logs_read": "Workload logs read",
|
|
"token.created": "API token created",
|
|
"token.revoked": "API token revoked",
|
|
"token.expired_use": "Expired API token used",
|
|
"settings.token_policy_updated": "API token policy updated",
|
|
};
|
|
|
|
export interface AuditEventDisplay {
|
|
/** The subsystem, for the chip: "Workflows". */
|
|
category: string;
|
|
/** What happened, sentence case: "Schedule updated". */
|
|
action: string;
|
|
tone: AuditTone;
|
|
}
|
|
|
|
export function describeAuditEvent(eventType: string): AuditEventDisplay {
|
|
const dot = eventType.indexOf(".");
|
|
const prefix = dot === -1 ? "" : eventType.slice(0, dot);
|
|
const rest = dot === -1 ? eventType : eventType.slice(dot + 1);
|
|
|
|
const tone = TONE_RULES.find(([re]) => re.test(rest))?.[1] ?? "neutral";
|
|
|
|
const override = OVERRIDES[eventType];
|
|
const action = override ?? sentenceCase(rest);
|
|
|
|
return {
|
|
// An unknown prefix is shown as itself rather than hidden: a category
|
|
// this file has not been taught about is still better named by the
|
|
// server's own word for it than by nothing.
|
|
category: CATEGORY_LABELS.get(prefix) ?? sentenceCase(prefix || "Event"),
|
|
action,
|
|
tone,
|
|
};
|
|
}
|
|
|
|
function sentenceCase(s: string): string {
|
|
const words = s.replace(/[._]/g, " ").trim();
|
|
if (!words) return "";
|
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
}
|