Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1,308 changes: 1,207 additions & 101 deletions docs/openapi/monitoring-api.json

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions src/commands/forensics/evaluations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {Command, Flags} from '@oclif/core'
import {apiGetPage} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import {buildClient, display, globalFlags} from '../../lib/base-command.js'
import type {ColumnDef} from '../../lib/output.js'

interface EvalRow {
occurredAt: string
ruleType: string
region: string
outputMatched: boolean
checkId: string
policySnapshotHashHex: string
}

const COLUMNS: ColumnDef<EvalRow>[] = [
{header: 'WHEN', get: (r) => r.occurredAt},
{header: 'RULE', get: (r) => r.ruleType},
{header: 'REGION', get: (r) => r.region},
{header: 'MATCHED', get: (r) => (r.outputMatched ? 'yes' : 'no')},
{header: 'CHECK', get: (r) => r.checkId.slice(0, 8)},
{header: 'POLICY', get: (r) => r.policySnapshotHashHex.slice(0, 12)},
]

export default class ForensicsEvaluations extends Command {
static description = 'List rule evaluations produced for a monitor (paginated)'
static examples = [
'<%= config.bin %> forensics evaluations --monitor-id 5f4…',
'<%= config.bin %> forensics evaluations --monitor-id 5f4… --only-matched',
'<%= config.bin %> forensics evaluations --monitor-id 5f4… --rule-type consecutive_failures --region us-east',
]
static flags = {
...globalFlags,
'monitor-id': Flags.string({description: 'Monitor ID (UUID)', required: true}),
'rule-type': Flags.string({description: 'Filter by rule type'}),
region: Flags.string({description: 'Filter by probe region'}),
'only-matched': Flags.boolean({description: 'Return only evaluations that fired'}),
from: Flags.string({description: 'ISO-8601 lower bound (occurredAt >= from)'}),
to: Flags.string({description: 'ISO-8601 upper bound (occurredAt < to)'}),
page: Flags.integer({description: 'Page index (0-based)', default: 0}),
size: Flags.integer({description: 'Page size', default: 50}),
}

async run() {
const {flags} = await this.parse(ForensicsEvaluations)
const client = buildClient(flags)

const params: Record<string, unknown> = {page: flags.page, size: flags.size}
if (flags['rule-type']) params.ruleType = flags['rule-type']
if (flags.region) params.region = flags.region
if (flags['only-matched']) params.onlyMatched = true
if (flags.from) params.from = flags.from
if (flags.to) params.to = flags.to

const result = await apiGetPage(
client,
`/api/v1/forensics/monitors/${flags['monitor-id']}/rule-evaluations`,
apiSchemas.RuleEvaluationDto,
params,
)

display(this, result.data, flags.output, COLUMNS as ColumnDef[])
}
}
45 changes: 45 additions & 0 deletions src/commands/forensics/snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {Args, Command} from '@oclif/core'
import {apiGetSingle} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import {buildClient, globalFlags} from '../../lib/base-command.js'
import {formatOutput, OutputFormat} from '../../lib/output.js'

export default class ForensicsSnapshot extends Command {
static description = 'Fetch a policy snapshot by its content-addressed SHA-256 hash'
static examples = ['<%= config.bin %> forensics snapshot 5a1f…']
static flags = {...globalFlags}
static args = {
hash: Args.string({description: 'Snapshot hash (lowercase hex, SHA-256)', required: true}),
}

async run() {
const {args, flags} = await this.parse(ForensicsSnapshot)
const client = buildClient(flags)
const snapshot = await apiGetSingle(
client,
`/api/v1/forensics/policy-snapshots/${args.hash}`,
apiSchemas.PolicySnapshotDto,
)

const format = flags.output as OutputFormat
if (format === 'json' || format === 'yaml') {
this.log(formatOutput(snapshot, format))
return
}

this.log('')
this.log(` Hash: ${snapshot.hashHex}`)
this.log(` Engine: ${snapshot.engineVersion}`)
this.log(` First seen at: ${snapshot.firstSeenAt}`)
this.log(` Last seen at: ${snapshot.lastSeenAt}`)
this.log('')
this.log(' Policy')
this.log(
JSON.stringify(snapshot.policy, null, 2)
.split('\n')
.map((line) => ` ${line}`)
.join('\n'),
)
this.log('')
}
}
56 changes: 56 additions & 0 deletions src/commands/forensics/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {Args, Command} from '@oclif/core'
import {apiGetSingle} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import {buildClient, globalFlags} from '../../lib/base-command.js'
import {formatOutput, OutputFormat} from '../../lib/output.js'

