VYPR
High severity8.3NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

@hulumi/policies bypasses IAM-role policy checks when the role trusts multiple OIDC providers

CVE-2026-48032

Description

Affected: @hulumi/policies < 1.4.0Fixed in: 1.4.0Severity: High — CWE-697 (Incorrect Comparison)

Summary

AWS IAM trust policies can list more than one federated identity provider — for example, a role that accepts BOTH GitHub Actions OIDC and Google's OIDC. The G_OIDC_1 and G_OIDC_2 policy rules are supposed to flag IAM roles whose GitHub-OIDC trust is too permissive (e.g. wildcard sub: conditions that would let any branch or any pull request assume the role).

The bug: when the role's Principal.Federated field was a JSON array of multiple providers, the rules failed to recognise that GitHub Actions was one of them. The providers list was coerced into a single comma-joined string, the matcher only looked at the start, and the GitHub OIDC hostname was lost in the join. Both rules concluded "this isn't a GitHub-OIDC role" and skipped the wildcard check.

Impact

A trust policy that listed the real GitHub OIDC provider ARN alongside any second provider would slip past both detectors. Consumers using HulumiHardeningPack or HulumiGithubHardeningPack could ship an IAM role with wildcard sub: conditions (allowing untrusted PRs from forks to assume the role) while their policy validation reported the stack as compliant. The G_OIDC_2 detector also failed to mark such roles for the cluster-admin / AdministratorAccess blast-radius check.

Patches

Upgrade to @hulumi/policies@1.4.0. The shared GitHub-OIDC-provider matcher now correctly walks lists of providers — if any element of the list is the real GitHub OIDC ARN, the role is treated as GitHub-OIDC-assumable and the wildcard / blast-radius checks apply.

Workarounds

None reliable — upgrade is the fix.

Resources
  • PR #178 (Cluster A); regression tests at packages/policies/tests/github/{g-oidc-2,github-oidc-issuer}.test.ts.

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 policy rules incorrectly processed a JSON array of federated identity providers as a single string, causing the GitHub OIDC provider to be missed."

Attack vector

An attacker could create an IAM role with a trust policy that lists the GitHub OIDC provider ARN alongside another provider. The policy rules, failing to correctly identify the GitHub OIDC provider due to string coercion, would skip checks for overly permissive conditions like wildcard `sub:` statements. This would allow untrusted pull requests from forks to assume the role, bypassing intended security controls [ref_id=1].

Affected code

The vulnerability exists in the policy rules that check IAM roles for GitHub OIDC trust. Specifically, the logic failed when the `Principal.Federated` field was a JSON array containing multiple providers, leading to incorrect identification of GitHub OIDC roles [ref_id=1].

What the fix does

The patch modifies the GitHub-OIDC-provider matcher to correctly iterate through lists of providers. If any provider in the list matches the real GitHub OIDC ARN, the role is now recognized as GitHub-OIDC-assumable. This ensures that subsequent wildcard and blast-radius checks are applied, closing the vulnerability [patch_id=5478257].

Preconditions

  • configThe target system must be using `@hulumi/policies` version less than 1.4.0.
  • inputAn IAM role must be configured with a trust policy listing the GitHub OIDC provider ARN alongside at least one other provider.

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.