Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
08ead1d
feat(security): audit_trail_events table, list endpoint and Maple's o…
Makisuo Nov 11, 2026
ded2423
fix(security): best-effort trail copy on the direct write, sibling cr…
Makisuo Nov 12, 2026
3b562af
fix(security): fail an email filter when the member directory is down…
Makisuo Nov 12, 2026
918b68c
feat(security): the audit log page in the Security hub
Makisuo Nov 12, 2026
e26901d
feat(security): an outcome reason on audit trail events, and Maple en…
Makisuo Nov 12, 2026
09838bd
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
a098f13
fix(security): the audit trail endpoint's errors carry a public title…
Makisuo Nov 12, 2026
60525ab
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
0153b0c
fix(security): audit log page review fixes
Makisuo Nov 12, 2026
cc6dca8
test(web): a sample URL for the security audit log page
Makisuo Nov 12, 2026
001b331
fix(web): the audit log row toggle says when the event happened
Makisuo Nov 12, 2026
2c15b63
fix(web): name the audit log's filter selects for what they filter
Makisuo Nov 12, 2026
736658e
fix(security): a refused API request is a permission event with a target
Makisuo Nov 12, 2026
7c1ab46
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
e150a50
fix(security): audit log page fixes from testing on the preview
Makisuo Nov 12, 2026
d3a9f40
fix(web): an actor typed but not applied survives a change of another…
Makisuo Nov 12, 2026
9f6d7fc
fix(security): the audit log's event id link carries the entry's time…
Makisuo Nov 12, 2026
22849d0
feat(security): Vercel and GCP audit events go to the audit trail, no…
Makisuo Nov 12, 2026
5e11c11
fix(security): the audit trail accepts only the org's own drain, boun…
Makisuo Nov 12, 2026
10153a2
fix(security): a source lookup that fails asks the Vercel audit drain…
Makisuo Nov 12, 2026
7cd5701
feat(security): the audit detection vocabulary, its alert_rules colum…
Makisuo Nov 12, 2026
80d2c18
feat(security): audit log detections, alert rules that count audit ev…
Makisuo Nov 12, 2026
1bf6770
test(security): audit log detections, from the rule row to the routes
Makisuo Nov 12, 2026
c2d355d
feat(security): a Detections page and editor for audit log detections
Makisuo Nov 12, 2026
496bed5
test(security): detections lab states and scene tests
Makisuo Nov 12, 2026
3fac3b1
docs(alerting): audit log detections, who creates them and what membe…
Makisuo Nov 12, 2026
c592466
fix(security): an audit incident links the audit log only for someone…
Makisuo Nov 12, 2026
ce4b6d6
fix(security): detections from testing on the preview
Makisuo Nov 12, 2026
4f7ce0e
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 12, 2026
9b9aaf6
fix(security): the Detections page, from testing on the preview
Makisuo Nov 12, 2026
8e22c94
chore(web): the kit index lists canReadAuditLog
Makisuo Nov 12, 2026
95e56d5
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 12, 2026
adbc8d9
fix(security): a detection's count floor moves only when it counts more
Makisuo Nov 12, 2026
58b8ee3
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 12, 2026
eceb267
fix(security): the audit log's alert control keeps its caveat in its …
Makisuo Nov 12, 2026
521a1be
fix(security): an audit incident says who may open the log only once …
Makisuo Nov 12, 2026
ff20358
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 12, 2026
d8f5e05
fix(security): an audit actor filter stays out of API spans
Makisuo Nov 12, 2026
6d4324d
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 12, 2026
73aa975
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 12, 2026
a98bced
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 12, 2026
8d7ab1f
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 12, 2026
2fc53cf
fix(security): a detection's row says who it notifies at every width
Makisuo Nov 12, 2026
14ee1c9
Merge remote-tracking branch 'origin/main' into audit-trail/table
Makisuo Nov 13, 2026
a59b9d6
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
8fef5b3
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
2d2bdc1
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
8bc7eba
chore(db): one detection migration on top of main's newest
Makisuo Nov 13, 2026
fbac02f
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 13, 2026
d32d7de
fix(audit-trail): cost sources have a category in the trail
Makisuo Nov 13, 2026
f5dceed
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
7816d03
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
10a38c1
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
79a4e6e
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 13, 2026
e89330f
fix(audit-trail): a dropped trail copy from the queue is a warning, n…
Makisuo Nov 13, 2026
7b544b4
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
67090fa
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
5db0f72
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
4042f62
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 13, 2026
bec8c40
fix(security): the audit log names the platform on a phone and in its…
Makisuo Nov 13, 2026
0dd401d
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
48fe85b
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 13, 2026
97bf700
fix(security): no gap before the comma in the audit log's alert button
Makisuo Nov 13, 2026
61c0c83
Merge remote-tracking branch 'origin/main' into audit-trail/table
Makisuo Nov 13, 2026
73fd519
Merge branch 'audit-trail/table' into audit-trail/page
Makisuo Nov 13, 2026
04f6108
Merge branch 'audit-trail/page' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
d60d7b5
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
c429e3b
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 13, 2026
4cbc165
fix(security): a mistyped detection link does not read as a new detec…
Makisuo Nov 13, 2026
d8dd5ff
Merge remote-tracking branch 'origin/main' into audit-trail/vercel-gcp
Makisuo Nov 13, 2026
839c56f
Merge branch 'audit-trail/vercel-gcp' into audit-trail/detections
Makisuo Nov 13, 2026
febc3e8
Merge branch 'audit-trail/detections' into audit-trail/detections-page
Makisuo Nov 13, 2026
d20e687
Merge remote-tracking branch 'origin/main' into audit-trail/detection…
Makisuo Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/landing/src/content/docs/alerting/alert-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,32 @@ The response lists the value and status of each window per group in `series`, an

