1import * as guardduty from "@distilled.cloud/aws/guardduty";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import { AWSEnvironment } from "../Environment.ts";
16import type { Providers } from "../Providers.ts";
17
18/** File format of the hosted IP list. */
19export type IpSetFormat =
20 | "TXT"
21 | "STIX"
22 | "OTX_CSV"
23 | "ALIEN_VAULT"
24 | "PROOF_POINT"
25 | "FIRE_EYE";
26
27export interface IPSetProps {
28 /**
29 * ID of the detector the trusted IP set belongs to. Changing this replaces
30 * the IP set.
31 */
32 detectorId: string;
33
34 /**
35 * Display name of the IP set. If omitted, a unique name is generated.
36 * Updatable in place.
37 */
38 name?: string;
39
40 /**
41 * Format of the file hosting the IP list. Changing this replaces the IP
42 * set.
43 */
44 format: IpSetFormat;
45
46 /**
47 * S3 URI of the file containing the trusted IP list, e.g.
48 * `https://s3.amazonaws.com/my-bucket/trusted.txt`. Updatable in place.
49 */
50 location: string;
51
52 /**
53 * Whether GuardDuty actively uses the IP set. Traffic from trusted IPs
54 * does not generate findings while active. Updatable in place.
55 * @default true
56 */
57 activate?: boolean;
58
59 /**
60 * Account ID of the bucket owner hosting the list (guards against bucket
61 * squatting).
62 */
63 expectedBucketOwner?: string;
64
65 /**
66 * Tags applied to the IP set. Alchemy ownership tags are merged in
67 * automatically.
68 */
69 tags?: Record<string, string>;
70}
71
72/** @resource */
73export interface IPSet extends Resource<
74 "AWS.GuardDuty.IPSet",
75 IPSetProps,
76 {
77 /** ID of the detector the IP set belongs to. */
78 detectorId: string;
79 /** The auto-generated IP set ID. */
80 ipSetId: string;
81 /** ARN of the IP set. */
82 ipSetArn: string;
83 /** Display name of the IP set. */
84 name: string;
85 /** File format of the hosted list. */
86 format: string;
87 /** S3 URI of the hosted list. */
88 location: string;
89 /** Current status (`ACTIVE`, `INACTIVE`, `ACTIVATING`, `ERROR`, …). */
90 status: string;
91 },
92 never,
93 Providers
94> {}
95
96/**
97 * A GuardDuty trusted IP set — an S3-hosted list of IP addresses GuardDuty
98 * treats as trusted, suppressing findings for traffic from them. The list
99 * file must exist in S3 before activation; name, location, and activation
100 * are updatable in place, while format changes replace the set.
101 *
102 * ### Trusting Known IPs
103 * **Example:** Trust the office IP range
104 * ```typescript
105 * const detector = yield* AWS.GuardDuty.Detector("Detector", {});
106 * const ipSet = yield* AWS.GuardDuty.IPSet("OfficeIPs", {
107 * detectorId: detector.detectorId,
108 * format: "TXT",
109 * location: "https://s3.amazonaws.com/my-security-bucket/office-ips.txt",
110 * });
111 * ```
112 *
113 * **Example:** Stage a list without activating it
114 * ```typescript
115 * const ipSet = yield* AWS.GuardDuty.IPSet("StagedIPs", {
116 * detectorId: detector.detectorId,
117 * format: "TXT",
118 * location: "https://s3.amazonaws.com/my-security-bucket/staged.txt",
119 * activate: false,
120 * });
121 * ```
122 */
123const IPSetResource = Resource<IPSet>("AWS.GuardDuty.IPSet");
124
125export { IPSetResource as IPSet };
126
127const ipSetArn = (
128 region: string,
129 accountId: string,
130 detectorId: string,
131 ipSetId: string,
132) =>
133 `arn:aws:guardduty:${region}:${accountId}:detector/${detectorId}/ipset/${ipSetId}`;
134
135/** Statuses that count as "the set is (becoming) active". */
136const ACTIVE_STATUSES = ["ACTIVE", "ACTIVATING"];
137
138export const IPSetProvider = () =>
139 Provider.effect(
140 IPSetResource,
141 Effect.gen(function* () {
142 const toName = (id: string, props: { name?: string }) =>
143 props.name
144 ? Effect.succeed(props.name)
145 : createPhysicalName({ id, maxLength: 64 });
146
147 const getIPSet = (detectorId: string, ipSetId: string) =>
148 guardduty
149 .getIPSet({ DetectorId: detectorId, IpSetId: ipSetId })
150 .pipe(
151 Effect.catchTag("BadRequestException", () =>
152 Effect.succeed(undefined),
153 ),
154 );
155
156 // Recover the set id after state loss by matching the deterministic
157 // name across the detector's IP sets.
158 const findByName = Effect.fn(function* (
159 detectorId: string,
160 name: string,
161 ) {
162 const pages = yield* guardduty.listIPSets
163 .pages({ DetectorId: detectorId })
164 .pipe(Stream.runCollect);
165 for (const ipSetId of Array.from(pages).flatMap(
166 (page) => page.IpSetIds ?? [],
167 )) {
168 const s = yield* getIPSet(detectorId, ipSetId);
169 if (s?.Name === name && s.Status !== "DELETE_PENDING") {
170 return { ipSetId, set: s };
171 }
172 }
173 return undefined;
174 });
175
176 const buildAttrs = Effect.fn(function* (
177 detectorId: string,
178 ipSetId: string,
179 s: guardduty.GetIPSetResponse,
180 ) {
181 const { accountId, region } = yield* AWSEnvironment.current;
182 return {
183 detectorId,
184 ipSetId,
185 ipSetArn: ipSetArn(region, accountId, detectorId, ipSetId),
186 name: s.Name,
187 format: s.Format,
188 location: s.Location,
189 status: s.Status,
190 };
191 });
192
193 return {
194 stables: ["detectorId", "ipSetId", "ipSetArn"],
195 diff: Effect.fn(function* ({ olds, news }) {
196 if (!isResolved(news)) return;
197 if (olds === undefined) return;
198 if (
199 olds.detectorId !== news.detectorId ||
200 olds.format !== news.format
201 ) {
202 return { action: "replace" } as const;
203 }
204 }),
205 read: Effect.fn(function* ({ id, olds, output }) {
206 const detectorId = output?.detectorId ?? olds?.detectorId;
207 if (!detectorId) return undefined;
208 let ipSetId = output?.ipSetId;
209 let set = ipSetId ? yield* getIPSet(detectorId, ipSetId) : undefined;
210 if (!set) {
211 const found = yield* findByName(
212 detectorId,
213 yield* toName(id, olds ?? {}),
214 );
215 if (!found) return undefined;
216 ({ ipSetId, set } = found);
217 }
218 const attrs = yield* buildAttrs(detectorId, ipSetId!, set);
219 return (yield* hasAlchemyTags(id, set.Tags)) ? attrs : Unowned(attrs);
220 }),
221 list: () =>
222 Effect.gen(function* () {
223 const { DetectorIds } = yield* guardduty.listDetectors({});
224 const out: IPSet["Attributes"][] = [];
225 for (const detectorId of DetectorIds ?? []) {
226 const pages = yield* guardduty.listIPSets
227 .pages({ DetectorId: detectorId })
228 .pipe(Stream.runCollect);
229 for (const ipSetId of Array.from(pages).flatMap(
230 (page) => page.IpSetIds ?? [],
231 )) {
232 const s = yield* getIPSet(detectorId, ipSetId);
233 if (s) out.push(yield* buildAttrs(detectorId, ipSetId, s));
234 }
235 }
236 return out;
237 }),
238 reconcile: Effect.fn(function* ({ id, news, output, session }) {
239 const detectorId = news.detectorId;
240 const name = yield* toName(id, news);
241 const desiredActivate = news.activate ?? true;
242 const internalTags = yield* createInternalTags(id);
243 const desiredTags = { ...news.tags, ...internalTags };
244
245 // 1. OBSERVE — output.ipSetId is only a cache; fall back to a
246 // name search so a crash-and-retry converges.
247 let ipSetId = output?.ipSetId;
248 let live = ipSetId ? yield* getIPSet(detectorId, ipSetId) : undefined;
249 if (!live) {
250 const found = yield* findByName(detectorId, name);
251 if (found) ({ ipSetId, set: live } = found);
252 }
253
254 if (!live || !ipSetId) {
255 // 2. ENSURE — create with tags applied inline.
256 const created = yield* guardduty.createIPSet({
257 DetectorId: detectorId,
258 Name: name,
259 Format: news.format,
260 Location: news.location,
261 Activate: desiredActivate,
262 ExpectedBucketOwner: news.expectedBucketOwner,
263 Tags: desiredTags,
264 });
265 ipSetId = created.IpSetId;
266 } else {
267 // 3. SYNC settings — observed ↔ desired.
268 const observedActive = ACTIVE_STATUSES.includes(live.Status);
269 const drift =
270 live.Name !== name ||
271 live.Location !== news.location ||
272 observedActive !== desiredActivate;
273 if (drift) {
274 yield* guardduty.updateIPSet({
275 DetectorId: detectorId,
276 IpSetId: ipSetId,
277 Name: name,
278 Location: news.location,
279 Activate: desiredActivate,
280 ExpectedBucketOwner: news.expectedBucketOwner,
281 });
282 }
283
284 // 3b. SYNC tags — diff against OBSERVED cloud tags.
285 const { accountId, region } = yield* AWSEnvironment.current;
286 const arn = ipSetArn(region, accountId, detectorId, ipSetId);
287 const { upsert, removed } = diffTags(
288 tagRecord(live.Tags),
289 desiredTags,
290 );
291 if (upsert.length > 0) {
292 yield* guardduty.tagResource({
293 ResourceArn: arn,
294 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
295 });
296 }
297 if (removed.length > 0) {
298 yield* guardduty.untagResource({
299 ResourceArn: arn,
300 TagKeys: removed,
301 });
302 }
303 }
304
305 // 4. RETURN fresh attributes.
306 const final = yield* guardduty.getIPSet({
307 DetectorId: detectorId,
308 IpSetId: ipSetId,
309 });
310 yield* session.note(`${detectorId}/${ipSetId}`);
311 return yield* buildAttrs(detectorId, ipSetId, final);
312 }),
313 delete: Effect.fn(function* ({ output }) {
314 // Idempotent — the set (or its whole detector) may already be
315 // gone; both surface as BadRequestException.
316 yield* guardduty
317 .deleteIPSet({
318 DetectorId: output.detectorId,
319 IpSetId: output.ipSetId,
320 })
321 .pipe(Effect.catchTag("BadRequestException", () => Effect.void));
322 }),
323 };
324 }),
325 );