1import * as securityhub from "@distilled.cloud/aws/securityhub";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7
8/**
9 * How linked Regions are selected for cross-Region finding aggregation.
10 */
11export type RegionLinkingMode =
12 | "ALL_REGIONS"
13 | "ALL_REGIONS_EXCEPT_SPECIFIED"
14 | "SPECIFIED_REGIONS"
15 | "NO_REGIONS";
16
17export interface FindingAggregatorProps {
18 /**
19 * How linked Regions are selected: `ALL_REGIONS` aggregates from every
20 * Region (including future ones), `ALL_REGIONS_EXCEPT_SPECIFIED` excludes
21 * `regions`, `SPECIFIED_REGIONS` includes only `regions`, and `NO_REGIONS`
22 * disables aggregation. Updatable in place.
23 */
24 regionLinkingMode: RegionLinkingMode;
25
26 /**
27 * The Regions excluded from (`ALL_REGIONS_EXCEPT_SPECIFIED`) or included
28 * in (`SPECIFIED_REGIONS`) aggregation. Updatable in place.
29 */
30 regions?: string[];
31}
32
33/** @resource */
34export interface FindingAggregator extends Resource<
35 "AWS.SecurityHub.FindingAggregator",
36 FindingAggregatorProps,
37 {
38 /** ARN of the finding aggregator (its identity). */
39 findingAggregatorArn: string;
40 /** The home Region findings are aggregated into. */
41 findingAggregationRegion: string | undefined;
42 /** The active Region-linking mode. */
43 regionLinkingMode: string | undefined;
44 /** The Regions included in or excluded from aggregation. */
45 regions: string[] | undefined;
46 },
47 never,
48 Providers
49> {}
50
51/**
52 * The Security Hub cross-Region finding aggregator — replicates findings
53 * from linked Regions into the home Region. Only one aggregator can exist
54 * per account, so this is a singleton: adopting a pre-existing aggregator
55 * that Alchemy did not create requires `--adopt`.
56 *
57 * ### Aggregating Findings Across Regions
58 * **Example:** Aggregate from All Regions
59 * ```typescript
60 * const aggregator = yield* AWS.SecurityHub.FindingAggregator("Aggregator", {
61 * regionLinkingMode: "ALL_REGIONS",
62 * });
63 * ```
64 *
65 * **Example:** Aggregate from Specific Regions
66 * ```typescript
67 * const aggregator = yield* AWS.SecurityHub.FindingAggregator("Aggregator", {
68 * regionLinkingMode: "SPECIFIED_REGIONS",
69 * regions: ["us-east-1", "eu-west-1"],
70 * });
71 * ```
72 */
73const FindingAggregatorResource = Resource<FindingAggregator>(
74 "AWS.SecurityHub.FindingAggregator",
75);
76
77export { FindingAggregatorResource as FindingAggregator };
78
79export const FindingAggregatorProvider = () =>
80 Provider.effect(
81 FindingAggregatorResource,
82 Effect.gen(function* () {
83 const getAggregator = (arn: string) =>
84 securityhub.getFindingAggregator({ FindingAggregatorArn: arn }).pipe(
85 Effect.map(
86 (r) => r as securityhub.GetFindingAggregatorResponse | undefined,
87 ),
88 Effect.catchTag("ResourceNotFoundException", () =>
89 Effect.succeed(undefined),
90 ),
91 Effect.catchTag("InvalidAccessException", () =>
92 Effect.succeed(undefined),
93 ),
94 );
95
96 // At most one aggregator exists per account — a single page suffices.
97 const findAggregator = securityhub.listFindingAggregators({}).pipe(
98 Effect.map((r) => r.FindingAggregators?.[0]?.FindingAggregatorArn),
99 Effect.catchTag("InvalidAccessException", () =>
100 Effect.succeed(undefined),
101 ),
102 );
103
104 const buildAttrs = (r: securityhub.GetFindingAggregatorResponse) => ({
105 findingAggregatorArn: r.FindingAggregatorArn!,
106 findingAggregationRegion: r.FindingAggregationRegion,
107 regionLinkingMode: r.RegionLinkingMode,
108 regions: r.Regions,
109 });
110
111 return {
112 stables: ["findingAggregatorArn", "findingAggregationRegion"],
113 read: Effect.fn(function* ({ output }) {
114 if (output?.findingAggregatorArn) {
115 const live = yield* getAggregator(output.findingAggregatorArn);
116 return live ? buildAttrs(live) : undefined;
117 }
118 // Aggregators cannot be tagged; an existing one we have no state
119 // for is foreign until adopted.
120 const arn = yield* findAggregator;
121 if (!arn) return undefined;
122 const live = yield* getAggregator(arn);
123 return live ? Unowned(buildAttrs(live)) : undefined;
124 }),
125 list: () =>
126 Effect.gen(function* () {
127 const arn = yield* findAggregator;
128 if (!arn) return [];
129 const live = yield* getAggregator(arn);
130 return live ? [buildAttrs(live)] : [];
131 }),
132 reconcile: Effect.fn(function* ({ news, output, session }) {
133 // 1. OBSERVE — the account's single aggregator is authoritative.
134 const arn = output?.findingAggregatorArn ?? (yield* findAggregator);
135 const live = arn ? yield* getAggregator(arn) : undefined;
136
137 let final: securityhub.GetFindingAggregatorResponse;
138 if (!live) {
139 // 2. ENSURE.
140 final = yield* securityhub.createFindingAggregator({
141 RegionLinkingMode: news.regionLinkingMode,
142 Regions: news.regions,
143 });
144 } else if (
145 live.RegionLinkingMode !== news.regionLinkingMode ||
146 // The API returns Regions in normalized order — compare as sets.
147 JSON.stringify([...(live.Regions ?? [])].sort()) !==
148 JSON.stringify([...(news.regions ?? [])].sort())
149 ) {
150 // 3. SYNC — observed ↔ desired.
151 final = yield* securityhub.updateFindingAggregator({
152 FindingAggregatorArn: live.FindingAggregatorArn!,
153 RegionLinkingMode: news.regionLinkingMode,
154 Regions: news.regions,
155 });
156 } else {
157 final = live;
158 }
159
160 // 4. RETURN fresh attributes.
161 yield* session.note(final.FindingAggregatorArn!);
162 return buildAttrs(final);
163 }),
164 delete: Effect.fn(function* ({ output }) {
165 // Idempotent — the aggregator (or the whole hub) may already be gone.
166 yield* securityhub
167 .deleteFindingAggregator({
168 FindingAggregatorArn: output.findingAggregatorArn,
169 })
170 .pipe(
171 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
172 Effect.catchTag("InvalidAccessException", () => Effect.void),
173 );
174 }),
175 };
176 }),
177 );