`POST /v2/alerts/rules/test` evaluates a rule once against current data. Set `send_notification` to `true` to also deliver a test notification. See the [API reference](/docs/reference/api) for every endpoint and field.

## Audit log detections

A **detection** is an alert rule that counts audit events. It fires when a set number of matching events arrive within a window, for example 5 or more failed sign-ins in 10 minutes. It is not one of the query modes above: organization admins create, edit and delete detections under **Security → Detections**, and the alert rules API does not create or change them.

A detection matches events by **Platform**, **Category** and **Outcome**, each of which can stay on **Any**, and optionally by one exact **Action**, written as the platform names it, such as `team.member.role.updated`. With no filters it counts every audit event. It cannot match on who acted: a detection never names a person.

| Field | Values |
| -------- | ------------------------------------------- |
| Count | 1 to 100,000 events |
| Window | 5 minutes to 24 hours |
| Severity | Warning or Critical |
| Notify | Up to 20 notification destinations, or none |

An organization can have up to 50 detections. Each one is checked every minute.

There are two ways to start one:

- Open **Security → Detections** and click **New detection**. While the organization has no detections, the page lists five ready-made ones: a member added or removed, a permission or role changed, a credential created, rotated or revoked, a burst of refused requests, and repeated sign-in failures. Picking one fills in the editor. Nothing is created until you save.
- Filter the audit log under **Security → Audit log** and click **Alert on these events**. The editor opens with the platform, category and outcome you filtered by. An actor filter is not carried over.

When a detection fires it opens an [incident](/docs/alerting/incidents) and notifies its [destinations](/docs/alerting/notification-destinations). The notification carries the number of matching events, never an event or who caused it. A detection with no destination still opens the incident, and nobody is notified.

Every member of the organization can see a detection's name and that it fired. The events it counted are in the audit log, which only organization admins can read.

Deleting a detection also deletes the incidents it opened, open ones included.

## Troubleshooting

