1import * as AWS from "@/AWS";
2import { ActionTarget } from "@/AWS/SecurityHub/ActionTarget.ts";
3import { AutomationRule } from "@/AWS/SecurityHub/AutomationRule.ts";
4import { FindingAggregator } from "@/AWS/SecurityHub/FindingAggregator.ts";
5import { Hub } from "@/AWS/SecurityHub/Hub.ts";
6import { Insight } from "@/AWS/SecurityHub/Insight.ts";
7import * as Test from "@/Test/Alchemy";
8import * as securityhub from "@distilled.cloud/aws/securityhub";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import { makeSecurityHubTestLease } from "./TestLease.ts";
12
13const { test, beforeAll, afterAll } = Test.make({ providers: AWS.providers() });
14const testLease = makeSecurityHubTestLease();
15
16beforeAll(testLease.acquire, { timeout: 240_000 });
17afterAll(testLease.release);
18
19// `describeHub` throws `InvalidAccessException` when the account is not
20// subscribed to Security Hub.
21const describeHub = securityhub.describeHub({}).pipe(
22 Effect.map((hub) => hub as securityhub.DescribeHubResponse | undefined),
23 Effect.catchTag("InvalidAccessException", () => Effect.succeed(undefined)),
24 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
25);
26
27// ActionTarget, Insight, AutomationRule, and FindingAggregator all require
28// Security Hub to be enabled. To avoid touching a Hub the user already
29// operates (capture-and-restore safety), the test only runs when Security Hub
30// is not already enabled — it enables the Hub itself and disables it again on
31// destroy.
32test.provider(
33 "lifecycle: action target + insight + automation rule + finding aggregator",
34 (stack) =>
35 Effect.gen(function* () {
36 // Destroy OUR previous resources first — a crashed prior run leaves the
37 // test's own Hub enabled, which must not be mistaken for a foreign one.
38 yield* stack.destroy();
39
40 const preexisting = yield* describeHub;
41 if (preexisting) {
42 yield* Effect.logInfo(
43 `Security Hub already enabled (${preexisting.HubArn}) — skipping SecurityHub resources lifecycle test`,
44 );
45 return;
46 }
47
48 // Phase 0 — enable the Hub alone so the sub-resources (which have no
49 // data dependency on it) never race its enablement.
50 yield* stack.deploy(
51 Effect.gen(function* () {
52 return yield* Hub("Hub", { enableDefaultStandards: false });
53 }),
54 );
55
56 // Phase 1 — create all four sub-resources.
57 const created = yield* stack.deploy(
58 Effect.gen(function* () {
59 yield* Hub("Hub", { enableDefaultStandards: false });
60 const action = yield* ActionTarget("Escalate", {
61 name: "Escalate",
62 description: "Escalate the selected findings",
63 });
64 const insight = yield* Insight("ActiveByResource", {
65 filters: {
66 RecordState: [{ Value: "ACTIVE", Comparison: "EQUALS" }],
67 },
68 groupByAttribute: "ResourceId",
69 });
70 const rule = yield* AutomationRule("SuppressInfo", {
71 description: "Suppress informational findings",
72 ruleOrder: 1,
73 criteria: {
74 SeverityLabel: [{ Value: "INFORMATIONAL", Comparison: "EQUALS" }],
75 },
76 actions: [
77 {
78 Type: "FINDING_FIELDS_UPDATE",
79 FindingFieldsUpdate: {
80 Workflow: { Status: "SUPPRESSED" },
81 },
82 },
83 ],
84 tags: { env: "test" },
85 });
86 const aggregator = yield* FindingAggregator("Aggregator", {
87 regionLinkingMode: "SPECIFIED_REGIONS",
88 regions: ["eu-west-1"],
89 });
90 return {
91 actionTargetArn: action.actionTargetArn,
92 actionId: action.id,
93 insightArn: insight.insightArn,
94 insightName: insight.name,
95 ruleArn: rule.ruleArn,
96 aggregatorArn: aggregator.findingAggregatorArn,
97 };
98 }),
99 );
100 expect(created.actionTargetArn).toContain(":action/custom/");
101 expect(created.insightArn).toContain(":insight/");
102 expect(created.ruleArn).toContain(":automation-rule/");
103 expect(created.aggregatorArn).toContain(":finding-aggregator/");
104
105 // Out-of-band verification.
106 const liveAction = yield* securityhub.describeActionTargets({
107 ActionTargetArns: [created.actionTargetArn],
108 });
109 expect(liveAction.ActionTargets?.[0]?.Name).toBe("Escalate");
110
111 const liveInsight = yield* securityhub.getInsights({
112 InsightArns: [created.insightArn],
113 });
114 expect(liveInsight.Insights?.[0]?.GroupByAttribute).toBe("ResourceId");
115
116 const liveRule = yield* securityhub.batchGetAutomationRules({
117 AutomationRulesArns: [created.ruleArn],
118 });
119 expect(liveRule.Rules?.[0]?.RuleOrder).toBe(1);
120 const ruleTags = yield* securityhub.listTagsForResource({
121 ResourceArn: created.ruleArn,
122 });
123 expect(ruleTags.Tags?.["env"]).toBe("test");
124 expect(ruleTags.Tags?.["alchemy::id"]).toBe("SuppressInfo");
125
126 const liveAggregator = yield* securityhub.getFindingAggregator({
127 FindingAggregatorArn: created.aggregatorArn,
128 });
129 expect(liveAggregator.RegionLinkingMode).toBe("SPECIFIED_REGIONS");
130 expect(liveAggregator.Regions).toEqual(["eu-west-1"]);
131
132 // Phase 2 — in-place updates across all four resources.
133 const updated = yield* stack.deploy(
134 Effect.gen(function* () {
135 yield* Hub("Hub", { enableDefaultStandards: false });
136 const action = yield* ActionTarget("Escalate", {
137 name: "Escalate",
138 description: "Escalate the selected findings to on-call",
139 });
140 const insight = yield* Insight("ActiveByResource", {
141 filters: {
142 RecordState: [{ Value: "ACTIVE", Comparison: "EQUALS" }],
143 },
144 groupByAttribute: "SeverityLabel",
145 });
146 const rule = yield* AutomationRule("SuppressInfo", {
147 description: "Suppress informational findings",
148 ruleOrder: 5,
149 ruleStatus: "DISABLED",
150 criteria: {
151 SeverityLabel: [{ Value: "INFORMATIONAL", Comparison: "EQUALS" }],
152 },
153 actions: [
154 {
155 Type: "FINDING_FIELDS_UPDATE",
156 FindingFieldsUpdate: {
157 Workflow: { Status: "SUPPRESSED" },
158 },
159 },
160 ],
161 tags: { env: "prod" },
162 });
163 const aggregator = yield* FindingAggregator("Aggregator", {
164 regionLinkingMode: "SPECIFIED_REGIONS",
165 regions: ["eu-west-1", "eu-central-1"],
166 });
167 return {
168 actionTargetArn: action.actionTargetArn,
169 insightArn: insight.insightArn,
170 ruleArn: rule.ruleArn,
171 aggregatorArn: aggregator.findingAggregatorArn,
172 };
173 }),
174 );
175
176 // All four update in place — identities are unchanged.
177 expect(updated.actionTargetArn).toBe(created.actionTargetArn);
178 expect(updated.insightArn).toBe(created.insightArn);
179 expect(updated.ruleArn).toBe(created.ruleArn);
180 expect(updated.aggregatorArn).toBe(created.aggregatorArn);
181
182 const updatedAction = yield* securityhub.describeActionTargets({
183 ActionTargetArns: [created.actionTargetArn],
184 });
185 expect(updatedAction.ActionTargets?.[0]?.Description).toBe(
186 "Escalate the selected findings to on-call",
187 );
188
189 const updatedInsight = yield* securityhub.getInsights({
190 InsightArns: [created.insightArn],
191 });
192 expect(updatedInsight.Insights?.[0]?.GroupByAttribute).toBe(
193 "SeverityLabel",
194 );
195
196 const updatedRule = yield* securityhub.batchGetAutomationRules({
197 AutomationRulesArns: [created.ruleArn],
198 });
199 expect(updatedRule.Rules?.[0]?.RuleOrder).toBe(5);
200 expect(updatedRule.Rules?.[0]?.RuleStatus).toBe("DISABLED");
201 const updatedTags = yield* securityhub.listTagsForResource({
202 ResourceArn: created.ruleArn,
203 });
204 expect(updatedTags.Tags?.["env"]).toBe("prod");
205
206 const updatedAggregator = yield* securityhub.getFindingAggregator({
207 FindingAggregatorArn: created.aggregatorArn,
208 });
209 // The API returns Regions in normalized order.
210 expect([...(updatedAggregator.Regions ?? [])].sort()).toEqual([
211 "eu-central-1",
212 "eu-west-1",
213 ]);
214
215 // Destroy — every resource is removed and Security Hub is disabled
216 // (which also proves delete-idempotence via the InvalidAccess catches:
217 // the hub disable may land before the sub-resource deletes re-check).
218 yield* stack.destroy();
219 const after = yield* describeHub;
220 expect(after).toBeUndefined();
221 }),
222 {
223 tags: ["provider:aws", "provider:aws:securityhub", "live"],
224 timeout: 300_000,
225 },
226);