| 1 | import * as AWS from "@/AWS"; |
| 2 | import { CisScanConfiguration } from "@/AWS/Inspector2/CisScanConfiguration.ts"; |
| 3 | import { Filter } from "@/AWS/Inspector2/Filter.ts"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as inspector2 from "@distilled.cloud/aws/inspector2"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Result from "effect/Result"; |
| 10 | |
| 11 | const { test } = Test.make({ providers: AWS.providers() }); |
| 12 | |
| 13 | // The findings-filter APIs work regardless of Inspector enablement, so the |
| 14 | // Filter lifecycle always runs live. |
| 15 | test.provider( |
| 16 | "lifecycle: findings filter create, update, destroy", |
| 17 | (stack) => |
| 18 | Effect.gen(function* () { |
| 19 | yield* stack.destroy(); |
| 20 | |
| 21 | // Self-heal: an interrupted previous run can orphan the live filter |
| 22 | // (state lost, so the cloud resource is unadoptable under a fresh |
| 23 | // stage). Delete any filter this test's logical id created before. |
| 24 | const orphans = (yield* inspector2.listFilters({})).filters.filter( |
| 25 | (f) => f.tags?.["alchemy::id"] === "SuppressInfo", |
| 26 | ); |
| 27 | yield* Effect.forEach(orphans, (f) => |
| 28 | inspector2 |
| 29 | .deleteFilter({ arn: f.arn }) |
| 30 | .pipe( |
| 31 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 32 | ), |
| 33 | ); |
| 34 | |
| 35 | const deploy = (props: { action: "NONE" | "SUPPRESS"; reason: string }) => |
| 36 | stack.deploy( |
| 37 | Effect.gen(function* () { |
| 38 | const filter = yield* Filter("SuppressInfo", { |
| 39 | action: props.action, |
| 40 | reason: props.reason, |
| 41 | description: "created by alchemy Inspector2 resource test", |
| 42 | filterCriteria: { |
| 43 | severity: [{ comparison: "EQUALS", value: "INFORMATIONAL" }], |
| 44 | }, |
| 45 | tags: { env: "test" }, |
| 46 | }); |
| 47 | return { |
| 48 | arn: filter.arn, |
| 49 | name: filter.name, |
| 50 | action: filter.action, |
| 51 | reason: filter.reason, |
| 52 | }; |
| 53 | }), |
| 54 | ); |
| 55 | |
| 56 | // Create. |
| 57 | const created = yield* deploy({ |
| 58 | action: "SUPPRESS", |
| 59 | reason: "informational findings are tracked elsewhere", |
| 60 | }); |
| 61 | expect(created.arn).toContain("/filter/"); |
| 62 | expect(created.action).toBe("SUPPRESS"); |
| 63 | |
| 64 | // Out-of-band verification via distilled. |
| 65 | const live = (yield* inspector2.listFilters({ arns: [created.arn] })) |
| 66 | .filters[0]; |
| 67 | expect(live?.action).toBe("SUPPRESS"); |
| 68 | expect(live?.tags?.["env"]).toBe("test"); |
| 69 | expect(live?.tags?.["alchemy::id"]).toBe("SuppressInfo"); |
| 70 | |
| 71 | // Canonical list() coverage. |
| 72 | const provider = yield* Provider.findProvider(Filter); |
| 73 | const all = yield* provider.list(); |
| 74 | expect(all.some((f) => f.arn === created.arn)).toBe(true); |
| 75 | |
| 76 | // Update in place — the ARN is stable. |
| 77 | const updated = yield* deploy({ |
| 78 | action: "NONE", |
| 79 | reason: "keep them visible after all", |
| 80 | }); |
| 81 | expect(updated.arn).toBe(created.arn); |
| 82 | expect(updated.action).toBe("NONE"); |
| 83 | const liveUpdated = (yield* inspector2.listFilters({ |
| 84 | arns: [created.arn], |
| 85 | })).filters[0]; |
| 86 | expect(liveUpdated?.action).toBe("NONE"); |
| 87 | expect(liveUpdated?.reason).toBe("keep them visible after all"); |
| 88 | |
| 89 | // Destroy — the filter is gone. |
| 90 | yield* stack.destroy(); |
| 91 | const gone = yield* inspector2.listFilters({ arns: [created.arn] }); |
| 92 | expect(gone.filters).toHaveLength(0); |
| 93 | }), |
| 94 | { |
| 95 | tags: ["provider:aws", "provider:aws:inspector2", "live"], |
| 96 | timeout: 120_000, |
| 97 | }, |
| 98 | ); |
| 99 | |
| 100 | // The CIS scan APIs are hard-gated on Inspector enablement — a disabled |
| 101 | // account gets a typed AccessDeniedException ("Invoking account is not |
| 102 | // enabled."). This ungated probe pins that behavior; the full lifecycle |
| 103 | // below only runs against an Inspector-enabled account. |
| 104 | test.provider( |
| 105 | "CIS scan APIs reject a non-enabled account (typed)", |
| 106 | () => |
| 107 | Effect.gen(function* () { |
| 108 | const account = (yield* inspector2.batchGetAccountStatus({})) |
| 109 | .accounts?.[0]; |
| 110 | if (account?.state?.status === "ENABLED") { |
| 111 | yield* Effect.logInfo( |
| 112 | "Inspector is enabled in this account — CIS APIs are accessible, probe not applicable", |
| 113 | ); |
| 114 | return; |
| 115 | } |
| 116 | const result = yield* Effect.result( |
| 117 | inspector2.listCisScanConfigurations({}), |
| 118 | ); |
| 119 | expect(Result.isFailure(result)).toBe(true); |
| 120 | if (Result.isFailure(result)) { |
| 121 | expect(result.failure._tag).toBe("AccessDeniedException"); |
| 122 | } |
| 123 | }), |
| 124 | { tags: ["provider:aws", "provider:aws:inspector2", "live"] }, |
| 125 | ); |
| 126 | |
| 127 | // Full CIS scan configuration lifecycle — requires Inspector to be enabled |
| 128 | // (INSPECTOR2_TEST_CIS=1 on an enabled account). |
| 129 | test.provider.skipIf(!process.env.INSPECTOR2_TEST_CIS)( |
| 130 | "lifecycle: CIS scan configuration create, update, destroy", |
| 131 | (stack) => |
| 132 | Effect.gen(function* () { |
| 133 | yield* stack.destroy(); |
| 134 | |
| 135 | const deploy = (props: { |
| 136 | securityLevel: "LEVEL_1" | "LEVEL_2"; |
| 137 | timeOfDay: string; |
| 138 | }) => |
| 139 | stack.deploy( |
| 140 | Effect.gen(function* () { |
| 141 | const cis = yield* CisScanConfiguration("NightlyCis", { |
| 142 | securityLevel: props.securityLevel, |
| 143 | schedule: { |
| 144 | daily: { |
| 145 | startTime: { timeOfDay: props.timeOfDay, timezone: "UTC" }, |
| 146 | }, |
| 147 | }, |
| 148 | targets: { |
| 149 | accountIds: ["SELF"], |
| 150 | targetResourceTags: { AlchemyCisTest: ["true"] }, |
| 151 | }, |
| 152 | tags: { env: "test" }, |
| 153 | }); |
| 154 | return { |
| 155 | scanConfigurationArn: cis.scanConfigurationArn, |
| 156 | scanName: cis.scanName, |
| 157 | securityLevel: cis.securityLevel, |
| 158 | }; |
| 159 | }), |
| 160 | ); |
| 161 | |
| 162 | const created = yield* deploy({ |
| 163 | securityLevel: "LEVEL_1", |
| 164 | timeOfDay: "02:00", |
| 165 | }); |
| 166 | expect(created.scanConfigurationArn).toContain("scan-configuration"); |
| 167 | expect(created.securityLevel).toBe("LEVEL_1"); |
| 168 | |
| 169 | const byArn = () => |
| 170 | inspector2 |
| 171 | .listCisScanConfigurations({ |
| 172 | filterCriteria: { |
| 173 | scanConfigurationArnFilters: [ |
| 174 | { comparison: "EQUALS", value: created.scanConfigurationArn }, |
| 175 | ], |
| 176 | }, |
| 177 | }) |
| 178 | .pipe(Effect.map((r) => r.scanConfigurations?.[0])); |
| 179 | |
| 180 | const live = yield* byArn(); |
| 181 | expect(live?.securityLevel).toBe("LEVEL_1"); |
| 182 | expect(live?.schedule?.daily?.startTime.timeOfDay).toBe("02:00"); |
| 183 | |
| 184 | // Canonical list() coverage. |
| 185 | const provider = yield* Provider.findProvider(CisScanConfiguration); |
| 186 | const all = yield* provider.list(); |
| 187 | expect( |
| 188 | all.some( |
| 189 | (c) => c.scanConfigurationArn === created.scanConfigurationArn, |
| 190 | ), |
| 191 | ).toBe(true); |
| 192 | |
| 193 | // Update in place — the ARN is stable. |
| 194 | const updated = yield* deploy({ |
| 195 | securityLevel: "LEVEL_2", |
| 196 | timeOfDay: "03:30", |
| 197 | }); |
| 198 | expect(updated.scanConfigurationArn).toBe(created.scanConfigurationArn); |
| 199 | expect(updated.securityLevel).toBe("LEVEL_2"); |
| 200 | const liveUpdated = yield* byArn(); |
| 201 | expect(liveUpdated?.schedule?.daily?.startTime.timeOfDay).toBe("03:30"); |
| 202 | |
| 203 | // Destroy — the configuration is gone. |
| 204 | yield* stack.destroy(); |
| 205 | expect(yield* byArn()).toBeUndefined(); |
| 206 | }), |
| 207 | { |
| 208 | tags: ["provider:aws", "provider:aws:inspector2", "live"], |
| 209 | timeout: 120_000, |
| 210 | }, |
| 211 | ); |