- **The rule never fires.** Open the rule and look at its **Checks**. **Skipped** checks mean the window had no data or fewer samples than **Minimum samples**. In the editor, the chart says how many windows would be skipped and why. Check the service names and environments against what is arriving.
Expand Down
84 changes: 76 additions & 8 deletions apps/web/src/lab/entries/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import * as AuditLogPage from "../../page/security/audit-log/model"
import * as AuditLogUpdate from "../../page/security/audit-log/update"
import { view as auditLogView } from "../../page/security/audit-log/view"
import * as DependenciesPage from "../../page/security/dependencies/model"
import * as DetectionFormPage from "../../page/security/detection-form/model"
import * as DetectionFormUpdate from "../../page/security/detection-form/update"
import { view as detectionFormView } from "../../page/security/detection-form/view"
import * as DetectionsPage from "../../page/security/detections/model"
import * as DetectionsUpdate from "../../page/security/detections/update"
import { view as detectionsView } from "../../page/security/detections/view"
import * as DependenciesUpdate from "../../page/security/dependencies/update"
import { view as dependenciesView } from "../../page/security/dependencies/view"
import * as FindingPage from "../../page/security/finding/model"
import * as FindingUpdate from "../../page/security/finding/update"
import { breadcrumbsOf as findingCrumbs, view as findingView } from "../../page/security/finding/view"
import { breadcrumbsOf as tabsCrumbs } from "../../page/security/kit/layout"
import { breadcrumbsOf as tabsCrumbs, detectionFormCrumbs } from "../../page/security/kit/layout"
import * as RunPage from "../../page/security/run/model"
import * as RunUpdate from "../../page/security/run/update"
import { breadcrumbsOf as runCrumbs, view as runView } from "../../page/security/run/view"
Expand All @@ -34,6 +40,8 @@ const Model = Schema.Struct({
finding: Schema.NullOr(FindingPage.Model),
telemetry: Schema.NullOr(TelemetryPage.Model),
auditLog: Schema.NullOr(AuditLogPage.Model),
detections: Schema.NullOr(DetectionsPage.Model),
detectionForm: Schema.NullOr(DetectionFormPage.Model),
dependencies: Schema.NullOr(DependenciesPage.Model),
})
type Model = typeof Model.Type
Expand All @@ -44,6 +52,8 @@ const Message = defineMessageUnion({
GotFindingMessage: { message: FindingPage.Message },
GotTelemetryMessage: { message: TelemetryPage.Message },
GotAuditLogMessage: { message: AuditLogPage.Message },
GotDetectionsMessage: { message: DetectionsPage.Message },
GotDetectionFormMessage: { message: DetectionFormPage.Message },
GotDependenciesMessage: { message: DependenciesPage.Message },
})
type Message = typeof Message.Type
Expand Down Expand Up @@ -71,6 +81,8 @@ const init = (input: typeof Flags.Type) => {
finding: null,
telemetry: null,
auditLog: null,
detections: null,
detectionForm: null,
dependencies: null,
}
switch (built._tag) {
Expand Down Expand Up @@ -109,6 +121,20 @@ const init = (input: typeof Flags.Type) => {
Message.GotAuditLogMessage({ message }),
),
}
case "Detections":
return {
model: { ...empty, detections: built.step.model },
commands: Command.mapMessages(built.step.commands, (message) =>
Message.GotDetectionsMessage({ message }),
),
}
case "DetectionForm":
return {
model: { ...empty, detectionForm: built.step.model },
commands: Command.mapMessages(built.step.commands, (message) =>
Message.GotDetectionFormMessage({ message }),
),
}
case "Dependencies":
return {
model: { ...empty, dependencies: built.step.model },
Expand Down Expand Up @@ -171,6 +197,26 @@ const update = (model: Model, message: Message) =>
),
}
},
GotDetectionsMessage: ({ message: inner }) => {
if (model.detections === null) return { model }
const next = DetectionsUpdate.update(model.detections, inner, context)
return {
model: { ...model, detections: next.model },
commands: Command.mapMessages(Arr.filter(next.commands ?? [], isLocalCommand), (child) =>
Message.GotDetectionsMessage({ message: child }),
),
}
},
GotDetectionFormMessage: ({ message: inner }) => {
if (model.detectionForm === null) return { model }
const next = DetectionFormUpdate.update(model.detectionForm, inner, context)
return {
model: { ...model, detectionForm: next.model },
commands: Command.mapMessages(Arr.filter(next.commands ?? [], isLocalCommand), (child) =>
Message.GotDetectionFormMessage({ message: child }),
),
}
},
GotDependenciesMessage: ({ message: inner }) => {
if (model.dependencies === null) return { model }
const next = DependenciesUpdate.update(model.dependencies, inner, context)
Expand Down Expand Up @@ -224,15 +270,33 @@ const view = (model: Model, h: HtmlBuilder<Message>): Html =>
viewInputs: { context },
toParentMessage: (message) => Message.GotDependenciesMessage({ message }),
})
: model.auditLog !== null
: model.detections !== null
? h.submodel({
slotId: "security-lab-audit-log",
model: model.auditLog,
view: auditLogView,
slotId: "security-lab-detections",
model: model.detections,
view: detectionsView,
viewInputs: { context },
toParentMessage: (message) => Message.GotAuditLogMessage({ message }),
toParentMessage: (message) => Message.GotDetectionsMessage({ message }),
})
: h.empty
: model.detectionForm !== null
? h.submodel({
slotId: "security-lab-detection-form",
model: model.detectionForm,
view: detectionFormView,
viewInputs: { context },
toParentMessage: (message) =>
Message.GotDetectionFormMessage({ message }),
})
: model.auditLog !== null
? h.submodel({
slotId: "security-lab-audit-log",
model: model.auditLog,
view: auditLogView,
viewInputs: { context },
toParentMessage: (message) =>
Message.GotAuditLogMessage({ message }),
})
: h.empty

