1import * as guardduty from "@distilled.cloud/aws/guardduty";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import {
7 createInternalTags,
8 diffTags,
9 hasAlchemyTags,
10 tagRecord,
11} from "../../Tags.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * How frequently GuardDuty exports updated findings.
17 */
18export type FindingPublishingFrequency =
19 | "FIFTEEN_MINUTES"
20 | "ONE_HOUR"
21 | "SIX_HOURS";
22
23export interface DetectorProps {
24 /**
25 * Whether the detector is enabled. A disabled detector stops analyzing data
26 * sources but is not deleted.
27 * @default true
28 */
29 enable?: boolean;
30
31 /**
32 * How frequently GuardDuty publishes updated findings to CloudWatch Events.
33 * @default "SIX_HOURS"
34 */
35 findingPublishingFrequency?: FindingPublishingFrequency;
36
37 /**
38 * Tags applied to the detector. Alchemy ownership tags are merged in
39 * automatically so the detector can be recognized on subsequent runs.
40 */
41 tags?: Record<string, string>;
42}
43
44/** @resource */
45export interface Detector extends Resource<
46 "AWS.GuardDuty.Detector",
47 DetectorProps,
48 {
49 /** The auto-generated detector ID (unique per account/region). */
50 detectorId: string;
51 /** ARN of the detector — the IAM resource for detector-scoped actions. */
52 detectorArn: string;
53 /** Current status of the detector (`ENABLED` / `DISABLED`). */
54 status: string | undefined;
55 /** The effective finding-publishing frequency. */
56 findingPublishingFrequency: string | undefined;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * A GuardDuty detector — the account/region singleton that enables Amazon
64 * GuardDuty threat detection. Only one detector can exist per region, so this
65 * resource is a capture-and-restore singleton: adopting a pre-existing detector
66 * that Alchemy did not create requires `--adopt`.
67 *
68 * ### Enabling GuardDuty
69 * **Example:** Enable with default settings
70 * ```typescript
71 * const detector = yield* GuardDuty.Detector("Detector", {});
72 * ```
73 *
74 * **Example:** Frequent finding publishing
75 * ```typescript
76 * const detector = yield* GuardDuty.Detector("Detector", {
77 * enable: true,
78 * findingPublishingFrequency: "FIFTEEN_MINUTES",
79 * tags: { team: "security" },
80 * });
81 * ```
82 */
83const DetectorResource = Resource<Detector>("AWS.GuardDuty.Detector");
84
85export { DetectorResource as Detector };
86
87export const detectorArn = (
88 region: string,
89 accountId: string,
90 detectorId: string,
91) => `arn:aws:guardduty:${region}:${accountId}:detector/${detectorId}`;
92
93const buildAttrs = Effect.fn(function* (
94 detectorId: string,
95 d: guardduty.GetDetectorResponse,
96) {
97 const { accountId, region } = yield* AWSEnvironment.current;
98 return {
99 detectorId,
100 detectorArn: detectorArn(region, accountId, detectorId),
101 status: d.Status,
102 findingPublishingFrequency: d.FindingPublishingFrequency,
103 };
104});
105
106export const DetectorProvider = () =>
107 Provider.effect(
108 DetectorResource,
109 Effect.gen(function* () {
110 // Resolve the first (only) detector in the current region, if any.
111 const firstDetectorId = guardduty
112 .listDetectors({})
113 .pipe(Effect.map((r) => r.DetectorIds?.[0]));
114
115 const getDetector = (detectorId: string) =>
116 guardduty
117 .getDetector({ DetectorId: detectorId })
118 .pipe(
119 Effect.catchTag("BadRequestException", () =>
120 Effect.succeed(undefined),
121 ),
122 );
123
124 return {
125 read: Effect.fn(function* ({ id, output }) {
126 const detectorId = output?.detectorId ?? (yield* firstDetectorId);
127 if (!detectorId) return undefined;
128 const d = yield* getDetector(detectorId);
129 if (!d) return undefined;
130 const attrs = yield* buildAttrs(detectorId, d);
131 return (yield* hasAlchemyTags(id, d.Tags)) ? attrs : Unowned(attrs);
132 }),
133
134 // GuardDuty allows at most one detector per account/region. Enumerate
135 // every detector and hydrate its full attributes.
136 list: () =>
137 Effect.gen(function* () {
138 const { DetectorIds } = yield* guardduty.listDetectors({});
139 const out: {
140 detectorId: string;
141 detectorArn: string;
142 status: string | undefined;
143 findingPublishingFrequency: string | undefined;
144 }[] = [];
145 for (const detectorId of DetectorIds ?? []) {
146 const d = yield* getDetector(detectorId);
147 if (d) out.push(yield* buildAttrs(detectorId, d));
148 }
149 return out;
150 }),
151
152 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
153 const { accountId, region } = yield* AWSEnvironment.current;
154 const internalTags = yield* createInternalTags(id);
155 const desiredTags = { ...news.tags, ...internalTags };
156 const desiredEnable = news.enable ?? true;
157
158 // 1. OBSERVE — cloud state is authoritative; output is only an id cache.
159 let detectorId = output?.detectorId ?? (yield* firstDetectorId);
160 let live = detectorId ? yield* getDetector(detectorId) : undefined;
161
162 if (!detectorId || !live) {
163 // 2. ENSURE — create the detector with tags applied inline.
164 const created = yield* guardduty.createDetector({
165 Enable: desiredEnable,
166 FindingPublishingFrequency: news.findingPublishingFrequency,
167 Tags: desiredTags,
168 });
169 detectorId = created.DetectorId!;
170 } else {
171 // 3. SYNC settings — observed ↔ desired.
172 const observedEnabled = live.Status === "ENABLED";
173 const settingsChanged =
174 observedEnabled !== desiredEnable ||
175 (news.findingPublishingFrequency !== undefined &&
176 news.findingPublishingFrequency !==
177 live.FindingPublishingFrequency);
178 if (settingsChanged) {
179 yield* guardduty.updateDetector({
180 DetectorId: detectorId,
181 Enable: desiredEnable,
182 FindingPublishingFrequency: news.findingPublishingFrequency,
183 });
184 }
185
186 // 3b. SYNC tags — diff against OBSERVED cloud tags.
187 const { upsert, removed } = diffTags(
188 tagRecord(live.Tags),
189 desiredTags,
190 );
191 const arn = detectorArn(region, accountId, detectorId);
192 if (upsert.length > 0) {
193 yield* guardduty.tagResource({
194 ResourceArn: arn,
195 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
196 });
197 }
198 if (removed.length > 0) {
199 yield* guardduty.untagResource({
200 ResourceArn: arn,
201 TagKeys: removed,
202 });
203 }
204 }
205
206 // 4. RETURN fresh attributes.
207 const final = yield* guardduty.getDetector({
208 DetectorId: detectorId,
209 });
210 yield* session.note(detectorId);
211 return yield* buildAttrs(detectorId, final);
212 }),
213
214 delete: Effect.fn(function* ({ output }) {
215 yield* guardduty
216 .deleteDetector({ DetectorId: output.detectorId })
217 .pipe(Effect.catchTag("BadRequestException", () => Effect.void));
218 }),
219 };
220 }),
221 );