1import * as AWS from "@/AWS";
2import { Detector } from "@/AWS/GuardDuty/Detector.ts";
3import { Filter } from "@/AWS/GuardDuty/Filter.ts";
4import { IPSet } from "@/AWS/GuardDuty/IPSet.ts";
5import { ThreatIntelSet } from "@/AWS/GuardDuty/ThreatIntelSet.ts";
6import { Bucket } from "@/AWS/S3/Bucket.ts";
7import * as Test from "@/Test/Alchemy";
8import * as guardduty from "@distilled.cloud/aws/guardduty";
9import * as s3 from "@distilled.cloud/aws/s3";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import { makeGuardDutyTestLease } from "./TestLease.ts";
13
14const { test, beforeAll, afterAll } = Test.make({
15 providers: AWS.providers(),
16});
17const testLease = makeGuardDutyTestLease();
18
19beforeAll(testLease.acquire, { timeout: 240_000 });
20afterAll(testLease.release);
21
22const TRUSTED_KEY = "trusted-ips.txt";
23const THREAT_KEY = "threat-ips.txt";
24
25// One aggregate lifecycle test: the three detector-scoped resources share a
26// detector (an account/region singleton) and the two S3-hosted lists share a
27// bucket, so provisioning them together keeps the suite fast and mirrors how
28// they are deployed in practice.
29test.provider(
30 "lifecycle: filter, trusted IP set, and threat intel set on one detector",
31 (stack) =>
32 Effect.gen(function* () {
33 // The GuardDuty detector is an account/region singleton — never touch
34 // one this test did not create (capture-and-restore safety).
35 const preexisting = (yield* guardduty.listDetectors({})).DetectorIds?.[0];
36 if (preexisting) {
37 yield* Effect.logInfo(
38 `GuardDuty detector ${preexisting} already exists — skipping detector-scoped resource test`,
39 );
40 return;
41 }
42
43 yield* stack.destroy();
44
45 // Phase 1 — the bucket hosting both lists must exist (with objects)
46 // before GuardDuty is pointed at it.
47 const phase1 = yield* stack.deploy(
48 Effect.gen(function* () {
49 const bucket = yield* Bucket("ListsBucket", { forceDestroy: true });
50 return { bucketName: bucket.bucketName };
51 }),
52 );
53 yield* s3.putObject({
54 Bucket: phase1.bucketName,
55 Key: TRUSTED_KEY,
56 Body: "203.0.113.10\n203.0.113.11\n",
57 });
58 yield* s3.putObject({
59 Bucket: phase1.bucketName,
60 Key: THREAT_KEY,
61 Body: "198.51.100.20\n198.51.100.21\n",
62 });
63
64 // Phase 2 — detector + the three detector-scoped resources.
65 const deploy = (filterProps: {
66 action: "NOOP" | "ARCHIVE";
67 rank: number;
68 description: string;
69 }) =>
70 stack.deploy(
71 Effect.gen(function* () {
72 // Keep the bucket deployed across phases; its physical name is
73 // already resolved from phase 1.
74 yield* Bucket("ListsBucket", { forceDestroy: true });
75 const detector = yield* Detector("Detector", {
76 findingPublishingFrequency: "SIX_HOURS",
77 tags: { fixture: "guardduty-detector-resources" },
78 });
79 const filter = yield* Filter("HighSeverity", {
80 detectorId: detector.detectorId,
81 description: filterProps.description,
82 action: filterProps.action,
83 rank: filterProps.rank,
84 findingCriteria: { Criterion: { severity: { Gte: 7 } } },
85 tags: { env: "test" },
86 });
87 const ipSet = yield* IPSet("TrustedIPs", {
88 detectorId: detector.detectorId,
89 format: "TXT",
90 location: `https://s3.amazonaws.com/${phase1.bucketName}/${TRUSTED_KEY}`,
91 activate: true,
92 });
93 const threatSet = yield* ThreatIntelSet("ThreatIPs", {
94 detectorId: detector.detectorId,
95 format: "TXT",
96 location: `https://s3.amazonaws.com/${phase1.bucketName}/${THREAT_KEY}`,
97 activate: false,
98 });
99 return {
100 detectorId: detector.detectorId,
101 filterName: filter.name,
102 filterAction: filter.action,
103 filterRank: filter.rank,
104 ipSetId: ipSet.ipSetId,
105 ipSetStatus: ipSet.status,
106 threatIntelSetId: threatSet.threatIntelSetId,
107 threatIntelSetStatus: threatSet.status,
108 };
109 }),
110 );
111
112 const created = yield* deploy({
113 action: "NOOP",
114 rank: 1,
115 description: "keep high severity findings visible",
116 });
117 expect(created.detectorId).toBeTruthy();
118 expect(created.filterAction).toBe("NOOP");
119 expect(created.filterRank).toBe(1);
120 expect(["ACTIVE", "ACTIVATING"]).toContain(created.ipSetStatus);
121 expect(created.threatIntelSetStatus).toBe("INACTIVE");
122
123 // Out-of-band verification.
124 const liveFilter = yield* guardduty.getFilter({
125 DetectorId: created.detectorId,
126 FilterName: created.filterName,
127 });
128 expect(liveFilter.Action).toBe("NOOP");
129 expect(liveFilter.Tags?.["env"]).toBe("test");
130 expect(liveFilter.Tags?.["alchemy::id"]).toBe("HighSeverity");
131
132 const liveIpSet = yield* guardduty.getIPSet({
133 DetectorId: created.detectorId,
134 IpSetId: created.ipSetId,
135 });
136 expect(liveIpSet.Format).toBe("TXT");
137 expect(liveIpSet.Location).toContain(TRUSTED_KEY);
138
139 const liveThreatSet = yield* guardduty.getThreatIntelSet({
140 DetectorId: created.detectorId,
141 ThreatIntelSetId: created.threatIntelSetId,
142 });
143 expect(liveThreatSet.Format).toBe("TXT");
144 expect(liveThreatSet.Location).toContain(THREAT_KEY);
145
146 // Update — flip the filter to auto-archive in place (no replacement).
147 // Rank stays 1: GuardDuty bounds rank by the number of filters on the
148 // detector, so a lone filter can only ever hold rank 1.
149 const updated = yield* deploy({
150 action: "ARCHIVE",
151 rank: 1,
152 description: "auto-archive high severity findings",
153 });
154 expect(updated.detectorId).toBe(created.detectorId);
155 expect(updated.filterName).toBe(created.filterName);
156 expect(updated.ipSetId).toBe(created.ipSetId);
157 expect(updated.threatIntelSetId).toBe(created.threatIntelSetId);
158
159 const afterUpdate = yield* guardduty.getFilter({
160 DetectorId: created.detectorId,
161 FilterName: created.filterName,
162 });
163 expect(afterUpdate.Action).toBe("ARCHIVE");
164 expect(afterUpdate.Rank).toBe(1);
165 expect(afterUpdate.Description).toBe(
166 "auto-archive high severity findings",
167 );
168
169 // Destroy — everything (including the detector singleton) is gone.
170 yield* stack.destroy();
171 const after = yield* guardduty.listDetectors({});
172 expect(after.DetectorIds ?? []).toHaveLength(0);
173 }),
174 {
175 tags: ["provider:aws", "provider:aws:guardduty", "provider:aws:s3", "live"],
176 timeout: 240_000,
177 },
178);