| 1 | import * as GuardDuty from "@/AWS/GuardDuty"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 7 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 8 | import path from "pathe"; |
| 9 | |
| 10 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 11 | |
| 12 | export 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 | */ |
| 22 | const 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. |
| 42 | export 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 | ); |