export const lab = defineShellLab("Security Center", {
Model,
Expand All @@ -256,5 +320,9 @@ export const lab = defineShellLab("Security Center", {
? tabsCrumbs("dependencies")
: model.auditLog !== null
? tabsCrumbs("auditLog")
: [],
: model.detections !== null
? tabsCrumbs("detections")
: model.detectionForm !== null
? detectionFormCrumbs()
: [],
})
98 changes: 98 additions & 0 deletions apps/web/src/lab/security-detections-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
AlertDestinationId,
AlertRuleId,
type AuditDetection,
type AuditDetectionListResponse,
} from "@maple/domain/http"
import type { AlertDestinationRow } from "@maple/web-data/collections/alerts"
import { Array as Arr, Schema } from "effect"

// Audit log detections for the Security hub's lab and scene tests: the shapes the list has to
// draw (an exact action, no filters at all, one switched off, one that notifies nobody).

const HOUR = 3_600_000

const ruleId = Schema.decodeSync(AlertRuleId)
const destinationId = Schema.decodeSync(AlertDestinationId)

export const SECURITY_CHANNEL_ID = destinationId("0199b001-0000-7000-8000-000000000001")
export const ON_CALL_ID = destinationId("0199b001-0000-7000-8000-000000000002")

/** The org's alert destinations, as the live sync delivers them. */
export const detectionDestinationsOf = (orgId: string, nowMs: number): ReadonlyArray<AlertDestinationRow> =>
Arr.map(
[
{ id: SECURITY_CHANNEL_ID, name: "#security", type: "slack" },
{ id: ON_CALL_ID, name: "Security on-call", type: "pagerduty" },
],
(destination) => ({
...destination,
org_id: orgId,
enabled: true,
config_json: { summary: destination.name, channelLabel: null },
last_tested_at: null,
last_test_error: null,
disabled_at: null,
disabled_reason: null,
created_at: new Date(nowMs - 30 * 24 * HOUR).toISOString(),
updated_at: new Date(nowMs - 30 * 24 * HOUR).toISOString(),
}),
)

type Seed = Pick<AuditDetection, "name" | "filters" | "threshold" | "windowMinutes" | "severity"> &
Partial<Pick<AuditDetection, "enabled" | "destinationIds">>

const SEEDS: ReadonlyArray<Seed> = [
{
name: "Role changes on Vercel",
filters: { provider: "vercel", action: "team.member.role.updated" },
threshold: 1,
windowMinutes: 5,
severity: "warning",
},
{
name: "Burst of refused requests",
filters: { outcome: "denied" },
threshold: 10,
windowMinutes: 10,
severity: "critical",
destinationIds: [SECURITY_CHANNEL_ID, ON_CALL_ID],
},
{
name: "Credential changes on Google Cloud",
filters: { provider: "gcp", category: "credential" },
threshold: 1,
windowMinutes: 5,
severity: "warning",
destinationIds: [],
},
{
name: "Repeated sign-in failures",
filters: { category: "authentication", outcome: "failure" },
threshold: 5,
windowMinutes: 10,
severity: "critical",
enabled: false,
},
{
name: "Unusual audit volume",
filters: {},
threshold: 500,
windowMinutes: 60,
severity: "warning",
},
]

/** The detections of an org that set a few up, newest first. */
export const detectionsOf = (nowMs: number): AuditDetectionListResponse => ({
detections: Arr.map(SEEDS, (seed, index) => ({
id: ruleId(`0199b002-0000-7000-8000-00000000000${index + 1}`),
enabled: true,
destinationIds: [SECURITY_CHANNEL_ID],
createdAt: nowMs - (index + 1) * 26 * HOUR,
updatedAt: nowMs - (index + 1) * 5 * HOUR,
...seed,
})),
})

export const noDetections: AuditDetectionListResponse = { detections: [] }
Loading
Loading