1import * as securityhub from "@distilled.cloud/aws/securityhub";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import {
8 createInternalTags,
9 diffTags,
10 hasAlchemyTags,
11 tagRecord,
12} from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14
15/** Whether the automation rule is applied to new and updated findings. */
16export type RuleStatus = "ENABLED" | "DISABLED";
17
18export interface AutomationRuleProps {
19 /**
20 * Name of the rule. If omitted, a unique name is generated. Updatable in
21 * place.
22 */
23 ruleName?: string;
24
25 /**
26 * Description of the rule. Updatable in place.
27 */
28 description: string;
29
30 /**
31 * Order in which rules are applied (1 first; lower wins on conflicts).
32 * Updatable in place.
33 */
34 ruleOrder: number;
35
36 /**
37 * Whether the rule is applied to findings: `ENABLED` or `DISABLED`.
38 * Updatable in place.
39 * @default "ENABLED"
40 */
41 ruleStatus?: RuleStatus;
42
43 /**
44 * Whether matching findings stop evaluating further rules. Updatable in
45 * place.
46 * @default false
47 */
48 isTerminal?: boolean;
49
50 /**
51 * The criteria findings are matched against, e.g.
52 * `{ SeverityLabel: [{ Value: "INFORMATIONAL", Comparison: "EQUALS" }] }`.
53 * Updatable in place.
54 */
55 criteria: securityhub.AutomationRulesFindingFilters;
56
57 /**
58 * The updates applied to matching findings, e.g.
59 * `[{ Type: "FINDING_FIELDS_UPDATE", FindingFieldsUpdate: { Workflow: { Status: "SUPPRESSED" } } }]`.
60 * Updatable in place.
61 */
62 actions: securityhub.AutomationRulesAction[];
63
64 /**
65 * Tags applied to the rule. Alchemy ownership tags are merged in
66 * automatically.
67 */
68 tags?: Record<string, string>;
69}
70
71/** @resource */
72export interface AutomationRule extends Resource<
73 "AWS.SecurityHub.AutomationRule",
74 AutomationRuleProps,
75 {
76 /** ARN of the automation rule (its identity). */
77 ruleArn: string;
78 /** Name of the rule. */
79 ruleName: string;
80 /** Evaluation order of the rule. */
81 ruleOrder: number | undefined;
82 /** Whether the rule is applied to findings. */
83 ruleStatus: string | undefined;
84 /** Whether matching findings stop evaluating further rules. */
85 isTerminal: boolean | undefined;
86 },
87 never,
88 Providers
89> {}
90
91/**
92 * A Security Hub automation rule — automatically updates findings that match
93 * its criteria (suppress, change severity, add notes) as they are ingested.
94 *
95 * ### Automating Finding Triage
96 * **Example:** Suppress Informational Findings
97 * ```typescript
98 * const rule = yield* AWS.SecurityHub.AutomationRule("SuppressInfo", {
99 * description: "Suppress informational findings",
100 * ruleOrder: 1,
101 * criteria: {
102 * SeverityLabel: [{ Value: "INFORMATIONAL", Comparison: "EQUALS" }],
103 * },
104 * actions: [{
105 * Type: "FINDING_FIELDS_UPDATE",
106 * FindingFieldsUpdate: { Workflow: { Status: "SUPPRESSED" } },
107 * }],
108 * });
109 * ```
110 *
111 * **Example:** Escalate Production Findings
112 * ```typescript
113 * const rule = yield* AWS.SecurityHub.AutomationRule("EscalateProd", {
114 * description: "Raise severity of findings on production resources",
115 * ruleOrder: 2,
116 * isTerminal: true,
117 * criteria: {
118 * ResourceTags: [{ Key: "env", Value: "prod", Comparison: "EQUALS" }],
119 * },
120 * actions: [{
121 * Type: "FINDING_FIELDS_UPDATE",
122 * FindingFieldsUpdate: { Severity: { Label: "CRITICAL" } },
123 * }],
124 * });
125 * ```
126 */
127const AutomationRuleResource = Resource<AutomationRule>(
128 "AWS.SecurityHub.AutomationRule",
129);
130
131export { AutomationRuleResource as AutomationRule };
132
133export const AutomationRuleProvider = () =>
134 Provider.effect(
135 AutomationRuleResource,
136 Effect.gen(function* () {
137 const toName = (id: string, props: { ruleName?: string }) =>
138 props.ruleName
139 ? Effect.succeed(props.ruleName)
140 : createPhysicalName({ id, maxLength: 128 });
141
142 const getRule = (arn: string) =>
143 securityhub
144 .batchGetAutomationRules({ AutomationRulesArns: [arn] })
145 .pipe(
146 Effect.map((r) => r.Rules?.[0]),
147 Effect.catchTag("ResourceNotFoundException", () =>
148 Effect.succeed(undefined),
149 ),
150 Effect.catchTag("InvalidAccessException", () =>
151 Effect.succeed(undefined),
152 ),
153 );
154
155 // `ListAutomationRules` is not modeled as paginated in the Smithy spec
156 // (no `.pages` helper) — page manually, bounded (accounts are limited
157 // to 100 rules).
158 const listRules = Effect.gen(function* () {
159 const out: securityhub.AutomationRulesMetadata[] = [];
160 let nextToken: string | undefined;
161 for (let i = 0; i < 10; i++) {
162 const page = yield* securityhub.listAutomationRules({
163 MaxResults: 100,
164 NextToken: nextToken,
165 });
166 out.push(...(page.AutomationRulesMetadata ?? []));
167 nextToken = page.NextToken;
168 if (!nextToken) break;
169 }
170 return out;
171 }).pipe(
172 Effect.catchTag("InvalidAccessException", () =>
173 Effect.succeed([] as securityhub.AutomationRulesMetadata[]),
174 ),
175 );
176
177 const readTags = (arn: string) =>
178 securityhub.listTagsForResource({ ResourceArn: arn }).pipe(
179 Effect.map((r) => tagRecord(r.Tags)),
180 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
181 );
182
183 const buildAttrs = (r: {
184 RuleArn?: string;
185 RuleName?: string;
186 RuleOrder?: number;
187 RuleStatus?: string;
188 IsTerminal?: boolean;
189 }) => ({
190 ruleArn: r.RuleArn!,
191 ruleName: r.RuleName ?? "",
192 ruleOrder: r.RuleOrder,
193 ruleStatus: r.RuleStatus,
194 isTerminal: r.IsTerminal,
195 });
196
197 return {
198 stables: ["ruleArn"],
199 read: Effect.fn(function* ({ id, olds, output }) {
200 let arn = output?.ruleArn;
201 if (!arn) {
202 // No prior state — find by (deterministic) name.
203 const name = yield* toName(id, olds ?? {});
204 const all = yield* listRules;
205 arn = all.find((r) => r.RuleName === name)?.RuleArn;
206 }
207 if (!arn) return undefined;
208 const live = yield* getRule(arn);
209 if (!live) return undefined;
210 const attrs = buildAttrs(live);
211 const tags = yield* readTags(arn);
212 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
213 }),
214 list: () =>
215 listRules.pipe(
216 Effect.map((all) =>
217 all.filter((r) => r.RuleArn).map((r) => buildAttrs(r)),
218 ),
219 ),
220 reconcile: Effect.fn(function* ({ id, news, output, session }) {
221 const name = yield* toName(id, news);
222 const internalTags = yield* createInternalTags(id);
223 const desiredTags = { ...news.tags, ...internalTags };
224
225 // 1. OBSERVE — by cached ARN first, then by name.
226 let arn = output?.ruleArn;
227 let live = arn ? yield* getRule(arn) : undefined;
228 if (!live) {
229 const all = yield* listRules;
230 arn = all.find((r) => r.RuleName === name)?.RuleArn;
231 live = arn ? yield* getRule(arn) : undefined;
232 }
233
234 if (!live) {
235 // 2. ENSURE — create with tags applied inline.
236 const { RuleArn } = yield* securityhub.createAutomationRule({
237 RuleName: name,
238 Description: news.description,
239 RuleOrder: news.ruleOrder,
240 RuleStatus: news.ruleStatus ?? "ENABLED",
241 IsTerminal: news.isTerminal ?? false,
242 Criteria: news.criteria,
243 Actions: news.actions,
244 Tags: desiredTags,
245 });
246 arn = RuleArn!;
247 } else {
248 arn = live.RuleArn!;
249 // 3. SYNC settings — observed ↔ desired.
250 const desiredStatus = news.ruleStatus ?? "ENABLED";
251 const desiredTerminal = news.isTerminal ?? false;
252 const drift =
253 live.RuleName !== name ||
254 live.Description !== news.description ||
255 live.RuleOrder !== news.ruleOrder ||
256 live.RuleStatus !== desiredStatus ||
257 (live.IsTerminal ?? false) !== desiredTerminal ||
258 JSON.stringify(live.Criteria) !== JSON.stringify(news.criteria) ||
259 JSON.stringify(live.Actions) !== JSON.stringify(news.actions);
260 if (drift) {
261 yield* securityhub.batchUpdateAutomationRules({
262 UpdateAutomationRulesRequestItems: [
263 {
264 RuleArn: arn,
265 RuleName: name,
266 Description: news.description,
267 RuleOrder: news.ruleOrder,
268 RuleStatus: desiredStatus,
269 IsTerminal: desiredTerminal,
270 Criteria: news.criteria,
271 Actions: news.actions,
272 },
273 ],
274 });
275 }
276
277 // 3b. SYNC tags — diff against OBSERVED cloud tags.
278 const currentTags = yield* readTags(arn);
279 const { upsert, removed } = diffTags(currentTags, desiredTags);
280 if (upsert.length > 0) {
281 yield* securityhub.tagResource({
282 ResourceArn: arn,
283 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
284 });
285 }
286 if (removed.length > 0) {
287 yield* securityhub.untagResource({
288 ResourceArn: arn,
289 TagKeys: removed,
290 });
291 }
292 }
293
294 // 4. RETURN fresh attributes.
295 const final = yield* getRule(arn);
296 yield* session.note(arn);
297 return buildAttrs(final ?? { RuleArn: arn, RuleName: name });
298 }),
299 delete: Effect.fn(function* ({ output }) {
300 // Idempotent — an unknown ARN comes back in
301 // UnprocessedAutomationRules rather than as an error.
302 yield* securityhub
303 .batchDeleteAutomationRules({
304 AutomationRulesArns: [output.ruleArn],
305 })
306 .pipe(
307 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
308 Effect.catchTag("InvalidAccessException", () => Effect.void),
309 );
310 }),
311 };
312 }),
313 );