Strays

What Strays installs

Strays needs a role in your AWS account, so you should be able to read every line of it first, check it hasn't changed, and remove it in one click whenever you like. Nothing here is a summary: these are the files themselves.

Removing Strays is deleting one CloudFormation stack (strays-audit, or strays-audit-organization for a whole organisation). Strays can't delete the role. You remove it by deleting the stack, and access stops at once. Signed-in customers get the console link and commands for each account under Remove Strays from AWS. More on the security page.

What Strays cannot do

The role is read-only. With it, Strays cannot:

This isn't only a promise: our tests fail if any action in the template creates, changes or deletes something, or reads the contents of your data. The one action that isn't a pure read is iam:GenerateCredentialReport, which asks IAM to prepare the credential report it then reads. One optional extra is described under the permissions: it exists only if you choose to give Strays your Terraform state bucket.

One thing to know: for three of its listing calls, AWS sends back more than Strays uses: Lambda environment variables (lambda:ListFunctions) and instance user data (ec2:DescribeLaunchTemplateVersions, autoscaling:DescribeLaunchConfigurations). Strays reads only names, runtimes and image IDs from them and never keeps the rest; our tests scan an account that has secrets in both places and check they are stored nowhere. Those three actions are what the outdated-runtime, public-Lambda-URL and unused-image checks need; you can remove them from the template, and those checks then can't run. Keep secrets in Secrets Manager or Parameter Store rather than in environment variables or user data in any case.

Who can use the role

The role's trust policy lets exactly two things assume it: Strays' scanner role (strays-prod-worker) and Strays' web role (strays-prod-web, which checks the connection), and only when the request carries your external ID, a random value unique to your organisation. Anyone else, including another Strays customer, is refused. This is the protection AWS recommends against the "confused deputy" problem: even if someone learned your role's name, they could not use it without the external ID. Naming the two roles, not the whole account, means no other identity in Strays' AWS account (another function, a user or a future service) can assume it; the hourly scheduler, for one, has no way to. The role names are parameters of the template, with those defaults. The trust policy in the template is:

AssumeRolePolicyDocument:
  Version: "2012-10-17"
  Statement:
    - Effect: Allow
      Principal:
        AWS:
        - arn:aws:iam::<Strays' account ID>:role/strays-prod-worker
        - arn:aws:iam::<Strays' account ID>:role/strays-prod-web
      Action: sts:AssumeRole
      Condition:
        StringEquals:
          sts:ExternalId: <your external ID>

Every time Strays uses the role, AWS records an AssumeRole event in your CloudTrail, so you can see when it was used.

Every permission, and why

These 89 actions are read from the role template below each time this page is built, so the list can't drift from the role. All of them describe or list things, or read settings and metrics.

