1import * as securityhub from "@distilled.cloud/aws/securityhub";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { AWSEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface ActionTargetProps {
12 /**
13 * Display name of the custom action (shown in the Security Hub console's
14 * action menu, max 20 characters). Updatable in place.
15 */
16 name: string;
17
18 /**
19 * Description of the custom action. Updatable in place.
20 */
21 description: string;
22
23 /**
24 * ID of the custom action (alphanumeric, max 20 characters). It becomes the
25 * final segment of the action target ARN. If omitted, a unique id is
26 * generated. Changing this replaces the action target.
27 */
28 id?: string;
29}
30
31/** @resource */
32export interface ActionTarget extends Resource<
33 "AWS.SecurityHub.ActionTarget",
34 ActionTargetProps,
35 {
36 /** ARN of the action target (`…:action/custom/{id}`). */
37 actionTargetArn: string;
38 /** ID of the custom action (final ARN segment). */
39 id: string;
40 /** Display name of the custom action. */
41 name: string;
42 /** Description of the custom action. */
43 description: string;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * A Security Hub custom action target. Selecting the custom action on
51 * findings or insights in the console publishes a
52 * `Security Hub Findings - Custom Action` event to EventBridge, which a
53 * Function can consume via {@link consumeCustomActions}.
54 *
55 * ### Creating a Custom Action
56 * **Example:** Send Findings to a Triage Function
57 * ```typescript
58 * const action = yield* AWS.SecurityHub.ActionTarget("Escalate", {
59 * name: "Escalate",
60 * description: "Escalate the selected findings to on-call",
61 * });
62 * ```
63 *
64 * **Example:** Consume Custom Action Events
65 * ```typescript
66 * yield* AWS.SecurityHub.consumeCustomActions(
67 * { actionArns: [action.actionTargetArn] },
68 * (events) =>
69 * Stream.runForEach(events, (event) =>
70 * Effect.log(event.detail.findings),
71 * ),
72 * );
73 * ```
74 */
75const ActionTargetResource = Resource<ActionTarget>(
76 "AWS.SecurityHub.ActionTarget",
77);
78
79export { ActionTargetResource as ActionTarget };
80
81const actionTargetArn = (region: string, accountId: string, id: string) =>
82 `arn:aws:securityhub:${region}:${accountId}:action/custom/${id}`;
83
84export const ActionTargetProvider = () =>
85 Provider.effect(
86 ActionTargetResource,
87 Effect.gen(function* () {
88 // Custom action ids are strictly alphanumeric, max 20 characters —
89 // strip the physical name's delimiters and keep its trailing (random,
90 // instance-stable) suffix for uniqueness.
91 const toId = (id: string, props: { id?: string }) =>
92 props.id
93 ? Effect.succeed(props.id)
94 : createPhysicalName({ id, maxLength: 64 }).pipe(
95 Effect.map((name) =>
96 name.replace(/[^a-zA-Z0-9]/g, "").slice(-20),
97 ),
98 );
99
100 const getActionTarget = (arn: string) =>
101 securityhub.describeActionTargets({ ActionTargetArns: [arn] }).pipe(
102 Effect.map((r) => r.ActionTargets?.[0]),
103 Effect.catchTag("ResourceNotFoundException", () =>
104 Effect.succeed(undefined),
105 ),
106 // The whole hub may be disabled — the action target is gone too.
107 Effect.catchTag("InvalidAccessException", () =>
108 Effect.succeed(undefined),
109 ),
110 );
111
112 const buildAttrs = (arn: string, t: securityhub.ActionTarget) => ({
113 actionTargetArn: arn,
114 id: arn.split("/").at(-1)!,
115 name: t.Name ?? "",
116 description: t.Description ?? "",
117 });
118
119 return {
120 stables: ["actionTargetArn", "id"],
121 diff: Effect.fn(function* ({ id, olds, news }) {
122 if (!isResolved(news)) return;
123 if (olds === undefined) return;
124 if ((yield* toId(id, olds)) !== (yield* toId(id, news))) {
125 return { action: "replace" } as const;
126 }
127 }),
128 read: Effect.fn(function* ({ id, olds, output }) {
129 const actionId = output?.id ?? (yield* toId(id, olds ?? {}));
130 const { accountId, region } = yield* AWSEnvironment.current;
131 const arn = actionTargetArn(region, accountId, actionId);
132 const live = yield* getActionTarget(arn);
133 if (!live) return undefined;
134 // Action targets cannot be tagged; the id embeds the app/stage
135 // physical-name hash, so a match is ours.
136 return buildAttrs(arn, live);
137 }),
138 list: () =>
139 Effect.gen(function* () {
140 const pages = yield* securityhub.describeActionTargets
141 .pages({})
142 .pipe(Stream.runCollect);
143 const out: ActionTarget["Attributes"][] = [];
144 for (const page of pages) {
145 for (const t of page.ActionTargets ?? []) {
146 if (t.ActionTargetArn) {
147 out.push(buildAttrs(t.ActionTargetArn, t));
148 }
149 }
150 }
151 return out;
152 }).pipe(
153 // No hub — no action targets.
154 Effect.catchTag("InvalidAccessException", () =>
155 Effect.succeed([] as ActionTarget["Attributes"][]),
156 ),
157 ),
158 reconcile: Effect.fn(function* ({ id, news, output, session }) {
159 const actionId = output?.id ?? (yield* toId(id, news));
160 const { accountId, region } = yield* AWSEnvironment.current;
161 const arn = actionTargetArn(region, accountId, actionId);
162
163 // 1. OBSERVE — cloud state is authoritative.
164 const live = yield* getActionTarget(arn);
165
166 if (!live) {
167 // 2. ENSURE — tolerate the create race.
168 yield* securityhub
169 .createActionTarget({
170 Name: news.name,
171 Description: news.description,
172 Id: actionId,
173 })
174 .pipe(
175 Effect.catchTag("ResourceConflictException", () => Effect.void),
176 );
177 } else if (
178 live.Name !== news.name ||
179 live.Description !== news.description
180 ) {
181 // 3. SYNC — observed ↔ desired.
182 yield* securityhub.updateActionTarget({
183 ActionTargetArn: arn,
184 Name: news.name,
185 Description: news.description,
186 });
187 }
188
189 // 4. RETURN fresh attributes.
190 const final = yield* getActionTarget(arn);
191 yield* session.note(arn);
192 return buildAttrs(
193 arn,
194 final ?? { Name: news.name, Description: news.description },
195 );
196 }),
197 delete: Effect.fn(function* ({ output }) {
198 // Idempotent — the action target (or the whole hub) may be gone.
199 yield* securityhub
200 .deleteActionTarget({ ActionTargetArn: output.actionTargetArn })
201 .pipe(
202 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
203 Effect.catchTag("InvalidAccessException", () => Effect.void),
204 );
205 }),
206 };
207 }),
208 );