1import * as Lambda from "@/AWS/Lambda";
2import * as SecurityHub from "@/AWS/SecurityHub";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class SecurityHubTestFunction extends Lambda.Function<Lambda.Function>()(
13 "SecurityHubTestFunction",
14) {}
15
16/**
17 * Routes answer `{ …fields }` on success or `{ errorTag }` when the operation
18 * fails with a TYPED error — the test asserts the tag is in a route-specific
19 * allowlist, which proves both the binding wiring and the IAM grant. An
20 * untyped error crashes into a 500 instead.
21 */
22const errorTagged = <A, E extends { _tag: string }, R>(
23 effect: Effect.Effect<A, E, R>,
24): Effect.Effect<A | { errorTag: string; errorMessage?: string }, never, R> =>
25 effect.pipe(
26 Effect.map((a): A | { errorTag: string; errorMessage?: string } => a),
27 Effect.catch((e) =>
28 Effect.succeed({
29 errorTag: e._tag,
30 errorMessage:
31 (e as { Message?: string }).Message ??
32 (e as { message?: string }).message,
33 }),
34 ),
35 );
36
37// One binding per exercised Security Hub capability. Every SecurityHub
38// binding shares the single account-level scaffolding shape (the request
39// passes through as-is), so a representative spread across the thematic
40// groups proves the scaffold; org/member writes with cross-account side
41// effects (CreateMembers, EnableOrganizationAdminAccount, …) use the same
42// builder and are not re-proven here.
43export default SecurityHubTestFunction.make(
44 {
45 main,
46 functionUrl: true,
47 timeout: Duration.seconds(30),
48 },
49 Effect.gen(function* () {
50 // Security Hub must be enabled for every runtime call — the Hub is the
51 // account/region singleton the whole fixture leans on. The Bindings test
52 // only runs when the account had no Hub (capture-and-restore safety).
53 yield* SecurityHub.Hub("BindingsHub", { enableDefaultStandards: false });
54
55 // Findings triage loop
56 const getFindings = yield* SecurityHub.GetFindings();
57 const getFindingHistory = yield* SecurityHub.GetFindingHistory();
58 const batchImportFindings = yield* SecurityHub.BatchImportFindings();
59 const batchUpdateFindings = yield* SecurityHub.BatchUpdateFindings();
60
61 // Insights
62 const getInsights = yield* SecurityHub.GetInsights();
63
64 // Standards & controls
65 const describeStandards = yield* SecurityHub.DescribeStandards();
66 const getEnabledStandards = yield* SecurityHub.GetEnabledStandards();
67 const listSecurityControlDefinitions =
68 yield* SecurityHub.ListSecurityControlDefinitions();
69 const getSecurityControlDefinition =
70 yield* SecurityHub.GetSecurityControlDefinition();
71
72 // Product integrations
73 const describeProducts = yield* SecurityHub.DescribeProducts();
74 const listEnabledProductsForImport =
75 yield* SecurityHub.ListEnabledProductsForImport();
76
77 // Custom actions, automation rules & aggregation reads
78 const describeActionTargets = yield* SecurityHub.DescribeActionTargets();
79 const listAutomationRules = yield* SecurityHub.ListAutomationRules();
80 const listFindingAggregators = yield* SecurityHub.ListFindingAggregators();
81
82 // Members & organization
83 const listMembers = yield* SecurityHub.ListMembers();
84 const listInvitations = yield* SecurityHub.ListInvitations();
85 const getInvitationsCount = yield* SecurityHub.GetInvitationsCount();
86 const getAdministratorAccount =
87 yield* SecurityHub.GetAdministratorAccount();
88 const listOrganizationAdminAccounts =
89 yield* SecurityHub.ListOrganizationAdminAccounts();
90 const describeOrganizationConfiguration =
91 yield* SecurityHub.DescribeOrganizationConfiguration();
92
93 const bound = {
94 getFindings,
95 getFindingHistory,
96 batchImportFindings,
97 batchUpdateFindings,
98 getInsights,
99 describeStandards,
100 getEnabledStandards,
101 listSecurityControlDefinitions,
102 getSecurityControlDefinition,
103 describeProducts,
104 listEnabledProductsForImport,
105 describeActionTargets,
106 listAutomationRules,
107 listFindingAggregators,
108 listMembers,
109 listInvitations,
110 getInvitationsCount,
111 getAdministratorAccount,
112 listOrganizationAdminAccounts,
113 describeOrganizationConfiguration,
114 };
115
116 return {
117 fetch: Effect.gen(function* () {
118 const request = yield* HttpServerRequest;
119 const url = new URL(request.originalUrl);
120 const pathname = url.pathname;
121
122 if (request.method === "GET" && pathname === "/bindings") {
123 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
124 }
125
126 // Imports a synthetic ASFF finding through the account's default
127 // product — the documented way to exercise a findings pipeline. The
128 // test computes id/account/region out-of-band and passes them in.
129 if (request.method === "POST" && pathname === "/import") {
130 const id = url.searchParams.get("id");
131 const accountId = url.searchParams.get("account");
132 const region = url.searchParams.get("region");
133 if (!id || !accountId || !region) {
134 return yield* HttpServerResponse.json(
135 { error: "missing id/account/region" },
136 { status: 400 },
137 );
138 }
139 const productArn = `arn:aws:securityhub:${region}:${accountId}:product/${accountId}/default`;
140 const now = new Date().toISOString();
141 const result = yield* batchImportFindings({
142 Findings: [
143 {
144 SchemaVersion: "2018-10-08",
145 Id: id,
146 ProductArn: productArn,
147 GeneratorId: "alchemy-bindings-test",
148 AwsAccountId: accountId,
149 Types: ["Software and Configuration Checks"],
150 CreatedAt: now,
151 UpdatedAt: now,
152 Severity: { Label: "INFORMATIONAL" },
153 Title: "Alchemy SecurityHub bindings test finding",
154 Description:
155 "Synthetic finding imported by the alchemy SecurityHub bindings test.",
156 Resources: [
157 {
158 Type: "Other",
159 Id: "alchemy-bindings-test-resource",
160 Partition: "aws",
161 Region: region,
162 },
163 ],
164 },
165 ],
166 });
167 return yield* HttpServerResponse.json({
168 success: Number(result.SuccessCount ?? 0),
169 failed: Number(result.FailedCount ?? 0),
170 });
171 }
172
173 if (request.method === "GET" && pathname === "/findings") {
174 const id = url.searchParams.get("id");
175 const { Findings } = yield* getFindings({
176 ...(id
177 ? { Filters: { Id: [{ Value: id, Comparison: "EQUALS" }] } }
178 : { MaxResults: 10 }),
179 });
180 return yield* HttpServerResponse.json({
181 count: (Findings ?? []).length,
182 workflow: Findings?.[0]?.Workflow?.Status,
183 title: Findings?.[0]?.Title,
184 });
185 }
186
187 // Updates customer-editable fields on the imported finding.
188 if (request.method === "POST" && pathname === "/resolve") {
189 const id = url.searchParams.get("id");
190 const productArn = url.searchParams.get("productArn");
191 if (!id || !productArn) {
192 return yield* HttpServerResponse.json(
193 { error: "missing id/productArn" },
194 { status: 400 },
195 );
196 }
197 const result = yield* batchUpdateFindings({
198 FindingIdentifiers: [{ Id: id, ProductArn: productArn }],
199 Workflow: { Status: "NOTIFIED" },
200 Note: {
201 Text: "acknowledged by the alchemy bindings test",
202 UpdatedBy: "alchemy",
203 },
204 });
205 return yield* HttpServerResponse.json({
206 processed: (result.ProcessedFindings ?? []).length,
207 unprocessed: (result.UnprocessedFindings ?? []).length,
208 });
209 }
210
211 // Finding history may lag the import — a typed ResourceNotFound is an
212 // acceptable outcome that still proves the binding + IAM wiring.
213 if (request.method === "GET" && pathname === "/history") {
214 const id = url.searchParams.get("id");
215 const productArn = url.searchParams.get("productArn");
216 if (!id || !productArn) {
217 return yield* HttpServerResponse.json(
218 { error: "missing id/productArn" },
219 { status: 400 },
220 );
221 }
222 const result = yield* errorTagged(
223 getFindingHistory({
224 FindingIdentifier: { Id: id, ProductArn: productArn },
225 }).pipe(Effect.map((r) => ({ records: (r.Records ?? []).length }))),
226 );
227 return yield* HttpServerResponse.json(result);
228 }
229
230 if (request.method === "GET" && pathname === "/insights") {
231 const { Insights } = yield* getInsights();
232 return yield* HttpServerResponse.json({
233 count: (Insights ?? []).length,
234 });
235 }
236
237 if (request.method === "GET" && pathname === "/standards") {
238 const { Standards } = yield* describeStandards();
239 return yield* HttpServerResponse.json({
240 count: (Standards ?? []).length,
241 });
242 }
243
244 if (request.method === "GET" && pathname === "/enabled-standards") {
245 const { StandardsSubscriptions } = yield* getEnabledStandards();
246 return yield* HttpServerResponse.json({
247 count: (StandardsSubscriptions ?? []).length,
248 });
249 }
250
251 if (request.method === "GET" && pathname === "/control-definitions") {
252 const { SecurityControlDefinitions } =
253 yield* listSecurityControlDefinitions({ MaxResults: 10 });
254 return yield* HttpServerResponse.json({
255 count: (SecurityControlDefinitions ?? []).length,
256 });
257 }
258
259 if (request.method === "GET" && pathname === "/control") {
260 const { SecurityControlDefinition } =
261 yield* getSecurityControlDefinition({
262 SecurityControlId: "IAM.1",
263 });
264 return yield* HttpServerResponse.json({
265 id: SecurityControlDefinition?.SecurityControlId ?? null,
266 severity: SecurityControlDefinition?.SeverityRating ?? null,
267 });
268 }
269
270 if (request.method === "GET" && pathname === "/products") {
271 const { Products } = yield* describeProducts({ MaxResults: 10 });
272 return yield* HttpServerResponse.json({
273 count: (Products ?? []).length,
274 });
275 }
276
277 if (request.method === "GET" && pathname === "/enabled-products") {
278 const { ProductSubscriptions } =
279 yield* listEnabledProductsForImport();
280 return yield* HttpServerResponse.json({
281 count: (ProductSubscriptions ?? []).length,
282 });
283 }
284
285 if (request.method === "GET" && pathname === "/action-targets") {
286 const { ActionTargets } = yield* describeActionTargets();
287 return yield* HttpServerResponse.json({
288 count: (ActionTargets ?? []).length,
289 });
290 }
291
292 if (request.method === "GET" && pathname === "/automation-rules") {
293 const { AutomationRulesMetadata } = yield* listAutomationRules();
294 return yield* HttpServerResponse.json({
295 count: (AutomationRulesMetadata ?? []).length,
296 });
297 }
298
299 if (request.method === "GET" && pathname === "/aggregators") {
300 const { FindingAggregators } = yield* listFindingAggregators();
301 return yield* HttpServerResponse.json({
302 count: (FindingAggregators ?? []).length,
303 });
304 }
305
306 if (request.method === "GET" && pathname === "/members") {
307 const { Members } = yield* listMembers();
308 return yield* HttpServerResponse.json({
309 count: (Members ?? []).length,
310 });
311 }
312
313 if (request.method === "GET" && pathname === "/invitations") {
314 const { Invitations } = yield* listInvitations();
315 return yield* HttpServerResponse.json({
316 count: (Invitations ?? []).length,
317 });
318 }
319
320 if (request.method === "GET" && pathname === "/invitations-count") {
321 const { InvitationsCount } = yield* getInvitationsCount();
322 return yield* HttpServerResponse.json({
323 count: Number(InvitationsCount ?? 0),
324 });
325 }
326
327 // A standalone account has no administrator — Security Hub answers
328 // with an empty document or a typed error.
329 if (request.method === "GET" && pathname === "/admin") {
330 const result = yield* errorTagged(
331 getAdministratorAccount().pipe(
332 Effect.map((r) => ({
333 administrator: r.Administrator?.AccountId ?? null,
334 })),
335 ),
336 );
337 return yield* HttpServerResponse.json(result);
338 }
339
340 // Organization administration: for a standalone (non-management)
341 // account Security Hub answers with a typed error — asserting the tag
342 // proves the binding + IAM wiring end-to-end.
343 if (request.method === "GET" && pathname === "/org-admins") {
344 const result = yield* errorTagged(
345 listOrganizationAdminAccounts().pipe(
346 Effect.map((r) => ({ admins: (r.AdminAccounts ?? []).length })),
347 ),
348 );
349 return yield* HttpServerResponse.json(result);
350 }
351
352 if (request.method === "GET" && pathname === "/org-config") {
353 const result = yield* errorTagged(
354 describeOrganizationConfiguration().pipe(
355 Effect.map((r) => ({ autoEnable: r.AutoEnable ?? false })),
356 ),
357 );
358 return yield* HttpServerResponse.json(result);
359 }
360
361 return yield* HttpServerResponse.json(
362 { error: "Not found", method: request.method, pathname },
363 { status: 404 },
364 );
365 }).pipe(
366 // Surface typed operation errors as JSON so live-test failures carry
367 // the tag + message instead of an opaque 500 (routes that expect a
368 // typed error use `errorTagged` above and never reach this).
369 Effect.catch((e) =>
370 HttpServerResponse.json(
371 {
372 errorTag: (e as { _tag?: string })._tag ?? "UnknownError",
373 errorMessage:
374 (e as { message?: string }).message ??
375 (e as { Message?: string }).Message,
376 },
377 { status: 500 },
378 ),
379 ),
380 Effect.orDie,
381 ),
382 };
383 }).pipe(
384 Effect.provide(
385 Layer.mergeAll(
386 SecurityHub.BatchImportFindingsHttp,
387 SecurityHub.BatchUpdateFindingsHttp,
388 SecurityHub.DescribeActionTargetsHttp,
389 SecurityHub.DescribeOrganizationConfigurationHttp,
390 SecurityHub.DescribeProductsHttp,
391 SecurityHub.DescribeStandardsHttp,
392 SecurityHub.GetAdministratorAccountHttp,
393 SecurityHub.GetEnabledStandardsHttp,
394 SecurityHub.GetFindingHistoryHttp,
395 SecurityHub.GetFindingsHttp,
396 SecurityHub.GetInsightsHttp,
397 SecurityHub.GetInvitationsCountHttp,
398 SecurityHub.GetSecurityControlDefinitionHttp,
399 SecurityHub.ListAutomationRulesHttp,
400 SecurityHub.ListEnabledProductsForImportHttp,
401 SecurityHub.ListFindingAggregatorsHttp,
402 SecurityHub.ListInvitationsHttp,
403 SecurityHub.ListMembersHttp,
404 SecurityHub.ListOrganizationAdminAccountsHttp,
405 SecurityHub.ListSecurityControlDefinitionsHttp,
406 ),
407 ),
408 ),
409);