ActionWhy Strays needs it
acm (2)
acm:DescribeCertificateReads a certificate's expiry date and use.
acm:ListCertificatesLists TLS certificates, to warn before they expire.
autoscaling (1)
autoscaling:DescribeLaunchConfigurationsFinds old launch configurations that nothing uses. AWS includes the user data in this response; Strays reads only the image ID and never stores the rest.
backup (3)
backup:ListBackupPlansLists backup plans (settings only).
backup:ListBackupVaultsLists backup vaults, to check important data is backed up.
backup:ListTagsReads backup tags, to find the owner.
budgets (1)
budgets:ViewBudgetReads AWS Budgets you already have, to compare with spend.
ce (3)
ce:GetCostAndUsageReads your spend totals for budgets and cost by tag.
ce:GetSavingsPlansPurchaseRecommendationReads AWS's own savings suggestions, so Strays can show you them.
ce:ListCostAllocationTagsChecks which cost tags are active, for cost by tag.
cloudtrail (3)
cloudtrail:DescribeTrailsChecks that CloudTrail logging is switched on.
cloudtrail:GetTrailStatusChecks CloudTrail is actually logging.
cloudtrail:LookupEventsFinds who created a resource, from CloudTrail's event history.
cloudwatch (2)
cloudwatch:DescribeAlarmsChecks that important resources have alarms.
cloudwatch:GetMetricDataReads usage numbers (CPU, connections, traffic) to tell idle from busy.
ec2 (14)
ec2:DescribeAddressesFinds Elastic IP addresses that aren't attached (they cost money).
ec2:DescribeImagesFinds machine images (AMIs) nobody uses, and ones shared publicly.
ec2:DescribeInstancesFinds stopped, idle or unowned servers and who launched them.
ec2:DescribeLaunchTemplateVersionsChecks which launch template versions are still referenced. AWS includes each version's user data in this response; Strays reads only the image ID and never stores the rest.
ec2:DescribeLaunchTemplatesChecks which launch templates are still referenced.
ec2:DescribeNatGatewaysFinds NAT gateways, which cost money every hour, and checks they are used.
ec2:DescribeNetworkInterfacesFinds network interfaces that are unused or exposed.
ec2:DescribeRegionsLists the regions your account uses, so every region is scanned.
ec2:DescribeSecurityGroupsFinds firewall rules open to the whole internet.
ec2:DescribeSnapshotAttributeChecks whether a disk snapshot is shared publicly.
ec2:DescribeSnapshotsFinds old and orphaned disk snapshots, and their size.
ec2:DescribeVolumesFinds disks (EBS volumes) not attached to anything, a common source of waste.
ec2:DescribeVpcEndpointsFinds VPC endpoints, which cost money every hour.
ec2:GetEbsEncryptionByDefaultChecks that new disks are encrypted by default.
ecr (4)
ecr:DescribeImagesFinds old, large container images.
ecr:DescribeRepositoriesLists container repositories, for the asset register.
ecr:GetLifecyclePolicyChecks a repository cleans up old images.
ecr:ListTagsForResourceReads repository tags, to find the owner.
eks (2)
eks:DescribeClusterChecks a cluster's settings, such as a public endpoint.
eks:ListClustersFinds Kubernetes clusters, which cost money every hour.
elasticloadbalancing (5)
elasticloadbalancing:DescribeListenersChecks load balancers for unencrypted (HTTP) listeners.
elasticloadbalancing:DescribeLoadBalancersFinds load balancers, which cost money, and whether they are public.
elasticloadbalancing:DescribeTagsReads load balancer tags, to find the owner.
elasticloadbalancing:DescribeTargetGroupsFinds load balancers with nothing behind them.
elasticloadbalancing:DescribeTargetHealthChecks whether anything healthy is behind a load balancer.
guardduty (2)
guardduty:GetDetectorChecks the GuardDuty detector is enabled.
guardduty:ListDetectorsChecks that GuardDuty threat detection is on.
iam (21)
iam:GenerateCredentialReportAsks IAM to prepare its credential report. The only action that isn't a pure read, and it changes nothing in your account.
iam:GetAccessKeyLastUsedShows when an access key was last used, to find stale keys.
iam:GetAccountPasswordPolicyChecks the password policy is strong enough.
iam:GetAccountSummaryChecks account-level security facts, such as root user MFA.
iam:GetCredentialReportReads IAM's credential report: users, MFA status and key dates, never passwords or keys.
iam:GetLoginProfileShows whether a user has a console password (never the password).
iam:GetRoleReads a role's trust policy and last use.
iam:GetUserReads a user's details and tags, for leaver checks.
iam:ListAccessKeysLists access key IDs and ages (never the secret keys).
iam:ListAttachedGroupPoliciesShows which policies a group has.
iam:ListAttachedRolePoliciesShows which policies a role has, to spot admin access.
iam:ListAttachedUserPoliciesShows which policies a user has, to spot admin access.
iam:ListGroupsForUserShows a user's groups, to spot inherited admin access.
iam:ListMFADevicesShows whether a user has MFA.
iam:ListPoliciesLists policies in the account, for the asset register.
iam:ListPolicyTagsReads policy tags, to find the owner.
iam:ListRoleTagsReads role tags, to find the owner.
iam:ListRolesLists roles, to find ones that are unused or too trusting.
iam:ListSSHPublicKeysLists SSH public keys a user has uploaded (public keys only).
iam:ListUserTagsReads user tags, to find the owner.
iam:ListUsersLists IAM users, for access reviews and leaver checks.
kms (3)
kms:DescribeKeyReads a key's settings, such as rotation and whether it is scheduled for deletion.
kms:ListKeysLists encryption keys (it never uses them), for the asset register.
kms:ListResourceTagsReads key tags, to find the owner.
lambda (3)
lambda:ListFunctionUrlConfigsFinds functions exposed on a public URL without authentication.
lambda:ListFunctionsLists functions and their settings. AWS includes environment variables in this response; Strays ignores them and never stores them.
lambda:ListTagsReads function tags, to find the owner.
logs (2)
logs:DescribeLogGroupsFinds log groups that keep data forever (never the log messages).
logs:ListTagsForResourceReads log group tags, to find the owner.
rds (7)
rds:DescribeDBClusterSnapshotAttributesChecks whether a cluster snapshot is shared publicly.
rds:DescribeDBClusterSnapshotsFinds old database cluster snapshots.
rds:DescribeDBClustersFinds database clusters and their encryption and backup settings.
rds:DescribeDBInstanceAutomatedBackupsFinds automated backups left behind by deleted databases.
rds:DescribeDBInstancesFinds idle or unencrypted databases and public access settings.
rds:DescribeDBSnapshotAttributesChecks whether a database snapshot is shared publicly.
rds:DescribeDBSnapshotsFinds old database snapshots (it lists them, never their contents).
route53domains (1)
route53domains:ListDomainsLists registered domains, for the asset register and expiry warnings.
s3 (8)
s3:GetAccountPublicAccessBlockChecks the account-wide block on public buckets.
s3:GetBucketAclChecks whether a bucket's access list makes it public.
s3:GetBucketLocationFinds which region a bucket is in.
s3:GetBucketPolicyStatusChecks whether a bucket policy makes it public.
s3:GetBucketTaggingReads bucket tags, to find the owner.
s3:GetLifecycleConfigurationChecks whether a bucket cleans up old data (a setting, not the data).
s3:ListAllMyBucketsLists bucket names, for the asset register and public-bucket checks.
s3:ListBucketMultipartUploadsFinds abandoned partial uploads that still cost money.
secretsmanager (1)
secretsmanager:ListSecretsLists secret names and dates, to find unused ones. It cannot return a secret's value.
sts (1)
sts:GetCallerIdentityConfirms which account the role belongs to, so scans never run against the wrong one.

