serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env-var / secret documentation drift guard.
|
|
3
|
+
*
|
|
4
|
+
* The Cloudflare Worker consumes a growing set of env vars via the
|
|
5
|
+
* cf-adapter config loader (`CfConfigEnv`). Credential material
|
|
6
|
+
* (`SERVER_PASSWORD`, `OPER_PASSWORD` / `OPER_SALT` / `OPER_HASH`,
|
|
7
|
+
* `SASL_ACCOUNTS`) MUST be Workers secrets (`wrangler secret put`),
|
|
8
|
+
* never plaintext `[vars]` entries; everything else is a normal
|
|
9
|
+
* `[vars]` knob. Historically `apps/cf-worker/wrangler.toml` carried a
|
|
10
|
+
* stale "no secret bindings consumed" comment, so an operator relying
|
|
11
|
+
* on it could paste oper passwords into `[vars]` and leak them into
|
|
12
|
+
* the repo / bundle.
|
|
13
|
+
*
|
|
14
|
+
* This guard parses the wrangler.toml `consumed-env-vars` comment
|
|
15
|
+
* block and asserts it stays in sync with the config loader:
|
|
16
|
+
*
|
|
17
|
+
* 1. every `CfConfigEnv` member is enumerated in the block
|
|
18
|
+
* (classified `[vars]` or `[secret]`),
|
|
19
|
+
* 2. every block entry is still consumed (no stale names),
|
|
20
|
+
* 3. no `[secret]`-classified var ships as a plaintext `[vars]` key,
|
|
21
|
+
* 4. the ticket-mandated sensitive vars (`OPER_PASSWORD`,
|
|
22
|
+
* `SERVER_PASSWORD`, `SASL_ACCOUNTS`) are classified `[secret]`.
|
|
23
|
+
*
|
|
24
|
+
* The contract is enforced by `tests/env-var-drift.test.ts` against
|
|
25
|
+
* the real repo files on every CI run.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Env vars documented in the wrangler.toml block but consumed
|
|
30
|
+
* OUTSIDE the cf-adapter config loader. `WEB_ORIGINS` is read
|
|
31
|
+
* directly by the worker edge (`origin-allowlist.ts`), so it is a
|
|
32
|
+
* legitimate block entry the loader-source scan cannot see.
|
|
33
|
+
*/
|
|
34
|
+
export const EXTRA_DOCUMENTED_ENV_VARS: ReadonlySet<string> = new Set(['WEB_ORIGINS']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Vars that are credential material per the drift-guard ticket.
|
|
38
|
+
* Each MUST be classified `[secret]` in the wrangler.toml block
|
|
39
|
+
* (when documented). `OPER_SALT` / `OPER_HASH` are also stored as
|
|
40
|
+
* secrets in practice, but only these three are hard-pinned so the
|
|
41
|
+
* pinned set matches the ticket's acceptance criteria exactly.
|
|
42
|
+
*/
|
|
43
|
+
export const REQUIRED_SECRET_ENV_VARS: ReadonlySet<string> = new Set([
|
|
44
|
+
'OPER_PASSWORD',
|
|
45
|
+
'SERVER_PASSWORD',
|
|
46
|
+
'SASL_ACCOUNTS',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Marker lines bounding the machine-parsed block in wrangler.toml. */
|
|
50
|
+
const BLOCK_BEGIN = '## BEGIN consumed-env-vars';
|
|
51
|
+
const BLOCK_END = '## END consumed-env-vars';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A documented block entry: `## [vars] NAME — description` or
|
|
55
|
+
* `## [secret] NAME — description`.
|
|
56
|
+
*/
|
|
57
|
+
const BLOCK_LINE_RE = /^##\s*\[(vars|secret)\]\s+([A-Z][A-Z0-9_]+)\b/;
|
|
58
|
+
|
|
59
|
+
/** One documented env var with its sensitivity classification. */
|
|
60
|
+
export interface DocumentedEnvVar {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly classification: 'vars' | 'secret';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract the env-var member names of `CfConfigEnv` from the config
|
|
67
|
+
* loader source. Members are `NAME?:` / `NAME:` interface lines;
|
|
68
|
+
* doc-comment prose and module-level constants do not match. Returns
|
|
69
|
+
* a sorted, de-duplicated list. Empty when the interface is absent
|
|
70
|
+
* (e.g. the file was renamed — the contract test surfaces that as a
|
|
71
|
+
* loud all-vars-missing failure).
|
|
72
|
+
*/
|
|
73
|
+
export function extractLoaderEnvVars(configLoaderSource: string): string[] {
|
|
74
|
+
const start = configLoaderSource.indexOf('export interface CfConfigEnv {');
|
|
75
|
+
if (start === -1) return [];
|
|
76
|
+
const body = configLoaderSource.slice(start);
|
|
77
|
+
const end = body.indexOf('\n}');
|
|
78
|
+
const scope = end === -1 ? body : body.slice(0, end);
|
|
79
|
+
const names = new Set<string>();
|
|
80
|
+
for (const line of scope.split('\n')) {
|
|
81
|
+
const name = line.match(/^\s+([A-Z][A-Z0-9_]+)\??:\s/u)?.[1];
|
|
82
|
+
if (name !== undefined) names.add(name);
|
|
83
|
+
}
|
|
84
|
+
return [...names].sort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse the `consumed-env-vars` block from wrangler.toml text.
|
|
89
|
+
* Non-conforming lines inside the block (prose, blank lines) are
|
|
90
|
+
* ignored. Returns an empty list when the block markers are absent.
|
|
91
|
+
*/
|
|
92
|
+
export function parseConsumedEnvBlock(tomlSource: string): DocumentedEnvVar[] {
|
|
93
|
+
const beginIdx = tomlSource.indexOf(BLOCK_BEGIN);
|
|
94
|
+
if (beginIdx === -1) return [];
|
|
95
|
+
const afterBegin = tomlSource.slice(beginIdx + BLOCK_BEGIN.length);
|
|
96
|
+
const endIdx = afterBegin.indexOf(BLOCK_END);
|
|
97
|
+
const block = endIdx === -1 ? afterBegin : afterBegin.slice(0, endIdx);
|
|
98
|
+
const out: DocumentedEnvVar[] = [];
|
|
99
|
+
for (const line of block.split('\n')) {
|
|
100
|
+
const match = line.match(BLOCK_LINE_RE);
|
|
101
|
+
if (match === null) continue;
|
|
102
|
+
// Both capture groups are unconditional participants of
|
|
103
|
+
// BLOCK_LINE_RE, so they are defined whenever the regex matches;
|
|
104
|
+
// the tuple cast only satisfies noUncheckedIndexedAccess.
|
|
105
|
+
const [classification, name] = match.slice(1) as ['vars' | 'secret', string];
|
|
106
|
+
out.push({ name, classification });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract the plaintext keys of the wrangler.toml `[vars]` table.
|
|
113
|
+
* Commented-out assignments (`# KEY = ...`) and other tables do not
|
|
114
|
+
* contribute.
|
|
115
|
+
*/
|
|
116
|
+
export function extractVarsTableKeys(tomlSource: string): Set<string> {
|
|
117
|
+
const keys = new Set<string>();
|
|
118
|
+
let inVars = false;
|
|
119
|
+
for (const line of tomlSource.split('\n')) {
|
|
120
|
+
if (/^\[vars\]/u.test(line)) {
|
|
121
|
+
inVars = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (/^\[/u.test(line)) {
|
|
125
|
+
inVars = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!inVars) continue;
|
|
129
|
+
const key = line.match(/^([A-Za-z0-9_]+)\s*=/u)?.[1];
|
|
130
|
+
if (key !== undefined) keys.add(key);
|
|
131
|
+
}
|
|
132
|
+
return keys;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Result of the drift check: all-clear or a list of drift findings. */
|
|
136
|
+
export type EnvVarDriftResult =
|
|
137
|
+
| { ok: true; documentedCount: number }
|
|
138
|
+
| { ok: false; errors: string[] };
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Compare the config loader's consumed env vars against the
|
|
142
|
+
* wrangler.toml `consumed-env-vars` documentation block. Pure —
|
|
143
|
+
* `tests/env-var-drift.test.ts` wires the real repo files.
|
|
144
|
+
*/
|
|
145
|
+
export function checkEnvVarDrift(
|
|
146
|
+
configLoaderSource: string,
|
|
147
|
+
tomlSource: string,
|
|
148
|
+
): EnvVarDriftResult {
|
|
149
|
+
const loaderVars = extractLoaderEnvVars(configLoaderSource);
|
|
150
|
+
const loaderSet = new Set(loaderVars);
|
|
151
|
+
const documented = parseConsumedEnvBlock(tomlSource);
|
|
152
|
+
const byName = new Map(documented.map((entry) => [entry.name, entry]));
|
|
153
|
+
const varsKeys = extractVarsTableKeys(tomlSource);
|
|
154
|
+
const errors: string[] = [];
|
|
155
|
+
|
|
156
|
+
for (const name of loaderVars) {
|
|
157
|
+
if (!byName.has(name)) {
|
|
158
|
+
errors.push(
|
|
159
|
+
`${name} is consumed by packages/cf-adapter/src/config-loader.ts (CfConfigEnv) but is not documented in the apps/cf-worker/wrangler.toml consumed-env-vars block`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const entry of documented) {
|
|
165
|
+
if (!loaderSet.has(entry.name) && !EXTRA_DOCUMENTED_ENV_VARS.has(entry.name)) {
|
|
166
|
+
errors.push(
|
|
167
|
+
`${entry.name} is documented in the wrangler.toml consumed-env-vars block but is no longer consumed (stale entry — remove it, or add it to EXTRA_DOCUMENTED_ENV_VARS if it moved outside the config loader)`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const name of REQUIRED_SECRET_ENV_VARS) {
|
|
173
|
+
const entry = byName.get(name);
|
|
174
|
+
if (entry !== undefined && entry.classification !== 'secret') {
|
|
175
|
+
errors.push(
|
|
176
|
+
`${name} is credential material but is classified [vars] in the wrangler.toml consumed-env-vars block — classify it [secret] and set it with \`wrangler secret put ${name}\``,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const entry of documented) {
|
|
182
|
+
if (entry.classification === 'secret' && varsKeys.has(entry.name)) {
|
|
183
|
+
errors.push(
|
|
184
|
+
`${entry.name} is classified [secret] but appears as a plaintext key in the wrangler.toml [vars] table — set it with \`wrangler secret put ${entry.name}\` instead`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return errors.length === 0
|
|
190
|
+
? { ok: true, documentedCount: documented.length }
|
|
191
|
+
: { ok: false, errors };
|
|
192
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runHostnameGuard } from './deploy-hostname.js';
|
|
2
|
+
|
|
3
|
+
const result = runHostnameGuard(process.argv.slice(2), process.env);
|
|
4
|
+
|
|
5
|
+
if (result.exitCode === 0) {
|
|
6
|
+
console.log(result.message);
|
|
7
|
+
} else {
|
|
8
|
+
console.error(result.message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
process.exit(result.exitCode);
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
export {
|
|
2
2
|
MUTATION_SCORE_THRESHOLD,
|
|
3
|
+
findAwsCredentialSteps,
|
|
3
4
|
readCoverageThresholds,
|
|
4
5
|
readStrykerConfig,
|
|
5
6
|
readWorkflowJob,
|
|
7
|
+
readWorkflowPermissions,
|
|
6
8
|
} from './validate.js';
|
|
9
|
+
export {
|
|
10
|
+
EXTRA_DOCUMENTED_ENV_VARS,
|
|
11
|
+
REQUIRED_SECRET_ENV_VARS,
|
|
12
|
+
checkEnvVarDrift,
|
|
13
|
+
extractLoaderEnvVars,
|
|
14
|
+
extractVarsTableKeys,
|
|
15
|
+
parseConsumedEnvBlock,
|
|
16
|
+
} from './env-var-drift.js';
|
|
17
|
+
export type { DocumentedEnvVar, EnvVarDriftResult } from './env-var-drift.js';
|
|
18
|
+
export {
|
|
19
|
+
PLACEHOLDER_HOSTNAMES,
|
|
20
|
+
checkDeployHostname,
|
|
21
|
+
isPlaceholderHostname,
|
|
22
|
+
readServerNameFromWranglerToml,
|
|
23
|
+
runHostnameGuard,
|
|
24
|
+
} from './deploy-hostname.js';
|
|
25
|
+
export type { HostnameCheck, HostnameGuardResult } from './deploy-hostname.js';
|
|
@@ -37,9 +37,66 @@ interface WorkflowJob {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
interface ParsedWorkflow {
|
|
40
|
+
permissions?: Record<string, string> | string;
|
|
40
41
|
jobs?: Record<string, WorkflowJob>;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Parse a GitHub Actions YAML file and return the top-level
|
|
46
|
+
* `permissions:` block as a `Record<string, string>` (e.g.
|
|
47
|
+
* `{ 'id-token': 'write', contents: 'read' }`).
|
|
48
|
+
*
|
|
49
|
+
* Throws if the workflow has no top-level `permissions:` block.
|
|
50
|
+
* The literal `'read-all'` / `'write-all'` shapes are returned
|
|
51
|
+
* verbatim as a single key so callers can flag them.
|
|
52
|
+
*
|
|
53
|
+
* @param yamlText raw YAML source of a workflow file.
|
|
54
|
+
*/
|
|
55
|
+
export function readWorkflowPermissions(yamlText: string): Record<string, string> {
|
|
56
|
+
const parsed = load(yamlText) as ParsedWorkflow;
|
|
57
|
+
if (parsed.permissions === undefined || parsed.permissions === null) {
|
|
58
|
+
throw new Error('Workflow has no top-level permissions: block');
|
|
59
|
+
}
|
|
60
|
+
if (typeof parsed.permissions === 'string') {
|
|
61
|
+
return { [parsed.permissions]: parsed.permissions };
|
|
62
|
+
}
|
|
63
|
+
return parsed.permissions;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return every step in the workflow that uses `configure-aws-credentials`,
|
|
68
|
+
* keyed by `"<job>.<index>"`. Each value is the step's `with:` mapping
|
|
69
|
+
* (an empty object if the step has no `with:`).
|
|
70
|
+
*
|
|
71
|
+
* Used by the OIDC-only lint to assert no static-key inputs survive.
|
|
72
|
+
*
|
|
73
|
+
* @param yamlText raw YAML source of a workflow file.
|
|
74
|
+
*/
|
|
75
|
+
export function findAwsCredentialSteps(
|
|
76
|
+
yamlText: string,
|
|
77
|
+
): Array<{ job: string; index: number; with: Record<string, unknown> }> {
|
|
78
|
+
const parsed = load(yamlText) as ParsedWorkflow;
|
|
79
|
+
const out: Array<{ job: string; index: number; with: Record<string, unknown> }> = [];
|
|
80
|
+
for (const [jobName, job] of Object.entries(parsed.jobs ?? {})) {
|
|
81
|
+
const steps = job?.steps ?? [];
|
|
82
|
+
steps.forEach((step, index) => {
|
|
83
|
+
const uses = step.uses;
|
|
84
|
+
if (typeof uses === 'string' && uses.includes('configure-aws-credentials')) {
|
|
85
|
+
const withBlock = step.with;
|
|
86
|
+
out.push({
|
|
87
|
+
job: jobName,
|
|
88
|
+
index,
|
|
89
|
+
with:
|
|
90
|
+
withBlock && typeof withBlock === 'object'
|
|
91
|
+
? (withBlock as Record<string, unknown>)
|
|
92
|
+
: {},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
/**
|
|
44
101
|
* Extract the `coverage.thresholds` block from a vitest config file.
|
|
45
102
|
*
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
buildWranglerDeployArgs,
|
|
7
|
+
parseWrapperArgv,
|
|
8
|
+
resolveGuardedHostname,
|
|
9
|
+
runDeploy,
|
|
10
|
+
} from '../src/cf-deploy.js';
|
|
11
|
+
|
|
12
|
+
const tmpDirs: string[] = [];
|
|
13
|
+
function writeTempWranglerToml(serverName: string | undefined): string {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), 'cf-deploy-test-'));
|
|
15
|
+
tmpDirs.push(dir);
|
|
16
|
+
const vars = serverName === undefined ? '' : `[vars]\nSERVER_NAME = "${serverName}"\n`;
|
|
17
|
+
writeFileSync(join(dir, 'wrangler.toml'), vars);
|
|
18
|
+
return join(dir, 'wrangler.toml');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
23
|
+
// Best-effort cleanup; tmpdir is OS-swept anyway.
|
|
24
|
+
try {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
/* ignore */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('parseWrapperArgv', () => {
|
|
33
|
+
it('returns the config path and passthrough args after a -- separator', () => {
|
|
34
|
+
expect(parseWrapperArgv(['wrangler.toml', '--', '--dry-run', '--x=1'])).toEqual({
|
|
35
|
+
configPath: 'wrangler.toml',
|
|
36
|
+
passthrough: ['--dry-run', '--x=1'],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('strips repeated leading -- separators (pnpm double-forwarding)', () => {
|
|
41
|
+
expect(parseWrapperArgv(['wrangler.toml', '--', '--', '--dry-run'])).toEqual({
|
|
42
|
+
configPath: 'wrangler.toml',
|
|
43
|
+
passthrough: ['--dry-run'],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('treats non-flag args after the config path as passthrough without a separator', () => {
|
|
48
|
+
expect(parseWrapperArgv(['wrangler.toml', '--dry-run'])).toEqual({
|
|
49
|
+
configPath: 'wrangler.toml',
|
|
50
|
+
passthrough: ['--dry-run'],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects an empty argv (usage error)', () => {
|
|
55
|
+
expect(parseWrapperArgv([])).toEqual({ configPath: '', passthrough: [] });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('resolveGuardedHostname', () => {
|
|
60
|
+
it('prefers a non-blank SERVER_NAME env override over the config value', () => {
|
|
61
|
+
const configPath = writeTempWranglerToml('irc.your-domain.invalid');
|
|
62
|
+
const result = resolveGuardedHostname(configPath, { SERVER_NAME: 'irc.fowl.cloud' });
|
|
63
|
+
expect(result).toEqual({ ok: true, hostname: 'irc.fowl.cloud' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to the config SERVER_NAME when the env override is unset', () => {
|
|
67
|
+
const configPath = writeTempWranglerToml('irc.fowl.cloud');
|
|
68
|
+
const result = resolveGuardedHostname(configPath, {});
|
|
69
|
+
expect(result).toEqual({ ok: true, hostname: 'irc.fowl.cloud' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects the placeholder config value when no override is set', () => {
|
|
73
|
+
const configPath = writeTempWranglerToml('irc.your-domain.invalid');
|
|
74
|
+
const result = resolveGuardedHostname(configPath, {});
|
|
75
|
+
expect(result.ok).toBe(false);
|
|
76
|
+
if (!result.ok) expect(result.message).toContain('placeholder');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects a blank env override falling back to a placeholder config', () => {
|
|
80
|
+
const configPath = writeTempWranglerToml('irc.example.com');
|
|
81
|
+
const result = resolveGuardedHostname(configPath, { SERVER_NAME: ' ' });
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects when SERVER_NAME is set nowhere (no env override, none in config)', () => {
|
|
86
|
+
const configPath = writeTempWranglerToml(undefined);
|
|
87
|
+
const result = resolveGuardedHostname(configPath, {});
|
|
88
|
+
expect(result.ok).toBe(false);
|
|
89
|
+
if (!result.ok) expect(result.message).toContain('SERVER_NAME is not set');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('buildWranglerDeployArgs', () => {
|
|
94
|
+
it('injects the resolved hostname as a --var override before passthrough args', () => {
|
|
95
|
+
expect(buildWranglerDeployArgs('irc.fowl.cloud', ['--dry-run'])).toEqual([
|
|
96
|
+
'deploy',
|
|
97
|
+
'--var',
|
|
98
|
+
'SERVER_NAME:irc.fowl.cloud',
|
|
99
|
+
'--dry-run',
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('works with no passthrough args', () => {
|
|
104
|
+
expect(buildWranglerDeployArgs('irc.example.org', [])).toEqual([
|
|
105
|
+
'deploy',
|
|
106
|
+
'--var',
|
|
107
|
+
'SERVER_NAME:irc.example.org',
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('runDeploy', () => {
|
|
113
|
+
/** Writes a PATH-resolvable `wrangler` shim that records its argv. */
|
|
114
|
+
function writeWranglerShim(): { binDir: string; argsFile: string } {
|
|
115
|
+
const dir = mkdtempSync(join(tmpdir(), 'cf-deploy-shim-'));
|
|
116
|
+
tmpDirs.push(dir);
|
|
117
|
+
const argsFile = join(dir, 'args.txt');
|
|
118
|
+
const shim = ['#!/bin/sh', `printf '%s\\n' "$@" > ${JSON.stringify(argsFile)}`, 'exit 0'].join(
|
|
119
|
+
'\n',
|
|
120
|
+
);
|
|
121
|
+
const bin = join(dir, 'wrangler');
|
|
122
|
+
writeFileSync(bin, shim, { mode: 0o755 });
|
|
123
|
+
return { binDir: dir, argsFile };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('invokes wrangler with the injected --var and passthrough args (exit 0)', () => {
|
|
127
|
+
const { binDir, argsFile } = writeWranglerShim();
|
|
128
|
+
const configPath = writeTempWranglerToml('irc.your-domain.invalid');
|
|
129
|
+
const prevPath = process.env.PATH;
|
|
130
|
+
process.env.PATH = `${binDir}:${prevPath ?? ''}`;
|
|
131
|
+
try {
|
|
132
|
+
const code = runDeploy([configPath, '--', '--dry-run'], {
|
|
133
|
+
SERVER_NAME: 'irc.fowl.cloud',
|
|
134
|
+
});
|
|
135
|
+
expect(code).toBe(0);
|
|
136
|
+
expect(readFileSync(argsFile, 'utf8')).toBe(
|
|
137
|
+
['deploy', '--var', 'SERVER_NAME:irc.fowl.cloud', '--dry-run', ''].join('\n'),
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
process.env.PATH = prevPath;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns 1 without invoking wrangler when the resolved hostname is a placeholder', () => {
|
|
145
|
+
const { binDir, argsFile } = writeWranglerShim();
|
|
146
|
+
const configPath = writeTempWranglerToml('irc.example.com');
|
|
147
|
+
const prevPath = process.env.PATH;
|
|
148
|
+
process.env.PATH = `${binDir}:${prevPath ?? ''}`;
|
|
149
|
+
try {
|
|
150
|
+
const code = runDeploy([configPath], {});
|
|
151
|
+
expect(code).toBe(1);
|
|
152
|
+
expect(existsSync(argsFile)).toBe(false);
|
|
153
|
+
} finally {
|
|
154
|
+
process.env.PATH = prevPath;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns 2 on a usage error (no config path)', () => {
|
|
159
|
+
expect(runDeploy([], {})).toBe(2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('returns 1 when wrangler cannot be spawned (missing from PATH)', () => {
|
|
163
|
+
const configPath = writeTempWranglerToml('irc.fowl.cloud');
|
|
164
|
+
const prevPath = process.env.PATH;
|
|
165
|
+
process.env.PATH = '';
|
|
166
|
+
try {
|
|
167
|
+
expect(runDeploy([configPath], {})).toBe(1);
|
|
168
|
+
} finally {
|
|
169
|
+
process.env.PATH = prevPath;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('propagates a non-zero wrangler exit code', () => {
|
|
174
|
+
const dir = mkdtempSync(join(tmpdir(), 'cf-deploy-failshim-'));
|
|
175
|
+
tmpDirs.push(dir);
|
|
176
|
+
writeFileSync(join(dir, 'wrangler'), '#!/bin/sh\nexit 3\n', { mode: 0o755 });
|
|
177
|
+
const configPath = writeTempWranglerToml('irc.fowl.cloud');
|
|
178
|
+
const prevPath = process.env.PATH;
|
|
179
|
+
process.env.PATH = `${dir}:${prevPath ?? ''}`;
|
|
180
|
+
try {
|
|
181
|
+
expect(runDeploy([configPath], {})).toBe(3);
|
|
182
|
+
} finally {
|
|
183
|
+
process.env.PATH = prevPath;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns 1 when wrangler dies to a signal (null status fallback)', () => {
|
|
188
|
+
const dir = mkdtempSync(join(tmpdir(), 'cf-deploy-sigshim-'));
|
|
189
|
+
tmpDirs.push(dir);
|
|
190
|
+
writeFileSync(join(dir, 'wrangler'), '#!/bin/sh\nkill -TERM $$\n', { mode: 0o755 });
|
|
191
|
+
const configPath = writeTempWranglerToml('irc.fowl.cloud');
|
|
192
|
+
const prevPath = process.env.PATH;
|
|
193
|
+
process.env.PATH = `${dir}:${prevPath ?? ''}`;
|
|
194
|
+
try {
|
|
195
|
+
expect(runDeploy([configPath], {})).toBe(1);
|
|
196
|
+
} finally {
|
|
197
|
+
process.env.PATH = prevPath;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC-only AWS deploy enforcement.
|
|
3
|
+
*
|
|
4
|
+
* The deploy workflow (`.github/workflows/deploy-aws.yml`) must NOT
|
|
5
|
+
* accept long-lived access keys. It must require an OIDC web-identity
|
|
6
|
+
* role (`AWS_DEPLOY_ROLE_ARN`), grant `id-token: write`, fail fast when
|
|
7
|
+
* the role ARN is missing, and assert that `aws sts get-caller-identity`
|
|
8
|
+
* returns the configured role before any `cdk deploy` / `cdk synth` —
|
|
9
|
+
* so a silent fallback to ambient static credentials cannot ship a
|
|
10
|
+
* deploy.
|
|
11
|
+
*
|
|
12
|
+
* These assertions run as part of the CI-hardening suite on every PR,
|
|
13
|
+
* the same way the coverage-gate and mutation-testing contracts do.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { describe, expect, it } from 'vitest';
|
|
19
|
+
import {
|
|
20
|
+
findAwsCredentialSteps,
|
|
21
|
+
readWorkflowJob,
|
|
22
|
+
readWorkflowPermissions,
|
|
23
|
+
} from '../src/validate.js';
|
|
24
|
+
|
|
25
|
+
const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
|
26
|
+
const WORKFLOW_PATH = resolve(REPO_ROOT, '.github/workflows/deploy-aws.yml');
|
|
27
|
+
const WORKFLOW = readFileSync(WORKFLOW_PATH, 'utf8');
|
|
28
|
+
|
|
29
|
+
describe('deploy-aws.yml: OIDC is the only accepted AWS credential source', () => {
|
|
30
|
+
it('does NOT pass aws-access-key-id / aws-secret-access-key to configure-aws-credentials', () => {
|
|
31
|
+
const steps = findAwsCredentialSteps(WORKFLOW);
|
|
32
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
33
|
+
for (const step of steps) {
|
|
34
|
+
expect(step.with).not.toHaveProperty('aws-access-key-id');
|
|
35
|
+
expect(step.with).not.toHaveProperty('aws-secret-access-key');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('configures a role-to-assume on every configure-aws-credentials step', () => {
|
|
40
|
+
const steps = findAwsCredentialSteps(WORKFLOW);
|
|
41
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
42
|
+
for (const step of steps) {
|
|
43
|
+
expect(step.with['role-to-assume']).toBeDefined();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('grants id-token: write at the top level (OIDC minting requires it)', () => {
|
|
48
|
+
const permissions = readWorkflowPermissions(WORKFLOW);
|
|
49
|
+
expect(permissions['id-token']).toBe('write');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('keeps the permissions block least-privilege (only id-token + contents)', () => {
|
|
53
|
+
const permissions = readWorkflowPermissions(WORKFLOW);
|
|
54
|
+
expect(permissions.contents).toBe('read');
|
|
55
|
+
const keys = Object.keys(permissions).sort();
|
|
56
|
+
expect(keys).toEqual(['contents', 'id-token']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('fails fast when AWS_DEPLOY_ROLE_ARN is empty (guard step exists in every AWS-touching job)', () => {
|
|
60
|
+
// The "deploy" job runs `pnpm deploy:aws` (cdk deploy) and the
|
|
61
|
+
// "deploy-web" job runs `node scripts/deploy-web-aws.mjs`. Both
|
|
62
|
+
// must abort before any AWS call if the role ARN secret is unset.
|
|
63
|
+
for (const jobName of ['deploy', 'deploy-web']) {
|
|
64
|
+
const job = readWorkflowJob(WORKFLOW, jobName);
|
|
65
|
+
const hasGuard = job.steps.some((step) => {
|
|
66
|
+
const ifExpr = typeof step.if === 'string' ? step.if : '';
|
|
67
|
+
const env = step.env as Record<string, string> | undefined;
|
|
68
|
+
return (
|
|
69
|
+
ifExpr.includes('AWS_DEPLOY_ROLE_ARN') &&
|
|
70
|
+
ifExpr.includes("== ''") &&
|
|
71
|
+
env !== undefined &&
|
|
72
|
+
typeof env.AWS_DEPLOY_ROLE_ARN === 'string' &&
|
|
73
|
+
typeof step.run === 'string' &&
|
|
74
|
+
step.run.includes('exit 1')
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
expect(hasGuard).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('asserts the sts get-caller-identity ARN matches the configured role before any CDK call', () => {
|
|
82
|
+
// The "deploy" job gates `cdk deploy`. It must, after
|
|
83
|
+
// configure-aws-credentials and before `pnpm deploy:aws`, run
|
|
84
|
+
// `aws sts get-caller-identity` and reject anything that is not
|
|
85
|
+
// the configured role ARN. This catches silent fallbacks to
|
|
86
|
+
// ambient static credentials.
|
|
87
|
+
const job = readWorkflowJob(WORKFLOW, 'deploy');
|
|
88
|
+
const runSteps = job.steps
|
|
89
|
+
.filter((s) => typeof s.run === 'string')
|
|
90
|
+
.map((s) => (s.run as string).trim());
|
|
91
|
+
const hasStsGuard = runSteps.some(
|
|
92
|
+
(r) => r.includes('aws sts get-caller-identity') && r.includes('AWS_DEPLOY_ROLE_ARN'),
|
|
93
|
+
);
|
|
94
|
+
expect(hasStsGuard).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|