1import * as inspector2 from "@distilled.cloud/aws/inspector2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { AWSEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10
11/**
12 * The Amazon Inspector scan types that can be enabled per account.
13 */
14export type ResourceScanType =
15 | "EC2"
16 | "ECR"
17 | "LAMBDA"
18 | "LAMBDA_CODE"
19 | "CODE_REPOSITORY";
20
21/**
22 * Raised when Inspector does not reach the requested scan state within the
23 * provider's bounded convergence budget.
24 */
25export class Inspector2NotConverged extends Data.TaggedError(
26 "Inspector2NotConverged",
27)<{
28 readonly accountId: string;
29 readonly expected: string;
30 readonly actual: Readonly<Record<string, string | undefined>>;
31}> {}
32
33export interface EnablerProps {
34 /**
35 * The resource scan types to enable for the account (e.g. `EC2`, `ECR`,
36 * `LAMBDA`). Types managed by this resource are disabled again on destroy.
37 */
38 resourceTypes: ResourceScanType[];
39}
40
41/** @resource */
42export interface Enabler extends Resource<
43 "AWS.Inspector2.Enabler",
44 EnablerProps,
45 {
46 /** The account the enabler applies to. */
47 accountId: string;
48 /** The scan types currently enabled and managed by this resource. */
49 resourceTypes: string[];
50 /** The overall Inspector account status (`ENABLED` / `DISABLED` / ...). */
51 state: string | undefined;
52 },
53 never,
54 Providers
55> {}
56
57/**
58 * Amazon Inspector account enablement — an account/region singleton that turns
59 * on continuous vulnerability scanning for the selected resource types (EC2,
60 * ECR, Lambda). Inspector exposes no tagging, so ownership is tracked by the
61 * set of scan types this resource enabled; destroy only disables those types
62 * and leaves any the account had enabled out-of-band untouched.
63 *
64 * ### Enabling Inspector
65 * **Example:** Enable EC2, ECR, and Lambda scanning
66 * ```typescript
67 * const inspector = yield* Inspector2.Enabler("Inspector", {
68 * resourceTypes: ["EC2", "ECR", "LAMBDA"],
69 * });
70 * ```
71 */
72const EnablerResource = Resource<Enabler>("AWS.Inspector2.Enabler");
73
74export { EnablerResource as Enabler };
75
76// The scan-type keys as they appear on the account's `resourceState`, mapped to
77// their `EnableRequest` resource-type names.
78const RESOURCE_STATE_KEYS = {
79 ec2: "EC2",
80 ecr: "ECR",
81 lambda: "LAMBDA",
82 lambdaCode: "LAMBDA_CODE",
83 codeRepository: "CODE_REPOSITORY",
84} as const;
85
86const TYPE_TO_STATE_KEY: Record<string, keyof typeof RESOURCE_STATE_KEYS> = {
87 EC2: "ec2",
88 ECR: "ecr",
89 LAMBDA: "lambda",
90 LAMBDA_CODE: "lambdaCode",
91 CODE_REPOSITORY: "codeRepository",
92};
93
94const statusOfType = (
95 resourceState: inspector2.ResourceState | undefined,
96 type: string,
97): string | undefined => {
98 const key = TYPE_TO_STATE_KEY[type];
99 if (!resourceState || !key) return undefined;
100 return resourceState[key]?.status;
101};
102
103const enabledTypes = (
104 resourceState: inspector2.ResourceState | undefined,
105): string[] => {
106 if (!resourceState) return [];
107 const out: string[] = [];
108 for (const key of Object.keys(RESOURCE_STATE_KEYS) as Array<
109 keyof typeof RESOURCE_STATE_KEYS
110 >) {
111 if (resourceState[key]?.status === "ENABLED") {
112 out.push(RESOURCE_STATE_KEYS[key]);
113 }
114 }
115 return out;
116};
117
118const statusesOf = (
119 resourceState: inspector2.ResourceState | undefined,
120 types: readonly string[],
121): Record<string, string | undefined> =>
122 Object.fromEntries(
123 types.map((type) => [type, statusOfType(resourceState, type)]),
124 );
125
126export const EnablerProvider = () =>
127 Provider.effect(
128 EnablerResource,
129 Effect.gen(function* () {
130 const getAccount = (accountId: string) =>
131 inspector2
132 .batchGetAccountStatus({ accountIds: [accountId] })
133 .pipe(Effect.map((r) => r.accounts?.[0]));
134
135 // Inspector enablement can remain transitional for several minutes.
136 // Keep the provider's wait bounded (~50s) and, critically, check the
137 // terminal value because Effect.repeat returns its last success when the
138 // repetition budget is exhausted even if `until` never became true.
139 const waitUntilEnabled = Effect.fn(function* (
140 accountId: string,
141 types: readonly string[],
142 ) {
143 const account = yield* getAccount(accountId).pipe(
144 Effect.repeat({
145 schedule: Schedule.spaced("5 seconds"),
146 until: (account) =>
147 types.every(
148 (t) => statusOfType(account?.resourceState, t) === "ENABLED",
149 ),
150 times: 10,
151 }),
152 );
153 if (
154 !types.every(
155 (type) => statusOfType(account?.resourceState, type) === "ENABLED",
156 )
157 ) {
158 return yield* Effect.fail(
159 new Inspector2NotConverged({
160 accountId,
161 expected: "ENABLED",
162 actual: statusesOf(account?.resourceState, types),
163 }),
164 );
165 }
166 return account;
167 });
168
169 const disableManagedTypes = Effect.fn(function* (
170 accountId: string,
171 types: readonly string[],
172 ) {
173 if (types.length === 0) return;
174
175 let account = yield* getAccount(accountId);
176 const enabling = types.filter(
177 (type) => statusOfType(account?.resourceState, type) === "ENABLING",
178 );
179 if (enabling.length > 0) {
180 yield* waitUntilEnabled(accountId, enabling);
181 account = yield* getAccount(accountId);
182 }
183
184 // DISABLING and DISABLED are already converging toward deletion. Only
185 // issue disable for types that are observably enabled.
186 const enabled = types.filter(
187 (type) => statusOfType(account?.resourceState, type) === "ENABLED",
188 );
189 if (enabled.length === 0) return;
190 yield* inspector2
191 .disable({ accountIds: [accountId], resourceTypes: enabled })
192 .pipe(
193 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
194 );
195 });
196
197 return {
198 read: Effect.fn(function* ({ output }) {
199 const accountId =
200 output?.accountId ?? (yield* AWSEnvironment.current).accountId;
201 const account = yield* getAccount(accountId);
202 if (!account) return undefined;
203 const enabled = enabledTypes(account.resourceState);
204 const managed = output
205 ? output.resourceTypes.filter(
206 (type) =>
207 statusOfType(account.resourceState, type) !== "DISABLED",
208 )
209 : enabled;
210 if (managed.length === 0) return undefined;
211 const attrs = {
212 accountId,
213 resourceTypes: managed,
214 state: account.state?.status,
215 };
216 // Inspector has no tags. A live singleton without prior Alchemy
217 // state is foreign and must be explicitly adopted.
218 return output ? attrs : Unowned(attrs);
219 }),
220
221 // Inspector enablement is an account/region singleton keyed on the
222 // caller's account. Report the single account status.
223 list: () =>
224 Effect.gen(function* () {
225 const { accountId } = yield* AWSEnvironment.current;
226 const account = yield* getAccount(accountId);
227 const enabled = enabledTypes(account?.resourceState);
228 if (!account || enabled.length === 0) return [];
229 return [
230 {
231 accountId,
232 resourceTypes: enabled,
233 state: account.state?.status,
234 },
235 ];
236 }),
237
238 reconcile: Effect.fn(function* ({ news, output, session }) {
239 const { accountId } = yield* AWSEnvironment.current;
240 const desired: string[] = news.resourceTypes ?? [];
241
242 // 1. OBSERVE
243 const account = yield* getAccount(accountId);
244 const currentlyEnabled = enabledTypes(account?.resourceState);
245
246 // 2. ENSURE — enable any desired type not yet enabled.
247 const toEnable = desired.filter(
248 (type) =>
249 !currentlyEnabled.includes(type) &&
250 statusOfType(account?.resourceState, type) !== "ENABLING",
251 );
252 if (toEnable.length > 0) {
253 yield* inspector2.enable({
254 accountIds: [accountId],
255 resourceTypes: toEnable,
256 });
257 }
258
259 // Wait for the desired types to reach the terminal ENABLED state
260 // before returning — otherwise a follow-up destroy would be rejected
261 // with ENABLE_IN_PROGRESS.
262 if (desired.some((type) => !currentlyEnabled.includes(type))) {
263 yield* waitUntilEnabled(accountId, desired);
264 }
265
266 // 3. SYNC — disable any type we previously managed but no longer want.
267 // Disable teardown is slow (minutes); fire it and do not block, the
268 // account converges to the desired set asynchronously.
269 const previouslyManaged = output?.resourceTypes ?? [];
270 const noLongerManaged = previouslyManaged.filter(
271 (type) => !desired.includes(type),
272 );
273 yield* disableManagedTypes(accountId, noLongerManaged);
274
275 const final = yield* getAccount(accountId);
276 const managed = [
277 ...new Set([
278 ...previouslyManaged.filter((type) => desired.includes(type)),
279 ...toEnable,
280 ]),
281 ];
282 yield* session.note(accountId);
283 return {
284 accountId,
285 resourceTypes: managed,
286 state: final?.state?.status,
287 };
288 }),
289
290 delete: Effect.fn(function* ({ output }) {
291 yield* disableManagedTypes(output.accountId, output.resourceTypes);
292 }),
293 };
294 }),
295 );