@prism-d1/cli 1.0.79 → 1.0.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/github-workflows/prism-ai-metrics.yml +83 -122
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +66 -0
- package/dist/assets/github-workflows/prism-eval-gate.yml +1 -1
- package/dist/assets/gitlab-workflows/.gitlab-ci.yml +0 -3
- package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +53 -56
- package/dist/assets/infra/bin/app.ts +1 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +4 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +378 -1235
- package/dist/assets/infra/lib/lambda/event-schema.ts +144 -0
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +124 -36
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +249 -35
- package/dist/assets/infra/lib/lambda/security-remediation-tracker.ts +17 -2
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +1483 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +110 -119
- package/dist/assets/infra/package.json +2 -1
- package/dist/assets/infra/tsconfig.json +9 -2
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts +8 -2
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +32 -32
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-github-workflows.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-github-workflows.js +7 -2
- package/dist/src/commands/bootstrapper/install-github-workflows.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.js +7 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.js.map +1 -1
- package/dist/src/utils/region.d.ts +50 -0
- package/dist/src/utils/region.d.ts.map +1 -0
- package/dist/src/utils/region.js +62 -0
- package/dist/src/utils/region.js.map +1 -0
- package/package.json +2 -2
- package/dist/assets/eval-harness/README.md +0 -144
- package/dist/assets/github-workflows/README.md +0 -110
- package/dist/assets/github-workflows/prism-dora-weekly.yml +0 -334
- package/dist/assets/gitlab-workflows/prism-dora-weekly.yml +0 -181
- package/dist/assets/infra/lib/lambda/defect-correlator.ts +0 -142
- package/dist/assets/infra/lib/lambda/spec-to-code-calculator.ts +0 -123
- package/dist/assets/metric-hooks/README.md +0 -107
|
@@ -0,0 +1,1483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudWatch Custom Widget: Team Velocity panels.
|
|
3
|
+
*
|
|
4
|
+
* Invoked by CloudWatch dashboards with the VIEWING USER's IAM credentials
|
|
5
|
+
* (access control is lambda:InvokeFunction on this function). Reads the
|
|
6
|
+
* PRISM events table directly via the by-detail-type GSI — full history
|
|
7
|
+
* (365-day TTL), real event timestamps, no CloudWatch metric-dimension
|
|
8
|
+
* matching or 14-day ingestion-window constraints.
|
|
9
|
+
*
|
|
10
|
+
* Widget params:
|
|
11
|
+
* view — 'dora' | 'aidora' | 'eval' | 'governance' | 'agents' | 'security'
|
|
12
|
+
* Time range follows the dashboard (widgetContext.timeRange).
|
|
13
|
+
*
|
|
14
|
+
* The 'aidora' view additionally delegates to the otel-receiver's
|
|
15
|
+
* GET /v1/productivity handler (direct Lambda invoke — single aggregation
|
|
16
|
+
* implementation, no drift) for attribution-store KPIs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DynamoDBClient, QueryCommand, AttributeValue } from '@aws-sdk/client-dynamodb';
|
|
20
|
+
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
21
|
+
|
|
22
|
+
const ddb = new DynamoDBClient({});
|
|
23
|
+
const lambdaClient = new LambdaClient({});
|
|
24
|
+
const EVENTS_TABLE = process.env.EVENTS_TABLE || 'prism-d1-events';
|
|
25
|
+
const RECEIVER_FUNCTION = process.env.RECEIVER_FUNCTION || 'prism-d1-otel-receiver';
|
|
26
|
+
|
|
27
|
+
interface WidgetEvent {
|
|
28
|
+
describe?: boolean;
|
|
29
|
+
view?: string;
|
|
30
|
+
widgetContext?: { timeRange?: { start: number; end: number }; theme?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DOCS = `## Team Velocity / Executive / CISO Panels
|
|
34
|
+
DDB-backed panels reading the PRISM events table (by-detail-type GSI) and
|
|
35
|
+
the attribution store. Full history, real event timestamps.
|
|
36
|
+
|
|
37
|
+
### Parameters
|
|
38
|
+
| Name | Type | Default | Description |
|
|
39
|
+
|------|------|---------|-------------|
|
|
40
|
+
| view | string | dora | dora, aidora, repos, eval, governance, agents, security, exec, exec-security, ciso-exposure, ciso-sla, ciso-risk, ciso-shiftleft, ciso-classes |
|
|
41
|
+
|
|
42
|
+
The \`exec\` view additionally computes an **observed** PRISM level from
|
|
43
|
+
outcome metrics (capped at L4 — L5 requires an autonomy signal that no
|
|
44
|
+
emitter produces). This is not the scanner's capability score.
|
|
45
|
+
|
|
46
|
+
The \`ciso-*\` views read finding events directly rather than CloudWatch
|
|
47
|
+
metrics, because the metric publisher emits either the full dimension set
|
|
48
|
+
or none — a widget querying a **partial** set (e.g. AIOrigin alone) never
|
|
49
|
+
matches. \`ciso-classes\` additionally surfaces \`compliance_mappings\`,
|
|
50
|
+
a string array that cannot be a CloudWatch dimension at all.
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
type Palette = { fg: string; mut: string; bord: string; accent: string; ok: string; warn: string; danger: string };
|
|
54
|
+
function palette(dark: boolean): Palette {
|
|
55
|
+
return {
|
|
56
|
+
fg: dark ? '#d1d5db' : '#16191f',
|
|
57
|
+
mut: dark ? '#8d99a8' : '#687078',
|
|
58
|
+
bord: dark ? '#414750' : '#e9ebed',
|
|
59
|
+
accent: dark ? '#44b9d6' : '#0073bb',
|
|
60
|
+
ok: dark ? '#5fd38d' : '#1d8102',
|
|
61
|
+
warn: dark ? '#e0b13e' : '#906806',
|
|
62
|
+
danger: dark ? '#f2827f' : '#d13212',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const esc = (s: string): string => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
67
|
+
const num = (n: number): string => n.toLocaleString('en-US');
|
|
68
|
+
const pct = (n: number | null): string => (n === null || Number.isNaN(n) ? '—' : `${Math.round(n * 10) / 10}%`);
|
|
69
|
+
|
|
70
|
+
/** Horizontal CSS bar scaled against a maximum (inline, no JS). */
|
|
71
|
+
function bar(value: number, max: number, color: string): string {
|
|
72
|
+
const w = max > 0 ? Math.max(1, Math.round((value / max) * 100)) : 0;
|
|
73
|
+
return `<div style="background:${color};height:8px;width:${w}%;border-radius:2px;min-width:2px;margin-bottom:2px"></div>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- Events table access ----
|
|
77
|
+
|
|
78
|
+
interface PrismEvent {
|
|
79
|
+
timestamp: string;
|
|
80
|
+
detail_type: string;
|
|
81
|
+
data: Record<string, any>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Query all events of a detail-type within [from, to] ISO range (paginated). */
|
|
85
|
+
async function queryEvents(detailType: string, fromIso: string, toIso: string): Promise<PrismEvent[]> {
|
|
86
|
+
const out: PrismEvent[] = [];
|
|
87
|
+
let lastKey: Record<string, AttributeValue> | undefined;
|
|
88
|
+
do {
|
|
89
|
+
const resp = await ddb.send(new QueryCommand({
|
|
90
|
+
TableName: EVENTS_TABLE,
|
|
91
|
+
IndexName: 'by-detail-type',
|
|
92
|
+
KeyConditionExpression: 'detail_type = :dt AND sk BETWEEN :from AND :to',
|
|
93
|
+
ExpressionAttributeValues: {
|
|
94
|
+
':dt': { S: detailType },
|
|
95
|
+
':from': { S: fromIso },
|
|
96
|
+
':to': { S: toIso },
|
|
97
|
+
},
|
|
98
|
+
ExclusiveStartKey: lastKey,
|
|
99
|
+
Limit: 1000,
|
|
100
|
+
}));
|
|
101
|
+
for (const item of resp.Items ?? []) {
|
|
102
|
+
try {
|
|
103
|
+
out.push({
|
|
104
|
+
// sk may be `${timestamp}#${finding_id}` for per-finding fan-out
|
|
105
|
+
// events (see the sort-key note in metrics-processor.ts). Strip the
|
|
106
|
+
// discriminator so Date.parse and slice() see a clean ISO string.
|
|
107
|
+
// No-op for the bare-timestamp sks written before that change.
|
|
108
|
+
timestamp: (item.sk?.S ?? '').split('#')[0],
|
|
109
|
+
detail_type: detailType,
|
|
110
|
+
data: JSON.parse(item.data?.S ?? '{}'),
|
|
111
|
+
});
|
|
112
|
+
} catch { /* skip unparseable rows */ }
|
|
113
|
+
}
|
|
114
|
+
lastKey = resp.LastEvaluatedKey;
|
|
115
|
+
} while (lastKey && out.length < 50000);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- HTML helpers ----
|
|
120
|
+
|
|
121
|
+
function kpiRow(cells: Array<{ label: string; value: string; note?: string; color?: string }>, p: Palette): string {
|
|
122
|
+
return `<div style="display:flex;gap:12px;flex-wrap:wrap">${cells.map(c => `
|
|
123
|
+
<div style="flex:1;min-width:130px;border:1px solid ${p.bord};border-radius:6px;padding:10px 14px">
|
|
124
|
+
<div style="color:${p.mut};font-size:11px">${esc(c.label)}</div>
|
|
125
|
+
<div style="color:${c.color ?? p.fg};font-size:24px;font-weight:600;margin:2px 0">${c.value}</div>
|
|
126
|
+
${c.note ? `<div style="color:${p.mut};font-size:10px">${esc(c.note)}</div>` : ''}
|
|
127
|
+
</div>`).join('')}</div>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function emptyState(what: string, hint: string, p: Palette): string {
|
|
131
|
+
return `<div style="border:1px dashed ${p.bord};border-radius:6px;padding:16px;color:${p.mut}">
|
|
132
|
+
No ${esc(what)} events in the selected time range.<br>
|
|
133
|
+
<span style="font-size:11px">${esc(hint)}</span></div>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function table(headers: string[], rows: string[][], p: Palette, rightFrom = 1): string {
|
|
137
|
+
if (rows.length === 0) return '';
|
|
138
|
+
return `<table style="border-collapse:collapse;width:100%;margin-top:10px">
|
|
139
|
+
<tr>${headers.map((h, i) => `<th style="padding:5px 8px;color:${p.mut};font-weight:normal;text-align:${i >= rightFrom ? 'right' : 'left'};font-size:11px">${esc(h)}</th>`).join('')}</tr>
|
|
140
|
+
${rows.map(r => `<tr style="border-top:1px solid ${p.bord}">${r.map((c, i) => `<td style="padding:5px 8px;text-align:${i >= rightFrom ? 'right' : 'left'};font-size:12px">${c}</td>`).join('')}</tr>`).join('')}
|
|
141
|
+
</table>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function footnote(text: string, p: Palette): string {
|
|
145
|
+
return `<div style="color:${p.mut};font-size:10px;margin-top:8px">${esc(text)}</div>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Inline SVG sparkline. Custom widgets cannot execute JavaScript, so trend
|
|
150
|
+
* visuals must be server-rendered — no Chart.js, no canvas.
|
|
151
|
+
* Points are [x-ordered] values; y is auto-scaled to the series range.
|
|
152
|
+
*/
|
|
153
|
+
function sparkline(values: number[], p: Palette, opts: { color?: string; height?: number; width?: number; yMax?: number } = {}): string {
|
|
154
|
+
const w = opts.width ?? 260;
|
|
155
|
+
const h = opts.height ?? 36;
|
|
156
|
+
const color = opts.color ?? p.accent;
|
|
157
|
+
if (values.length === 0) return `<span style="color:${p.mut};font-size:11px">no data</span>`;
|
|
158
|
+
if (values.length === 1) {
|
|
159
|
+
return `<svg width="${w}" height="${h}"><circle cx="${w / 2}" cy="${h / 2}" r="3" fill="${color}"/></svg>`;
|
|
160
|
+
}
|
|
161
|
+
const max = opts.yMax ?? Math.max(...values, 0.0001);
|
|
162
|
+
const min = Math.min(...values, 0);
|
|
163
|
+
const span = max - min || 1;
|
|
164
|
+
const pts = values.map((v, i) => {
|
|
165
|
+
const x = (i / (values.length - 1)) * (w - 4) + 2;
|
|
166
|
+
const y = h - 2 - ((v - min) / span) * (h - 4);
|
|
167
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
168
|
+
}).join(' ');
|
|
169
|
+
return `<svg width="${w}" height="${h}" style="display:block">
|
|
170
|
+
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/>
|
|
171
|
+
</svg>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Bucket timestamped values into per-day series (ascending by day). */
|
|
175
|
+
function dailySeries<T>(items: T[], tsOf: (t: T) => string, valueOf: (bucket: T[]) => number): number[] {
|
|
176
|
+
const byDay = new Map<string, T[]>();
|
|
177
|
+
for (const it of items) {
|
|
178
|
+
const day = tsOf(it).slice(0, 10);
|
|
179
|
+
if (!day) continue;
|
|
180
|
+
const arr = byDay.get(day) ?? [];
|
|
181
|
+
arr.push(it);
|
|
182
|
+
byDay.set(day, arr);
|
|
183
|
+
}
|
|
184
|
+
return [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([, bucket]) => valueOf(bucket));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- View: dora (proxy KPIs, honestly labeled) ----
|
|
188
|
+
|
|
189
|
+
async function renderDora(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
|
|
190
|
+
const [deploys, prs] = await Promise.all([
|
|
191
|
+
queryEvents('prism.d1.deploy', fromIso, toIso),
|
|
192
|
+
queryEvents('prism.d1.pr', fromIso, toIso),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
if (deploys.length === 0 && prs.length === 0) {
|
|
196
|
+
return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge. Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const mergeFreq = days > 0 ? deploys.length / days : deploys.length;
|
|
200
|
+
const leadTimes = prs.map(e => e.data.dora?.lead_time_seconds).filter((v: any) => typeof v === 'number' && v > 0);
|
|
201
|
+
const avgLeadHrs = leadTimes.length ? leadTimes.reduce((a: number, b: number) => a + b, 0) / leadTimes.length / 3600 : null;
|
|
202
|
+
// Median is the DORA convention for lead time. Available only because the
|
|
203
|
+
// workflow emits per-PR facts instead of a pre-averaged weekly figure.
|
|
204
|
+
const medLeadHrs = median(leadTimes) !== null ? (median(leadTimes) as number) / 3600 : null;
|
|
205
|
+
|
|
206
|
+
// CFR and MTTR are aggregated HERE, over the dashboard's selected range,
|
|
207
|
+
// from the per-PR is_failure_fix label. Previously both came from the single
|
|
208
|
+
// most recent weekly assessment, so they ignored the time-range picker while
|
|
209
|
+
// the other two KPIs in this row honoured it.
|
|
210
|
+
const failureFixes = prs.filter(e => e.data.dora?.is_failure_fix === true);
|
|
211
|
+
const cfr = prs.length > 0 ? (failureFixes.length / prs.length) * 100 : null;
|
|
212
|
+
const fixLeadTimes = failureFixes
|
|
213
|
+
.map(e => e.data.dora?.lead_time_seconds)
|
|
214
|
+
.filter((v: any) => typeof v === 'number' && v > 0);
|
|
215
|
+
const mttrH = fixLeadTimes.length
|
|
216
|
+
? fixLeadTimes.reduce((a: number, b: number) => a + b, 0) / fixLeadTimes.length / 3600
|
|
217
|
+
: null;
|
|
218
|
+
|
|
219
|
+
return kpiRow([
|
|
220
|
+
{ label: 'Merge Frequency', value: `${Math.round(mergeFreq * 10) / 10}/day`, note: `${num(deploys.length)} merges in range (deploy proxy)` },
|
|
221
|
+
{ label: 'PR Cycle Time (p50)', value: medLeadHrs === null ? '—' : `${Math.round(medLeadHrs * 10) / 10}h`, note: avgLeadHrs === null ? 'PR open → merge (lead-time proxy)' : `mean ${Math.round(avgLeadHrs * 10) / 10}h · lead-time proxy`, color: medLeadHrs !== null && medLeadHrs < 24 ? p.ok : undefined },
|
|
222
|
+
{ label: 'Revert Rate', value: pct(cfr), note: `${num(failureFixes.length)} of ${num(prs.length)} PRs (CFR proxy)`, color: cfr === null ? undefined : cfr < 15 ? p.ok : p.warn },
|
|
223
|
+
{ label: 'Revert Turnaround', value: mttrH === null ? '—' : `${Math.round(mttrH * 10) / 10}h`, note: 'revert PR open → merge (MTTR proxy)' },
|
|
224
|
+
], p) + footnote('Proxies: merge≈deploy, PR cycle≈lead time, revert/hotfix-titled PRs≈failures. All four aggregate over the selected time range. True deploy/incident integration is on the roadmap.', p);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Median of a numeric list, or null when empty. */
|
|
228
|
+
function median(values: number[]): number | null {
|
|
229
|
+
if (values.length === 0) return null;
|
|
230
|
+
const s = [...values].sort((a, b) => a - b);
|
|
231
|
+
const mid = Math.floor(s.length / 2);
|
|
232
|
+
return s.length % 2 === 0 ? (s[mid - 1] + s[mid]) / 2 : s[mid];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- Attribution coverage (the denominator every AI metric needs) ----
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every AI metric on these dashboards — AI share, merge rate, defect rate,
|
|
239
|
+
* $/shipped, the observed PRISM level, the CISO per-100-commits comparison — is
|
|
240
|
+
* computed from the attribution store, which only sees developers who installed
|
|
241
|
+
* codeburn AND its sync schedule. It is a SAMPLE of unknown coverage, and
|
|
242
|
+
* without a denominator a 40%-onboarded team and a genuinely 40%-AI team render
|
|
243
|
+
* identically.
|
|
244
|
+
*
|
|
245
|
+
* CI gives us the census: prism-ai-metrics.yml fires on every merge regardless
|
|
246
|
+
* of who authored it and reports pr.total_commits. Attribution gives us the
|
|
247
|
+
* sample. The ratio is the coverage.
|
|
248
|
+
*
|
|
249
|
+
* Returns null when CI events are absent, since coverage is unknowable then —
|
|
250
|
+
* deliberately not 0%, which would imply "measured and found nothing".
|
|
251
|
+
*/
|
|
252
|
+
function attributionCoverage(prs: PrismEvent[], attributedCommits: number | null): {
|
|
253
|
+
pct: number | null;
|
|
254
|
+
ciCommits: number;
|
|
255
|
+
attributed: number | null;
|
|
256
|
+
} {
|
|
257
|
+
const ciCommits = prs.reduce((sum, e) => {
|
|
258
|
+
const n = e.data.pr?.total_commits;
|
|
259
|
+
return sum + (typeof n === 'number' && n > 0 ? n : 0);
|
|
260
|
+
}, 0);
|
|
261
|
+
if (ciCommits === 0 || attributedCommits === null) {
|
|
262
|
+
return { pct: null, ciCommits, attributed: attributedCommits };
|
|
263
|
+
}
|
|
264
|
+
// Cap at 100%: attribution can legitimately exceed CI-observed commits when a
|
|
265
|
+
// developer syncs work that has not merged yet, which is not over-coverage.
|
|
266
|
+
return {
|
|
267
|
+
pct: Math.min((attributedCommits / ciCommits) * 100, 100),
|
|
268
|
+
ciCommits,
|
|
269
|
+
attributed: attributedCommits,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Coverage KPI cell, with an honest note about what low coverage means. */
|
|
274
|
+
function coverageCell(cov: { pct: number | null; ciCommits: number; attributed: number | null }, p: Palette) {
|
|
275
|
+
const note = cov.pct === null
|
|
276
|
+
? (cov.ciCommits === 0 ? 'no CI commit census in range' : 'attribution store unreachable')
|
|
277
|
+
: `${num(cov.attributed ?? 0)} attributed / ${num(cov.ciCommits)} seen by CI`;
|
|
278
|
+
return {
|
|
279
|
+
label: 'Attribution Coverage',
|
|
280
|
+
value: pct(cov.pct),
|
|
281
|
+
note,
|
|
282
|
+
// Below ~80% every AI metric on this dashboard is a low-confidence sample.
|
|
283
|
+
color: cov.pct === null ? undefined : cov.pct >= 80 ? p.ok : cov.pct >= 50 ? p.warn : p.danger,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- View: aidora (attribution store + eval events) ----
|
|
288
|
+
|
|
289
|
+
/** Invoke an otel-receiver GET route directly (single aggregation impl, no drift). */
|
|
290
|
+
async function invokeReceiver(routePath: string, query: string): Promise<any> {
|
|
291
|
+
const receiverEvent = {
|
|
292
|
+
rawPath: routePath,
|
|
293
|
+
rawQueryString: query,
|
|
294
|
+
requestContext: {
|
|
295
|
+
http: { method: 'GET', path: routePath },
|
|
296
|
+
// Direct invoke bypasses the API GW JWT authorizer by design: this code
|
|
297
|
+
// path already required lambda:InvokeFunction IAM, which gates viewers.
|
|
298
|
+
authorizer: { jwt: { claims: { email: 'cloudwatch-dashboard-widget' } } },
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const resp = await lambdaClient.send(new InvokeCommand({
|
|
302
|
+
FunctionName: RECEIVER_FUNCTION,
|
|
303
|
+
Payload: Buffer.from(JSON.stringify(receiverEvent)),
|
|
304
|
+
}));
|
|
305
|
+
const raw = JSON.parse(Buffer.from(resp.Payload ?? new Uint8Array()).toString() || '{}');
|
|
306
|
+
if (raw.statusCode !== 200) throw new Error(`${routePath} HTTP ${raw.statusCode ?? '?'}`);
|
|
307
|
+
return JSON.parse(raw.body);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function fetchProductivity(fromIso: string, toIso: string): Promise<any> {
|
|
311
|
+
// Receiver validates from/to as YYYY-MM-DD (day granularity).
|
|
312
|
+
return invokeReceiver('/v1/productivity', `user=all&from=${fromIso.slice(0, 10)}&to=${toIso.slice(0, 10)}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- View: repos (per-repo attribution breakdown) ----
|
|
316
|
+
|
|
317
|
+
async function renderRepos(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
318
|
+
let report: any;
|
|
319
|
+
try {
|
|
320
|
+
report = await invokeReceiver('/v1/repos', `from=${fromIso.slice(0, 10)}&to=${toIso.slice(0, 10)}`);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return emptyState('repo attribution', `Attribution store unreachable (${(err as Error).message}). Requires codeburn sync --attribution.`, p);
|
|
323
|
+
}
|
|
324
|
+
const rows: any[] = report?.repos ?? [];
|
|
325
|
+
if (rows.length === 0) {
|
|
326
|
+
return emptyState('repo attribution', 'Populated by `codeburn sync push --attribution` from developer machines.', p);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const maxCommits = Math.max(...rows.map(r => r.commits.total), 1);
|
|
330
|
+
const body = rows.slice(0, 15).map(r => [
|
|
331
|
+
esc(r.repo),
|
|
332
|
+
`${bar(r.commits.total, maxCommits, p.accent)}<span style="color:${p.mut}">${num(r.commits.total)}</span>`,
|
|
333
|
+
`${num(r.commits.ai)} / ${num(r.commits.human)}`,
|
|
334
|
+
`<span style="color:${(r.ratios.aiSharePct ?? 0) >= 30 ? p.ok : p.mut}">${pct(r.ratios.aiSharePct)}</span>`,
|
|
335
|
+
`<span style="color:${r.ratios.mergeRatePct === null ? p.mut : r.ratios.mergeRatePct >= 85 ? p.ok : p.warn}">${pct(r.ratios.mergeRatePct)}</span>`,
|
|
336
|
+
`<span style="color:${r.ratios.defectRatePct === null ? p.mut : r.ratios.defectRatePct <= 10 ? p.ok : p.danger}">${pct(r.ratios.defectRatePct)}</span>`,
|
|
337
|
+
num(r.contributors),
|
|
338
|
+
esc((r.lastActivity ?? '').slice(0, 10) || '—'),
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
return table(
|
|
342
|
+
['Repository', 'Commits', 'AI / Human', 'AI Share', 'AI Merge Rate', 'AI Defect Rate', 'Devs', 'Last Activity'],
|
|
343
|
+
body, p, 2,
|
|
344
|
+
) + footnote('Attribution store — full history. Spend is per-developer (see Developer Productivity); sessions spanning repos cannot be split by repo without double-counting.', p);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function renderAidora(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
348
|
+
const [report, evals, prs] = await Promise.all([
|
|
349
|
+
fetchProductivity(fromIso, toIso).catch(() => null),
|
|
350
|
+
queryEvents('prism.d1.eval', fromIso, toIso),
|
|
351
|
+
queryEvents('prism.d1.pr', fromIso, toIso),
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
const t = report?.totals;
|
|
355
|
+
const passRate = evals.length > 0
|
|
356
|
+
? (evals.filter(e => e.data.eval?.result === 'PASS').length / evals.length) * 100
|
|
357
|
+
: null;
|
|
358
|
+
const cov = attributionCoverage(prs, t?.commits?.total ?? null);
|
|
359
|
+
|
|
360
|
+
const cells = [
|
|
361
|
+
{ label: 'AI Share of Commits', value: pct(t?.ratios?.aiSharePct ?? null), note: 'attribution store', color: (t?.ratios?.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
|
|
362
|
+
{ label: 'AI → Merge Rate', value: pct(t?.ratios?.mergeRatePct ?? null), note: 'L2 ≥20% · L4 ≥45%', color: (t?.ratios?.mergeRatePct ?? 0) >= 45 ? p.ok : (t?.ratios?.mergeRatePct ?? 0) >= 20 ? p.warn : undefined },
|
|
363
|
+
{ label: 'AI Defect Proxy', value: pct(t?.ratios?.defectRatePct ?? null), note: 'reverted / merged AI commits', color: (t?.ratios?.defectRatePct ?? 100) <= 10 ? p.ok : p.warn },
|
|
364
|
+
{ label: '$ / Shipped Commit', value: t?.ratios?.costPerShippedCommit != null ? `$${t.ratios.costPerShippedCommit.toFixed(2)}` : '—', note: 'attribution store' },
|
|
365
|
+
{ label: 'Eval Gate Pass', value: pct(passRate), note: evals.length ? `${num(evals.length)} gate runs · L2 ≥80% · L4 ≥95%` : 'no eval events in range', color: passRate === null ? undefined : passRate >= 95 ? p.ok : passRate >= 80 ? p.warn : p.danger },
|
|
366
|
+
coverageCell(cov, p),
|
|
367
|
+
];
|
|
368
|
+
const warn = report === null ? footnote('Attribution store unreachable — commit KPIs unavailable.', p) : '';
|
|
369
|
+
const covNote = cov.pct !== null && cov.pct < 80
|
|
370
|
+
? footnote(`Attribution coverage is ${pct(cov.pct)} — the four AI KPIs above are a sample, not a census. Developers without codeburn sync installed contribute commits that CI counts and attribution does not, which understates AI share. Coverage rises as more developers run \`prism-cli bootstrapper setup-otel-sync\`.`, p)
|
|
371
|
+
: '';
|
|
372
|
+
return kpiRow(cells, p) + warn + covNote;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---- Observed PRISM level (computed from live metrics, not the scanner) ----
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The scanner (`prism-cli assessment`) scores **capability** from static repo
|
|
379
|
+
* signals — does CLAUDE.md exist, is there a specs/ dir, are AI trailers in
|
|
380
|
+
* commit conventions. This computes **observed** maturity from outcomes that
|
|
381
|
+
* are actually flowing, using the level definitions in the README maturity
|
|
382
|
+
* table. The two can legitimately disagree: a team can score L4 on capability
|
|
383
|
+
* (all the config exists) while observing L2 (nobody is shipping AI code), and
|
|
384
|
+
* that gap is the interesting signal.
|
|
385
|
+
*
|
|
386
|
+
* Capped at L4. L5 ("agents contributing to architecture, >20% autonomous
|
|
387
|
+
* deployments") requires an autonomy signal that no emitter produces — we do
|
|
388
|
+
* not have a way to distinguish an autonomous deployment from an assisted one,
|
|
389
|
+
* so claiming L5 from these inputs would be fabrication.
|
|
390
|
+
*
|
|
391
|
+
* Returns null when there is not enough data to assess. That is deliberately
|
|
392
|
+
* distinct from L1: "no data" means the pipeline isn't reporting, whereas L1
|
|
393
|
+
* is a real finding ("ad hoc AI use, no metrics").
|
|
394
|
+
*/
|
|
395
|
+
type ObservedLevel = {
|
|
396
|
+
level: string;
|
|
397
|
+
label: string;
|
|
398
|
+
gates: Array<{ name: string; pass: boolean; detail: string }>;
|
|
399
|
+
blockedBy: string | null;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
function computeObservedLevel(input: {
|
|
403
|
+
aiSharePct: number | null;
|
|
404
|
+
mergeRatePct: number | null;
|
|
405
|
+
defectRatePct: number | null;
|
|
406
|
+
evalRuns: number;
|
|
407
|
+
evalPassPct: number | null;
|
|
408
|
+
costByToolCount: number;
|
|
409
|
+
mcpCalls: number;
|
|
410
|
+
guardrailTriggers: number;
|
|
411
|
+
}): ObservedLevel | null {
|
|
412
|
+
const { aiSharePct, mergeRatePct, defectRatePct, evalRuns, evalPassPct,
|
|
413
|
+
costByToolCount, mcpCalls, guardrailTriggers } = input;
|
|
414
|
+
|
|
415
|
+
// Not assessable: no commit attribution at all.
|
|
416
|
+
if (aiSharePct === null) return null;
|
|
417
|
+
|
|
418
|
+
const gates = [
|
|
419
|
+
{
|
|
420
|
+
name: 'L2 — AI adoption',
|
|
421
|
+
pass: aiSharePct >= 30,
|
|
422
|
+
detail: `AI share ${pct(aiSharePct)} (need >= 30%)`,
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: 'L3 — eval gates in pipeline',
|
|
426
|
+
pass: evalRuns > 0 && (evalPassPct ?? 0) >= 80 && (mergeRatePct ?? 0) >= 20,
|
|
427
|
+
detail: evalRuns === 0
|
|
428
|
+
? 'no eval gate runs in range'
|
|
429
|
+
: `eval pass ${pct(evalPassPct)} (need >= 80%), AI merge ${pct(mergeRatePct)} (need >= 20%)`,
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'L4 — FinOps + governed agents',
|
|
433
|
+
pass: costByToolCount > 0 && (mcpCalls > 0 || guardrailTriggers > 0)
|
|
434
|
+
&& defectRatePct !== null && defectRatePct <= 20,
|
|
435
|
+
detail: `cost attribution ${costByToolCount > 0 ? 'present' : 'missing'}, `
|
|
436
|
+
+ `governance events ${mcpCalls + guardrailTriggers}, AI defect ${pct(defectRatePct)} (need <= 20%)`,
|
|
437
|
+
},
|
|
438
|
+
];
|
|
439
|
+
|
|
440
|
+
// Levels are cumulative — the first failed gate caps the level.
|
|
441
|
+
let level = 'L1';
|
|
442
|
+
let label = 'Experimental';
|
|
443
|
+
let blockedBy: string | null = gates[0].name;
|
|
444
|
+
if (gates[0].pass) {
|
|
445
|
+
level = 'L2'; label = 'Structured'; blockedBy = gates[1].name;
|
|
446
|
+
if (gates[1].pass) {
|
|
447
|
+
level = 'L3'; label = 'Integrated'; blockedBy = gates[2].name;
|
|
448
|
+
if (gates[2].pass) {
|
|
449
|
+
level = 'L4'; label = 'Orchestrated';
|
|
450
|
+
// L5 is intentionally unreachable from these inputs.
|
|
451
|
+
blockedBy = 'L5 — requires an autonomy signal (not emitted)';
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { level, label, gates, blockedBy };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---- View: exec (business KPIs + delivery proxies + observed level) ----
|
|
459
|
+
|
|
460
|
+
async function renderExec(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
|
|
461
|
+
const [report, evals, mcp, guardrails, deploys, prs] = await Promise.all([
|
|
462
|
+
fetchProductivity(fromIso, toIso).catch(() => null),
|
|
463
|
+
queryEvents('prism.d1.eval', fromIso, toIso),
|
|
464
|
+
queryEvents('prism.d1.mcp.tool_call', fromIso, toIso),
|
|
465
|
+
queryEvents('prism.d1.guardrail', fromIso, toIso),
|
|
466
|
+
queryEvents('prism.d1.deploy', fromIso, toIso),
|
|
467
|
+
queryEvents('prism.d1.pr', fromIso, toIso),
|
|
468
|
+
]);
|
|
469
|
+
|
|
470
|
+
const t = report?.totals;
|
|
471
|
+
const r = t?.ratios ?? {};
|
|
472
|
+
const evalPassPct = evals.length > 0
|
|
473
|
+
? (evals.filter(e => e.data.eval?.result === 'PASS').length / evals.length) * 100
|
|
474
|
+
: null;
|
|
475
|
+
|
|
476
|
+
const observed = computeObservedLevel({
|
|
477
|
+
aiSharePct: r.aiSharePct ?? null,
|
|
478
|
+
mergeRatePct: r.mergeRatePct ?? null,
|
|
479
|
+
defectRatePct: r.defectRatePct ?? null,
|
|
480
|
+
evalRuns: evals.length,
|
|
481
|
+
evalPassPct,
|
|
482
|
+
costByToolCount: Object.keys(t?.usage?.byTool ?? {}).length,
|
|
483
|
+
mcpCalls: mcp.length,
|
|
484
|
+
guardrailTriggers: guardrails.length,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// --- Observed level card ---
|
|
488
|
+
const levelCard = observed === null
|
|
489
|
+
? `<div style="border:1px dashed ${p.bord};border-radius:6px;padding:10px 14px;color:${p.mut};min-width:230px">
|
|
490
|
+
<div style="font-size:11px">Observed PRISM Level</div>
|
|
491
|
+
<div style="font-size:20px;font-weight:600;margin:2px 0">Insufficient data</div>
|
|
492
|
+
<div style="font-size:10px">No commit attribution in range — run <code>codeburn sync push --attribution</code></div>
|
|
493
|
+
</div>`
|
|
494
|
+
: `<div style="border:1px solid ${p.accent};border-radius:6px;padding:10px 14px;min-width:230px">
|
|
495
|
+
<div style="color:${p.mut};font-size:11px">Observed PRISM Level <span style="opacity:.8">(outcomes)</span></div>
|
|
496
|
+
<div style="font-size:26px;font-weight:600;color:${p.accent};margin:2px 0">${esc(observed.level)} <span style="font-size:14px;color:${p.fg}">${esc(observed.label)}</span></div>
|
|
497
|
+
<div style="color:${p.mut};font-size:10px">Next: ${esc(observed.blockedBy ?? '—')}</div>
|
|
498
|
+
</div>`;
|
|
499
|
+
|
|
500
|
+
const gateTable = observed === null ? '' : table(
|
|
501
|
+
['Level gate', 'Status', 'Evidence'],
|
|
502
|
+
observed.gates.map(g => [
|
|
503
|
+
esc(g.name),
|
|
504
|
+
`<span style="color:${g.pass ? p.ok : p.mut}">${g.pass ? 'met' : 'not met'}</span>`,
|
|
505
|
+
`<span style="color:${p.mut}">${esc(g.detail)}</span>`,
|
|
506
|
+
]), p, 1);
|
|
507
|
+
|
|
508
|
+
// --- Business KPIs (attribution store) ---
|
|
509
|
+
const cov = attributionCoverage(prs, t?.commits?.total ?? null);
|
|
510
|
+
const businessKpis = kpiRow([
|
|
511
|
+
{ label: 'AI Share of Commits', value: pct(r.aiSharePct ?? null), note: 'L2 >= 30%', color: (r.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
|
|
512
|
+
{ label: 'AI Merge Rate', value: pct(r.mergeRatePct ?? null), note: 'L2 >= 20% · L4 >= 45%', color: (r.mergeRatePct ?? 0) >= 45 ? p.ok : (r.mergeRatePct ?? 0) >= 20 ? p.warn : undefined },
|
|
513
|
+
{ label: '$ / Shipped Commit', value: r.costPerShippedCommit != null ? `$${r.costPerShippedCommit.toFixed(2)}` : '—', note: 'unit economics' },
|
|
514
|
+
{ label: `AI Spend (${Math.round(days)}d)`, value: t?.usage?.costUsd != null ? `$${Math.round(t.usage.costUsd).toLocaleString('en-US')}` : '—', note: 'attribution store' },
|
|
515
|
+
{ label: 'Eval Gate Pass', value: pct(evalPassPct), note: evals.length ? `${num(evals.length)} runs` : 'no runs in range', color: evalPassPct === null ? undefined : evalPassPct >= 95 ? p.ok : evalPassPct >= 80 ? p.warn : p.danger },
|
|
516
|
+
coverageCell(cov, p),
|
|
517
|
+
], p);
|
|
518
|
+
|
|
519
|
+
// --- Delivery proxies, humanized units ---
|
|
520
|
+
const mergeFreq = days > 0 ? deploys.length / days : deploys.length;
|
|
521
|
+
const leadTimes = prs.map(e => e.data.dora?.lead_time_seconds).filter((v: any) => typeof v === 'number' && v > 0);
|
|
522
|
+
const medLead = median(leadTimes);
|
|
523
|
+
const medLeadHrs = medLead !== null ? medLead / 3600 : null;
|
|
524
|
+
// Aggregated over the selected range from per-PR facts — see renderDora.
|
|
525
|
+
const failureFixes = prs.filter(e => e.data.dora?.is_failure_fix === true);
|
|
526
|
+
const cfr = prs.length > 0 ? (failureFixes.length / prs.length) * 100 : null;
|
|
527
|
+
const fixLeadTimes = failureFixes
|
|
528
|
+
.map(e => e.data.dora?.lead_time_seconds)
|
|
529
|
+
.filter((v: any) => typeof v === 'number' && v > 0);
|
|
530
|
+
const mttrH = fixLeadTimes.length
|
|
531
|
+
? fixLeadTimes.reduce((a: number, b: number) => a + b, 0) / fixLeadTimes.length / 3600
|
|
532
|
+
: null;
|
|
533
|
+
|
|
534
|
+
const deliveryKpis = (deploys.length === 0 && prs.length === 0)
|
|
535
|
+
? footnote('No delivery events in range — install prism-ai-metrics.yml to populate the delivery proxies.', p)
|
|
536
|
+
: kpiRow([
|
|
537
|
+
{ label: 'Merge Frequency', value: `${Math.round(mergeFreq * 10) / 10}/day`, note: 'deploy proxy' },
|
|
538
|
+
{ label: 'PR Cycle Time', value: medLeadHrs === null ? '—' : `${Math.round(medLeadHrs * 10) / 10}h`, note: 'p50 · lead-time proxy', color: medLeadHrs !== null && medLeadHrs < 24 ? p.ok : undefined },
|
|
539
|
+
{ label: 'Revert Rate', value: pct(cfr), note: 'change-failure proxy', color: cfr !== null && cfr < 15 ? p.ok : cfr !== null ? p.warn : undefined },
|
|
540
|
+
{ label: 'Revert Turnaround', value: mttrH === null ? '—' : `${Math.round(mttrH * 10) / 10}h`, note: 'MTTR proxy' },
|
|
541
|
+
], p);
|
|
542
|
+
|
|
543
|
+
const warn = report === null
|
|
544
|
+
? footnote('Attribution store unreachable — business KPIs and observed level unavailable.', p)
|
|
545
|
+
: '';
|
|
546
|
+
|
|
547
|
+
return `<div style="display:flex;gap:12px;align-items:stretch;flex-wrap:wrap;margin-bottom:12px">${levelCard}<div style="flex:1;min-width:420px">${businessKpis}</div></div>`
|
|
548
|
+
+ gateTable
|
|
549
|
+
+ `<div style="color:${p.mut};font-size:11px;margin:14px 0 6px">Delivery (proxies — merge-based, not true deploy/incident data)</div>`
|
|
550
|
+
+ deliveryKpis
|
|
551
|
+
+ warn
|
|
552
|
+
+ (cov.pct !== null && cov.pct < 80
|
|
553
|
+
? footnote(`Attribution coverage is ${pct(cov.pct)}. The observed level's L2/L4 gates read AI share and merge rate from the attribution store, so at this coverage they are computed on a sample and will understate maturity. Treat the level as a floor, not a measurement, until coverage exceeds 80%.`, p)
|
|
554
|
+
: '')
|
|
555
|
+
+ footnote('Observed level is computed from outcomes and capped at L4; L5 needs an autonomy signal no emitter produces. It is not the scanner\'s capability score — divergence between the two is expected and informative.', p);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---- Deferred origin resolution (attribution join at render time) ----
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Resolve AI-vs-human origin for a set of commit SHAs by looking them up in
|
|
562
|
+
* the attribution store.
|
|
563
|
+
*
|
|
564
|
+
* Why this happens here and not in CI: attribution spans land when a
|
|
565
|
+
* developer's machine runs `codeburn sync push --attribution`, which the
|
|
566
|
+
* installer schedules every ~12 hours. CI emits events at PR merge, usually
|
|
567
|
+
* before those spans exist — so an origin verdict computed in the workflow is
|
|
568
|
+
* frequently wrong and then permanently baked into an immutable event. The
|
|
569
|
+
* workflows therefore emit commit SHAs (facts that never expire) and this
|
|
570
|
+
* runs the join at query time, by which point attribution has landed. A PR
|
|
571
|
+
* merged before its sync simply renders correctly on the next look.
|
|
572
|
+
*
|
|
573
|
+
* Returns 'ai' if ANY commit in the set is AI-attributed, 'human' if all are
|
|
574
|
+
* classified human, and 'unknown' when no SHA is found in the store (not yet
|
|
575
|
+
* synced, or the author never onboarded codeburn). 'unknown' is deliberately
|
|
576
|
+
* distinct from 'human' — conflating them would silently undercount AI origin
|
|
577
|
+
* on a partially-onboarded team.
|
|
578
|
+
*/
|
|
579
|
+
type ResolvedOrigin = 'ai' | 'human' | 'unknown';
|
|
580
|
+
|
|
581
|
+
async function resolveOriginForShas(
|
|
582
|
+
repo: string,
|
|
583
|
+
shas: string[],
|
|
584
|
+
cache: Map<string, ResolvedOrigin>,
|
|
585
|
+
): Promise<ResolvedOrigin> {
|
|
586
|
+
if (!repo || shas.length === 0) return 'unknown';
|
|
587
|
+
const cacheKey = `${repo}|${shas.join(',')}`;
|
|
588
|
+
const cached = cache.get(cacheKey);
|
|
589
|
+
if (cached) return cached;
|
|
590
|
+
|
|
591
|
+
let sawHuman = false;
|
|
592
|
+
for (const sha of shas) {
|
|
593
|
+
try {
|
|
594
|
+
const commit = await invokeReceiver('/v1/attribution', `repo=${encodeURIComponent(repo)}&sha=${encodeURIComponent(sha)}`);
|
|
595
|
+
// The receiver infers origin from correlated usage spans (traceId join):
|
|
596
|
+
// a commit with LLM API calls in its session is AI-generated.
|
|
597
|
+
if (commit?.aiOrigin === 'ai-generated' || commit?.aiOrigin === 'ai-assisted' || commit?.ai_origin === 'ai-generated') {
|
|
598
|
+
cache.set(cacheKey, 'ai');
|
|
599
|
+
return 'ai';
|
|
600
|
+
}
|
|
601
|
+
sawHuman = true;
|
|
602
|
+
} catch {
|
|
603
|
+
// 404 (not synced yet) or transport failure — treat as no signal for
|
|
604
|
+
// this SHA and keep checking the rest.
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const result: ResolvedOrigin = sawHuman ? 'human' : 'unknown';
|
|
608
|
+
cache.set(cacheKey, result);
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Resolve origin for a finding, preferring the deferred attribution join and
|
|
614
|
+
* falling back to the legacy trailer-derived `ai_origin` for events emitted
|
|
615
|
+
* before the cutover.
|
|
616
|
+
*/
|
|
617
|
+
async function findingOrigin(
|
|
618
|
+
f: PrismEvent,
|
|
619
|
+
cache: Map<string, ResolvedOrigin>,
|
|
620
|
+
): Promise<{ origin: ResolvedOrigin; source: 'attribution' | 'trailer' | 'none' }> {
|
|
621
|
+
const d = f.data.security_agent_finding ?? {};
|
|
622
|
+
const shas: string[] = Array.isArray(d.commit_shas) ? d.commit_shas : [];
|
|
623
|
+
if (shas.length > 0) {
|
|
624
|
+
const origin = await resolveOriginForShas(f.data.repo ?? '', shas, cache);
|
|
625
|
+
if (origin !== 'unknown') return { origin, source: 'attribution' };
|
|
626
|
+
}
|
|
627
|
+
if (d.ai_origin) {
|
|
628
|
+
return { origin: d.ai_origin === 'human' ? 'human' : 'ai', source: 'trailer' };
|
|
629
|
+
}
|
|
630
|
+
return { origin: 'unknown', source: 'none' };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Resolve who remediated a finding, using the FIX PR's commit SHAs.
|
|
635
|
+
*
|
|
636
|
+
* Same deferred-join rationale as findingOrigin, but note this was a SECOND
|
|
637
|
+
* and separate trailer path: the remediation tracker copied
|
|
638
|
+
* `ai_context.origin` off the merged-PR event, which `prism-ai-metrics.yml`
|
|
639
|
+
* derived from `ai_ratio` — itself computed by counting AI-Origin trailers.
|
|
640
|
+
* So "which code had findings" and "who fixed them" both silently degraded to
|
|
641
|
+
* human at hook removal, via different routes.
|
|
642
|
+
*/
|
|
643
|
+
async function remediationOrigin(
|
|
644
|
+
r: PrismEvent,
|
|
645
|
+
cache: Map<string, ResolvedOrigin>,
|
|
646
|
+
): Promise<{ origin: ResolvedOrigin; source: 'attribution' | 'trailer' | 'none' }> {
|
|
647
|
+
const d = r.data.security_remediation ?? {};
|
|
648
|
+
const shas: string[] = Array.isArray(d.fix_commit_shas) ? d.fix_commit_shas : [];
|
|
649
|
+
if (shas.length > 0) {
|
|
650
|
+
const origin = await resolveOriginForShas(r.data.repo ?? '', shas, cache);
|
|
651
|
+
if (origin !== 'unknown') return { origin, source: 'attribution' };
|
|
652
|
+
}
|
|
653
|
+
// 'unknown' is the tracker's own default when the PR event carried no
|
|
654
|
+
// ai_context at all — treat it as no signal rather than as a human fix.
|
|
655
|
+
if (d.remediated_by_origin && d.remediated_by_origin !== 'unknown') {
|
|
656
|
+
return { origin: d.remediated_by_origin === 'human' ? 'human' : 'ai', source: 'trailer' };
|
|
657
|
+
}
|
|
658
|
+
return { origin: 'unknown', source: 'none' };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---- Shared security event access ----
|
|
662
|
+
|
|
663
|
+
/** Remediation SLA budgets per the AI-DLC steering baseline (SECURITY-09). */
|
|
664
|
+
const SLA_HOURS: Record<string, number> = { CRITICAL: 24, HIGH: 72, MEDIUM: 720, LOW: 720 };
|
|
665
|
+
const FINDING_TYPES = [
|
|
666
|
+
'prism.d1.security.code_review',
|
|
667
|
+
'prism.d1.security.design_review',
|
|
668
|
+
'prism.d1.security.pen_test',
|
|
669
|
+
];
|
|
670
|
+
|
|
671
|
+
interface SecurityCorpus {
|
|
672
|
+
findings: PrismEvent[];
|
|
673
|
+
remediations: PrismEvent[];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Findings across all three review phases plus their remediation events. */
|
|
677
|
+
async function loadSecurityCorpus(fromIso: string, toIso: string): Promise<SecurityCorpus> {
|
|
678
|
+
const [findingEvents, remediationEvents] = await Promise.all([
|
|
679
|
+
Promise.all(FINDING_TYPES.map(t => queryEvents(t, fromIso, toIso))).then(r => r.flat()),
|
|
680
|
+
queryEvents('prism.d1.security.remediation', fromIso, toIso),
|
|
681
|
+
]);
|
|
682
|
+
return {
|
|
683
|
+
findings: findingEvents.filter(e => e.data.security_agent_finding),
|
|
684
|
+
remediations: remediationEvents.filter(e => e.data.security_remediation),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const noFindings = (p: Palette): string => emptyState(
|
|
689
|
+
'security finding',
|
|
690
|
+
'Populated by the Continuum scan step (prism-eval-gate-kiro.yml) and the security-agent-processor webhook.',
|
|
691
|
+
p,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// ---- View: exec-security (condensed posture strip; CISO dashboard has depth) ----
|
|
695
|
+
|
|
696
|
+
async function renderExecSecurity(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
697
|
+
const [{ findings, remediations }, guardrails, mcp, exfil] = await Promise.all([
|
|
698
|
+
loadSecurityCorpus(fromIso, toIso),
|
|
699
|
+
queryEvents('prism.d1.guardrail', fromIso, toIso),
|
|
700
|
+
queryEvents('prism.d1.mcp.tool_call', fromIso, toIso),
|
|
701
|
+
queryEvents('prism.d1.security', fromIso, toIso),
|
|
702
|
+
]);
|
|
703
|
+
|
|
704
|
+
if (findings.length === 0 && remediations.length === 0 && guardrails.length === 0 && mcp.length === 0) {
|
|
705
|
+
return emptyState('security / governance', 'Populated by the Continuum scan step and the sample-app runtime.', p);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const bySev = new Map<string, number>();
|
|
709
|
+
let exploitValidated = 0;
|
|
710
|
+
for (const f of findings) {
|
|
711
|
+
const d = f.data.security_agent_finding;
|
|
712
|
+
bySev.set(d.severity ?? 'UNKNOWN', (bySev.get(d.severity ?? 'UNKNOWN') ?? 0) + 1);
|
|
713
|
+
if (d.exploit_validated) exploitValidated += 1;
|
|
714
|
+
}
|
|
715
|
+
const critHigh = (bySev.get('CRITICAL') ?? 0) + (bySev.get('HIGH') ?? 0);
|
|
716
|
+
|
|
717
|
+
// SLA per the AI-DLC steering baseline (SECURITY-09).
|
|
718
|
+
let withinSla = 0, slaEligible = 0;
|
|
719
|
+
for (const r of remediations) {
|
|
720
|
+
const sev = String(r.data.security_remediation.severity ?? '').toUpperCase();
|
|
721
|
+
const budget = SLA_HOURS[sev];
|
|
722
|
+
if (budget === undefined) continue;
|
|
723
|
+
slaEligible += 1;
|
|
724
|
+
if (Number(r.data.security_remediation.remediation_time_hours ?? 0) <= budget) withinSla += 1;
|
|
725
|
+
}
|
|
726
|
+
const slaPct = slaEligible > 0 ? (withinSla / slaEligible) * 100 : null;
|
|
727
|
+
|
|
728
|
+
const blocked = guardrails.filter(g => g.data.guardrail?.action_taken === 'BLOCK').length;
|
|
729
|
+
const denied = mcp.filter(m => m.data.mcp_tool_call?.authorized === false || m.data.mcp_tool_call?.result_status === 'denied').length;
|
|
730
|
+
|
|
731
|
+
return kpiRow([
|
|
732
|
+
{ label: 'Open Critical + High', value: num(critHigh), note: [...bySev.entries()].map(([s, n]) => `${s}:${n}`).join(' · ') || undefined, color: critHigh > 0 ? p.danger : p.ok },
|
|
733
|
+
{ label: 'Exploit Validated', value: num(exploitValidated), note: 'immediate blockers', color: exploitValidated > 0 ? p.danger : p.ok },
|
|
734
|
+
{ label: 'Within Remediation SLA', value: pct(slaPct), note: '24h crit · 72h high', color: slaPct === null ? undefined : slaPct >= 90 ? p.ok : p.warn },
|
|
735
|
+
{ label: 'Guardrail Blocks', value: num(blocked), color: blocked > 0 ? p.warn : p.ok },
|
|
736
|
+
{ label: 'MCP Denials', value: num(denied), note: 'out-of-scope attempts', color: denied > 0 ? p.warn : p.ok },
|
|
737
|
+
{ label: 'Exfiltration Alerts', value: num(exfil.length), color: exfil.length > 0 ? p.danger : p.ok },
|
|
738
|
+
], p) + footnote('Condensed posture. Full severity/phase/origin breakdowns are on the CISO Compliance dashboard.', p);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ---- View: eval ----
|
|
742
|
+
|
|
743
|
+
async function renderEval(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
744
|
+
const evals = await queryEvents('prism.d1.eval', fromIso, toIso);
|
|
745
|
+
if (evals.length === 0) {
|
|
746
|
+
return emptyState('prism.d1.eval', 'Populated by prism-eval-gate-kiro.yml / prism-eval-gate.yml on pull requests.', p);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const byRubric = new Map<string, { total: number; pass: number; scoreSum: number }>();
|
|
750
|
+
for (const e of evals) {
|
|
751
|
+
const r = e.data.eval?.rubric ?? 'unknown';
|
|
752
|
+
const agg = byRubric.get(r) ?? { total: 0, pass: 0, scoreSum: 0 };
|
|
753
|
+
agg.total += 1;
|
|
754
|
+
if (e.data.eval?.result === 'PASS') agg.pass += 1;
|
|
755
|
+
agg.scoreSum += e.data.eval?.score ?? 0;
|
|
756
|
+
byRubric.set(r, agg);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const rubricRows = [...byRubric.entries()].map(([r, a]) => [
|
|
760
|
+
esc(r),
|
|
761
|
+
`<span style="color:${a.pass / a.total >= 0.8 ? p.ok : p.danger}">${pct((a.pass / a.total) * 100)}</span>`,
|
|
762
|
+
(a.scoreSum / a.total).toFixed(2),
|
|
763
|
+
num(a.total),
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
const recent = evals.sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 8).map(e => [
|
|
767
|
+
esc(e.timestamp.slice(0, 16).replace('T', ' ')),
|
|
768
|
+
esc(e.data.eval?.rubric ?? '—'),
|
|
769
|
+
`<span style="color:${e.data.eval?.result === 'PASS' ? p.ok : p.danger}">${esc(e.data.eval?.result ?? '—')}</span>`,
|
|
770
|
+
(e.data.eval?.score ?? 0).toFixed(2),
|
|
771
|
+
e.data.eval?.pr_number ? `#${e.data.eval.pr_number}` : '—',
|
|
772
|
+
esc(e.data.repo ?? '—'),
|
|
773
|
+
]);
|
|
774
|
+
|
|
775
|
+
// Trend sparklines (chronological). Custom widgets can't run JS, so these
|
|
776
|
+
// are server-rendered inline SVG.
|
|
777
|
+
const chrono = [...evals].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
778
|
+
const scoreSeries = dailySeries(chrono, e => e.timestamp,
|
|
779
|
+
bucket => bucket.reduce((s, e) => s + (e.data.eval?.score ?? 0), 0) / bucket.length);
|
|
780
|
+
const findingsSeries = dailySeries(chrono, e => e.timestamp,
|
|
781
|
+
bucket => bucket.reduce((s, e) => s + (e.data.eval?.findings ?? 0), 0) / bucket.length);
|
|
782
|
+
const highTotal = chrono.reduce((s, e) => s + (e.data.eval?.high_findings ?? 0), 0);
|
|
783
|
+
|
|
784
|
+
const trends = `<div style="display:flex;gap:24px;margin-bottom:12px;flex-wrap:wrap">
|
|
785
|
+
<div>
|
|
786
|
+
<div style="color:${p.mut};font-size:11px">Avg score / day (0–1)</div>
|
|
787
|
+
${sparkline(scoreSeries, p, { color: p.accent, yMax: 1 })}
|
|
788
|
+
</div>
|
|
789
|
+
<div>
|
|
790
|
+
<div style="color:${p.mut};font-size:11px">Avg findings / PR</div>
|
|
791
|
+
${sparkline(findingsSeries, p, { color: p.warn })}
|
|
792
|
+
</div>
|
|
793
|
+
<div>
|
|
794
|
+
<div style="color:${p.mut};font-size:11px">High-severity findings (range)</div>
|
|
795
|
+
<div style="font-size:22px;font-weight:600;color:${highTotal > 0 ? p.danger : p.ok}">${num(highTotal)}</div>
|
|
796
|
+
</div>
|
|
797
|
+
</div>`;
|
|
798
|
+
|
|
799
|
+
return trends
|
|
800
|
+
+ table(['Rubric / Gate', 'Pass Rate', 'Avg Score', 'Runs'], rubricRows, p)
|
|
801
|
+
+ `<div style="color:${p.mut};font-size:11px;margin-top:12px">Recent gate runs</div>`
|
|
802
|
+
+ table(['Time (UTC)', 'Rubric', 'Result', 'Score', 'PR', 'Repo'], recent, p, 2);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---- View: governance ----
|
|
806
|
+
|
|
807
|
+
async function renderGovernance(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
808
|
+
const [guardrails, mcp] = await Promise.all([
|
|
809
|
+
queryEvents('prism.d1.guardrail', fromIso, toIso),
|
|
810
|
+
queryEvents('prism.d1.mcp.tool_call', fromIso, toIso),
|
|
811
|
+
]);
|
|
812
|
+
if (guardrails.length === 0 && mcp.length === 0) {
|
|
813
|
+
return emptyState('guardrail / MCP', 'Populated while the sample-app agent and MCP server run (Module 02 exercises).', p);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const byCat = new Map<string, number>();
|
|
817
|
+
let blocked = 0, anonymized = 0;
|
|
818
|
+
for (const g of guardrails) {
|
|
819
|
+
const c = g.data.guardrail?.trigger_category ?? 'UNKNOWN';
|
|
820
|
+
byCat.set(c, (byCat.get(c) ?? 0) + 1);
|
|
821
|
+
if (g.data.guardrail?.action_taken === 'BLOCK') blocked += 1;
|
|
822
|
+
if (g.data.guardrail?.action_taken === 'ANONYMIZE') anonymized += 1;
|
|
823
|
+
}
|
|
824
|
+
const denied = mcp.filter(m => m.data.mcp_tool_call?.authorized === false || m.data.mcp_tool_call?.result_status === 'denied').length;
|
|
825
|
+
// risk_level is emitted per tool call by the MCP audit logger — high-risk
|
|
826
|
+
// call volume is the governance headline, not raw call count.
|
|
827
|
+
const HIGH_RISK = new Set(['high', 'critical']);
|
|
828
|
+
const highRisk = mcp.filter(m => HIGH_RISK.has(String(m.data.mcp_tool_call?.risk_level ?? '').toLowerCase())).length;
|
|
829
|
+
const byTool = new Map<string, { calls: number; denied: number; risk: string; scopes: Set<string> }>();
|
|
830
|
+
for (const m of mcp) {
|
|
831
|
+
const call = m.data.mcp_tool_call ?? {};
|
|
832
|
+
const t = call.tool_name ?? 'unknown';
|
|
833
|
+
const agg = byTool.get(t) ?? { calls: 0, denied: 0, risk: '—', scopes: new Set<string>() };
|
|
834
|
+
agg.calls += 1;
|
|
835
|
+
if (call.authorized === false || call.result_status === 'denied') agg.denied += 1;
|
|
836
|
+
if (call.risk_level) agg.risk = String(call.risk_level);
|
|
837
|
+
for (const s of call.scopes_used ?? []) agg.scopes.add(String(s));
|
|
838
|
+
byTool.set(t, agg);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return kpiRow([
|
|
842
|
+
{ label: 'Guardrail Triggers', value: num(guardrails.length), note: [...byCat.entries()].map(([c, n]) => `${c}:${n}`).join(' · ') || undefined },
|
|
843
|
+
{ label: 'Blocked', value: num(blocked), color: blocked > 0 ? p.warn : p.ok },
|
|
844
|
+
{ label: 'Anonymized', value: num(anonymized) },
|
|
845
|
+
{ label: 'MCP Tool Calls', value: num(mcp.length) },
|
|
846
|
+
{ label: 'High-Risk Calls', value: num(highRisk), note: 'risk_level high/critical', color: highRisk > 0 ? p.warn : p.ok },
|
|
847
|
+
{ label: 'MCP Denied', value: num(denied), color: denied > 0 ? p.warn : p.ok },
|
|
848
|
+
], p) + table(['MCP Tool', 'Risk', 'Scopes Used', 'Calls', 'Denied'],
|
|
849
|
+
[...byTool.entries()].sort((a, b) => b[1].calls - a[1].calls).slice(0, 10)
|
|
850
|
+
.map(([t, a]) => [
|
|
851
|
+
esc(t),
|
|
852
|
+
`<span style="color:${HIGH_RISK.has(a.risk.toLowerCase()) ? p.warn : p.mut}">${esc(a.risk)}</span>`,
|
|
853
|
+
`<span style="color:${p.mut}">${esc([...a.scopes].slice(0, 3).join(', ') || '—')}</span>`,
|
|
854
|
+
num(a.calls),
|
|
855
|
+
`<span style="color:${a.denied > 0 ? p.warn : p.mut}">${num(a.denied)}</span>`,
|
|
856
|
+
]), p, 3);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---- View: agents ----
|
|
860
|
+
|
|
861
|
+
async function renderAgents(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
862
|
+
const events = await queryEvents('prism.d1.agent', fromIso, toIso);
|
|
863
|
+
if (events.length === 0) {
|
|
864
|
+
return emptyState('prism.d1.agent', 'Populated while the sample-app Strands agent runs (Module 02 exercises).', p);
|
|
865
|
+
}
|
|
866
|
+
const byAgent = new Map<string, { runs: number; ok: number; durSum: number; steps: number; tokens: number; tools: number; guardrails: number }>();
|
|
867
|
+
for (const e of events) {
|
|
868
|
+
const a = e.data.agent?.agent_name ?? 'unknown';
|
|
869
|
+
const agg = byAgent.get(a) ?? { runs: 0, ok: 0, durSum: 0, steps: 0, tokens: 0, tools: 0, guardrails: 0 };
|
|
870
|
+
agg.runs += 1;
|
|
871
|
+
if (e.data.agent?.status === 'success') agg.ok += 1;
|
|
872
|
+
agg.durSum += e.data.agent?.duration_ms ?? 0;
|
|
873
|
+
agg.steps += e.data.agent?.steps_taken ?? 0;
|
|
874
|
+
agg.tokens += e.data.agent?.tokens_used ?? 0;
|
|
875
|
+
agg.tools += e.data.agent?.tools_invoked ?? 0;
|
|
876
|
+
agg.guardrails += e.data.agent?.guardrails_triggered ?? 0;
|
|
877
|
+
byAgent.set(a, agg);
|
|
878
|
+
}
|
|
879
|
+
const totals = [...byAgent.values()].reduce((t, a) => ({
|
|
880
|
+
runs: t.runs + a.runs, ok: t.ok + a.ok, durSum: t.durSum + a.durSum,
|
|
881
|
+
tokens: t.tokens + a.tokens, guardrails: t.guardrails + a.guardrails,
|
|
882
|
+
}), { runs: 0, ok: 0, durSum: 0, tokens: 0, guardrails: 0 });
|
|
883
|
+
|
|
884
|
+
return kpiRow([
|
|
885
|
+
{ label: 'Invocations', value: num(totals.runs) },
|
|
886
|
+
{ label: 'Success Rate', value: pct(totals.runs ? (totals.ok / totals.runs) * 100 : null), color: totals.ok === totals.runs ? p.ok : p.warn },
|
|
887
|
+
{ label: 'Avg Duration', value: totals.runs ? `${num(Math.round(totals.durSum / totals.runs))}ms` : '—' },
|
|
888
|
+
{ label: 'Tokens Used', value: num(totals.tokens) },
|
|
889
|
+
{ label: 'Guardrails Hit', value: num(totals.guardrails), note: 'during agent runs', color: totals.guardrails > 0 ? p.warn : p.ok },
|
|
890
|
+
], p) + table(['Agent', 'Runs', 'Success', 'Avg ms', 'Avg Steps', 'Avg Tools', 'Tokens', 'Guardrails'],
|
|
891
|
+
[...byAgent.entries()].sort((a, b) => b[1].runs - a[1].runs).map(([a, v]) => [
|
|
892
|
+
esc(a),
|
|
893
|
+
num(v.runs),
|
|
894
|
+
`<span style="color:${v.ok === v.runs ? p.ok : p.warn}">${pct((v.ok / v.runs) * 100)}</span>`,
|
|
895
|
+
num(Math.round(v.durSum / v.runs)),
|
|
896
|
+
(v.steps / v.runs).toFixed(1),
|
|
897
|
+
(v.tools / v.runs).toFixed(1),
|
|
898
|
+
num(v.tokens),
|
|
899
|
+
`<span style="color:${v.guardrails > 0 ? p.warn : p.mut}">${num(v.guardrails)}</span>`,
|
|
900
|
+
]), p);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ---- View: security ----
|
|
904
|
+
|
|
905
|
+
async function renderSecurity(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
906
|
+
const { findings, remediations } = await loadSecurityCorpus(fromIso, toIso);
|
|
907
|
+
|
|
908
|
+
if (findings.length === 0 && remediations.length === 0) {
|
|
909
|
+
return emptyState('security finding', 'Populated by the Continuum scan step in prism-eval-gate-kiro.yml when findings exist.', p);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const bySev = new Map<string, number>();
|
|
913
|
+
const byPhase = new Map<string, number>();
|
|
914
|
+
let ai = 0, human = 0, unknownOrigin = 0, exploitValidated = 0;
|
|
915
|
+
const cvssScores: number[] = [];
|
|
916
|
+
const originCache = new Map<string, ResolvedOrigin>();
|
|
917
|
+
const originSources = new Set<string>();
|
|
918
|
+
for (const f of findings) {
|
|
919
|
+
const d = f.data.security_agent_finding;
|
|
920
|
+
bySev.set(d.severity ?? 'UNKNOWN', (bySev.get(d.severity ?? 'UNKNOWN') ?? 0) + 1);
|
|
921
|
+
byPhase.set(d.phase ?? 'unknown', (byPhase.get(d.phase ?? 'unknown') ?? 0) + 1);
|
|
922
|
+
// Deferred join — see resolveOriginForShas. Falls back to the legacy
|
|
923
|
+
// trailer field for events emitted before the cutover.
|
|
924
|
+
const { origin, source } = await findingOrigin(f, originCache);
|
|
925
|
+
if (source !== 'none') originSources.add(source);
|
|
926
|
+
if (origin === 'ai') ai += 1;
|
|
927
|
+
else if (origin === 'human') human += 1;
|
|
928
|
+
else unknownOrigin += 1;
|
|
929
|
+
if (d.exploit_validated) exploitValidated += 1;
|
|
930
|
+
if (typeof d.cvss_score === 'number') cvssScores.push(d.cvss_score);
|
|
931
|
+
}
|
|
932
|
+
const critHigh = (bySev.get('CRITICAL') ?? 0) + (bySev.get('HIGH') ?? 0);
|
|
933
|
+
const avgCvss = cvssScores.length ? cvssScores.reduce((a, b) => a + b, 0) / cvssScores.length : null;
|
|
934
|
+
|
|
935
|
+
// --- Remediation SLA (steering file SECURITY-09: 24h Critical, 72h High,
|
|
936
|
+
// 30d Medium). Closure is half the security story — findings alone don't
|
|
937
|
+
// tell you whether the team is actually fixing them.
|
|
938
|
+
let remedHoursSum = 0, withinSla = 0, slaEligible = 0, remedAi = 0, remedHuman = 0, remedUnknown = 0;
|
|
939
|
+
const remedBySev = new Map<string, { count: number; hoursSum: number; within: number }>();
|
|
940
|
+
for (const r of remediations) {
|
|
941
|
+
const d = r.data.security_remediation;
|
|
942
|
+
const sev = String(d.severity ?? 'UNKNOWN').toUpperCase();
|
|
943
|
+
const hours = Number(d.remediation_time_hours ?? 0);
|
|
944
|
+
remedHoursSum += hours;
|
|
945
|
+
const agg = remedBySev.get(sev) ?? { count: 0, hoursSum: 0, within: 0 };
|
|
946
|
+
agg.count += 1;
|
|
947
|
+
agg.hoursSum += hours;
|
|
948
|
+
const budget = SLA_HOURS[sev];
|
|
949
|
+
if (budget !== undefined) {
|
|
950
|
+
slaEligible += 1;
|
|
951
|
+
if (hours <= budget) { withinSla += 1; agg.within += 1; }
|
|
952
|
+
}
|
|
953
|
+
remedBySev.set(sev, agg);
|
|
954
|
+
// Deferred join on the fix PR's commits — see remediationOrigin. Shares
|
|
955
|
+
// originCache with the findings loop above, so overlapping PRs are
|
|
956
|
+
// resolved once per render.
|
|
957
|
+
const { origin: remOrigin, source: remSource } = await remediationOrigin(r, originCache);
|
|
958
|
+
if (remSource !== 'none') originSources.add(remSource);
|
|
959
|
+
if (remOrigin === 'ai') remedAi += 1;
|
|
960
|
+
else if (remOrigin === 'human') remedHuman += 1;
|
|
961
|
+
else remedUnknown += 1;
|
|
962
|
+
}
|
|
963
|
+
const avgRemedHours = remediations.length ? remedHoursSum / remediations.length : null;
|
|
964
|
+
const slaPct = slaEligible > 0 ? (withinSla / slaEligible) * 100 : null;
|
|
965
|
+
|
|
966
|
+
const kpis = kpiRow([
|
|
967
|
+
{ label: 'Critical + High', value: num(critHigh), color: critHigh > 0 ? p.danger : p.ok },
|
|
968
|
+
{ label: 'Total Findings', value: num(findings.length), note: [...bySev.entries()].map(([s, n]) => `${s}:${n}`).join(' · ') },
|
|
969
|
+
{ label: 'Exploit Validated', value: num(exploitValidated), note: 'pen-test proven — immediate blocker', color: exploitValidated > 0 ? p.danger : p.ok },
|
|
970
|
+
{ label: 'Avg CVSS', value: avgCvss === null ? '—' : avgCvss.toFixed(1), color: avgCvss !== null && avgCvss >= 7 ? p.danger : undefined },
|
|
971
|
+
{ label: 'AI-Origin', value: num(ai), note: `${num(human)} human` + (unknownOrigin > 0 ? ` · ${num(unknownOrigin)} unresolved` : '') },
|
|
972
|
+
], p);
|
|
973
|
+
|
|
974
|
+
const remedKpis = remediations.length === 0
|
|
975
|
+
? footnote('No remediation events in range — remediation tracking populates from prism.d1.security.remediation (security-remediation-tracker).', p)
|
|
976
|
+
: `<div style="color:${p.mut};font-size:11px;margin:12px 0 6px">Remediation (SLA: 24h Critical · 72h High · 30d Medium)</div>` + kpiRow([
|
|
977
|
+
{ label: 'Remediated', value: num(remediations.length) },
|
|
978
|
+
{ label: 'Avg Time', value: avgRemedHours === null ? '—' : `${Math.round(avgRemedHours * 10) / 10}h` },
|
|
979
|
+
{ label: 'Within SLA', value: pct(slaPct), color: slaPct === null ? undefined : slaPct >= 90 ? p.ok : slaPct >= 70 ? p.warn : p.danger },
|
|
980
|
+
{ label: 'Fixed by AI', value: num(remedAi), note: `${num(remedHuman)} by human` + (remedUnknown > 0 ? ` · ${num(remedUnknown)} unresolved` : '') },
|
|
981
|
+
], p) + table(['Severity', 'Fixed', 'Avg Hours', 'Within SLA'],
|
|
982
|
+
[...remedBySev.entries()].sort((a, b) => b[1].count - a[1].count).map(([sev, v]) => [
|
|
983
|
+
esc(sev),
|
|
984
|
+
num(v.count),
|
|
985
|
+
(v.hoursSum / v.count).toFixed(1),
|
|
986
|
+
`<span style="color:${v.within === v.count ? p.ok : p.warn}">${v.within} / ${v.count}</span>`,
|
|
987
|
+
]), p);
|
|
988
|
+
|
|
989
|
+
const recentFindings = findings.sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 8);
|
|
990
|
+
const recent = await Promise.all(recentFindings.map(async f => {
|
|
991
|
+
const d = f.data.security_agent_finding;
|
|
992
|
+
const sevColor = d.severity === 'CRITICAL' || d.severity === 'HIGH' ? p.danger : d.severity === 'MEDIUM' ? p.warn : p.mut;
|
|
993
|
+
// originCache is already warm from the KPI loop above, so this adds no
|
|
994
|
+
// extra receiver invokes for findings we have already resolved.
|
|
995
|
+
const { origin, source } = await findingOrigin(f, originCache);
|
|
996
|
+
const originLabel = origin === 'unknown' ? '—' : origin === 'ai' ? 'ai' : 'human';
|
|
997
|
+
return [
|
|
998
|
+
esc(f.timestamp.slice(0, 16).replace('T', ' ')),
|
|
999
|
+
`<span style="color:${sevColor}">${esc(d.severity ?? '—')}</span>`,
|
|
1000
|
+
typeof d.cvss_score === 'number' ? d.cvss_score.toFixed(1) : '—',
|
|
1001
|
+
esc(d.phase ?? '—'),
|
|
1002
|
+
esc(d.cwe_id ?? '—'),
|
|
1003
|
+
d.exploit_validated ? `<span style="color:${p.danger}">yes</span>` : '—',
|
|
1004
|
+
`${esc(originLabel)}${source === 'trailer' ? `<span style="color:${p.mut}"> (trailer)</span>` : ''}`,
|
|
1005
|
+
esc(f.data.repo ?? '—'),
|
|
1006
|
+
];
|
|
1007
|
+
}));
|
|
1008
|
+
|
|
1009
|
+
const originNote = originSources.has('trailer')
|
|
1010
|
+
? footnote('Some origins resolved from legacy git trailers — those events predate the commit-SHA cutover. New findings resolve against the attribution store at render time.', p)
|
|
1011
|
+
: '';
|
|
1012
|
+
|
|
1013
|
+
return kpis + remedKpis
|
|
1014
|
+
+ `<div style="color:${p.mut};font-size:11px;margin-top:12px">Recent findings · by phase: ${esc([...byPhase.entries()].map(([ph, n]) => `${ph}:${n}`).join(' · ') || 'none')}</div>`
|
|
1015
|
+
+ table(['Time (UTC)', 'Severity', 'CVSS', 'Phase', 'CWE', 'Exploit', 'Origin', 'Repo'], recent, p, 99)
|
|
1016
|
+
+ originNote;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ---- CISO views (depth; the exec strip owns the headline numbers) ----
|
|
1020
|
+
|
|
1021
|
+
// --- Row 1: current exposure ---
|
|
1022
|
+
|
|
1023
|
+
async function renderCisoExposure(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
1024
|
+
const { findings, remediations } = await loadSecurityCorpus(fromIso, toIso);
|
|
1025
|
+
if (findings.length === 0) return noFindings(p);
|
|
1026
|
+
|
|
1027
|
+
const bySev = new Map<string, number>();
|
|
1028
|
+
const cvss: number[] = [];
|
|
1029
|
+
let exploitValidated = 0;
|
|
1030
|
+
for (const f of findings) {
|
|
1031
|
+
const d = f.data.security_agent_finding;
|
|
1032
|
+
const sev = String(d.severity ?? 'UNKNOWN').toUpperCase();
|
|
1033
|
+
bySev.set(sev, (bySev.get(sev) ?? 0) + 1);
|
|
1034
|
+
if (d.exploit_validated) exploitValidated += 1;
|
|
1035
|
+
if (typeof d.cvss_score === 'number') cvss.push(d.cvss_score);
|
|
1036
|
+
}
|
|
1037
|
+
const critHigh = (bySev.get('CRITICAL') ?? 0) + (bySev.get('HIGH') ?? 0);
|
|
1038
|
+
|
|
1039
|
+
// Aging: a finding is open until a remediation event references its id.
|
|
1040
|
+
const remediatedIds = new Set(
|
|
1041
|
+
remediations.map(r => r.data.security_remediation?.finding_id).filter(Boolean),
|
|
1042
|
+
);
|
|
1043
|
+
const now = Date.parse(toIso);
|
|
1044
|
+
let oldestOpenDays: number | null = null;
|
|
1045
|
+
let openCount = 0;
|
|
1046
|
+
for (const f of findings) {
|
|
1047
|
+
const d = f.data.security_agent_finding;
|
|
1048
|
+
if (d.finding_id && remediatedIds.has(d.finding_id)) continue;
|
|
1049
|
+
openCount += 1;
|
|
1050
|
+
const found = Date.parse(d.found_at ?? f.timestamp);
|
|
1051
|
+
if (Number.isNaN(found)) continue;
|
|
1052
|
+
const ageDays = (now - found) / 86400000;
|
|
1053
|
+
if (oldestOpenDays === null || ageDays > oldestOpenDays) oldestOpenDays = ageDays;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const cvssNote = cvss.length === 0
|
|
1057
|
+
? 'not scored by this emitter'
|
|
1058
|
+
: `${num(cvss.length)} of ${num(findings.length)} scored`;
|
|
1059
|
+
|
|
1060
|
+
return kpiRow([
|
|
1061
|
+
{
|
|
1062
|
+
label: 'Open Critical + High',
|
|
1063
|
+
value: num(critHigh),
|
|
1064
|
+
note: [...bySev.entries()].sort().map(([s, n]) => `${s}:${n}`).join(' · '),
|
|
1065
|
+
color: critHigh > 0 ? p.danger : p.ok,
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
label: 'Exploit Validated',
|
|
1069
|
+
value: num(exploitValidated),
|
|
1070
|
+
note: 'pen-test proven — immediate blocker',
|
|
1071
|
+
color: exploitValidated > 0 ? p.danger : p.ok,
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
label: 'Max CVSS',
|
|
1075
|
+
value: cvss.length ? Math.max(...cvss).toFixed(1) : '—',
|
|
1076
|
+
note: cvssNote,
|
|
1077
|
+
color: cvss.length && Math.max(...cvss) >= 9 ? p.danger : cvss.length && Math.max(...cvss) >= 7 ? p.warn : undefined,
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
label: 'Avg CVSS',
|
|
1081
|
+
value: cvss.length ? (cvss.reduce((a, b) => a + b, 0) / cvss.length).toFixed(1) : '—',
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
label: 'Oldest Unremediated',
|
|
1085
|
+
value: oldestOpenDays === null ? '—' : `${Math.floor(oldestOpenDays)}d`,
|
|
1086
|
+
note: `${num(openCount)} still open`,
|
|
1087
|
+
color: oldestOpenDays !== null && oldestOpenDays > 30 ? p.danger : oldestOpenDays !== null && oldestOpenDays > 7 ? p.warn : p.ok,
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
label: 'Findings Recorded',
|
|
1091
|
+
value: num(findings.length),
|
|
1092
|
+
note: 'in selected range',
|
|
1093
|
+
},
|
|
1094
|
+
], p) + footnote(
|
|
1095
|
+
'Open = no remediation event references the finding id. "Findings Recorded" counts findings, not scan runs — the SecurityScanCount metric is emitted once per finding, so a scan-count KPI would be double-labelled.',
|
|
1096
|
+
p,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// --- Row 2: remediation SLA compliance ---
|
|
1101
|
+
|
|
1102
|
+
async function renderCisoSla(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
1103
|
+
const { remediations } = await loadSecurityCorpus(fromIso, toIso);
|
|
1104
|
+
if (remediations.length === 0) {
|
|
1105
|
+
return emptyState(
|
|
1106
|
+
'prism.d1.security.remediation',
|
|
1107
|
+
'Populated by security-remediation-tracker when a finding is closed. No remediations in range means nothing has been fixed yet — or the tracker is not wired.',
|
|
1108
|
+
p,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const bySev = new Map<string, { count: number; hoursSum: number; within: number; worst: number }>();
|
|
1113
|
+
let withinTotal = 0, eligibleTotal = 0;
|
|
1114
|
+
for (const r of remediations) {
|
|
1115
|
+
const d = r.data.security_remediation;
|
|
1116
|
+
const sev = String(d.severity ?? 'UNKNOWN').toUpperCase();
|
|
1117
|
+
const hours = Number(d.remediation_time_hours ?? 0);
|
|
1118
|
+
const agg = bySev.get(sev) ?? { count: 0, hoursSum: 0, within: 0, worst: 0 };
|
|
1119
|
+
agg.count += 1;
|
|
1120
|
+
agg.hoursSum += hours;
|
|
1121
|
+
if (hours > agg.worst) agg.worst = hours;
|
|
1122
|
+
const budget = SLA_HOURS[sev];
|
|
1123
|
+
if (budget !== undefined) {
|
|
1124
|
+
eligibleTotal += 1;
|
|
1125
|
+
if (hours <= budget) { agg.within += 1; withinTotal += 1; }
|
|
1126
|
+
}
|
|
1127
|
+
bySev.set(sev, agg);
|
|
1128
|
+
}
|
|
1129
|
+
const slaPct = eligibleTotal > 0 ? (withinTotal / eligibleTotal) * 100 : null;
|
|
1130
|
+
const breached = eligibleTotal - withinTotal;
|
|
1131
|
+
|
|
1132
|
+
const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
|
|
1133
|
+
const rows = [...bySev.entries()]
|
|
1134
|
+
.sort((a, b) => (order.indexOf(a[0]) + 99 * (order.indexOf(a[0]) < 0 ? 1 : 0)) - (order.indexOf(b[0]) + 99 * (order.indexOf(b[0]) < 0 ? 1 : 0)))
|
|
1135
|
+
.map(([sev, v]) => {
|
|
1136
|
+
const budget = SLA_HOURS[sev];
|
|
1137
|
+
const breach = budget === undefined ? 0 : v.count - v.within;
|
|
1138
|
+
return [
|
|
1139
|
+
esc(sev),
|
|
1140
|
+
budget === undefined ? '—' : budget >= 720 ? `${budget / 24}d` : `${budget}h`,
|
|
1141
|
+
num(v.count),
|
|
1142
|
+
(v.hoursSum / v.count).toFixed(1),
|
|
1143
|
+
v.worst.toFixed(1),
|
|
1144
|
+
budget === undefined ? '—' : `<span style="color:${v.within === v.count ? p.ok : p.warn}">${v.within} / ${v.count}</span>`,
|
|
1145
|
+
breach > 0 ? `<span style="color:${p.danger}">${num(breach)}</span>` : '0',
|
|
1146
|
+
];
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
return kpiRow([
|
|
1150
|
+
{
|
|
1151
|
+
label: 'Within Remediation SLA',
|
|
1152
|
+
value: pct(slaPct),
|
|
1153
|
+
note: `${num(withinTotal)} of ${num(eligibleTotal)} eligible`,
|
|
1154
|
+
color: slaPct === null ? undefined : slaPct >= 90 ? p.ok : slaPct >= 70 ? p.warn : p.danger,
|
|
1155
|
+
},
|
|
1156
|
+
{ label: 'SLA Breaches', value: num(breached), note: 'named per severity below', color: breached > 0 ? p.danger : p.ok },
|
|
1157
|
+
{ label: 'Findings Closed', value: num(remediations.length) },
|
|
1158
|
+
], p)
|
|
1159
|
+
+ table(['Severity', 'Budget', 'Fixed', 'Avg Hours', 'Worst', 'Within SLA', 'Breached'], rows, p)
|
|
1160
|
+
+ footnote('Budgets from the AI-DLC steering baseline (SECURITY-09): 24h Critical, 72h High, 30d Medium/Low. Breaches are counted per severity rather than averaged into a single number, because one 200h Critical is not offset by ten fast Lows.', p);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// --- Row 3: AI code risk profile, normalized by commit volume ---
|
|
1164
|
+
|
|
1165
|
+
async function renderCisoRisk(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
1166
|
+
const [{ findings, remediations }, report] = await Promise.all([
|
|
1167
|
+
loadSecurityCorpus(fromIso, toIso),
|
|
1168
|
+
fetchProductivity(fromIso, toIso).catch(() => null),
|
|
1169
|
+
]);
|
|
1170
|
+
if (findings.length === 0) return noFindings(p);
|
|
1171
|
+
|
|
1172
|
+
const cache = new Map<string, ResolvedOrigin>();
|
|
1173
|
+
const sources = new Set<string>();
|
|
1174
|
+
// origin -> severity -> count
|
|
1175
|
+
const matrix = new Map<ResolvedOrigin, Map<string, number>>();
|
|
1176
|
+
const totals = new Map<ResolvedOrigin, number>();
|
|
1177
|
+
for (const f of findings) {
|
|
1178
|
+
const { origin, source } = await findingOrigin(f, cache);
|
|
1179
|
+
if (source !== 'none') sources.add(source);
|
|
1180
|
+
const sev = String(f.data.security_agent_finding.severity ?? 'UNKNOWN').toUpperCase();
|
|
1181
|
+
const row = matrix.get(origin) ?? new Map<string, number>();
|
|
1182
|
+
row.set(sev, (row.get(sev) ?? 0) + 1);
|
|
1183
|
+
matrix.set(origin, row);
|
|
1184
|
+
totals.set(origin, (totals.get(origin) ?? 0) + 1);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const aiCommits = report?.totals?.commits?.ai ?? null;
|
|
1188
|
+
const humanCommits = report?.totals?.commits?.human ?? null;
|
|
1189
|
+
const aiFindings = totals.get('ai') ?? 0;
|
|
1190
|
+
const humanFindings = totals.get('human') ?? 0;
|
|
1191
|
+
const unresolved = totals.get('unknown') ?? 0;
|
|
1192
|
+
|
|
1193
|
+
const per100 = (f: number, c: number | null): number | null =>
|
|
1194
|
+
c === null || c === 0 ? null : (f / c) * 100;
|
|
1195
|
+
const aiRate = per100(aiFindings, aiCommits);
|
|
1196
|
+
const humanRate = per100(humanFindings, humanCommits);
|
|
1197
|
+
const ratio = aiRate !== null && humanRate !== null && humanRate > 0 ? aiRate / humanRate : null;
|
|
1198
|
+
|
|
1199
|
+
const normalized = kpiRow([
|
|
1200
|
+
{
|
|
1201
|
+
label: 'AI findings / 100 commits',
|
|
1202
|
+
value: aiRate === null ? '—' : aiRate.toFixed(1),
|
|
1203
|
+
note: aiCommits === null ? 'commit volume unavailable' : `${num(aiFindings)} findings / ${num(aiCommits)} AI commits`,
|
|
1204
|
+
},
|
|
1205
|
+
{
|
|
1206
|
+
label: 'Human findings / 100 commits',
|
|
1207
|
+
value: humanRate === null ? '—' : humanRate.toFixed(1),
|
|
1208
|
+
note: humanCommits === null ? 'commit volume unavailable' : `${num(humanFindings)} findings / ${num(humanCommits)} human commits`,
|
|
1209
|
+
},
|
|
1210
|
+
{
|
|
1211
|
+
label: 'AI : Human risk ratio',
|
|
1212
|
+
value: ratio === null ? '—' : `${ratio.toFixed(2)}x`,
|
|
1213
|
+
// A bare dash invites the reader to assume the worst. Say which side is
|
|
1214
|
+
// missing: no human baseline is a very different situation from no data.
|
|
1215
|
+
note: ratio !== null
|
|
1216
|
+
? 'L2 ≤1.2x · L4 ≤0.9x'
|
|
1217
|
+
: humanCommits === null
|
|
1218
|
+
? 'commit volume unavailable'
|
|
1219
|
+
: humanRate === 0
|
|
1220
|
+
? 'no human-origin findings to compare against'
|
|
1221
|
+
: 'insufficient data',
|
|
1222
|
+
color: ratio === null ? undefined : ratio <= 0.9 ? p.ok : ratio <= 1.2 ? p.warn : p.danger,
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
label: 'Unresolved Origin',
|
|
1226
|
+
value: num(unresolved),
|
|
1227
|
+
note: unresolved > 0 ? 'SHAs not in attribution store' : 'all findings attributed',
|
|
1228
|
+
color: unresolved > 0 ? p.warn : p.ok,
|
|
1229
|
+
},
|
|
1230
|
+
], p);
|
|
1231
|
+
|
|
1232
|
+
const sevOrder = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
|
|
1233
|
+
const matrixRows = (['ai', 'human', 'unknown'] as ResolvedOrigin[])
|
|
1234
|
+
.filter(o => totals.has(o))
|
|
1235
|
+
.map(o => {
|
|
1236
|
+
const row = matrix.get(o) ?? new Map<string, number>();
|
|
1237
|
+
return [
|
|
1238
|
+
o === 'unknown' ? 'unresolved' : o,
|
|
1239
|
+
...sevOrder.map(s => {
|
|
1240
|
+
const n = row.get(s) ?? 0;
|
|
1241
|
+
const color = n === 0 ? p.mut : s === 'CRITICAL' || s === 'HIGH' ? p.danger : p.fg;
|
|
1242
|
+
return `<span style="color:${color}">${num(n)}</span>`;
|
|
1243
|
+
}),
|
|
1244
|
+
num(totals.get(o) ?? 0),
|
|
1245
|
+
];
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// Remediation latency by who fixed it — now resolved by the same deferred
|
|
1249
|
+
// attribution join as the findings, so this survives hook removal.
|
|
1250
|
+
const remedByOrigin = new Map<string, { count: number; hoursSum: number }>();
|
|
1251
|
+
for (const r of remediations) {
|
|
1252
|
+
const { origin, source } = await remediationOrigin(r, cache);
|
|
1253
|
+
if (source !== 'none') sources.add(source);
|
|
1254
|
+
const key = origin === 'unknown' ? 'unresolved' : origin;
|
|
1255
|
+
const agg = remedByOrigin.get(key) ?? { count: 0, hoursSum: 0 };
|
|
1256
|
+
agg.count += 1;
|
|
1257
|
+
agg.hoursSum += Number(r.data.security_remediation?.remediation_time_hours ?? 0);
|
|
1258
|
+
remedByOrigin.set(key, agg);
|
|
1259
|
+
}
|
|
1260
|
+
const remedRows = [...remedByOrigin.entries()]
|
|
1261
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
1262
|
+
.map(([k, v]) => [esc(k), num(v.count), (v.hoursSum / v.count).toFixed(1)]);
|
|
1263
|
+
|
|
1264
|
+
const volumeWarn = aiCommits === null
|
|
1265
|
+
? footnote('Attribution store unreachable — rates cannot be normalized, so only raw counts are shown. Raw counts are not comparable when AI and human commit volumes differ.', p)
|
|
1266
|
+
: '';
|
|
1267
|
+
const trailerWarn = sources.has('trailer')
|
|
1268
|
+
? footnote('Some origins came from legacy git trailers (events predating the commit-SHA cutover, on either the finding or the remediation path). Trailers disappear at Phase 3 hook removal; new events resolve against the attribution store.', p)
|
|
1269
|
+
: '';
|
|
1270
|
+
|
|
1271
|
+
return normalized
|
|
1272
|
+
+ `<div style="color:${p.mut};font-size:11px;margin:14px 0 0">Origin × severity</div>`
|
|
1273
|
+
+ table(['Origin', ...sevOrder, 'Total'], matrixRows, p)
|
|
1274
|
+
+ (remedRows.length
|
|
1275
|
+
? `<div style="color:${p.mut};font-size:11px;margin:14px 0 0">Remediation latency by fixer</div>`
|
|
1276
|
+
+ table(['Fixed by', 'Count', 'Avg Hours'], remedRows, p)
|
|
1277
|
+
: '')
|
|
1278
|
+
+ volumeWarn
|
|
1279
|
+
+ trailerWarn
|
|
1280
|
+
+ footnote('Rates are the only defensible answer to "is AI code riskier?" — raw finding counts scale with how much code each origin produced.', p);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// --- Row 4: shift-left effectiveness ---
|
|
1284
|
+
|
|
1285
|
+
async function renderCisoShiftleft(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
1286
|
+
const { findings } = await loadSecurityCorpus(fromIso, toIso);
|
|
1287
|
+
if (findings.length === 0) return noFindings(p);
|
|
1288
|
+
|
|
1289
|
+
const PHASES = ['design_review', 'code_review', 'pen_test'];
|
|
1290
|
+
const byPhase = new Map<string, PrismEvent[]>();
|
|
1291
|
+
for (const f of findings) {
|
|
1292
|
+
const phase = String(f.data.security_agent_finding.phase ?? 'unknown');
|
|
1293
|
+
const arr = byPhase.get(phase) ?? [];
|
|
1294
|
+
arr.push(f);
|
|
1295
|
+
byPhase.set(phase, arr);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Survival rate: a class of issue (CWE, else category) raised at design
|
|
1299
|
+
// review that reappears in a later phase was not actually fixed early.
|
|
1300
|
+
// Both steering files define this metric and nothing emits it.
|
|
1301
|
+
const classOf = (f: PrismEvent): string | null => {
|
|
1302
|
+
const d = f.data.security_agent_finding;
|
|
1303
|
+
return d.cwe_id ?? d.category ?? null;
|
|
1304
|
+
};
|
|
1305
|
+
const designClasses = new Set(
|
|
1306
|
+
(byPhase.get('design_review') ?? []).map(classOf).filter((c): c is string => !!c),
|
|
1307
|
+
);
|
|
1308
|
+
const laterClasses = new Set(
|
|
1309
|
+
[...(byPhase.get('code_review') ?? []), ...(byPhase.get('pen_test') ?? [])]
|
|
1310
|
+
.map(classOf).filter((c): c is string => !!c),
|
|
1311
|
+
);
|
|
1312
|
+
const survived = [...designClasses].filter(c => laterClasses.has(c));
|
|
1313
|
+
const survivalPct = designClasses.size > 0 ? (survived.length / designClasses.size) * 100 : null;
|
|
1314
|
+
|
|
1315
|
+
const phaseKpis = kpiRow([
|
|
1316
|
+
...PHASES.map(ph => ({
|
|
1317
|
+
label: ph.replace(/_/g, ' '),
|
|
1318
|
+
value: num((byPhase.get(ph) ?? []).length),
|
|
1319
|
+
note: ph === 'design_review' ? 'cheapest to fix' : ph === 'pen_test' ? 'most expensive' : undefined,
|
|
1320
|
+
})),
|
|
1321
|
+
{
|
|
1322
|
+
label: 'Finding Survival Rate',
|
|
1323
|
+
value: pct(survivalPct),
|
|
1324
|
+
note: designClasses.size === 0
|
|
1325
|
+
? 'no design-phase findings to track'
|
|
1326
|
+
: `${survived.length} of ${designClasses.size} classes resurfaced`,
|
|
1327
|
+
color: survivalPct === null ? undefined : survivalPct <= 10 ? p.ok : survivalPct <= 30 ? p.warn : p.danger,
|
|
1328
|
+
},
|
|
1329
|
+
], p);
|
|
1330
|
+
|
|
1331
|
+
const trends = `<div style="display:flex;gap:24px;margin:14px 0 0;flex-wrap:wrap">${PHASES.map(ph => {
|
|
1332
|
+
const chrono = (byPhase.get(ph) ?? []).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1333
|
+
const series = dailySeries(chrono, e => e.timestamp, bucket => bucket.length);
|
|
1334
|
+
return `<div>
|
|
1335
|
+
<div style="color:${p.mut};font-size:11px">${esc(ph.replace(/_/g, ' '))} findings / day</div>
|
|
1336
|
+
${sparkline(series, p, { color: ph === 'pen_test' ? p.danger : ph === 'code_review' ? p.warn : p.accent, width: 200 })}
|
|
1337
|
+
</div>`;
|
|
1338
|
+
}).join('')}</div>`;
|
|
1339
|
+
|
|
1340
|
+
const survivedRows = survived.slice(0, 10).map(c => {
|
|
1341
|
+
const design = (byPhase.get('design_review') ?? []).filter(f => classOf(f) === c).length;
|
|
1342
|
+
const later = [...(byPhase.get('code_review') ?? []), ...(byPhase.get('pen_test') ?? [])]
|
|
1343
|
+
.filter(f => classOf(f) === c).length;
|
|
1344
|
+
return [esc(c), num(design), num(later)];
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
const unknownPhase = (byPhase.get('unknown') ?? []).length;
|
|
1348
|
+
|
|
1349
|
+
return phaseKpis
|
|
1350
|
+
+ trends
|
|
1351
|
+
+ (survivedRows.length
|
|
1352
|
+
? `<div style="color:${p.mut};font-size:11px;margin:14px 0 0">Issue classes that survived design review</div>`
|
|
1353
|
+
+ table(['CWE / Category', 'At Design', 'Later Phases'], survivedRows, p)
|
|
1354
|
+
: '')
|
|
1355
|
+
+ (unknownPhase > 0 ? footnote(`${unknownPhase} finding(s) carry no phase and are excluded from shift-left analysis.`, p) : '')
|
|
1356
|
+
+ footnote('Survival rate matches issue classes (CWE, else category) across phases — lower is better. A class raised at design that reappears in code review or pen test was flagged early but not actually prevented.', p);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// --- Row 5: vulnerability classes and compliance coverage ---
|
|
1360
|
+
|
|
1361
|
+
async function renderCisoClasses(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
1362
|
+
const { findings } = await loadSecurityCorpus(fromIso, toIso);
|
|
1363
|
+
if (findings.length === 0) return noFindings(p);
|
|
1364
|
+
|
|
1365
|
+
const SEV_RANK: Record<string, number> = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
|
1366
|
+
const byCwe = new Map<string, { count: number; worst: string; cvss: number[] }>();
|
|
1367
|
+
const byCategory = new Map<string, number>();
|
|
1368
|
+
const byFramework = new Map<string, number>();
|
|
1369
|
+
let taggedCwe = 0, taggedCategory = 0, taggedCompliance = 0;
|
|
1370
|
+
|
|
1371
|
+
for (const f of findings) {
|
|
1372
|
+
const d = f.data.security_agent_finding;
|
|
1373
|
+
const sev = String(d.severity ?? 'UNKNOWN').toUpperCase();
|
|
1374
|
+
if (d.cwe_id) {
|
|
1375
|
+
taggedCwe += 1;
|
|
1376
|
+
const agg = byCwe.get(d.cwe_id) ?? { count: 0, worst: 'UNKNOWN', cvss: [] };
|
|
1377
|
+
agg.count += 1;
|
|
1378
|
+
if ((SEV_RANK[sev] ?? 0) > (SEV_RANK[agg.worst] ?? 0)) agg.worst = sev;
|
|
1379
|
+
if (typeof d.cvss_score === 'number') agg.cvss.push(d.cvss_score);
|
|
1380
|
+
byCwe.set(d.cwe_id, agg);
|
|
1381
|
+
}
|
|
1382
|
+
if (d.category) {
|
|
1383
|
+
taggedCategory += 1;
|
|
1384
|
+
byCategory.set(d.category, (byCategory.get(d.category) ?? 0) + 1);
|
|
1385
|
+
}
|
|
1386
|
+
const mappings: unknown = d.compliance_mappings;
|
|
1387
|
+
if (Array.isArray(mappings) && mappings.length > 0) {
|
|
1388
|
+
taggedCompliance += 1;
|
|
1389
|
+
for (const m of mappings) {
|
|
1390
|
+
const key = String(m);
|
|
1391
|
+
byFramework.set(key, (byFramework.get(key) ?? 0) + 1);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const cweMax = Math.max(...[...byCwe.values()].map(v => v.count), 1);
|
|
1397
|
+
const cweRows = [...byCwe.entries()]
|
|
1398
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
1399
|
+
.slice(0, 12)
|
|
1400
|
+
.map(([cwe, v]) => [
|
|
1401
|
+
esc(cwe),
|
|
1402
|
+
`${bar(v.count, cweMax, (SEV_RANK[v.worst] ?? 0) >= 3 ? p.danger : p.accent)}${num(v.count)}`,
|
|
1403
|
+
`<span style="color:${(SEV_RANK[v.worst] ?? 0) >= 3 ? p.danger : p.fg}">${esc(v.worst)}</span>`,
|
|
1404
|
+
v.cvss.length ? (v.cvss.reduce((a, b) => a + b, 0) / v.cvss.length).toFixed(1) : '—',
|
|
1405
|
+
]);
|
|
1406
|
+
|
|
1407
|
+
const catRows = [...byCategory.entries()]
|
|
1408
|
+
.sort((a, b) => b[1] - a[1])
|
|
1409
|
+
.slice(0, 10)
|
|
1410
|
+
.map(([c, n]) => [esc(c), num(n)]);
|
|
1411
|
+
|
|
1412
|
+
const fwRows = [...byFramework.entries()]
|
|
1413
|
+
.sort((a, b) => b[1] - a[1])
|
|
1414
|
+
.map(([fw, n]) => [esc(fw), num(n), pct((n / findings.length) * 100)]);
|
|
1415
|
+
|
|
1416
|
+
const coverage = kpiRow([
|
|
1417
|
+
{ label: 'Distinct CWEs', value: num(byCwe.size), note: `${num(taggedCwe)} of ${num(findings.length)} findings tagged` },
|
|
1418
|
+
{ label: 'Categories', value: num(byCategory.size), note: `${num(taggedCategory)} tagged` },
|
|
1419
|
+
{ label: 'Frameworks Touched', value: num(byFramework.size), note: `${num(taggedCompliance)} findings mapped` },
|
|
1420
|
+
], p);
|
|
1421
|
+
|
|
1422
|
+
// The kiro workflow's inline emission only carries finding_id, phase,
|
|
1423
|
+
// severity, cwe_id and commit_shas. category / compliance_mappings /
|
|
1424
|
+
// cvss_score come from the security-agent-processor webhook path, so a
|
|
1425
|
+
// kiro-only deployment legitimately has empty category and framework tables.
|
|
1426
|
+
const emitterNote = (taggedCategory === 0 || taggedCompliance === 0)
|
|
1427
|
+
? footnote('Empty category / framework tables mean findings arrived via the kiro eval-gate inline emission, which carries only finding_id, phase, severity, cwe_id and commit_shas. category and compliance_mappings are populated by the security-agent-processor webhook path.', p)
|
|
1428
|
+
: '';
|
|
1429
|
+
|
|
1430
|
+
return coverage
|
|
1431
|
+
+ (cweRows.length
|
|
1432
|
+
? `<div style="color:${p.mut};font-size:11px;margin:14px 0 0">Top CWEs</div>`
|
|
1433
|
+
+ table(['CWE', 'Findings', 'Worst Severity', 'Avg CVSS'], cweRows, p)
|
|
1434
|
+
: footnote('No findings carry a cwe_id in this range.', p))
|
|
1435
|
+
+ (catRows.length
|
|
1436
|
+
? `<div style="color:${p.mut};font-size:11px;margin:14px 0 0">Vulnerability categories</div>` + table(['Category', 'Findings'], catRows, p)
|
|
1437
|
+
: '')
|
|
1438
|
+
+ (fwRows.length
|
|
1439
|
+
? `<div style="color:${p.mut};font-size:11px;margin:14px 0 0">Compliance framework coverage</div>`
|
|
1440
|
+
+ table(['Framework', 'Findings', '% of all findings'], fwRows, p)
|
|
1441
|
+
: '')
|
|
1442
|
+
+ emitterNote
|
|
1443
|
+
+ footnote('compliance_mappings is a string array on each finding — it cannot be expressed as a CloudWatch dimension, so this view is only possible by reading events directly.', p);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// ---- Handler ----
|
|
1447
|
+
|
|
1448
|
+
export async function handler(event: WidgetEvent): Promise<string> {
|
|
1449
|
+
if (event.describe) return DOCS;
|
|
1450
|
+
|
|
1451
|
+
const dark = event.widgetContext?.theme === 'dark';
|
|
1452
|
+
const p = palette(dark);
|
|
1453
|
+
const end = event.widgetContext?.timeRange?.end ?? Date.now();
|
|
1454
|
+
const start = event.widgetContext?.timeRange?.start ?? end - 30 * 24 * 3600 * 1000;
|
|
1455
|
+
const fromIso = new Date(start).toISOString();
|
|
1456
|
+
const toIso = new Date(end).toISOString();
|
|
1457
|
+
const days = Math.max((end - start) / 86400000, 1 / 24);
|
|
1458
|
+
const view = event.view ?? 'dora';
|
|
1459
|
+
|
|
1460
|
+
try {
|
|
1461
|
+
let body: string;
|
|
1462
|
+
switch (view) {
|
|
1463
|
+
case 'dora': body = await renderDora(fromIso, toIso, days, p); break;
|
|
1464
|
+
case 'exec': body = await renderExec(fromIso, toIso, days, p); break;
|
|
1465
|
+
case 'exec-security': body = await renderExecSecurity(fromIso, toIso, p); break;
|
|
1466
|
+
case 'aidora': body = await renderAidora(fromIso, toIso, p); break;
|
|
1467
|
+
case 'repos': body = await renderRepos(fromIso, toIso, p); break;
|
|
1468
|
+
case 'eval': body = await renderEval(fromIso, toIso, p); break;
|
|
1469
|
+
case 'governance': body = await renderGovernance(fromIso, toIso, p); break;
|
|
1470
|
+
case 'agents': body = await renderAgents(fromIso, toIso, p); break;
|
|
1471
|
+
case 'security': body = await renderSecurity(fromIso, toIso, p); break;
|
|
1472
|
+
case 'ciso-exposure': body = await renderCisoExposure(fromIso, toIso, p); break;
|
|
1473
|
+
case 'ciso-sla': body = await renderCisoSla(fromIso, toIso, p); break;
|
|
1474
|
+
case 'ciso-risk': body = await renderCisoRisk(fromIso, toIso, p); break;
|
|
1475
|
+
case 'ciso-shiftleft': body = await renderCisoShiftleft(fromIso, toIso, p); break;
|
|
1476
|
+
case 'ciso-classes': body = await renderCisoClasses(fromIso, toIso, p); break;
|
|
1477
|
+
default: body = `<div style="color:${p.danger}">Unknown view: ${esc(view)}</div>`;
|
|
1478
|
+
}
|
|
1479
|
+
return `<div style="font-family:'Amazon Ember',Helvetica,Arial,sans-serif;color:${p.fg};font-size:13px">${body}</div>`;
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
return `<div style="color:${p.danger};font-size:12px">Panel error: ${esc((err as Error).message)}</div>`;
|
|
1482
|
+
}
|
|
1483
|
+
}
|