@prism-d1/cli 1.0.27 → 1.0.29
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/eval-harness/README.md +114 -0
- package/dist/assets/eval-harness/eval-config.json +10 -0
- package/dist/assets/eval-harness/rubrics/agent-quality.json +79 -0
- package/dist/assets/eval-harness/rubrics/api-response-quality.json +45 -0
- package/dist/assets/eval-harness/rubrics/code-quality.json +98 -0
- package/dist/assets/eval-harness/rubrics/security-compliance.json +145 -0
- package/dist/assets/eval-harness/rubrics/spec-compliance.json +67 -0
- package/dist/assets/eval-harness/run-eval.sh +122 -0
- package/dist/assets/github-workflows/README.md +110 -0
- package/dist/assets/github-workflows/prism-agent-eval.yml +313 -0
- package/dist/assets/github-workflows/prism-ai-metrics.yml +261 -0
- package/dist/assets/github-workflows/prism-dora-weekly.yml +334 -0
- package/dist/assets/github-workflows/prism-eval-gate.yml +310 -0
- package/dist/assets/infra/bin/app.ts +56 -0
- package/dist/assets/infra/cdk.json +12 -0
- package/dist/assets/infra/lib/api-stack.ts +347 -0
- package/dist/assets/infra/lib/constructs/bedrock-guardrail-construct.ts +201 -0
- package/dist/assets/infra/lib/constructs/guardrail-enforcer-construct.ts +59 -0
- package/dist/assets/infra/lib/constructs/prism-vpc-construct.ts +75 -0
- package/dist/assets/infra/lib/constructs/security-agent-construct.ts +266 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +1392 -0
- package/dist/assets/infra/lib/lambda/api-handler.ts +477 -0
- package/dist/assets/infra/lib/lambda/defect-correlator.ts +142 -0
- package/dist/assets/infra/lib/lambda/exfiltration-detector.ts +100 -0
- package/dist/assets/infra/lib/lambda/layers/guardrail-enforcer/nodejs/guardrail-enforcer.js +53 -0
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +748 -0
- package/dist/assets/infra/lib/lambda/security-agent-processor.ts +231 -0
- package/dist/assets/infra/lib/lambda/security-remediation-tracker.ts +120 -0
- package/dist/assets/infra/lib/lambda/security-response-automator.ts +130 -0
- package/dist/assets/infra/lib/lambda/spec-to-code-calculator.ts +123 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +701 -0
- package/dist/assets/infra/package.json +23 -0
- package/dist/assets/infra/tsconfig.json +24 -0
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +3 -4
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.js +2 -5
- package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
- package/dist/src/commands/securityagent/setup.d.ts.map +1 -1
- package/dist/src/commands/securityagent/setup.js +2 -3
- package/dist/src/commands/securityagent/setup.js.map +1 -1
- package/dist/src/commands/workshop/deploy-infra.d.ts.map +1 -1
- package/dist/src/commands/workshop/deploy-infra.js +2 -3
- package/dist/src/commands/workshop/deploy-infra.js.map +1 -1
- package/dist/src/commands/workshop/generate-demo-data.d.ts.map +1 -1
- package/dist/src/commands/workshop/generate-demo-data.js +3 -8
- package/dist/src/commands/workshop/generate-demo-data.js.map +1 -1
- package/dist/src/commands/workshop/perform-pen-test.d.ts.map +1 -1
- package/dist/src/commands/workshop/perform-pen-test.js +5 -14
- package/dist/src/commands/workshop/perform-pen-test.js.map +1 -1
- package/dist/src/utils/root.d.ts +6 -0
- package/dist/src/utils/root.d.ts.map +1 -1
- package/dist/src/utils/root.js +29 -0
- package/dist/src/utils/root.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EventBridgeClient,
|
|
3
|
+
PutEventsCommand,
|
|
4
|
+
} from '@aws-sdk/client-eventbridge';
|
|
5
|
+
import {
|
|
6
|
+
DynamoDBClient,
|
|
7
|
+
PutItemCommand,
|
|
8
|
+
QueryCommand,
|
|
9
|
+
} from '@aws-sdk/client-dynamodb';
|
|
10
|
+
|
|
11
|
+
// ---- Types ----
|
|
12
|
+
|
|
13
|
+
interface ApiGatewayEvent {
|
|
14
|
+
httpMethod: string;
|
|
15
|
+
path: string;
|
|
16
|
+
pathParameters?: Record<string, string> | null;
|
|
17
|
+
queryStringParameters?: Record<string, string> | null;
|
|
18
|
+
body?: string | null;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ApiGatewayResponse {
|
|
23
|
+
statusCode: number;
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
body: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MetricPayload {
|
|
29
|
+
'detail-type': string;
|
|
30
|
+
detail: {
|
|
31
|
+
team_id: string;
|
|
32
|
+
repo: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
prism_level?: string;
|
|
35
|
+
metric?: { name: string; value: number; unit: string };
|
|
36
|
+
ai_context?: {
|
|
37
|
+
tool: string;
|
|
38
|
+
model: string;
|
|
39
|
+
origin: string;
|
|
40
|
+
};
|
|
41
|
+
dora?: Record<string, number | null>;
|
|
42
|
+
ai_dora?: Record<string, number | null>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- Clients ----
|
|
47
|
+
|
|
48
|
+
const eventBridgeClient = new EventBridgeClient({});
|
|
49
|
+
const dynamoClient = new DynamoDBClient({});
|
|
50
|
+
|
|
51
|
+
const EVENT_BUS_NAME = process.env.EVENT_BUS_NAME!;
|
|
52
|
+
const EVENTS_TABLE = process.env.EVENTS_TABLE!;
|
|
53
|
+
const METADATA_TABLE = process.env.METADATA_TABLE!;
|
|
54
|
+
|
|
55
|
+
const CORS_HEADERS: Record<string, string> = {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'Access-Control-Allow-Origin': '*',
|
|
58
|
+
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
|
59
|
+
'Access-Control-Allow-Headers': 'Content-Type,X-Api-Key,Authorization',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ---- Handler ----
|
|
63
|
+
|
|
64
|
+
export async function handler(event: ApiGatewayEvent): Promise<ApiGatewayResponse> {
|
|
65
|
+
console.log('API request:', JSON.stringify(event, null, 2));
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// OPTIONS preflight
|
|
69
|
+
if (event.httpMethod === 'OPTIONS') {
|
|
70
|
+
return respond(200, { message: 'OK' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Route requests
|
|
74
|
+
if (event.httpMethod === 'POST' && event.path === '/metrics') {
|
|
75
|
+
return await handlePostMetrics(event);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (event.httpMethod === 'POST' && event.path === '/assessment') {
|
|
79
|
+
return await handlePostAssessment(event);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (event.httpMethod === 'GET' && event.path.startsWith('/metrics/')) {
|
|
83
|
+
return await handleGetMetrics(event);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (event.httpMethod === 'POST' && event.path === '/security-findings') {
|
|
87
|
+
return await handlePostSecurityFindings(event);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (event.httpMethod === 'GET' && event.path.startsWith('/security-findings/')) {
|
|
91
|
+
return await handleGetSecurityFindings(event);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return respond(404, { error: 'Not Found', message: `No route for ${event.httpMethod} ${event.path}` });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('Unhandled error:', err);
|
|
97
|
+
return respond(500, { error: 'Internal Server Error', message: (err as Error).message });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- POST /metrics ----
|
|
102
|
+
|
|
103
|
+
async function handlePostMetrics(event: ApiGatewayEvent): Promise<ApiGatewayResponse> {
|
|
104
|
+
if (!event.body) {
|
|
105
|
+
return respond(400, { error: 'Bad Request', message: 'Request body is required' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let payload: MetricPayload;
|
|
109
|
+
try {
|
|
110
|
+
payload = JSON.parse(event.body);
|
|
111
|
+
} catch {
|
|
112
|
+
return respond(400, { error: 'Bad Request', message: 'Invalid JSON body' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const validationError = validateMetricPayload(payload);
|
|
116
|
+
if (validationError) {
|
|
117
|
+
return respond(400, { error: 'Bad Request', message: validationError });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await eventBridgeClient.send(
|
|
121
|
+
new PutEventsCommand({
|
|
122
|
+
Entries: [
|
|
123
|
+
{
|
|
124
|
+
Source: 'prism.d1.velocity',
|
|
125
|
+
DetailType: payload['detail-type'],
|
|
126
|
+
Detail: JSON.stringify(payload.detail),
|
|
127
|
+
EventBusName: EVENT_BUS_NAME,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return respond(202, {
|
|
134
|
+
message: 'Event accepted',
|
|
135
|
+
detail_type: payload['detail-type'],
|
|
136
|
+
team_id: payload.detail.team_id,
|
|
137
|
+
repo: payload.detail.repo,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- POST /assessment ----
|
|
142
|
+
|
|
143
|
+
async function handlePostAssessment(event: ApiGatewayEvent): Promise<ApiGatewayResponse> {
|
|
144
|
+
if (!event.body) {
|
|
145
|
+
return respond(400, { error: 'Bad Request', message: 'Request body is required' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let payload: MetricPayload;
|
|
149
|
+
try {
|
|
150
|
+
payload = JSON.parse(event.body);
|
|
151
|
+
} catch {
|
|
152
|
+
return respond(400, { error: 'Bad Request', message: 'Invalid JSON body' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!payload.detail?.team_id || !payload.detail?.repo) {
|
|
156
|
+
return respond(400, { error: 'Bad Request', message: 'team_id and repo are required' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Store assessment directly in DynamoDB for fast lookups
|
|
160
|
+
const item: Record<string, { S?: string; N?: string }> = {
|
|
161
|
+
team_id: { S: payload.detail.team_id },
|
|
162
|
+
repo: { S: payload.detail.repo },
|
|
163
|
+
last_updated: { S: payload.detail.timestamp ?? new Date().toISOString() },
|
|
164
|
+
prism_level: { N: payload.detail.prism_level ?? '1' },
|
|
165
|
+
last_event_type: { S: 'prism.d1.assessment' },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (payload.detail.metric) {
|
|
169
|
+
item.assessment_metric = { S: payload.detail.metric.name };
|
|
170
|
+
item.assessment_value = { N: payload.detail.metric.value.toString() };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await dynamoClient.send(
|
|
174
|
+
new PutItemCommand({
|
|
175
|
+
TableName: METADATA_TABLE,
|
|
176
|
+
Item: item,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Also publish to EventBridge for pipeline processing
|
|
181
|
+
await eventBridgeClient.send(
|
|
182
|
+
new PutEventsCommand({
|
|
183
|
+
Entries: [
|
|
184
|
+
{
|
|
185
|
+
Source: 'prism.d1.velocity',
|
|
186
|
+
DetailType: 'prism.d1.assessment',
|
|
187
|
+
Detail: JSON.stringify(payload.detail),
|
|
188
|
+
EventBusName: EVENT_BUS_NAME,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return respond(202, {
|
|
195
|
+
message: 'Assessment accepted',
|
|
196
|
+
team_id: payload.detail.team_id,
|
|
197
|
+
repo: payload.detail.repo,
|
|
198
|
+
prism_level: payload.detail.prism_level,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- GET /metrics/{team_id} ----
|
|
203
|
+
|
|
204
|
+
async function handleGetMetrics(event: ApiGatewayEvent): Promise<ApiGatewayResponse> {
|
|
205
|
+
const teamId = event.pathParameters?.team_id ?? extractTeamIdFromPath(event.path);
|
|
206
|
+
|
|
207
|
+
if (!teamId) {
|
|
208
|
+
return respond(400, { error: 'Bad Request', message: 'team_id path parameter is required' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const repo = event.queryStringParameters?.repo;
|
|
212
|
+
const hours = parseInt(event.queryStringParameters?.hours ?? '168', 10); // default 7 days
|
|
213
|
+
const detailType = event.queryStringParameters?.detail_type;
|
|
214
|
+
|
|
215
|
+
const startTime = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
216
|
+
const endTime = new Date().toISOString();
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
let rows: Record<string, string | null>[];
|
|
220
|
+
|
|
221
|
+
if (detailType && !repo) {
|
|
222
|
+
// Query by detail_type using GSI, then filter by team_id client-side
|
|
223
|
+
const result = await dynamoClient.send(
|
|
224
|
+
new QueryCommand({
|
|
225
|
+
TableName: EVENTS_TABLE,
|
|
226
|
+
IndexName: 'by-detail-type',
|
|
227
|
+
KeyConditionExpression: 'detail_type = :dt AND sk BETWEEN :start AND :end',
|
|
228
|
+
FilterExpression: 'begins_with(pk, :teamPrefix)',
|
|
229
|
+
ExpressionAttributeValues: {
|
|
230
|
+
':dt': { S: detailType },
|
|
231
|
+
':start': { S: startTime },
|
|
232
|
+
':end': { S: endTime },
|
|
233
|
+
':teamPrefix': { S: `${teamId}#` },
|
|
234
|
+
},
|
|
235
|
+
Limit: 1000,
|
|
236
|
+
ScanIndexForward: false,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
rows = (result.Items ?? []).map(mapDynamoItem);
|
|
241
|
+
} else {
|
|
242
|
+
// Query by pk (team_id#repo) using main table
|
|
243
|
+
const pk = repo ? `${teamId}#${repo}` : undefined;
|
|
244
|
+
|
|
245
|
+
if (pk) {
|
|
246
|
+
const result = await dynamoClient.send(
|
|
247
|
+
new QueryCommand({
|
|
248
|
+
TableName: EVENTS_TABLE,
|
|
249
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
250
|
+
ExpressionAttributeValues: {
|
|
251
|
+
':pk': { S: pk },
|
|
252
|
+
':start': { S: startTime },
|
|
253
|
+
':end': { S: endTime },
|
|
254
|
+
...(detailType ? { ':dt': { S: detailType } } : {}),
|
|
255
|
+
},
|
|
256
|
+
...(detailType ? { FilterExpression: 'detail_type = :dt' } : {}),
|
|
257
|
+
Limit: 1000,
|
|
258
|
+
ScanIndexForward: false,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
rows = (result.Items ?? []).map(mapDynamoItem);
|
|
263
|
+
} else {
|
|
264
|
+
// No repo specified — query GSI by detail_type if provided, otherwise
|
|
265
|
+
// we need to scan with team prefix filter. For efficiency, use a
|
|
266
|
+
// begins_with filter on the main table by doing a query per common
|
|
267
|
+
// detail type or just filter. Here we use the GSI with a broad approach.
|
|
268
|
+
// Since we don't have a pk, query all detail types for this team.
|
|
269
|
+
const result = await dynamoClient.send(
|
|
270
|
+
new QueryCommand({
|
|
271
|
+
TableName: EVENTS_TABLE,
|
|
272
|
+
IndexName: 'by-detail-type',
|
|
273
|
+
KeyConditionExpression: 'detail_type = :dt AND sk BETWEEN :start AND :end',
|
|
274
|
+
FilterExpression: 'begins_with(pk, :teamPrefix)',
|
|
275
|
+
ExpressionAttributeValues: {
|
|
276
|
+
':dt': { S: 'prism.d1.commit' },
|
|
277
|
+
':start': { S: startTime },
|
|
278
|
+
':end': { S: endTime },
|
|
279
|
+
':teamPrefix': { S: `${teamId}#` },
|
|
280
|
+
},
|
|
281
|
+
Limit: 1000,
|
|
282
|
+
ScanIndexForward: false,
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Query all detail types in parallel
|
|
287
|
+
const detailTypes = [
|
|
288
|
+
'prism.d1.pr',
|
|
289
|
+
'prism.d1.deploy',
|
|
290
|
+
'prism.d1.eval',
|
|
291
|
+
'prism.d1.incident',
|
|
292
|
+
'prism.d1.assessment',
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
const additionalResults = await Promise.all(
|
|
296
|
+
detailTypes.map((dt) =>
|
|
297
|
+
dynamoClient.send(
|
|
298
|
+
new QueryCommand({
|
|
299
|
+
TableName: EVENTS_TABLE,
|
|
300
|
+
IndexName: 'by-detail-type',
|
|
301
|
+
KeyConditionExpression: 'detail_type = :dt AND sk BETWEEN :start AND :end',
|
|
302
|
+
FilterExpression: 'begins_with(pk, :teamPrefix)',
|
|
303
|
+
ExpressionAttributeValues: {
|
|
304
|
+
':dt': { S: dt },
|
|
305
|
+
':start': { S: startTime },
|
|
306
|
+
':end': { S: endTime },
|
|
307
|
+
':teamPrefix': { S: `${teamId}#` },
|
|
308
|
+
},
|
|
309
|
+
Limit: 1000,
|
|
310
|
+
ScanIndexForward: false,
|
|
311
|
+
}),
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const allItems = [
|
|
317
|
+
...(result.Items ?? []),
|
|
318
|
+
...additionalResults.flatMap((r) => r.Items ?? []),
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
// Sort by sk descending and limit to 1000
|
|
322
|
+
allItems.sort((a, b) => (b.sk?.S ?? '').localeCompare(a.sk?.S ?? ''));
|
|
323
|
+
rows = allItems.slice(0, 1000).map(mapDynamoItem);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return respond(200, {
|
|
328
|
+
team_id: teamId,
|
|
329
|
+
query_hours: hours,
|
|
330
|
+
count: rows.length,
|
|
331
|
+
records: rows,
|
|
332
|
+
});
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.error('DynamoDB query error:', err);
|
|
335
|
+
return respond(500, { error: 'Query Failed', message: (err as Error).message });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function mapDynamoItem(item: Record<string, { S?: string; N?: string }>): Record<string, string | null> {
|
|
340
|
+
const record: Record<string, string | null> = {};
|
|
341
|
+
for (const [key, value] of Object.entries(item)) {
|
|
342
|
+
record[key] = value.S ?? value.N ?? null;
|
|
343
|
+
}
|
|
344
|
+
return record;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- Helpers ----
|
|
348
|
+
|
|
349
|
+
function extractTeamIdFromPath(path: string): string | undefined {
|
|
350
|
+
const match = path.match(/^\/metrics\/([^/]+)$/);
|
|
351
|
+
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function validateMetricPayload(payload: MetricPayload): string | null {
|
|
355
|
+
const validDetailTypes = [
|
|
356
|
+
'prism.d1.commit',
|
|
357
|
+
'prism.d1.pr',
|
|
358
|
+
'prism.d1.deploy',
|
|
359
|
+
'prism.d1.eval',
|
|
360
|
+
'prism.d1.incident',
|
|
361
|
+
'prism.d1.assessment',
|
|
362
|
+
'prism.d1.agent',
|
|
363
|
+
'prism.d1.agent.eval',
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
if (!payload['detail-type']) {
|
|
367
|
+
return 'detail-type is required';
|
|
368
|
+
}
|
|
369
|
+
if (!validDetailTypes.includes(payload['detail-type'])) {
|
|
370
|
+
return `Invalid detail-type. Must be one of: ${validDetailTypes.join(', ')}`;
|
|
371
|
+
}
|
|
372
|
+
if (!payload.detail) {
|
|
373
|
+
return 'detail object is required';
|
|
374
|
+
}
|
|
375
|
+
if (!payload.detail.team_id) {
|
|
376
|
+
return 'detail.team_id is required';
|
|
377
|
+
}
|
|
378
|
+
if (!payload.detail.repo) {
|
|
379
|
+
return 'detail.repo is required';
|
|
380
|
+
}
|
|
381
|
+
if (!payload.detail.timestamp) {
|
|
382
|
+
return 'detail.timestamp is required';
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- POST /security-findings ----
|
|
388
|
+
|
|
389
|
+
async function handlePostSecurityFindings(event: ApiGatewayEvent): Promise<ApiGatewayResponse> {
|
|
390
|
+
const body = JSON.parse(event.body ?? '{}');
|
|
391
|
+
const findings = body.findings ?? [body];
|
|
392
|
+
|
|
393
|
+
if (!Array.isArray(findings) || findings.length === 0) {
|
|
394
|
+
return respond(400, { error: 'findings array is required' });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const entries = findings.map((finding: any) => ({
|
|
398
|
+
Source: 'prism.d1.velocity',
|
|
399
|
+
DetailType: `prism.d1.security.${finding.type ?? finding.phase ?? 'code_review'}`,
|
|
400
|
+
EventBusName: EVENT_BUS_NAME,
|
|
401
|
+
Detail: JSON.stringify({
|
|
402
|
+
team_id: finding.team_id ?? 'unknown',
|
|
403
|
+
repo: finding.repository ?? finding.repo ?? 'unknown',
|
|
404
|
+
timestamp: finding.found_at ?? new Date().toISOString(),
|
|
405
|
+
prism_level: 3,
|
|
406
|
+
metric: { name: 'security_finding', value: 1, unit: 'count' },
|
|
407
|
+
security_agent_finding: finding,
|
|
408
|
+
}),
|
|
409
|
+
}));
|
|
410
|
+
|
|
411
|
+
// Emit in batches of 10
|
|
412
|
+
for (let i = 0; i < entries.length; i += 10) {
|
|
413
|
+
await eventBridgeClient.send(new PutEventsCommand({ Entries: entries.slice(i, i + 10) }));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return respond(200, { message: 'OK', findingsProcessed: entries.length });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- GET /security-findings/{team_id} ----
|
|
420
|
+
|
|
421
|
+
async function handleGetSecurityFindings(event: ApiGatewayEvent): Promise<ApiGatewayResponse> {
|
|
422
|
+
const teamId = event.pathParameters?.team_id;
|
|
423
|
+
if (!teamId) {
|
|
424
|
+
return respond(400, { error: 'team_id path parameter is required' });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const hours = parseInt(event.queryStringParameters?.hours ?? '168', 10); // Default 7 days
|
|
428
|
+
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
429
|
+
|
|
430
|
+
const securityTypes = [
|
|
431
|
+
'prism.d1.security.design_review',
|
|
432
|
+
'prism.d1.security.code_review',
|
|
433
|
+
'prism.d1.security.pen_test',
|
|
434
|
+
];
|
|
435
|
+
|
|
436
|
+
const allFindings: any[] = [];
|
|
437
|
+
|
|
438
|
+
for (const detailType of securityTypes) {
|
|
439
|
+
const result = await dynamoClient.send(
|
|
440
|
+
new QueryCommand({
|
|
441
|
+
TableName: EVENTS_TABLE,
|
|
442
|
+
IndexName: 'by-detail-type',
|
|
443
|
+
KeyConditionExpression: 'detail_type = :dt AND sk >= :since',
|
|
444
|
+
ExpressionAttributeValues: {
|
|
445
|
+
':dt': { S: detailType },
|
|
446
|
+
':since': { S: since },
|
|
447
|
+
},
|
|
448
|
+
ScanIndexForward: false,
|
|
449
|
+
Limit: 100,
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
if (result.Items) {
|
|
454
|
+
for (const item of result.Items) {
|
|
455
|
+
const data = JSON.parse(item.data?.S ?? '{}');
|
|
456
|
+
if (data.team_id === teamId) {
|
|
457
|
+
allFindings.push({
|
|
458
|
+
detail_type: detailType,
|
|
459
|
+
timestamp: item.sk?.S,
|
|
460
|
+
finding_id: item.finding_id?.S,
|
|
461
|
+
...data,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return respond(200, { team_id: teamId, findings: allFindings, count: allFindings.length });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function respond(statusCode: number, body: Record<string, unknown>): ApiGatewayResponse {
|
|
472
|
+
return {
|
|
473
|
+
statusCode,
|
|
474
|
+
headers: CORS_HEADERS,
|
|
475
|
+
body: JSON.stringify(body),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamoDBClient,
|
|
3
|
+
QueryCommand,
|
|
4
|
+
} from '@aws-sdk/client-dynamodb';
|
|
5
|
+
import {
|
|
6
|
+
EventBridgeClient,
|
|
7
|
+
PutEventsCommand,
|
|
8
|
+
} from '@aws-sdk/client-eventbridge';
|
|
9
|
+
|
|
10
|
+
const dynamoClient = new DynamoDBClient({});
|
|
11
|
+
const eventBridgeClient = new EventBridgeClient({});
|
|
12
|
+
|
|
13
|
+
const EVENTS_TABLE = process.env.EVENTS_TABLE!;
|
|
14
|
+
const EVENT_BUS_NAME = process.env.EVENT_BUS_NAME!;
|
|
15
|
+
const LOOKBACK_HOURS = parseInt(process.env.LOOKBACK_HOURS ?? '24', 10);
|
|
16
|
+
|
|
17
|
+
interface DeployEvent {
|
|
18
|
+
source: string;
|
|
19
|
+
'detail-type': string;
|
|
20
|
+
detail: {
|
|
21
|
+
team_id: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
dora?: {
|
|
25
|
+
change_failure_rate?: number;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Triggered by failed deployment events.
|
|
32
|
+
* Queries recent commits by AI origin, calculates AI vs human defect rates,
|
|
33
|
+
* and emits a prism.d1.quality event.
|
|
34
|
+
*/
|
|
35
|
+
export async function handler(event: DeployEvent): Promise<void> {
|
|
36
|
+
const detail = event.detail;
|
|
37
|
+
|
|
38
|
+
// Only process deployment failures
|
|
39
|
+
const cfr = detail.dora?.change_failure_rate ?? 0;
|
|
40
|
+
if (cfr === 0) {
|
|
41
|
+
console.log('Deployment succeeded, no defect correlation needed');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(`Processing failed deployment for ${detail.team_id}/${detail.repo}`);
|
|
46
|
+
|
|
47
|
+
const deployTime = new Date(detail.timestamp);
|
|
48
|
+
const lookbackStart = new Date(deployTime.getTime() - LOOKBACK_HOURS * 60 * 60 * 1000);
|
|
49
|
+
|
|
50
|
+
// Query recent commit events for this repo
|
|
51
|
+
try {
|
|
52
|
+
const result = await dynamoClient.send(
|
|
53
|
+
new QueryCommand({
|
|
54
|
+
TableName: EVENTS_TABLE,
|
|
55
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
56
|
+
ExpressionAttributeValues: {
|
|
57
|
+
':pk': { S: `${detail.team_id}#${detail.repo}` },
|
|
58
|
+
':start': { S: lookbackStart.toISOString() },
|
|
59
|
+
':end': { S: deployTime.toISOString() },
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!result.Items || result.Items.length === 0) {
|
|
65
|
+
console.log('No recent commits found in lookback window');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Count commits by AI origin
|
|
70
|
+
let aiCommitCount = 0;
|
|
71
|
+
let humanCommitCount = 0;
|
|
72
|
+
|
|
73
|
+
for (const item of result.Items) {
|
|
74
|
+
const detailType = item.detail_type?.S ?? '';
|
|
75
|
+
if (detailType !== 'prism.d1.commit') continue;
|
|
76
|
+
|
|
77
|
+
const data = JSON.parse(item.data?.S ?? '{}');
|
|
78
|
+
const origin = data.ai_context?.origin ?? 'human';
|
|
79
|
+
|
|
80
|
+
if (origin === 'ai-generated' || origin === 'ai-assisted') {
|
|
81
|
+
aiCommitCount++;
|
|
82
|
+
} else {
|
|
83
|
+
humanCommitCount++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const totalCommits = aiCommitCount + humanCommitCount;
|
|
88
|
+
if (totalCommits === 0) {
|
|
89
|
+
console.log('No commit events in lookback window');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Calculate defect rates using proportional distribution.
|
|
94
|
+
// Since we can't attribute a specific failure to a specific commit,
|
|
95
|
+
// we distribute the change failure rate proportionally by code origin.
|
|
96
|
+
// This gives a directional signal: if 80% of commits are AI and the
|
|
97
|
+
// deploy failed, AI code carries 80% of the attributed failure weight.
|
|
98
|
+
const aiDefectRate = aiCommitCount > 0 ? (cfr * aiCommitCount) / totalCommits : 0;
|
|
99
|
+
const humanDefectRate = humanCommitCount > 0 ? (cfr * humanCommitCount) / totalCommits : 0;
|
|
100
|
+
|
|
101
|
+
const qualityEvent = {
|
|
102
|
+
team_id: detail.team_id,
|
|
103
|
+
repo: detail.repo,
|
|
104
|
+
timestamp: detail.timestamp,
|
|
105
|
+
prism_level: 2,
|
|
106
|
+
metric: {
|
|
107
|
+
name: 'post_merge_defect_rate',
|
|
108
|
+
value: cfr,
|
|
109
|
+
unit: 'percent',
|
|
110
|
+
},
|
|
111
|
+
ai_dora: {
|
|
112
|
+
post_merge_defect_rate: cfr,
|
|
113
|
+
},
|
|
114
|
+
quality: {
|
|
115
|
+
deployment_id: `deploy-${Date.now()}`,
|
|
116
|
+
ai_defect_rate: Math.round(aiDefectRate * 10000) / 10000,
|
|
117
|
+
human_defect_rate: Math.round(humanDefectRate * 10000) / 10000,
|
|
118
|
+
total_ai_commits: aiCommitCount,
|
|
119
|
+
total_human_commits: humanCommitCount,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await eventBridgeClient.send(
|
|
124
|
+
new PutEventsCommand({
|
|
125
|
+
Entries: [
|
|
126
|
+
{
|
|
127
|
+
Source: 'prism.d1.velocity',
|
|
128
|
+
DetailType: 'prism.d1.quality',
|
|
129
|
+
EventBusName: EVENT_BUS_NAME,
|
|
130
|
+
Detail: JSON.stringify(qualityEvent),
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
console.log(
|
|
137
|
+
`Emitted quality event: AI defect rate ${aiDefectRate.toFixed(4)}, Human defect rate ${humanDefectRate.toFixed(4)}, ${aiCommitCount} AI commits, ${humanCommitCount} human commits`,
|
|
138
|
+
);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error('Failed to correlate defects:', err);
|
|
141
|
+
}
|
|
142
|
+
}
|