Extra, in the management account only (AWS Organizations)

If you connect a whole organisation, the stack adds these to the role in the management account so Strays can list your accounts:

Optional: your Terraform state bucket

Off unless you type a bucket name when you install the role. Then, and only then, the role can read that one bucket (and decrypt with the one key you name), so Strays can tell resources made in code from ones made by hand. State files can contain sensitive values, so leave this empty if you'd rather not.

The templates, word for word

Exactly what is installed in your account, shown as served and escaped, so nothing is hidden. Each can be downloaded, and its SHA-256 is below so you can check your copy.

The role (CloudFormation)

Creates the read-only strays-audit role in one AWS account. This is the template the one-click link opens.

Download strays-audit-role.yaml (7876 bytes)

SHA-256: e6c70fb8eb6cf15cf6f72a4395cac018c545dca2dd95654acc5fef4b1accc324

AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Read-only IAM role for a Strays AWS audit (waste and security). It can only describe resources,
  read metrics and read the IAM credential report; it cannot change or delete anything.
  Delete this stack when the audit is finished.

Parameters:
  TrustedAccountId:
    Type: String
    Description: AWS account ID of the auditor who will assume this role.
    AllowedPattern: "^[0-9]{12}$"
    ConstraintDescription: A 12-digit AWS account ID.
  ExternalId:
    Type: String
    Description: The external ID the auditor gave you. Stops anyone else using this role.
    MinLength: 8
    MaxLength: 1224
    AllowedPattern: "^[\\w+=,.@:/-]+$"
  WorkerRoleName:
    Type: String
    Default: strays-prod-worker
    Description: >-
      Name of the auditor's scanning role in the auditor's account. Only this role and the web
      role below can assume this one. Leave the default unless the auditor says otherwise.
    AllowedPattern: "^[\\w+=,.@-]{1,64}$"
  WebRoleName:
    Type: String
    Default: strays-prod-web
    Description: >-
      Name of the auditor's web role (connection checks) in the auditor's account.
    AllowedPattern: "^[\\w+=,.@-]{1,64}$"
  RoleName:
    Type: String
    Default: strays-audit
    Description: Name for the role.
  TerraformStateBucket:
    Type: String
    Default: ""
    Description: >-
      Optional. S3 bucket holding Terraform state, so the audit can tell managed resources from
      ones created by hand. Leave empty to skip.
  TerraformStateKmsKeyArn:
    Type: String
    Default: ""
    Description: Optional. KMS key ARN if that bucket uses a customer-managed key.

