VYPR
High severity7.1NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

@hulumi/baseline: AccountFoundation audit-delivery S3 bucket could be silently weakened

CVE-2026-48035

Description

Affected: @hulumi/baseline < 1.4.0Fixed in: 1.4.0Severity: 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:

  1. No Write-Once-Read-Many on the startup-hardened audit bucket. The startup-hardened tier hard-coded objectLock: false on 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.)
  2. **forceDestroy was forwarded to the audit bucket.** Nothing prevented a downstream stack from setting logBucketForceDestroy: true, which made pulumi destroy purge every audit-log object on teardown.
  3. Sandbox tier dropped everything. Sandbox-tier AccountFoundation created its audit bucket with tier: "sandbox", which skipped Object Lock, server access logging, AND the CloudTrail-Lake EventDataStore (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: true on the startup-hardened tier;
  • emits the CloudTrail-Lake EventDataStore regardless 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 Config ConfigWritabilityCheckFile probe 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 ### Migration for the forceDestroy behaviour change.

Affected products

1

Patches

1
070da5d31424

security: fix 4 HIGH + 15 MEDIUM Codex findings (8 root-cause clusters) — DCO-signed replacement for #177 (#178)

https://github.com/kerberosmansour/hulumiKerberosmansourMay 19, 2026via body-scan-shorthand
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

3

News mentions

0

No linked articles in our index yet.