| 1 | import * as securityTxt from "@distilled.cloud/cloudflare/security-txt"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { listAllZones } from "../Zone/lookup.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.SecurityTxt.SecurityTxt" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | export type Props = { |
| 16 | /** |
| 17 | * Zone the security.txt file belongs to. Stable — changing the zone |
| 18 | * triggers a replacement (the old zone's security.txt is deleted and a |
| 19 | * new one is created on the new zone). |
| 20 | */ |
| 21 | zoneId: string; |
| 22 | /** |
| 23 | * Whether the security.txt file is served at |
| 24 | * `/.well-known/security.txt` on the zone. Mutable. |
| 25 | * |
| 26 | * @default true |
| 27 | */ |
| 28 | enabled?: boolean; |
| 29 | /** |
| 30 | * Contact channels for security researchers — `mailto:` addresses, |
| 31 | * `tel:` numbers, or HTTPS URLs (RFC 9116 `Contact` field). Required — |
| 32 | * Cloudflare rejects a security.txt without it. Mutable. |
| 33 | */ |
| 34 | contact: string[]; |
| 35 | /** |
| 36 | * RFC 3339 timestamp after which the file's content should be |
| 37 | * considered stale (RFC 9116 `Expires` field), e.g. |
| 38 | * `2027-01-01T00:00:00Z`. Required — Cloudflare rejects a security.txt |
| 39 | * without it. Mutable. |
| 40 | */ |
| 41 | expires: string; |
| 42 | /** |
| 43 | * URLs of pages crediting security researchers (RFC 9116 |
| 44 | * `Acknowledgments` field). Mutable. |
| 45 | */ |
| 46 | acknowledgments?: string[]; |
| 47 | /** |
| 48 | * Canonical URLs where this security.txt file is served (RFC 9116 |
| 49 | * `Canonical` field). Mutable. |
| 50 | */ |
| 51 | canonical?: string[]; |
| 52 | /** |
| 53 | * URLs of encryption keys for secure communication (RFC 9116 |
| 54 | * `Encryption` field). Mutable. |
| 55 | */ |
| 56 | encryption?: string[]; |
| 57 | /** |
| 58 | * URLs of security-related job openings (RFC 9116 `Hiring` field). |
| 59 | * Mutable. |
| 60 | */ |
| 61 | hiring?: string[]; |
| 62 | /** |
| 63 | * URLs of the vulnerability disclosure policy (RFC 9116 `Policy` |
| 64 | * field). Mutable. |
| 65 | */ |
| 66 | policy?: string[]; |
| 67 | /** |
| 68 | * Comma-separated list of language tags the security team prefers |
| 69 | * (RFC 9116 `Preferred-Languages` field), e.g. `"en, es"`. Mutable. |
| 70 | */ |
| 71 | preferredLanguages?: string; |
| 72 | }; |
| 73 | |
| 74 | export type Attributes = { |
| 75 | /** Zone the security.txt file belongs to. */ |
| 76 | zoneId: string; |
| 77 | /** Whether the file is served at `/.well-known/security.txt`. */ |
| 78 | enabled: boolean; |
| 79 | /** Contact channels for security researchers. */ |
| 80 | contact: string[]; |
| 81 | /** RFC 3339 timestamp after which the file is considered stale. */ |
| 82 | expires: string; |
| 83 | /** URLs of pages crediting security researchers. */ |
| 84 | acknowledgments: string[] | undefined; |
| 85 | /** Canonical URLs where this security.txt file is served. */ |
| 86 | canonical: string[] | undefined; |
| 87 | /** URLs of encryption keys for secure communication. */ |
| 88 | encryption: string[] | undefined; |
| 89 | /** URLs of security-related job openings. */ |
| 90 | hiring: string[] | undefined; |
| 91 | /** URLs of the vulnerability disclosure policy. */ |
| 92 | policy: string[] | undefined; |
| 93 | /** Comma-separated list of preferred language tags. */ |
| 94 | preferredLanguages: string | undefined; |
| 95 | }; |
| 96 | |
| 97 | export type SecurityTxt = Resource<TypeId, Props, Attributes, never, Providers>; |
| 98 | |
| 99 | /** |
| 100 | * A zone's `security.txt` file |
| 101 | * (`/zones/{zone_id}/security-center/securitytxt`), served by the |
| 102 | * Cloudflare edge at `https://<zone>/.well-known/security.txt` per |
| 103 | * RFC 9116 so security researchers know how to report vulnerabilities. |
| 104 | * |
| 105 | * The file is a singleton per zone with true create/delete semantics: |
| 106 | * creating the resource publishes the file, updating it is a full |
| 107 | * replace of every field, and destroying it removes the file from the |
| 108 | * zone entirely. |
| 109 | * |
| 110 | * Cloudflare requires the RFC 9116 mandatory fields — `contact` and |
| 111 | * `expires` — on every write. |
| 112 | * ### Publishing a security.txt |
| 113 | * **Example:** Minimal security.txt |
| 114 | * ```typescript |
| 115 | * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" }); |
| 116 | * |
| 117 | * yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", { |
| 118 | * zoneId: zone.zoneId, |
| 119 | * contact: ["mailto:security@example.com"], |
| 120 | * expires: "2027-01-01T00:00:00Z", |
| 121 | * }); |
| 122 | * ``` |
| 123 | * |
| 124 | * **Example:** Full security.txt with policy and acknowledgments |
| 125 | * ```typescript |
| 126 | * yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", { |
| 127 | * zoneId: zone.zoneId, |
| 128 | * contact: ["mailto:security@example.com", "https://example.com/report"], |
| 129 | * expires: "2027-01-01T00:00:00Z", |
| 130 | * policy: ["https://example.com/security-policy"], |
| 131 | * acknowledgments: ["https://example.com/hall-of-fame"], |
| 132 | * encryption: ["https://example.com/pgp-key.txt"], |
| 133 | * preferredLanguages: "en, es", |
| 134 | * }); |
| 135 | * ``` |
| 136 | * |
| 137 | * ### Pausing without deleting |
| 138 | * **Example:** Keep the configuration but stop serving the file |
| 139 | * ```typescript |
| 140 | * yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", { |
| 141 | * zoneId: zone.zoneId, |
| 142 | * enabled: false, |
| 143 | * contact: ["mailto:security@example.com"], |
| 144 | * expires: "2027-01-01T00:00:00Z", |
| 145 | * }); |
| 146 | * ``` |
| 147 | * |
| 148 | * @see https://developers.cloudflare.com/security-center/infrastructure/security-file/ |
| 149 | * |
| 150 | * @resource |
| 151 | * @product Security.txt |
| 152 | * @category Application Security |
| 153 | */ |
| 154 | export const SecurityTxt = Resource<SecurityTxt>(TypeId, { |
| 155 | aliases: ["Cloudflare.SecurityTxt"], |
| 156 | }); |
| 157 | |
| 158 | /** |
| 159 | * Returns true if the given value is a SecurityTxt resource. |
| 160 | */ |
| 161 | export const isSecurityTxt = (value: unknown): value is SecurityTxt => |
| 162 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 163 | |
| 164 | export const SecurityTxtProvider = () => |
| 165 | Provider.succeed(SecurityTxt, { |
| 166 | stables: ["zoneId"], |
| 167 | |
| 168 | list: Effect.fn(function* () { |
| 169 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 170 | // There is no account-wide API for this per-zone singleton, and the |
| 171 | // file is only present on zones that have explicitly configured it — |
| 172 | // enumerate every zone, read its security.txt, and emit one entry per |
| 173 | // configured zone (an empty-string sentinel means unconfigured, which |
| 174 | // `read` treats as absent, so skip it). |
| 175 | const allZones = yield* listAllZones(accountId); |
| 176 | const rows = yield* Effect.forEach( |
| 177 | allZones.map((zone) => zone.id), |
| 178 | (zoneId) => |
| 179 | securityTxt |
| 180 | .getSecurityTxt({ zoneId }) |
| 181 | .pipe( |
| 182 | Effect.map((observed) => |
| 183 | typeof observed === "string" |
| 184 | ? undefined |
| 185 | : toAttributes(zoneId, observed), |
| 186 | ), |
| 187 | ), |
| 188 | { concurrency: 10 }, |
| 189 | ); |
| 190 | return rows.filter((row): row is Attributes => row !== undefined); |
| 191 | }), |
| 192 | |
| 193 | diff: Effect.fn(function* ({ olds = {}, news, output }) { |
| 194 | const o = olds as Props; |
| 195 | const n = news as Props; |
| 196 | // zoneId is Input<string>; compare only once both sides are concrete. |
| 197 | const oldZoneId = |
| 198 | output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined); |
| 199 | if ( |
| 200 | oldZoneId !== undefined && |
| 201 | typeof n.zoneId === "string" && |
| 202 | oldZoneId !== n.zoneId |
| 203 | ) { |
| 204 | return { action: "replace" } as const; |
| 205 | } |
| 206 | return undefined; |
| 207 | }), |
| 208 | |
| 209 | read: Effect.fn(function* ({ output, olds }) { |
| 210 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 211 | if (!zoneId) return undefined; |
| 212 | const observed = yield* securityTxt.getSecurityTxt({ zoneId }); |
| 213 | // An unconfigured zone returns an empty-string sentinel — absent. |
| 214 | if (typeof observed === "string") return undefined; |
| 215 | const attrs = toAttributes(zoneId, observed); |
| 216 | // The file is a zone singleton with no ownership markers. On a cold |
| 217 | // read (no prior output) an existing file was configured outside |
| 218 | // Alchemy — brand it `Unowned` so the engine refuses to take over |
| 219 | // unless `--adopt` is set. |
| 220 | if (output === undefined) return Unowned(attrs); |
| 221 | return attrs; |
| 222 | }), |
| 223 | |
| 224 | reconcile: Effect.fn(function* ({ news }) { |
| 225 | // Inputs have been resolved to concrete strings by Plan. |
| 226 | const zoneId = news.zoneId as string; |
| 227 | const desired = desiredAttributes(zoneId, news); |
| 228 | |
| 229 | // 1. Observe — read the live file (an empty-string sentinel means |
| 230 | // the zone has no security.txt yet). |
| 231 | const observed = yield* securityTxt.getSecurityTxt({ zoneId }); |
| 232 | |
| 233 | // 2. Sync — PUT is a full replace (a true upsert), so create and |
| 234 | // update are the same call. Skip the API entirely when the |
| 235 | // observed file already matches the desired one. |
| 236 | if ( |
| 237 | typeof observed !== "string" && |
| 238 | attributesEqual(toAttributes(zoneId, observed), desired) |
| 239 | ) { |
| 240 | return toAttributes(zoneId, observed); |
| 241 | } |
| 242 | yield* securityTxt.putSecurityTxt({ |
| 243 | zoneId, |
| 244 | enabled: desired.enabled, |
| 245 | contact: desired.contact, |
| 246 | expires: desired.expires, |
| 247 | acknowledgments: desired.acknowledgments, |
| 248 | canonical: desired.canonical, |
| 249 | encryption: desired.encryption, |
| 250 | hiring: desired.hiring, |
| 251 | policy: desired.policy, |
| 252 | preferredLanguages: desired.preferredLanguages, |
| 253 | }); |
| 254 | |
| 255 | // 3. Return — re-read so attributes reflect what Cloudflare stored. |
| 256 | const final = yield* securityTxt.getSecurityTxt({ zoneId }); |
| 257 | return typeof final === "string" ? desired : toAttributes(zoneId, final); |
| 258 | }), |
| 259 | |
| 260 | delete: Effect.fn(function* ({ output }) { |
| 261 | // DELETE on an already-absent security.txt returns success, so the |
| 262 | // operation is naturally idempotent. |
| 263 | yield* securityTxt.deleteSecurityTxt({ zoneId: output.zoneId }); |
| 264 | }), |
| 265 | }); |
| 266 | |
| 267 | /** Normalize the desired state from input props into the Attributes shape. */ |
| 268 | const desiredAttributes = (zoneId: string, news: Props): Attributes => ({ |
| 269 | zoneId, |
| 270 | enabled: news.enabled ?? true, |
| 271 | contact: [...news.contact], |
| 272 | expires: news.expires, |
| 273 | acknowledgments: nonEmpty(news.acknowledgments), |
| 274 | canonical: nonEmpty(news.canonical), |
| 275 | encryption: nonEmpty(news.encryption), |
| 276 | hiring: nonEmpty(news.hiring), |
| 277 | policy: nonEmpty(news.policy), |
| 278 | preferredLanguages: |
| 279 | news.preferredLanguages === "" ? undefined : news.preferredLanguages, |
| 280 | }); |
| 281 | |
| 282 | /** Normalize the observed GET response into the Attributes shape. */ |
| 283 | const toAttributes = ( |
| 284 | zoneId: string, |
| 285 | observed: Exclude<securityTxt.GetSecurityTxtResponse, string>, |
| 286 | ): Attributes => ({ |
| 287 | zoneId, |
| 288 | enabled: observed.enabled ?? false, |
| 289 | contact: [...(observed.contact ?? [])], |
| 290 | expires: observed.expires ?? "", |
| 291 | acknowledgments: nonEmpty(observed.acknowledgments), |
| 292 | canonical: nonEmpty(observed.canonical), |
| 293 | encryption: nonEmpty(observed.encryption), |
| 294 | hiring: nonEmpty(observed.hiring), |
| 295 | // Cloudflare echoes an empty string for an unset Preferred-Languages. |
| 296 | policy: nonEmpty(observed.policy), |
| 297 | preferredLanguages: |
| 298 | observed.preferredLanguages === "" |
| 299 | ? undefined |
| 300 | : (observed.preferredLanguages ?? undefined), |
| 301 | }); |
| 302 | |
| 303 | /** Null/empty list fields normalize to `undefined` (not configured). */ |
| 304 | const nonEmpty = ( |
| 305 | values: readonly string[] | null | undefined, |
| 306 | ): string[] | undefined => |
| 307 | values === null || values === undefined || values.length === 0 |
| 308 | ? undefined |
| 309 | : [...values]; |
| 310 | |
| 311 | const listEquals = ( |
| 312 | a: string[] | undefined, |
| 313 | b: string[] | undefined, |
| 314 | ): boolean => { |
| 315 | if (a === undefined || b === undefined) return a === b; |
| 316 | return a.length === b.length && a.every((v, i) => v === b[i]); |
| 317 | }; |
| 318 | |
| 319 | const attributesEqual = (a: Attributes, b: Attributes): boolean => |
| 320 | a.zoneId === b.zoneId && |
| 321 | a.enabled === b.enabled && |
| 322 | a.expires === b.expires && |
| 323 | a.preferredLanguages === b.preferredLanguages && |
| 324 | listEquals(a.contact, b.contact) && |
| 325 | listEquals(a.acknowledgments, b.acknowledgments) && |
| 326 | listEquals(a.canonical, b.canonical) && |
| 327 | listEquals(a.encryption, b.encryption) && |
| 328 | listEquals(a.hiring, b.hiring) && |
| 329 | listEquals(a.policy, b.policy); |