Conditions:
  HasStateBucket: !Not [!Equals [!Ref TerraformStateBucket, ""]]
  HasStateKmsKey: !Not [!Equals [!Ref TerraformStateKmsKeyArn, ""]]

Resources:
  AuditRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Ref RoleName
      Description: Read-only access for a Strays AWS audit (waste and security)
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !Sub "arn:${AWS::Partition}:iam::${TrustedAccountId}:role/${WorkerRoleName}"
                - !Sub "arn:${AWS::Partition}:iam::${TrustedAccountId}:role/${WebRoleName}"
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                sts:ExternalId: !Ref ExternalId
      Policies:
        - PolicyName: costwatch-read-only
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # `costwatch policy` plus strays.risks.RISK_ACTIONS; tests/test_onboarding.py keeps them in sync
              - Sid: CostwatchReadOnly
                Effect: Allow
                Action:
                  - sts:GetCallerIdentity
                  - ec2:DescribeRegions
                  - ec2:DescribeVolumes
                  - ec2:DescribeSnapshots
                  - ec2:DescribeImages
                  - ec2:DescribeAddresses
                  - ec2:DescribeInstances
                  - ec2:DescribeNatGateways
                  - ec2:DescribeLaunchTemplates
                  - ec2:DescribeLaunchTemplateVersions
                  - autoscaling:DescribeLaunchConfigurations
                  - elasticloadbalancing:DescribeLoadBalancers
                  - elasticloadbalancing:DescribeTargetGroups
                  - elasticloadbalancing:DescribeTargetHealth
                  - elasticloadbalancing:DescribeTags
                  - rds:DescribeDBSnapshots
                  - rds:DescribeDBClusterSnapshots
                  - rds:DescribeDBInstances
                  - rds:DescribeDBInstanceAutomatedBackups
                  - cloudwatch:GetMetricData
                  - logs:DescribeLogGroups
                  - cloudtrail:LookupEvents
                  # Security risk checks
                  - iam:GetAccountSummary
                  - iam:GenerateCredentialReport
                  - iam:GetCredentialReport
                  - ec2:DescribeSecurityGroups
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DescribeSnapshotAttribute
                  - rds:DescribeDBSnapshotAttributes
                  - rds:DescribeDBClusterSnapshotAttributes
                  - s3:GetAccountPublicAccessBlock
                  - s3:ListAllMyBuckets
                  - s3:GetBucketLocation
                  - s3:GetBucketPolicyStatus
                  - s3:GetBucketAcl
                  - ec2:GetEbsEncryptionByDefault
                  - cloudtrail:DescribeTrails
                  - cloudtrail:GetTrailStatus
                  - guardduty:ListDetectors
                  - guardduty:GetDetector
                  - rds:DescribeDBClusters
                  - lambda:ListFunctions
                  - eks:ListClusters
                  - eks:DescribeCluster
                  - ec2:DescribeVpcEndpoints
                  - iam:ListRoles
                  - iam:GetRole
                  - iam:ListUsers
                  - iam:ListAttachedUserPolicies
                  - iam:ListGroupsForUser
                  - iam:ListAttachedGroupPolicies
                  - iam:GetAccountPasswordPolicy
                  - lambda:ListFunctionUrlConfigs
                  - elasticloadbalancing:DescribeListeners
                  - acm:ListCertificates
                  - acm:DescribeCertificate
                  - route53domains:ListDomains
                  - budgets:ViewBudget
                  - cloudwatch:DescribeAlarms
                  - s3:GetLifecycleConfiguration
                  - s3:ListBucketMultipartUploads
                  - kms:ListKeys
                  - kms:DescribeKey
                  - secretsmanager:ListSecrets
                  - ecr:DescribeRepositories
                  - ecr:GetLifecyclePolicy
                  - ecr:DescribeImages
                  - ce:GetSavingsPlansPurchaseRecommendation
                  - ce:GetCostAndUsage
                  - ce:ListCostAllocationTags
                  - iam:ListAttachedRolePolicies
                  - iam:GetUser
                  - iam:ListAccessKeys
                  - iam:GetAccessKeyLastUsed
                  - iam:GetLoginProfile
                  - iam:ListMFADevices
                  - iam:ListSSHPublicKeys
                  # Inventory and tagging (strays.tagging)
                  - lambda:ListTags
                  - kms:ListResourceTags
                  - ecr:ListTagsForResource
                  - logs:ListTagsForResource
                  - s3:GetBucketTagging
                  - backup:ListBackupVaults
                  - backup:ListBackupPlans
                  - backup:ListTags
                  - iam:ListUserTags
                  - iam:ListRoleTags
                  - iam:ListPolicies
                  - iam:ListPolicyTags
                Resource: "*"
      Tags:
        - Key: purpose
          Value: strays-audit
        - Key: managed-by
          Value: strays

  TerraformStateRead:
    Type: AWS::IAM::RolePolicy
    Condition: HasStateBucket
    Properties:
      RoleName: !Ref AuditRole
      PolicyName: terraform-state-read
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action: s3:ListBucket
            Resource: !Sub "arn:${AWS::Partition}:s3:::${TerraformStateBucket}"
          - Effect: Allow
            Action: s3:GetObject
            Resource: !Sub "arn:${AWS::Partition}:s3:::${TerraformStateBucket}/*"
          - !If
            - HasStateKmsKey
            - Effect: Allow
              Action: kms:Decrypt
              Resource: !Ref TerraformStateKmsKeyArn
            - !Ref AWS::NoValue