export default class ForensicsTimeline extends Command {
static description = "Show the full forensic timeline for an incident (state transitions, triggering evaluations, active policy snapshot)"
static examples = ['<%= config.bin %> forensics timeline 5f4…']
static flags = {...globalFlags}
static args = {
id: Args.string({description: 'Incident ID (UUID)', required: true}),
}

async run() {
const {args, flags} = await this.parse(ForensicsTimeline)
const client = buildClient(flags)
const timeline = await apiGetSingle(
client,
`/api/v1/forensics/incidents/${args.id}/timeline`,
apiSchemas.IncidentTimelineDto,
)

const format = flags.output as OutputFormat
if (format === 'json' || format === 'yaml') {
this.log(formatOutput(timeline, format))
return
}

this.log('')
this.log(` Incident ${args.id}`)
this.log('')
this.log(' Transitions')
for (const t of timeline.transitions) {
const evalIds = t.triggeringEvaluationIds?.length
? ` evals=${t.triggeringEvaluationIds.length}`
: ''
this.log(
` ${t.occurredAt} ${t.fromStatus} → ${t.toStatus} reason=${t.reason} check=${t.checkId}${evalIds}`,
)
}
this.log('')
this.log(` Triggering evaluations (${timeline.triggeringEvaluations.length})`)
for (const e of timeline.triggeringEvaluations) {
const matched = e.outputMatched ? 'MATCH' : 'miss '
this.log(
` ${e.occurredAt} ${matched} rule=${e.ruleType} region=${e.region} check=${e.checkId}`,
)
}
if (timeline.policySnapshot) {
this.log('')
this.log(` Policy snapshot: ${timeline.policySnapshot.hashHex.slice(0, 16)}…`)
}
this.log('')
}
}
49 changes: 49 additions & 0 deletions src/commands/forensics/trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {Args, Command} from '@oclif/core'
import {apiGetSingle} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import {buildClient, globalFlags} from '../../lib/base-command.js'
import {formatOutput, OutputFormat} from '../../lib/output.js'

export default class ForensicsTrace extends Command {
static description = 'Show everything the detection engine recorded for a single check execution'
static examples = ['<%= config.bin %> forensics trace a1b2c3d4-…']
static flags = {...globalFlags}
static args = {
'check-id': Args.string({description: 'Check execution ID (UUID, minted by the scheduler)', required: true}),
}

async run() {
const {args, flags} = await this.parse(ForensicsTrace)
const client = buildClient(flags)
const trace = await apiGetSingle(
client,
`/api/v1/forensics/traces/${args['check-id']}`,
apiSchemas.CheckTraceDto,
)

const format = flags.output as OutputFormat
if (format === 'json' || format === 'yaml') {
this.log(formatOutput(trace, format))
return
}

this.log('')
this.log(` Check ${trace.checkId}`)
this.log('')
this.log(` Evaluations (${trace.evaluations.length})`)
for (const e of trace.evaluations) {
const matched = e.outputMatched ? 'MATCH' : 'miss '
this.log(` ${e.occurredAt} ${matched} rule=${e.ruleType} region=${e.region}`)
}
this.log('')
this.log(` Transitions (${trace.transitions.length})`)
for (const t of trace.transitions) {
this.log(` ${t.occurredAt} ${t.fromStatus} → ${t.toStatus} reason=${t.reason}`)
}
if (trace.policySnapshot) {
this.log('')
this.log(` Policy snapshot: ${trace.policySnapshot.hashHex.slice(0, 16)}…`)
}
this.log('')
}
}
58 changes: 58 additions & 0 deletions src/commands/forensics/transitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Command, Flags} from '@oclif/core'
import {apiGetPage} from '../../lib/api-client.js'
import {schemas as apiSchemas} from '../../lib/api-zod.generated.js'
import {buildClient, display, globalFlags} from '../../lib/base-command.js'
import type {ColumnDef} from '../../lib/output.js'

interface TransitionRow {
occurredAt: string
fromStatus: string
toStatus: string
reason: string
incidentId?: string | null
checkId: string
policySnapshotHashHex: string
}

const COLUMNS: ColumnDef<TransitionRow>[] = [
{header: 'WHEN', get: (r) => r.occurredAt},
{header: 'FROM → TO', get: (r) => `${r.fromStatus} → ${r.toStatus}`},
{header: 'REASON', get: (r) => r.reason},
{header: 'INCIDENT', get: (r) => (r.incidentId ? r.incidentId.slice(0, 8) : '–')},
{header: 'CHECK', get: (r) => r.checkId.slice(0, 8)},
{header: 'POLICY', get: (r) => r.policySnapshotHashHex.slice(0, 12)},
]

export default class ForensicsTransitions extends Command {
static description = 'List state transitions recorded for a monitor (paginated)'
static examples = [
'<%= config.bin %> forensics transitions --monitor-id 5f4…',
'<%= config.bin %> forensics transitions --monitor-id 5f4… --from 2026-01-01T00:00:00Z',
]
static flags = {
...globalFlags,
'monitor-id': Flags.string({description: 'Monitor ID (UUID)', required: true}),
from: Flags.string({description: 'ISO-8601 lower bound (occurredAt >= from)'}),
to: Flags.string({description: 'ISO-8601 upper bound (occurredAt < to)'}),
page: Flags.integer({description: 'Page index (0-based)', default: 0}),
size: Flags.integer({description: 'Page size', default: 50}),
}

async run() {
const {flags} = await this.parse(ForensicsTransitions)
const client = buildClient(flags)

const params: Record<string, unknown> = {page: flags.page, size: flags.size}
if (flags.from) params.from = flags.from
if (flags.to) params.to = flags.to

const result = await apiGetPage(
client,
`/api/v1/forensics/monitors/${flags['monitor-id']}/transitions`,
apiSchemas.IncidentStateTransitionDto,
params,
)

display(this, result.data, flags.output, COLUMNS as ColumnDef[])
}
}
Loading
Loading