From 52cc6964319b4e4e1956879e43ec0379ce1cfe75 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:41:27 -0400 Subject: [PATCH 1/2] fix(deps): bump PyJWT to >=2.13.0 to close HS256 forgery (#47) PyJWT <2.13.0 accepts a public-key JWK as an HMAC secret, letting an attacker forge HS256 tokens when mixed key families are allowed (GHSA high-sev Dependabot alert). 2.13.0 rejects the mismatch. Direct dependency; uv.lock re-resolves 2.12.1 -> 2.13.0. --- pyproject.toml | 2 +- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 359986d5..31c77bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "fastapi>=0.136.3", "uvicorn>=0.48.0", "httpx>=0.28.1", - "PyJWT>=2.12.1", + "PyJWT>=2.13.0", "cryptography>=48.0.1", "langgraph-sdk>=0.4.2", "langchain>=1.3.9", diff --git a/uv.lock b/uv.lock index 807360cf..86d0553a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -2045,7 +2045,7 @@ requires-dist = [ { name = "langsmith", specifier = "==0.8.18" }, { name = "markdownify", specifier = ">=1.2.2" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, - { name = "pyjwt", specifier = ">=2.12.1" }, + { name = "pyjwt", specifier = ">=2.13.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.15" }, @@ -2618,11 +2618,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] From faae9a685b123a869314781858f4628a6c1d0272 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:45:17 -0400 Subject: [PATCH 2/2] Scope secrets fetch to --secret-id-list; drop ListSecrets grant (#48) Switch fetch-config.sh from a name-prefix batch-get-secret-value --filters scan to an explicit --secret-id-list (the 28 SECRET_VARS, chunked at the 20/call cap). An id-list batch authorizes per-secret ARN, so the instance role's BatchGetSecretValue moves from Resource:* to the open-swe-/* prefix and the account-wide ListSecrets grant is dropped entirely. The box can no longer enumerate secret names account-wide; cross-env value isolation is unchanged (GetSecretValue was already prefix-scoped). Resolves OSWE-IAC-SECRETS-LIST-01. Also capture each chunk response into a variable and consume the producer via command substitution so a failed AWS call aborts under set -e instead of being swallowed by process substitution and misreported as a missing required var. Refs: OSWE-IAC-SECRETS-LIST-01 --- deploy/seahaven/fetch-config.sh | 72 +++++++++++++++++++-------- infra/lib/constructs/instance-role.ts | 37 ++++++-------- 2 files changed, 66 insertions(+), 43 deletions(-) diff --git a/deploy/seahaven/fetch-config.sh b/deploy/seahaven/fetch-config.sh index e029ed5b..bf82cad1 100755 --- a/deploy/seahaven/fetch-config.sh +++ b/deploy/seahaven/fetch-config.sh @@ -158,34 +158,64 @@ while IFS=$'\t' read -r nb vb; do done < <(jq -r '.Parameters[] | (.Name|@base64) + "\t" + (.Value|@base64)' <<<"$ssm_json") log "loaded ${ssm_count} config param(s) from SSM" -# 2) Secrets Manager (sensitive values). batch-get-secret-value filters by name -# prefix; one secret per env var, SecretString = the value. The AWS CLI does NOT -# auto-paginate this operation (unlike list/get-parameters-by-path), and a single -# response caps well under the full set (~10 items/page) — so we MUST follow -# NextToken ourselves or secrets on later pages are silently dropped (they then -# surface as FAIL-FAST "missing required var"). `--no-cli-pager` only disables the -# OUTPUT pager, not API pagination. Each page's records are base64(namevalue). +# 2) Secrets Manager (sensitive values). We request secrets by EXPLICIT id +# (`batch-get-secret-value --secret-id-list ...`) rather than a name-prefix +# `--filters` collection scan (OSWE-IAC-SECRETS-LIST-01). Two wins: +# (a) Least privilege — an explicit id list lets the instance role scope +# BatchGetSecretValue to the per-secret ARN prefix and DROP the account-wide +# `secretsmanager:ListSecrets` grant that a filtered scan unavoidably forces +# (ListSecrets has no resource-level scoping). A filtered batch call also only +# authorizes against `*`; an id-list call authorizes per-secret ARN. +# (b) Deterministic set — the value set is the fixed SECRET_VARS shells created by +# the CDK ConfigStore, so we no longer depend on a list scan returning every +# page. Value-less shells and ids with no current value come back in the +# response `.Errors[]` (ResourceNotFound), never in `.SecretValues[]`, so a +# genuinely-missing REQUIRED secret is still caught by the FAIL-FAST check +# below — an absent optional secret is simply skipped. +# `--secret-id-list` is capped at 20 ids per call, so we chunk it. `--no-cli-pager` +# disables only the OUTPUT pager. Each record is base64(namevalue). +# +# SOURCE OF TRUTH for this list: infra/lib/constructs/config-store.ts `SECRET_VARS`. +# Keep the two in lockstep — a new secret shell created there must be added here or it +# will never be fetched into the .env. +SECRET_VARS=( + ANTHROPIC_API_KEY CORRIDOR_API_TOKEN CORRIDOR_MCP_TOKEN CORRIDOR_TOKEN + DASHBOARD_JWT_SECRET DAYTONA_API_KEY EXA_API_KEY FIREWORKS_API_KEY + GITHUB_APP_CLIENT_SECRET GITHUB_APP_PRIVATE_KEY GITHUB_PAT GITHUB_WEBHOOK_SECRET + GOOGLE_API_KEY GROQ_API_KEY JUDGE_ANTHROPIC_API_KEY LANGSMITH_API_KEY + LANGSMITH_API_KEY_PROD LANGCHAIN_API_KEY LINEAR_API_KEY LINEAR_WEBHOOK_SECRET + OPENAI_API_KEY RUNLOOP_API_KEY SLACK_BOT_TOKEN SLACK_CLIENT_SECRET + SLACK_SIGNING_SECRET TOKEN_ENCRYPTION_KEY USER_ID_API_KEY_MAP X_SERVICE_AUTH_JWT_SECRET +) + batch_get_secrets_tsv() { - local token="" page - while :; do - if [ -n "$token" ]; then - page="$(aws secretsmanager batch-get-secret-value \ - --filters "Key=name,Values=${SECRET_PREFIX}" \ - --region "$REGION" --no-cli-pager --output json --next-token "$token")" - else - page="$(aws secretsmanager batch-get-secret-value \ - --filters "Key=name,Values=${SECRET_PREFIX}" \ - --region "$REGION" --no-cli-pager --output json)" - fi + local -a ids=() + local v + for v in "${SECRET_VARS[@]}"; do ids+=("${SECRET_PREFIX}${v}"); done + + local i page + local -a chunk + for ((i = 0; i < ${#ids[@]}; i += 20)); do + chunk=("${ids[@]:i:20}") + # Capture the response into a variable FIRST so a non-zero `aws` exit (throttle, + # AccessDenied, KMS DecryptionFailure) aborts under set -e instead of being + # silently swallowed — then we'd FAIL-FAST below as "missing secret" with a wrong + # root cause. (Value-less / absent shells come back in .Errors[], not .SecretValues[].) + page="$(aws secretsmanager batch-get-secret-value \ + --secret-id-list "${chunk[@]}" \ + --region "$REGION" --no-cli-pager --output json)" printf '%s' "$page" \ | jq -r '.SecretValues[] | select(.SecretString != null) | (.Name|@base64) + "\t" + (.SecretString|@base64)' - token="$(printf '%s' "$page" | jq -r '.NextToken // empty')" - [ -n "$token" ] || break done } log "reading secrets under ${SECRET_PREFIX} ..." secret_count=0 +# Capture into a variable (NOT `done < <(...)` process substitution) so a non-zero +# exit from batch_get_secrets_tsv propagates under set -e — process substitution hides +# the producer's exit status from the parent shell, which would let a failed AWS call +# fall through to a misleading "missing required var" FAIL-FAST. Mirrors the SSM read. +secrets_tsv="$(batch_get_secrets_tsv)" while IFS=$'\t' read -r nb vb; do [ -n "$nb" ] || continue name="$(printf '%s' "$nb" | b64d)" @@ -198,7 +228,7 @@ while IFS=$'\t' read -r nb vb; do [ -n "$key" ] || continue accept_var "$key" "$value" "Secrets" secret_count=$((secret_count + 1)) -done < <(batch_get_secrets_tsv) +done <<<"$secrets_tsv" log "loaded ${secret_count} secret(s) from Secrets Manager" # --- Sea Haven DEFAULT_REPO_OWNER guard -------------------------------------- diff --git a/infra/lib/constructs/instance-role.ts b/infra/lib/constructs/instance-role.ts index 9f3ea60a..2d75a2e0 100644 --- a/infra/lib/constructs/instance-role.ts +++ b/infra/lib/constructs/instance-role.ts @@ -64,35 +64,28 @@ export class InstanceRole extends Construct { // random 6-char suffix, hence the trailing `*`. This is the statement that // actually gates which secret VALUES the box can read: prefix-scoped, so the // dev box can never read prod secret values (and vice versa). GetSecretValue is - // checked per-secret even when the value is returned via the batch call below. + // checked per-secret even when the value is returned via BatchGetSecretValue. + // + // BatchGetSecretValue is included here (prefix-scoped, NOT `*`) because + // fetch-config.sh now fetches by EXPLICIT `--secret-id-list` rather than a name + // `--filters` scan (OSWE-IAC-SECRETS-LIST-01). An id-list batch call authorizes + // against each referenced secret's ARN, so the prefix ARN satisfies it — and we + // no longer need the account-wide `secretsmanager:ListSecrets` grant that a + // filtered collection scan unavoidably forced (ListSecrets has no resource-level + // scoping). Net effect: the box can read open-swe-/* secret values and + // nothing else — it can no longer enumerate secret names account-wide. this.role.addToPolicy( new iam.PolicyStatement({ sid: "ReadSecretValues", - actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"], + actions: [ + "secretsmanager:GetSecretValue", + "secretsmanager:BatchGetSecretValue", + "secretsmanager:DescribeSecret", + ], resources: [`arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:${p}/*`], }), ); - // OPERATION-level grants for fetch-config.sh's prefix-FILTERED batch read - // (`batch-get-secret-value --filters Key=name,Values=open-swe-/`). Both of - // these are collection operations that AWS authorizes against `*`, NOT a - // per-secret ARN: BatchGetSecretValue with a filter is a collection call (a - // prefix-scoped ARN does NOT satisfy it — it AccessDenies), and ListSecrets is a - // list action with no resource-level scoping at all. Neither returns or widens - // VALUE access: a secret's value is still only returned when the prefix-scoped - // GetSecretValue above allows it, so cross-env VALUE isolation is preserved. The - // residual is metadata-only (the box can ENUMERATE secret names account-wide). - // Future hardening to drop both `*` grants: switch fetch-config to an explicit - // `--secret-id-list` (no filter), which lets BatchGetSecretValue be prefix-scoped - // and needs no ListSecrets. Tracked as OSWE-IAC-SECRETS-LIST-01. - this.role.addToPolicy( - new iam.PolicyStatement({ - sid: "SecretsBatchListOps", - actions: ["secretsmanager:BatchGetSecretValue", "secretsmanager:ListSecrets"], - resources: ["*"], - }), - ); - // NOTE (T11): SSM SecureString + Secrets Manager here are assumed to use the // AWS-managed keys (alias/aws/ssm, alias/aws/secretsmanager) for which the // service grants Decrypt implicitly — so NO kms:Decrypt is granted. If T11