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 * 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 collectPages,
13 encodeOwnership,
14 hasOwnershipMarker,
15 lastSegment,
16 ownedByAlchemy,
17 parseOwnership,
18 projectOf,
19 replaceOn,
20 sameText,
21 toResourceId,
22 updateMaskOf,
23} from "./internal.ts";
24
25export type MuteConfigType =
26 | "MUTE_CONFIG_TYPE_UNSPECIFIED"
27 | "STATIC"
28 | "DYNAMIC";
29
30export type MuteConfigProps = {
31 /**
32 * Mute config id (the `{muteConfig}` segment of
33 * `projects/{project}/muteConfigs/{muteConfig}`). If omitted, a unique
34 * id is generated. Lowercase letters, digits, and hyphens; must start
35 * with a letter; max 63 characters. Immutable — changing it replaces
36 * the config.
37 */
38 muteConfigId?: string;
39 /**
40 * Finding filter that selects which findings are muted.
41 */
42 filter: string;
43 /**
44 * Human-readable description (max 1024 characters). Mute configs have
45 * no labels field, so Alchemy ownership is stored in a `[alchemy …]`
46 * prefix and stripped from attributes.
47 */
48 description?: string;
49 /**
50 * Mute config type. `STATIC` mutes matching findings immediately.
51 * `DYNAMIC` re-evaluates until `expiryTime`. Immutable — changing it
52 * replaces the config.
53 * @default "STATIC"
54 */
55 type?: MuteConfigType;
56 /**
57 * RFC3339 expiry for `DYNAMIC` mute configs.
58 */
59 expiryTime?: string;
60};
61
62export type MuteConfig = Resource<
63 "GCP.SecurityCenter.MuteConfig",
64 MuteConfigProps,
65 {
66 /** Full resource name `projects/{project}/muteConfigs/{muteConfig}`. */
67 name: string;
68 /** Mute config id (last path segment). */
69 muteConfigId: string;
70 /** Project id. */
71 project: string;
72 /** Finding filter. */
73 filter: string | undefined;
74 /** User description with the Alchemy ownership prefix stripped. */
75 description: string | undefined;
76 /** Mute config type. */
77 type: string | undefined;
78 /** RFC3339 expiry, if set. */
79 expiryTime: string | undefined;
80 /** RFC3339 creation timestamp. */
81 createTime: string | undefined;
82 /** RFC3339 last-update timestamp. */
83 updateTime: string | undefined;
84 /** Last editor email. */
85 mostRecentEditor: string | undefined;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * A project-scoped Security Command Center mute config that hides matching
93 * findings from the default view.
94 *
95 * Mute configs have no labels field — Alchemy stamps ownership into the
96 * description so `list` / nuke can find them. Id and type are identity.
97 * Filter, description, and expiry update in place.
98 *
99 * ### Creating a Mute Config
100 * **Example:** Mute low-severity findings
101 * ```typescript
102 * const mute = yield* GCP.SecurityCenter.MuteConfig("Low", {
103 * filter: 'severity="LOW"',
104 * description: "mute low severity",
105 * });
106 * ```
107 *
108 * ### Updating a Mute Config
109 * **Example:** Mute medium severity as well
110 * ```typescript
111 * const mute = yield* GCP.SecurityCenter.MuteConfig("Low", {
112 * filter: 'severity="LOW" OR severity="MEDIUM"',
113 * description: "mute low and medium",
114 * });
115 * ```
116 *
117 * @resource
118 * @category SecurityCenter
119 */
120export const MuteConfig = Resource<MuteConfig>("GCP.SecurityCenter.MuteConfig");
121
122export class MuteConfigNotResolved extends Data.TaggedError(
123 "GCP.SecurityCenter.MuteConfigNotResolved",
124)<{
125 name: string;
126}> {}
127
128const resourceName = (project: string, muteConfigId: string) =>
129 `projects/${project}/muteConfigs/${muteConfigId}`;
130
131const toAttrs = (
132 config: scc.GoogleCloudSecuritycenterV1MuteConfig,
133 project: string,
134) => {
135 const name = config.name ?? "";
136 const parsed = parseOwnership(config.description);
137 return {
138 name,
139 muteConfigId: lastSegment(name),
140 project: projectOf(name) || project,
141 filter: config.filter,
142 description: parsed.text,
143 type: config.type,
144 expiryTime: config.expiryTime,
145 createTime: config.createTime,
146 updateTime: config.updateTime,
147 mostRecentEditor: config.mostRecentEditor,
148 };
149};
150
151const getByName = (name: string) =>
152 name.length === 0
153 ? Effect.succeed(undefined)
154 : scc
155 .getProjectsMuteConfigs({ name })
156 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
157
158export const MuteConfigProvider = () =>
159 Provider.succeed(MuteConfig, {
160 stables: ["name", "muteConfigId", "project", "type", "createTime"],
161
162 diff: Effect.fn(function* ({ news, olds, output }) {
163 if (!isResolved(news)) return undefined;
164 return (
165 replaceOn(
166 olds?.muteConfigId ?? output?.muteConfigId,
167 news.muteConfigId,
168 ) ??
169 replaceOn(olds?.type ?? output?.type ?? "STATIC", news.type ?? "STATIC")
170 );
171 }),
172
173 read: Effect.fn(function* ({ id, olds, output }) {
174 const env = yield* GcpEnvironment.current;
175 const muteConfigId = yield* toResourceId(
176 id,
177 olds?.muteConfigId,
178 output?.muteConfigId,
179 "m",
180 );
181 const name = output?.name ?? resourceName(env.project, muteConfigId);
182 const existing = yield* getByName(name);
183 if (existing === undefined) return undefined;
184 const attrs = toAttrs(existing, env.project);
185 return (yield* ownedByAlchemy(id, existing.description))
186 ? attrs
187 : Unowned(attrs);
188 }),
189
190 list: () =>
191 Effect.gen(function* () {
192 const env = yield* GcpEnvironment.current;
193 const items = yield* collectPages(
194 scc.listProjectsMuteConfigs.pages({
195 parent: `projects/${env.project}`,
196 pageSize: 100,
197 }),
198 (page) => page.muteConfigs,
199 ).pipe(
200 Effect.catchTag("NotFound", () =>
201 Effect.succeed([] as scc.GoogleCloudSecuritycenterV1MuteConfig[]),
202 ),
203 );
204 return items
205 .filter((config) => hasOwnershipMarker(config.description))
206 .map((config) => toAttrs(config, env.project));
207 }),
208
209 reconcile: Effect.fn(function* ({ id, news, output }) {
210 const env = yield* GcpEnvironment.current;
211 const muteConfigId = yield* toResourceId(
212 id,
213 news.muteConfigId,
214 output?.muteConfigId,
215 "m",
216 );
217 const name = resourceName(env.project, muteConfigId);
218 const ownership = yield* createInternalLabels(id);
219 const description = encodeOwnership(ownership, news.description);
220 const type = news.type ?? "STATIC";
221 const body: scc.GoogleCloudSecuritycenterV1MuteConfig = {
222 filter: news.filter,
223 description,
224 type,
225 expiryTime: news.expiryTime,
226 };
227
228 let current = yield* getByName(output?.name ?? name);
229
230 if (current === undefined) {
231 const created = yield* scc
232 .createProjectsMuteConfigs({
233 parent: `projects/${env.project}`,
234 muteConfigId,
235 body,
236 })
237 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
238 current = created ?? undefined;
239 }
240
241 if (current === undefined) {
242 return yield* new MuteConfigNotResolved({ name });
243 }
244
245 const currentName = current.name ?? name;
246 const filterChanged = !sameText(current.filter, news.filter);
247 const descriptionChanged = !sameText(current.description, description);
248 const expiryChanged = !sameText(current.expiryTime, news.expiryTime);
249 const updateMask = updateMaskOf(
250 filterChanged ? "filter" : undefined,
251 descriptionChanged ? "description" : undefined,
252 expiryChanged ? "expiry_time" : undefined,
253 );
254
255 if (updateMask.length > 0) {
256 current = yield* scc.patchProjectsMuteConfigs({
257 name: currentName,
258 updateMask,
259 body: {
260 ...body,
261 name: currentName,
262 },
263 });
264 }
265
266 return toAttrs(current, env.project);
267 }),
268
269 delete: Effect.fn(function* ({ output }) {
270 yield* scc
271 .deleteProjectsMuteConfigs({ name: output.name })
272 .pipe(Effect.catchTag("NotFound", () => Effect.void));
273 }),
274 });