1import * as cloudforceOne from "@distilled.cloud/cloudflare/cloudforce-one";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.CloudforceOne.ScanConfig" as const;
13type TypeId = typeof TypeId;
14
15export type ScanConfigProps = {
16 /**
17 * IP addresses or CIDR blocks to scan. The maximum number of total IP
18 * addresses allowed is 5000.
19 */
20 ips: string[];
21 /**
22 * Number of days between each scan (`0` = one-off scan).
23 * @default 0
24 */
25 frequency?: number;
26 /**
27 * Ports to scan. Valid values are `"default"` (the 100 most commonly open
28 * ports), `"all"`, or a list of ports / port ranges (e.g. `["1-80", "443"]`).
29 * @default ["default"]
30 */
31 ports?: string[];
32};
33
34export type ScanConfigAttributes = {
35 /**
36 * Server-assigned scan config identifier (UUID).
37 */
38 configId: string;
39 /**
40 * The Cloudflare account the scan config belongs to.
41 */
42 accountId: string;
43 /**
44 * IP addresses or CIDR blocks being scanned.
45 */
46 ips: string[];
47 /**
48 * Number of days between each scan (`0` = one-off scan).
49 */
50 frequency: number;
51 /**
52 * Ports being scanned.
53 */
54 ports: string[];
55};
56
57export type ScanConfig = Resource<
58 TypeId,
59 ScanConfigProps,
60 ScanConfigAttributes,
61 never,
62 Providers
63>;
64
65/**
66 * A Cloudforce One attack-surface scan configuration.
67 *
68 * Cloudforce One (Cloudflare's threat-intelligence product) can periodically
69 * port-scan IP addresses you own to map your attack surface. A scan config
70 * declares which IPs to scan, on which ports, and how often. Scan results are
71 * read back via the scan-results API; the config itself is the only
72 * declarative piece.
73 *
74 * Requires the `cfone.port_scan` entitlement (Cloudforce One subscription) —
75 * accounts without it receive an `Unauthorized` error for every scan-config
76 * operation.
77 * ### Creating a Scan Config
78 * **Example:** One-off scan of a single address
79 * ```typescript
80 * const scan = yield* Cloudflare.CloudforceOne.ScanConfig("edge-scan", {
81 * ips: ["203.0.113.7/32"],
82 * frequency: 0,
83 * });
84 * ```
85 *
86 * **Example:** Weekly scan of a CIDR block on specific ports
87 * ```typescript
88 * const scan = yield* Cloudflare.CloudforceOne.ScanConfig("perimeter", {
89 * ips: ["203.0.113.0/24"],
90 * frequency: 7,
91 * ports: ["1-80", "443"],
92 * });
93 * ```
94 *
95 * ### Updating
96 * **Example:** Change the schedule and port list in place
97 * ```typescript
98 * const scan = yield* Cloudflare.CloudforceOne.ScanConfig("perimeter", {
99 * ips: ["203.0.113.0/24"],
100 * frequency: 30,
101 * ports: ["all"],
102 * });
103 * ```
104 *
105 * @see https://developers.cloudflare.com/security-center/intel-apis/attack-surface-scans/
106 *
107 * @resource
108 * @product Cloudforce One
109 * @category Observability & Analytics
110 */
111export const ScanConfig = Resource<ScanConfig>(TypeId);
112
113/**
114 * Returns true if the given value is a ScanConfig resource.
115 */
116export const isScanConfig = (value: unknown): value is ScanConfig =>
117 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
118
119export const ScanConfigProvider = () =>
120 Provider.succeed(ScanConfig, {
121 stables: ["configId", "accountId"],
122 diff: Effect.fn(function* ({ news, output }) {
123 const { accountId } = yield* yield* CloudflareEnvironment;
124 if (!isResolved(news)) return undefined;
125 // Scan configs are account-scoped; moving accounts is a replacement.
126 if ((output?.accountId ?? accountId) !== accountId) {
127 return { action: "replace" } as const;
128 }
129 // ips / frequency / ports are all mutable via PATCH.
130 return undefined;
131 }),
132 read: Effect.fn(function* ({ output }) {
133 // There is no get-by-id endpoint and configs carry no name, so a cold
134 // read (lost state, no cached configId) has no identity to match — we
135 // can only observe through the cached configId.
136 if (!output?.configId) return undefined;
137 const acct = output.accountId;
138 const observed = yield* findConfig(acct, output.configId);
139 return observed ? toAttributes(observed, acct) : undefined;
140 }),
141 list: Effect.fn(function* () {
142 const { accountId } = yield* yield* CloudflareEnvironment;
143 return yield* cloudforceOne.listScanConfigs.pages({ accountId }).pipe(
144 Stream.runCollect,
145 Effect.map((chunk) =>
146 Array.from(chunk).flatMap((page) =>
147 (page.result ?? []).map((c) => toAttributes(c, accountId)),
148 ),
149 ),
150 // Accounts without the cfone.port_scan entitlement reject every
151 // scan-config call with the typed Unauthorized — treat as "none".
152 Effect.catchTag("Unauthorized", () =>
153 Effect.succeed<ScanConfigAttributes[]>([]),
154 ),
155 );
156 }),
157 reconcile: Effect.fn(function* ({ news, output }) {
158 const { accountId } = yield* yield* CloudflareEnvironment;
159
160 // Observe — the configId cached on `output` is a hint, not a
161 // guarantee: missing from the list falls through to create.
162 const observed = output?.configId
163 ? yield* findConfig(output.accountId ?? accountId, output.configId)
164 : undefined;
165
166 if (!observed) {
167 // Ensure — greenfield (or out-of-band delete).
168 const created = yield* cloudforceOne.createScanConfig({
169 accountId,
170 ips: news.ips,
171 frequency: news.frequency,
172 ports: news.ports,
173 });
174 return toAttributes(created, accountId);
175 }
176
177 // Sync — diff observed cloud state against desired; PATCH accepts
178 // partial bodies, so send only what the user pinned, and skip the
179 // call entirely on a no-op.
180 const dirty =
181 !sameList(observed.ips, news.ips) ||
182 (news.frequency !== undefined &&
183 observed.frequency !== news.frequency) ||
184 (news.ports !== undefined && !sameList(observed.ports, news.ports));
185
186 if (!dirty) {
187 return toAttributes(observed, observed.accountId);
188 }
189
190 const updated = yield* cloudforceOne
191 .patchScanConfig({
192 accountId: observed.accountId,
193 configId: observed.id,
194 ips: news.ips,
195 frequency: news.frequency,
196 ports: news.ports,
197 })
198 .pipe(
199 // Deleted out-of-band between observe and patch — recreate.
200 Effect.catchTag("ScanConfigNotFound", () =>
201 cloudforceOne.createScanConfig({
202 accountId,
203 ips: news.ips,
204 frequency: news.frequency,
205 ports: news.ports,
206 }),
207 ),
208 );
209 return toAttributes(updated, observed.accountId);
210 }),
211 delete: Effect.fn(function* ({ output }) {
212 yield* cloudforceOne
213 .deleteScanConfig({
214 accountId: output.accountId,
215 configId: output.configId,
216 })
217 .pipe(Effect.catchTag("ScanConfigNotFound", () => Effect.void));
218 }),
219 });
220
221type ObservedScanConfig = {
222 id: string;
223 accountId: string;
224 frequency: number;
225 ips: readonly string[];
226 ports: readonly string[];
227};
228
229/**
230 * Find a scan config by id. There is no get-by-id endpoint, so observe via
231 * the account-level list; absent from the list means gone.
232 */
233const findConfig = (accountId: string, configId: string) =>
234 cloudforceOne
235 .listScanConfigs({ accountId })
236 .pipe(
237 Effect.map((page): ObservedScanConfig | undefined =>
238 page.result.find((c) => c.id === configId),
239 ),
240 );
241
242const sameList = (observed: readonly string[], desired: readonly string[]) =>
243 observed.length === desired.length &&
244 [...observed].sort().join(",") === [...desired].sort().join(",");
245
246const toAttributes = (
247 config: ObservedScanConfig,
248 accountId: string,
249): ScanConfigAttributes => ({
250 configId: config.id,
251 accountId,
252 ips: [...config.ips],
253 frequency: config.frequency,
254 ports: [...config.ports],
255});