Outputs:
  RoleArn:
    Description: Send this to the auditor.
    Value: !GetAtt AuditRole.Arn

The organization template (CloudFormation)

For a whole AWS Organization: the same role in the management account, permission to list your accounts, and a StackSet that creates the same role in the member accounts.

Download strays-audit-organization.yaml (6181 bytes)

SHA-256: 7f39a3cd6b5809e5a6bc59510d1aaba70394a81f2f0453049ca07d75f23a1aa8

AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Connects a whole AWS Organization to a Strays audit. Run it once, in the management account.
  It creates the read-only strays-audit role in this account, lets Strays list the
  organization's accounts, and creates a service-managed StackSet that puts the same role in
  every member account of the organizational units you choose, now and as accounts are added.
  Delete this stack when the audit is finished.

Parameters:
  TrustedAccountId:
    Type: String
    Description: AWS account ID of the auditor who will assume the roles.
    AllowedPattern: "^[0-9]{12}$"
    ConstraintDescription: A 12-digit AWS account ID.
  ExternalId:
    Type: String
    Description: The external ID the auditor gave you. Stops anyone else using the roles.
    MinLength: 8
    MaxLength: 1224
    AllowedPattern: "^[\\w+=,.@:/-]+$"
  OrganizationalUnitIds:
    Type: CommaDelimitedList
    Description: >-
      Where the role goes: organizational unit IDs (ou-xxxx-xxxxxxxx) separated by commas, or the
      root ID (r-xxxx) for every account in the organization.
    AllowedPattern: "^(r-[0-9a-z]{4,32}|ou-[0-9a-z]{4,32}-[0-9a-z]{8,32})$"
    ConstraintDescription: Organizational unit IDs (ou-...) or the root ID (r-...).
  RoleTemplateUrl:
    Type: String
    Description: >-
      Address of the Strays role template (strays-audit-role.yaml), used for this account and
      for every member account, so all of them get exactly the same role.
    AllowedPattern: "^https://.+"
  WorkerRoleName:
    Type: String
    Default: strays-prod-worker
    Description: Name of the auditor's scanning role; with the web role, the only roles that can assume these.
    AllowedPattern: "^[\\w+=,.@-]{1,64}$"
  WebRoleName:
    Type: String
    Default: strays-prod-web
    Description: Name of the auditor's web role (connection checks).
    AllowedPattern: "^[\\w+=,.@-]{1,64}$"
  RoleName:
    Type: String
    Default: strays-audit
    Description: Name for the role in every account.
  ManagementRole:
    Type: String
    Default: create
    AllowedValues: [create, existing]
    Description: >-
      "existing" if this management account was connected to Strays on its own first, so it
      already has the role (from the strays-audit-role stack); the role is then left as it is.
  TerraformStateBucket:
    Type: String
    Default: ""
    Description: Optional. S3 bucket holding Terraform state. Leave empty to skip.
  TerraformStateKmsKeyArn:
    Type: String
    Default: ""
    Description: Optional. KMS key ARN if that bucket uses a customer-managed key.

