@hulumi/baseline: AccountFoundation audit-delivery S3 bucket could be silently weakened
Description
Affected: @hulumi/baseline < 1.4.0 — Fixed in: 1.4.0 — Severity: High — CWE-1059 (Insufficient Technical Documentation / Behavioral Inconsistency)
Summary
The S3 bucket that AccountFoundation creates to receive CloudTrail and AWS Config audit logs is meant to be tamper-resistant — if someone with delete access can erase from it, the forensic trail is gone. There were three independent ways the protection could be silently weakened:
- No Write-Once-Read-Many on the startup-hardened audit bucket. The startup-hardened tier hard-coded
objectLock: falseon the audit bucket. (The reason was real — bucket-wide Object Lock blocks an AWS Config write-then-delete probe — but the fix was a sledgehammer that disabled WORM for all objects, not just the probe key.) - **
forceDestroywas forwarded to the audit bucket.** Nothing prevented a downstream stack from settinglogBucketForceDestroy: true, which madepulumi destroypurge every audit-log object on teardown. - Sandbox tier dropped everything. Sandbox-tier
AccountFoundationcreated its audit bucket withtier: "sandbox", which skipped Object Lock, server access logging, AND the CloudTrail-LakeEventDataStore(the independent immutable mirror) — leaving sandbox accounts with no audit immutability at all.
Impact
Consumers using AccountFoundation could ship an AWS account whose CloudTrail / Config audit logs were deletable by any S3-delete-capable principal — while believing the startup-hardened tier guaranteed tamper-resistance. Sandbox-tier deployments had no audit immutability at all (defects 1 and 3 compounded).
Patches
Upgrade to @hulumi/baseline@1.4.0. A single invariant in SecureBucket now fires whenever the bucket actually backs CloudTrail/Config delivery (i.e. awsServiceLogDelivery.cloudTrail === true || .config === true):
- refuses
forceDestroy: trueon the startup-hardened tier; - emits the CloudTrail-Lake
EventDataStoreregardless of parent tier (so sandbox accounts regain immutable audit capture); - adds a deny-
s3:DeleteObject*bucket-policy statement scoped to the CloudTrail and Config history/snapshot prefixes (a retention floor on the audit objects). The deny excludes the AWS ConfigConfigWritabilityCheckFileprobe key so Config's write-then-delete still works, which is why bucket-wide Object Lock is intentionally NOT re-enabled.
Workarounds
Replicating audit logs out-of-account to an Object-Locked archive bucket partially mitigates while you upgrade.
Resources
- PR #178 (Cluster C); see CHANGELOG
### Migrationfor theforceDestroybehaviour change.
Affected products
1Patches
1070da5d31424security: fix 4 HIGH + 15 MEDIUM Codex findings (8 root-cause clusters) — DCO-signed replacement for #177 (#178)
49 files changed · +3612 −94
docs/deployment/sandbox-account.md+12 −2 modified@@ -203,8 +203,18 @@ Then set a repository **secret**: | `PULUMI_BACKEND_URL` | `s3://hulumi-pulumi-state-<account-id>?region=us-east-1` | The workflow refuses state bucket names that do not start with -`hulumi-` and end with the sandbox account ID. That prevents accidentally -pointing an open-source CI run at a production or shared state bucket. +`hulumi-` and end with the sandbox account ID. That name-shape check is a +first-line guard against an obvious typo pointing CI at a differently +named bucket — it is **not** an ownership control. A bucket name is not a +trust boundary: any AWS account can pre-create a bucket whose name embeds +our account ID. Ownership is enforced separately and authoritatively: the +weekly-integration and e2e-cleanup workflows resolve the bucket's +canonical owner (`aws s3api get-bucket-acl … Owner.ID`) and pass +`--expected-bucket-owner <sandbox-account-id>` on every `s3api` call that +touches the state bucket, failing the job closed before any state I/O, +bucket hardening, or `pulumi destroy` if the owner is not the sandbox +account. That prevents pointing an open-source CI run at a production, +shared, or attacker-controlled state bucket. The backend bucket is not part of the stacks under test, so normal `pulumi destroy` runs do not delete it or its versioned state objects. Treat backend retention and lifecycle expiration as an explicit sandbox
docs/integration-testing.md+10 −6 modified@@ -80,13 +80,17 @@ curious readers: ## Failed-run cleanup -If a real-AWS run fails during teardown, use the maintainer-only +If a real-AWS run fails during teardown, use the [`e2e-cleanup`](../.github/workflows/e2e-cleanup.yml) workflow instead -of ad hoc console deletion. Pass the 10-character suffix from the failed -stack name (`sandbox-<suffix>`). The cleanup script selects that Pulumi -stack from the private backend, drains only S3 buckets whose physical -name starts with `af-e2e-<suffix>-`, then runs `pulumi destroy` and -`removeStack`. +of ad hoc console deletion. It is destructive (drains S3 + `pulumi +destroy` + `removeStack`) so it is gated behind the protected +`e2e-cleanup` GitHub Environment (required reviewers) and only runs on +`refs/heads/main`; it also verifies the state bucket is owned by the +sandbox account before destroying anything. Pass the 10-character suffix +from the failed stack name (`sandbox-<suffix>`). The cleanup script +selects that Pulumi stack from the private backend, drains only S3 +buckets whose physical name starts with `af-e2e-<suffix>-`, then runs +`pulumi destroy` and `removeStack`. The cleanup path is intentionally Pulumi-state driven. `@hulumi/drift` is useful for classifying drift, but it is not a deletion engine and its
.github/workflows/drift-reconciler-cleanup.yml+7 −0 modified@@ -38,6 +38,13 @@ jobs: name: reconciler plan runs-on: ubuntu-latest timeout-minutes: 15 + # The plan role is read-only, but workflow_dispatch is fired with + # the actor's permissions and the job still consumes OIDC against + # the sandbox account. Gate behind a low-friction protected + # environment so the linter's WF_ENV_1 invariant holds without + # changing the read-only blast radius. (Maintainer must configure + # `aws-reconciler-plan` in repo settings — see PR body.) + environment: aws-reconciler-plan outputs: suffix: ${{ steps.env.outputs.suffix }} resource_prefix_hash: ${{ steps.env.outputs.resource_prefix_hash }}
.github/workflows/e2e-cleanup.yml+47 −0 modified@@ -21,6 +21,14 @@ jobs: name: cleanup AccountFoundation e2e stack runs-on: ubuntu-latest timeout-minutes: 15 + # Destructive: assumes the sandbox OIDC role, drains S3, runs + # `pulumi destroy`, and removes the stack. Gated behind a protected + # GitHub Environment with required reviewers (maintainer must + # configure `e2e-cleanup` with required reviewers — see PR body). + # `if:` is defense-in-depth so a non-main ref can never trigger it + # even before the environment review is reached. + environment: e2e-cleanup + if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -116,6 +124,45 @@ jobs: aws-region: ${{ steps.env.outputs.region }} role-session-name: hulumi-e2e-cleanup + - name: Verify state bucket is owned by the sandbox account + env: + PULUMI_BACKEND_URL: ${{ secrets.PULUMI_BACKEND_URL }} + AWS_SANDBOX_ACCOUNT_ID: ${{ secrets.AWS_SANDBOX_ACCOUNT_ID }} + run: | + # The name-shape check in the validation step (hulumi- prefix + + # account-id suffix) does not prove ownership. Before this + # workflow drains S3 and runs `pulumi destroy`, resolve the + # canonical bucket owner and fail closed unless it is the + # sandbox account. Pulumi-Cloud-token mode has no bucket — skip. + : "${AWS_SANDBOX_ACCOUNT_ID:?AWS_SANDBOX_ACCOUNT_ID secret not set}" + if [ -z "${PULUMI_BACKEND_URL:-}" ]; then + echo "No S3 backend URL — Pulumi Cloud token mode, no bucket to verify." + exit 0 + fi + BUCKET="$(node -e 'process.stdout.write(new URL(process.env.PULUMI_BACKEND_URL).hostname)')" + echo "::add-mask::$BUCKET" + if [ -z "$BUCKET" ]; then + echo "::error::Could not resolve state bucket from PULUMI_BACKEND_URL." + exit 1 + fi + if aws s3api head-bucket \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" >/dev/null 2>&1; then + OWNER_ID="$(aws s3api get-bucket-acl \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" \ + --query 'Owner.ID' --output text)" + CANONICAL_ID="$(aws s3api list-buckets --query 'Owner.ID' --output text)" + if [ -z "$OWNER_ID" ] || [ "$OWNER_ID" != "$CANONICAL_ID" ]; then + echo "::error::State bucket owner does not match the sandbox account canonical user. Refusing to destroy." + exit 1 + fi + echo "State bucket ownership verified against the sandbox account." + else + echo "::error::State bucket is missing or not owned by the sandbox account. Refusing to destroy." + exit 1 + fi + - name: Build cleanup dependencies run: pnpm --filter @hulumi/drift build
.github/workflows/weekly-integration.yml+51 −0 modified@@ -39,6 +39,13 @@ jobs: name: account-foundation integration (real AWS) runs-on: ubuntu-latest timeout-minutes: 30 + # Scheduled runs do not need an environment review (no actor), but + # the workflow_dispatch path is fired with the dispatching actor's + # permissions and consumes OIDC against the sandbox account. Gate + # behind a protected environment so any manual dispatch must clear + # required reviewers. (Maintainer must configure + # `aws-weekly-integration` in repo settings — see PR body.) + environment: aws-weekly-integration strategy: fail-fast: false # AccountFoundation touches account-wide services such as Config, @@ -153,12 +160,53 @@ jobs: aws-region: ${{ vars.AWS_SANDBOX_REGION }} role-session-name: hulumi-weekly-integration-${{ matrix.tier }} + - name: Verify state bucket is owned by the sandbox account + if: steps.env.outputs.backend_mode == 's3' + env: + STATE_BUCKET: ${{ steps.env.outputs.state_bucket }} + AWS_SANDBOX_ACCOUNT_ID: ${{ secrets.AWS_SANDBOX_ACCOUNT_ID }} + run: | + # Name-shape checks (hulumi- prefix + account-id suffix) do not + # prove ownership: an attacker can pre-create a bucket whose + # name embeds our account ID in another AWS account. Resolve + # the canonical owner and fail closed on mismatch BEFORE any + # state I/O or bucket hardening writes. The `s3api` calls below + # additionally pass --expected-bucket-owner as defense in depth. + : "${AWS_SANDBOX_ACCOUNT_ID:?AWS_SANDBOX_ACCOUNT_ID secret not set}" + if aws s3api head-bucket \ + --bucket "$STATE_BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" >/dev/null 2>&1; then + OWNER_ID="$(aws s3api get-bucket-acl \ + --bucket "$STATE_BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" \ + --query 'Owner.ID' --output text)" + CANONICAL_ID="$(aws s3api list-buckets \ + --query 'Owner.ID' --output text)" + if [ -z "$OWNER_ID" ] || [ "$OWNER_ID" != "$CANONICAL_ID" ]; then + echo "::error::State bucket owner does not match the sandbox account canonical user. Refusing to touch it." + exit 1 + fi + echo "State bucket ownership verified against the sandbox account." + else + # head-bucket failed under --expected-bucket-owner: either the + # bucket does not exist yet (we will create it below, owned by + # this account) or it exists but is NOT owned by the sandbox + # account (403). Distinguish the two — fail closed on 403. + if aws s3api head-bucket --bucket "$STATE_BUCKET" >/dev/null 2>&1; then + echo "::error::State bucket exists but is not owned by the sandbox account. Refusing to touch it." + exit 1 + fi + echo "State bucket does not exist yet — it will be created in this account by the hardening step." + fi + - name: Ensure S3 Pulumi state backend is hardened if: steps.env.outputs.backend_mode == 's3' env: STATE_BUCKET: ${{ steps.env.outputs.state_bucket }} STATE_REGION: ${{ steps.env.outputs.state_region }} + AWS_SANDBOX_ACCOUNT_ID: ${{ secrets.AWS_SANDBOX_ACCOUNT_ID }} run: | + : "${AWS_SANDBOX_ACCOUNT_ID:?AWS_SANDBOX_ACCOUNT_ID secret not set}" if ! aws s3api head-bucket --bucket "$STATE_BUCKET" >/dev/null 2>&1; then if [ "$STATE_REGION" = "us-east-1" ]; then aws s3api create-bucket --bucket "$STATE_BUCKET" --region "$STATE_REGION" @@ -172,15 +220,18 @@ jobs: aws s3api put-public-access-block \ --bucket "$STATE_BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" \ --public-access-block-configuration \ BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true aws s3api put-bucket-versioning \ --bucket "$STATE_BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" \ --versioning-configuration Status=Enabled aws s3api put-bucket-encryption \ --bucket "$STATE_BUCKET" \ + --expected-bucket-owner "$AWS_SANDBOX_ACCOUNT_ID" \ --server-side-encryption-configuration \ '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'
packages/baseline/src/aws/guardduty.ts+79 −0 modified@@ -4,6 +4,24 @@ // Startup-Hardened: enables S3Protection + MalwareProtection + // RuntimeMonitoring + RDSProtection + EKSAuditLogs as separate // `aws.guardduty.DetectorFeature` resources (the modern API). +// +// Reuse path posture assertion (M-DETECTIVEREUSE): +// when `existingDetectorId` is supplied we import the detector via +// `Detector.get`, then call `aws.guardduty.getDetectorOutput` to +// observe its current posture. A `.apply` throws if the detector is +// not ENABLED + FIFTEEN_MINUTES so the deployment fails rather than +// silently inheriting a suspended / SIX_HOURS / non-Hulumi detector. +// +// The verified Output is exposed on the returned `Detector` resource +// as a non-enumerable `__hulumiVerifiedDetectorId` property and is +// consumed by `securityhub.ts` (which is the next baseline component +// AccountFoundation composes after GuardDuty). Threading the +// verification through ONE downstream consumer is intentional: it +// keeps the deployment-abort contract intact (Pulumi engine refuses +// to register the CIS standards subscription if verification fails) +// while minimising the fan-out of rejected-output observers that +// would otherwise emit spurious `unhandledRejection` events in the +// vitest mock runtime. import * as pulumi from "@pulumi/pulumi"; import * as aws from "@pulumi/aws"; @@ -18,6 +36,9 @@ export const GUARDDUTY_HARDENED_FEATURES = [ "RUNTIME_MONITORING", ] as const; +export const GUARDDUTY_REQUIRED_STATUS = "ENABLED"; +export const GUARDDUTY_REQUIRED_FREQUENCY = "FIFTEEN_MINUTES"; + export interface GuardDutyHelperArgs { tier: Tier; parent: pulumi.Resource; @@ -31,6 +52,15 @@ export interface GuardDutyHelperResult { features: aws.guardduty.DetectorFeature[]; } +/** + * Non-enumerable property stashed on a reused `Detector` resource to + * carry the posture-verified id. Consumed by `securityhub.ts` to gate + * the standards-subscription registration on the assertion succeeding. + * + * @internal + */ +export const VERIFIED_DETECTOR_ID_KEY = "__hulumiVerifiedDetectorId" as const; + export function createGuardDuty(args: GuardDutyHelperArgs): GuardDutyHelperResult { const parent = { parent: args.parent } as const; @@ -56,6 +86,55 @@ export function createGuardDuty(args: GuardDutyHelperArgs): GuardDutyHelperResul parent, ); + if (args.existingDetectorId !== undefined) { + // Posture assertion for the reuse path. `Detector.get` only verifies + // EXISTENCE; AWS happily returns a SUSPENDED detector or one publishing + // every SIX_HOURS. We invoke the read-only `getDetector` data source + // and produce a `verifiedId` Output that throws via `.apply` if the + // posture is non-compliant. + // + // Critical: build `verifiedId` from `pulumi.output(args.existingDetectorId)` + // — NOT from `detector.id`. Pulumi's invoke path `await`s every + // CustomResource dependency's `id.isKnown`, so threading the + // invoke's own result back into `detector.id` would deadlock. + // Reading the id from the user-supplied Input breaks any cycle — + // the existingDetectorId is what we want to validate anyway. + // + // We stash `verifiedId` on the returned `Detector` resource via a + // non-enumerable property (so it does not interfere with Pulumi's + // resource serialization). `securityhub.ts` reads it and folds it + // into the standards-subscription input chain, which gates the + // deployment behind the assertion without fanning the rejected + // output across every component output. + const sourceId: pulumi.Output<string> = pulumi.output(args.existingDetectorId); + const posture = aws.guardduty.getDetectorOutput({ id: sourceId }, { parent: args.parent }); + const verifiedId: pulumi.Output<string> = pulumi + .all([sourceId, posture.status, posture.findingPublishingFrequency]) + .apply(([id, status, frequency]) => { + if (status !== GUARDDUTY_REQUIRED_STATUS) { + throw new Error( + `AccountFoundation: reused GuardDuty detector ${id} has status="${status}"; ` + + `Hulumi baseline requires status="${GUARDDUTY_REQUIRED_STATUS}". ` + + `Re-enable the detector or omit existingGuardDutyDetectorId so Hulumi can create a baseline-compliant detector.`, + ); + } + if (frequency !== GUARDDUTY_REQUIRED_FREQUENCY) { + throw new Error( + `AccountFoundation: reused GuardDuty detector ${id} has findingPublishingFrequency="${frequency}"; ` + + `Hulumi baseline requires findingPublishingFrequency="${GUARDDUTY_REQUIRED_FREQUENCY}". ` + + `Update the detector's finding publishing frequency to FIFTEEN_MINUTES or omit existingGuardDutyDetectorId.`, + ); + } + return id; + }); + Object.defineProperty(detector, VERIFIED_DETECTOR_ID_KEY, { + value: verifiedId, + enumerable: false, + writable: false, + configurable: false, + }); + } + const features: aws.guardduty.DetectorFeature[] = []; if (args.tier === "startup-hardened") { for (const feature of GUARDDUTY_HARDENED_FEATURES) {
packages/baseline/src/aws/secure-bucket.ts+66 −0 modified@@ -61,6 +61,26 @@ export class SecureBucket extends pulumi.ComponentResource implements SecureBuck throw new Error("Startup-Hardened requires logBucketArn; see docs/tiers.md"); } + // This bucket genuinely backs CloudTrail and/or AWS Config delivery — + // it is an audit-record sink, not an arbitrary data bucket. Its + // tamper-resistance is keyed off this declared FUNCTION, independent of + // the parent tier, so the invariant holds at one chokepoint. + const backsAuditDelivery = + args.awsServiceLogDelivery?.cloudTrail === true || + args.awsServiceLogDelivery?.config === true; + + // M-FORCEDESTROY block: a Startup-Hardened bucket that backs the audit + // trail must never be silently tear-down-able. forceDestroy stays fully + // usable for non-audit buckets and for sandbox-tier ephemeral e2e + // stacks (which legitimately `pulumi destroy`). + if (backsAuditDelivery && args.forceDestroy === true && args.tier === "startup-hardened") { + throw new Error( + "Startup-Hardened audit-delivery bucket (CloudTrail/Config) must not set " + + "forceDestroy: destroying it would erase tamper-evident audit records; " + + "see docs/tiers.md", + ); + } + // Object Lock is the Startup-Hardened default, but a consumer may // opt out with `objectLock: false` — e.g. the AWS Config / CloudTrail // delivery bucket, whose delivery validation is incompatible with @@ -232,6 +252,41 @@ export class SecureBucket extends pulumi.ComponentResource implements SecureBuck ); } + // Retention floor WITHOUT bucket-wide Object Lock. AWS Config's + // PutDeliveryChannel write-then-delete probe writes/deletes a + // single key (AWSLogs/<acct>/Config/ConfigWritabilityCheckFile), + // so a bucket-wide Object Lock default retention breaks Config + // (InsufficientDeliveryPolicyException) — which is why this + // bucket sets objectLock:false. Instead, a scoped deny makes the + // delivered audit records tamper-resistant while deliberately + // EXCLUDING the writability-check key (the deny targets only the + // CloudTrail log prefix and the Config ConfigHistory/ + // ConfigSnapshot prefixes — never the Config delivery prefix at + // large, and never the probe key — so Config's probe still + // round-trips). + if (backsAuditDelivery && args.tier === "startup-hardened") { + const protectedPrefixes: string[] = []; + if (args.awsServiceLogDelivery?.cloudTrail === true) { + protectedPrefixes.push(`${arn}/AWSLogs/${accountId}/CloudTrail/*`); + } + if (args.awsServiceLogDelivery?.config === true) { + protectedPrefixes.push( + `${arn}/AWSLogs/${accountId}/Config/ConfigHistory/*`, + `${arn}/AWSLogs/${accountId}/Config/ConfigSnapshot/*`, + ); + } + statements.push({ + Sid: "DenyAuditLogTampering", + Effect: "Deny", + Principal: "*", + Action: ["s3:DeleteObject", "s3:DeleteObjectVersion", "s3:PutBucketVersioning"], + // The bucket ARN guards s3:PutBucketVersioning (which acts + // on the bucket, not an object); the prefixes guard the + // delete actions on the delivered audit objects. + Resource: [arn, ...protectedPrefixes], + }); + } + return JSON.stringify({ Version: "2012-10-17", Statement: statements, @@ -260,6 +315,9 @@ export class SecureBucket extends pulumi.ComponentResource implements SecureBuck ); } + // BucketLogging stays tier-gated: server-access logging needs a + // pre-existing external target bucket, which the sandbox smoke path + // deliberately does not provision. if (args.tier === "startup-hardened") { new aws.s3.BucketLogging( `${name}-logging`, @@ -270,7 +328,15 @@ export class SecureBucket extends pulumi.ComponentResource implements SecureBuck }, childOptions(LEGACY_BUCKET_LOGGING_V2_TYPE), ); + } + // The CloudTrail-Lake EventDataStore has no external-target dependency, + // so it is keyed off the bucket's audit FUNCTION rather than the tier. + // This restores immutable audit capture for the sandbox + // AccountFoundation internal log bucket. Startup-Hardened with a + // logBucketArn but no audit-delivery role keeps the historical + // behaviour (EventDataStore emitted) for back-compat. + if (backsAuditDelivery || args.tier === "startup-hardened") { new aws.cloudtrail.EventDataStore( `${name}-data-events`, {
packages/baseline/src/aws/securityhub.ts+52 −4 modified@@ -14,11 +14,25 @@ // CreateDetector API resolves only after status === ENABLED, so the // dependsOn chain provides equivalent ordering for real deployments. // Documented in docs/slo/lessons/hulumi-m3.md. +// +// Reuse-destroy safety (M-DETECTIVEREUSE): +// When `useExistingAccount === true` we IMPORT the account-wide Security +// Hub via `Account.get` — destroying the stack does not disable the hub. +// But the CIS + NIST `StandardsSubscription` resources we register are +// net-new and their default destroy behaviour calls +// BatchDisableStandards account-wide. That would silently down-grade +// the reused account's monitoring posture on `pulumi destroy`. The fix +// is `retainOnDelete: true` on the subscription resources for the +// reuse path: destroy leaves the standards subscribed for whoever +// originally owned the account, while net-new deploys retain the +// original delete-and-unsubscribe semantics (Hulumi owns the lifecycle +// end-to-end, so destroy correctly cleans up everything it created). import * as pulumi from "@pulumi/pulumi"; import * as aws from "@pulumi/aws"; import type { Tier } from "./tier"; +import { VERIFIED_DETECTOR_ID_KEY } from "./guardduty"; export const CIS_V5_STANDARD_ARN_PARTIAL = "standards/cis-aws-foundations-benchmark/v/5.0.0"; export const NIST_800_53_R5_STANDARD_ARN_PARTIAL = "standards/nist-800-53/v/5.0.0"; @@ -65,23 +79,57 @@ export function createSecurityHub(args: SecurityHubHelperArgs): SecurityHubHelpe { parent: args.parent, dependsOn: guardDutyReadyDeps }, ); + // Reuse path: retain subscriptions on destroy so a `pulumi destroy` + // never executes BatchDisableStandards against an account-wide hub + // Hulumi did not create. Net-new path: keep default delete semantics + // — Hulumi owns the account and the subscriptions, and destroy + // should clean them up symmetrically. + const retainOnDelete = args.useExistingAccount === true; + + // Fold the GuardDuty reuse-path posture-verified Output (M-DETECTIVEREUSE) + // into the standardsArn input of the CIS / NIST subscriptions when + // present. The verified Output throws on bad posture, which Pulumi + // propagates as a deployment failure when it tries to register the + // subscription resource. Concentrating the assertion in this single + // input chain (instead of overwriting `detector.id`, which fans out + // through every Pulumi-engine-tracked downstream consumer) keeps the + // deployment-abort contract intact while minimising spurious + // unhandled-rejection noise from the mock runtime. + const verifiedDetectorId = ( + args.guardDutyDetector as unknown as Record<string, pulumi.Output<string> | undefined> + )[VERIFIED_DETECTOR_ID_KEY]; + + const gatedCisArn: pulumi.Output<string> = pulumi + .all([ + pulumi.interpolate`arn:aws:securityhub:${args.region}::${CIS_V5_STANDARD_ARN_PARTIAL}`, + verifiedDetectorId ?? pulumi.output<string | undefined>(undefined), + ]) + .apply(([arn]) => arn); + const cisSubscription = new aws.securityhub.StandardsSubscription( `${args.namePrefix}-securityhub-cis-v5`, { - standardsArn: pulumi.interpolate`arn:aws:securityhub:${args.region}::${CIS_V5_STANDARD_ARN_PARTIAL}`, + standardsArn: gatedCisArn, }, - { parent: args.parent, dependsOn: [hub] }, + { parent: args.parent, dependsOn: [hub], retainOnDelete }, ); const result: SecurityHubHelperResult = { hub, cisSubscription }; if (args.tier === "startup-hardened") { + const gatedNistArn: pulumi.Output<string> = pulumi + .all([ + pulumi.interpolate`arn:aws:securityhub:${args.region}::${NIST_800_53_R5_STANDARD_ARN_PARTIAL}`, + verifiedDetectorId ?? pulumi.output<string | undefined>(undefined), + ]) + .apply(([arn]) => arn); + result.nistSubscription = new aws.securityhub.StandardsSubscription( `${args.namePrefix}-securityhub-nist-800-53-r5`, { - standardsArn: pulumi.interpolate`arn:aws:securityhub:${args.region}::${NIST_800_53_R5_STANDARD_ARN_PARTIAL}`, + standardsArn: gatedNistArn, }, - { parent: args.parent, dependsOn: [hub] }, + { parent: args.parent, dependsOn: [hub], retainOnDelete }, ); }
packages/baseline/tests/account-foundation.test.ts+150 −1 modified@@ -5,13 +5,81 @@ import { describe, it, expect, beforeEach } from "vitest"; import { resolve } from "node:path"; +import * as pulumi from "@pulumi/pulumi"; import { AccountFoundation } from "../src/aws/account-foundation"; import { GUARDDUTY_HARDENED_FEATURES } from "../src/aws/guardduty"; import { AWS_TAG_VALUE_MAX_LENGTH } from "../src/aws/tags"; import { registrations, resetRegistrations, valueOf, settlePulumi } from "./setup"; import { expectNoForbiddenShortcuts } from "../../../tests/_utils/forbidden-shortcut"; +/** + * Install a healthy-posture stub for the `aws:guardduty/getDetector` + * invoke. The reused-detector posture assertion in guardduty.ts (see + * M-DETECTIVEREUSE) calls `getDetectorOutput({id})` and throws unless + * `status === "ENABLED"` and `findingPublishingFrequency === + * "FIFTEEN_MINUTES"`. The shared `setup.ts` mock returns `args.inputs` + * for unknown invoke tokens (no `status` field), so reuse-path tests + * here must layer in a tuned mock that returns a baseline-compliant + * detector. Dedicated negative-path tests for non-ENABLED / + * non-FIFTEEN_MINUTES live in tests/guardduty-reuse-posture.test.ts + * (their own per-file mock isolation). + */ +function installHealthyGuardDutyDetectorMock(): void { + pulumi.runtime.setMocks({ + newResource: (args: pulumi.runtime.MockResourceArgs) => { + const existingId = args.id !== undefined && args.id.length > 0 ? args.id : undefined; + registrations.push({ + type: args.type, + name: args.name, + inputs: { ...(args.inputs as Record<string, unknown>) }, + ...(existingId !== undefined ? { id: existingId } : {}), + ...(args.provider !== undefined ? { provider: args.provider } : {}), + }); + const baseState: Record<string, unknown> = { ...(args.inputs as Record<string, unknown>) }; + if (args.type.startsWith("aws:s3/bucketV2") || args.type.startsWith("aws:s3/bucket")) { + baseState.arn = baseState.arn ?? `arn:aws:s3:::${args.name}-mock`; + baseState.bucketDomainName = + baseState.bucketDomainName ?? `${args.name}-mock.s3.amazonaws.com`; + } else if (args.type === "aws:cloudwatch/logGroup:LogGroup") { + baseState.name = baseState.name ?? args.name; + } else { + baseState.arn = baseState.arn ?? `arn:aws:mock:${args.type}:${args.name}`; + } + return { id: existingId ?? `${args.name}_id`, state: baseState }; + }, + call: (args: pulumi.runtime.MockCallArgs) => { + if (args.token === "aws:index/getCallerIdentity:getCallerIdentity") { + return { + accountId: "111122223333", + arn: "arn:aws:iam::111122223333:user/mock", + userId: "MOCKID", + }; + } + if (args.token === "aws:index/getRegion:getRegion") { + return { + name: "us-east-1", + description: "US East (N. Virginia)", + endpoint: "ec2.us-east-1.amazonaws.com", + }; + } + if (args.token === "aws:guardduty/getDetector:getDetector") { + return { + id: (args.inputs as { id?: string }).id ?? "mock-detector", + arn: "arn:aws:guardduty:us-east-1:111122223333:detector/mock", + region: "us-east-1", + serviceRoleArn: "arn:aws:iam::111122223333:role/aws-service-role/guardduty", + tags: {}, + features: [], + status: "ENABLED", + findingPublishingFrequency: "FIFTEEN_MINUTES", + }; + } + return args.inputs; + }, + }); +} + const IAC_ROLE_ARN = "arn:aws:iam::111122223333:role/hulumi-sandbox-iac-role"; const SANDBOX_TYPES = [ @@ -368,7 +436,17 @@ describe("AccountFoundation — real provider input compatibility", () => { expect(ephemeralBucket?.inputs.forceDestroy).toBe(true); }); - it("references an existing GuardDuty detector without creating a new one", async () => { + // M-DETECTIVEREUSE: the reuse path now also asserts posture + // (status === ENABLED && findingPublishingFrequency === FIFTEEN_MINUTES) + // via `aws.guardduty.getDetectorOutput`. We install a baseline-compliant + // detector mock so the *structural* invariants this test was originally + // written for — no new Detector resource is created with `enable`/tags + // — still hold under the strengthened reuse contract. Negative-path + // posture tests (SUSPENDED / SIX_HOURS) live in + // tests/guardduty-reuse-posture.test.ts which uses per-file mock + // isolation to drive the failure branches. + it("references an existing GuardDuty detector without creating a new one (posture-validated)", async () => { + installHealthyGuardDutyDetectorMock(); const existingDetectorId = "existing-detector-123"; const af = new AccountFoundation("af-existing-guardduty", { tier: "sandbox", @@ -604,6 +682,77 @@ describe("AccountFoundation — CloudTrail log group output for downstream alarm }); }); +describe("AccountFoundation — audit-delivery bucket tamper-resistance invariant (HIGH)", () => { + beforeEach(resetRegistrations); + + it("sandbox internal log bucket still emits the CloudTrail-Lake EventDataStore (immutable audit capture)", async () => { + const af = new AccountFoundation("af-sandbox-eds", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + }); + await valueOf(af.cloudTrailArn); + await settlePulumi(); + + const eds = registrations.find( + (r) => r.type === "aws:cloudtrail/eventDataStore:EventDataStore", + ); + expect(eds).toBeDefined(); + expect(eds!.inputs.retentionPeriod).toBe(7); + }); + + it("startup-hardened internal log bucket carries the deny-audit-tampering invariant, sparing the Config writability-check key", async () => { + const af = new AccountFoundation("af-hardened-deny", { + tier: "startup-hardened", + iacRoleArn: IAC_ROLE_ARN, + orgAccountIds: ["111111111111"], + }); + await valueOf(af.cloudTrailArn); + await settlePulumi(); + + const bucketPolicy = registrations.find((r) => r.type === "aws:s3/bucketPolicy:BucketPolicy"); + expect(bucketPolicy).toBeDefined(); + const doc = parsePolicy(bucketPolicy!.inputs.policy); + const deny = doc.Statement.find((s) => s.Sid === "DenyAuditLogTampering"); + expect(deny).toBeDefined(); + expect(deny!.Effect).toBe("Deny"); + expect(deny!.Action).toEqual([ + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:PutBucketVersioning", + ]); + const resources = deny!.Resource as string[]; + expect(resources.some((r) => r.includes("/AWSLogs/111122223333/CloudTrail/*"))).toBe(true); + expect(resources.some((r) => r.includes("/AWSLogs/111122223333/Config/ConfigHistory/*"))).toBe( + true, + ); + expect(JSON.stringify(deny)).not.toContain("ConfigWritabilityCheckFile"); + }); + + it("startup-hardened AccountFoundation rejects logBucketForceDestroy=true on the audit bucket", () => { + expect( + () => + new AccountFoundation("af-hardened-fd", { + tier: "startup-hardened", + iacRoleArn: IAC_ROLE_ARN, + orgAccountIds: ["111111111111"], + logBucketForceDestroy: true, + }), + ).toThrowError(/audit.*forceDestroy.*docs\/tiers\.md/i); + }); + + it("sandbox AccountFoundation still honors logBucketForceDestroy=true (ephemeral e2e cleanup)", async () => { + const af = new AccountFoundation("af-sandbox-fd", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + logBucketForceDestroy: true, + }); + await valueOf(af.cloudTrailArn); + await settlePulumi(); + const bucket = registrations.find((r) => r.type === "aws:s3/bucket:Bucket"); + expect(bucket?.inputs.forceDestroy).toBe(true); + }); +}); + describe("AccountFoundation — no sleep / setTimeout in component-composition source", () => { it("packages/baseline/src/aws/ has zero setTimeout / sleep / await new Promise occurrences outside probes/", () => { expectNoForbiddenShortcuts({
packages/baseline/tests/guardduty-reuse-posture.test.ts+254 −0 added@@ -0,0 +1,254 @@ +// Regression: M-DETECTIVEREUSE GuardDuty arm. +// +// When `existingGuardDutyDetectorId` is supplied, AccountFoundation must +// not silently accept a suspended or non-FIFTEEN_MINUTES detector. The +// reuse path must invoke aws.guardduty.getDetector and assert posture +// (status === "ENABLED" && findingPublishingFrequency === +// "FIFTEEN_MINUTES"); a mismatched detector must fail the deployment. +// +// Production wiring: the verified Output is folded into the CIS / NIST +// `standardsArn` input chain in securityhub.ts. On bad posture the +// Output rejects, Pulumi refuses to register the subscription resource, +// and the deploy aborts. The test observes this by wrapping +// `aws.securityhub.StandardsSubscription` and awaiting its +// `standardsArn` input — when the assertion throws, the wrapped Output +// rejects with the same error. +// +// Vitest isolates test files (default pool: threads, isolate: true), so +// the per-file `pulumi.runtime.setMocks` and constructor wrapper below +// only affect this file's resource graph. + +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; +import * as aws from "@pulumi/aws"; +import * as pulumi from "@pulumi/pulumi"; + +import { registrations, resetRegistrations, valueOf, settlePulumi } from "./setup"; + +const IAC_ROLE_ARN = "arn:aws:iam::111122223333:role/hulumi-sandbox-iac-role"; +const EXISTING_ID = "existing-detector-123"; + +// Per-test mutable mock state for the aws:guardduty/getDetector invoke. +let mockDetectorPosture: { status: string; findingPublishingFrequency: string } = { + status: "ENABLED", + findingPublishingFrequency: "FIFTEEN_MINUTES", +}; + +// Capture the `standardsArn` Output of every StandardsSubscription +// constructed in this test file. The negative-path tests await these +// to observe whether the posture-gated chain throws. +const subscriptionStandardsArns: pulumi.Output<string>[] = []; + +const OriginalStandardsSubscription = aws.securityhub.StandardsSubscription; +class InstrumentedStandardsSubscription extends OriginalStandardsSubscription { + constructor( + name: string, + args: aws.securityhub.StandardsSubscriptionArgs, + opts?: pulumi.CustomResourceOptions, + ) { + subscriptionStandardsArns.push(pulumi.output(args.standardsArn)); + super(name, args, opts); + } +} + +beforeAll(() => { + Object.defineProperty(aws.securityhub, "StandardsSubscription", { + value: InstrumentedStandardsSubscription, + writable: true, + configurable: true, + }); +}); + +afterAll(() => { + Object.defineProperty(aws.securityhub, "StandardsSubscription", { + value: OriginalStandardsSubscription, + writable: true, + configurable: true, + }); +}); + +// Re-register Pulumi mocks for this file's worker. Extends the global +// setup.ts mocks with a tuned call() that returns the configured +// detector posture for getDetector invokes. +pulumi.runtime.setMocks({ + newResource: (args: pulumi.runtime.MockResourceArgs) => { + const existingId = args.id !== undefined && args.id.length > 0 ? args.id : undefined; + registrations.push({ + type: args.type, + name: args.name, + inputs: { ...(args.inputs as Record<string, unknown>) }, + ...(existingId !== undefined ? { id: existingId } : {}), + ...(args.provider !== undefined ? { provider: args.provider } : {}), + }); + const baseState: Record<string, unknown> = { ...(args.inputs as Record<string, unknown>) }; + if (args.type.startsWith("aws:s3/bucketV2") || args.type.startsWith("aws:s3/bucket")) { + baseState.arn = baseState.arn ?? `arn:aws:s3:::${args.name}-mock`; + baseState.bucketDomainName = + baseState.bucketDomainName ?? `${args.name}-mock.s3.amazonaws.com`; + } else if (args.type === "aws:cloudwatch/logGroup:LogGroup") { + baseState.name = baseState.name ?? args.name; + } else { + baseState.arn = baseState.arn ?? `arn:aws:mock:${args.type}:${args.name}`; + } + return { + id: existingId ?? `${args.name}_id`, + state: baseState, + }; + }, + call: (args: pulumi.runtime.MockCallArgs) => { + if (args.token === "aws:index/getCallerIdentity:getCallerIdentity") { + return { + accountId: "111122223333", + arn: "arn:aws:iam::111122223333:user/mock", + userId: "MOCKID", + }; + } + if (args.token === "aws:index/getRegion:getRegion") { + return { + name: "us-east-1", + description: "US East (N. Virginia)", + endpoint: "ec2.us-east-1.amazonaws.com", + }; + } + if (args.token === "aws:guardduty/getDetector:getDetector") { + return { + id: (args.inputs as { id?: string }).id ?? EXISTING_ID, + arn: `arn:aws:guardduty:us-east-1:111122223333:detector/${EXISTING_ID}`, + region: "us-east-1", + serviceRoleArn: "arn:aws:iam::111122223333:role/aws-service-role/guardduty", + tags: {}, + features: [], + status: mockDetectorPosture.status, + findingPublishingFrequency: mockDetectorPosture.findingPublishingFrequency, + }; + } + return args.inputs; + }, +}); + +// AccountFoundation is imported AFTER setMocks so any module-load +// side effects (none currently) see the per-file mocks. +import { AccountFoundation } from "../src/aws/account-foundation"; + +// Helper to await a Pulumi Output that may reject (valueOf in setup.ts +// only resolves on success). Used by the negative-path tests below. +function valueOfOrThrow<T>(output: pulumi.Output<T>): Promise<T> { + return new Promise((resolve, reject) => { + try { + output.apply((value: T) => { + resolve(value); + return value; + }); + } catch (e) { + reject(e); + } + // Pulumi rejects via the underlying promise chain; observe it. + (output as unknown as { promise: () => Promise<T> }).promise?.().catch(reject); + }); +} + +// On the negative-posture tests the rejected gated Output fans out +// across (a) the StandardsSubscription's registerResource input +// serialization, (b) this test file's wrapper capture, (c) Pulumi's +// internal apply chains. Each consumer creates its own promise that +// rejects independently. The test's primary assertion is captured by +// expect(...).rejects.toThrow on subscriptionStandardsArns[0]; the +// fan-out copies emit `unhandledRejection` events that vitest's +// listener captures and surfaces as worker-level errors. +// +// We swap vitest's unhandledRejection listeners with a wrapper that +// drops events whose error message matches the expected +// M-DETECTIVEREUSE posture pattern. Any other unhandled rejection +// still flows through to vitest. Restored in afterAll(). +const expectedPosturePattern = /Hulumi baseline requires (status|findingPublishingFrequency)=/; +type NodeListener = (...args: unknown[]) => unknown; +let savedListeners: NodeListener[] = []; + +beforeAll(() => { + savedListeners = process.listeners("unhandledRejection") as unknown as NodeListener[]; + process.removeAllListeners("unhandledRejection"); + for (const listener of savedListeners) { + process.on("unhandledRejection", (reason: unknown, promise: Promise<unknown>) => { + const msg = (reason as { message?: string } | undefined)?.message ?? String(reason); + if (expectedPosturePattern.test(msg)) { + return; + } + listener(reason, promise); + }); + } +}); + +afterAll(() => { + process.removeAllListeners("unhandledRejection"); + for (const listener of savedListeners) { + process.on("unhandledRejection", listener as NodeListener); + } +}); + +describe("M-DETECTIVEREUSE GuardDuty arm — reused detector posture is asserted", () => { + beforeEach(() => { + resetRegistrations(); + subscriptionStandardsArns.length = 0; + // Reset to a healthy default; each test overrides as needed. + mockDetectorPosture = { status: "ENABLED", findingPublishingFrequency: "FIFTEEN_MINUTES" }; + }); + + it("accepts a reused detector that is ENABLED + FIFTEEN_MINUTES", async () => { + mockDetectorPosture = { status: "ENABLED", findingPublishingFrequency: "FIFTEEN_MINUTES" }; + const af = new AccountFoundation("af-reuse-ok", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + existingGuardDutyDetectorId: EXISTING_ID, + }); + await expect(valueOf(af.guardDutyDetectorId)).resolves.toBe(EXISTING_ID); + await settlePulumi(); + // CIS subscription's posture-gated standardsArn resolves cleanly. + expect(subscriptionStandardsArns.length).toBeGreaterThan(0); + for (const arnOutput of subscriptionStandardsArns) { + await expect(valueOfOrThrow(arnOutput)).resolves.toMatch(/cis-aws-foundations/); + } + }); + + it("rejects a reused detector whose status is not ENABLED (e.g. SUSPENDED)", async () => { + mockDetectorPosture = { + status: "SUSPENDED", + findingPublishingFrequency: "FIFTEEN_MINUTES", + }; + new AccountFoundation("af-reuse-suspended", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + existingGuardDutyDetectorId: EXISTING_ID, + }); + // The standards-subscription standardsArn input is folded into the + // posture-verified Output, so it must reject on non-ENABLED status. + expect(subscriptionStandardsArns.length).toBeGreaterThan(0); + await expect(valueOfOrThrow(subscriptionStandardsArns[0])).rejects.toThrow(/ENABLED/); + }); + + it("rejects a reused detector whose findingPublishingFrequency is not FIFTEEN_MINUTES (e.g. SIX_HOURS)", async () => { + mockDetectorPosture = { + status: "ENABLED", + findingPublishingFrequency: "SIX_HOURS", + }; + new AccountFoundation("af-reuse-sixhours", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + existingGuardDutyDetectorId: EXISTING_ID, + }); + expect(subscriptionStandardsArns.length).toBeGreaterThan(0); + await expect(valueOfOrThrow(subscriptionStandardsArns[0])).rejects.toThrow(/FIFTEEN_MINUTES/); + }); + + it("net-new (non-reuse) deploys are unaffected by the posture invoke", async () => { + // No existingGuardDutyDetectorId → no getDetector invoke is required. + // The detector resource is created with FIFTEEN_MINUTES + enable=true. + const af = new AccountFoundation("af-netnew", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + }); + await expect(valueOf(af.guardDutyDetectorId)).resolves.toBeDefined(); + await settlePulumi(); + const detector = registrations.find((r) => r.type === "aws:guardduty/detector:Detector"); + expect(detector?.inputs.enable).toBe(true); + expect(detector?.inputs.findingPublishingFrequency).toBe("FIFTEEN_MINUTES"); + }); +});
packages/baseline/tests/integration/account-foundation.integration.test.ts+151 −15 modified@@ -270,13 +270,47 @@ async function cleanupStartupLoggingTargetBucket(): Promise<void> { } } +// Exact-shape predicates for the sweepers below. Affix-only filters +// (startsWith/endsWith) are too loose: an attacker-named or operator- +// created resource that happens to share the prefix and suffix would be +// silently deleted. The shapes here are derived from RESOURCE_PREFIX +// (`af-e2e-(sb|sh)-<10 lowercase hex>`) plus the suite's own resource +// templates in src/aws (see `${name}-logs-data-events` in +// secure-bucket.ts and `hulumi-${name}-access-analyzer` in +// iam-baseline.ts). The sweepers below match the EXACT regex AND log- +// and-skip anything that matches the affix but fails the exact regex — +// fail closed, do not delete. +export const E2E_ANALYZER_NAME_REGEX = + /^hulumi-af-e2e-(sb|sh)-[0-9a-f]{10}(?:-[A-Za-z0-9]+)*-access-analyzer$/; +export const E2E_EVENT_DATA_STORE_NAME_REGEX = + /^af-e2e-(sb|sh)-[0-9a-f]{10}(?:-[A-Za-z0-9]+)*-data-events$/; + +export function looksLikeE2eAnalyzerName(name: string): boolean { + return name.startsWith("hulumi-af-e2e-") && name.endsWith("-access-analyzer"); +} + +export function looksLikeE2eEventDataStoreName(name: string): boolean { + return name.startsWith("af-e2e-") && name.endsWith("-data-events"); +} + +export function isE2eAnalyzerName(name: string): boolean { + return E2E_ANALYZER_NAME_REGEX.test(name); +} + +export function isE2eEventDataStoreName(name: string): boolean { + return E2E_EVENT_DATA_STORE_NAME_REGEX.test(name); +} + // IAM Access Analyzer is a Startup-Hardened-only sub-resource and AWS // caps analyzers per account/region (default 1). A prior e2e run that // failed AFTER the analyzer was created can orphan it (a failed // `pulumi up` is not always reclaimed by destroy), exhausting the quota // and blocking every later run with ServiceQuotaExceededException. Sweep -// only THIS suite's own analyzers — names are `hulumi-af-e2e-*-access- -// analyzer` — so a real account/organization analyzer is never touched. +// only THIS suite's own analyzers — names match the EXACT generated +// shape `hulumi-af-e2e-(sb|sh)-<10 hex>-…-access-analyzer`. Candidates +// that match the loose affix but FAIL the exact regex are logged and +// skipped (fail closed), so a real account/organization analyzer is +// never touched. async function sweepStaleE2eAnalyzers(): Promise<void> { if (SELECTED_TIER !== "startup-hardened") return; let listed: { analyzers?: { name?: string }[] }; @@ -291,14 +325,19 @@ async function sweepStaleE2eAnalyzers(): Promise<void> { if (isMissingAwsResource(err)) return; throw err; } - const stale = (listed.analyzers ?? []) + const candidates = (listed.analyzers ?? []) .map((a) => a.name) - .filter( - (name): name is string => - typeof name === "string" && - name.startsWith("hulumi-af-e2e-") && - name.endsWith("-access-analyzer"), - ); + .filter((name): name is string => typeof name === "string" && looksLikeE2eAnalyzerName(name)); + const stale: string[] = []; + for (const name of candidates) { + if (isE2eAnalyzerName(name)) { + stale.push(name); + } else { + console.warn( + `[account-foundation-e2e] skipping access analyzer ${name}: matches affix but not the exact e2e shape; refusing to delete`, + ); + } + } for (const name of stale) { try { await aws(["accessanalyzer", "delete-analyzer", "--analyzer-name", name, "--region", REGION]); @@ -308,12 +347,22 @@ async function sweepStaleE2eAnalyzers(): Promise<void> { } } +// Maximum age (ms) for an EventDataStore created without a Hulumi-owned +// tag to still be considered a stale e2e leftover. Suite resources are +// short-lived (a single weekly run + occasional manual cleanup); 24h is +// generous slack for retries. Anything older is treated as not-ours. +const E2E_EVENT_DATA_STORE_MAX_AGE_MS = 24 * 60 * 60 * 1000; + // The Startup-Hardened SecureBucket creates a CloudTrail EventDataStore. // A prior e2e run that created one but failed before `destroy` (or whose // store predates the forceDestroy fix) leaves a TERMINATION-PROTECTED, -// billable store behind. Sweep only this suite's own stores — -// `af-e2e-*-data-events` — disabling termination protection first so the -// delete succeeds. Scoped by name so a real audit store is never touched. +// billable store behind. Sweep only this suite's own stores: names match +// the EXACT generated shape `af-e2e-(sb|sh)-<10 hex>-…-data-events` AND +// either carry a Hulumi-owned tag OR were created within the last 24h. +// Candidates that match the loose affix but FAIL the exact regex are +// logged and skipped (fail closed), as are candidates that match the +// exact regex but have neither a Hulumi tag nor a recent creation time. +// Disable termination protection first so the delete succeeds. async function sweepStaleE2eEventDataStores(): Promise<void> { if (SELECTED_TIER !== "startup-hardened") return; let listed: { @@ -322,6 +371,7 @@ async function sweepStaleE2eEventDataStores(): Promise<void> { Name?: string; TerminationProtectionEnabled?: boolean; Status?: string; + CreatedTimestamp?: string; }[]; }; try { @@ -330,21 +380,39 @@ async function sweepStaleE2eEventDataStores(): Promise<void> { if (isMissingAwsResource(err)) return; throw err; } - const stale = (listed.EventDataStores ?? []).filter( + const candidates = (listed.EventDataStores ?? []).filter( ( eds, ): eds is { EventDataStoreArn: string; Name: string; TerminationProtectionEnabled?: boolean; Status?: string; + CreatedTimestamp?: string; } => typeof eds.EventDataStoreArn === "string" && typeof eds.Name === "string" && - eds.Name.startsWith("af-e2e-") && - eds.Name.endsWith("-data-events") && + looksLikeE2eEventDataStoreName(eds.Name) && eds.Status !== "PENDING_DELETION", ); + const stale: typeof candidates = []; + for (const eds of candidates) { + if (!isE2eEventDataStoreName(eds.Name)) { + console.warn( + `[account-foundation-e2e] skipping EventDataStore ${eds.Name}: matches affix but not the exact e2e shape; refusing to delete`, + ); + continue; + } + const ownedByHulumi = await isEventDataStoreOwnedByHulumi(eds.EventDataStoreArn); + const recentEnough = isRecentEnough(eds.CreatedTimestamp); + if (!ownedByHulumi && !recentEnough) { + console.warn( + `[account-foundation-e2e] skipping EventDataStore ${eds.Name}: exact-name match but no Hulumi tag and not within the ${E2E_EVENT_DATA_STORE_MAX_AGE_MS}ms window; refusing to delete`, + ); + continue; + } + stale.push(eds); + } for (const eds of stale) { try { if (eds.TerminationProtectionEnabled === true) { @@ -372,6 +440,32 @@ async function sweepStaleE2eEventDataStores(): Promise<void> { } } +function isRecentEnough(createdTimestamp: string | undefined): boolean { + if (typeof createdTimestamp !== "string" || createdTimestamp.length === 0) return false; + const created = Date.parse(createdTimestamp); + if (Number.isNaN(created)) return false; + return Date.now() - created <= E2E_EVENT_DATA_STORE_MAX_AGE_MS; +} + +async function isEventDataStoreOwnedByHulumi(arn: string): Promise<boolean> { + try { + const response = await awsJson<{ + ResourceTagList?: { TagsList?: { Key?: string; Value?: string }[] }[]; + }>(["cloudtrail", "list-tags", "--resource-id-list", arn, "--region", REGION]); + const tagsList = response.ResourceTagList?.[0]?.TagsList ?? []; + return tagsList.some( + (tag) => + (tag.Key === "ManagedBy" && tag.Value === "Hulumi") || + (tag.Key === "hulumi:component" && typeof tag.Value === "string"), + ); + } catch (err) { + if (isMissingAwsResource(err)) return false; + // If list-tags fails for any other reason, fall back to creation-time + // (which the caller will also evaluate); do not infer ownership. + return false; + } +} + function configRecorderNameFromArn(arn: string): string { const marker = ":recorder/"; const index = arn.indexOf(marker); @@ -516,6 +610,48 @@ const skipReason = !RUN_INTEGRATION ? "no Pulumi backend configured — set PULUMI_BACKEND_URL or PULUMI_ACCESS_TOKEN" : "HULUMI_IAC_ROLE_ARN unset — AccountFoundation requires the IaC role ARN"; +describe("AccountFoundation — e2e sweep name predicates (always-on unit checks)", () => { + // The integration suite is gated behind real-AWS credentials and + // skips in normal CI. These unit-style assertions exercise the pure + // name predicates so the affix-vs-exact-shape contract that protects + // against deleting non-e2e resources is verified on every CI run. + it("accepts a real generated EventDataStore name and rejects adversarial affix matches", () => { + expect(isE2eEventDataStoreName("af-e2e-sb-0123456789-logs-data-events")).toBe(true); + expect(isE2eEventDataStoreName("af-e2e-sh-abcdef0123-logs-data-events")).toBe(true); + expect(isE2eEventDataStoreName("af-e2e-sb-0123456789-data-events")).toBe(true); + // Adversarial / unrelated names that match the loose affix but are + // NOT this suite's own resources MUST be rejected. + expect(isE2eEventDataStoreName("af-e2e-prod-audit-data-events")).toBe(false); + expect(isE2eEventDataStoreName("af-e2e-sb-data-events")).toBe(false); + expect(isE2eEventDataStoreName("af-e2e-sb-ZZZZZZZZZZ-logs-data-events")).toBe(false); + expect(isE2eEventDataStoreName("af-e2e-xx-0123456789-logs-data-events")).toBe(false); + expect(isE2eEventDataStoreName("not-an-e2e-data-events")).toBe(false); + // Random suffix appended by autonaming would break the exact regex — + // the sweep then logs-and-skips rather than deleting, which is the + // intended fail-closed behaviour. + expect(isE2eEventDataStoreName("af-e2e-sh-abcdef0123-logs-data-events-7a3b5c1")).toBe(false); + // But the loose affix predicate (the candidate filter) must still + // accept adversarial-shaped names so the fail-closed branch is + // exercised in production — otherwise we would silently never log + // the skip warning. + expect(looksLikeE2eEventDataStoreName("af-e2e-prod-audit-data-events")).toBe(true); + expect(looksLikeE2eEventDataStoreName("af-e2e-sb-0123456789-logs-data-events")).toBe(true); + expect(looksLikeE2eEventDataStoreName("hulumi-pulumi-state")).toBe(false); + }); + + it("accepts a real generated access analyzer name and rejects adversarial affix matches", () => { + expect(isE2eAnalyzerName("hulumi-af-e2e-sh-abcdef0123-access-analyzer")).toBe(true); + expect(isE2eAnalyzerName("hulumi-af-e2e-sb-0123456789-access-analyzer")).toBe(true); + // Adversarial / unrelated. + expect(isE2eAnalyzerName("hulumi-af-e2e-org-audit-access-analyzer")).toBe(false); + expect(isE2eAnalyzerName("hulumi-af-e2e-sh-ZZZZZZZZZZ-access-analyzer")).toBe(false); + expect(isE2eAnalyzerName("hulumi-af-e2e-sb-access-analyzer")).toBe(false); + expect(isE2eAnalyzerName("organization-access-analyzer")).toBe(false); + expect(looksLikeE2eAnalyzerName("hulumi-af-e2e-org-audit-access-analyzer")).toBe(true); + expect(looksLikeE2eAnalyzerName("organization-access-analyzer")).toBe(false); + }); +}); + describe("AccountFoundation — real AWS integration (weekly)", () => { // See docs/integration-testing-roadmap.md#account-foundation for the // remaining failure-injection contract. Success-path sandbox and
packages/baseline/tests/secure-bucket.test.ts+124 −0 modified@@ -293,6 +293,130 @@ describe("SecureBucket — Startup-Hardened tier adds object-lock + logging + da }); }); +describe("SecureBucket — audit-delivery tamper-resistance invariant (HIGH)", () => { + beforeEach(resetRegistrations); + + it("startup-hardened audit-delivery bucket with forceDestroy:true throws (M-FORCEDESTROY block)", () => { + expect( + () => + new SecureBucket("sb-audit-fd", { + tier: "startup-hardened", + logBucketArn: LOG_BUCKET_ARN, + objectLock: false, + awsServiceLogDelivery: { cloudTrail: true, config: true }, + forceDestroy: true, + }), + ).toThrowError(/audit.*forceDestroy.*docs\/tiers\.md/i); + }); + + it("non-audit startup-hardened bucket with forceDestroy:true is still allowed", async () => { + const bucket = new SecureBucket("sb-nonaudit-fd", { + tier: "startup-hardened", + logBucketArn: LOG_BUCKET_ARN, + forceDestroy: true, + }); + await valueOf(bucket.arn); + await settlePulumi(); + const bucketReg = findRegistration("aws:s3/bucket:Bucket"); + expect(bucketReg?.inputs.forceDestroy).toBe(true); + }); + + it("sandbox audit-delivery bucket with forceDestroy:true is still allowed (ephemeral e2e)", async () => { + const bucket = new SecureBucket("sb-sandbox-audit-fd", { + tier: "sandbox", + awsServiceLogDelivery: { cloudTrail: true, config: true }, + forceDestroy: true, + }); + await valueOf(bucket.arn); + await settlePulumi(); + const bucketReg = findRegistration("aws:s3/bucket:Bucket"); + expect(bucketReg?.inputs.forceDestroy).toBe(true); + }); + + it("startup-hardened audit bucket policy denies deleting CloudTrail/Config history objects but NOT the Config writability-check key", async () => { + const bucket = new SecureBucket("sb-audit-deny", { + tier: "startup-hardened", + logBucketArn: LOG_BUCKET_ARN, + objectLock: false, + awsServiceLogDelivery: { cloudTrail: true, config: true }, + }); + await valueOf(bucket.bucketPolicy.policy); + await settlePulumi(); + + const policy = findRegistration("aws:s3/bucketPolicy:BucketPolicy"); + expect(policy).toBeDefined(); + const doc = parsePolicy(policy!.inputs.policy); + const deny = doc.Statement.find((s) => s.Sid === "DenyAuditLogTampering"); + expect(deny).toBeDefined(); + expect(deny!.Effect).toBe("Deny"); + expect(deny!.Principal).toBe("*"); + expect(deny!.Action).toEqual([ + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:PutBucketVersioning", + ]); + + const resources = deny!.Resource as string[]; + // CloudTrail log objects are protected. + expect(resources).toContain( + "arn:aws:s3:::sb-audit-deny-bucket-mock/AWSLogs/111122223333/CloudTrail/*", + ); + // Config history + snapshot objects are protected. + expect(resources).toContain( + "arn:aws:s3:::sb-audit-deny-bucket-mock/AWSLogs/111122223333/Config/ConfigHistory/*", + ); + expect(resources).toContain( + "arn:aws:s3:::sb-audit-deny-bucket-mock/AWSLogs/111122223333/Config/ConfigSnapshot/*", + ); + // The bucket itself is covered for the PutBucketVersioning lock. + expect(resources).toContain("arn:aws:s3:::sb-audit-deny-bucket-mock"); + + // The AWS Config write-then-delete probe key MUST NOT be denied, or + // PutDeliveryChannel fails with InsufficientDeliveryPolicyException. + const serialized = JSON.stringify(deny); + expect(serialized).not.toContain("ConfigWritabilityCheckFile"); + // The deny must not blanket the whole Config prefix (that would catch + // the writability-check key). + expect(resources).not.toContain( + "arn:aws:s3:::sb-audit-deny-bucket-mock/AWSLogs/111122223333/Config/*", + ); + }); + + it("sandbox audit-delivery bucket emits the CloudTrail-Lake EventDataStore regardless of tier", async () => { + const bucket = new SecureBucket("sb-sandbox-eds", { + tier: "sandbox", + awsServiceLogDelivery: { cloudTrail: true, config: true }, + }); + await valueOf(bucket.arn); + await settlePulumi(); + + const eds = findRegistration("aws:cloudtrail/eventDataStore:EventDataStore"); + expect(eds).toBeDefined(); + expect(eds!.inputs.retentionPeriod).toBe(7); + // No tier-gated BucketLogging in sandbox (needs an external target). + expect(findRegistration("aws:s3/bucketLogging:BucketLogging")).toBeUndefined(); + }); + + it("sandbox bucket WITHOUT awsServiceLogDelivery emits no EventDataStore", async () => { + const bucket = new SecureBucket("sb-sandbox-no-eds", { tier: "sandbox" }); + await valueOf(bucket.arn); + await settlePulumi(); + expect(findRegistration("aws:cloudtrail/eventDataStore:EventDataStore")).toBeUndefined(); + }); + + it("non-audit startup-hardened bucket policy has no deny-audit-tampering statement", async () => { + const bucket = new SecureBucket("sb-hard-nonaudit", { + tier: "startup-hardened", + logBucketArn: LOG_BUCKET_ARN, + }); + await valueOf(bucket.bucketPolicy.policy); + await settlePulumi(); + const policy = findRegistration("aws:s3/bucketPolicy:BucketPolicy"); + const doc = parsePolicy(policy!.inputs.policy); + expect(doc.Statement.find((s) => s.Sid === "DenyAuditLogTampering")).toBeUndefined(); + }); +}); + describe("SecureBucket — tier matrix delta count ≥ 3 (schema regression)", () => { it("Startup-Hardened sub-resource type set minus Sandbox set has ≥3 members", async () => { resetRegistrations();
packages/baseline/tests/securityhub-reuse-retain.test.ts+131 −0 added@@ -0,0 +1,131 @@ +// Regression: M-DETECTIVEREUSE SecurityHub arm. +// +// When `useExistingSecurityHubAccount === true`, AccountFoundation imports +// the account-wide hub via `Account.get`, but the CIS + NIST +// StandardsSubscription resources it creates are NOT imported — they are +// net-new resources whose default destroy behaviour calls +// BatchDisableStandards. On `pulumi destroy` of the reused stack, that +// would unsubscribe CIS/NIST account-wide while leaving Security Hub +// itself enabled (it was imported, so it isn't destroyed) — a silent +// monitoring downgrade. +// +// Fix: subscriptions created on the reuse path carry retainOnDelete=true +// so destroy leaves them in place. Non-reuse deploys retain the original +// delete semantics. +// +// `retainOnDelete` lives on CustomResourceOptions, not resource inputs, +// so vitest's setup.ts mock — which only captures `MockResourceArgs` — +// cannot see it. `pulumi.runtime.registerStackTransformation` also +// requires an initialized stack resource which the test runtime does +// not have. We observe the option by wrapping the +// `aws.securityhub.StandardsSubscription` constructor and capturing the +// `opts.retainOnDelete` value before delegating to the real +// constructor — purely test-side instrumentation, the production code +// path is unchanged. + +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; +import * as aws from "@pulumi/aws"; +import * as pulumi from "@pulumi/pulumi"; + +import { AccountFoundation } from "../src/aws/account-foundation"; +import { resetRegistrations, valueOf, settlePulumi } from "./setup"; + +const IAC_ROLE_ARN = "arn:aws:iam::111122223333:role/hulumi-sandbox-iac-role"; + +const retainObservations = new Map<string, boolean | undefined>(); + +// Wrap the StandardsSubscription constructor to record opts.retainOnDelete +// per resource name. We restore the original constructor in afterAll so +// the instrumentation is scoped to this file. The aws.securityhub +// namespace exports lazy-load properties (getter-only), so we use +// Object.defineProperty to redefine the property as a writable value +// before substituting the constructor. +const OriginalStandardsSubscription = aws.securityhub.StandardsSubscription; +class InstrumentedStandardsSubscription extends OriginalStandardsSubscription { + constructor( + name: string, + args: aws.securityhub.StandardsSubscriptionArgs, + opts?: pulumi.CustomResourceOptions, + ) { + retainObservations.set(name, opts?.retainOnDelete); + super(name, args, opts); + } +} + +beforeAll(() => { + Object.defineProperty(aws.securityhub, "StandardsSubscription", { + value: InstrumentedStandardsSubscription, + writable: true, + configurable: true, + }); +}); + +afterAll(() => { + Object.defineProperty(aws.securityhub, "StandardsSubscription", { + value: OriginalStandardsSubscription, + writable: true, + configurable: true, + }); +}); + +describe("M-DETECTIVEREUSE SecurityHub arm — reuse retains standards on destroy", () => { + beforeEach(() => { + resetRegistrations(); + retainObservations.clear(); + }); + + it("reuse path (startup-hardened): CIS + NIST StandardsSubscription carry retainOnDelete=true", async () => { + const af = new AccountFoundation("af-sh-reuse-retain", { + tier: "startup-hardened", + iacRoleArn: IAC_ROLE_ARN, + orgAccountIds: ["111111111111"], + useExistingSecurityHubAccount: true, + }); + await valueOf(af.securityHubHubArn); + await settlePulumi(); + + expect(retainObservations.size).toBe(2); + for (const [name, retain] of retainObservations) { + expect(retain, `subscription ${name} must carry retainOnDelete=true on reuse path`).toBe( + true, + ); + } + }); + + it("reuse path (sandbox): CIS StandardsSubscription carries retainOnDelete=true", async () => { + const af = new AccountFoundation("af-sh-reuse-sandbox", { + tier: "sandbox", + iacRoleArn: IAC_ROLE_ARN, + useExistingSecurityHubAccount: true, + }); + await valueOf(af.securityHubHubArn); + await settlePulumi(); + + expect(retainObservations.size).toBe(1); + for (const [name, retain] of retainObservations) { + expect(retain, `subscription ${name} must carry retainOnDelete=true on reuse path`).toBe( + true, + ); + } + }); + + it("non-reuse path: subscriptions retain default delete semantics (retainOnDelete falsy)", async () => { + const af = new AccountFoundation("af-sh-netnew", { + tier: "startup-hardened", + iacRoleArn: IAC_ROLE_ARN, + orgAccountIds: ["111111111111"], + }); + await valueOf(af.securityHubHubArn); + await settlePulumi(); + + expect(retainObservations.size).toBe(2); + for (const [name, retain] of retainObservations) { + expect( + retain === undefined || retain === false, + `subscription ${name} must not opt into retainOnDelete on non-reuse path (got ${String( + retain, + )})`, + ).toBe(true); + } + }); +});
packages/drift/src/adapters/cloudwatch-log-group.ts+71 −0 modified@@ -4,6 +4,7 @@ import { DescribeLogGroupsCommand, type CloudWatchLogsClientConfig, } from "@aws-sdk/client-cloudwatch-logs"; +import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import type { ReconcileActionExecutor, @@ -14,18 +15,26 @@ import type { export interface CloudWatchLogGroupExecutorArgs { client?: CloudWatchLogsClient; clientConfig?: CloudWatchLogsClientConfig; + stsClient?: STSClient; expectedPrefix: string; } export class CloudWatchLogGroupExecutor implements ReconcileActionExecutor { private readonly client: CloudWatchLogsClient; + private readonly stsClient: STSClient; private readonly expectedPrefix: string; + private resolvedAccountId?: string; constructor(args: CloudWatchLogGroupExecutorArgs) { if (args.expectedPrefix.trim().length < 6 || /[*?]/.test(args.expectedPrefix)) { throw new Error("Refusing broad CloudWatch Logs cleanup prefix."); } this.client = args.client ?? new CloudWatchLogsClient(args.clientConfig ?? {}); + this.stsClient = + args.stsClient ?? + new STSClient( + args.clientConfig?.region !== undefined ? { region: args.clientConfig.region } : {}, + ); this.expectedPrefix = args.expectedPrefix; } @@ -49,6 +58,11 @@ export class CloudWatchLogGroupExecutor implements ReconcileActionExecutor { }; } + const placementBlock = await this.blockOnPlacementMismatch(action); + if (placementBlock !== undefined) { + return placementBlock; + } + try { if (!(await this.logGroupExists(logGroupName))) { return { @@ -75,6 +89,63 @@ export class CloudWatchLogGroupExecutor implements ReconcileActionExecutor { } } + // Fail-closed binding: the configured client must operate in the same + // account and region the action's resource was discovered in. A mis-wired + // (prod creds / wrong region) client must never delete a same-name log + // group that belongs to an unrelated placement. + private async blockOnPlacementMismatch( + action: ReconcilePlanAction, + ): Promise<ReconcileActionResult | undefined> { + const expectedAccountId = action.resource.accountId; + const expectedRegion = action.resource.region; + if (expectedAccountId === undefined || expectedRegion === undefined) { + return { + actionId: action.id, + status: "blocked", + message: "resource account or region is unknown; refusing to delete on unknown placement", + }; + } + + let resolvedAccountId: string; + let resolvedRegion: string; + try { + resolvedAccountId = await this.resolveAccountId(); + resolvedRegion = await this.client.config.region(); + } catch { + return { + actionId: action.id, + status: "blocked", + message: "could not resolve client account or region; refusing to delete", + }; + } + + if (resolvedAccountId !== expectedAccountId) { + return { + actionId: action.id, + status: "blocked", + message: "client account does not match the resource account", + }; + } + if (resolvedRegion !== expectedRegion) { + return { + actionId: action.id, + status: "blocked", + message: "client region does not match the resource region", + }; + } + return undefined; + } + + private async resolveAccountId(): Promise<string> { + if (this.resolvedAccountId !== undefined) return this.resolvedAccountId; + const identity = await this.stsClient.send(new GetCallerIdentityCommand({})); + if (identity.Account === undefined || identity.Account.length === 0) { + throw new Error("STS GetCallerIdentity returned no account"); + } + this.resolvedAccountId = identity.Account; + return this.resolvedAccountId; + } + private async logGroupExists(logGroupName: string): Promise<boolean> { const result = await this.client.send( new DescribeLogGroupsCommand({ logGroupNamePrefix: logGroupName, limit: 1 }),
packages/drift/src/classifier.ts+59 −4 modified@@ -157,6 +157,33 @@ export class DriftClassifier { providerDrift: pv.detected, }; + // FAIL-CLOSED GATE (M-ADAPTERFAIL). A rejected / failed required + // adapter unwraps to {detected:false, ok:false}. Trusting only + // `detected` would treat that as "clean" (mutated=false) and emit + // None/none, which would then be cached and short-circuit every + // subsequent call inside the TTL — a fail-open. The Automation-API + // (`auto`) and provider-version (`pv`) adapters are required inputs + // to the matrix (`mutated` / `providerDrift`); if either failed we + // cannot trust the snapshot, so degrade to the SAME verdict the + // probe-failure path (E1) produces — Unknown / low — and DO NOT + // write that degraded result to the cache. + const requiredAdapterFailed = !auto.ok || !pv.ok; + if (requiredAdapterFailed) { + const degradedVerdict: DriftVerdict = { + resource, + source: "Unknown", + confidence: "low", + evidence, + ...(buildRecommendation("Unknown") !== undefined + ? { recommendation: buildRecommendation("Unknown")! } + : {}), + }; + // Same effect as the meetsMinConfidence early-return below: return + // without writing the cache so a degraded run never becomes the + // cached canonical entry and never short-circuits later calls. + return degradedVerdict; + } + let { source, confidence } = hardenedVerdict(snapshot); if ( !probeResult.ok && @@ -169,12 +196,33 @@ export class DriftClassifier { // for the maintainer. } - // CloudTrail console events promote the verdict directly when the - // probe is unavailable but events surfaced via the long-window - // lookup. (Tracked separately from the in-flight probe sentinel.) - if (snapshot.mutated && ct.detected && !snapshot.eventDelivered) { + // Mixed / ConsoleBreakGlass escalation is driven by REAL CloudTrail + // audit evidence (`ct.detected`), NOT by probe liveness alone + // (M-MIXED). hardenedVerdict() may return Mixed / ConsoleBreakGlass + // purely from `snapshot.eventDelivered` (a healthy in-flight probe + // sentinel) — but a live probe is not proof a console event actually + // occurred. The `!snapshot.eventDelivered` guard that used to wrap + // this block has been removed so the ct.detected-based correction + // ALSO runs when the probe is healthy. + if (snapshot.mutated && ct.detected) { + // Real console event observed in CloudTrail → escalate. This also + // covers the long-window lookup case where the probe was + // unavailable but events surfaced. source = snapshot.providerDrift ? "Mixed" : "ConsoleBreakGlass"; confidence = "high"; + } else if ((source === "Mixed" || source === "ConsoleBreakGlass") && !ct.detected) { + // hardenedVerdict escalated to Mixed / ConsoleBreakGlass from + // probe liveness alone, but CloudTrail did NOT actually observe a + // console event. Demote to the appropriate non-escalated verdict: + // mutated + providerDrift → ProviderApiChurn / medium (matrix + // row 4 semantics); otherwise → Unknown / low (matrix row 5). + if (snapshot.providerDrift) { + source = "ProviderApiChurn"; + confidence = "medium"; + } else { + source = "Unknown"; + confidence = "low"; + } } if (snapshot.mutated && gl.detected && source === "Unknown") { @@ -238,6 +286,13 @@ const CONFIDENCE_RANK: Record<Confidence, number> = { }; function meetsMinConfidence(actual: Confidence, threshold: Confidence | undefined): boolean { + // Hardened default (M-ADAPTERFAIL): a `confidence:"none"` only ever + // accompanies a None verdict or a degraded run. Even when the caller + // sets no explicit threshold, never let a `none`-confidence verdict + // become the cached canonical entry (it would short-circuit every + // subsequent call inside the TTL — a fail-open). All real verdicts + // carry low/medium/high. + if (CONFIDENCE_RANK[actual] <= CONFIDENCE_RANK.none) return false; if (!threshold) return true; return CONFIDENCE_RANK[actual] >= CONFIDENCE_RANK[threshold]; }
packages/drift/src/discovery.ts+22 −3 modified@@ -8,6 +8,17 @@ import type { ResourceRelationship, } from "./reconciler"; +// Security-control services that are account-region singletons by nature: +// deleting one tears down the account's entire detection/posture surface. +// Treated as shared singletons even when discovery has no caller-supplied +// `singleton` flag, so the reconciler's singleton guard always fires. +const SECURITY_SINGLETON_TYPE = + /^aws:guardduty\/detector:Detector$|^aws:securityhub\/account:Account$/; + +export function isSecuritySingletonType(type: string): boolean { + return SECURITY_SINGLETON_TYPE.test(type); +} + export interface PulumiStateResource { urn: string; type: string; @@ -98,7 +109,11 @@ export function discoverReconcileTargets( ...(cloud?.accountId !== undefined ? { accountId: cloud.accountId } : {}), ...(cloud?.tags !== undefined ? { tags: cloud.tags } : {}), ...(cloud?.createdAt !== undefined ? { createdAt: cloud.createdAt } : {}), - ...(cloud?.singleton !== undefined ? { singleton: cloud.singleton } : {}), + ...(cloud?.singleton === true || isSecuritySingletonType(resource.type) + ? { singleton: true } + : cloud?.singleton !== undefined + ? { singleton: cloud.singleton } + : {}), }; if (physicalId !== undefined) identity.physicalId = physicalId; targets.push({ @@ -115,11 +130,15 @@ export function discoverReconcileTargets( continue; } const ownership = ownershipEvidenceFor(resource, request.scope); + const identity: ResourceIdentity = + isSecuritySingletonType(resource.type) && resource.singleton !== true + ? { ...resource, singleton: true } + : resource; targets.push({ - identity: resource, + identity, inState: false, existsInCloud: true, - relationship: relationshipFor(resource, ownership), + relationship: relationshipFor(identity, ownership), ownership, }); }
packages/drift/src/index.ts+1 −0 modified@@ -63,6 +63,7 @@ export { } from "./adapters/cloudwatch-log-group"; export { discoverReconcileTargets, + isSecuritySingletonType, type CloudInventoryResource, type DiscoverReconcileTargetsRequest, type DiscoverReconcileTargetsResult,
packages/drift/src/reconciler.ts+4 −1 modified@@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { isSecuritySingletonType } from "./discovery"; + export const RECONCILER_PLAN_SCHEMA_VERSION = "hulumi.drift.reconcile.plan.v1"; export const RECONCILER_RESOURCE_STATES = [ @@ -377,7 +379,8 @@ function classifyTarget( const inRegion = matchesOne(target.identity.region, scope.regions); const inAccount = matchesOne(target.identity.accountId, scope.accountIds); const olderThanMinAge = matchesAge(target.identity.createdAt, scope.minAgeMinutes, now); - const singleton = target.identity.singleton === true; + const singleton = + target.identity.singleton === true || isSecuritySingletonType(target.identity.type); if (scope.resourcePrefix === undefined && !target.inState && target.existsInCloud) { blocked.push({
packages/drift/tests/classifier-fail-closed.test.ts+291 −0 added@@ -0,0 +1,291 @@ +// Fail-closed regression suite for two MED findings, one root cause: +// classifier.ts built VerdictSnapshot only from adapter `detected` +// booleans, discarding adapter `ok` (failure) state and the real +// CloudTrail audit evidence `ct.detected`. +// +// M-ADAPTERFAIL: a rejected/failed required adapter (esp. the +// Automation-API `auto`) was unwrapped as {detected:false, ok:false}; +// `ok` was ignored → mutated=false → None/none, which was then cached. +// Subsequent calls within TTL short-circuited from that fail-open +// cache entry. Expected: degrade to the existing probe-failure verdict +// (Unknown/low) AND do NOT write the degraded result to cache, so a +// 2nd call re-runs the adapters. +// +// M-MIXED: Mixed / ConsoleBreakGlass promotion fired on probe liveness +// (snapshot.eventDelivered) alone; the real ct.detected correction was +// skipped when the probe was healthy. A healthy probe + Automation +// diff + providerDrift + ct.detected:false was over-promoted to +// Mixed/high. Expected: that case is NOT Mixed/ConsoleBreakGlass high +// (it is the provider-churn verdict). A healthy probe WITH real +// ct.detected must still escalate (control). + +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { DriftClassifier } from "../src/classifier"; +import type { AdapterSignal, DriftAdapter } from "../src/types"; + +class StaticAdapter implements DriftAdapter { + constructor( + private readonly _name: string, + private readonly signalResult: AdapterSignal, + ) {} + name(): string { + return this._name; + } + async available(): Promise<boolean> { + return true; + } + async signal(): Promise<AdapterSignal> { + return this.signalResult; + } +} + +/** Counts invocations so we can prove cache short-circuit (or not). */ +class CountingAdapter implements DriftAdapter { + public count = 0; + constructor( + private readonly _name: string, + private readonly signalResult: AdapterSignal, + ) {} + name(): string { + return this._name; + } + async available(): Promise<boolean> { + return true; + } + async signal(): Promise<AdapterSignal> { + this.count += 1; + return this.signalResult; + } +} + +/** Always rejects — models an Automation-API call that fails / is denied. */ +class RejectingAdapter implements DriftAdapter { + public count = 0; + constructor(private readonly _name: string) {} + name(): string { + return this._name; + } + async available(): Promise<boolean> { + return true; + } + async signal(): Promise<AdapterSignal> { + this.count += 1; + throw new Error(`${this._name} adapter rejected (simulated API failure)`); + } +} + +const clean: AdapterSignal = { detected: false, ok: true, data: {} }; +const detected: AdapterSignal = { detected: true, ok: true, data: {} }; +/** detected=false but the underlying API failed (degraded, not clean). */ +const failed: AdapterSignal = { detected: false, ok: false, data: {} }; + +describe("classifier fail-closed — M-ADAPTERFAIL", () => { + let dir: string | undefined; + + afterEach(() => { + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("Automation-API adapter rejection ⇒ degrade verdict (NOT None/none)", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-failclosed-")); + const classifier = new DriftClassifier({ + adapters: { + automationApi: new RejectingAdapter("AutomationApi"), + cloudTrail: new StaticAdapter("CloudTrail", clean), + providerVersion: new StaticAdapter("ProviderVersion", clean), + gitLog: new StaticAdapter("GitLog", clean), + }, + probe: async () => ({ delivered: false, inTransit: false }), + }); + + const verdict = await classifier.classify("stack", "urn:r1", { + cacheDir: dir, + cacheTtlSeconds: 600, + }); + + // The fail-open bug returned None/none here. Fail-closed must degrade + // to the existing probe-failure verdict (Unknown / low). + expect(verdict.source).not.toBe("None"); + expect(verdict.confidence).not.toBe("none"); + expect(verdict.source).toBe("Unknown"); + expect(verdict.confidence).toBe("low"); + }); + + it("Automation-API rejection is NOT written to cache (2nd call re-runs adapters)", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-failclosed-")); + const auto = new RejectingAdapter("AutomationApi"); + const ct = new CountingAdapter("CloudTrail", clean); + const pv = new CountingAdapter("ProviderVersion", clean); + const gl = new CountingAdapter("GitLog", clean); + const classifier = new DriftClassifier({ + adapters: { automationApi: auto, cloudTrail: ct, providerVersion: pv, gitLog: gl }, + probe: async () => ({ delivered: false, inTransit: false }), + }); + const opts = { cacheDir: dir, cacheTtlSeconds: 600 }; + + const v1 = await classifier.classify("stack", "urn:r1", opts); + expect(v1.source).toBe("Unknown"); + expect(auto.count).toBe(1); + + // Degraded verdict must NOT have been cached: the 2nd call inside TTL + // must re-run every adapter rather than short-circuit fail-open. + const v2 = await classifier.classify("stack", "urn:r1", opts); + expect(v2.source).toBe("Unknown"); + expect(auto.count).toBe(2); + expect(ct.count).toBe(2); + expect(pv.count).toBe(2); + expect(gl.count).toBe(2); + }); + + it("Automation-API ok=false (no reject) also degrades and is not cached", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-failclosed-")); + const auto = new CountingAdapter("AutomationApi", failed); + const ct = new CountingAdapter("CloudTrail", clean); + const pv = new CountingAdapter("ProviderVersion", clean); + const gl = new CountingAdapter("GitLog", clean); + const classifier = new DriftClassifier({ + adapters: { automationApi: auto, cloudTrail: ct, providerVersion: pv, gitLog: gl }, + probe: async () => ({ delivered: false, inTransit: false }), + }); + const opts = { cacheDir: dir, cacheTtlSeconds: 600 }; + + const v1 = await classifier.classify("stack", "urn:r1", opts); + expect(v1.source).toBe("Unknown"); + expect(v1.confidence).toBe("low"); + + const v2 = await classifier.classify("stack", "urn:r1", opts); + expect(v2.source).toBe("Unknown"); + // Not short-circuited from a fail-open cache entry. + expect(auto.count).toBe(2); + }); + + it("provider-version adapter failure also degrades fail-closed", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-failclosed-")); + const classifier = new DriftClassifier({ + adapters: { + automationApi: new StaticAdapter("AutomationApi", detected), + cloudTrail: new StaticAdapter("CloudTrail", clean), + providerVersion: new RejectingAdapter("ProviderVersion"), + gitLog: new StaticAdapter("GitLog", clean), + }, + probe: async () => ({ delivered: false, inTransit: false }), + }); + + const verdict = await classifier.classify("stack", "urn:r1", { + cacheDir: dir, + cacheTtlSeconds: 600, + }); + expect(verdict.source).toBe("Unknown"); + expect(verdict.confidence).toBe("low"); + }); +}); + +describe("classifier fail-closed — M-MIXED (require real ct.detected)", () => { + let dir: string | undefined; + + afterEach(() => { + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("healthy probe + Automation diff + providerDrift + ct.detected:false ⇒ NOT Mixed/high", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-mixed-fc-")); + const classifier = new DriftClassifier({ + adapters: { + automationApi: new StaticAdapter("AutomationApi", detected), + // CloudTrail did NOT actually observe a console event. + cloudTrail: new StaticAdapter("CloudTrail", clean), + providerVersion: new StaticAdapter("ProviderVersion", detected), + gitLog: new StaticAdapter("GitLog", clean), + }, + // Probe is healthy and "delivered" — but that is only probe + // liveness, not real CloudTrail audit evidence. + probe: async () => ({ delivered: true, inTransit: false }), + }); + + const verdict = await classifier.classify("stack", "urn:r1", { + cacheDir: dir, + cacheTtlSeconds: 0, + }); + + // Over-promotion bug yielded Mixed/high here. Without real + // ct.detected the verdict must NOT escalate to Mixed or + // ConsoleBreakGlass. With mutated + providerDrift the appropriate + // non-escalated verdict is ProviderApiChurn / medium. + expect(verdict.source).not.toBe("Mixed"); + expect(verdict.source).not.toBe("ConsoleBreakGlass"); + expect(verdict.confidence).not.toBe("high"); + expect(verdict.source).toBe("ProviderApiChurn"); + expect(verdict.confidence).toBe("medium"); + }); + + it("healthy probe + Automation diff + no providerDrift + ct.detected:false ⇒ NOT ConsoleBreakGlass", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-mixed-fc-")); + const classifier = new DriftClassifier({ + adapters: { + automationApi: new StaticAdapter("AutomationApi", detected), + cloudTrail: new StaticAdapter("CloudTrail", clean), + providerVersion: new StaticAdapter("ProviderVersion", clean), + gitLog: new StaticAdapter("GitLog", clean), + }, + probe: async () => ({ delivered: true, inTransit: false }), + }); + + const verdict = await classifier.classify("stack", "urn:r1", { + cacheDir: dir, + cacheTtlSeconds: 0, + }); + + expect(verdict.source).not.toBe("ConsoleBreakGlass"); + expect(verdict.source).not.toBe("Mixed"); + expect(verdict.confidence).not.toBe("high"); + }); + + it("CONTROL: healthy probe WITH real ct.detected still escalates (Mixed/high)", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-mixed-fc-")); + const classifier = new DriftClassifier({ + adapters: { + automationApi: new StaticAdapter("AutomationApi", detected), + cloudTrail: new StaticAdapter("CloudTrail", detected), + providerVersion: new StaticAdapter("ProviderVersion", detected), + gitLog: new StaticAdapter("GitLog", clean), + }, + probe: async () => ({ delivered: true, inTransit: false }), + }); + + const verdict = await classifier.classify("stack", "urn:r1", { + cacheDir: dir, + cacheTtlSeconds: 0, + }); + + // Real CloudTrail evidence present → escalation is correct. + expect(verdict.source).toBe("Mixed"); + expect(verdict.confidence).toBe("high"); + }); + + it("CONTROL: healthy probe WITH real ct.detected, no providerDrift ⇒ ConsoleBreakGlass/high", async () => { + dir = mkdtempSync(join(tmpdir(), "hulumi-drift-mixed-fc-")); + const classifier = new DriftClassifier({ + adapters: { + automationApi: new StaticAdapter("AutomationApi", detected), + cloudTrail: new StaticAdapter("CloudTrail", detected), + providerVersion: new StaticAdapter("ProviderVersion", clean), + gitLog: new StaticAdapter("GitLog", clean), + }, + probe: async () => ({ delivered: true, inTransit: false }), + }); + + const verdict = await classifier.classify("stack", "urn:r1", { + cacheDir: dir, + cacheTtlSeconds: 0, + }); + + expect(verdict.source).toBe("ConsoleBreakGlass"); + expect(verdict.confidence).toBe("high"); + }); +});
packages/drift/tests/cloudwatch-log-group-executor.test.ts+115 −1 modified@@ -6,7 +6,13 @@ import { OrphanReconciler, type ReconcilePlanAction } from "../src/reconciler"; class FakeLogsClient { readonly commands: unknown[] = []; - constructor(private readonly behavior: "exists" | "absent" | "fails" = "exists") {} + readonly config: { region: () => Promise<string> }; + constructor( + private readonly behavior: "exists" | "absent" | "fails" = "exists", + region = "us-east-1", + ) { + this.config = { region: () => Promise.resolve(region) }; + } async send(command: unknown): Promise<Record<string, unknown>> { this.commands.push(command); @@ -25,6 +31,17 @@ class FakeLogsClient { } } +class FakeStsClient { + constructor(private readonly account: string | "throws" = "123456789012") {} + + async send(): Promise<{ Account?: string }> { + if (this.account === "throws") { + throw new Error("STS unavailable"); + } + return { Account: this.account }; + } +} + function action(logGroupName = "af-e2e-abc123-logs"): ReconcilePlanAction { return { id: "action-0000", @@ -58,6 +75,7 @@ describe("CloudWatchLogGroupExecutor", () => { executors: { deleteCloudWatchLogGroup: new CloudWatchLogGroupExecutor({ client: client as never, + stsClient: new FakeStsClient() as never, expectedPrefix: "af-e2e-abc123", }), }, @@ -102,6 +120,7 @@ describe("CloudWatchLogGroupExecutor", () => { it("treats already absent log groups as idempotent success", async () => { const result = await new CloudWatchLogGroupExecutor({ client: new FakeLogsClient("absent") as never, + stsClient: new FakeStsClient() as never, expectedPrefix: "af-e2e-abc123", }).execute(action()); @@ -136,6 +155,7 @@ describe("CloudWatchLogGroupExecutor", () => { executors: { deleteCloudWatchLogGroup: new CloudWatchLogGroupExecutor({ client: new FakeLogsClient("fails") as never, + stsClient: new FakeStsClient() as never, expectedPrefix: "af-e2e-abc123", }), }, @@ -168,4 +188,98 @@ describe("CloudWatchLogGroupExecutor", () => { expect(JSON.stringify(result)).not.toContain("af-e2e-abc123-logs"); expect(JSON.stringify(result)).not.toContain("123456789012"); }); + + it("blocks and issues no delete when the client account differs from the resource", async () => { + const client = new FakeLogsClient(); + const result = await new CloudWatchLogGroupExecutor({ + client: client as never, + stsClient: new FakeStsClient("999999999999") as never, + expectedPrefix: "af-e2e-abc123", + }).execute(action()); + + expect(result.status).toBe("blocked"); + expect(result.message).toMatch(/account/i); + expect(client.commands.some((command) => command instanceof DescribeLogGroupsCommand)).toBe( + false, + ); + expect(client.commands.some((command) => command instanceof DeleteLogGroupCommand)).toBe(false); + }); + + it("blocks and issues no delete when the client region differs from the resource", async () => { + const client = new FakeLogsClient("exists", "us-west-2"); + const result = await new CloudWatchLogGroupExecutor({ + client: client as never, + stsClient: new FakeStsClient() as never, + expectedPrefix: "af-e2e-abc123", + }).execute(action()); + + expect(result.status).toBe("blocked"); + expect(result.message).toMatch(/region/i); + expect(client.commands).toHaveLength(0); + }); + + it("proceeds when client account and region match the resource (happy path)", async () => { + const client = new FakeLogsClient(); + const result = await new CloudWatchLogGroupExecutor({ + client: client as never, + stsClient: new FakeStsClient() as never, + expectedPrefix: "af-e2e-abc123", + }).execute(action()); + + expect(result).toEqual({ + actionId: "action-0000", + status: "succeeded", + counts: { deletedLogGroups: 1, alreadyAbsent: 0 }, + }); + expect(client.commands.some((command) => command instanceof DeleteLogGroupCommand)).toBe(true); + }); + + it("fails closed and issues no delete when the resource account or region is missing", async () => { + const client = new FakeLogsClient(); + const noAccount = await new CloudWatchLogGroupExecutor({ + client: client as never, + stsClient: new FakeStsClient() as never, + expectedPrefix: "af-e2e-abc123", + }).execute({ + ...action(), + resource: { + provider: "aws", + type: "aws:cloudwatch/logGroup:LogGroup", + physicalId: "af-e2e-abc123-logs", + region: "us-east-1", + }, + }); + + expect(noAccount.status).toBe("blocked"); + expect(client.commands).toHaveLength(0); + + const noRegion = await new CloudWatchLogGroupExecutor({ + client: client as never, + stsClient: new FakeStsClient() as never, + expectedPrefix: "af-e2e-abc123", + }).execute({ + ...action(), + resource: { + provider: "aws", + type: "aws:cloudwatch/logGroup:LogGroup", + physicalId: "af-e2e-abc123-logs", + accountId: "123456789012", + }, + }); + + expect(noRegion.status).toBe("blocked"); + expect(client.commands).toHaveLength(0); + }); + + it("fails closed when STS identity resolution throws", async () => { + const client = new FakeLogsClient(); + const result = await new CloudWatchLogGroupExecutor({ + client: client as never, + stsClient: new FakeStsClient("throws") as never, + expectedPrefix: "af-e2e-abc123", + }).execute(action()); + + expect(result.status).toBe("blocked"); + expect(client.commands).toHaveLength(0); + }); });
packages/drift/tests/security-singleton-binding.test.ts+150 −0 added@@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { discoverReconcileTargets, isSecuritySingletonType } from "../src/discovery"; +import { + OrphanReconciler, + type ReconcileActionExecutor, + type ReconcileActionResult, + type ReconcileTarget, +} from "../src/reconciler"; + +const NOW = new Date("2026-05-08T12:00:00.000Z"); + +class NoopDeleteExecutor implements ReconcileActionExecutor { + async execute(): Promise<ReconcileActionResult> { + return { actionId: "x", status: "succeeded" }; + } +} + +function cloudOnlyTarget(type: string, physicalId: string): ReconcileTarget { + return { + inState: false, + existsInCloud: true, + identity: { + provider: "aws", + type, + physicalId, + region: "us-east-1", + accountId: "123456789012", + createdAt: "2026-05-08T10:00:00.000Z", + tags: { "hulumi:component": "AccountFoundation" }, + }, + ownership: [ + { signal: "name-prefix", subject: physicalId, confidence: "high" }, + { signal: "tag", subject: "hulumi:component=AccountFoundation", confidence: "high" }, + ], + }; +} + +function planFor(targets: ReconcileTarget[], allowSingletonDelete = false) { + return new OrphanReconciler({ + executors: { + deleteGuardDutyDetector: new NoopDeleteExecutor(), + deleteSecurityHubHub: new NoopDeleteExecutor(), + }, + }).plan({ + now: NOW, + nonce: "fixed", + mode: "sweep-only", + scope: { + resourcePrefix: "af-e2e-abc123", + regions: ["us-east-1"], + accountIds: ["123456789012"], + minAgeMinutes: 15, + allowSingletonDelete, + }, + targets, + }); +} + +describe("isSecuritySingletonType", () => { + it("matches only the security-control singleton services", () => { + expect(isSecuritySingletonType("aws:guardduty/detector:Detector")).toBe(true); + expect(isSecuritySingletonType("aws:securityhub/account:Account")).toBe(true); + expect(isSecuritySingletonType("aws:s3/bucket:Bucket")).toBe(false); + expect(isSecuritySingletonType("aws:guardduty/detector:DetectorFeature")).toBe(false); + }); +}); + +describe("security singleton guard without a caller-supplied flag", () => { + for (const type of ["aws:guardduty/detector:Detector", "aws:securityhub/account:Account"]) { + it(`retains cloud-only ${type} with singleton UNSET and a delete executor registered`, () => { + const target = cloudOnlyTarget(type, "af-e2e-abc123-sec"); + // No singleton flag from discovery/caller — pre-fix this slipped past. + expect(target.identity.singleton).toBeUndefined(); + + const plan = planFor([target]); + + expect(plan.actions[0]?.type).toBe("retainSharedSingleton"); + expect(plan.actions[0]?.recommendedAction).toBe("retainExternal"); + expect(plan.actions[0]?.executable).toBe(false); + expect(plan.actions[0]?.blockedActions.map((b) => b.reason)).toContain( + "shared singleton deletion is disabled", + ); + }); + + it(`still allows ${type} teardown when scope.allowSingletonDelete=true`, () => { + const plan = planFor([cloudOnlyTarget(type, "af-e2e-abc123-sec")], true); + + expect(plan.actions[0]?.recommendedAction).toBe("deleteCloudResource"); + expect(plan.actions[0]?.type).toMatch(/^delete(GuardDutyDetector|SecurityHubHub)$/); + expect(plan.actions[0]?.executable).toBe(true); + }); + } +}); + +describe("discovery infers singleton from security-control type", () => { + it("marks a cloud-only GuardDuty detector as shared-singleton with no caller flag", () => { + const result = discoverReconcileTargets({ + scope: { resourcePrefix: "af-e2e-abc123" }, + pulumiState: { resources: [] }, + cloudResources: [ + { + provider: "aws", + type: "aws:securityhub/account:Account", + physicalId: "af-e2e-abc123-sechub", + tags: { "hulumi:component": "AccountFoundation" }, + }, + ], + }); + + expect(result.targets[0]?.relationship).toBe("shared-singleton"); + expect(result.targets[0]?.identity.singleton).toBe(true); + + const plan = new OrphanReconciler({ + executors: { deleteSecurityHubHub: new NoopDeleteExecutor() }, + }).plan({ + now: NOW, + mode: "sweep-only", + scope: { resourcePrefix: "af-e2e-abc123" }, + targets: result.targets, + }); + expect(plan.actions[0]?.recommendedAction).toBe("retainExternal"); + expect(plan.actions[0]?.executable).toBe(false); + }); + + it("infers singleton for state-owned security-control resources", () => { + const result = discoverReconcileTargets({ + scope: { resourceTypes: ["aws:guardduty/detector:Detector"] }, + pulumiState: { + resources: [ + { + urn: "urn:pulumi:s::p::aws:guardduty/detector:Detector::d", + type: "aws:guardduty/detector:Detector", + id: "detector-1", + }, + ], + }, + cloudResources: [ + { + provider: "aws", + type: "aws:guardduty/detector:Detector", + physicalId: "detector-1", + region: "us-east-1", + }, + ], + }); + + expect(result.targets[0]?.identity.singleton).toBe(true); + }); +});
packages/k8s-baseline/README.md+11 −11 modified@@ -19,17 +19,17 @@ The exact `@pulumi/*` versions match `peerDependencies`. ## Components -| Component | Purpose | -| --------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `HardenedHelmRelease` | Helm release with PSA-baseline labels, SHA-pinned chart digest, default release-name stability | -| `MetricsServer` | Kubernetes Metrics API install for HPA telemetry with secure-by-default APIService TLS | -| `EksSubnetTagger` | Auto-tag EKS-bound subnets with `kubernetes.io/role/{,internal-}elb` | -| `EksAdminAccessPath` | Auditable EKS operator access path for private or restricted-public control-plane hardening | -| `IstioFoundation` | Bundled hardened Istio install (`istiod` + `istio-cni` + `ingressgateway`, PSA-baseline-clean) | -| `AlbMeshedHttpEntrypoint` | ALB Ingress + Istio `Gateway` + `VirtualService` + `AuthorizationPolicy` for one workload | -| `KubernetesSecretFromAwsSecretsManager` | K8s `Secret` from an AWS Secrets Manager value, fail-closed on JSON-shape violations | -| `RdsCredentialSecret` | Extract RDS auto-managed master credential into a K8s `Secret` with fail-closed semantics | -| `GitHubAppCredential` | Secrets Manager container + JWT-mint helper bundle for GitHub App credential rotation | +| Component | Purpose | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HardenedHelmRelease` | Helm release with exact chart-version pinning (no `latest`, no semver ranges), enforced `https://`/`oci://` repository scheme, PSA-baseline labels, default release-name stability | +| `MetricsServer` | Kubernetes Metrics API install for HPA telemetry with secure-by-default APIService TLS | +| `EksSubnetTagger` | Auto-tag EKS-bound subnets with `kubernetes.io/role/{,internal-}elb` | +| `EksAdminAccessPath` | Auditable EKS operator access path for private or restricted-public control-plane hardening | +| `IstioFoundation` | Bundled hardened Istio install (`istiod` + `istio-cni` + `ingressgateway`, PSA-baseline-clean) | +| `AlbMeshedHttpEntrypoint` | ALB Ingress + Istio `Gateway` + `VirtualService` + `AuthorizationPolicy` for one workload | +| `KubernetesSecretFromAwsSecretsManager` | K8s `Secret` from an AWS Secrets Manager value, fail-closed on JSON-shape violations | +| `RdsCredentialSecret` | Extract RDS auto-managed master credential into a K8s `Secret` with fail-closed semantics | +| `GitHubAppCredential` | Secrets Manager container + JWT-mint helper bundle for GitHub App credential rotation | ## Quick-start — `IstioFoundation`
packages/k8s-baseline/src/cidr-coverage.ts+179 −0 added@@ -0,0 +1,179 @@ +// Self-contained CIDR validation + internet-coverage detection. +// +// A fail-closed control that keys on exact-literal membership (e.g. +// `["0.0.0.0/0", "::/0"]`) is trivially bypassed by a semantically +// equivalent split: `["0.0.0.0/1", "128.0.0.0/1"]` collectively covers +// the entire IPv4 space but neither entry matches the literal `0.0.0.0/0`. +// +// This helper parses each CIDR to a numeric (network, prefixLen) pair and +// asks the real question: does the union of the supplied ranges cover the +// entire address family? No external dependency (BigInt-only) so this does +// not trip the exact-pin / cooling-off supply-chain gate. + +export type IpFamily = "ipv4" | "ipv6"; + +interface ParsedCidr { + family: IpFamily; + /** First address in the block, as a BigInt. */ + start: bigint; + /** Last address in the block, as a BigInt. */ + end: bigint; + prefixLen: number; +} + +const IPV4_BITS = 32n; +const IPV6_BITS = 128n; +const IPV4_MAX = (1n << IPV4_BITS) - 1n; +const IPV6_MAX = (1n << IPV6_BITS) - 1n; + +function parseIpv4(addr: string): bigint | undefined { + const parts = addr.split("."); + if (parts.length !== 4) return undefined; + let value = 0n; + for (const part of parts) { + // Reject empty, non-numeric, leading-zero ("01"), or out-of-range octets. + if (!/^\d{1,3}$/.test(part)) return undefined; + if (part.length > 1 && part.startsWith("0")) return undefined; + const n = Number(part); + if (n > 255) return undefined; + value = (value << 8n) | BigInt(n); + } + return value; +} + +function parseIpv6(addr: string): bigint | undefined { + // No embedded IPv4-in-IPv6 (e.g. ::ffff:1.2.3.4) handling: CIDR entries + // for SG/endpoint allow-lists are hextet form in practice. Reject the + // dotted-quad tail rather than silently mis-parse it. + if (addr.includes(".")) return undefined; + const doubleColonCount = (addr.match(/::/g) ?? []).length; + if (doubleColonCount > 1) return undefined; + + let head: string[]; + let tail: string[]; + if (doubleColonCount === 1) { + const [h, t] = addr.split("::"); + head = h === "" ? [] : h.split(":"); + tail = t === "" ? [] : t.split(":"); + } else { + head = addr.split(":"); + tail = []; + } + + const hextets = [...head, ...tail]; + if (doubleColonCount === 0 && hextets.length !== 8) return undefined; + if (doubleColonCount === 1 && hextets.length >= 8) return undefined; + if (head.length + tail.length > 8) return undefined; + + for (const h of hextets) { + if (!/^[0-9a-fA-F]{1,4}$/.test(h)) return undefined; + } + + const zeros = 8 - (head.length + tail.length); + const full = [...head, ...Array<string>(doubleColonCount === 1 ? zeros : 0).fill("0"), ...tail]; + if (full.length !== 8) return undefined; + + let value = 0n; + for (const h of full) { + value = (value << 16n) | BigInt(parseInt(h, 16)); + } + return value; +} + +/** + * Parse a single CIDR string. Returns `undefined` for any syntactically + * malformed input (the previous control accepted any non-blank string). + */ +export function parseCidr(cidr: string): ParsedCidr | undefined { + const slash = cidr.indexOf("/"); + if (slash === -1) return undefined; + const addrPart = cidr.slice(0, slash); + const prefixPart = cidr.slice(slash + 1); + if (!/^\d{1,3}$/.test(prefixPart)) return undefined; + if (prefixPart.length > 1 && prefixPart.startsWith("0")) return undefined; + const prefixLen = Number(prefixPart); + + const isV6 = addrPart.includes(":"); + if (isV6) { + const value = parseIpv6(addrPart); + if (value === undefined || prefixLen > 128) return undefined; + const hostBits = IPV6_BITS - BigInt(prefixLen); + const mask = hostBits === 0n ? 0n : (1n << hostBits) - 1n; + // Reject CIDRs whose host bits are set (non-canonical network address). + if ((value & mask) !== 0n) return undefined; + return { + family: "ipv6", + start: value, + end: value | mask, + prefixLen, + }; + } + + const value = parseIpv4(addrPart); + if (value === undefined || prefixLen > 32) return undefined; + const hostBits = IPV4_BITS - BigInt(prefixLen); + const mask = hostBits === 0n ? 0n : (1n << hostBits) - 1n; + if ((value & mask) !== 0n) return undefined; + return { + family: "ipv4", + start: value, + end: value | mask, + prefixLen, + }; +} + +export interface CidrCoverageResult { + /** A malformed CIDR string, if any was supplied. */ + malformed?: string; + /** True if the supplied set covers the entire address space of a family. */ + coversInternet: boolean; + /** Which family the full-coverage union belongs to (if `coversInternet`). */ + family?: IpFamily; +} + +/** + * Decide whether `values` (a mixed IPv4/IPv6 CIDR list) collectively covers + * the entire address space of either family, or contains a malformed entry. + * + * Coverage is computed per family by merging sorted [start,end] intervals and + * checking the merged span equals the full family range. A single entry with + * prefix length 0 trivially covers its family; so does any union of narrower + * blocks whose ranges merge to the whole space (the split-range bypass). + */ +export function analyzeCidrCoverage(values: readonly string[]): CidrCoverageResult { + const byFamily: Record<IpFamily, ParsedCidr[]> = { ipv4: [], ipv6: [] }; + + for (const raw of values) { + const parsed = parseCidr(raw); + if (parsed === undefined) { + return { malformed: raw, coversInternet: false }; + } + byFamily[parsed.family].push(parsed); + } + + for (const family of ["ipv4", "ipv6"] as const) { + const blocks = byFamily[family]; + if (blocks.length === 0) continue; + const familyMax = family === "ipv4" ? IPV4_MAX : IPV6_MAX; + + const sorted = [...blocks].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0)); + + // The whole space starts at 0; if the lowest block does not, no full + // coverage is possible for this family. + if (sorted[0].start !== 0n) continue; + + let covered = sorted[0].end; + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i]; + // A gap exists only when the next block starts strictly beyond + // covered+1; adjacent or overlapping blocks extend coverage. + if (next.start > covered + 1n) break; + if (next.end > covered) covered = next.end; + } + if (covered >= familyMax) { + return { coversInternet: true, family }; + } + } + + return { coversInternet: false }; +}
packages/k8s-baseline/src/eks-admin-access-path.ts+32 −12 modified@@ -11,12 +11,12 @@ import type { EksAdminAccessPathOutputs, EksEndpointAccessConfig, } from "./eks-admin-access-path.outputs"; +import { analyzeCidrCoverage } from "./cidr-coverage"; export const EKS_ADMIN_ACCESS_PATH_COMPONENT_TYPE = "hulumi:k8s:EksAdminAccessPath"; const HTTPS_PORT = 443; const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; -const BROAD_CIDRS = new Set(["0.0.0.0/0", "::/0"]); const VALID_ENDPOINT_MODES: ReadonlySet<EksEndpointAccessMode> = new Set([ "private", "restricted-public", @@ -45,8 +45,27 @@ function normalizeStringList(field: string, values: string[] | undefined): strin }); } -function hasBroadCidr(values: string[]): string | undefined { - return values.find((value) => BROAD_CIDRS.has(value)); +/** + * Validate a CIDR list and decide whether it is "broad" — i.e. it covers the + * entire IPv4 or IPv6 address space. A bare-literal `0.0.0.0/0` / `::/0` set + * membership check is bypassed by a semantically equivalent split such as + * `["0.0.0.0/1", "128.0.0.0/1"]`, so coverage is computed by merging the + * parsed ranges. Malformed entries (previously any non-blank string was + * accepted) are rejected outright. + * + * Returns: + * - `{ malformed }` if any entry is not a syntactically valid CIDR, + * - `{ broad: true }` if the union covers a whole address family, + * - `{ broad: false }` otherwise. + */ +function classifyCidrs(name: string, field: string, values: string[]): { broad: boolean } { + const result = analyzeCidrCoverage(values); + if (result.malformed !== undefined) { + throw new Error( + `EksAdminAccessPath: ${field} contains a malformed/invalid CIDR "${result.malformed}" (component "${name}")`, + ); + } + return { broad: result.coversInternet }; } function requireTemporaryBroadPublicAccess( @@ -117,11 +136,11 @@ function normalizeArgs(name: string, args: EksAdminAccessPathArgs): NormalizedAr ); } - const broadPublicCidr = hasBroadCidr(publicAccessCidrs); + const publicCidrClass = classifyCidrs(name, "publicAccessCidrs", publicAccessCidrs); let temporaryBroadPublicAccess: TemporaryBroadPublicAccess | undefined; - if (broadPublicCidr !== undefined && endpointMode !== "public-temporary") { + if (publicCidrClass.broad && endpointMode !== "public-temporary") { throw new Error( - `EksAdminAccessPath: publicAccessCidrs contains ${broadPublicCidr}; use endpointMode "public-temporary" with temporaryBroadPublicAccess instead (component "${name}")`, + `EksAdminAccessPath: publicAccessCidrs covers the entire internet (e.g. 0.0.0.0/0, ::/0, or a split-range union); use endpointMode "public-temporary" with temporaryBroadPublicAccess instead (component "${name}")`, ); } if (endpointMode === "public-temporary") { @@ -135,13 +154,14 @@ function normalizeArgs(name: string, args: EksAdminAccessPathArgs): NormalizedAr ); } - const broadOperatorCidr = - hasBroadCidr(operatorCidrBlocks) ?? hasBroadCidr(operatorIpv6CidrBlocks); - if (broadOperatorCidr !== undefined) { - const field = - broadOperatorCidr === "::/0" ? "operatorAccess.ipv6CidrBlocks" : "operatorAccess.cidrBlocks"; + if (classifyCidrs(name, "operatorAccess.cidrBlocks", operatorCidrBlocks).broad) { + throw new Error( + `EksAdminAccessPath: operatorAccess.cidrBlocks covers the entire internet (e.g. 0.0.0.0/0 or a split-range union); broad control-plane SG ingress is refused (component "${name}")`, + ); + } + if (classifyCidrs(name, "operatorAccess.ipv6CidrBlocks", operatorIpv6CidrBlocks).broad) { throw new Error( - `EksAdminAccessPath: ${field} contains ${broadOperatorCidr}; broad control-plane SG ingress is refused (component "${name}")`, + `EksAdminAccessPath: operatorAccess.ipv6CidrBlocks covers the entire internet (e.g. ::/0 or a split-range union); broad control-plane SG ingress is refused (component "${name}")`, ); }
packages/k8s-baseline/src/metrics-server.ts+20 −6 modified@@ -51,7 +51,7 @@ function validateArgs(args: MetricsServerArgs): void { throw new Error("MetricsServer: extraArgs must not contain empty strings"); } } - if (extraArgs.includes("--kubelet-insecure-tls") && args.insecureKubeletTls === undefined) { + if (hasKubeletInsecureTlsFlag(extraArgs) && args.insecureKubeletTls === undefined) { throw new Error( "MetricsServer: --kubelet-insecure-tls requires insecureKubeletTls with a non-empty reason", ); @@ -60,6 +60,23 @@ function validateArgs(args: MetricsServerArgs): void { requireReason("insecureApiServiceTls", args.insecureApiServiceTls); } +const KUBELET_INSECURE_TLS_FLAG = "--kubelet-insecure-tls"; + +/** + * pflag accepts a boolean flag either bare (`--kubelet-insecure-tls`) or as a + * single argv token with an `=value` suffix (`--kubelet-insecure-tls=true`, + * `=1`, `=t`, `=TRUE`, …). An exact-literal `Array.includes` of the bare flag + * misses every `=value` form, letting the insecure flag reach Helm without the + * mandatory `insecureKubeletTls` reason. Treat any of these forms — including a + * contradictory explicit `=false` (the component owns this flag; passing it at + * all is a misconfig that must surface the opt-in) — as the insecure flag. + */ +function hasKubeletInsecureTlsFlag(extraArgs: readonly string[]): boolean { + return extraArgs.some( + (a) => a === KUBELET_INSECURE_TLS_FLAG || a.startsWith(`${KUBELET_INSECURE_TLS_FLAG}=`), + ); +} + function setIfDefined(target: Record<string, unknown>, key: string, value: unknown): void { if (value !== undefined) { target[key] = value; @@ -90,11 +107,8 @@ export class MetricsServer extends pulumi.ComponentResource implements MetricsSe ); const chartArgs = [...(args.extraArgs ?? [])]; - if ( - args.insecureKubeletTls?.enabled === true && - !chartArgs.includes("--kubelet-insecure-tls") - ) { - chartArgs.push("--kubelet-insecure-tls"); + if (args.insecureKubeletTls?.enabled === true && !hasKubeletInsecureTlsFlag(chartArgs)) { + chartArgs.push(KUBELET_INSECURE_TLS_FLAG); } const values: Record<string, unknown> = {
packages/k8s-baseline/tests/eks-admin-access-path.test.ts+79 −0 modified@@ -151,4 +151,83 @@ describe("EksAdminAccessPath — invalid input refusals", () => { }), ).toThrow(/clusterSecurityGroupId is required/); }); + + test("Scenario: restricted public endpoint rejects split-range full IPv4 coverage", () => { + expect( + () => + new EksAdminAccessPath("admin-path", { + clusterName: "prod-eks", + endpointMode: "restricted-public", + publicAccessCidrs: ["0.0.0.0/1", "128.0.0.0/1"], + }), + ).toThrow(/public-temporary/); + }); + + test("Scenario: operator cidrBlocks reject split-range full IPv4 coverage", () => { + expect( + () => + new EksAdminAccessPath("admin-path", { + clusterName: "prod-eks", + endpointMode: "private", + clusterSecurityGroupId: "sg-cluster", + operatorAccess: { + cidrBlocks: ["0.0.0.0/1", "128.0.0.0/1"], + }, + }), + ).toThrow(/operatorAccess\.cidrBlocks.*broad control-plane SG ingress is refused/); + }); + + test("Scenario: operator ipv6CidrBlocks reject split-range full IPv6 coverage", () => { + expect( + () => + new EksAdminAccessPath("admin-path", { + clusterName: "prod-eks", + endpointMode: "private", + clusterSecurityGroupId: "sg-cluster", + operatorAccess: { + ipv6CidrBlocks: ["::/1", "8000::/1"], + }, + }), + ).toThrow(/operatorAccess\.ipv6CidrBlocks.*broad control-plane SG ingress is refused/); + }); + + test("Scenario: malformed CIDR is rejected", () => { + expect( + () => + new EksAdminAccessPath("admin-path", { + clusterName: "prod-eks", + endpointMode: "restricted-public", + publicAccessCidrs: ["999.0.0.0/8"], + }), + ).toThrow(/malformed|invalid CIDR/i); + }); + + test("Scenario: missing prefix length is rejected as malformed", () => { + expect( + () => + new EksAdminAccessPath("admin-path", { + clusterName: "prod-eks", + endpointMode: "restricted-public", + publicAccessCidrs: ["203.0.113.10"], + }), + ).toThrow(/malformed|invalid CIDR/i); + }); + + test("Scenario: canonical single 0.0.0.0/0 under public-temporary exception still accepted", async () => { + const c = new EksAdminAccessPath("admin-path", { + clusterName: "prod-eks", + endpointMode: "public-temporary", + publicAccessCidrs: ["0.0.0.0/0"], + temporaryBroadPublicAccess: { + reason: "bootstrap operator VPN", + expiresOn: "2026-06-30", + }, + }); + + await settlePulumi(); + + expect(await valueOf(c.endpointPublicAccess)).toBe(true); + expect(await valueOf(c.publicAccessCidrs)).toEqual(["0.0.0.0/0"]); + expect(await valueOf(c.policyExceptionReason)).toMatch(/bootstrap operator VPN/); + }); });
packages/k8s-baseline/tests/metrics-server.test.ts+41 −0 modified@@ -82,6 +82,23 @@ describe("MetricsServer — happy paths", () => { expect(await valueOf(metrics.insecureApiServiceTlsReason)).toMatch(/temporary bootstrap/); }); + test("explicit =true kubelet flag with a supplied reason records the reason", async () => { + const metrics = new MetricsServer("cluster-metrics", { + extraArgs: ["--kubelet-insecure-tls=true"], + insecureKubeletTls: { + enabled: true, + reason: "node serving certs rotating during bootstrap", + }, + }); + await settlePulumi(); + + const releaseReg = registrations.find((r) => r.type === "kubernetes:helm.sh/v3:Release"); + const values = releaseReg!.inputs.values as Record<string, unknown>; + // The auto-append guard must recognise the =true form and NOT duplicate. + expect(values.args).toEqual(["--kubelet-insecure-tls=true"]); + expect(await valueOf(metrics.insecureKubeletTlsReason)).toMatch(/node serving certs rotating/); + }); + test("outputs expose the metrics APIService name and chart version", async () => { const metrics = new MetricsServer("cluster-metrics"); await settlePulumi(); @@ -121,6 +138,30 @@ describe("MetricsServer — invalid input refusals", () => { ).toThrow(/requires insecureKubeletTls with a non-empty reason/); }); + test("kubelet insecure TLS flag in =true form requires explicit reason", () => { + expect( + () => new MetricsServer("cluster-metrics", { extraArgs: ["--kubelet-insecure-tls=true"] }), + ).toThrow(/requires insecureKubeletTls with a non-empty reason/); + }); + + test("kubelet insecure TLS flag in =TRUE form requires explicit reason", () => { + expect( + () => new MetricsServer("cluster-metrics", { extraArgs: ["--kubelet-insecure-tls=TRUE"] }), + ).toThrow(/requires insecureKubeletTls with a non-empty reason/); + }); + + test("kubelet insecure TLS flag in =1 form requires explicit reason", () => { + expect( + () => new MetricsServer("cluster-metrics", { extraArgs: ["--kubelet-insecure-tls=1"] }), + ).toThrow(/requires insecureKubeletTls with a non-empty reason/); + }); + + test("contradictory --kubelet-insecure-tls=false still requires explicit opt-in", () => { + expect( + () => new MetricsServer("cluster-metrics", { extraArgs: ["--kubelet-insecure-tls=false"] }), + ).toThrow(/requires insecureKubeletTls with a non-empty reason/); + }); + test("insecure opt-in requires a reason", () => { expect( () =>
packages/policies/src/aws/cis-v5-pack.rules.ts+13 −2 modified@@ -9,7 +9,10 @@ import type { ResourceValidationPolicy } from "@pulumi/policy"; +import { isUrnChildOfComponent } from "../urn"; + const CIS_DOCS = "https://www.cisecurity.org/benchmark/amazon_web_services"; +const HULUMI_SECURE_BUCKET_TYPE = "hulumi:baseline:aws:SecureBucket"; const S3_BUCKET_TYPES = ["aws:s3/bucket:Bucket", "aws:s3/bucketV2:BucketV2"] as const; @@ -161,7 +164,11 @@ export const cis_2_1_1_ssePresent: ResourceValidationPolicy = { enforcementLevel: "advisory", validateResource: (args, reportViolation) => { if (!(S3_BUCKET_TYPES as readonly string[]).includes(args.type)) return; - if (args.urn.includes("hulumi:baseline:aws:SecureBucket$")) return; + // Anchored URN type-chain check — see ../urn.ts. The previous + // `args.urn.includes("hulumi:baseline:aws:SecureBucket$")` matched the + // operator-controlled logical-name suffix and let a raw bucket named + // `hulumi:baseline:aws:SecureBucket$x` silence this advisory. + if (isUrnChildOfComponent(args.urn, HULUMI_SECURE_BUCKET_TYPE)) return; reportViolation( violation( "CIS-AWS-v5.0.0:2.1.1", @@ -179,7 +186,11 @@ export const cis_2_1_5_tlsOnly: ResourceValidationPolicy = { enforcementLevel: "advisory", validateResource: (args, reportViolation) => { if (!(S3_BUCKET_TYPES as readonly string[]).includes(args.type)) return; - if (args.urn.includes("hulumi:baseline:aws:SecureBucket$")) return; + // Anchored URN type-chain check — see ../urn.ts. The previous + // `args.urn.includes("hulumi:baseline:aws:SecureBucket$")` matched the + // operator-controlled logical-name suffix and let a raw bucket named + // `hulumi:baseline:aws:SecureBucket$x` silence this advisory. + if (isUrnChildOfComponent(args.urn, HULUMI_SECURE_BUCKET_TYPE)) return; reportViolation( violation( "CIS-AWS-v5.0.0:2.1.5",
packages/policies/src/aws/hulumi-hardening-pack.ts+105 −11 modified@@ -15,6 +15,7 @@ import type { import type { PackMetadata, EnforcementLevel } from "../metadata"; import { matchSuppression, type Suppression } from "./suppressions"; +import { urnsShareParentComponent } from "../urn"; export const HULUMI_SECURE_BUCKET_TYPE = "hulumi:baseline:aws:SecureBucket"; export const RAW_S3_BUCKET_TYPES = ["aws:s3/bucket:Bucket", "aws:s3/bucketV2:BucketV2"] as const; @@ -184,11 +185,10 @@ export const h4StartupHardenedRequiresLogging: StackValidationPolicy = { }); for (const bucket of hardenedBuckets) { if (matchSuppression("HULUMI-H4", bucket.urn, suppressions).suppressed) continue; - const parentPrefix = bucket.urn.split("$")[0]; const loggingSibling = args.resources.find( (r: PolicyResource) => (BUCKET_LOGGING_TYPES as readonly string[]).includes(r.type) && - r.urn.startsWith(parentPrefix), + urnsShareParentComponent(bucket.urn, r.urn), ); if (!loggingSibling) { reportViolation( @@ -264,6 +264,69 @@ function isTlsOnlyPolicy(props: Record<string, unknown>): boolean { }); } +// Value-binding helpers for H5: a sibling resource that has the right type +// and shape proves NOTHING unless it actually points at the exempted bucket. +// Without this, an attacker who forges a SecureBucket-typed parent can also +// add five decoy siblings (PublicAccessBlock + SSE + ownership + versioning +// + policy) targeting a *different* bucket — H5 finds the decoys via shared +// parent URN and reports no violation, while the actually-exempted raw +// bucket is fully unhardened. The siblings' `bucket` prop and the bucket +// policy's `Resource` ARNs must reference the exempted bucket explicitly. +function bucketTargetCandidates(bucket: PolicyResource): Set<string> { + const out = new Set<string>(); + if (typeof bucket.name === "string" && bucket.name !== "") out.add(bucket.name); + const props = (bucket.props ?? {}) as Record<string, unknown>; + const bucketProp = props.bucket; + if (typeof bucketProp === "string" && bucketProp !== "") out.add(bucketProp); + const idProp = props.id; + if (typeof idProp === "string" && idProp !== "") out.add(idProp); + return out; +} + +function bucketControlTargetsBucket( + props: Record<string, unknown>, + bucket: PolicyResource, +): boolean { + const target = props.bucket; + if (typeof target !== "string" || target === "") return false; + return bucketTargetCandidates(bucket).has(target); +} + +function isTlsOnlyPolicyForBucket(props: Record<string, unknown>, bucket: PolicyResource): boolean { + if (!bucketControlTargetsBucket(props, bucket)) return false; + let doc: unknown = props.policy; + if (typeof doc === "string") { + try { + doc = JSON.parse(doc); + } catch { + return false; + } + } + const statements = asRecord(doc)?.Statement; + const list = Array.isArray(statements) ? statements : []; + // The Resource block on the relevant Deny statement must cover both the + // bucket ARN and the object ARN of the exempted bucket — otherwise the + // policy can name a Deny on a *different* bucket and still pass. + const candidates = bucketTargetCandidates(bucket); + return list.some((raw) => { + const stmt = asRecord(raw); + if (stmt?.Effect !== "Deny") return false; + const secureTransport = asRecord(asRecord(stmt.Condition)?.Bool)?.["aws:SecureTransport"]; + if (!(secureTransport === "false" || secureTransport === false)) return false; + const resources: unknown = stmt.Resource; + const resourceList: unknown[] = Array.isArray(resources) ? resources : [resources]; + let coversBucket = false; + let coversObjects = false; + for (const candidate of candidates) { + const bucketArn = `arn:aws:s3:::${candidate}`; + const objectArn = `${bucketArn}/*`; + if (resourceList.some((entry) => entry === bucketArn)) coversBucket = true; + if (resourceList.some((entry) => entry === objectArn)) coversObjects = true; + } + return coversBucket && coversObjects; + }); +} + // H5 — defense in depth for the H1 SecureBucket exemption. // // H1's exemption (isSecureBucketManagedBucketUrn) keys only on a Pulumi @@ -291,30 +354,61 @@ export const h5SecureBucketExemptionRequiresHardening: StackValidationPolicy = { }); for (const bucket of exemptedBuckets) { if (matchSuppression("HULUMI-H5", bucket.urn, suppressions).suppressed) continue; - const parentPrefix = bucket.urn.split("$")[0]; + // Every sibling check is BOTH structural (anchored parent-component + // type-chain match, not unanchored URN prefix) AND value-binding + // (sibling's `bucket` prop, or for the policy its `Resource` block, + // must reference the exempted bucket explicitly). The old + // `urn.startsWith(bucket.urn.split("$")[0])` form was forgeable in + // two ways: (a) a sibling parented under an unrelated component + // could share the string prefix; (b) without value binding, any + // siblings under the same forged wrapper that targeted a *different* + // bucket satisfied the check while the exempted bucket stayed raw. const sibling = ( types: readonly string[], - ok: (props: Record<string, unknown>) => boolean, + ok: (props: Record<string, unknown>, bucketRes: PolicyResource) => boolean, ): boolean => args.resources.some( (r: PolicyResource) => types.includes(r.type) && - r.urn.startsWith(parentPrefix) && - ok((r.props ?? {}) as Record<string, unknown>), + urnsShareParentComponent(bucket.urn, r.urn) && + ok((r.props ?? {}) as Record<string, unknown>, bucket), ); const missing: string[] = []; - if (!sibling(BUCKET_PAB_TYPES, isAllPublicAccessBlocked)) { + if ( + !sibling( + BUCKET_PAB_TYPES, + (p, b) => bucketControlTargetsBucket(p, b) && isAllPublicAccessBlocked(p), + ) + ) { missing.push("all-true BucketPublicAccessBlock"); } - if (!sibling(BUCKET_SSE_TYPES, isKmsSse)) missing.push("SSE-KMS encryption"); - if (!sibling(BUCKET_OWNERSHIP_TYPES, isOwnerEnforced)) { + if (!sibling(BUCKET_SSE_TYPES, (p, b) => bucketControlTargetsBucket(p, b) && isKmsSse(p))) { + missing.push("SSE-KMS encryption"); + } + if ( + !sibling( + BUCKET_OWNERSHIP_TYPES, + (p, b) => bucketControlTargetsBucket(p, b) && isOwnerEnforced(p), + ) + ) { missing.push("BucketOwnerEnforced ownership controls"); } - if (!sibling(BUCKET_VERSIONING_TYPES, isVersioningEnabled)) { + if ( + !sibling( + BUCKET_VERSIONING_TYPES, + (p, b) => bucketControlTargetsBucket(p, b) && isVersioningEnabled(p), + ) + ) { missing.push("enabled bucket versioning"); } - if (!sibling(BUCKET_POLICY_TYPES, isTlsOnlyPolicy)) missing.push("TLS-only bucket policy"); + if (!sibling(BUCKET_POLICY_TYPES, isTlsOnlyPolicyForBucket)) { + missing.push("TLS-only bucket policy bound to this bucket"); + } + // `isTlsOnlyPolicy` is kept exported for backwards-compat in case + // out-of-tree callers still reference it; H5 itself now uses the + // bound variant exclusively. + void isTlsOnlyPolicy; if (missing.length > 0) { reportViolation(
packages/policies/src/cloudflare/hulumi-hardening-pack.ts+7 −1 modified@@ -6,6 +6,7 @@ import type { import type { PackMetadata } from "../metadata"; import { matchSuppression, type Suppression } from "../aws/suppressions"; +import { isUrnChildOfComponent } from "../urn"; export const CF_DNS_1_RULE_ID = "CF_DNS_1_NO_DNS_ONLY_PUBLIC_APP_RECORD"; export const CF_DNSSEC_1_RULE_ID = "CF_DNSSEC_1_REQUIRE_PUBLIC_ZONE_DNSSEC"; @@ -39,7 +40,12 @@ function isDnsRecordType(type: string): boolean { } function isChildOf(urn: string, componentType: string): boolean { - return urn.includes(`${componentType}$`); + // Anchored URN type-chain check — see ../urn.ts. The previous form + // `urn.includes(\`${componentType}$\`)` matched a substring anywhere in + // the URN, including the operator-controlled logical-name suffix, so a + // raw DnsRecord / zone named `hulumi:cloudflare:PublicHostname$x` could + // bypass CF_DNS_1 / CF_DNSSEC_1. + return isUrnChildOfComponent(urn, componentType); } function stringArrayIncludes(value: unknown, needle: string): boolean {
packages/policies/src/github/github-oidc-issuer.ts+20 −4 modified@@ -5,16 +5,16 @@ export const GITHUB_OIDC_ISSUER_HOST = "token.actions.githubusercontent.com"; -// Extract the OIDC-provider host from a trust-policy `Principal.Federated` -// value (an IAM OIDC-provider ARN +// Extract the OIDC-provider host from a single trust-policy +// `Principal.Federated` entry (an IAM OIDC-provider ARN // `arn:aws:iam::<acct>:oidc-provider/<host>[/...]`, or a raw issuer URL) // and compare it EXACTLY to the GitHub Actions issuer host. An unanchored // `String(...).includes("token.actions.githubusercontent.com")` substring // test would also match a crafted // `.../oidc-provider/token.actions.githubusercontent.com.evil.com` or // `.../oidc-provider/evil.com/token.actions.githubusercontent.com`. -export function federatedIsGithubOidc(federated: string): boolean { - let host = federated; +function federatedEntryIsGithubOidc(entry: string): boolean { + let host = entry; const marker = "oidc-provider/"; const idx = host.indexOf(marker); if (idx !== -1) host = host.slice(idx + marker.length); @@ -23,3 +23,19 @@ export function federatedIsGithubOidc(federated: string): boolean { if (slash !== -1) host = host.slice(0, slash); return host === GITHUB_OIDC_ISSUER_HOST; } + +// AWS IAM `Principal.Federated` may be a single string OR an array of +// federated-provider strings. A trust policy listing the real GitHub OIDC +// provider ARN alongside another provider must still be treated as +// GitHub-OIDC-trusted; a lossy `String(...)` coercion comma-joins arrays +// and bypasses the anchored host match. Match if ANY array element is a +// string identifying the GitHub Actions issuer. +export function federatedIsGithubOidc(federated: unknown): boolean { + if (Array.isArray(federated)) { + return federated.some( + (entry) => typeof entry === "string" && federatedEntryIsGithubOidc(entry), + ); + } + if (typeof federated === "string") return federatedEntryIsGithubOidc(federated); + return false; +}
packages/policies/src/github/g-oidc-1.ts+1 −2 modified@@ -84,8 +84,7 @@ function inspectAwsIamTrustPolicy( const s = stmt as Record<string, unknown>; const principal = s.Principal as Record<string, unknown> | undefined; if (!principal || principal.Federated === undefined) continue; - const fed = String(principal.Federated); - if (!federatedIsGithubOidc(fed)) continue; + if (!federatedIsGithubOidc(principal.Federated)) continue; const conds = s.Condition as Record<string, unknown> | undefined; if (!conds || typeof conds !== "object") continue; for (const [operator, rawCondition] of Object.entries(conds)) {
packages/policies/src/github/g-oidc-2.ts+1 −1 modified@@ -63,7 +63,7 @@ export function trustPolicyTrustsGithubOidc(assumeRolePolicy: unknown): boolean | Record<string, unknown> | undefined; if (!principal || principal.Federated === undefined) continue; - if (federatedIsGithubOidc(String(principal.Federated))) return true; + if (federatedIsGithubOidc(principal.Federated)) return true; } return false; }
packages/policies/src/github/hulumi-hardening-pack.rules.ts+9 −2 modified@@ -9,6 +9,7 @@ import type { ResourceValidationPolicy, StackValidationPolicy } from "@pulumi/po import { matchSuppression, type Suppression } from "./suppressions"; import { G_OIDC_1 } from "./g-oidc-1"; import { G_OIDC_2 } from "./g-oidc-2"; +import { isUrnChildOfComponent } from "../urn"; const HULUMI_SECURE_REPOSITORY_TYPE = "hulumi:baseline:github:SecureRepository"; const HULUMI_ORG_FOUNDATION_TYPE = "hulumi:baseline:github:OrgFoundation"; @@ -30,11 +31,17 @@ function readSuppressions(config: Record<string, unknown> | undefined): Suppress } function isChildOfSecureRepository(urn: string): boolean { - return urn.includes(`${HULUMI_SECURE_REPOSITORY_TYPE}$`); + // Anchored URN type-chain check — see ../urn.ts. The prior + // `urn.includes(\`${HULUMI_SECURE_REPOSITORY_TYPE}$\`)` was bypassed by a + // raw github.Repository declared with a logical name carrying the + // SecureRepository type token. + return isUrnChildOfComponent(urn, HULUMI_SECURE_REPOSITORY_TYPE); } function isChildOfOrgFoundation(urn: string): boolean { - return urn.includes(`${HULUMI_ORG_FOUNDATION_TYPE}$`); + // Anchored URN type-chain check — see ../urn.ts. Same forged-logical-name + // spoof class as isChildOfSecureRepository above. + return isUrnChildOfComponent(urn, HULUMI_ORG_FOUNDATION_TYPE); } /**
packages/policies/src/k8s/cidr-coverage.ts+140 −0 added@@ -0,0 +1,140 @@ +// Self-contained CIDR validation + internet-coverage detection. +// +// Duplicated from `@hulumi/k8s-baseline`'s `src/cidr-coverage.ts` because the +// two packages are separate; cross-package imports between sibling packages +// in this workspace are awkward, and adding a new npm dependency would +// trigger the dependabot exact-pin / cooling-off gate. The helper is pure +// and tiny (no network, no clock, no globals), so a careful duplicate is +// preferable to a fragile cross-package import or a new dependency. +// +// Keep the two copies in sync when updating coverage semantics. + +export type IpFamily = "ipv4" | "ipv6"; + +interface ParsedCidr { + family: IpFamily; + start: bigint; + end: bigint; + prefixLen: number; +} + +const IPV4_BITS = 32n; +const IPV6_BITS = 128n; +const IPV4_MAX = (1n << IPV4_BITS) - 1n; +const IPV6_MAX = (1n << IPV6_BITS) - 1n; + +function parseIpv4(addr: string): bigint | undefined { + const parts = addr.split("."); + if (parts.length !== 4) return undefined; + let value = 0n; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return undefined; + if (part.length > 1 && part.startsWith("0")) return undefined; + const n = Number(part); + if (n > 255) return undefined; + value = (value << 8n) | BigInt(n); + } + return value; +} + +function parseIpv6(addr: string): bigint | undefined { + if (addr.includes(".")) return undefined; + const doubleColonCount = (addr.match(/::/g) ?? []).length; + if (doubleColonCount > 1) return undefined; + + let head: string[]; + let tail: string[]; + if (doubleColonCount === 1) { + const [h, t] = addr.split("::"); + head = h === "" ? [] : h.split(":"); + tail = t === "" ? [] : t.split(":"); + } else { + head = addr.split(":"); + tail = []; + } + + const hextets = [...head, ...tail]; + if (doubleColonCount === 0 && hextets.length !== 8) return undefined; + if (doubleColonCount === 1 && hextets.length >= 8) return undefined; + if (head.length + tail.length > 8) return undefined; + + for (const h of hextets) { + if (!/^[0-9a-fA-F]{1,4}$/.test(h)) return undefined; + } + + const zeros = 8 - (head.length + tail.length); + const full = [...head, ...Array<string>(doubleColonCount === 1 ? zeros : 0).fill("0"), ...tail]; + if (full.length !== 8) return undefined; + + let value = 0n; + for (const h of full) { + value = (value << 16n) | BigInt(parseInt(h, 16)); + } + return value; +} + +export function parseCidr(cidr: string): ParsedCidr | undefined { + const slash = cidr.indexOf("/"); + if (slash === -1) return undefined; + const addrPart = cidr.slice(0, slash); + const prefixPart = cidr.slice(slash + 1); + if (!/^\d{1,3}$/.test(prefixPart)) return undefined; + if (prefixPart.length > 1 && prefixPart.startsWith("0")) return undefined; + const prefixLen = Number(prefixPart); + + const isV6 = addrPart.includes(":"); + if (isV6) { + const value = parseIpv6(addrPart); + if (value === undefined || prefixLen > 128) return undefined; + const hostBits = IPV6_BITS - BigInt(prefixLen); + const mask = hostBits === 0n ? 0n : (1n << hostBits) - 1n; + if ((value & mask) !== 0n) return undefined; + return { family: "ipv6", start: value, end: value | mask, prefixLen }; + } + + const value = parseIpv4(addrPart); + if (value === undefined || prefixLen > 32) return undefined; + const hostBits = IPV4_BITS - BigInt(prefixLen); + const mask = hostBits === 0n ? 0n : (1n << hostBits) - 1n; + if ((value & mask) !== 0n) return undefined; + return { family: "ipv4", start: value, end: value | mask, prefixLen }; +} + +export interface CidrCoverageResult { + malformed?: string; + coversInternet: boolean; + family?: IpFamily; +} + +export function analyzeCidrCoverage(values: readonly string[]): CidrCoverageResult { + const byFamily: Record<IpFamily, ParsedCidr[]> = { ipv4: [], ipv6: [] }; + + for (const raw of values) { + const parsed = parseCidr(raw); + if (parsed === undefined) { + return { malformed: raw, coversInternet: false }; + } + byFamily[parsed.family].push(parsed); + } + + for (const family of ["ipv4", "ipv6"] as const) { + const blocks = byFamily[family]; + if (blocks.length === 0) continue; + const familyMax = family === "ipv4" ? IPV4_MAX : IPV6_MAX; + + const sorted = [...blocks].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0)); + if (sorted[0].start !== 0n) continue; + + let covered = sorted[0].end; + for (let i = 1; i < sorted.length; i++) { + const next = sorted[i]; + if (next.start > covered + 1n) break; + if (next.end > covered) covered = next.end; + } + if (covered >= familyMax) { + return { coversInternet: true, family }; + } + } + + return { coversInternet: false }; +}
packages/policies/src/k8s/eks-cluster-pack.ts+21 −2 modified@@ -9,6 +9,7 @@ import type { ResourceValidationPolicy } from "@pulumi/policy"; import type { PackMetadata } from "../metadata"; import { matchSuppression, type Suppression } from "../aws/suppressions"; +import { analyzeCidrCoverage } from "./cidr-coverage"; const DOCS_BASE = "https://github.com/kerberosmansour/hulumi/blob/main/docs/components/README.md"; @@ -53,9 +54,27 @@ export const eksCl1NoBroadPublicEndpoint: ResourceValidationPolicy = { if (matchSuppression("HULUMI-EKS-CL-1", args.urn, suppressions).suppressed) return; const cidrs = vpc.publicAccessCidrs ?? []; // AWS default for unset is ["0.0.0.0/0"] — the unsafe-by-default behavior we want to catch. - if (cidrs.length === 0 || cidrs.includes("0.0.0.0/0")) { + if (cidrs.length === 0) { reportViolation( - `HULUMI-EKS-CL-1: EKS cluster ${args.urn} has endpointPublicAccess=true with publicAccessCidrs containing 0.0.0.0/0 (or unset, which defaults to 0.0.0.0/0). Restrict to the operator network. Docs: ${DOCS_BASE}`, + `HULUMI-EKS-CL-1: EKS cluster ${args.urn} has endpointPublicAccess=true with publicAccessCidrs unset, which defaults to 0.0.0.0/0. Restrict to the operator network. Docs: ${DOCS_BASE}`, + ); + return; + } + // Bare-literal includes("0.0.0.0/0") is bypassed by a split like + // ["0.0.0.0/1","128.0.0.0/1"] that collectively covers the internet. + // Use coverage analysis to catch the split-range form and also reject + // any syntactically malformed CIDR string (the previous control + // accepted any non-blank string). + const coverage = analyzeCidrCoverage(cidrs); + if (coverage.malformed !== undefined) { + reportViolation( + `HULUMI-EKS-CL-1: EKS cluster ${args.urn} has a malformed/invalid publicAccessCidrs entry "${coverage.malformed}". Provide syntactically valid CIDR(s). Docs: ${DOCS_BASE}`, + ); + return; + } + if (coverage.coversInternet) { + reportViolation( + `HULUMI-EKS-CL-1: EKS cluster ${args.urn} has endpointPublicAccess=true with publicAccessCidrs that cover the entire internet (0.0.0.0/0, ::/0, or a split-range union such as 0.0.0.0/1 + 128.0.0.0/1). Restrict to the operator network. Docs: ${DOCS_BASE}`, ); } },
packages/policies/src/platform/deployment-governance-pack.ts+7 −1 modified@@ -5,6 +5,7 @@ import type { } from "@pulumi/policy"; import type { PackMetadata } from "../metadata"; +import { isUrnChildOfComponent } from "../urn"; export const DEPLOY_GOV_1_RULE_ID = "DEPLOY_GOV_1_REQUIRE_PROTECTED_ENVIRONMENT"; export const DEPLOY_GOV_2_RULE_ID = "DEPLOY_GOV_2_NO_LONG_LIVED_AWS_SECRETS"; @@ -85,7 +86,12 @@ function hasOidcRoleEvidence(resources: readonly PolicyResource[], repoName: str } function isChildOf(resource: PolicyResource, componentType: string): boolean { - return resource.urn.includes(`${componentType}$`); + // Anchored URN type-chain check — see ../urn.ts. The previous form + // `resource.urn.includes(\`${componentType}$\`)` was bypassed when a raw + // resource was declared with a logical name embedding the component type, + // because Pulumi URNs include the operator-controlled logical name after + // the final `::`. + return isUrnChildOfComponent(resource.urn, componentType); } function deploymentRepositoryFoundationName(resource: PolicyResource): string | undefined {
packages/policies/src/urn.ts+94 −0 added@@ -0,0 +1,94 @@ +// Anchored Pulumi-URN parsing used by policy packs to decide whether a +// resource is a child of a given component type. Substring/`includes` +// matching is unsafe because Pulumi URNs include the operator-controlled +// logical resource name after the final `::`, so a raw resource can be +// declared with a logical name that embeds a parent-component type string +// and bypass any policy that keys on `urn.includes("<type>$")`. This module +// parses the URN into stack / project / type-chain / logical-name and +// matches only against the type-chain ancestors, never the logical name. +// +// Pulumi URN shape (see pulumi/pulumi `pkg/resource/urn.go`): +// urn:pulumi:<stack>::<project>::<typeChain>::<logicalName> +// where <typeChain> is a `$`-joined chain of types ending in the resource's +// own type, e.g. `hulumi:baseline:aws:SecureBucket$aws:s3/bucketV2:BucketV2`. +// Mirrors the safer slicing already used by isSecureBucketManagedBucketUrn +// in aws/hulumi-hardening-pack.ts and federatedIsGithubOidc in +// github/github-oidc-issuer.ts — kept in one place so every pack is anchored. + +const URN_PREFIX = "urn:pulumi:"; + +export interface ParsedUrn { + readonly stack: string; + readonly project: string; + /** Type-chain ancestors in order; the LAST entry is the resource's own type. */ + readonly typeChain: readonly string[]; + readonly logicalName: string; +} + +/** + * Parse a Pulumi URN. Returns `undefined` for any URN that does not match + * the expected `urn:pulumi:<stack>::<project>::<typeChain>::<logicalName>` + * shape — callers should fail closed and treat that as a non-child. + */ +export function parseUrn(urn: string): ParsedUrn | undefined { + if (typeof urn !== "string" || !urn.startsWith(URN_PREFIX)) return undefined; + const parts = urn.split("::"); + if (parts.length < 4) return undefined; + // The first segment is `urn:pulumi:<stack>` — split off the stack. + const stack = parts[0].slice(URN_PREFIX.length); + if (stack === "") return undefined; + const project = parts[1]; + // The logical name is everything after the final `::`. The type chain is + // the segment immediately before it. Anything between project and type + // chain (e.g. provider segments) is preserved by joining; the leaf two + // segments are the part we trust. + const logicalName = parts[parts.length - 1]; + const typeChainRaw = parts[parts.length - 2]; + if (project === "" || typeChainRaw === "" || logicalName === "") return undefined; + const typeChain = typeChainRaw.split("$").filter((t) => t !== ""); + if (typeChain.length === 0) return undefined; + return { stack, project, typeChain, logicalName }; +} + +/** + * True iff `componentType` appears as a NON-LEAF entry in the URN's type + * chain — i.e. the resource is strictly inside a parent component of that + * type. The leaf entry is the resource's own type and is deliberately + * excluded so that a top-level resource OF the component type does not + * count as "its own child". + * + * Safe against the forged-logical-name spoof: a raw resource declared + * with logical name `hulumi:platform:DeploymentRepositoryFoundation$x` + * has that string in `logicalName`, NOT in `typeChain`, and is rejected. + */ +export function isUrnChildOfComponent(urn: string, componentType: string): boolean { + if (typeof componentType !== "string" || componentType === "") return false; + const parsed = parseUrn(urn); + if (parsed === undefined) return false; + // All ancestors except the resource's own (leaf) type. + const ancestors = parsed.typeChain.slice(0, -1); + return ancestors.includes(componentType); +} + +/** + * Two resources share the same parent component instance iff their URNs + * share the same (stack, project, typeChain ancestors, and the component + * instance has the same logical name). Pulumi does not encode the parent's + * logical name into a child URN, so the strongest invariant we can assert + * from URN parsing alone is "same type-chain ancestry" — which is what + * sibling-style policy checks need (combined with a value-binding check + * on a property of the sibling that names the target resource). + */ +export function urnsShareParentComponent(a: string, b: string): boolean { + const pa = parseUrn(a); + const pb = parseUrn(b); + if (pa === undefined || pb === undefined) return false; + if (pa.stack !== pb.stack || pa.project !== pb.project) return false; + const ancestorsA = pa.typeChain.slice(0, -1); + const ancestorsB = pb.typeChain.slice(0, -1); + if (ancestorsA.length === 0 || ancestorsA.length !== ancestorsB.length) return false; + for (let i = 0; i < ancestorsA.length; i++) { + if (ancestorsA[i] !== ancestorsB[i]) return false; + } + return true; +}
packages/policies/tests/cis-v5-pack.test.ts+39 −0 modified@@ -572,3 +572,42 @@ describe("CisV5Pack metadata — every registered rule has a runtime test", () = } }); }); + +// Cluster B regression — cis_2_1_1_ssePresent and cis_2_1_5_tlsOnly skip +// when `args.urn.includes("hulumi:baseline:aws:SecureBucket$")`. That +// substring fires on attacker-controlled logical names too. Advisory +// enforcement only, but the same forged-logical-name spoof class. +describe("CIS-v5 §2.1.1 / §2.1.5 — forged-logical-name URN spoof", () => { + let violations: string[]; + const report = (m: string): void => { + violations.push(m); + }; + + beforeEach(() => { + violations = []; + }); + + it("CIS 2.1.1 still advises a raw bucket whose LOGICAL NAME embeds SecureBucket type", () => { + const args = makeResourceArgs({ + type: "aws:s3/bucketV2:BucketV2", + urn: "urn:pulumi:s::p::aws:s3/bucketV2:BucketV2::hulumi:baseline:aws:SecureBucket$evil-bucket", + name: "hulumi:baseline:aws:SecureBucket$evil-bucket", + props: {}, + }); + invokeResource(cis_2_1_1_ssePresent, args, report); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/CIS-AWS-v5\.0\.0:2\.1\.1/); + }); + + it("CIS 2.1.5 still advises a raw bucket whose LOGICAL NAME embeds SecureBucket type", () => { + const args = makeResourceArgs({ + type: "aws:s3/bucketV2:BucketV2", + urn: "urn:pulumi:s::p::aws:s3/bucketV2:BucketV2::hulumi:baseline:aws:SecureBucket$evil-bucket", + name: "hulumi:baseline:aws:SecureBucket$evil-bucket", + props: {}, + }); + invokeResource(cis_2_1_5_tlsOnly, args, report); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/CIS-AWS-v5\.0\.0:2\.1\.5/); + }); +});
packages/policies/tests/cloudflare/hulumi-cloudflare-hardening-pack.test.ts+61 −0 modified@@ -367,3 +367,64 @@ describe("HulumiCloudflareHardeningPack CF_ORIGIN_1 — secure origin evidence r expect(violations[0]).toContain(appRecord.urn); }); }); + +// Cluster B regression — `isChildOf` in this pack used `urn.includes(\`${type}$\`)` +// over the full URN; the operator-controlled logical-name suffix could embed +// PUBLIC_HOSTNAME_TYPE or ZONE_FOUNDATION_TYPE and bypass CF_DNS_1 / CF_DNSSEC_1. +describe("HulumiCloudflareHardeningPack — forged-logical-name URN spoof", () => { + let violations: string[]; + const report = (m: string): void => { + violations.push(m); + }; + + beforeEach(() => { + violations = []; + }); + + it("CF_DNS_1 reports even when a raw DNS record's LOGICAL NAME embeds PublicHostname type", () => { + // Raw DNS record marked as a public application whose logical name + // carries the PublicHostname type token. Type chain is just the raw + // DnsRecord, NOT a child of any PublicHostname component. + const spoofedArgs = makeResourceArgs({ + type: DNS_RECORD_TYPE, + urn: `urn:pulumi:s::p::${DNS_RECORD_TYPE}::${PUBLIC_HOSTNAME_TYPE}$app-record`, + name: `${PUBLIC_HOSTNAME_TYPE}$app-record`, + // `purpose: "public-app"` makes this a real DNS-only public-app + // record CF_DNS_1 should reject. Pre-fix, the spoofed logical name + // made `isChildOf(urn, PUBLIC_HOSTNAME_TYPE)` substring-match `true` + // → CF_DNS_1 early-returned and the violation never fired. Post-fix, + // the anchored helper returns false (no real PublicHostname parent) + // → the rule proceeds and reports as it should. + props: { type: "A", proxied: false, purpose: "public-app" }, + }); + + ( + cfDns1NoDnsOnlyPublicAppRecord.validateResource as ( + a: ResourceValidationArgs, + r: (m: string) => void, + ) => void + )(spoofedArgs, report); + + expect(violations).toHaveLength(1); + expect(violations[0]).toContain(CF_DNS_1_RULE_ID); + }); + + it("CF_DNSSEC_1 reports even when a raw zone's LOGICAL NAME embeds ZoneFoundation type", () => { + const spoofedZone = makePolicyResource({ + type: ZONE_TYPE, + urn: `urn:pulumi:s::p::${ZONE_TYPE}::${ZONE_FOUNDATION_TYPE}$evil-zone`, + name: `${ZONE_FOUNDATION_TYPE}$evil-zone`, + props: {}, + }); + + ( + cfDnssec1RequirePublicZoneDnssec.validateStack as ( + a: StackValidationArgs, + r: (m: string) => void, + ) => void + )(makeStackArgs([spoofedZone]), report); + + expect(violations).toHaveLength(1); + expect(violations[0]).toContain(CF_DNSSEC_1_RULE_ID); + }); +});
packages/policies/tests/github/github-oidc-issuer.test.ts+36 −0 modified@@ -39,3 +39,39 @@ describe("federatedIsGithubOidc — anchored host match", () => { expect(federatedIsGithubOidc("")).toBe(false); }); }); + +describe("federatedIsGithubOidc — array Principal.Federated", () => { + it("matches when the GitHub provider is one element among others", () => { + expect( + federatedIsGithubOidc([ + "arn:aws:iam::123456789012:oidc-provider/accounts.google.com", + "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com", + ]), + ).toBe(true); + }); + + it("rejects an array of only non-GitHub providers", () => { + expect( + federatedIsGithubOidc([ + "arn:aws:iam::123456789012:oidc-provider/accounts.google.com", + "cognito-identity.amazonaws.com", + ]), + ).toBe(false); + }); + + it("rejects an array whose only GitHub-ish element is a crafted look-alike", () => { + expect( + federatedIsGithubOidc([ + "arn:aws:iam::123456789012:oidc-provider/accounts.google.com", + "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com.evil.com", + ]), + ).toBe(false); + }); + + it("rejects non-string / empty inputs", () => { + expect(federatedIsGithubOidc(undefined)).toBe(false); + expect(federatedIsGithubOidc(null)).toBe(false); + expect(federatedIsGithubOidc([])).toBe(false); + expect(federatedIsGithubOidc([123, { a: 1 }])).toBe(false); + }); +});
packages/policies/tests/github/g-oidc-2.test.ts+34 −0 modified@@ -123,6 +123,40 @@ describe("G_OIDC_2 — cluster-admin / AdministratorAccess via GitHub OIDC", () expect(violations[0]).toMatch(/AdministratorAccess/); }); + it("flags AdministratorAccess on a role whose Principal.Federated is an array (GitHub + another) (#170)", () => { + const arrayFedTrust = JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { + Federated: [ + "arn:aws:iam::1:oidc-provider/accounts.google.com", + "arn:aws:iam::1:oidc-provider/token.actions.githubusercontent.com", + ], + }, + Action: "sts:AssumeRoleWithWebIdentity", + }, + ], + }); + const arrayFedRole = res({ + type: "aws:iam/role:Role", + urn: "urn:pulumi:s::p::aws:iam/role:Role::array-fed", + name: "array-fed", + props: { assumeRolePolicy: arrayFedTrust, arn: "arn:aws:iam::1:role/array-fed" }, + }); + const attach = res({ + type: "aws:iam/rolePolicyAttachment:RolePolicyAttachment", + urn: "urn:pulumi:s::p::aws:iam/rolePolicyAttachment:RolePolicyAttachment::admin", + name: "admin", + props: { policyArn: "arn:aws:iam::aws:policy/AdministratorAccess" }, + propertyDependencies: { role: [arrayFedRole] }, + }); + violations = run(makeStackArgs([arrayFedRole, attach])); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/AdministratorAccess/); + }); + it("does NOT flag a namespace-scoped AmazonEKSEditPolicy association", () => { const assoc = res({ type: "aws:eks/accessPolicyAssociation:AccessPolicyAssociation",
packages/policies/tests/github/hulumi-hardening-pack.test.ts+99 −0 modified@@ -324,6 +324,42 @@ describe("G_OIDC_1 / HULUMI-H3 — wildcard rejection across AWS/Azure/GCP", () } }); + it("rejects AWS IAM trust policy with an array Principal.Federated listing the GitHub provider (#170)", () => { + const args = makeResourceArgs({ + type: G_OIDC_1_AWS_IAM_ROLE_TYPE, + urn: "urn:pulumi:s::p::aws:iam/role:Role::array-fed-role", + name: "array-fed-role", + props: { + assumeRolePolicy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { + Federated: [ + "arn:aws:iam::123456789012:oidc-provider/accounts.google.com", + "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com", + ], + }, + Action: "sts:AssumeRoleWithWebIdentity", + Condition: { + StringLike: { + "token.actions.githubusercontent.com:sub": "repo:org/repo:*", + }, + }, + }, + ], + }), + }, + }); + (G_OIDC_1.validateResource as (a: ResourceValidationArgs, r: (m: string) => void) => void)( + args, + report, + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/G_OIDC_1/); + }); + it("rejects AWS IAM trust policy with set-qualified StringEquals + wildcard sub value", () => { const args = makeResourceArgs({ type: G_OIDC_1_AWS_IAM_ROLE_TYPE, @@ -428,3 +464,66 @@ describe("G_OIDC_1 / HULUMI-H3 — wildcard rejection across AWS/Azure/GCP", () expect(h3NoWildcardTrustPolicy.validateResource).toBe(G_OIDC_1.validateResource); }); }); + +// Cluster B regression — `isChildOfSecureRepository` / `isChildOfOrgFoundation` +// used `urn.includes(\`${type}$\`)` over the full URN. An attacker-controlled +// logical name embedding the parent type token bypassed H1 / H2 entirely. +describe("HulumiGithubHardeningPack — forged-logical-name URN spoof", () => { + let violations: string[]; + const report = (m: string): void => { + violations.push(m); + }; + + beforeEach(() => { + violations = []; + }); + + it("H1 still reports a raw github.Repository whose LOGICAL NAME embeds SecureRepository type", () => { + // Spoof: raw github.Repository with logical name + // `hulumi:baseline:github:SecureRepository$victim`. URN contains the + // type token via the logical-name suffix; type chain is just + // `github:index/repository:Repository`. + const args = makeResourceArgs({ + type: "github:index/repository:Repository", + urn: "urn:pulumi:s::p::github:index/repository:Repository::hulumi:baseline:github:SecureRepository$victim", + name: "hulumi:baseline:github:SecureRepository$victim", + props: { name: "victim-repo" }, + }); + + ( + h1NoRawGithubRepository.validateResource as ( + a: ResourceValidationArgs, + r: (m: string) => void, + ) => void + )(args, report); + + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/HULUMI-H1/); + }); + + it("H2 still reports a wildcard OIDC template whose LOGICAL NAME embeds OrgFoundation type", () => { + // Spoof on H2's wildcard guard — same forged-logical-name trick. + const args = makeResourceArgs({ + type: "github:index/actionsOrganizationOidcSubjectClaimCustomizationTemplate:ActionsOrganizationOidcSubjectClaimCustomizationTemplate", + urn: "urn:pulumi:s::p::github:index/actionsOrganizationOidcSubjectClaimCustomizationTemplate:ActionsOrganizationOidcSubjectClaimCustomizationTemplate::hulumi:baseline:github:OrgFoundation$spoof", + name: "hulumi:baseline:github:OrgFoundation$spoof", + // Wildcard claim — H2's mandatory rejection axis. Pre-fix, the URN's + // logical-name spoof made `isChildOfOrgFoundation` return true via + // substring match, so H2 early-returned and the wildcard was never + // inspected. Post-fix, the anchored check returns false (template's + // type chain is not under any real OrgFoundation), and the wildcard + // is reported as it should be. + props: { includeClaimKeys: ["*"] }, + }); + + ( + h2NoWildcardOidcTemplate.validateResource as ( + a: ResourceValidationArgs, + r: (m: string) => void, + ) => void + )(args, report); + + expect(violations.length).toBeGreaterThanOrEqual(1); + expect(violations[0]).toMatch(/HULUMI-H2/); + }); +});
packages/policies/tests/hulumi-hardening-pack.test.ts+209 −2 modified@@ -444,12 +444,18 @@ describe("HulumiHardeningPack H5 — SecureBucket H1 exemption must be backed by name: "sb-bucket", props: {}, }); + // Fixtures explicitly bind each sibling to the exempted bucket via the + // `bucket` prop and (for the policy) via the `Resource` block. This is + // what a genuine SecureBucket emits — and is what H5's value-binding + // check requires to defeat the "decoy siblings targeting a different + // bucket" bypass. const hardenedSiblings = (): PolicyResource[] => [ makePolicyResource({ type: "aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock", urn: `${PARENT}$aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock::sb-pab`, name: "sb-pab", props: { + bucket: "sb-bucket", blockPublicAcls: true, ignorePublicAcls: true, blockPublicPolicy: true, @@ -461,33 +467,36 @@ describe("HulumiHardeningPack H5 — SecureBucket H1 exemption must be backed by urn: `${PARENT}$aws:s3/bucketServerSideEncryptionConfiguration:BucketServerSideEncryptionConfiguration::sb-sse`, name: "sb-sse", props: { + bucket: "sb-bucket", rules: [{ applyServerSideEncryptionByDefault: { sseAlgorithm: "aws:kms" } }], }, }), makePolicyResource({ type: "aws:s3/bucketOwnershipControls:BucketOwnershipControls", urn: `${PARENT}$aws:s3/bucketOwnershipControls:BucketOwnershipControls::sb-own`, name: "sb-own", - props: { rule: { objectOwnership: "BucketOwnerEnforced" } }, + props: { bucket: "sb-bucket", rule: { objectOwnership: "BucketOwnerEnforced" } }, }), makePolicyResource({ type: "aws:s3/bucketVersioning:BucketVersioning", urn: `${PARENT}$aws:s3/bucketVersioning:BucketVersioning::sb-ver`, name: "sb-ver", - props: { versioningConfiguration: { status: "Enabled" } }, + props: { bucket: "sb-bucket", versioningConfiguration: { status: "Enabled" } }, }), makePolicyResource({ type: "aws:s3/bucketPolicy:BucketPolicy", urn: `${PARENT}$aws:s3/bucketPolicy:BucketPolicy::sb-pol`, name: "sb-pol", props: { + bucket: "sb-bucket", policy: JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Deny", Principal: "*", Action: "s3:*", + Resource: ["arn:aws:s3:::sb-bucket", "arn:aws:s3:::sb-bucket/*"], Condition: { Bool: { "aws:SecureTransport": "false" } }, }, ], @@ -631,3 +640,201 @@ describe("PackMetadata — shape is stable per interfaces.md §2", () => { } }); }); + +// Cluster B regression — two URN/binding bypasses against H5 that the +// previous implementation missed: +// 1. Decoy siblings parented under the same forged SecureBucket wrapper +// that satisfy shape checks but reference a DIFFERENT bucket via +// their `bucket` prop / the policy's `Resource` block. +// 2. Sibling resources whose URNs share the `bucket.urn.split("$")[0]` +// string prefix but whose actual parent component type is different +// (the old `startsWith(parentPrefix)` check is fooled by any URN +// sharing the project + outer-type-prefix segment). +describe("HulumiHardeningPack H5 — URN/binding bypasses (Cluster B regression)", () => { + let violations: string[]; + const report = (msg: string): void => { + violations.push(msg); + }; + beforeEach(() => { + violations = []; + }); + + const PARENT = "urn:pulumi:s::p::hulumi:baseline:aws:SecureBucket"; + const exemptedBucket = (): PolicyResource => + makePolicyResource({ + type: "aws:s3/bucket:Bucket", + urn: `${PARENT}$aws:s3/bucket:Bucket::sb-bucket`, + name: "sb-bucket", + props: {}, + }); + + // Decoy siblings: same parent component URN prefix and correct shape, + // but every `bucket` prop / Resource ARN points at "other-bucket". + const decoySiblings = (): PolicyResource[] => [ + makePolicyResource({ + type: "aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock", + urn: `${PARENT}$aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock::decoy-pab`, + name: "decoy-pab", + props: { + bucket: "other-bucket", + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: true, + restrictPublicBuckets: true, + }, + }), + makePolicyResource({ + type: "aws:s3/bucketServerSideEncryptionConfiguration:BucketServerSideEncryptionConfiguration", + urn: `${PARENT}$aws:s3/bucketServerSideEncryptionConfiguration:BucketServerSideEncryptionConfiguration::decoy-sse`, + name: "decoy-sse", + props: { + bucket: "other-bucket", + rules: [{ applyServerSideEncryptionByDefault: { sseAlgorithm: "aws:kms" } }], + }, + }), + makePolicyResource({ + type: "aws:s3/bucketOwnershipControls:BucketOwnershipControls", + urn: `${PARENT}$aws:s3/bucketOwnershipControls:BucketOwnershipControls::decoy-own`, + name: "decoy-own", + props: { bucket: "other-bucket", rule: { objectOwnership: "BucketOwnerEnforced" } }, + }), + makePolicyResource({ + type: "aws:s3/bucketVersioning:BucketVersioning", + urn: `${PARENT}$aws:s3/bucketVersioning:BucketVersioning::decoy-ver`, + name: "decoy-ver", + props: { bucket: "other-bucket", versioningConfiguration: { status: "Enabled" } }, + }), + makePolicyResource({ + type: "aws:s3/bucketPolicy:BucketPolicy", + urn: `${PARENT}$aws:s3/bucketPolicy:BucketPolicy::decoy-pol`, + name: "decoy-pol", + props: { + bucket: "other-bucket", + policy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Deny", + Principal: "*", + Action: "s3:*", + Resource: ["arn:aws:s3:::other-bucket", "arn:aws:s3:::other-bucket/*"], + Condition: { Bool: { "aws:SecureTransport": "false" } }, + }, + ], + }), + }, + }), + ]; + + const runH5 = (resources: PolicyResource[]): void => + ( + h5SecureBucketExemptionRequiresHardening.validateStack as ( + a: StackValidationArgs, + r: (m: string) => void, + ) => void + )(makeStackArgs(resources), report); + + it("reports HULUMI-H5 when siblings exist but target a DIFFERENT bucket (decoy-sibling bypass)", () => { + runH5([exemptedBucket(), ...decoySiblings()]); + expect(violations).toHaveLength(1); + // Every control is missing because none is bound to the exempted bucket. + expect(violations[0]).toMatch(/HULUMI-H5/); + expect(violations[0]).toMatch(/all-true BucketPublicAccessBlock/); + expect(violations[0]).toMatch(/SSE-KMS encryption/); + expect(violations[0]).toMatch(/BucketOwnerEnforced ownership controls/); + expect(violations[0]).toMatch(/enabled bucket versioning/); + expect(violations[0]).toMatch(/TLS-only bucket policy/); + }); + + it("reports HULUMI-H5 when 'siblings' are parented under an UNRELATED component that shares a URN string prefix", () => { + // Old code used `r.urn.startsWith(bucket.urn.split('$')[0])` which would + // accept a resource under any component whose URN starts with the same + // project/stack/outer-type-prefix segment. The anchored helper requires + // the parent component type chain to match. + const UNRELATED_PARENT = "urn:pulumi:s::p::hulumi:baseline:aws:SecureBucketImposter"; + const wronglyParented = makePolicyResource({ + type: "aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock", + urn: `${UNRELATED_PARENT}$aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock::wrong-pab`, + name: "wrong-pab", + props: { + bucket: "sb-bucket", + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: true, + restrictPublicBuckets: true, + }, + }); + runH5([exemptedBucket(), wronglyParented]); + // PAB is reported missing because the only PAB-shaped resource is not + // a sibling of the exempted bucket's parent component. + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/all-true BucketPublicAccessBlock/); + }); + + it("reports HULUMI-H5 when the bucket policy's Resource block names a DIFFERENT bucket (decoy policy)", () => { + // The four non-policy siblings are correctly bound; the policy alone + // names another bucket in Resource. Only the TLS-only-policy control + // should be reported as missing. + const PARENT_LOCAL = PARENT; // alias for clarity + const siblings: PolicyResource[] = [ + makePolicyResource({ + type: "aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock", + urn: `${PARENT_LOCAL}$aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock::sb-pab`, + name: "sb-pab", + props: { + bucket: "sb-bucket", + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: true, + restrictPublicBuckets: true, + }, + }), + makePolicyResource({ + type: "aws:s3/bucketServerSideEncryptionConfiguration:BucketServerSideEncryptionConfiguration", + urn: `${PARENT_LOCAL}$aws:s3/bucketServerSideEncryptionConfiguration:BucketServerSideEncryptionConfiguration::sb-sse`, + name: "sb-sse", + props: { + bucket: "sb-bucket", + rules: [{ applyServerSideEncryptionByDefault: { sseAlgorithm: "aws:kms" } }], + }, + }), + makePolicyResource({ + type: "aws:s3/bucketOwnershipControls:BucketOwnershipControls", + urn: `${PARENT_LOCAL}$aws:s3/bucketOwnershipControls:BucketOwnershipControls::sb-own`, + name: "sb-own", + props: { bucket: "sb-bucket", rule: { objectOwnership: "BucketOwnerEnforced" } }, + }), + makePolicyResource({ + type: "aws:s3/bucketVersioning:BucketVersioning", + urn: `${PARENT_LOCAL}$aws:s3/bucketVersioning:BucketVersioning::sb-ver`, + name: "sb-ver", + props: { bucket: "sb-bucket", versioningConfiguration: { status: "Enabled" } }, + }), + makePolicyResource({ + type: "aws:s3/bucketPolicy:BucketPolicy", + urn: `${PARENT_LOCAL}$aws:s3/bucketPolicy:BucketPolicy::sb-pol`, + name: "sb-pol", + props: { + bucket: "sb-bucket", + policy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Deny", + Principal: "*", + Action: "s3:*", + // Decoy: Resource names a different bucket. + Resource: ["arn:aws:s3:::other-bucket", "arn:aws:s3:::other-bucket/*"], + Condition: { Bool: { "aws:SecureTransport": "false" } }, + }, + ], + }), + }, + }), + ]; + runH5([exemptedBucket(), ...siblings]); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/TLS-only bucket policy/); + expect(violations[0]).not.toMatch(/all-true BucketPublicAccessBlock/); + }); +});
packages/policies/tests/k8s/eks-cluster-pack.test.ts+45 −0 modified@@ -90,6 +90,51 @@ describe("Scenario: EKS public endpoint broad CIDR rejected (HULUMI-EKS-CL-1)", expect(violations).toHaveLength(0); }); + it("rejects public endpoint with split-range CIDRs covering all of IPv4", () => { + const args = makeArgs({ + type: "aws:eks/cluster:Cluster", + urn: "urn:p::p::aws:eks/cluster:Cluster::split-range", + name: "split-range", + props: { + vpcConfig: { + endpointPublicAccess: true, + publicAccessCidrs: ["0.0.0.0/1", "128.0.0.0/1"], + }, + enabledClusterLogTypes: ["audit"], + }, + }); + ( + eksCl1NoBroadPublicEndpoint.validateResource as ( + a: ResourceValidationArgs, + r: (m: string) => void, + ) => void + )(args, report); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatch(/HULUMI-EKS-CL-1/); + }); + + it("rejects public endpoint with malformed publicAccessCidrs", () => { + const args = makeArgs({ + type: "aws:eks/cluster:Cluster", + urn: "urn:p::p::aws:eks/cluster:Cluster::malformed", + name: "malformed", + props: { + vpcConfig: { + endpointPublicAccess: true, + publicAccessCidrs: ["not-a-cidr"], + }, + enabledClusterLogTypes: ["audit"], + }, + }); + ( + eksCl1NoBroadPublicEndpoint.validateResource as ( + a: ResourceValidationArgs, + r: (m: string) => void, + ) => void + )(args, report); + expect(violations).toHaveLength(1); + }); + it("allows private-only endpoint regardless of CIDRs", () => { const args = makeArgs({ type: "aws:eks/cluster:Cluster",
packages/policies/tests/platform/deployment-governance-pack.test.ts+41 −0 modified@@ -315,3 +315,44 @@ describe("HulumiDeploymentGovernancePack DEPLOY_GOV_2 — long-lived AWS secret expect(violations).toEqual([]); }); }); + +// Cluster B regression — URN substring spoof (`isChildOf` used `urn.includes` +// over the FULL URN, so a raw resource whose operator-controlled logical +// name contained the parent type token bypassed every DEPLOY_GOV_1 check). +describe("HulumiDeploymentGovernancePack DEPLOY_GOV_1 — forged-logical-name URN spoof", () => { + let violations: string[]; + const report = (m: string): void => { + violations.push(m); + }; + + beforeEach(() => { + violations = []; + }); + + it("reports DEPLOY_GOV_1 even when the repo's LOGICAL NAME embeds the foundation type", () => { + // Exploit: declare a raw github.Repository with logical name + // `<DeploymentRepositoryFoundation type>$<anything>`. The URL now contains + // the substring `hulumi:platform:DeploymentRepositoryFoundation$` but the + // resource is NOT a child of any DeploymentRepositoryFoundation — its + // type chain is just `github:index/repository:Repository`. A safe + // anchored check parses the URN type chain and refuses this spoof. + const spoofedRepo = makePolicyResource({ + type: GITHUB_REPOSITORY_TYPE, + // Logical name carries the parent type substring; type chain does not. + urn: `urn:pulumi:s::p::${GITHUB_REPOSITORY_TYPE}::${DEPLOYMENT_REPOSITORY_FOUNDATION_TYPE}$deploy-repo`, + name: `${DEPLOYMENT_REPOSITORY_FOUNDATION_TYPE}$deploy-repo`, + props: { name: "victim-repo", topics: ["deployment"] }, + }); + + ( + deployGov1RequireProtectedEnvironment.validateStack as ( + a: StackValidationArgs, + r: (m: string) => void, + ) => void + )(makeStackArgs([spoofedRepo]), report); + + // Without the anchored fix, this assertion is `.toEqual([])` — the spoof bypasses. + expect(violations).toHaveLength(1); + expect(violations[0]).toContain(DEPLOY_GOV_1_RULE_ID); + }); +});
packages/policies/tests/urn.test.ts+137 −0 added@@ -0,0 +1,137 @@ +// Anchored URN parsing — regression tests for the cluster of "policy +// bypassed by an attacker-controlled logical resource name" findings. +// The unsafe pattern that was scattered across multiple packs: +// +// urn.includes(`${componentType}$`) +// +// returns true whenever the substring appears ANYWHERE in the URN — +// including in the logical-name suffix that is operator-controlled. An +// attacker can declare a raw resource named e.g. +// `hulumi:platform:DeploymentRepositoryFoundation$x` and the substring +// check fires even though the resource is not actually parented under +// any DeploymentRepositoryFoundation component. +// +// The anchored helpers parse the URN into its type-chain (the part +// between project and logical-name segments) and match only against +// type-chain ancestors, never the logical name. + +import { describe, it, expect } from "vitest"; + +import { parseUrn, isUrnChildOfComponent, urnsShareParentComponent } from "../src/urn"; + +// The four URNs we care about across this whole cluster: +// +// GENUINE_CHILD = a real child resource of a real parent component +// FORGED_LOGICAL_NAME = a top-level resource whose LOGICAL NAME contains +// the parent type token (attacker's spoof) +// GENUINE_TOP_LEVEL = a top-level resource of the parent type itself +// (not a child of itself) +// SIBLING_DIFF_PARENT = a resource under a DIFFERENT component instance +// +// Tests assert the anchored helper accepts only the first and rejects +// the rest — none of the substring-based checks did. + +const STACK = "dev"; +const PROJECT = "myproject"; + +function makeUrn(typeChain: string, logicalName: string): string { + return `urn:pulumi:${STACK}::${PROJECT}::${typeChain}::${logicalName}`; +} + +describe("parseUrn", () => { + it("splits stack/project/typeChain/logicalName from a single-type URN", () => { + const parsed = parseUrn(makeUrn("aws:s3/bucketV2:BucketV2", "scratch")); + expect(parsed).toBeDefined(); + expect(parsed!.stack).toBe(STACK); + expect(parsed!.project).toBe(PROJECT); + expect(parsed!.typeChain).toEqual(["aws:s3/bucketV2:BucketV2"]); + expect(parsed!.logicalName).toBe("scratch"); + }); + + it("splits a `$`-joined parent-child type chain", () => { + const urn = makeUrn( + "hulumi:baseline:aws:SecureBucket$aws:s3/bucketV2:BucketV2", + "scratch-bucket", + ); + const parsed = parseUrn(urn); + expect(parsed!.typeChain).toEqual([ + "hulumi:baseline:aws:SecureBucket", + "aws:s3/bucketV2:BucketV2", + ]); + expect(parsed!.logicalName).toBe("scratch-bucket"); + }); + + it("returns undefined for malformed URNs (fail closed)", () => { + expect(parseUrn("")).toBeUndefined(); + expect(parseUrn("not-a-urn")).toBeUndefined(); + expect(parseUrn("urn:pulumi:::missing::::")).toBeUndefined(); + expect(parseUrn("urn:pulumi:dev::project")).toBeUndefined(); + }); +}); + +describe("isUrnChildOfComponent — anchored against forged-logical-name spoof", () => { + const PARENT_TYPE = "hulumi:platform:DeploymentRepositoryFoundation"; + + it("returns true for a genuine child (parent type in non-leaf chain entry)", () => { + const urn = makeUrn(`${PARENT_TYPE}$github:index/repository:Repository`, "my-repo"); + expect(isUrnChildOfComponent(urn, PARENT_TYPE)).toBe(true); + }); + + it("returns false when the parent type appears ONLY in the logical name (spoof)", () => { + // Exploit vector from the DEPLOY_GOV_1 finding: raw `github.Repository` + // declared with logical name containing the substring `<parentType>$`. + // URN: ...::github:index/repository:Repository::hulumi:platform:DeploymentRepositoryFoundation$deploy-repo + const urn = makeUrn("github:index/repository:Repository", `${PARENT_TYPE}$deploy-repo`); + expect(isUrnChildOfComponent(urn, PARENT_TYPE)).toBe(false); + }); + + it("returns false for a top-level resource OF the parent type itself", () => { + // The component instance is not its own child. + const urn = makeUrn(PARENT_TYPE, "foundation-1"); + expect(isUrnChildOfComponent(urn, PARENT_TYPE)).toBe(false); + }); + + it("returns false for a resource under a different parent component", () => { + const urn = makeUrn( + "hulumi:platform:DeploymentRepositoryFoundation$github:index/repository:Repository", + "my-repo", + ); + expect(isUrnChildOfComponent(urn, "hulumi:cloudflare:PublicHostname")).toBe(false); + }); + + it("returns false for empty / nonsense component type", () => { + const urn = makeUrn(`${PARENT_TYPE}$github:index/repository:Repository`, "my-repo"); + expect(isUrnChildOfComponent(urn, "")).toBe(false); + expect(isUrnChildOfComponent(urn, "DeploymentRepositoryFoundation")).toBe(false); // partial type + }); +}); + +describe("urnsShareParentComponent — sibling matching anchored to parent component", () => { + const PARENT = "hulumi:baseline:aws:SecureBucket"; + const BUCKET = "aws:s3/bucketV2:BucketV2"; + const POLICY = "aws:s3/bucketPolicy:BucketPolicy"; + + it("returns true for two children of the same parent component type chain", () => { + const a = makeUrn(`${PARENT}$${BUCKET}`, "scratch-bucket"); + const b = makeUrn(`${PARENT}$${POLICY}`, "scratch-policy"); + expect(urnsShareParentComponent(a, b)).toBe(true); + }); + + it("returns false when one resource is top-level (no parent component)", () => { + const a = makeUrn(`${PARENT}$${BUCKET}`, "scratch-bucket"); + const b = makeUrn(POLICY, "raw-policy"); + expect(urnsShareParentComponent(a, b)).toBe(false); + }); + + it("returns false when parents are different component types", () => { + const a = makeUrn(`${PARENT}$${BUCKET}`, "scratch-bucket"); + const b = makeUrn(`hulumi:platform:DeploymentRepositoryFoundation$${POLICY}`, "evil-policy"); + expect(urnsShareParentComponent(a, b)).toBe(false); + }); + + it("returns false across different stacks or projects", () => { + const a = makeUrn(`${PARENT}$${BUCKET}`, "scratch-bucket"); + const b = `urn:pulumi:other-stack::${PROJECT}::${PARENT}$${POLICY}::scratch-policy`; + expect(urnsShareParentComponent(a, b)).toBe(false); + }); +});
scripts/workflow-governance-lint.mjs+157 −0 modified@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process"; export const WF_SHA_1_RULE_ID = "WF_SHA_1_FULL_LENGTH_SHA_PIN"; export const WF_PERM_1_RULE_ID = "WF_PERM_1_MINIMUM_GITHUB_TOKEN_PERMISSIONS"; export const WF_CODEOWNERS_1_RULE_ID = "WF_CODEOWNERS_1_WORKFLOWS_PROTECTED"; +export const WF_ENV_1_RULE_ID = "WF_ENV_1_DISPATCH_PRIVILEGED_JOB_REQUIRES_ENVIRONMENT"; const SHA_REF = /^[0-9a-f]{40}$/i; const USES_LINE = /^\s*-?\s*uses:\s*["']?([^"'\s#]+)["']?/; @@ -142,6 +143,161 @@ function lintWorkflowPermissions(file, lines) { return diagnostics; } +// WF_ENV_1 helpers. +// +// `workflow_dispatch` is operator-triggered with the actor's permissions — +// any contributor with repo write can fire it. Jobs in such workflows that +// assume an AWS role via OIDC or run `pulumi destroy` are privileged and +// destructive; they MUST be gated behind a protected GitHub Environment +// (with required reviewers) so a single repo-write account cannot drive +// a real-cloud teardown. Scheduled triggers on the same workflow do not +// loosen the rule — the requirement is on jobs reachable via dispatch. +const AWS_CREDENTIALS_USES = /aws-actions\/configure-aws-credentials/; +const ROLE_TO_ASSUME = /^\s*role-to-assume\s*:/; +const PULUMI_DESTROY = /\bpulumi\s+destroy\b/; + +function workflowHasDispatchTrigger(lines) { + // Detect `on:` blocks that include `workflow_dispatch:` (either as a + // bare key, a flow scalar, or a flow-style sequence/string). Handles: + // on: workflow_dispatch + // on: [push, workflow_dispatch] + // on: + // workflow_dispatch: + // schedule: + // - cron: ... + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx]; + const inline = /^on:\s*(.*)$/.exec(line); + if (!inline || /^\s/.test(line)) continue; + const rest = inline[1].trim(); + if (rest.length > 0 && !rest.startsWith("#")) { + // Flow-style `on:` value on the same line. + if (/\bworkflow_dispatch\b/.test(rest)) return true; + continue; + } + // Block-style `on:` — walk indented children. + for (let j = idx + 1; j < lines.length; j += 1) { + const child = lines[j]; + if (child.trim() === "") continue; + if (!/^\s/.test(child)) break; + if (/^\s+workflow_dispatch\s*:/.test(child) || /^\s+workflow_dispatch\s*$/.test(child)) { + return true; + } + } + } + return false; +} + +function extractJobs(lines) { + // Identify the top-level `jobs:` block, then split each direct child + // (one indent in from `jobs:`) into its own slice. Returns + // { id, startLine, endLine, lines }[]. + const jobs = []; + let jobsStart = -1; + let jobsIndent = -1; + for (let idx = 0; idx < lines.length; idx += 1) { + if (/^jobs:\s*$/.test(lines[idx]) && !/^\s/.test(lines[idx])) { + jobsStart = idx; + break; + } + } + if (jobsStart === -1) return jobs; + // Find the indent of the first job id (first non-blank, indented line + // after `jobs:`). Treat that as the per-job header indent. + for (let idx = jobsStart + 1; idx < lines.length; idx += 1) { + if (lines[idx].trim() === "") continue; + const match = /^(\s+)([A-Za-z0-9_-]+)\s*:\s*$/.exec(lines[idx]); + if (match) { + jobsIndent = match[1].length; + } + break; + } + if (jobsIndent < 0) return jobs; + + let current; + for (let idx = jobsStart + 1; idx < lines.length; idx += 1) { + const line = lines[idx]; + const trimmed = line.trim(); + // A line at column 0 ends the jobs block. + if (trimmed !== "" && !/^\s/.test(line)) break; + const header = new RegExp(`^\\s{${jobsIndent}}([A-Za-z0-9_-]+)\\s*:\\s*$`).exec(line); + if (header) { + if (current) { + current.endLine = idx - 1; + jobs.push(current); + } + current = { id: header[1], startLine: idx, endLine: lines.length - 1, lines: [] }; + continue; + } + if (current) current.lines.push({ lineNumber: idx + 1, text: line }); + } + if (current) jobs.push(current); + return jobs; +} + +function jobDeclaresEnvironment(job, jobsIndent) { + // `environment:` must be a direct job-level property (one indent + // beyond the job header). A nested `environment:` under `with:` or a + // step does NOT satisfy the rule. + const expected = " ".repeat(jobsIndent + 2); + for (const { text } of job.lines) { + if (text.startsWith(`${expected}environment:`)) return true; + } + return false; +} + +function jobIsPrivileged(job) { + // Returns a short reason if the job assumes an AWS role or runs + // `pulumi destroy`, otherwise undefined. + for (const { text } of job.lines) { + if (AWS_CREDENTIALS_USES.test(text)) return "uses aws-actions/configure-aws-credentials"; + if (ROLE_TO_ASSUME.test(text)) return "declares role-to-assume"; + if (PULUMI_DESTROY.test(text)) return "runs pulumi destroy"; + } + return undefined; +} + +function detectJobsIndent(lines) { + // Mirrors the indent detection in extractJobs so the environment + // check can know the per-job indent. Returns -1 if not found. + let jobsStart = -1; + for (let idx = 0; idx < lines.length; idx += 1) { + if (/^jobs:\s*$/.test(lines[idx]) && !/^\s/.test(lines[idx])) { + jobsStart = idx; + break; + } + } + if (jobsStart === -1) return -1; + for (let idx = jobsStart + 1; idx < lines.length; idx += 1) { + if (lines[idx].trim() === "") continue; + const match = /^(\s+)([A-Za-z0-9_-]+)\s*:\s*$/.exec(lines[idx]); + if (match) return match[1].length; + break; + } + return -1; +} + +function lintWorkflowDispatchEnvironments(file, lines) { + const diagnostics = []; + if (!workflowHasDispatchTrigger(lines)) return diagnostics; + const jobsIndent = detectJobsIndent(lines); + if (jobsIndent < 0) return diagnostics; + for (const job of extractJobs(lines)) { + const reason = jobIsPrivileged(job); + if (!reason) continue; + if (jobDeclaresEnvironment(job, jobsIndent)) continue; + diagnostics.push( + makeDiagnostic( + WF_ENV_1_RULE_ID, + file, + job.startLine + 1, + `workflow_dispatch job '${job.id}' is privileged (${reason}) and must declare a protected 'environment:' with required reviewers`, + ), + ); + } + return diagnostics; +} + function lintWorkflowFile(repoRoot, file) { const full = join(repoRoot, file); const lines = readFileSync(full, "utf8").split(/\r?\n/); @@ -151,6 +307,7 @@ function lintWorkflowFile(repoRoot, file) { if (diagnostic) diagnostics.push(diagnostic); }); diagnostics.push(...lintWorkflowPermissions(file, lines)); + diagnostics.push(...lintWorkflowDispatchEnvironments(file, lines)); return diagnostics; }
tests/skill-bdd/workflow-governance-lint.test.ts+127 −0 modified@@ -84,4 +84,131 @@ describe("workflow-governance linter", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("workflow-governance: pass"); }); + + it("flags a workflow_dispatch job that assumes an AWS role without a protected environment (WF_ENV_1)", () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture, ".github", "workflows", "cleanup.yml"), + [ + "name: cleanup", + "on:", + " workflow_dispatch:", + "permissions:", + " id-token: write", + " contents: read", + "jobs:", + " cleanup:", + " runs-on: ubuntu-latest", + " steps:", + ` - uses: actions/checkout@${SHA} # v6`, + ` - uses: aws-actions/configure-aws-credentials@${SHA} # v6`, + " with:", + " role-to-assume: arn:aws:iam::123456789012:role/cleanup", + " aws-region: us-east-1", + "", + ].join("\n"), + ); + writeFileSync(join(fixture, "CODEOWNERS"), "/.github/workflows/ @security-team\n"); + + const result = runLinter(fixture); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.status).toBe(1); + expect(output).toContain("WF_ENV_1_DISPATCH_PRIVILEGED_JOB_REQUIRES_ENVIRONMENT"); + expect(output).toContain("cleanup.yml"); + expect(output).toContain("'cleanup'"); + }); + + it("flags a workflow_dispatch job that runs `pulumi destroy` without a protected environment (WF_ENV_1)", () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture, ".github", "workflows", "teardown.yml"), + [ + "name: teardown", + "on:", + " workflow_dispatch:", + "permissions:", + " id-token: write", + " contents: read", + "jobs:", + " teardown:", + " runs-on: ubuntu-latest", + " steps:", + ` - uses: actions/checkout@${SHA} # v6`, + " - run: pulumi destroy --yes", + "", + ].join("\n"), + ); + writeFileSync(join(fixture, "CODEOWNERS"), "/.github/workflows/ @security-team\n"); + + const result = runLinter(fixture); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.status).toBe(1); + expect(output).toContain("WF_ENV_1_DISPATCH_PRIVILEGED_JOB_REQUIRES_ENVIRONMENT"); + }); + + it("passes when a workflow_dispatch privileged job declares a protected environment (WF_ENV_1)", () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture, ".github", "workflows", "cleanup.yml"), + [ + "name: cleanup", + "on:", + " workflow_dispatch:", + "permissions:", + " id-token: write", + " contents: read", + "jobs:", + " cleanup:", + " runs-on: ubuntu-latest", + " environment: aws-cleanup", + " steps:", + ` - uses: actions/checkout@${SHA} # v6`, + ` - uses: aws-actions/configure-aws-credentials@${SHA} # v6`, + " with:", + " role-to-assume: arn:aws:iam::123456789012:role/cleanup", + " aws-region: us-east-1", + "", + ].join("\n"), + ); + writeFileSync(join(fixture, "CODEOWNERS"), "/.github/workflows/ @security-team\n"); + + const result = runLinter(fixture); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("workflow-governance: pass"); + }); + + it("does not flag workflows without workflow_dispatch even if they assume AWS roles (WF_ENV_1)", () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture, ".github", "workflows", "scheduled.yml"), + [ + "name: scheduled", + "on:", + " schedule:", + ' - cron: "0 4 * * 0"', + "permissions:", + " id-token: write", + " contents: read", + "jobs:", + " job:", + " runs-on: ubuntu-latest", + " steps:", + ` - uses: actions/checkout@${SHA} # v6`, + ` - uses: aws-actions/configure-aws-credentials@${SHA} # v6`, + " with:", + " role-to-assume: arn:aws:iam::123456789012:role/scheduled", + " aws-region: us-east-1", + "", + ].join("\n"), + ); + writeFileSync(join(fixture, "CODEOWNERS"), "/.github/workflows/ @security-team\n"); + + const result = runLinter(fixture); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("workflow-governance: pass"); + }); });
Vulnerability mechanics
Root cause
"The `AccountFoundation` component failed to enforce immutability for audit logs in S3, allowing deletion even when tamper-resistance was expected."
Attack vector
An attacker with S3 delete access could erase CloudTrail and AWS Config audit logs. This was possible because the startup-hardened tier incorrectly set `objectLock: false`, downstream stacks could enable `logBucketForceDestroy: true`, and the sandbox tier entirely skipped Object Lock and other immutability features. This left audit logs vulnerable to deletion, undermining forensic integrity.
Affected code
The vulnerability exists within the `AccountFoundation` component, specifically concerning the configuration of the S3 bucket used for audit logs. The issues stem from hardcoded `objectLock: false` in the startup-hardened tier, the forwarding of `forceDestroy` to the audit bucket, and the sandbox tier's configuration which skipped Object Lock and other security features.
What the fix does
The patch introduces an invariant in `SecureBucket` that triggers when the bucket is used for CloudTrail/Config delivery. It now refuses `forceDestroy: true` on the startup-hardened tier, ensures the CloudTrail-Lake `EventDataStore` is emitted for all tiers, and adds a bucket policy to deny `s3:DeleteObject*` actions for audit log prefixes. This policy specifically excludes the AWS Config write-then-delete probe key, maintaining necessary functionality while preventing unauthorized deletion of audit logs [patch_id=5478249].
Preconditions
- configThe `AccountFoundation` component is deployed.
- authThe attacker possesses S3 delete access or can influence stack configurations to enable `logBucketForceDestroy: true`.
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.