1import * as scc from "@distilled.cloud/gcp/securitycenter_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 collectPages,
14 encodeOwnership,
15 findOwned,
16 hasOwnershipMarker,
17 jsonEqual,
18 lastSegment,
19 ownedByAlchemy,
20 parseOwnership,
21 projectOf,
22 replaceOn,
23 sameText,
24 shaDisplayNameOf,
25 shaSettingsParent,
26 updateMaskOf,
27} from "./internal.ts";
28
29export type SecurityHealthAnalyticsEnablementState =
30 | "ENABLEMENT_STATE_UNSPECIFIED"
31 | "ENABLED"
32 | "DISABLED"
33 | "INHERITED";
34
35export type SecurityHealthAnalyticsCloudProvider =
36 | "CLOUD_PROVIDER_UNSPECIFIED"
37 | "GOOGLE_CLOUD_PLATFORM"
38 | "AMAZON_WEB_SERVICES"
39 | "MICROSOFT_AZURE";
40
41export type SecurityHealthAnalyticsSeverity =
42 | "SEVERITY_UNSPECIFIED"
43 | "CRITICAL"
44 | "HIGH"
45 | "MEDIUM"
46 | "LOW";
47
48export type Expr = {
49 /** CEL expression. */
50 expression?: string;
51 /** Short title. */
52 title?: string;
53 /** Human-readable description. */
54 description?: string;
55 /** Source location of the expression. */
56 location?: string;
57};
58
59export type CustomOutputProperty = {
60 /** Property name. */
61 name?: string;
62 /** CEL expression that produces the property value. */
63 valueExpression?: Expr;
64};
65
66export type CustomConfig = {
67 /** CEL predicate that selects resources. */
68 predicate?: Expr;
69 /** Resource types this module inspects. */
70 resourceSelector?: {
71 /** Fully-qualified resource type names. */
72 resourceTypes?: string[];
73 };
74 /** Finding severity. */
75 severity?: SecurityHealthAnalyticsSeverity;
76 /**
77 * Finding description. Alchemy ownership is stored in a `[alchemy …]`
78 * prefix here (custom modules have no labels) and stripped from
79 * attributes.
80 */
81 description?: string;
82 /** Recommended next steps. */
83 recommendation?: string;
84 /** Extra properties written onto findings. */
85 customOutput?: {
86 properties?: CustomOutputProperty[];
87 };
88};
89
90export type SecurityHealthAnalyticsSettingsCustomModuleProps = {
91 /**
92 * Module id (the `{customModule}` segment of
93 * `projects/{project}/securityHealthAnalyticsSettings/customModules/{customModule}`).
94 * Server-assigned on create. Supply it to target an existing module.
95 * Immutable — changing it replaces the module.
96 */
97 moduleId?: string;
98 /**
99 * Display name. Becomes the finding category. Must start with an
100 * uppercase letter, contain only letters, digits, or underscores, and
101 * be at most 128 characters. Immutable — changing it replaces the
102 * module.
103 */
104 displayName?: string;
105 /**
106 * Enablement state at this parent.
107 * @default "ENABLED"
108 */
109 enablementState?: SecurityHealthAnalyticsEnablementState;
110 /**
111 * Detection configuration. Required on create.
112 */
113 customConfig: CustomConfig;
114 /**
115 * Cloud provider this module applies to.
116 */
117 cloudProvider?: SecurityHealthAnalyticsCloudProvider;
118};
119
120export type SecurityHealthAnalyticsSettingsCustomModule = Resource<
121 "GCP.SecurityCenter.SecurityHealthAnalyticsSettingsCustomModule",
122 SecurityHealthAnalyticsSettingsCustomModuleProps,
123 {
124 /** Full resource name `projects/{project}/securityHealthAnalyticsSettings/customModules/{customModule}`. */
125 name: string;
126 /** Module id (last path segment). */
127 moduleId: string;
128 /** Project id. */
129 project: string;
130 /** Display name. */
131 displayName: string | undefined;
132 /** Enablement state. */
133 enablementState: string | undefined;
134 /** Detection configuration with the Alchemy ownership prefix stripped. */
135 customConfig: CustomConfig | undefined;
136 /** Cloud provider. */
137 cloudProvider: string | undefined;
138 /** Ancestor module this one inherits from, if any. */
139 ancestorModule: string | undefined;
140 /** RFC3339 last-update timestamp. */
141 updateTime: string | undefined;
142 /** Last editor email. */
143 lastEditor: string | undefined;
144 },
145 never,
146 Providers
147>;
148
149/**
150 * A project-scoped Security Health Analytics custom module.
151 *
152 * Custom modules have no labels field — Alchemy stamps ownership into
153 * `customConfig.description` so `list` / nuke can find them. Module id
154 * and display name are identity (the id is server-assigned). Enablement
155 * and custom config update in place.
156 *
157 * ### Creating a Custom Module
158 * **Example:** Always-true compute instance check
159 * ```typescript
160 * const module = yield* GCP.SecurityCenter.SecurityHealthAnalyticsSettingsCustomModule(
161 * "AlwaysTrue",
162 * {
163 * displayName: "AlchemyAlwaysTrue",
164 * customConfig: {
165 * predicate: { expression: 'resource.name == "alchemy-nonexistent"' },
166 * resourceSelector: {
167 * resourceTypes: ["compute.googleapis.com/Instance"],
168 * },
169 * severity: "LOW",
170 * description: "always true",
171 * recommendation: "n/a",
172 * },
173 * },
174 * );
175 * ```
176 *
177 * ### Updating a Custom Module
178 * **Example:** Disable the module
179 * ```typescript
180 * const module = yield* GCP.SecurityCenter.SecurityHealthAnalyticsSettingsCustomModule(
181 * "AlwaysTrue",
182 * {
183 * enablementState: "DISABLED",
184 * displayName: "AlchemyAlwaysTrue",
185 * customConfig: {
186 * predicate: { expression: 'resource.name == "alchemy-nonexistent"' },
187 * resourceSelector: {
188 * resourceTypes: ["compute.googleapis.com/Instance"],
189 * },
190 * severity: "LOW",
191 * },
192 * },
193 * );
194 * ```
195 *
196 * @resource
197 * @category SecurityCenter
198 */
199export const SecurityHealthAnalyticsSettingsCustomModule =
200 Resource<SecurityHealthAnalyticsSettingsCustomModule>(
201 "GCP.SecurityCenter.SecurityHealthAnalyticsSettingsCustomModule",
202 );
203
204export class SecurityHealthAnalyticsSettingsCustomModuleNotResolved extends Data.TaggedError(
205 "GCP.SecurityCenter.SecurityHealthAnalyticsSettingsCustomModuleNotResolved",
206)<{
207 name: string;
208}> {}
209
210const resourceName = (project: string, moduleId: string) =>
211 `projects/${project}/securityHealthAnalyticsSettings/customModules/${moduleId}`;
212
213const customConfigOf = (
214 config: scc.GoogleCloudSecuritycenterV1CustomConfig | undefined,
215 stripOwnership: boolean,
216): CustomConfig | undefined => {
217 if (config === undefined) return undefined;
218 const description = stripOwnership
219 ? parseOwnership(config.description).text
220 : config.description;
221 return {
222 predicate: config.predicate,
223 resourceSelector: config.resourceSelector,
224 severity: config.severity as SecurityHealthAnalyticsSeverity | undefined,
225 description,
226 recommendation: config.recommendation,
227 customOutput: config.customOutput,
228 };
229};
230
231const desiredCustomConfig = (
232 config: CustomConfig,
233 ownership: Record<string, string>,
234): scc.GoogleCloudSecuritycenterV1CustomConfig => ({
235 ...config,
236 description: encodeOwnership(ownership, config.description),
237});
238
239const toAttrs = (
240 module: scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule,
241 project: string,
242) => {
243 const name = module.name ?? "";
244 return {
245 name,
246 moduleId: lastSegment(name),
247 project: projectOf(name) || project,
248 displayName: module.displayName,
249 enablementState: module.enablementState,
250 customConfig: customConfigOf(module.customConfig, true),
251 cloudProvider: module.cloudProvider,
252 ancestorModule: module.ancestorModule,
253 updateTime: module.updateTime,
254 lastEditor: module.lastEditor,
255 };
256};
257
258const getByName = (name: string) =>
259 name.length === 0
260 ? Effect.succeed(undefined)
261 : scc
262 .getProjectsSecurityHealthAnalyticsSettingsCustomModules({ name })
263 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
264
265const listModules = (project: string) =>
266 collectPages(
267 scc.listProjectsSecurityHealthAnalyticsSettingsCustomModules.pages({
268 parent: shaSettingsParent(`projects/${project}`),
269 pageSize: 100,
270 }),
271 (page) => page.securityHealthAnalyticsCustomModules,
272 ).pipe(
273 Effect.catchTag("NotFound", () =>
274 Effect.succeed(
275 [] as scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule[],
276 ),
277 ),
278 );
279
280const observe = (project: string, id: string, name: string) =>
281 Effect.gen(function* () {
282 const existing = yield* getByName(name);
283 if (existing !== undefined) return existing;
284 const items = yield* listModules(project);
285 return yield* findOwned(
286 items,
287 (item) => item.customConfig?.description,
288 id,
289 );
290 });
291
292export const SecurityHealthAnalyticsSettingsCustomModuleProvider = () =>
293 Provider.succeed(SecurityHealthAnalyticsSettingsCustomModule, {
294 stables: ["name", "moduleId", "project", "displayName", "ancestorModule"],
295
296 diff: Effect.fn(function* ({ news, olds, output }) {
297 if (!isResolved(news)) return undefined;
298 return (
299 replaceOn(olds?.moduleId ?? output?.moduleId, news.moduleId) ??
300 replaceOn(olds?.displayName ?? output?.displayName, news.displayName)
301 );
302 }),
303
304 read: Effect.fn(function* ({ id, olds, output }) {
305 const env = yield* GcpEnvironment.current;
306 const moduleId = olds?.moduleId ?? output?.moduleId;
307 const name =
308 output?.name ?? (moduleId ? resourceName(env.project, moduleId) : "");
309 const existing = yield* observe(env.project, id, name);
310 if (existing === undefined) return undefined;
311 const attrs = toAttrs(existing, env.project);
312 return (yield* ownedByAlchemy(id, existing.customConfig?.description))
313 ? attrs
314 : Unowned(attrs);
315 }),
316
317 list: () =>
318 Effect.gen(function* () {
319 const env = yield* GcpEnvironment.current;
320 const items = yield* listModules(env.project);
321 return items
322 .filter((module) =>
323 hasOwnershipMarker(module.customConfig?.description),
324 )
325 .map((module) => toAttrs(module, env.project));
326 }),
327
328 reconcile: Effect.fn(function* ({ id, news, output }) {
329 const env = yield* GcpEnvironment.current;
330 const parent = shaSettingsParent(`projects/${env.project}`);
331 const moduleId = news.moduleId ?? output?.moduleId;
332 const name =
333 output?.name ?? (moduleId ? resourceName(env.project, moduleId) : "");
334 const ownership = yield* createInternalLabels(id);
335 const customConfig = desiredCustomConfig(news.customConfig, ownership);
336 const enablementState = news.enablementState ?? "ENABLED";
337 const displayName =
338 news.displayName ??
339 shaDisplayNameOf(
340 yield* createPhysicalName({
341 id,
342 maxLength: 63,
343 lowercase: true,
344 }),
345 );
346 const body: scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule =
347 {
348 displayName,
349 enablementState,
350 customConfig,
351 cloudProvider: news.cloudProvider,
352 };
353
354 let current = yield* observe(env.project, id, name);
355
356 if (current === undefined) {
357 current =
358 yield* scc.createProjectsSecurityHealthAnalyticsSettingsCustomModules(
359 {
360 parent,
361 body,
362 },
363 );
364 }
365
366 if (current === undefined) {
367 return yield* new SecurityHealthAnalyticsSettingsCustomModuleNotResolved(
368 {
369 name: name || parent,
370 },
371 );
372 }
373
374 const currentName = current.name ?? name;
375 const enablementChanged = !sameText(
376 current.enablementState,
377 enablementState,
378 );
379 const configChanged = !jsonEqual(
380 {
381 ...current.customConfig,
382 description: current.customConfig?.description,
383 },
384 customConfig,
385 );
386 const updateMask = updateMaskOf(
387 enablementChanged ? "enablement_state" : undefined,
388 configChanged ? "custom_config" : undefined,
389 );
390
391 if (updateMask.length > 0) {
392 current =
393 yield* scc.patchProjectsSecurityHealthAnalyticsSettingsCustomModules({
394 name: currentName,
395 updateMask,
396 body: {
397 enablementState,
398 customConfig,
399 },
400 });
401 }
402
403 return toAttrs(current, env.project);
404 }),
405
406 delete: Effect.fn(function* ({ output }) {
407 yield* scc
408 .deleteProjectsSecurityHealthAnalyticsSettingsCustomModules({
409 name: output.name,
410 })
411 .pipe(Effect.catchTag("NotFound", () => Effect.void));
412 }),
413 });