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 threat intelligence list. */
19export type ThreatIntelSetFormat =
20 | "TXT"
21 | "STIX"
22 | "OTX_CSV"
23 | "ALIEN_VAULT"
24 | "PROOF_POINT"
25 | "FIRE_EYE";
26
27export interface ThreatIntelSetProps {
28 /**
29 * ID of the detector the threat intel set belongs to. Changing this
30 * replaces the set.
31 */
32 detectorId: string;
33
34 /**
35 * Display name of the threat intel set. If omitted, a unique name is
36 * generated. Updatable in place.
37 */
38 name?: string;
39
40 /**
41 * Format of the file hosting the threat list. Changing this replaces the
42 * set.
43 */
44 format: ThreatIntelSetFormat;
45
46 /**
47 * S3 URI of the file containing known malicious IP addresses, e.g.
48 * `https://s3.amazonaws.com/my-bucket/threats.txt`. Updatable in place.
49 */
50 location: string;
51
52 /**
53 * Whether GuardDuty actively uses the list. Traffic to/from listed IPs
54 * generates 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 threat intel set. Alchemy ownership tags are merged
67 * in automatically.
68 */
69 tags?: Record<string, string>;
70}
71
72/** @resource */
73export interface ThreatIntelSet extends Resource<
74 "AWS.GuardDuty.ThreatIntelSet",
75 ThreatIntelSetProps,
76 {
77 /** ID of the detector the set belongs to. */
78 detectorId: string;
79 /** The auto-generated threat intel set ID. */
80 threatIntelSetId: string;
81 /** ARN of the threat intel set. */
82 threatIntelSetArn: string;
83 /** Display name of the 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 threat intelligence set — an S3-hosted list of known malicious
98 * IP addresses that GuardDuty generates findings for. The list file must
99 * exist in S3 before activation; name, location, and activation are
100 * updatable in place, while format changes replace the set.
101 *
102 * ### Custom Threat Intelligence
103 * **Example:** Feed a custom threat list
104 * ```typescript
105 * const detector = yield* AWS.GuardDuty.Detector("Detector", {});
106 * const threats = yield* AWS.GuardDuty.ThreatIntelSet("BadIPs", {
107 * detectorId: detector.detectorId,
108 * format: "TXT",
109 * location: "https://s3.amazonaws.com/my-security-bucket/threats.txt",
110 * });
111 * ```
112 */
113const ThreatIntelSetResource = Resource<ThreatIntelSet>(
114 "AWS.GuardDuty.ThreatIntelSet",
115);
116
117export { ThreatIntelSetResource as ThreatIntelSet };
118
119const threatIntelSetArn = (
120 region: string,
121 accountId: string,
122 detectorId: string,
123 threatIntelSetId: string,
124) =>
125 `arn:aws:guardduty:${region}:${accountId}:detector/${detectorId}/threatintelset/${threatIntelSetId}`;
126
127/** Statuses that count as "the set is (becoming) active". */
128const ACTIVE_STATUSES = ["ACTIVE", "ACTIVATING"];
129
130export const ThreatIntelSetProvider = () =>
131 Provider.effect(
132 ThreatIntelSetResource,
133 Effect.gen(function* () {
134 const toName = (id: string, props: { name?: string }) =>
135 props.name
136 ? Effect.succeed(props.name)
137 : createPhysicalName({ id, maxLength: 64 });
138
139 const getSet = (detectorId: string, threatIntelSetId: string) =>
140 guardduty
141 .getThreatIntelSet({
142 DetectorId: detectorId,
143 ThreatIntelSetId: threatIntelSetId,
144 })
145 .pipe(
146 Effect.catchTag("BadRequestException", () =>
147 Effect.succeed(undefined),
148 ),
149 );
150
151 // Recover the set id after state loss by matching the deterministic
152 // name across the detector's threat intel sets.
153 const findByName = Effect.fn(function* (
154 detectorId: string,
155 name: string,
156 ) {
157 const pages = yield* guardduty.listThreatIntelSets
158 .pages({ DetectorId: detectorId })
159 .pipe(Stream.runCollect);
160 for (const threatIntelSetId of Array.from(pages).flatMap(
161 (page) => page.ThreatIntelSetIds ?? [],
162 )) {
163 const s = yield* getSet(detectorId, threatIntelSetId);
164 if (s?.Name === name && s.Status !== "DELETE_PENDING") {
165 return { threatIntelSetId, set: s };
166 }
167 }
168 return undefined;
169 });
170
171 const buildAttrs = Effect.fn(function* (
172 detectorId: string,
173 threatIntelSetId: string,
174 s: guardduty.GetThreatIntelSetResponse,
175 ) {
176 const { accountId, region } = yield* AWSEnvironment.current;
177 return {
178 detectorId,
179 threatIntelSetId,
180 threatIntelSetArn: threatIntelSetArn(
181 region,
182 accountId,
183 detectorId,
184 threatIntelSetId,
185 ),
186 name: s.Name,
187 format: s.Format,
188 location: s.Location,
189 status: s.Status,
190 };
191 });
192
193 return {
194 stables: ["detectorId", "threatIntelSetId", "threatIntelSetArn"],
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 threatIntelSetId = output?.threatIntelSetId;
209 let set = threatIntelSetId
210 ? yield* getSet(detectorId, threatIntelSetId)
211 : undefined;
212 if (!set) {
213 const found = yield* findByName(
214 detectorId,
215 yield* toName(id, olds ?? {}),
216 );
217 if (!found) return undefined;
218 ({ threatIntelSetId, set } = found);
219 }
220 const attrs = yield* buildAttrs(detectorId, threatIntelSetId!, set);
221 return (yield* hasAlchemyTags(id, set.Tags)) ? attrs : Unowned(attrs);
222 }),
223 list: () =>
224 Effect.gen(function* () {
225 const { DetectorIds } = yield* guardduty.listDetectors({});
226 const out: ThreatIntelSet["Attributes"][] = [];
227 for (const detectorId of DetectorIds ?? []) {
228 const pages = yield* guardduty.listThreatIntelSets
229 .pages({ DetectorId: detectorId })
230 .pipe(Stream.runCollect);
231 for (const threatIntelSetId of Array.from(pages).flatMap(
232 (page) => page.ThreatIntelSetIds ?? [],
233 )) {
234 const s = yield* getSet(detectorId, threatIntelSetId);
235 if (s) {
236 out.push(yield* buildAttrs(detectorId, threatIntelSetId, s));
237 }
238 }
239 }
240 return out;
241 }),
242 reconcile: Effect.fn(function* ({ id, news, output, session }) {
243 const detectorId = news.detectorId;
244 const name = yield* toName(id, news);
245 const desiredActivate = news.activate ?? true;
246 const internalTags = yield* createInternalTags(id);
247 const desiredTags = { ...news.tags, ...internalTags };
248
249 // 1. OBSERVE — output.threatIntelSetId is only a cache; fall back
250 // to a name search so a crash-and-retry converges.
251 let threatIntelSetId = output?.threatIntelSetId;
252 let live = threatIntelSetId
253 ? yield* getSet(detectorId, threatIntelSetId)
254 : undefined;
255 if (!live) {
256 const found = yield* findByName(detectorId, name);
257 if (found) ({ threatIntelSetId, set: live } = found);
258 }
259
260 if (!live || !threatIntelSetId) {
261 // 2. ENSURE — create with tags applied inline.
262 const created = yield* guardduty.createThreatIntelSet({
263 DetectorId: detectorId,
264 Name: name,
265 Format: news.format,
266 Location: news.location,
267 Activate: desiredActivate,
268 ExpectedBucketOwner: news.expectedBucketOwner,
269 Tags: desiredTags,
270 });
271 threatIntelSetId = created.ThreatIntelSetId;
272 } else {
273 // 3. SYNC settings — observed ↔ desired.
274 const observedActive = ACTIVE_STATUSES.includes(live.Status);
275 const drift =
276 live.Name !== name ||
277 live.Location !== news.location ||
278 observedActive !== desiredActivate;
279 if (drift) {
280 yield* guardduty.updateThreatIntelSet({
281 DetectorId: detectorId,
282 ThreatIntelSetId: threatIntelSetId,
283 Name: name,
284 Location: news.location,
285 Activate: desiredActivate,
286 ExpectedBucketOwner: news.expectedBucketOwner,
287 });
288 }
289
290 // 3b. SYNC tags — diff against OBSERVED cloud tags.
291 const { accountId, region } = yield* AWSEnvironment.current;
292 const arn = threatIntelSetArn(
293 region,
294 accountId,
295 detectorId,
296 threatIntelSetId,
297 );
298 const { upsert, removed } = diffTags(
299 tagRecord(live.Tags),
300 desiredTags,
301 );
302 if (upsert.length > 0) {
303 yield* guardduty.tagResource({
304 ResourceArn: arn,
305 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
306 });
307 }
308 if (removed.length > 0) {
309 yield* guardduty.untagResource({
310 ResourceArn: arn,
311 TagKeys: removed,
312 });
313 }
314 }
315
316 // 4. RETURN fresh attributes.
317 const final = yield* guardduty.getThreatIntelSet({
318 DetectorId: detectorId,
319 ThreatIntelSetId: threatIntelSetId,
320 });
321 yield* session.note(`${detectorId}/${threatIntelSetId}`);
322 return yield* buildAttrs(detectorId, threatIntelSetId, final);
323 }),
324 delete: Effect.fn(function* ({ output }) {
325 // Idempotent — the set (or its whole detector) may already be
326 // gone; both surface as BadRequestException.
327 yield* guardduty
328 .deleteThreatIntelSet({
329 DetectorId: output.detectorId,
330 ThreatIntelSetId: output.threatIntelSetId,
331 })
332 .pipe(Effect.catchTag("BadRequestException", () => Effect.void));
333 }),
334 };
335 }),
336 );