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 {
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 * Which finding format Security Hub generates for controls.
17 */
18export type ControlFindingGenerator = "STANDARD_CONTROL" | "SECURITY_CONTROL";
19
20export interface HubProps {
21 /**
22 * Whether to enable the default security standards (AWS Foundational Security
23 * Best Practices, CIS) when Security Hub is first enabled. Only takes effect
24 * on the initial enablement.
25 * @default true
26 */
27 enableDefaultStandards?: boolean;
28
29 /**
30 * Whether Security Hub automatically enables new controls when they are added
31 * to enabled standards.
32 */
33 autoEnableControls?: boolean;
34
35 /**
36 * Whether to generate `STANDARD_CONTROL` or `SECURITY_CONTROL` findings.
37 */
38 controlFindingGenerator?: ControlFindingGenerator;
39
40 /**
41 * Tags applied to the Hub. Alchemy ownership tags are merged in automatically.
42 */
43 tags?: Record<string, string>;
44}
45
46/** @resource */
47export interface Hub extends Resource<
48 "AWS.SecurityHub.Hub",
49 HubProps,
50 {
51 /** ARN of the Security Hub Hub resource. */
52 hubArn: string;
53 /** When Security Hub was enabled for the account. */
54 subscribedAt: string | undefined;
55 /** Whether new controls are auto-enabled. */
56 autoEnableControls: boolean | undefined;
57 /** The active control finding generator. */
58 controlFindingGenerator: string | undefined;
59 },
60 never,
61 Providers
62> {}
63
64/**
65 * The Security Hub Hub — the account/region singleton that enables AWS Security
66 * Hub. Only one Hub can exist per region, so this is a capture-and-restore
67 * singleton: adopting a pre-existing Hub that Alchemy did not create requires
68 * `--adopt`.
69 *
70 * ### Enabling Security Hub
71 * **Example:** Enable with default standards
72 * ```typescript
73 * const hub = yield* SecurityHub.Hub("Hub", {});
74 * ```
75 *
76 * **Example:** Enable without default standards, auto-enable controls
77 * ```typescript
78 * const hub = yield* SecurityHub.Hub("Hub", {
79 * enableDefaultStandards: false,
80 * autoEnableControls: true,
81 * controlFindingGenerator: "SECURITY_CONTROL",
82 * tags: { team: "security" },
83 * });
84 * ```
85 */
86const HubResource = Resource<Hub>("AWS.SecurityHub.Hub");
87
88export { HubResource as Hub };
89
90const hubArnFallback = (region: string, accountId: string) =>
91 `arn:aws:securityhub:${region}:${accountId}:hub/default`;
92
93const buildAttrs = (hub: securityhub.DescribeHubResponse) => ({
94 hubArn: hub.HubArn!,
95 subscribedAt: hub.SubscribedAt,
96 autoEnableControls: hub.AutoEnableControls,
97 controlFindingGenerator: hub.ControlFindingGenerator,
98});
99
100// `describeHub` throws `InvalidAccessException` when the account is not
101// subscribed and `ResourceNotFoundException` transiently right after enable —
102// both mean "no Hub", so collapse them to `undefined`.
103const describeHub = securityhub.describeHub({}).pipe(
104 Effect.catchTag("InvalidAccessException", () => Effect.succeed(undefined)),
105 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
106);
107
108export const HubProvider = () =>
109 Provider.effect(
110 HubResource,
111 Effect.gen(function* () {
112 const readTags = (arn: string) =>
113 securityhub.listTagsForResource({ ResourceArn: arn }).pipe(
114 Effect.map((r) => tagRecord(r.Tags)),
115 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
116 );
117
118 return {
119 read: Effect.fn(function* ({ id }) {
120 const hub = yield* describeHub;
121 if (!hub) return undefined;
122 const attrs = buildAttrs(hub);
123 const tags = yield* readTags(attrs.hubArn);
124 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
125 }),
126
127 // Security Hub is an account/region singleton — `describeHub` returns
128 // the single Hub or throws when unsubscribed.
129 list: () =>
130 describeHub.pipe(Effect.map((hub) => (hub ? [buildAttrs(hub)] : []))),
131
132 reconcile: Effect.fn(function* ({ id, news = {}, session }) {
133 const { accountId, region } = yield* AWSEnvironment.current;
134 const internalTags = yield* createInternalTags(id);
135 const desiredTags = { ...news.tags, ...internalTags };
136
137 // 1. OBSERVE
138 let hub = yield* describeHub;
139
140 // 2. ENSURE — enable Security Hub if not already subscribed.
141 if (!hub) {
142 yield* securityhub.enableSecurityHub({
143 Tags: desiredTags,
144 EnableDefaultStandards: news.enableDefaultStandards ?? true,
145 ControlFindingGenerator: news.controlFindingGenerator,
146 });
147 hub = yield* securityhub.describeHub({});
148 }
149
150 const arn = hub.HubArn ?? hubArnFallback(region, accountId);
151
152 // 3. SYNC configuration — observed ↔ desired.
153 const configChanged =
154 (news.autoEnableControls !== undefined &&
155 news.autoEnableControls !== hub.AutoEnableControls) ||
156 (news.controlFindingGenerator !== undefined &&
157 news.controlFindingGenerator !== hub.ControlFindingGenerator);
158 if (configChanged) {
159 yield* securityhub.updateSecurityHubConfiguration({
160 AutoEnableControls: news.autoEnableControls,
161 ControlFindingGenerator: news.controlFindingGenerator,
162 });
163 }
164
165 // 3b. SYNC tags — diff against OBSERVED cloud tags.
166 const currentTags = yield* readTags(arn);
167 const { upsert, removed } = diffTags(currentTags, desiredTags);
168 if (upsert.length > 0) {
169 yield* securityhub.tagResource({
170 ResourceArn: arn,
171 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
172 });
173 }
174 if (removed.length > 0) {
175 yield* securityhub.untagResource({
176 ResourceArn: arn,
177 TagKeys: removed,
178 });
179 }
180
181 // 4. RETURN fresh attributes.
182 const final = yield* securityhub.describeHub({});
183 yield* session.note(arn);
184 return buildAttrs(final);
185 }),
186
187 delete: Effect.fn(function* () {
188 yield* securityhub.disableSecurityHub({}).pipe(
189 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
190 Effect.catchTag("InvalidAccessException", () => Effect.void),
191 );
192 }),
193 };
194 }),
195 );