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 etdDisplayNameOf,
16 etdSettingsParent,
17 findOwned,
18 hasOwnershipMarker,
19 jsonEqual,
20 lastSegment,
21 ownedByAlchemy,
22 parseOwnership,
23 projectOf,
24 replaceOn,
25 sameText,
26 updateMaskOf,
27} from "./internal.ts";
28
29export type EventThreatDetectionEnablementState =
30 | "ENABLEMENT_STATE_UNSPECIFIED"
31 | "ENABLED"
32 | "DISABLED"
33 | "INHERITED";
34
35export type EventThreatDetectionCloudProvider =
36 | "CLOUD_PROVIDER_UNSPECIFIED"
37 | "GOOGLE_CLOUD_PLATFORM"
38 | "AMAZON_WEB_SERVICES"
39 | "MICROSOFT_AZURE";
40
41export type EventThreatDetectionConfig = scc.DocumentMap;
42
43export type EventThreatDetectionSettingsCustomModuleProps = {
44 /**
45 * Module id (the `{module}` segment of
46 * `projects/{project}/eventThreatDetectionSettings/customModules/{module}`).
47 * Server-assigned on create. Supply it to target an existing module.
48 * Immutable — changing it replaces the module.
49 */
50 moduleId?: string;
51 /**
52 * Module type, e.g. `CONFIGURABLE_BAD_IP`. Immutable — changing it
53 * replaces the module.
54 */
55 type: string;
56 /**
57 * Display name (letters, digits, underscores; max 128 characters).
58 */
59 displayName?: string;
60 /**
61 * Human-readable description. Custom modules have no labels field, so
62 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
63 * from attributes.
64 */
65 description?: string;
66 /**
67 * Enablement state at this parent.
68 * @default "ENABLED"
69 */
70 enablementState?: EventThreatDetectionEnablementState;
71 /**
72 * Module configuration (template-specific JSON).
73 */
74 config?: EventThreatDetectionConfig;
75 /**
76 * Cloud provider this module applies to.
77 */
78 cloudProvider?: EventThreatDetectionCloudProvider;
79};
80
81export type EventThreatDetectionSettingsCustomModule = Resource<
82 "GCP.SecurityCenter.EventThreatDetectionSettingsCustomModule",
83 EventThreatDetectionSettingsCustomModuleProps,
84 {
85 /** Full resource name `projects/{project}/eventThreatDetectionSettings/customModules/{module}`. */
86 name: string;
87 /** Module id (last path segment). */
88 moduleId: string;
89 /** Project id. */
90 project: string;
91 /** Module type. */
92 type: string | undefined;
93 /** Display name. */
94 displayName: string | undefined;
95 /** User description with the Alchemy ownership prefix stripped. */
96 description: string | undefined;
97 /** Enablement state. */
98 enablementState: string | undefined;
99 /** Module configuration. */
100 config: EventThreatDetectionConfig | undefined;
101 /** Cloud provider. */
102 cloudProvider: string | undefined;
103 /** Ancestor module this one inherits from, if any. */
104 ancestorModule: string | undefined;
105 /** RFC3339 last-update timestamp. */
106 updateTime: string | undefined;
107 /** Last editor email. */
108 lastEditor: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * A project-scoped Event Threat Detection custom module.
116 *
117 * Custom modules have no labels field — Alchemy stamps ownership into
118 * the description so `list` / nuke can find them. Module id and type are
119 * identity (the id is server-assigned). Display name, description,
120 * enablement, and config update in place.
121 *
122 * ### Creating a Custom Module
123 * **Example:** Flag connections to a test IP
124 * ```typescript
125 * const module = yield* GCP.SecurityCenter.EventThreatDetectionSettingsCustomModule(
126 * "BadIp",
127 * {
128 * type: "CONFIGURABLE_BAD_IP",
129 * displayName: "alchemy_bad_ip",
130 * description: "test bad ip",
131 * config: {
132 * metadata: {
133 * severity: "LOW",
134 * description: "test",
135 * recommendation: "investigate",
136 * },
137 * ips: ["192.0.2.1"],
138 * },
139 * },
140 * );
141 * ```
142 *
143 * ### Updating a Custom Module
144 * **Example:** Disable the module
145 * ```typescript
146 * const module = yield* GCP.SecurityCenter.EventThreatDetectionSettingsCustomModule(
147 * "BadIp",
148 * {
149 * type: "CONFIGURABLE_BAD_IP",
150 * enablementState: "DISABLED",
151 * },
152 * );
153 * ```
154 *
155 * @resource
156 * @category SecurityCenter
157 */
158export const EventThreatDetectionSettingsCustomModule =
159 Resource<EventThreatDetectionSettingsCustomModule>(
160 "GCP.SecurityCenter.EventThreatDetectionSettingsCustomModule",
161 );
162
163export class EventThreatDetectionSettingsCustomModuleNotResolved extends Data.TaggedError(
164 "GCP.SecurityCenter.EventThreatDetectionSettingsCustomModuleNotResolved",
165)<{
166 name: string;
167}> {}
168
169const resourceName = (project: string, moduleId: string) =>
170 `projects/${project}/eventThreatDetectionSettings/customModules/${moduleId}`;
171
172const toAttrs = (
173 module: scc.EventThreatDetectionCustomModule,
174 project: string,
175) => {
176 const name = module.name ?? "";
177 const parsed = parseOwnership(module.description);
178 return {
179 name,
180 moduleId: lastSegment(name),
181 project: projectOf(name) || project,
182 type: module.type,
183 displayName: module.displayName,
184 description: parsed.text,
185 enablementState: module.enablementState,
186 config: module.config,
187 cloudProvider: module.cloudProvider,
188 ancestorModule: module.ancestorModule,
189 updateTime: module.updateTime,
190 lastEditor: module.lastEditor,
191 };
192};
193
194const getByName = (name: string) =>
195 name.length === 0
196 ? Effect.succeed(undefined)
197 : scc
198 .getProjectsEventThreatDetectionSettingsCustomModules({ name })
199 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
200
201const listModules = (project: string) =>
202 collectPages(
203 scc.listProjectsEventThreatDetectionSettingsCustomModules.pages({
204 parent: etdSettingsParent(`projects/${project}`),
205 pageSize: 100,
206 }),
207 (page) => page.eventThreatDetectionCustomModules,
208 ).pipe(
209 Effect.catchTag("NotFound", () =>
210 Effect.succeed([] as scc.EventThreatDetectionCustomModule[]),
211 ),
212 );
213
214const observe = (project: string, id: string, name: string) =>
215 Effect.gen(function* () {
216 const existing = yield* getByName(name);
217 if (existing !== undefined) return existing;
218 const items = yield* listModules(project);
219 return yield* findOwned(items, (item) => item.description, id);
220 });
221
222export const EventThreatDetectionSettingsCustomModuleProvider = () =>
223 Provider.succeed(EventThreatDetectionSettingsCustomModule, {
224 stables: ["name", "moduleId", "project", "type", "ancestorModule"],
225
226 diff: Effect.fn(function* ({ news, olds, output }) {
227 if (!isResolved(news)) return undefined;
228 return (
229 replaceOn(olds?.moduleId ?? output?.moduleId, news.moduleId) ??
230 replaceOn(olds?.type ?? output?.type, news.type)
231 );
232 }),
233
234 read: Effect.fn(function* ({ id, olds, output }) {
235 const env = yield* GcpEnvironment.current;
236 const moduleId = olds?.moduleId ?? output?.moduleId;
237 const name =
238 output?.name ?? (moduleId ? resourceName(env.project, moduleId) : "");
239 const existing = yield* observe(env.project, id, name);
240 if (existing === undefined) return undefined;
241 const attrs = toAttrs(existing, env.project);
242 return (yield* ownedByAlchemy(id, existing.description))
243 ? attrs
244 : Unowned(attrs);
245 }),
246
247 list: () =>
248 Effect.gen(function* () {
249 const env = yield* GcpEnvironment.current;
250 const items = yield* listModules(env.project);
251 return items
252 .filter((module) => hasOwnershipMarker(module.description))
253 .map((module) => toAttrs(module, env.project));
254 }),
255
256 reconcile: Effect.fn(function* ({ id, news, output }) {
257 const env = yield* GcpEnvironment.current;
258 const parent = etdSettingsParent(`projects/${env.project}`);
259 const moduleId = news.moduleId ?? output?.moduleId;
260 const name =
261 output?.name ?? (moduleId ? resourceName(env.project, moduleId) : "");
262 const ownership = yield* createInternalLabels(id);
263 const description = encodeOwnership(ownership, news.description);
264 const enablementState = news.enablementState ?? "ENABLED";
265 const displayName =
266 news.displayName ??
267 etdDisplayNameOf(
268 yield* createPhysicalName({
269 id,
270 maxLength: 63,
271 lowercase: true,
272 }),
273 );
274 const body: scc.EventThreatDetectionCustomModule = {
275 type: news.type,
276 displayName,
277 description,
278 enablementState,
279 config: news.config,
280 cloudProvider: news.cloudProvider,
281 };
282
283 let current = yield* observe(env.project, id, name);
284
285 if (current === undefined) {
286 const created =
287 yield* scc.createProjectsEventThreatDetectionSettingsCustomModules({
288 parent,
289 body,
290 });
291 current = created;
292 }
293
294 if (current === undefined) {
295 return yield* new EventThreatDetectionSettingsCustomModuleNotResolved({
296 name: name || parent,
297 });
298 }
299
300 const currentName = current.name ?? name;
301 const displayChanged = !sameText(current.displayName, displayName);
302 const descriptionChanged = !sameText(current.description, description);
303 const enablementChanged = !sameText(
304 current.enablementState,
305 enablementState,
306 );
307 const configChanged = !jsonEqual(current.config, news.config);
308 const updateMask = updateMaskOf(
309 displayChanged ? "display_name" : undefined,
310 descriptionChanged ? "description" : undefined,
311 enablementChanged ? "enablement_state" : undefined,
312 configChanged ? "config" : undefined,
313 );
314
315 if (updateMask.length > 0) {
316 current =
317 yield* scc.patchProjectsEventThreatDetectionSettingsCustomModules({
318 name: currentName,
319 updateMask,
320 body: {
321 displayName,
322 description,
323 enablementState,
324 config: news.config,
325 },
326 });
327 }
328
329 return toAttrs(current, env.project);
330 }),
331
332 delete: Effect.fn(function* ({ output }) {
333 yield* scc
334 .deleteProjectsEventThreatDetectionSettingsCustomModules({
335 name: output.name,
336 })
337 .pipe(Effect.catchTag("NotFound", () => Effect.void));
338 }),
339 });