Conditions:
  CreateManagementRole: !Equals [!Ref ManagementRole, create]

Resources:
  # StackSets with service-managed permissions never deploy to the management account, so this
  # account gets the same role from the same template as a nested stack
  ManagementAuditRole:
    Type: AWS::CloudFormation::Stack
    Condition: CreateManagementRole
    Properties:
      TemplateURL: !Ref RoleTemplateUrl
      Parameters:
        TrustedAccountId: !Ref TrustedAccountId
        ExternalId: !Ref ExternalId
        WorkerRoleName: !Ref WorkerRoleName
        WebRoleName: !Ref WebRoleName
        RoleName: !Ref RoleName
        TerraformStateBucket: !Ref TerraformStateBucket
        TerraformStateKmsKeyArn: !Ref TerraformStateKmsKeyArn
      Tags:
        - Key: purpose
          Value: strays-audit
        - Key: managed-by
          Value: strays

  # Added to this account's role: the one created above, or the one already there
  ListAccounts:
    Type: AWS::IAM::RolePolicy
    Properties:
      RoleName: !If
        - CreateManagementRole
        - !Select [1, !Split ["/", !GetAtt ManagementAuditRole.Outputs.RoleArn]]
        - !Ref RoleName
      PolicyName: organization-read
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          # Only the list of accounts (ID, name, status); nothing about policies or contents
          - Sid: OrganizationRead
            Effect: Allow
            Action:
              - organizations:DescribeOrganization
              - organizations:ListAccounts
            Resource: "*"

  MemberAuditRoles:
    Type: AWS::CloudFormation::StackSet
    Properties:
      StackSetName: strays-audit-members
      Description: Read-only strays-audit role for a Strays audit, in every member account
      # Carried to every member account's stack, so it's clear in each account who made it
      Tags:
        - Key: purpose
          Value: strays-audit
        - Key: managed-by
          Value: strays
      PermissionModel: SERVICE_MANAGED
      AutoDeployment:
        Enabled: true
        RetainStacksOnAccountRemoval: false
      Capabilities:
        - CAPABILITY_NAMED_IAM
      ManagedExecution:
        Active: true
      OperationPreferences:
        RegionConcurrencyType: PARALLEL
        MaxConcurrentPercentage: 100
        FailureTolerancePercentage: 10
      TemplateURL: !Ref RoleTemplateUrl
      Parameters:
        - ParameterKey: TrustedAccountId
          ParameterValue: !Ref TrustedAccountId
        - ParameterKey: ExternalId
          ParameterValue: !Ref ExternalId
        - ParameterKey: WorkerRoleName
          ParameterValue: !Ref WorkerRoleName
        - ParameterKey: WebRoleName
          ParameterValue: !Ref WebRoleName
        - ParameterKey: RoleName
          ParameterValue: !Ref RoleName
        - ParameterKey: TerraformStateBucket
          ParameterValue: !Ref TerraformStateBucket
        - ParameterKey: TerraformStateKmsKeyArn
          ParameterValue: !Ref TerraformStateKmsKeyArn
      StackInstancesGroup:
        - DeploymentTargets:
            OrganizationalUnitIds: !Ref OrganizationalUnitIds
          Regions:
            - !Ref AWS::Region

