diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d80dc191..0e5d51cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -198,17 +198,32 @@ graph, and the top row is the cheapest fix for the most (or worst) paths. ## Trajectory forecasting -The rule runner writes three more daily series beside the issue counts: the -backlog's total risk score (a few criticals weigh more than many lows), mean -time to resolution over issues resolved in the last 30 days, and the -admin-principal count from the policy evaluator's findings. `GET -/trends/:metric` fits an explainable least-squares line over the window and -returns 7/30-day projections with a residual band and a confidence level -derived from fit quality and point count — degraded a level, with the reason in -`note`, when no runner pass recorded in two days or the window has three or -more missing days. The Trends page renders the projection beside the series. -Publishing projections to the trust center is deferred until the publishable -discipline covers them. +The rule runner writes daily series beside the issue counts: the backlog's +total risk score (a few criticals weigh more than many lows), mean time to +resolution over issues resolved in the last 30 days, the admin-principal count +from the policy evaluator's findings, **SLO burn** (open findings past 80% of +their severity window — critical 7 days, high 30, medium 90, low 180 — derived +from creation dates the run already reads, no extra query) and +**exposure-by-type** (a bounded label-only read of internet-exposed vertices, +capped at 2000 with an explicit truncation flag). `GET /trends/:metric` fits +an explainable least-squares line over the window and returns 7/30-day +projections with a residual band and a confidence level derived from fit +quality and point count — degraded a level, with the reason in `note`, when no +runner pass recorded in two days or the window has three or more missing days. +The Trends page renders the projection beside the series, and each open issue +carries its own window state (`GET /issues` computes burn, `burning` and +`breached` on read from the severity window). + +**Projections publish with the posture**: while the reconciliation record is +`OK`, `GET /compliance/reconciliation/publishable` attaches compact +projections (metric id, per-day slope, confidence, basis and horizon, the +30-day projection) for open findings, backlog risk score and SLO burn — the +same forecaster, so the page and the API never disagree. STALE or MISSING +publishes nothing, and unreadable trends omit the projections without failing +the response. The sync layer owns the public wording: it maps the fixed metric +ids to reviewed labels and drops anything unknown, and the sanitization +tripwires still scan the whole fragment, so the public page shows reviewed +strings beside plain numbers only. ## Rule quality and calibration diff --git a/CHANGELOG.md b/CHANGELOG.md index 300bf25b..df3d03aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Khalifa. +## [Unreleased] - Risk windows, exposure series and trust-center projections (long tail, part 2) + +### Added + +- **Per-issue risk windows**: a new engine module (`slo.ts`) sets an SLA window per severity (critical 7 days, high 30, medium 90, low 180) and derives, deterministically from `createdAt`, the age, burn (`age / window`), state (`ok` / `burning` at 80% / `breached` past the window) and days-to-breach. Open issues expose it on read (`GET /issues`, `GET /issues/:id` — no stored state), the Issues list marks burning and breached findings, and the issue page's Status card states the window. +- **Two new trend series**: `sloBurning` (open findings past 80% of their window, split by severity plus the breached count — derived from the open issues the snapshot already reads, no extra query) and `exposedByType` (internet-exposed vertices grouped by resource type — one bounded, label-only read capped at 2000 with an explicit truncation flag; the cost objection answered by bound, not by skipping the series). Both flow through the trends route with sanitized samples and appear on the Trends page. +- **Trust-center projections**: while the reconciliation record is `OK`, `GET /compliance/reconciliation/publishable` attaches compact series projections (metric id, per-day slope, confidence, basis/horizon days, 30-day value) for open findings, backlog risk score and SLO burn, sharing one forecaster with the trends route (extracted to `services/trend-forecast.ts`). The sync layer maps the fixed metric ids to **reviewed labels** (nothing else can reach the page), drops unknown metrics and out-of-range values, and the sanitization tripwires scan the whole fragment; the rendered page gains a "Where the posture is heading" block. STALE/MISSING publishes nothing and unreadable trends omit projections without failing the response. + +### Notes + +- This completes the predictive completion plan (`~/Documents/Testing/plans/2026-10-27-predictive-completion.md`); the one deliberate remainder is ASN attribution, deferred with its reason recorded (external dataset + licensing). + ## [Unreleased] - UEBA breadth and EPSS/KEV enrichment (long tail, part 1) ### Added diff --git a/README.md b/README.md index 27ee7d0f..f36178eb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ One member role. Evaluation stays in-account. | **Detection** | GuardDuty detector coverage per region (including `MISSING`), Security Hub findings joined to internet-exposed resources (RULE-017, RULE-018), CloudTrail anomaly signals: root activity, secret reads from IPs outside each principal's own 30-day baseline, AccessDenied deviations from each principal's own daily baseline (RULE-020 to RULE-022), canary credential tripwires (RULE-023), secret scanning of Lambda environment variables plus IMDSv1-with-role detection (RULE-024), and Amazon Inspector CVEs on internet-exposed workloads (RULE-003). | | **Exposure** | Public EKS control planes (RULE-019) plus public S3/RDS and world-open security groups, derived from collected endpoint and network config, with optional observed traffic from VPC flow logs confirming where traffic actually goes. | | **Compliance** | CIS AWS Foundations v3.0 and the Khalifa baseline, per-control evidence, mapped SOC 2 / ISO 27001 / PCI DSS / NIST 800-53 requirements, and CIS reconciliation against Security Hub before anything is publishable. | -| **Trust center** | Nightly static trust-center page built from the publishable posture (statuses only, in-repo site). | +| **Trust center** | Nightly static trust-center page built from the publishable posture: framework statuses plus straight-line series projections while the reconciliation record is OK (reviewed labels, numbers only, in-repo site). | | **Runtime** | EKS Helm + `khalifa-eks` / `khalifa-data` / `khalifa-registry`. CDK Lambda remains for the in-repo pipeline. | | **Shift-left** | `npm run risk:plan -- ` predicts which rules a Terraform plan would feed (world-open security groups, account-root trusts, public buckets/RDS/EKS endpoints, secret policies, function URLs, IMDSv1) and fails the gate, naming the rule and the predicted path before anything is deployed. | diff --git a/api-service/src/__tests__/trust-center.test.ts b/api-service/src/__tests__/trust-center.test.ts index 2d7dab95..15d7f17d 100644 --- a/api-service/src/__tests__/trust-center.test.ts +++ b/api-service/src/__tests__/trust-center.test.ts @@ -5,6 +5,7 @@ import { getPublishableRequirements, publishableFrom, setReconciliationStoreForTests, + setTrendStoreForTests, } from '../routes/trust-center'; import { requireTrustCenterSync } from '../middleware/rbac'; @@ -173,6 +174,75 @@ describe('GET /compliance/reconciliation/publishable', () => { expect(res.body.status).toBe('OK'); expect(res.body.requirements).toHaveLength(1); }); + + function thirtyPoints(metric: string, start: number, step: number) { + return Array.from({ length: 30 }, (_, index) => ({ + metric, + date: new Date(Date.UTC(2026, 8, 1 + index)).toISOString().slice(0, 10), + value: start + step * index, + accountIds: [] as string[], + recordedAt: new Date().toISOString(), + })); + } + + it('attaches projections to an OK response, via the trends forecaster', async () => { + const generatedAt = new Date(Date.now() - 60_000).toISOString(); + setReconciliationStoreForTests({ + getLatest: jest.fn().mockResolvedValue(record({ generatedAt })), + save: jest.fn(), + }); + setTrendStoreForTests({ + getSeries: jest + .fn() + .mockImplementation(async (metric: string) => + metric === 'openRiskScore' ? thirtyPoints(metric, 900, -2) : [] + ), + } as never); + const res = makeRes(); + await getPublishableRequirements({} as Request, res); + setTrendStoreForTests(null); + + expect(res.body.status).toBe('OK'); + expect(res.body.projections).toEqual([ + expect.objectContaining({ + metric: 'openRiskScore', + perDay: -2, + confidence: expect.any(String), + basisDays: 30, + horizonDays: 30, + }), + ]); + expect(res.body.projections[0].projectedValue).toBeLessThan(900); + }); + + it('publishes no projections while the record is STALE, and survives unreadable trends', async () => { + setReconciliationStoreForTests({ + getLatest: jest + .fn() + .mockResolvedValue( + record({ generatedAt: new Date(Date.now() - MAX_RECORD_AGE_MS - 60_000).toISOString() }) + ), + save: jest.fn(), + }); + setTrendStoreForTests({ + getSeries: jest.fn().mockRejectedValue(new Error('AccessDenied')), + } as never); + const stale = makeRes(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + await getPublishableRequirements({} as Request, stale); + expect(stale.body.status).toBe('STALE'); + expect(stale.body.projections).toBeUndefined(); + + setReconciliationStoreForTests({ + getLatest: jest.fn().mockResolvedValue(record({ generatedAt: new Date().toISOString() })), + save: jest.fn(), + }); + const unreadable = makeRes(); + await getPublishableRequirements({} as Request, unreadable); + setTrendStoreForTests(null); + expect(unreadable.body.status).toBe('OK'); + expect(unreadable.body.projections).toBeUndefined(); + }); }); describe('requireTrustCenterSync', () => { diff --git a/api-service/src/routes/issues.ts b/api-service/src/routes/issues.ts index 7f7643d2..7f4e8507 100644 --- a/api-service/src/routes/issues.ts +++ b/api-service/src/routes/issues.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import { DynamoDBIssueRepository, + issueSlo, PostureTrendStore, type IssueListQuery, } from '@khalifa/risk-engine'; @@ -63,7 +64,12 @@ export async function listIssues(req: Request, res: Response): Promise { const response = await issueStore.listIssues(query); - res.json(response); + res.json({ + ...response, + items: response.items.map((issue) => + issue.status === 'open' ? { ...issue, slo: issueSlo(issue) } : issue + ), + }); } catch (error) { if ( error instanceof Error && @@ -158,6 +164,7 @@ export async function getIssue(req: Request<{ id: string }>, res: Response): Pro const response: IssueDetailResponse = { ...issue, + ...(issue.status === 'open' ? { slo: issueSlo(issue) } : {}), attackPath: { nodes: attackPathNodes, edges: attackPathEdges, diff --git a/api-service/src/routes/trends.ts b/api-service/src/routes/trends.ts index ea83cba5..93b49743 100644 --- a/api-service/src/routes/trends.ts +++ b/api-service/src/routes/trends.ts @@ -1,7 +1,8 @@ import type { Request, Response } from 'express'; -import { forecastSeries, PostureTrendStore } from '@khalifa/risk-engine'; +import { PostureTrendStore } from '@khalifa/risk-engine'; import { parseBoundedInteger } from '../middleware/gremlin-validator'; -import type { SeverityCounts, TrendForecast, TrendPoint, TrendResponse } from '../types'; +import { buildForecast } from '../services/trend-forecast'; +import type { ExposureCounts, SeverityCounts, TrendPoint, TrendResponse } from '../types'; const trendStore = new PostureTrendStore(); @@ -15,49 +16,10 @@ const VALID_METRICS = [ 'openRiskScore', 'mttrHours', 'adminPrincipals', + 'sloBurning', + 'exposedByType', ]; -const DAY_MS = 86_400_000; -const STALE_RUN_MS = 2 * DAY_MS; -const MAX_SERIES_GAP_DAYS = 3; - -// The engine's projection plus the series' own honesty check: no runner pass -// in two days, or three or more missing days inside the window, lowers the -// confidence a level and says why. This is the trend-store equivalent of the -// compliance engine's freshCoverage gate. -function buildForecast(metric: string, points: TrendPoint[]): TrendForecast | undefined { - const forecast = forecastSeries( - points.map((point) => ({ date: point.date, value: point.value })) - ); - if (!forecast) return undefined; - - let confidence = forecast.confidence; - const notes: string[] = []; - const last = points[points.length - 1]; - const recordedMs = last.recordedAt ? Date.parse(last.recordedAt) : NaN; - if (Number.isFinite(recordedMs) && Date.now() - recordedMs > STALE_RUN_MS) { - confidence = downgrade(confidence); - notes.push('No runner pass in the last two days'); - } - const spanDays = (Date.parse(last.date) - Date.parse(points[0].date)) / DAY_MS + 1; - if (spanDays - points.length >= MAX_SERIES_GAP_DAYS) { - confidence = downgrade(confidence); - notes.push('gaps in the series'); - } - - return { - ...forecast, - confidence, - ...(notes.length > 0 ? { note: notes.join('; ') } : {}), - }; -} - -function downgrade(confidence: TrendForecast['confidence']): TrendForecast['confidence'] { - if (confidence === 'high') return 'medium'; - if (confidence === 'medium') return 'low'; - return 'low'; -} - export async function getTrend(req: Request<{ metric: string }>, res: Response): Promise { try { const { metric } = req.params; @@ -82,6 +44,15 @@ export async function getTrend(req: Request<{ metric: string }>, res: Response): const points = await trendStore.getSeries(metric as any, days); const trendPoints: TrendPoint[] = points.map((p) => { const bySeverity = severityCounts(p.sample?.bySeverity); + const breached = nonNegativeInteger(p.sample?.breached); + const slo = + metric === 'sloBurning' && bySeverity && breached !== undefined + ? { ...bySeverity, breached } + : undefined; + const exposure = + metric === 'exposedByType' + ? exposureCounts(p.sample?.byType, p.sample?.truncated) + : undefined; return { metric: p.metric, date: p.date, @@ -89,6 +60,8 @@ export async function getTrend(req: Request<{ metric: string }>, res: Response): accountIds: p.accountIds, recordedAt: p.recordedAt, ...(bySeverity ? { bySeverity } : {}), + ...(slo ? { slo } : {}), + ...(exposure ? { exposure } : {}), }; }); @@ -113,6 +86,28 @@ export async function getTrend(req: Request<{ metric: string }>, res: Response): } } +// `sample` is free-form in storage; only reviewed shapes reach the client. The +// exposure map is bounded twice: at most 25 types, label-shaped keys only. +const MAX_EXPOSURE_TYPES = 25; +const EXPOSURE_LABEL = /^[A-Za-z][A-Za-z0-9]{0,40}$/; + +function nonNegativeInteger(raw: unknown): number | undefined { + return Number.isSafeInteger(raw) && (raw as number) >= 0 ? (raw as number) : undefined; +} + +function exposureCounts(byTypeRaw: unknown, truncatedRaw: unknown): ExposureCounts | undefined { + if (!byTypeRaw || typeof byTypeRaw !== 'object') return undefined; + const entries = Object.entries(byTypeRaw as Record); + if (entries.length === 0 || entries.length > MAX_EXPOSURE_TYPES) return undefined; + const byType: Record = {}; + for (const [label, count] of entries) { + const parsed = nonNegativeInteger(count); + if (!EXPOSURE_LABEL.test(label) || parsed === undefined) return undefined; + byType[label] = parsed; + } + return { byType, truncated: truncatedRaw === true }; +} + // `sample` is free-form in storage; only these four counts reach the client. function severityCounts(raw: unknown): SeverityCounts | undefined { if (!raw || typeof raw !== 'object') return undefined; diff --git a/api-service/src/routes/trust-center.ts b/api-service/src/routes/trust-center.ts index 0dde36da..b0c3b1c1 100644 --- a/api-service/src/routes/trust-center.ts +++ b/api-service/src/routes/trust-center.ts @@ -5,11 +5,14 @@ import { MAPPED_FRAMEWORKS, citationsByFramework, getControlById, + PostureTrendStore, type ComplianceControlStatus, type MappedFramework, type ReconciliationRecord, type ReconciliationStore, } from '@khalifa/risk-engine'; +import { buildForecast } from '../services/trend-forecast'; +import type { TrendPoint } from '../types'; // The compliance CronJob reconciles every 4 hours. A record older than a day // means reconciliation has stopped, and statuses that old must not be published. @@ -35,6 +38,21 @@ export interface PublishableMappedRequirement extends PublishableRequirement { framework: MappedFramework; } +// Projections publish the direction of the posture, never its contents: a +// fixed metric id (the sync layer owns the public wording), the fitted slope, +// the confidence and the 30-day projection. They are attached by the route +// only while the record is OK — STALE or MISSING publishes nothing, so a +// stale graph cannot project either. +export interface PublishableProjection { + metric: 'openIssuesBySeverity' | 'openRiskScore' | 'sloBurning'; + perDay: number; + confidence: 'high' | 'medium' | 'low'; + basisDays: number; + horizonDays: number; + projectedValue: number; + note?: string; +} + export interface PublishableResponse { // OK only when the latest record is OK and fresh. Anything else lists nothing. status: 'OK' | 'STALE' | 'MISSING'; @@ -43,8 +61,18 @@ export interface PublishableResponse { mappedFrameworks: PublishableMappedRequirement[]; /** Hours after which a record is STALE and nothing is published. */ maxAgeHours: number; + /** Only present while status is OK; absent when trends are unreadable. */ + projections?: PublishableProjection[]; } +const PROJECTION_METRICS: PublishableProjection['metric'][] = [ + 'openIssuesBySeverity', + 'openRiskScore', + 'sloBurning', +]; +const PROJECTION_WINDOW_DAYS = 30; +const PROJECTION_HORIZON_DAYS = 30; + export type BaselineStatuses = ReadonlyMap; // The only fields that leave Khalifa for the public trust center: no accounts, @@ -159,6 +187,55 @@ export function setReportStoreForTests(s: DynamoDBReportStore | null): void { reports = s; } +let trends: PostureTrendStore | undefined; + +function trendStore(): PostureTrendStore { + trends ??= new PostureTrendStore(); + return trends; +} + +export function setTrendStoreForTests(s: PostureTrendStore | null): void { + trends = s ?? undefined; +} + +// One bounded read per metric over the same window the trends page shows, +// through the same forecast (and its staleness honesty) the trends route +// serves. Failure-tolerant: unreadable trends mean no projections, never a +// failed publishable response. +export async function publishableProjections(): Promise { + try { + const projections: PublishableProjection[] = []; + for (const metric of PROJECTION_METRICS) { + const points = await trendStore().getSeries(metric, PROJECTION_WINDOW_DAYS); + if (points.length === 0) continue; + const trendPoints: TrendPoint[] = points.map((point) => ({ + metric: point.metric, + date: point.date, + value: point.value, + accountIds: point.accountIds, + recordedAt: point.recordedAt, + })); + const forecast = buildForecast(metric, trendPoints); + if (!forecast) continue; + const target = forecast.projected.find((p) => p.days === PROJECTION_HORIZON_DAYS); + if (!target) continue; + projections.push({ + metric, + perDay: Math.round(forecast.slopePerDay * 100) / 100, + confidence: forecast.confidence, + basisDays: forecast.basisDays, + horizonDays: PROJECTION_HORIZON_DAYS, + projectedValue: Math.round(target.value * 10) / 10, + ...(forecast.note ? { note: forecast.note } : {}), + }); + } + return projections.length > 0 ? projections : undefined; + } catch (error) { + console.error('Error reading trend forecasts for projections:', error); + return undefined; + } +} + export async function getPublishableRequirements(_req: Request, res: Response): Promise { try { const record = await reconciliationStore().getLatest(); @@ -178,7 +255,14 @@ export async function getPublishableRequirements(_req: Request, res: Response): baselineStatuses = undefined; } - res.json(publishableFrom(record, new Date(), baselineStatuses)); + const response = publishableFrom(record, new Date(), baselineStatuses); + // Projections ride the same gate: nothing publishes while the record is + // not OK, and a projection failure never fails the response. + if (response.status === 'OK') { + const projections = await publishableProjections(); + if (projections) response.projections = projections; + } + res.json(response); } catch (error) { console.error('Error reading the CIS reconciliation record:', error); res.status(500).json({ diff --git a/api-service/src/services/trend-forecast.ts b/api-service/src/services/trend-forecast.ts new file mode 100644 index 00000000..e3243454 --- /dev/null +++ b/api-service/src/services/trend-forecast.ts @@ -0,0 +1,44 @@ +import { forecastSeries } from '@khalifa/risk-engine'; +import type { TrendForecast, TrendPoint } from '../types'; + +const DAY_MS = 86_400_000; +const STALE_RUN_MS = 2 * DAY_MS; +const MAX_SERIES_GAP_DAYS = 3; + +// The engine's projection plus the series' own honesty check: no runner pass +// in two days, or three or more missing days inside the window, lowers the +// confidence a level and says why. This is the trend-store equivalent of the +// compliance engine's freshCoverage gate. Shared by the trends route and the +// trust-center projections, so the two can never disagree. +export function buildForecast(metric: string, points: TrendPoint[]): TrendForecast | undefined { + const forecast = forecastSeries( + points.map((point) => ({ date: point.date, value: point.value })) + ); + if (!forecast) return undefined; + + let confidence = forecast.confidence; + const notes: string[] = []; + const last = points[points.length - 1]; + const recordedMs = last.recordedAt ? Date.parse(last.recordedAt) : NaN; + if (Number.isFinite(recordedMs) && Date.now() - recordedMs > STALE_RUN_MS) { + confidence = downgrade(confidence); + notes.push('No runner pass in the last two days'); + } + const spanDays = (Date.parse(last.date) - Date.parse(points[0].date)) / DAY_MS + 1; + if (spanDays - points.length >= MAX_SERIES_GAP_DAYS) { + confidence = downgrade(confidence); + notes.push('gaps in the series'); + } + + return { + ...forecast, + confidence, + ...(notes.length > 0 ? { note: notes.join('; ') } : {}), + }; +} + +export function downgrade(confidence: TrendForecast['confidence']): TrendForecast['confidence'] { + if (confidence === 'high') return 'medium'; + if (confidence === 'medium') return 'low'; + return 'low'; +} diff --git a/api-service/src/types/index.ts b/api-service/src/types/index.ts index 77ad1aa9..d890a6b7 100644 --- a/api-service/src/types/index.ts +++ b/api-service/src/types/index.ts @@ -43,6 +43,8 @@ export interface Issue { resolvedReason?: string; suppression?: Suppression; statusHistory?: StatusChange[]; + /** Open issues only: age against the severity's risk window, computed on read. */ + slo?: IssueSlo; } export interface StatusChange { @@ -187,6 +189,19 @@ export interface TrendPoint { accountIds?: string[]; recordedAt: string; bySeverity?: SeverityCounts; + /** sloBurning only: how the burning/breached count splits by severity. */ + slo?: SloCounts; + /** exposedByType only: exposed vertices per resource type, capped. */ + exposure?: ExposureCounts; +} + +export interface SloCounts extends SeverityCounts { + breached: number; +} + +export interface ExposureCounts { + byType: Record; + truncated: boolean; } export interface SeverityCounts { @@ -235,7 +250,7 @@ export interface AttackScenarioRunResponse { } // Single source of truth lives with the likelihood it tempers. -import type { CoverageLevel } from '@khalifa/risk-engine'; +import type { CoverageLevel, IssueSlo } from '@khalifa/risk-engine'; export type { CoverageLevel }; export interface CoverageStatement { diff --git a/packages/risk-engine/src/index.ts b/packages/risk-engine/src/index.ts index 8c60b153..ca151019 100644 --- a/packages/risk-engine/src/index.ts +++ b/packages/risk-engine/src/index.ts @@ -51,6 +51,13 @@ export { export { globMatch } from './glob'; export { extractRulePath, type RulePath } from './rule-path'; export { pathLikelihood, type PathLikelihood, type PathLikelihoodFactors } from './likelihood'; +export { + issueSlo, + SLO_BURNING_THRESHOLD, + SLO_WINDOW_DAYS, + type IssueSlo, + type SloState, +} from './slo'; export { MAX_CITED_ENDPOINTS, observedTrafficFromPath, diff --git a/packages/risk-engine/src/posture-trend-store.ts b/packages/risk-engine/src/posture-trend-store.ts index 72005a5b..2c5b18b4 100644 --- a/packages/risk-engine/src/posture-trend-store.ts +++ b/packages/risk-engine/src/posture-trend-store.ts @@ -17,7 +17,9 @@ export type TrendMetric = | 'issuesResolved' | 'openRiskScore' | 'mttrHours' - | 'adminPrincipals'; + | 'adminPrincipals' + | 'sloBurning' + | 'exposedByType'; export interface PostureTrendPoint { metric: string; diff --git a/packages/risk-engine/src/runner.test.ts b/packages/risk-engine/src/runner.test.ts index b211a4bf..77b40fce 100644 --- a/packages/risk-engine/src/runner.test.ts +++ b/packages/risk-engine/src/runner.test.ts @@ -574,7 +574,82 @@ describe('RiskRuleRunner stale-issue fix', () => { { metric: 'crossAccountTrusts', value: 2, accountIds: [] }, { metric: 'usersWithoutMfa', value: 0, accountIds: [] }, { metric: 'openRiskScore', value: 90, accountIds: [] }, + { + metric: 'sloBurning', + value: 0, + accountIds: [], + sample: { bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, breached: 0 }, + }, + { + metric: 'exposedByType', + value: 0, + accountIds: [], + sample: { byType: {}, truncated: false }, + }, + ]); + }); + + test('snapshotTrends counts issues burning their severity window', async () => { + const runner = new RiskRuleRunner('localhost:8182'); + const store = (runner as any).issueStore; + const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000).toISOString(); + store.getOpenIssuesByRule = jest.fn().mockImplementation(async (ruleId: string) => { + if (ruleId === 'RULE-001') + return [ + { id: 'old-critical', severity: 'critical', createdAt: daysAgo(6) }, + { id: 'breached-high', severity: 'high', createdAt: daysAgo(31) }, + { id: 'fresh-low', severity: 'low', createdAt: daysAgo(1) }, + ]; + return []; + }); + store.listIssues = jest.fn().mockResolvedValue({ items: [], totalCount: 0 }); + (runner as any).neptuneClient = { executeQuery: jest.fn().mockResolvedValue([]) }; + + await (runner as any).snapshotTrends([ + { ruleId: 'RULE-001', executionTime: 1, matches: [], issuesCreated: 0, issuesResolved: 0 }, + ]); + + const call = mockRecordMany.mock.calls[0][0] as Array<{ + metric: string; + value: number; + sample?: Record; + }>; + expect(call.find((point) => point.metric === 'sloBurning')).toMatchObject({ + value: 2, + sample: { bySeverity: { critical: 1, high: 1, medium: 0, low: 0 }, breached: 1 }, + }); + }); + + test('snapshotTrends records exposure by type from one bounded label read', async () => { + const runner = new RiskRuleRunner('localhost:8182'); + const store = (runner as any).issueStore; + store.getOpenIssuesByRule = jest.fn().mockResolvedValue([]); + store.listIssues = jest.fn().mockResolvedValue({ items: [], totalCount: 0 }); + const executeQuery = jest.fn().mockImplementation(async (query: string) => { + if (query.includes('is_internet_exposed')) { + return ['Ec2Instance', 'Ec2Instance', 'LoadBalancer']; + } + return []; + }); + (runner as any).neptuneClient = { executeQuery }; + + await (runner as any).snapshotTrends([ + { ruleId: 'RULE-001', executionTime: 1, matches: [], issuesCreated: 0, issuesResolved: 0 }, ]); + + expect(executeQuery).toHaveBeenCalledWith( + expect.stringContaining("has('is_internet_exposed', true)") + ); + expect(executeQuery).toHaveBeenCalledWith(expect.stringContaining('.limit(')); + const call = mockRecordMany.mock.calls[0][0] as Array<{ + metric: string; + value: number; + sample?: Record; + }>; + expect(call.find((point) => point.metric === 'exposedByType')).toMatchObject({ + value: 3, + sample: { byType: { Ec2Instance: 2, LoadBalancer: 1 }, truncated: false }, + }); }); test('snapshotTrends adds the run’s created and resolved totals to the day', async () => { diff --git a/packages/risk-engine/src/runner.ts b/packages/risk-engine/src/runner.ts index c965aa3e..5dc4c944 100644 --- a/packages/risk-engine/src/runner.ts +++ b/packages/risk-engine/src/runner.ts @@ -16,6 +16,7 @@ import type { } from './types'; import { extractRulePath, type RulePath } from './rule-path'; import { coverageLevelForAge, type CoverageLevel } from './coverage'; +import { issueSlo } from './slo'; import { pathLikelihood } from './likelihood'; import { observedTrafficFromPath } from './observedTraffic'; import { pathIndicators, ThreatIndicatorStore, type ThreatIndicator } from './threat-intel'; @@ -33,6 +34,9 @@ export const RULE_RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 60_000, 60_000]; // incomplete and issues get no label (absent, not false). const INDEPENDENT_FINDING_CAP = 5000; const COVERAGE_RECORD_CAP = 5000; +// Exposure series: how many vertices the by-type aggregation will read; the +// point is flagged truncated when the cap bites. +const EXPOSED_TYPE_CAP = 2000; // Resources listed on one issue; matchCount keeps the full number. const MAX_ISSUE_RESOURCES = 200; @@ -278,6 +282,12 @@ export class RiskRuleRunner { const byRule = new Map(); const bySeverity: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + // SLO burn is derived from the open issues this loop already reads — no + // extra query — so the series is always exactly the backlog's state. + const sloBySeverity: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + let sloBreached = 0; + const snapshotAt = new Date(); + for (const result of results) { const openIssues = await this.issueStore.getOpenIssuesByRule(result.ruleId); totalOpen += openIssues.length; @@ -287,6 +297,13 @@ export class RiskRuleRunner { if (typeof issue.riskScore === 'number' && Number.isFinite(issue.riskScore)) { openRiskScore += issue.riskScore; } + if (issue.severity in sloBySeverity) { + const slo = issueSlo(issue, snapshotAt); + if (slo.state === 'burning' || slo.state === 'breached') { + sloBySeverity[issue.severity] += 1; + if (slo.state === 'breached') sloBreached += 1; + } + } } created += result.issuesCreated; resolved += result.issuesResolved; @@ -298,6 +315,7 @@ export class RiskRuleRunner { // than failing the trend snapshot. const mttrHours = await this.meanResolutionHours(); const adminPrincipals = await this.countAdminPrincipals(); + const exposedByType = await this.countExposedByType(); await store.recordMany([ // The value is the total open count; the severity split rides in `sample`. @@ -313,6 +331,25 @@ export class RiskRuleRunner { // Open risk score: the backlog's sum, so a few criticals weigh more than // many lows. { metric: 'openRiskScore', value: Math.round(openRiskScore * 10) / 10, accountIds: [] }, + // Findings burning their severity window (or past it): the backlog's + // own pace, derived from creation dates, not a new measurement. + { + metric: 'sloBurning', + value: + sloBySeverity.critical + sloBySeverity.high + sloBySeverity.medium + sloBySeverity.low, + accountIds: [], + sample: { bySeverity: sloBySeverity, breached: sloBreached }, + }, + ...(exposedByType + ? [ + { + metric: 'exposedByType' as const, + value: exposedByType.total, + accountIds: [], + sample: { byType: exposedByType.byType, truncated: exposedByType.truncated }, + }, + ] + : []), ...(mttrHours !== undefined ? [{ metric: 'mttrHours' as const, value: mttrHours, accountIds: [] }] : []), @@ -342,6 +379,30 @@ export class RiskRuleRunner { } } + // Internet-exposed vertices grouped by label, bounded: one property-filtered + // read (the same predicate the rules already run), a label-only payload and + // a cap with an explicit truncation flag — the cost objection answered by + // bound, not by skipping the series. Failures skip the point. + private async countExposedByType(): Promise< + { total: number; byType: Record; truncated: boolean } | undefined + > { + try { + const rows = await this.neptuneClient.executeQuery( + `g.V().has('is_internet_exposed', true).limit(${EXPOSED_TYPE_CAP + 1}).label()` + ); + const labels = rows.map((row) => String(row)).filter((label) => label.length > 0); + const truncated = labels.length > EXPOSED_TYPE_CAP; + const byType: Record = {}; + for (const label of labels.slice(0, EXPOSED_TYPE_CAP)) { + byType[label] = (byType[label] ?? 0) + 1; + } + const total = Object.values(byType).reduce((sum, count) => sum + count, 0); + return { total, byType, truncated }; + } catch { + return undefined; + } + } + // Admins the policy evaluator marked this collection window; a leading // indicator of blast-radius growth. Failures skip the point. private async countAdminPrincipals(): Promise { diff --git a/packages/risk-engine/src/slo.test.ts b/packages/risk-engine/src/slo.test.ts new file mode 100644 index 00000000..1e3b20b8 --- /dev/null +++ b/packages/risk-engine/src/slo.test.ts @@ -0,0 +1,39 @@ +import { issueSlo, SLO_WINDOW_DAYS } from './slo'; + +const NOW = new Date('2026-10-28T12:00:00Z'); +const issue = (severity: 'critical' | 'high' | 'medium' | 'low', daysAgo: number) => ({ + severity, + createdAt: new Date(NOW.getTime() - daysAgo * 86_400_000).toISOString(), +}); + +describe('issueSlo', () => { + it('uses the severity window and reports the burn deterministically', () => { + const slo = issueSlo(issue('high', 15), NOW); + expect(slo).toEqual({ + windowDays: 30, + ageDays: 15, + burn: 0.5, + state: 'ok', + breachInDays: 15, + }); + }); + + it('flips to burning at the threshold and breached past the window', () => { + expect(issueSlo(issue('critical', 5.6), NOW).state).toBe('burning'); + expect(issueSlo(issue('critical', 7), NOW).state).toBe('burning'); + expect(issueSlo(issue('critical', 7.1), NOW).state).toBe('breached'); + expect(issueSlo(issue('critical', 7.1), NOW).breachInDays).toBe(0); + }); + + it('windows grow with lower severity', () => { + expect(SLO_WINDOW_DAYS.critical).toBeLessThan(SLO_WINDOW_DAYS.high); + expect(SLO_WINDOW_DAYS.high).toBeLessThan(SLO_WINDOW_DAYS.medium); + expect(SLO_WINDOW_DAYS.medium).toBeLessThan(SLO_WINDOW_DAYS.low); + }); + + it('treats an unparseable createdAt as brand new rather than failing', () => { + const slo = issueSlo({ severity: 'low', createdAt: 'not-a-date' }, NOW); + expect(slo.ageDays).toBe(0); + expect(slo.state).toBe('ok'); + }); +}); diff --git a/packages/risk-engine/src/slo.ts b/packages/risk-engine/src/slo.ts new file mode 100644 index 00000000..8688dc84 --- /dev/null +++ b/packages/risk-engine/src/slo.ts @@ -0,0 +1,45 @@ +import type { Severity } from './types'; + +// Per-issue risk windows: how long each severity class may stay open before +// the finding has burned its SLA budget. The windows are deliberate policy, +// not measurements — a critical reaches exploitation faster than a low, so +// its window is a week and a low's is half a year — and every derived number +// is deterministic from the issue's own createdAt. +export const SLO_WINDOW_DAYS: Record = { + critical: 7, + high: 30, + medium: 90, + low: 180, +}; + +// Past this fraction of the window the issue is burning (a warning band); +// past 1.0 it has breached. +export const SLO_BURNING_THRESHOLD = 0.8; + +export type SloState = 'ok' | 'burning' | 'breached'; + +export interface IssueSlo { + windowDays: number; + ageDays: number; + /** ageDays / windowDays, rounded to 2 decimals. */ + burn: number; + state: SloState; + /** Whole days until burn reaches 1.0; 0 once breached. */ + breachInDays: number; +} + +const DAY_MS = 86_400_000; + +export function issueSlo( + issue: { severity: Severity; createdAt: string }, + now: Date = new Date() +): IssueSlo { + const windowDays = SLO_WINDOW_DAYS[issue.severity] ?? SLO_WINDOW_DAYS.medium; + const createdMs = Date.parse(issue.createdAt); + const ageMs = Number.isFinite(createdMs) ? Math.max(0, now.getTime() - createdMs) : 0; + const ageDays = Math.round((ageMs / DAY_MS) * 10) / 10; + const burn = Math.round((ageDays / windowDays) * 100) / 100; + const state: SloState = burn > 1 ? 'breached' : burn >= SLO_BURNING_THRESHOLD ? 'burning' : 'ok'; + const breachInDays = Math.max(0, Math.ceil(windowDays - ageDays)); + return { windowDays, ageDays, burn, state, breachInDays }; +} diff --git a/scripts/trust-center-sync/map.test.ts b/scripts/trust-center-sync/map.test.ts index 73e4e0b3..85200aa0 100644 --- a/scripts/trust-center-sync/map.test.ts +++ b/scripts/trust-center-sync/map.test.ts @@ -148,3 +148,85 @@ describe('buildTrustFragment', () => { ).toThrow('forbidden value'); }); }); + +describe('projections', () => { + const base = { + status: 'OK' as const, + generatedAt: '2026-10-14T11:30:00.000Z', + requirements: [], + mappedFrameworks: [], + }; + + it('maps known metrics to fixed public labels with plain numbers', () => { + const fragment = buildTrustFragment({ + ...base, + projections: [ + { + metric: 'openRiskScore', + perDay: -2.345, + confidence: 'medium', + basisDays: 30, + horizonDays: 30, + projectedValue: 830.12, + }, + ], + }); + + expect(fragment.projections).toEqual([ + { + label: 'Open backlog risk score', + perDay: -2.35, + confidence: 'medium', + basisDays: 30, + horizonDays: 30, + projectedValue: 830.12, + }, + ]); + }); + + it('drops unknown metrics, bad confidences and out-of-range windows', () => { + const fragment = buildTrustFragment({ + ...base, + projections: [ + { + metric: 'mystery' as never, + perDay: 1, + confidence: 'high', + basisDays: 30, + horizonDays: 30, + projectedValue: 10, + }, + { + metric: 'sloBurning', + perDay: 1, + confidence: 'certain' as never, + basisDays: 30, + horizonDays: 30, + projectedValue: 10, + }, + { + metric: 'sloBurning', + perDay: Number.NaN, + confidence: 'low', + basisDays: 30, + horizonDays: 30, + projectedValue: 10, + }, + { + metric: 'openIssuesBySeverity', + perDay: 1, + confidence: 'low', + basisDays: 999, + horizonDays: 30, + projectedValue: 10, + }, + ], + }); + + expect(fragment.projections).toBeUndefined(); + }); + + it('omits projections entirely when the API sends none', () => { + expect(buildTrustFragment(base).projections).toBeUndefined(); + }); +}); diff --git a/scripts/trust-center-sync/map.ts b/scripts/trust-center-sync/map.ts index 87d8418b..4f6630e8 100644 --- a/scripts/trust-center-sync/map.ts +++ b/scripts/trust-center-sync/map.ts @@ -1,6 +1,7 @@ import { MAPPED_PUBLISHED_FRAMEWORKS, PUBLISHED_FRAMEWORKS } from './types'; import type { PublishableMappedRequirement, + PublishableProjection, PublishableRequirement, PublishableResponse, PublishedFramework, @@ -122,10 +123,53 @@ export function buildTrustFragment(response: PublishableResponse): TrustConfigFr } const fragment: TrustConfigFragment = { frameworks, controls }; + const projections = sanitizeProjections(response.projections); + if (projections.length > 0) fragment.projections = projections; + assertSanitized(fragment); return fragment; } +// The public wording lives here, keyed by the API's fixed metric ids — an API +// response can choose a metric and carry numbers, nothing else. Unknown +// metrics, non-finite numbers or out-of-range windows are dropped, never +// rendered. +const PROJECTION_LABELS: Record = { + openIssuesBySeverity: 'Open findings', + openRiskScore: 'Open backlog risk score', + sloBurning: 'Findings past 80% of their risk window', +}; + +const PROJECTION_CONFIDENCE: PublishableProjection['confidence'][] = ['high', 'medium', 'low']; + +function sanitizeProjections( + raw: PublishableProjection[] | undefined +): NonNullable { + const projections: NonNullable = []; + for (const projection of raw ?? []) { + const label = PROJECTION_LABELS[projection.metric]; + if (!label || !PROJECTION_CONFIDENCE.includes(projection.confidence)) continue; + const numbers = [ + projection.perDay, + projection.basisDays, + projection.horizonDays, + projection.projectedValue, + ]; + if (!numbers.every((value) => typeof value === 'number' && Number.isFinite(value))) continue; + if (projection.basisDays < 1 || projection.basisDays > 365) continue; + if (projection.horizonDays < 1 || projection.horizonDays > 365) continue; + projections.push({ + label, + perDay: Math.round(projection.perDay * 100) / 100, + confidence: projection.confidence, + basisDays: Math.round(projection.basisDays), + horizonDays: Math.round(projection.horizonDays), + projectedValue: Math.round(projection.projectedValue * 100) / 100, + }); + } + return projections; +} + function frameworkEntry( id: PublishedFramework, name: string, diff --git a/scripts/trust-center-sync/types.ts b/scripts/trust-center-sync/types.ts index 951c7626..e6104221 100644 --- a/scripts/trust-center-sync/types.ts +++ b/scripts/trust-center-sync/types.ts @@ -27,6 +27,16 @@ export interface PublishableMappedRequirement extends PublishableRequirement { framework: Exclude; } +export interface PublishableProjection { + metric: 'openIssuesBySeverity' | 'openRiskScore' | 'sloBurning'; + perDay: number; + confidence: 'high' | 'medium' | 'low'; + basisDays: number; + horizonDays: number; + projectedValue: number; + note?: string; +} + export interface PublishableResponse { status: 'OK' | 'STALE' | 'MISSING'; generatedAt: string | null; @@ -34,6 +44,8 @@ export interface PublishableResponse { mappedFrameworks?: PublishableMappedRequirement[]; /** Hours after which a record is STALE; absent on older APIs. */ maxAgeHours?: number; + /** Only present while status is OK; absent when trends are unreadable. */ + projections?: PublishableProjection[]; } export type TrustControlStatus = 'implemented' | 'partial' | 'planned'; @@ -63,6 +75,19 @@ export interface TrustConfigFragment { }>; /** Posture transitions since the previous publish, newest first. */ changelog?: ChangelogEntry[]; + /** + * Direction of the posture series, only while the record is OK. Labels are + * fixed strings chosen here (never taken from the API response), so the + * public page can only ever show reviewed wording beside plain numbers. + */ + projections?: Array<{ + label: string; + perDay: number; + confidence: 'high' | 'medium' | 'low'; + basisDays: number; + horizonDays: number; + projectedValue: number; + }>; } export interface ChangelogEntry { diff --git a/trust-center/README.md b/trust-center/README.md index ff9a77e7..5ec584b1 100644 --- a/trust-center/README.md +++ b/trust-center/README.md @@ -40,7 +40,8 @@ npx serve trust-center/dist Everything posture-related comes from the generated files and is never hand-edited: `trust.config.json` (framework cards, grouped controls, posture -changelog), `build-meta.json` (monitoring statement), `questionnaire.json` +changelog, series projections), `build-meta.json` (monitoring statement), +`questionnaire.json` (downloadable answer sheet, linked from Documents), and `posture-state.json` (published so the next sync can compute the changelog). diff --git a/trust-center/build.ts b/trust-center/build.ts index e5805978..cb1ce770 100644 --- a/trust-center/build.ts +++ b/trust-center/build.ts @@ -55,6 +55,9 @@ export function loadGenerated(dir: string): { if (generated.frameworks !== undefined && !Array.isArray(generated.frameworks)) { throw new Error(`${configPath} frameworks must be an array`); } + if (generated.projections !== undefined && !Array.isArray(generated.projections)) { + throw new Error(`${configPath} projections must be an array`); + } if (generated.controls !== undefined && !Array.isArray(generated.controls)) { throw new Error(`${configPath} controls must be an array`); } diff --git a/trust-center/render.test.ts b/trust-center/render.test.ts index 933a78c6..c44b4130 100644 --- a/trust-center/render.test.ts +++ b/trust-center/render.test.ts @@ -232,3 +232,36 @@ describe('escapeHtml', () => { expect(escapeHtml(`&<>"'`)).toBe('&<>"''); }); }); + +describe('projections section', () => { + it('renders fixed-label projection lines when the sync published them', () => { + const { 'index.html': html } = renderSite( + config, + { + frameworks: [], + controls: [], + projections: [ + { + label: 'Open backlog risk score', + perDay: -2.35, + confidence: 'medium', + basisDays: 30, + horizonDays: 30, + projectedValue: 830.12, + }, + ], + }, + null + ); + + expect(html).toContain('Where the posture is heading'); + expect(html).toContain('Open backlog risk score'); + expect(html).toContain('falling 2.35/day over the last 30 days (medium confidence)'); + expect(html).toContain('about 830.12 in 30 days'); + }); + + it('renders nothing without projections', () => { + const { 'index.html': html } = renderSite(config, { frameworks: [], controls: [] }, null); + expect(html).not.toContain('Where the posture is heading'); + }); +}); diff --git a/trust-center/render.ts b/trust-center/render.ts index c27276e1..65146003 100644 --- a/trust-center/render.ts +++ b/trust-center/render.ts @@ -70,6 +70,7 @@ ${renderPractices(config)} ${renderDocuments(config, assets)} ${renderSubprocessors(config)} ${renderChangelog(generated.changelog)} +${renderProjections(generated.projections)} ${renderProvenance(generatedAt, recordAt, config.monitoring, meta?.source?.maxAgeHours)} ${renderFaq(config)} ${renderContact(config)} @@ -172,6 +173,26 @@ ${[...domain.items] `; } +function renderProjections(projections: GeneratedConfig['projections']): string { + if (!projections || projections.length === 0) return ''; + const items = projections + .map((projection) => { + const movement = + projection.perDay === 0 + ? 'holding flat' + : projection.perDay > 0 + ? `rising ${Math.abs(projection.perDay)}/day` + : `falling ${Math.abs(projection.perDay)}/day`; + return `
  • ${escapeHtml(projection.label)} is ${movement} over the last ${projection.basisDays} days (${escapeHtml(projection.confidence)} confidence), projecting to about ${projection.projectedValue} in ${projection.horizonDays} days.
  • `; + }) + .join('\n'); + return `
    +

    Where the posture is heading

    +

    Straight-line projections over the published series, with a noise band. These are predictions, not commitments, and they publish only while the underlying assessment is current.

    +
      ${items}
    +
    `; +} + function renderProvenance( generatedAt: string, recordAt: string | null, diff --git a/trust-center/types.ts b/trust-center/types.ts index abbb8dd4..a2f862df 100644 --- a/trust-center/types.ts +++ b/trust-center/types.ts @@ -77,6 +77,15 @@ export interface GeneratedConfig { }>; /** Posture transitions recorded by the sync tool, newest first. */ changelog?: Array<{ date: string; title: string; description?: string }>; + /** Series directions published by the sync tool while the record is OK. */ + projections?: Array<{ + label: string; + perDay: number; + confidence: 'high' | 'medium' | 'low'; + basisDays: number; + horizonDays: number; + projectedValue: number; + }>; } export interface BuildMeta { diff --git a/ui/app/issues/[id]/page.test.tsx b/ui/app/issues/[id]/page.test.tsx index 35d3f918..cdecf9d1 100644 --- a/ui/app/issues/[id]/page.test.tsx +++ b/ui/app/issues/[id]/page.test.tsx @@ -262,6 +262,18 @@ describe('IssuePage fix simulation', () => { expect(within(card).getByText(/treat the counts as aging evidence/)).toBeInTheDocument(); }); + it('shows the SLO risk window on an open issue', async () => { + mockedIssue.mockResolvedValue({ + ...detail(), + slo: { windowDays: 30, ageDays: 24, burn: 0.8, state: 'burning', breachInDays: 6 }, + }); + + render(); + + expect(await screen.findByText('Risk window')).toBeInTheDocument(); + expect(screen.getByText(/24 of 30 days/)).toHaveTextContent('breaches in 6 days'); + }); + it('labels the graph coverage when the issue was scored', async () => { mockedIssue.mockResolvedValue({ ...detail(), diff --git a/ui/app/issues/[id]/page.tsx b/ui/app/issues/[id]/page.tsx index cc3e8aa1..a9f73fa4 100644 --- a/ui/app/issues/[id]/page.tsx +++ b/ui/app/issues/[id]/page.tsx @@ -279,6 +279,26 @@ function StatusDetails({ issue }: { issue: IssueDetail }) { Current status:{' '} {STATUS_LABELS[issue.status]}

    + {issue.status === 'open' && issue.slo && ( + + + {issue.slo.ageDays} of {issue.slo.windowDays} days + {issue.slo.state === 'breached' + ? ' — past the SLA window' + : issue.slo.state === 'burning' + ? ` — burning, breaches in ${issue.slo.breachInDays} days` + : ''} + + + )} {issue.status === 'resolved' && (issue.resolvedAt ? ( <> diff --git a/ui/app/issues/page.test.tsx b/ui/app/issues/page.test.tsx index f8cb7947..9233491d 100644 --- a/ui/app/issues/page.test.tsx +++ b/ui/app/issues/page.test.tsx @@ -210,6 +210,27 @@ describe('IssuesPage', () => { ); }); + it('marks findings burning or past their SLA window in the list', async () => { + mockedIssues.mockResolvedValue({ + items: [ + { + ...issue('RULE-004-burning', 'high'), + slo: { windowDays: 30, ageDays: 25, burn: 0.83, state: 'burning', breachInDays: 5 }, + }, + { + ...issue('RULE-004-breached', 'critical'), + slo: { windowDays: 7, ageDays: 9, burn: 1.29, state: 'breached', breachInDays: 0 }, + }, + ], + totalCount: 2, + }); + + render(); + + expect(await screen.findByText('SLA window 83% burnt')).toBeInTheDocument(); + expect(screen.getByText('past SLA window')).toBeInTheDocument(); + }); + it('filters by severity from the summary and removes it from its chip', async () => { mockedSummary.mockResolvedValue(summary({ critical: [2, 0, 1], high: [4, 1, 1] })); mockedIssues.mockResolvedValue(list([issue('a', 'high')])); diff --git a/ui/app/issues/page.tsx b/ui/app/issues/page.tsx index 0fedb0d6..8748b693 100644 --- a/ui/app/issues/page.tsx +++ b/ui/app/issues/page.tsx @@ -94,7 +94,19 @@ function IssueRow({ )} {issue.owningTeam} - {issue.riskScore.toFixed(1)} + + {issue.riskScore.toFixed(1)} + {issue.slo && issue.slo.state !== 'ok' && ( +
    + {issue.slo.state === 'breached' + ? 'past SLA window' + : `SLA window ${Math.round(issue.slo.burn * 100)}% burnt`} +
    + )} + {issue.status === 'resolved' && issue.resolvedAt && ( diff --git a/ui/lib/trends.test.ts b/ui/lib/trends.test.ts index a2363361..812fdb48 100644 --- a/ui/lib/trends.test.ts +++ b/ui/lib/trends.test.ts @@ -87,11 +87,13 @@ describe('trend copy and insight', () => { it('only lists metrics something writes', () => { expect(Object.keys(TREND_METRICS).sort()).toEqual([ 'adminPrincipals', + 'exposedByType', 'issuesCreated', 'issuesResolved', 'mttrHours', 'openIssuesBySeverity', 'openRiskScore', + 'sloBurning', ]); }); }); diff --git a/ui/lib/trends.ts b/ui/lib/trends.ts index 2bba23e6..e186132b 100644 --- a/ui/lib/trends.ts +++ b/ui/lib/trends.ts @@ -11,6 +11,26 @@ export interface TrendMetricCopy { } export const TREND_METRICS: Record = { + sloBurning: { + id: 'sloBurning', + label: 'Findings past 80% of their risk window', + unit: 'findings burning', + meaning: + 'Open findings older than 80% of their severity window (critical 7 days, high 30, medium 90, low 180), derived from creation dates — the backlog’s own pace against the SLA policy.', + href: '/issues', + hrefLabel: 'Open issues', + lowerIsBetter: true, + }, + exposedByType: { + id: 'exposedByType', + label: 'Internet-exposed resources', + unit: 'exposed resources', + meaning: + 'Vertices the collector marked internet-exposed, grouped by resource type in each day’s sample. One bounded label read per run, capped at 2000.', + href: '/attack-paths', + hrefLabel: 'Path explorer', + lowerIsBetter: true, + }, openIssuesBySeverity: { id: 'openIssuesBySeverity', label: 'Open issues', diff --git a/ui/types/index.ts b/ui/types/index.ts index 13e668d6..98dadeba 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -18,6 +18,16 @@ export interface Issue { resolvedReason?: string; suppression?: Suppression; statusHistory?: StatusChange[]; + /** Open issues only: age against the severity's risk window, computed on read. */ + slo?: IssueSlo; +} + +export interface IssueSlo { + windowDays: number; + ageDays: number; + burn: number; + state: 'ok' | 'burning' | 'breached'; + breachInDays: number; } export interface StatusChange {