1import * as scc from "@distilled.cloud/gcp/securitycenter_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_CLOUD_PROVIDER,
13 DEFAULT_ENABLEMENT,
14 DEFAULT_ETD_TYPE,
15 defaultEtdConfig,
16 encodeDescription,
17 etdSettingsParent,
18 fingerprint,
19 folderIdOf,
20 folderParent,
21 hasOwnershipMarker,
22 lastSegment,
23 ownedByAlchemy,
24 parseName,
25 parseOwnership,
26 replaceOn,
27 resolveFolder,
28 sameText,
29 SecuritycenterNotResolved,
30 tryResolveFolder,
31 updateMaskOf,
32} from "./internal.ts";
33
34export type FolderEventThreatDetectionSettingsCustomModuleProps = {
35 /**
36 * Parent folder (`folders/{folder}` or the numeric id). Defaults to
37 * the project's Resource Manager folder ancestor.
38 * Immutable — changing it replaces the module.
39 */
40 folder?: string;
41 /**
42 * Custom module type (for example `CONFIGURABLE_BAD_IP`). Immutable —
43 * changing it replaces the module.
44 * @default "CONFIGURABLE_BAD_IP"
45 */
46 type?: string;
47 /**
48 * Module configuration matching `type`.
49 */
50 config?: scc.DocumentMap;
51 /**
52 * Enablement state.
53 * @default "ENABLED"
54 */
55 enablementState?:
56 | scc.EventThreatDetectionCustomModuleEnablementStateEnum
57 | (string & {});
58 /**
59 * Cloud provider this module applies to.
60 * @default "GOOGLE_CLOUD_PLATFORM"
61 */
62 cloudProvider?:
63 | scc.EventThreatDetectionCustomModuleCloudProviderEnum
64 | (string & {});
65 /**
66 * Human-readable display name.
67 */
68 displayName?: string;
69 /**
70 * Human-readable description. Event Threat Detection custom modules
71 * have no labels field, so Alchemy ownership is stored in a
72 * `[alchemy …]` prefix and stripped from attributes.
73 */
74 description?: string;
75};
76
77export type FolderEventThreatDetectionSettingsCustomModule = Resource<
78 "GCP.SecurityCenter.FolderEventThreatDetectionSettingsCustomModule",
79 FolderEventThreatDetectionSettingsCustomModuleProps,
80 {
81 /** Full resource name `{folder}/eventThreatDetectionSettings/customModules/{module}`. */
82 name: string;
83 /** Module id (last path segment; server assigned). */
84 moduleId: string;
85 /** Folder resource name. */
86 folder: string;
87 /** Folder id. */
88 folderId: string;
89 /** Project id of the deploying stack. */
90 project: string;
91 /** Custom module type. */
92 type: string | undefined;
93 /** Module configuration. */
94 config: scc.DocumentMap | undefined;
95 /** Enablement state. */
96 enablementState: string | undefined;
97 /** Cloud provider. */
98 cloudProvider: string | undefined;
99 /** Display name. */
100 displayName: string | undefined;
101 /** User description with the Alchemy ownership prefix stripped. */
102 description: string | undefined;
103 /** Ancestor module this one inherits from, if any. */
104 ancestorModule: string | undefined;
105 /** Last editor of the module. */
106 lastEditor: string | undefined;
107 /** RFC3339 last-update timestamp. */
108 updateTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * A folder-scoped Event Threat Detection custom module.
116 *
117 * Custom modules have no labels field — Alchemy stamps ownership into the
118 * description so `list` / nuke can find them. The module id is assigned by
119 * the API. Folder and type are identity. Config, enablement, display name,
120 * and description update in place.
121 *
122 * ### Creating a Custom Module
123 * **Example:** Flag a test IP
124 * ```typescript
125 * const module = yield* GCP.SecurityCenter.FolderEventThreatDetectionSettingsCustomModule(
126 * "BadIp",
127 * {
128 * type: "CONFIGURABLE_BAD_IP",
129 * config: {
130 * metadata: { severity: "LOW" },
131 * ips: ["192.0.2.1"],
132 * },
133 * description: "test bad ip",
134 * },
135 * );
136 * ```
137 *
138 * @resource
139 * @category SecurityCenter
140 */
141export const FolderEventThreatDetectionSettingsCustomModule =
142 Resource<FolderEventThreatDetectionSettingsCustomModule>(
143 "GCP.SecurityCenter.FolderEventThreatDetectionSettingsCustomModule",
144 );
145
146const toAttrs = (
147 module: scc.EventThreatDetectionCustomModule,
148 folder: string,
149 project: string,
150) => {
151 const name = module.name ?? "";
152 const parsed = parseName(name, "customModules");
153 const ownership = parseOwnership(module.description);
154 return {
155 name,
156 moduleId: parsed.id || lastSegment(name),
157 folder,
158 folderId: folderIdOf(folder),
159 project,
160 type: module.type,
161 config: module.config,
162 enablementState: module.enablementState,
163 cloudProvider: module.cloudProvider,
164 displayName: module.displayName,
165 description: ownership.text,
166 ancestorModule: module.ancestorModule,
167 lastEditor: module.lastEditor,
168 updateTime: module.updateTime,
169 };
170};
171
172const getByName = (name: string) =>
173 name.length === 0
174 ? Effect.succeed(undefined)
175 : scc
176 .getFoldersEventThreatDetectionSettingsCustomModules({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179const listAt = (parent: string) =>
180 scc.listFoldersEventThreatDetectionSettingsCustomModules
181 .pages({ parent, pageSize: 100 })
182 .pipe(
183 Stream.flatMap((page) =>
184 Stream.fromIterable(page.eventThreatDetectionCustomModules ?? []),
185 ),
186 Stream.runCollect,
187 Effect.map((chunk) => Array.from(chunk)),
188 Effect.catchTag("NotFound", () => Effect.succeed([])),
189 );
190
191const findOwned = (parent: string, id: string) =>
192 Effect.gen(function* () {
193 const modules = yield* listAt(parent);
194 for (const module of modules) {
195 if (yield* ownedByAlchemy(id, module.description)) return module;
196 }
197 return undefined;
198 });
199
200export const FolderEventThreatDetectionSettingsCustomModuleProvider = () =>
201 Provider.succeed(FolderEventThreatDetectionSettingsCustomModule, {
202 stables: ["name", "moduleId", "folder", "folderId", "project", "type"],
203
204 diff: Effect.fn(function* ({ news, olds, output }) {
205 if (!isResolved(news)) return undefined;
206 return (
207 replaceOn(
208 olds?.folder ?? output?.folder,
209 news.folder !== undefined ? folderParent(news.folder) : undefined,
210 ) ?? replaceOn(olds?.type ?? output?.type, news.type)
211 );
212 }),
213
214 read: Effect.fn(function* ({ id, olds, output }) {
215 const env = yield* GcpEnvironment.current;
216 const folder = yield* resolveFolder(
217 olds?.folder ?? output?.folder,
218 output?.folder,
219 );
220 let existing = yield* getByName(output?.name ?? "");
221 if (existing === undefined) {
222 existing = yield* findOwned(etdSettingsParent(folder), id);
223 }
224 if (existing === undefined) return undefined;
225 const attrs = toAttrs(existing, folder, env.project);
226 return (yield* ownedByAlchemy(id, existing.description))
227 ? attrs
228 : Unowned(attrs);
229 }),
230
231 list: () =>
232 Effect.gen(function* () {
233 const env = yield* GcpEnvironment.current;
234 const folder = yield* tryResolveFolder();
235 if (folder === undefined) return [];
236 const modules = yield* listAt(etdSettingsParent(folder));
237 return modules
238 .filter((module) => hasOwnershipMarker(module.description))
239 .map((module) => toAttrs(module, folder, env.project));
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const env = yield* GcpEnvironment.current;
244 const folder = yield* resolveFolder(news.folder, output?.folder);
245 const parent = etdSettingsParent(folder);
246 const ownership = yield* createInternalLabels(id);
247 const description = encodeDescription(ownership, news.description);
248 const type = news.type ?? DEFAULT_ETD_TYPE;
249 const config = news.config ?? defaultEtdConfig;
250 const enablementState = news.enablementState ?? DEFAULT_ENABLEMENT;
251 const cloudProvider = news.cloudProvider ?? DEFAULT_CLOUD_PROVIDER;
252 const displayName = news.displayName;
253
254 let current = yield* getByName(output?.name ?? "");
255 if (current === undefined) {
256 current = yield* findOwned(parent, id);
257 }
258
259 if (current === undefined) {
260 const created = yield* scc
261 .createFoldersEventThreatDetectionSettingsCustomModules({
262 parent,
263 body: {
264 type,
265 config,
266 enablementState,
267 cloudProvider,
268 displayName,
269 description,
270 },
271 })
272 .pipe(Effect.catchTag("Conflict", () => findOwned(parent, id)));
273 current = created ?? undefined;
274 }
275
276 if (current === undefined) {
277 return yield* new SecuritycenterNotResolved({
278 name: `${parent}/customModules`,
279 });
280 }
281
282 const currentName = current.name ?? "";
283 const updateMask = updateMaskOf(
284 fingerprint(current.config) !== fingerprint(config)
285 ? "config"
286 : undefined,
287 !sameText(current.enablementState, enablementState)
288 ? "enablementState"
289 : undefined,
290 !sameText(current.displayName, displayName) ? "displayName" : undefined,
291 !sameText(current.description, description) ? "description" : undefined,
292 );
293
294 if (updateMask.length > 0) {
295 current =
296 yield* scc.patchFoldersEventThreatDetectionSettingsCustomModules({
297 name: currentName,
298 updateMask,
299 body: { config, enablementState, displayName, description },
300 });
301 }
302
303 return toAttrs(current, folder, env.project);
304 }),
305
306 delete: Effect.fn(function* ({ output }) {
307 yield* scc
308 .deleteFoldersEventThreatDetectionSettingsCustomModules({
309 name: output.name,
310 })
311 .pipe(Effect.catchTag("NotFound", () => Effect.void));
312 }),
313 });