Outputs:
  ManagementAccountId:
    Description: Enter this in Strays as the management account.
    Value: !Ref AWS::AccountId
  RoleArn:
    Description: The role in this (management) account.
    Value: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${RoleName}"
  StackSetName:
    Description: The StackSet that creates the role in the member accounts.
    Value: !Ref MemberAuditRoles

The Terraform module (main.tf)

The same role for teams that use Terraform, with the same actions and the same trust policy (the two Strays roles named above).

Download strays-audit-terraform-main.tf (5195 bytes)

SHA-256: ab3d18c792787da044e0daf5e9bb575c6df83a3737188ec7e2c2d93b4cf14cb4

data "aws_partition" "current" {}

locals {
  # `costwatch policy` plus strays.risks.RISK_ACTIONS; tests/test_onboarding.py keeps them in sync
  read_only_actions = [
    "sts:GetCallerIdentity",
    "ec2:DescribeRegions",
    "ec2:DescribeVolumes",
    "ec2:DescribeSnapshots",
    "ec2:DescribeImages",
    "ec2:DescribeAddresses",
    "ec2:DescribeInstances",
    "ec2:DescribeNatGateways",
    "ec2:DescribeLaunchTemplates",
    "ec2:DescribeLaunchTemplateVersions",
    "autoscaling:DescribeLaunchConfigurations",
    "elasticloadbalancing:DescribeLoadBalancers",
    "elasticloadbalancing:DescribeTargetGroups",
    "elasticloadbalancing:DescribeTargetHealth",
    "elasticloadbalancing:DescribeTags",
    "rds:DescribeDBSnapshots",
    "rds:DescribeDBClusterSnapshots",
    "rds:DescribeDBInstances",
    "rds:DescribeDBInstanceAutomatedBackups",
    "cloudwatch:GetMetricData",
    "logs:DescribeLogGroups",
    "cloudtrail:LookupEvents",
    # Security risk checks
    "iam:GetAccountSummary",
    "iam:GenerateCredentialReport",
    "iam:GetCredentialReport",
    "ec2:DescribeSecurityGroups",
    "ec2:DescribeNetworkInterfaces",
    "ec2:DescribeSnapshotAttribute",
    "rds:DescribeDBSnapshotAttributes",
    "rds:DescribeDBClusterSnapshotAttributes",
    "s3:GetAccountPublicAccessBlock",
    "s3:ListAllMyBuckets",
    "s3:GetBucketLocation",
    "s3:GetBucketPolicyStatus",
    "s3:GetBucketAcl",
    "ec2:GetEbsEncryptionByDefault",
    "cloudtrail:DescribeTrails",
    "cloudtrail:GetTrailStatus",
    "guardduty:ListDetectors",
    "guardduty:GetDetector",
    "rds:DescribeDBClusters",
    "lambda:ListFunctions",
    "eks:ListClusters",
    "eks:DescribeCluster",
    "ec2:DescribeVpcEndpoints",
    "iam:ListRoles",
    "iam:GetRole",
    "iam:ListUsers",
    "iam:ListAttachedUserPolicies",
    "iam:ListGroupsForUser",
    "iam:ListAttachedGroupPolicies",
    "iam:GetAccountPasswordPolicy",
    "lambda:ListFunctionUrlConfigs",
    "elasticloadbalancing:DescribeListeners",
    "acm:ListCertificates",
    "acm:DescribeCertificate",
    "route53domains:ListDomains",
    "budgets:ViewBudget",
    "cloudwatch:DescribeAlarms",
    "s3:GetLifecycleConfiguration",
    "s3:ListBucketMultipartUploads",
    "kms:ListKeys",
    "kms:DescribeKey",
    "secretsmanager:ListSecrets",
    "ecr:DescribeRepositories",
    "ecr:GetLifecyclePolicy",
    "ecr:DescribeImages",
    "ce:GetSavingsPlansPurchaseRecommendation",
    "ce:GetCostAndUsage",
    "ce:ListCostAllocationTags",
    "iam:ListAttachedRolePolicies",
    "iam:GetUser",
    "iam:ListAccessKeys",
    "iam:GetAccessKeyLastUsed",
    "iam:GetLoginProfile",
    "iam:ListMFADevices",
    "iam:ListSSHPublicKeys",
    # Inventory and tagging (strays.tagging)
    "lambda:ListTags",
    "kms:ListResourceTags",
    "ecr:ListTagsForResource",
    "logs:ListTagsForResource",
    "s3:GetBucketTagging",
    "backup:ListBackupVaults",
    "backup:ListBackupPlans",
    "backup:ListTags",
    "iam:ListUserTags",
    "iam:ListRoleTags",
    "iam:ListPolicies",
    "iam:ListPolicyTags",
  ]
}

