1import * as websecurityscanner from "@distilled.cloud/gcp/websecurityscanner_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 getScanConfig,
13 jsonEqual,
14 lastSegment,
15 sameText,
16 scanConfigNameOf,
17 sortedStrings,
18 stringList,
19 toUserDisplayName,
20 unspecified,
21 updateMaskOf,
22} from "./internal.ts";
23
24export type ScanConfigSchedule = {
25 /**
26 * RFC3339 time of the next run. The server refreshes this after each
27 * run. Omit to start the first run immediately when a schedule is set.
28 */
29 scheduleTime?: string;
30 /**
31 * Days between scheduled executions. Required when `schedule` is set.
32 */
33 intervalDurationDays: number;
34};
35
36export type ScanConfigGoogleAccount = {
37 /**
38 * Google account username used during the scan.
39 */
40 username: string;
41 /**
42 * Input-only Google account password. Stored encrypted by GCP and
43 * never returned.
44 */
45 password?: string;
46};
47
48export type ScanConfigCustomAccount = {
49 /**
50 * Custom account username.
51 */
52 username: string;
53 /**
54 * Input-only custom account password. Stored encrypted by GCP and
55 * never returned.
56 */
57 password?: string;
58 /**
59 * Login form URL of the target site.
60 */
61 loginUrl: string;
62};
63
64export type ScanConfigIapCredential = {
65 /**
66 * OAuth2 client id of the IAP-protected resource.
67 */
68 targetAudienceClientId: string;
69};
70
71export type ScanConfigAuthentication = {
72 /**
73 * Authenticate with a Google account.
74 */
75 googleAccount?: ScanConfigGoogleAccount;
76 /**
77 * Authenticate with a custom account.
78 */
79 customAccount?: ScanConfigCustomAccount;
80 /**
81 * Authenticate with Identity-Aware Proxy.
82 */
83 iapCredential?: ScanConfigIapCredential;
84};
85
86export type ScanConfigLatestRun = {
87 /** Scan run resource name. */
88 name?: string;
89 /** Execution state (`QUEUED`, `SCANNING`, `FINISHED`). */
90 executionState?: string;
91 /** Result state after finish (`SUCCESS`, `ERROR`, `KILLED`). */
92 resultState?: string;
93 /** RFC3339 start time. */
94 startTime?: string;
95 /** RFC3339 end time. */
96 endTime?: string;
97 /** Completion percent 0–100. */
98 progressPercent?: number;
99 /** URLs crawled so far. */
100 urlsCrawledCount?: string;
101 /** URLs tested so far. */
102 urlsTestedCount?: string;
103 /** Whether any vulnerabilities were found. */
104 hasVulnerabilities?: boolean;
105};
106
107export type ScanConfigProps = {
108 /**
109 * Server-assigned scan config id (the `{scanConfigId}` segment of
110 * `projects/{project}/scanConfigs/{scanConfigId}`). Immutable —
111 * changing it replaces the config.
112 */
113 scanConfigId?: string;
114 /**
115 * Human-readable display name. If omitted, a unique name is generated
116 * from the stack, stage, and logical id.
117 */
118 displayName?: string;
119 /**
120 * Starting URLs the scanner crawls from. Each URL must belong to this
121 * project (App Engine, a reserved Compute IP, Cloud Run, or Cloud
122 * Functions).
123 */
124 startingUrls: string[];
125 /**
126 * Maximum QPS while scanning. Valid range is 5–20. Unspecified or 0
127 * uses the API default of 15.
128 */
129 maxQps?: number;
130 /**
131 * User agent used while scanning (`CHROME_LINUX`, `CHROME_ANDROID`,
132 * `SAFARI_IPHONE`).
133 */
134 userAgent?: string;
135 /**
136 * URL patterns excluded from the crawl.
137 */
138 blacklistPatterns?: string[];
139 /**
140 * Recurring scan schedule. Omit to keep the config idle until a scan
141 * is started explicitly.
142 */
143 schedule?: ScanConfigSchedule;
144 /**
145 * Credentials used while scanning. Passwords are input-only.
146 */
147 authentication?: ScanConfigAuthentication;
148 /**
149 * Platforms to scan (`APP_ENGINE`, `COMPUTE`, `CLOUD_RUN`,
150 * `CLOUD_FUNCTIONS`). Empty uses `APP_ENGINE`.
151 */
152 targetPlatforms?: string[];
153 /**
154 * Export scan results to Security Command Center (`ENABLED` or
155 * `DISABLED`). Default `ENABLED`.
156 */
157 exportToSecurityCommandCenter?: string;
158 /**
159 * Scan risk level (`NORMAL` or `LOW`). Default `NORMAL`.
160 */
161 riskLevel?: string;
162 /**
163 * When true, the scanner uses static IP addresses.
164 */
165 staticIpScan?: boolean;
166 /**
167 * When true, keep scanning even if most responses are HTTP errors.
168 */
169 ignoreHttpStatusErrors?: boolean;
170};
171
172export type ScanConfig = Resource<
173 "GCP.WebSecurityScanner.ScanConfig",
174 ScanConfigProps,
175 {
176 /** Full resource name `projects/{project}/scanConfigs/{scanConfigId}`. */
177 name: string;
178 /** Server-assigned scan config id. */
179 scanConfigId: string;
180 /** Project id used when the config was reconciled. */
181 project: string;
182 /** Human-readable display name. */
183 displayName: string | undefined;
184 /** Starting URLs. */
185 startingUrls: string[];
186 /** Maximum QPS. */
187 maxQps: number | undefined;
188 /** User agent. */
189 userAgent: string | undefined;
190 /** Excluded URL patterns. */
191 blacklistPatterns: string[];
192 /** Recurring schedule, if set. */
193 schedule: ScanConfigSchedule | undefined;
194 /** Authentication with passwords omitted. */
195 authentication: ScanConfigAuthentication | undefined;
196 /** Target platforms. */
197 targetPlatforms: string[];
198 /** Security Command Center export setting. */
199 exportToSecurityCommandCenter: string | undefined;
200 /** Risk level. */
201 riskLevel: string | undefined;
202 /** Whether static IP scanning is enabled. */
203 staticIpScan: boolean | undefined;
204 /** Whether HTTP error codes are ignored. */
205 ignoreHttpStatusErrors: boolean | undefined;
206 /** Whether Web Security Scanner manages this config. */
207 managedScan: boolean | undefined;
208 /** Most recent scan run, if any. */
209 latestRun: ScanConfigLatestRun | undefined;
210 },
211 never,
212 Providers
213>;
214
215/**
216 * A Web Security Scanner scan configuration.
217 *
218 * Scan config ids are assigned by Google and scan configs have no labels
219 * field, so Alchemy tracks a config only through the name recorded in
220 * state; a config read without prior state is reported as unowned. Display
221 * name, starting
222 * URLs, QPS, user agent, blacklist, schedule, authentication, platforms,
223 * export, risk, and scan flags update in place. Changing `scanConfigId`
224 * replaces the config.
225 *
226 * Starting URLs must belong to the project — a reserved Compute IP,
227 * App Engine (`https://PROJECT.appspot.com`), Cloud Run, or Cloud
228 * Functions.
229 *
230 * ### Creating a ScanConfig
231 * **Example:** Scan a reserved Compute IP
232 * ```typescript
233 * const ip = yield* GCP.Compute.Address("Target", {
234 * region: "us-central1",
235 * });
236 * const scan = yield* GCP.WebSecurityScanner.ScanConfig("Site", {
237 * displayName: "public site",
238 * startingUrls: [Output.interpolate`http://${ip.address}`],
239 * targetPlatforms: ["COMPUTE"],
240 * exportToSecurityCommandCenter: "DISABLED",
241 * });
242 * ```
243 *
244 * **Example:** Low-risk scan with excluded paths
245 * ```typescript
246 * const scan = yield* GCP.WebSecurityScanner.ScanConfig("Site", {
247 * displayName: "public site",
248 * startingUrls: ["https://my-app-uc.a.run.app/"],
249 * targetPlatforms: ["CLOUD_RUN"],
250 * maxQps: 5,
251 * riskLevel: "LOW",
252 * userAgent: "CHROME_LINUX",
253 * blacklistPatterns: ["https://my-app-uc.a.run.app/logout"],
254 * ignoreHttpStatusErrors: true,
255 * exportToSecurityCommandCenter: "DISABLED",
256 * });
257 * ```
258 *
259 * ### Updating a ScanConfig
260 * **Example:** Raise QPS and change the display name
261 * ```typescript
262 * // Same logical id as before; only the changed props differ.
263 * const scan = yield* GCP.WebSecurityScanner.ScanConfig("Site", {
264 * displayName: "public site v2",
265 * startingUrls: [Output.interpolate`http://${ip.address}`],
266 * targetPlatforms: ["COMPUTE"],
267 * maxQps: 10,
268 * exportToSecurityCommandCenter: "DISABLED",
269 * });
270 * ```
271 *
272 * @resource
273 * @category WebSecurityScanner
274 */
275export const ScanConfig = Resource<ScanConfig>(
276 "GCP.WebSecurityScanner.ScanConfig",
277);
278
279export class ScanConfigNotResolved extends Data.TaggedError(
280 "GCP.WebSecurityScanner.ScanConfigNotResolved",
281)<{
282 name: string;
283}> {}
284
285const toSchedule = (
286 schedule: websecurityscanner.Schedule | ScanConfigSchedule | undefined,
287): ScanConfigSchedule | undefined => {
288 if (schedule === undefined) return undefined;
289 if (schedule.intervalDurationDays === undefined) return undefined;
290 return {
291 intervalDurationDays: schedule.intervalDurationDays,
292 scheduleTime: schedule.scheduleTime,
293 };
294};
295
296const toAuthentication = (
297 authentication: websecurityscanner.Authentication | undefined,
298): ScanConfigAuthentication | undefined => {
299 if (authentication === undefined) return undefined;
300 const google = authentication.googleAccount;
301 const custom = authentication.customAccount;
302 const iapClientId =
303 authentication.iapCredential?.iapTestServiceAccountInfo
304 ?.targetAudienceClientId;
305 const next: ScanConfigAuthentication = {};
306 if (google?.username !== undefined) {
307 next.googleAccount = { username: google.username };
308 }
309 if (custom?.username !== undefined || custom?.loginUrl !== undefined) {
310 next.customAccount = {
311 username: custom?.username ?? "",
312 loginUrl: custom?.loginUrl ?? "",
313 };
314 }
315 if (iapClientId !== undefined) {
316 next.iapCredential = { targetAudienceClientId: iapClientId };
317 }
318 return Object.keys(next).length > 0 ? next : undefined;
319};
320
321const authenticationFingerprint = (
322 authentication: ScanConfigAuthentication | undefined,
323) =>
324 JSON.stringify({
325 googleUsername: authentication?.googleAccount?.username ?? "",
326 customUsername: authentication?.customAccount?.username ?? "",
327 customLoginUrl: authentication?.customAccount?.loginUrl ?? "",
328 iapClientId: authentication?.iapCredential?.targetAudienceClientId ?? "",
329 });
330
331const toLatestRun = (
332 run: websecurityscanner.ScanRun | undefined,
333): ScanConfigLatestRun | undefined => {
334 if (run === undefined) return undefined;
335 return {
336 name: run.name,
337 executionState: run.executionState,
338 resultState: run.resultState,
339 startTime: run.startTime,
340 endTime: run.endTime,
341 progressPercent: run.progressPercent,
342 urlsCrawledCount: run.urlsCrawledCount,
343 urlsTestedCount: run.urlsTestedCount,
344 hasVulnerabilities: run.hasVulnerabilities,
345 };
346};
347
348const toAttrs = (config: websecurityscanner.ScanConfig, project: string) => {
349 const name = config.name ?? "";
350 return {
351 name,
352 scanConfigId: lastSegment(name),
353 project,
354 displayName: config.displayName,
355 startingUrls: stringList(config.startingUrls),
356 maxQps: config.maxQps,
357 userAgent: unspecified(config.userAgent) || undefined,
358 blacklistPatterns: stringList(config.blacklistPatterns),
359 schedule: toSchedule(config.schedule),
360 authentication: toAuthentication(config.authentication),
361 targetPlatforms: stringList(config.targetPlatforms).filter(
362 (platform) => unspecified(platform).length > 0,
363 ),
364 exportToSecurityCommandCenter:
365 unspecified(config.exportToSecurityCommandCenter) || undefined,
366 riskLevel: unspecified(config.riskLevel) || undefined,
367 staticIpScan: config.staticIpScan,
368 ignoreHttpStatusErrors: config.ignoreHttpStatusErrors,
369 managedScan: config.managedScan,
370 latestRun: toLatestRun(config.latestRun),
371 };
372};
373
374const toAuthBody = (
375 authentication: ScanConfigAuthentication | undefined,
376): websecurityscanner.Authentication | undefined => {
377 if (authentication === undefined) return undefined;
378 const body: websecurityscanner.Authentication = {};
379 if (authentication.googleAccount !== undefined) {
380 body.googleAccount = {
381 username: authentication.googleAccount.username,
382 password: authentication.googleAccount.password,
383 };
384 }
385 if (authentication.customAccount !== undefined) {
386 body.customAccount = {
387 username: authentication.customAccount.username,
388 password: authentication.customAccount.password,
389 loginUrl: authentication.customAccount.loginUrl,
390 };
391 }
392 if (authentication.iapCredential !== undefined) {
393 body.iapCredential = {
394 iapTestServiceAccountInfo: {
395 targetAudienceClientId:
396 authentication.iapCredential.targetAudienceClientId,
397 },
398 };
399 }
400 return Object.keys(body).length > 0 ? body : undefined;
401};
402
403const toBody = (
404 news: ScanConfigProps,
405 displayName: string,
406): websecurityscanner.ScanConfig => {
407 const body: websecurityscanner.ScanConfig = {
408 displayName,
409 startingUrls: news.startingUrls,
410 };
411 if (news.maxQps !== undefined) body.maxQps = news.maxQps;
412 if (news.userAgent !== undefined) body.userAgent = news.userAgent;
413 if (news.blacklistPatterns !== undefined) {
414 body.blacklistPatterns = news.blacklistPatterns;
415 }
416 if (news.schedule !== undefined) {
417 body.schedule = {
418 intervalDurationDays: news.schedule.intervalDurationDays,
419 scheduleTime: news.schedule.scheduleTime,
420 };
421 }
422 const authentication = toAuthBody(news.authentication);
423 if (authentication !== undefined) body.authentication = authentication;
424 if (news.targetPlatforms !== undefined) {
425 body.targetPlatforms = news.targetPlatforms;
426 }
427 if (news.exportToSecurityCommandCenter !== undefined) {
428 body.exportToSecurityCommandCenter = news.exportToSecurityCommandCenter;
429 }
430 if (news.riskLevel !== undefined) body.riskLevel = news.riskLevel;
431 if (news.staticIpScan !== undefined) body.staticIpScan = news.staticIpScan;
432 if (news.ignoreHttpStatusErrors !== undefined) {
433 body.ignoreHttpStatusErrors = news.ignoreHttpStatusErrors;
434 }
435 return body;
436};
437
438const lookupNameOf = (
439 project: string,
440 news: { scanConfigId?: string },
441 output: { name?: string; scanConfigId?: string } | undefined,
442) =>
443 output?.name ??
444 (news.scanConfigId !== undefined
445 ? scanConfigNameOf(project, news.scanConfigId)
446 : output?.scanConfigId !== undefined
447 ? scanConfigNameOf(project, output.scanConfigId)
448 : "");
449
450export const ScanConfigProvider = () =>
451 Provider.succeed(ScanConfig, {
452 stables: ["name", "scanConfigId", "project"],
453
454 diff: Effect.fn(function* ({ news, olds, output }) {
455 if (!isResolved(news)) return undefined;
456 const previousId = olds?.scanConfigId ?? output?.scanConfigId;
457 if (
458 news.scanConfigId !== undefined &&
459 previousId !== undefined &&
460 news.scanConfigId !== previousId
461 ) {
462 return { action: "replace" as const, deleteFirst: false };
463 }
464 return undefined;
465 }),
466
467 read: Effect.fn(function* ({ olds, output }) {
468 const env = yield* GcpEnvironment.current;
469 const existing = yield* getScanConfig(
470 lookupNameOf(env.project, olds ?? {}, output),
471 );
472 if (existing === undefined) return undefined;
473 const attrs = toAttrs(existing, env.project);
474 // No labels field: without prior state it may not be ours.
475 return output !== undefined ? attrs : Unowned(attrs);
476 }),
477
478 reconcile: Effect.fn(function* ({ id, news, output }) {
479 const env = yield* GcpEnvironment.current;
480 const lookupName = lookupNameOf(env.project, news, output);
481
482 let current = yield* getScanConfig(lookupName);
483 const displayName = yield* toUserDisplayName(
484 id,
485 news.displayName,
486 current?.displayName ?? output?.displayName,
487 );
488 const desired = toBody(news, displayName);
489
490 if (current === undefined) {
491 const created = yield* websecurityscanner
492 .createProjectsScanConfigs({
493 parent: `projects/${env.project}`,
494 body: desired,
495 })
496 .pipe(
497 Effect.catchTag("Conflict", () => getScanConfig(lookupName)),
498 // A just-reserved static IP can take up to a minute to be visible.
499 Effect.retry({
500 while: (e) => e._tag === "StartingUrlIpNotReserved",
501 schedule: Schedule.spaced("3 seconds"),
502 times: 20,
503 }),
504 );
505 current = created ?? undefined;
506 }
507
508 if (current === undefined) {
509 return yield* new ScanConfigNotResolved({
510 name: lookupName.length > 0 ? lookupName : displayName,
511 });
512 }
513
514 const name = current.name ?? lookupName;
515 const displayChanged = !sameText(current.displayName, displayName);
516 const startingChanged = !jsonEqual(
517 stringList(current.startingUrls),
518 news.startingUrls,
519 );
520 const maxQpsChanged =
521 news.maxQps !== undefined && (current.maxQps ?? 15) !== news.maxQps;
522 const userAgentChanged =
523 news.userAgent !== undefined &&
524 unspecified(current.userAgent) !== unspecified(news.userAgent);
525 const blacklistChanged =
526 news.blacklistPatterns !== undefined &&
527 !jsonEqual(
528 stringList(current.blacklistPatterns),
529 news.blacklistPatterns,
530 );
531 const scheduleChanged =
532 news.schedule !== undefined &&
533 (current.schedule?.intervalDurationDays ?? 0) !==
534 news.schedule.intervalDurationDays;
535 const authenticationChanged =
536 news.authentication !== undefined &&
537 authenticationFingerprint(toAuthentication(current.authentication)) !==
538 authenticationFingerprint(news.authentication);
539 const platformsChanged =
540 news.targetPlatforms !== undefined &&
541 !jsonEqual(
542 sortedStrings(
543 stringList(current.targetPlatforms).filter(
544 (platform) => unspecified(platform).length > 0,
545 ),
546 ),
547 sortedStrings(news.targetPlatforms),
548 );
549 const exportChanged =
550 news.exportToSecurityCommandCenter !== undefined &&
551 unspecified(current.exportToSecurityCommandCenter) !==
552 unspecified(news.exportToSecurityCommandCenter);
553 const riskChanged =
554 news.riskLevel !== undefined &&
555 unspecified(current.riskLevel) !== unspecified(news.riskLevel);
556 const staticIpChanged =
557 news.staticIpScan !== undefined &&
558 (current.staticIpScan === true) !== news.staticIpScan;
559 const ignoreHttpChanged =
560 news.ignoreHttpStatusErrors !== undefined &&
561 (current.ignoreHttpStatusErrors === true) !==
562 news.ignoreHttpStatusErrors;
563
564 const updateMask = updateMaskOf(
565 displayChanged ? "displayName" : undefined,
566 startingChanged ? "startingUrls" : undefined,
567 maxQpsChanged ? "maxQps" : undefined,
568 userAgentChanged ? "userAgent" : undefined,
569 blacklistChanged ? "blacklistPatterns" : undefined,
570 scheduleChanged ? "schedule" : undefined,
571 authenticationChanged ? "authentication" : undefined,
572 platformsChanged ? "targetPlatforms" : undefined,
573 exportChanged ? "exportToSecurityCommandCenter" : undefined,
574 riskChanged ? "riskLevel" : undefined,
575 staticIpChanged ? "staticIpScan" : undefined,
576 ignoreHttpChanged ? "ignoreHttpStatusErrors" : undefined,
577 );
578
579 if (updateMask.length > 0) {
580 current = yield* websecurityscanner.patchProjectsScanConfigs({
581 name,
582 updateMask,
583 body: { ...desired, name },
584 });
585 }
586
587 return toAttrs(current, env.project);
588 }),
589
590 delete: Effect.fn(function* ({ output }) {
591 if (output.name.length === 0) return;
592 yield* websecurityscanner
593 .deleteProjectsScanConfigs({ name: output.name })
594 .pipe(Effect.catchTag("NotFound", () => Effect.void));
595 }),
596 });