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.
+
+`;
+}
+
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 {
|