data "aws_iam_policy_document" "trust" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:${data.aws_partition.current.partition}:iam::${var.trusted_account_id}:role/${var.worker_role_name}",
        "arn:${data.aws_partition.current.partition}:iam::${var.trusted_account_id}:role/${var.web_role_name}",
      ]
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

resource "aws_iam_role" "audit" {
  name                 = var.role_name
  description          = "Read-only access for a Strays AWS audit (waste and security)"
  assume_role_policy   = data.aws_iam_policy_document.trust.json
  max_session_duration = 3600
  tags                 = var.tags
}

data "aws_iam_policy_document" "read_only" {
  # checkov:skip=CKV_AWS_356:An account-wide audit must read every role, user, bucket and certificate; every action is read-only (enforced by tests)
  statement {
    sid       = "CostwatchReadOnly"
    actions   = local.read_only_actions
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "read_only" {
  name   = "costwatch-read-only"
  role   = aws_iam_role.audit.id
  policy = data.aws_iam_policy_document.read_only.json
}

data "aws_iam_policy_document" "state" {
  count = var.terraform_state_bucket == null ? 0 : 1

  statement {
    actions   = ["s3:ListBucket"]
    resources = ["arn:${data.aws_partition.current.partition}:s3:::${var.terraform_state_bucket}"]
  }

  statement {
    actions   = ["s3:GetObject"]
    resources = ["arn:${data.aws_partition.current.partition}:s3:::${var.terraform_state_bucket}/*"]
  }

  dynamic "statement" {
    for_each = var.terraform_state_kms_key_arn == null ? [] : [var.terraform_state_kms_key_arn]
    content {
      actions   = ["kms:Decrypt"]
      resources = [statement.value]
    }
  }
}

resource "aws_iam_role_policy" "state" {
  count = var.terraform_state_bucket == null ? 0 : 1

  name   = "terraform-state-read"
  role   = aws_iam_role.audit.id
  policy = data.aws_iam_policy_document.state[0].json
}

Check the files yourself

Download a template and work out its SHA-256. It should match the value above. The one-click link loads the same files.

macOS or Linux:

shasum -a 256 strays-audit-role.yaml

Windows (Command Prompt):

certutil -hashfile strays-audit-role.yaml SHA256

Once installed, see exactly what the role in your account allows, straight from AWS:

aws iam get-role --role-name strays-audit
aws iam get-role-policy --role-name strays-audit --policy-name costwatch-read-only