| 1 | import * as AWS from "@/AWS"; |
| 2 | import { ActionTarget } from "@/AWS/SecurityHub/ActionTarget.ts"; |
| 3 | import { AutomationRule } from "@/AWS/SecurityHub/AutomationRule.ts"; |
| 4 | import { FindingAggregator } from "@/AWS/SecurityHub/FindingAggregator.ts"; |
| 5 | import { Hub } from "@/AWS/SecurityHub/Hub.ts"; |
| 6 | import { Insight } from "@/AWS/SecurityHub/Insight.ts"; |
| 7 | import * as Test from "@/Test/Alchemy"; |
| 8 | import * as securityhub from "@distilled.cloud/aws/securityhub"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import { makeSecurityHubTestLease } from "./TestLease.ts"; |
| 12 | |
| 13 | const { test, beforeAll, afterAll } = Test.make({ providers: AWS.providers() }); |
| 14 | const testLease = makeSecurityHubTestLease(); |
| 15 | |
| 16 | beforeAll(testLease.acquire, { timeout: 240_000 }); |
| 17 | afterAll(testLease.release); |
| 18 | |
| 19 | // `describeHub` throws `InvalidAccessException` when the account is not |
| 20 | // subscribed to Security Hub. |
| 21 | const 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. |
| 32 | test.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 | ); |