1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { GcpEnvironment } from "../Environment.ts";
7import {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13export const MAX_ID_LENGTH = 63;
14export const MAX_DESCRIPTION_LENGTH = 1024;
15export const MAX_DISPLAY_NAME_LENGTH = 128;
16
17export class SecuritycenterNotResolved extends Data.TaggedError(
18 "GCP.SecurityCenter.NotResolved",
19)<{
20 name: string;
21}> {}
22
23export class OrganizationNotResolved extends Data.TaggedError(
24 "GCP.SecurityCenter.OrganizationNotResolved",
25)<{
26 project: string;
27}> {}
28
29export class FolderNotResolved extends Data.TaggedError(
30 "GCP.SecurityCenter.FolderNotResolved",
31)<{
32 project: string;
33}> {}
34
35export const lastSegment = (value: string) => {
36 const trimmed = value.replace(/\/+$/, "");
37 const parts = trimmed.split("/");
38 return parts[parts.length - 1] || trimmed;
39};
40
41export const projectOf = (name: string) => {
42 const parts = name.split("/");
43 const index = parts.indexOf("projects");
44 return index >= 0 ? (parts[index + 1] ?? "") : "";
45};
46
47export const organizationOf = (name: string) => {
48 const parts = name.split("/");
49 const index = parts.indexOf("organizations");
50 return index >= 0 ? (parts[index + 1] ?? "") : "";
51};
52
53export const organizationParent = (value: string) =>
54 value.startsWith("organizations/")
55 ? value
56 : `organizations/${lastSegment(value)}`;
57
58export const organizationIdOf = (value: string) => lastSegment(value);
59
60export const folderParent = (value: string) =>
61 value.startsWith("folders/") ? value : `folders/${lastSegment(value)}`;
62
63export const folderIdOf = (value: string) => lastSegment(value);
64
65export const parseName = (name: string, collection: string) => {
66 const parts = name.split("/").filter((part) => part.length > 0);
67 const collectionAt = parts.lastIndexOf(collection);
68 const locationsAt = parts.lastIndexOf("locations");
69 const projectsAt = parts.lastIndexOf("projects");
70 const orgsAt = parts.lastIndexOf("organizations");
71 const foldersAt = parts.lastIndexOf("folders");
72 return {
73 project:
74 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
75 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
76 folder: foldersAt >= 0 && parts[foldersAt + 1] ? parts[foldersAt + 1]! : "",
77 location:
78 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
79 id:
80 collectionAt >= 0 && parts[collectionAt + 1]
81 ? parts[collectionAt + 1]!
82 : lastSegment(name),
83 parent:
84 collectionAt > 0
85 ? parts.slice(0, collectionAt).join("/")
86 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
87 };
88};
89
90export const DEFAULT_MUTE_TYPE = "STATIC";
91export const DEFAULT_ENABLEMENT = "ENABLED";
92export const DEFAULT_CLOUD_PROVIDER = "GOOGLE_CLOUD_PLATFORM";
93export const DEFAULT_ETD_TYPE = "CONFIGURABLE_BAD_IP";
94
95export const defaultEtdConfig = {
96 metadata: {
97 severity: "LOW",
98 description: "alchemy custom module",
99 recommendation: "n/a",
100 },
101 ips: ["192.0.2.1"],
102};
103
104export const defaultShaCustomConfig = {
105 predicate: { expression: 'resource.name == "alchemy-nonexistent"' },
106 resourceSelector: {
107 resourceTypes: ["compute.googleapis.com/Instance"],
108 },
109 severity: "LOW",
110 description: "alchemy custom module",
111 recommendation: "n/a",
112};
113
114export const shaSettingsParent = (parent: string) =>
115 `${parent}/securityHealthAnalyticsSettings`;
116
117export const etdSettingsParent = (parent: string) =>
118 `${parent}/eventThreatDetectionSettings`;
119
120export const sameText = (left: string | undefined, right: string | undefined) =>
121 (left ?? "") === (right ?? "");
122
123export const jsonEqual = (left: unknown, right: unknown) =>
124 JSON.stringify(canonical(left) ?? null) ===
125 JSON.stringify(canonical(right) ?? null);
126
127export const updateMaskOf = (...fields: Array<string | undefined>) =>
128 fields.filter((field): field is string => field !== undefined).join(",");
129
130export const toResourceId = (
131 id: string,
132 requested: string | undefined,
133 existing: string | undefined,
134 prefix = "s",
135 maxLength = MAX_ID_LENGTH,
136) =>
137 Effect.gen(function* () {
138 if (requested !== undefined) return requested;
139 if (existing !== undefined) return existing;
140 const generated = yield* createPhysicalName({
141 id,
142 maxLength,
143 lowercase: true,
144 });
145 const next = /^[a-z]/.test(generated) ? generated : `${prefix}${generated}`;
146 return next.replace(/[^a-z0-9-]/g, "-").slice(0, maxLength);
147 });
148
149export const toPhysicalId = (
150 id: string,
151 explicit: string | undefined,
152 existing: string | undefined,
153) => toResourceId(id, explicit, existing);
154
155export const shaDisplayNameOf = (physical: string) => {
156 const cleaned = physical
157 .replace(/[^a-zA-Z0-9_]/g, "_")
158 .replace(/_+/g, "_")
159 .replace(/^_+|_+$/g, "");
160 const withLetter = /^[A-Z]/.test(cleaned)
161 ? cleaned
162 : `A${cleaned}`.replace(/_+/g, "_");
163 return withLetter.slice(0, MAX_DISPLAY_NAME_LENGTH);
164};
165
166export const etdDisplayNameOf = (physical: string) => {
167 const cleaned = physical
168 .replace(/[^a-zA-Z0-9_]/g, "_")
169 .replace(/_+/g, "_")
170 .replace(/^_+|_+$/g, "");
171 const withLetter = /^[A-Za-z]/.test(cleaned) ? cleaned : `e${cleaned}`;
172 return withLetter.slice(0, MAX_DISPLAY_NAME_LENGTH);
173};
174
175export const toShaDisplayName = (
176 id: string,
177 requested: string | undefined,
178 existing: string | undefined,
179) =>
180 Effect.gen(function* () {
181 if (requested !== undefined) return requested;
182 if (existing !== undefined) return existing;
183 const physical = yield* createPhysicalName({
184 id,
185 maxLength: 63,
186 lowercase: true,
187 });
188 return shaDisplayNameOf(physical);
189 });
190
191const markerOf = (stack: string, stage: string, id: string) =>
192 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
193
194const fitMarker = (labels: Record<string, string>, maxLength: number) => {
195 let stack = labels[alchemyLabelKeys.stack] ?? "x";
196 let stage = labels[alchemyLabelKeys.stage] ?? "x";
197 let id = labels[alchemyLabelKeys.id] ?? "x";
198 let marker = markerOf(stack, stage, id);
199 while (
200 marker.length > maxLength &&
201 (stack.length > 1 || stage.length > 1 || id.length > 1)
202 ) {
203 if (stack.length >= stage.length && stack.length >= id.length) {
204 stack = stack.slice(0, -1);
205 } else if (stage.length >= id.length) {
206 stage = stage.slice(0, -1);
207 } else {
208 id = id.slice(0, -1);
209 }
210 marker = markerOf(stack, stage, id);
211 }
212 return marker.slice(0, maxLength);
213};
214
215export const encodeOwnership = (
216 labels: Record<string, string>,
217 text: string | undefined,
218 maxLength = MAX_DESCRIPTION_LENGTH,
219): string => {
220 const marker = fitMarker(labels, maxLength);
221 const trimmed = text?.trim();
222 if (!trimmed) return marker.slice(0, maxLength);
223 return `${marker}\n${trimmed}`.slice(0, maxLength);
224};
225
226export const encodeDescription = encodeOwnership;
227
228export const parseOwnership = (
229 text: string | undefined,
230): {
231 labels: Record<string, string>;
232 text: string | undefined;
233} => {
234 if (!text?.startsWith("[alchemy ")) {
235 return { labels: {}, text };
236 }
237 const end = text.indexOf("]");
238 if (end < 0) return { labels: {}, text };
239 const labels: Record<string, string> = {};
240 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
241 const eq = part.indexOf("=");
242 if (eq > 0) {
243 labels[part.slice(0, eq)] = part.slice(eq + 1);
244 }
245 }
246 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
247 return { labels, text: rest.length > 0 ? rest : undefined };
248};
249
250export const hasOwnershipMarker = (text: string | undefined) =>
251 Object.keys(parseOwnership(text).labels).some((key) =>
252 key.startsWith("alchemy-"),
253 );
254
255const prefixMatch = (expected: string, observed: string) =>
256 expected === observed ||
257 expected.startsWith(observed) ||
258 observed.startsWith(expected);
259
260export const ownedByAlchemy = (id: string, text: string | undefined) =>
261 Effect.gen(function* () {
262 const expected = yield* createInternalLabels(id);
263 const { labels } = parseOwnership(text);
264 if (!hasOwnershipMarker(text)) return false;
265 const exact = yield* hasAlchemyLabels(id, labels);
266 if (exact) return true;
267 return (
268 prefixMatch(
269 expected[alchemyLabelKeys.stack] ?? "",
270 labels[alchemyLabelKeys.stack] ?? "",
271 ) &&
272 prefixMatch(
273 expected[alchemyLabelKeys.stage] ?? "",
274 labels[alchemyLabelKeys.stage] ?? "",
275 ) &&
276 prefixMatch(
277 expected[alchemyLabelKeys.id] ?? "",
278 labels[alchemyLabelKeys.id] ?? "",
279 )
280 );
281 });
282
283export const collectPages = <Page, A, E, R>(
284 pages: Stream.Stream<Page, E, R>,
285 items: (page: Page) => readonly A[] | undefined,
286) =>
287 pages.pipe(
288 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
289 Stream.runCollect,
290 Effect.map((chunk) => Array.from(chunk)),
291 );
292
293export const findOwned = <A>(
294 items: readonly A[],
295 textOf: (item: A) => string | undefined,
296 id: string,
297) =>
298 Effect.gen(function* () {
299 for (const item of items) {
300 if (yield* ownedByAlchemy(id, textOf(item))) return item;
301 }
302 return undefined;
303 });
304
305export const canonical = (value: unknown): unknown => {
306 if (value === undefined || value === null) return undefined;
307 if (typeof value === "boolean" || typeof value === "number") return value;
308 if (typeof value === "string") {
309 return value.length === 0 ? undefined : value;
310 }
311 if (Array.isArray(value)) {
312 const items = value.map(canonical).filter((item) => item !== undefined);
313 return items.length === 0 ? undefined : items;
314 }
315 if (typeof value === "object") {
316 const entries = Object.entries(value as Record<string, unknown>)
317 .map(([key, item]) => [key, canonical(item)] as const)
318 .filter(([, item]) => item !== undefined)
319 .sort(([left], [right]) => left.localeCompare(right));
320 if (entries.length === 0) return undefined;
321 return Object.fromEntries(entries);
322 }
323 return undefined;
324};
325
326export const fingerprint = (value: unknown): string =>
327 JSON.stringify(canonical(value) ?? null);
328
329const parentOf = (name: string) =>
330 name.startsWith("projects/")
331 ? resourcemanager.getProjects({ name }).pipe(
332 Effect.map((resource) => resource.parent),
333 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
334 )
335 : name.startsWith("folders/")
336 ? resourcemanager.getFolders({ name }).pipe(
337 Effect.map((folder) => folder.parent),
338 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
339 )
340 : Effect.succeed(undefined);
341
342export const tryResolveOrganization = () =>
343 Effect.gen(function* () {
344 const env = yield* GcpEnvironment.current;
345 let current: string | undefined = `projects/${env.project}`;
346 for (let i = 0; i < 8; i++) {
347 if (current === undefined) return undefined;
348 if (current.startsWith("organizations/")) return current;
349 current = yield* parentOf(current);
350 }
351 return undefined;
352 });
353
354export const resolveOrganization = (
355 explicit: string | undefined,
356 existing: string | undefined,
357) =>
358 Effect.gen(function* () {
359 if (explicit !== undefined) return organizationParent(explicit);
360 if (existing !== undefined) return organizationParent(existing);
361 const resolved = yield* tryResolveOrganization();
362 if (resolved === undefined) {
363 const env = yield* GcpEnvironment.current;
364 return yield* new OrganizationNotResolved({ project: env.project });
365 }
366 return resolved;
367 });
368
369export const tryResolveFolder = () =>
370 Effect.gen(function* () {
371 const env = yield* GcpEnvironment.current;
372 let current: string | undefined = `projects/${env.project}`;
373 for (let i = 0; i < 8; i++) {
374 if (current === undefined) return undefined;
375 if (current.startsWith("folders/")) return current;
376 if (current.startsWith("organizations/")) return undefined;
377 current = yield* parentOf(current);
378 }
379 return undefined;
380 });
381
382export const resolveFolder = (
383 explicit: string | undefined,
384 existing: string | undefined,
385) =>
386 Effect.gen(function* () {
387 if (explicit !== undefined) return folderParent(explicit);
388 if (existing !== undefined) return folderParent(existing);
389 const resolved = yield* tryResolveFolder();
390 if (resolved === undefined) {
391 const env = yield* GcpEnvironment.current;
392 return yield* new FolderNotResolved({ project: env.project });
393 }
394 return resolved;
395 });
396
397export const replaceOnIdentity = (changed: boolean) =>
398 changed
399 ? ({ action: "replace" as const, deleteFirst: false } as const)
400 : undefined;
401
402export const replaceOn = (
403 previous: string | undefined,
404 next: string | undefined,
405) =>
406 replaceOnIdentity(
407 previous !== undefined && next !== undefined && previous !== next,
408 );