1import * as inspector2 from "@distilled.cloud/aws/inspector2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 diffTags,
11 hasAlchemyTags,
12 tagRecord,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15
16/** CIS Benchmark hardening level the scan checks against. */
17export type CisSecurityLevel = "LEVEL_1" | "LEVEL_2";
18
19export interface CisScanConfigurationProps {
20 /**
21 * Name of the CIS scan configuration. If omitted, a unique name is
22 * generated. Updatable in place — identity is the configuration's ARN.
23 */
24 scanName?: string;
25
26 /**
27 * The CIS Benchmark level the scan checks target instances against:
28 * `LEVEL_1` (essential hardening) or `LEVEL_2` (defense in depth).
29 * Updatable in place.
30 */
31 securityLevel: CisSecurityLevel;
32
33 /**
34 * When the scan runs: `{ oneTime: {} }`, or a `daily` / `weekly` /
35 * `monthly` schedule with a start time, e.g.
36 * `{ daily: { startTime: { timeOfDay: "02:00", timezone: "UTC" } } }`.
37 * Updatable in place.
38 */
39 schedule: inspector2.Schedule;
40
41 /**
42 * The accounts (`["SELF"]` for the current account) and EC2 instance
43 * resource tags the scan targets. Updatable in place.
44 */
45 targets: {
46 /** Account ids to scan; `["SELF"]` targets the current account. */
47 accountIds: string[];
48 /**
49 * EC2 instances to scan, selected by resource tag, e.g.
50 * `{ Environment: ["production"] }`.
51 */
52 targetResourceTags: Record<string, string[]>;
53 };
54
55 /**
56 * Tags applied to the scan configuration. Alchemy ownership tags are
57 * merged in automatically.
58 */
59 tags?: Record<string, string>;
60}
61
62/** @resource */
63export interface CisScanConfiguration extends Resource<
64 "AWS.Inspector2.CisScanConfiguration",
65 CisScanConfigurationProps,
66 {
67 /** ARN of the CIS scan configuration (its identity). */
68 scanConfigurationArn: string;
69 /** Name of the CIS scan configuration. */
70 scanName: string;
71 /** CIS Benchmark level the scan checks against. */
72 securityLevel: CisSecurityLevel;
73 /** Account that owns the scan configuration. */
74 ownerId: string | undefined;
75 },
76 never,
77 Providers
78> {}
79
80/**
81 * An Amazon Inspector CIS scan configuration — schedules CIS Benchmark
82 * scans of SSM-managed EC2 instances selected by resource tags. Name,
83 * level, schedule, and targets are all updatable in place; identity is the
84 * configuration's ARN.
85 *
86 * ### Scheduling CIS Scans
87 * **Example:** Daily Level 1 scan of production instances
88 * ```typescript
89 * const scan = yield* AWS.Inspector2.CisScanConfiguration("NightlyCis", {
90 * securityLevel: "LEVEL_1",
91 * schedule: {
92 * daily: { startTime: { timeOfDay: "02:00", timezone: "UTC" } },
93 * },
94 * targets: {
95 * accountIds: ["SELF"],
96 * targetResourceTags: { Environment: ["production"] },
97 * },
98 * });
99 * ```
100 *
101 * **Example:** One-time Level 2 audit
102 * ```typescript
103 * const audit = yield* AWS.Inspector2.CisScanConfiguration("Level2Audit", {
104 * scanName: "level2-audit",
105 * securityLevel: "LEVEL_2",
106 * schedule: { oneTime: {} },
107 * targets: {
108 * accountIds: ["SELF"],
109 * targetResourceTags: { Audit: ["true"] },
110 * },
111 * });
112 * ```
113 */
114const CisScanConfigurationResource = Resource<CisScanConfiguration>(
115 "AWS.Inspector2.CisScanConfiguration",
116);
117
118export { CisScanConfigurationResource as CisScanConfiguration };
119
120export const CisScanConfigurationProvider = () =>
121 Provider.effect(
122 CisScanConfigurationResource,
123 Effect.gen(function* () {
124 const toName = (id: string, props: { scanName?: string }) =>
125 props.scanName
126 ? Effect.succeed(props.scanName)
127 : createPhysicalName({ id, maxLength: 128 });
128
129 const findBy = (
130 filter: inspector2.ListCisScanConfigurationsFilterCriteria,
131 ) =>
132 inspector2
133 .listCisScanConfigurations({ filterCriteria: filter })
134 .pipe(Effect.map((r) => r.scanConfigurations?.[0]));
135
136 const findByArn = (arn: string) =>
137 findBy({
138 scanConfigurationArnFilters: [{ comparison: "EQUALS", value: arn }],
139 });
140
141 const findByName = (name: string) =>
142 findBy({ scanNameFilters: [{ comparison: "EQUALS", value: name }] });
143
144 const buildAttrs = (c: inspector2.CisScanConfiguration) => ({
145 scanConfigurationArn: c.scanConfigurationArn,
146 scanName: c.scanName ?? "",
147 securityLevel: c.securityLevel as CisSecurityLevel,
148 ownerId: c.ownerId,
149 });
150
151 return {
152 stables: ["scanConfigurationArn", "ownerId"],
153 read: Effect.fn(function* ({ id, olds, output }) {
154 const live = output?.scanConfigurationArn
155 ? yield* findByArn(output.scanConfigurationArn)
156 : yield* findByName(yield* toName(id, olds ?? {}));
157 if (!live) return undefined;
158 const attrs = buildAttrs(live);
159 return (yield* hasAlchemyTags(id, live.tags))
160 ? attrs
161 : Unowned(attrs);
162 }),
163 list: () =>
164 inspector2.listCisScanConfigurations
165 .items({})
166 .pipe(Stream.map(buildAttrs), Stream.runCollect)
167 .pipe(Effect.map((c) => Array.from(c))),
168 reconcile: Effect.fn(function* ({ id, news, output, session }) {
169 const scanName = yield* toName(id, news);
170 const internalTags = yield* createInternalTags(id);
171 const desiredTags = { ...news.tags, ...internalTags };
172
173 // 1. OBSERVE — cloud state is authoritative. The ARN survives
174 // renames; fall back to a name lookup otherwise.
175 let live = output?.scanConfigurationArn
176 ? yield* findByArn(output.scanConfigurationArn)
177 : yield* findByName(scanName);
178
179 // 2. ENSURE — create when missing.
180 if (!live) {
181 const { scanConfigurationArn } =
182 yield* inspector2.createCisScanConfiguration({
183 scanName,
184 securityLevel: news.securityLevel,
185 schedule: news.schedule,
186 targets: news.targets,
187 tags: desiredTags,
188 });
189 live = scanConfigurationArn
190 ? yield* findByArn(scanConfigurationArn)
191 : yield* findByName(scanName);
192 if (!live) {
193 return yield* Effect.die(
194 new Error(
195 `Inspector2 CIS scan configuration ${scanName} not visible after create`,
196 ),
197 );
198 }
199 }
200
201 // 3. SYNC settings — observed ↔ desired.
202 const drift =
203 live.scanName !== scanName ||
204 live.securityLevel !== news.securityLevel ||
205 JSON.stringify(live.schedule) !== JSON.stringify(news.schedule) ||
206 JSON.stringify(live.targets?.accountIds) !==
207 JSON.stringify(news.targets.accountIds) ||
208 JSON.stringify(live.targets?.targetResourceTags) !==
209 JSON.stringify(news.targets.targetResourceTags);
210 if (drift) {
211 yield* inspector2.updateCisScanConfiguration({
212 scanConfigurationArn: live.scanConfigurationArn,
213 scanName,
214 securityLevel: news.securityLevel,
215 schedule: news.schedule,
216 targets: news.targets,
217 });
218 }
219
220 // 3b. SYNC tags — diff against OBSERVED cloud tags so adoption
221 // converges foreign tags too.
222 const { upsert, removed } = diffTags(
223 tagRecord(live.tags),
224 desiredTags,
225 );
226 if (upsert.length > 0) {
227 yield* inspector2.tagResource({
228 resourceArn: live.scanConfigurationArn,
229 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
230 });
231 }
232 if (removed.length > 0) {
233 yield* inspector2.untagResource({
234 resourceArn: live.scanConfigurationArn,
235 tagKeys: removed,
236 });
237 }
238
239 // 4. RETURN fresh attributes.
240 const final = yield* findByArn(live.scanConfigurationArn);
241 yield* session.note(live.scanConfigurationArn);
242 return buildAttrs(final ?? live);
243 }),
244 delete: Effect.fn(function* ({ output }) {
245 // Idempotent — the configuration may already be gone.
246 yield* inspector2
247 .deleteCisScanConfiguration({
248 scanConfigurationArn: output.scanConfigurationArn,
249 })
250 .pipe(
251 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
252 );
253 }),
254 };
255 }),
256 );