1import * as GuardDuty from "@/AWS/GuardDuty";
2import * as Lambda from "@/AWS/Lambda";
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 GuardDutyTestFunction extends Lambda.Function<Lambda.Function>()(
13 "GuardDutyTestFunction",
14) {}
15
16/**
17 * Routes answer `{ …fields }` on success or `{ errorTag }` when the
18 * operation fails with a TYPED error — the test asserts the tag is in a
19 * route-specific allowlist, which proves both the binding wiring and the
20 * IAM grant. An 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 GuardDuty capability, spanning both scaffolding
38// shapes: detector-scoped (DetectorId injected from the bound Detector) and
39// account-level (request passed as-is). Member/org-admin writes with
40// cross-account side effects (InviteMembers, EnableOrganizationAdminAccount,
41// …) share the same two builders and are not re-proven here.
42export default GuardDutyTestFunction.make(
43 {
44 main,
45 functionUrl: true,
46 timeout: Duration.seconds(30),
47 },
48 Effect.gen(function* () {
49 // The detector every detector-scoped binding is bound to. GuardDuty
50 // allows one detector per account/region — the test account keeps none
51 // deployed between runs (Detector.test.ts is capture-and-restore safe).
52 const detector = yield* GuardDuty.Detector("BindingsDetector", {
53 findingPublishingFrequency: "FIFTEEN_MINUTES",
54 tags: { fixture: "guardduty-bindings" },
55 });
56
57 // Findings triage loop
58 const createSampleFindings =
59 yield* GuardDuty.CreateSampleFindings(detector);
60 const listFindings = yield* GuardDuty.ListFindings(detector);
61 const getFindings = yield* GuardDuty.GetFindings(detector);
62 const getFindingsStatistics =
63 yield* GuardDuty.GetFindingsStatistics(detector);
64 const archiveFindings = yield* GuardDuty.ArchiveFindings(detector);
65 const unarchiveFindings = yield* GuardDuty.UnarchiveFindings(detector);
66 const updateFindingsFeedback =
67 yield* GuardDuty.UpdateFindingsFeedback(detector);
68
69 // Detector-scoped reads
70 const listMembers = yield* GuardDuty.ListMembers(detector);
71 const getAdministratorAccount =
72 yield* GuardDuty.GetAdministratorAccount(detector);
73 const getMalwareScanSettings =
74 yield* GuardDuty.GetMalwareScanSettings(detector);
75 const getUsageStatistics = yield* GuardDuty.GetUsageStatistics(detector);
76 const listCoverage = yield* GuardDuty.ListCoverage(detector);
77 const getRemainingFreeTrialDays =
78 yield* GuardDuty.GetRemainingFreeTrialDays(detector);
79 const listInvestigations = yield* GuardDuty.ListInvestigations(detector);
80 const describeOrganizationConfiguration =
81 yield* GuardDuty.DescribeOrganizationConfiguration(detector);
82
83 // Account-level (invitation flow + org administration)
84 const getInvitationsCount = yield* GuardDuty.GetInvitationsCount();
85 const listInvitations = yield* GuardDuty.ListInvitations();
86 const listOrganizationAdminAccounts =
87 yield* GuardDuty.ListOrganizationAdminAccounts();
88 const getOrganizationStatistics =
89 yield* GuardDuty.GetOrganizationStatistics();
90
91 const bound = {
92 createSampleFindings,
93 listFindings,
94 getFindings,
95 getFindingsStatistics,
96 archiveFindings,
97 unarchiveFindings,
98 updateFindingsFeedback,
99 listMembers,
100 getAdministratorAccount,
101 getMalwareScanSettings,
102 getUsageStatistics,
103 listCoverage,
104 getRemainingFreeTrialDays,
105 listInvestigations,
106 describeOrganizationConfiguration,
107 getInvitationsCount,
108 listInvitations,
109 listOrganizationAdminAccounts,
110 getOrganizationStatistics,
111 };
112
113 return {
114 fetch: Effect.gen(function* () {
115 const request = yield* HttpServerRequest;
116 const url = new URL(request.originalUrl);
117 const pathname = url.pathname;
118
119 if (request.method === "GET" && pathname === "/bindings") {
120 return yield* HttpServerResponse.json({
121 bound: Object.keys(bound),
122 });
123 }
124
125 // Generates sample findings on our own detector — the documented
126 // way to exercise a findings pipeline without staging a threat.
127 if (request.method === "POST" && pathname === "/sample") {
128 yield* createSampleFindings({
129 FindingTypes: ["Recon:EC2/PortProbeUnprotectedPort"],
130 });
131 return yield* HttpServerResponse.json({ ok: true });
132 }
133
134 if (request.method === "GET" && pathname === "/findings") {
135 const { FindingIds } = yield* listFindings();
136 return yield* HttpServerResponse.json({
137 count: (FindingIds ?? []).length,
138 ids: (FindingIds ?? []).slice(0, 5),
139 });
140 }
141
142 if (request.method === "GET" && pathname === "/finding-detail") {
143 const id = url.searchParams.get("id");
144 if (!id) {
145 return yield* HttpServerResponse.json(
146 { error: "missing id" },
147 { status: 400 },
148 );
149 }
150 const { Findings } = yield* getFindings({ FindingIds: [id] });
151 return yield* HttpServerResponse.json({
152 count: (Findings ?? []).length,
153 type: Findings?.[0]?.Type,
154 archived: Findings?.[0]?.Service?.Archived ?? false,
155 });
156 }
157
158 if (request.method === "GET" && pathname === "/stats") {
159 const { FindingStatistics } = yield* getFindingsStatistics({
160 FindingStatisticTypes: ["COUNT_BY_SEVERITY"],
161 });
162 return yield* HttpServerResponse.json({
163 severities: Object.keys(FindingStatistics?.CountBySeverity ?? {}),
164 });
165 }
166
167 // Archive whatever findings currently exist (sample findings from
168 // /sample) — a real write against our own detector.
169 if (request.method === "POST" && pathname === "/archive") {
170 const { FindingIds } = yield* listFindings();
171 if ((FindingIds ?? []).length === 0) {
172 return yield* HttpServerResponse.json({ archived: 0 });
173 }
174 const result = yield* errorTagged(
175 archiveFindings({ FindingIds: FindingIds! }).pipe(
176 Effect.map(() => ({ archived: FindingIds!.length })),
177 ),
178 );
179 return yield* HttpServerResponse.json(result);
180 }
181
182 if (request.method === "GET" && pathname === "/members") {
183 const { Members } = yield* listMembers();
184 return yield* HttpServerResponse.json({
185 count: (Members ?? []).length,
186 });
187 }
188
189 // A standalone account has no administrator — GuardDuty answers
190 // with an empty document or a typed BadRequestException.
191 if (request.method === "GET" && pathname === "/admin") {
192 const result = yield* errorTagged(
193 getAdministratorAccount().pipe(
194 Effect.map((r) => ({
195 administrator: r.Administrator?.AccountId ?? null,
196 })),
197 ),
198 );
199 return yield* HttpServerResponse.json(result);
200 }
201
202 if (request.method === "GET" && pathname === "/malware-settings") {
203 const result = yield* errorTagged(
204 getMalwareScanSettings().pipe(
205 Effect.map((r) => ({
206 ebsSnapshotPreservation: r.EbsSnapshotPreservation ?? null,
207 })),
208 ),
209 );
210 return yield* HttpServerResponse.json(result);
211 }
212
213 if (request.method === "GET" && pathname === "/usage") {
214 const { UsageStatistics } = yield* getUsageStatistics({
215 UsageStatisticType: "SUM_BY_DATA_SOURCE",
216 UsageCriteria: { DataSources: ["FLOW_LOGS"] },
217 });
218 return yield* HttpServerResponse.json({
219 dataSources: (UsageStatistics?.SumByDataSource ?? []).length,
220 });
221 }
222
223 if (request.method === "GET" && pathname === "/coverage") {
224 const { Resources } = yield* listCoverage();
225 return yield* HttpServerResponse.json({
226 resources: (Resources ?? []).length,
227 });
228 }
229
230 if (request.method === "GET" && pathname === "/free-trial") {
231 // The smithy model requires AccountIds — the caller passes its
232 // own account id.
233 const account = url.searchParams.get("account");
234 if (!account) {
235 return yield* HttpServerResponse.json(
236 { error: "missing account" },
237 { status: 400 },
238 );
239 }
240 const { Accounts } = yield* getRemainingFreeTrialDays({
241 AccountIds: [account],
242 });
243 return yield* HttpServerResponse.json({
244 accounts: (Accounts ?? []).length,
245 });
246 }
247
248 if (request.method === "GET" && pathname === "/investigations") {
249 const result = yield* errorTagged(
250 listInvestigations().pipe(
251 Effect.map((r) => ({
252 count: (r.Investigations ?? []).length,
253 })),
254 ),
255 );
256 return yield* HttpServerResponse.json(result);
257 }
258
259 // Org administration: this account is a standalone (non-delegated)
260 // account, so GuardDuty answers with a typed error — asserting the
261 // tag proves the binding + IAM wiring end-to-end.
262 if (request.method === "GET" && pathname === "/org-config") {
263 const result = yield* errorTagged(
264 describeOrganizationConfiguration().pipe(
265 Effect.map((r) => ({ autoEnable: r.AutoEnable ?? false })),
266 ),
267 );
268 return yield* HttpServerResponse.json(result);
269 }
270
271 if (request.method === "GET" && pathname === "/org-admins") {
272 const result = yield* errorTagged(
273 listOrganizationAdminAccounts().pipe(
274 Effect.map((r) => ({
275 admins: (r.AdminAccounts ?? []).length,
276 })),
277 ),
278 );
279 return yield* HttpServerResponse.json(result);
280 }
281
282 if (request.method === "GET" && pathname === "/org-stats") {
283 const result = yield* errorTagged(
284 getOrganizationStatistics().pipe(
285 Effect.map((r) => ({
286 activeAccounts:
287 r.OrganizationDetails?.OrganizationStatistics
288 ?.ActiveAccountsCount ?? 0,
289 })),
290 ),
291 );
292 return yield* HttpServerResponse.json(result);
293 }
294
295 // Account-level invitation reads.
296 if (request.method === "GET" && pathname === "/invitations-count") {
297 const { InvitationsCount } = yield* getInvitationsCount();
298 return yield* HttpServerResponse.json({
299 count: InvitationsCount ?? 0,
300 });
301 }
302
303 if (request.method === "GET" && pathname === "/invitations") {
304 const { Invitations } = yield* listInvitations();
305 return yield* HttpServerResponse.json({
306 count: (Invitations ?? []).length,
307 });
308 }
309
310 return yield* HttpServerResponse.json(
311 { error: "Not found", method: request.method, pathname },
312 { status: 404 },
313 );
314 }).pipe(Effect.orDie),
315 };
316 }).pipe(
317 Effect.provide(
318 Layer.mergeAll(
319 GuardDuty.ArchiveFindingsHttp,
320 GuardDuty.CreateSampleFindingsHttp,
321 GuardDuty.DescribeOrganizationConfigurationHttp,
322 GuardDuty.GetAdministratorAccountHttp,
323 GuardDuty.GetFindingsHttp,
324 GuardDuty.GetFindingsStatisticsHttp,
325 GuardDuty.GetInvitationsCountHttp,
326 GuardDuty.GetMalwareScanSettingsHttp,
327 GuardDuty.GetOrganizationStatisticsHttp,
328 GuardDuty.GetRemainingFreeTrialDaysHttp,
329 GuardDuty.GetUsageStatisticsHttp,
330 GuardDuty.ListCoverageHttp,
331 GuardDuty.ListFindingsHttp,
332 GuardDuty.ListInvestigationsHttp,
333 GuardDuty.ListInvitationsHttp,
334 GuardDuty.ListMembersHttp,
335 GuardDuty.ListOrganizationAdminAccountsHttp,
336 GuardDuty.UnarchiveFindingsHttp,
337 GuardDuty.UpdateFindingsFeedbackHttp,
338 ),
339 ),
340 ),
341);