Amazon Web Services¶
Connecting from the web app?
Follow the prerequisites and credential creation instructions below, then return to Cloud Security → Settings → Providers → Add provider. Enter the provider IDs under Configuration and save the credential using New secret under Permissions. Run Test Provider, fix required failures, and save. The LimaCharlie CLI examples below are an alternative. First-time setup and verification explains the full journey.
Read-only inventory via an IAM identity that assumes a read-only role. Two topologies:
- Single account (below): one IAM user plus one role in that account.
- AWS Organization: deploy the same role to every account via a
service-managed CloudFormation StackSet and set
aws_member_role_name; the management-account role named byaws_role_arnadditionally needsorganizations:List*/Describe*. The base IAM user still needs nothing beyondsts:AssumeRole— member-account discovery is performed with the assumed role, not with the user's own credentials.
Architecture (least-privilege)¶
An IAM user whose only permission is sts:AssumeRole on a read-only
role (SecurityAudit + ViewOnlyAccess), gated by an external ID.
LimaCharlie stores the user's access key, assumes the role, and reads. The
user itself can do nothing but assume that one role.
Create the identity (single account)¶
Use a dedicated test account for your first connection if you have one. Ask an AWS administrator who can create IAM users, roles, policies, and access keys to run the steps below. The created user is the application's identity, not a human login. The role grants the read permissions, and the external ID is a value that must match between the role's trust policy and LimaCharlie.
Choose one tab; both create the same user, role, and access key.
- Sign in to the AWS console as an administrator, open IAM → Users → Create user,
and name the user
lc-cloudsec. Leave console access off. Create the user without attaching policies; its permission is added below. - Copy your 12-digit account ID from the account menu. Generate and save a unique
alphanumeric external ID (for example, using a password manager). Replace
<ACCOUNT_ID>and<EXTERNAL_ID>in the following policy with those values. -
Open IAM → Roles → Create role → Custom trust policy and paste:
-
Continue to permissions and select both
SecurityAuditandViewOnlyAccess. Name the roleLimaCharlieCloudSecRO, create it, and copy its ARN from the role details. Keep the external ID for the LimaCharlie wizard. -
Open IAM → Users → lc-cloudsec → Permissions → Add permissions → Create inline policy. Select the JSON editor and paste this policy, replacing
<ACCOUNT_ID>with the same account ID: -
Name the policy
lc-assume-roand create it. The user now has permission to assume this role; the read permissions belong to the role. - On the user's Security credentials tab, choose Create access key. Review the use-case guidance, select Other, continue through the prompts, and save the Access key ID and Secret access key. AWS shows the secret only at creation; keep it for the credential step below.
AWS documents custom trust policies and access key creation.
- Sign in to the AWS console with that administrator identity (not root).
- Open CloudShell from the console toolbar and use its Bash shell. The AWS CLI is already installed and uses your console identity. See AWS CloudShell setup.
- Run
aws sts get-caller-identityand confirm the Account is the one you intend to connect. - Run the commands below in that same shell. Stop if a command fails rather than continuing with an incomplete role.
- Keep the generated external ID and the access key's AccessKeyId and SecretAccessKey. They are used in different fields, as shown below.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
EXTERNAL_ID=$(openssl rand -hex 16) # save this
aws iam create-user --user-name lc-cloudsec
cat > trust.json <<EOF
{ "Version": "2012-10-17", "Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::${ACCOUNT_ID}:user/lc-cloudsec" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "sts:ExternalId": "${EXTERNAL_ID}" } }
}] }
EOF
aws iam create-role --role-name LimaCharlieCloudSecRO \
--assume-role-policy-document file://trust.json
aws iam attach-role-policy --role-name LimaCharlieCloudSecRO \
--policy-arn arn:aws:iam::aws:policy/SecurityAudit
aws iam attach-role-policy --role-name LimaCharlieCloudSecRO \
--policy-arn arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
cat > assume.json <<EOF
{ "Version": "2012-10-17", "Statement": [{
"Effect": "Allow", "Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::${ACCOUNT_ID}:role/LimaCharlieCloudSecRO"
}] }
EOF
aws iam put-user-policy --user-name lc-cloudsec \
--policy-name lc-assume-ro --policy-document file://assume.json
aws iam create-access-key --user-name lc-cloudsec # capture AccessKeyId + SecretAccessKey
Create the credentials secret¶
In the LimaCharlie wizard's Permissions step, select New secret. Choose
a name such as aws-credentials, then paste this JSON with the two values
from AWS: the access key ID and secret access key. If you used the CLI, do not
paste the entire command output.
No aws_ prefix, and no other keys
aws_access_key_id / aws_secret_access_key are silently ignored — the
SDK then falls back to the default credential chain and the auth check
fails with no EC2 IMDS role found. Use the bare access_key_id /
secret_access_key keys. Those two are the only keys read: a
session_token is not supported, so temporary/session credentials
cannot be used here (they are dropped, and the assume then fails with
InvalidClientTokenId). Use the long-lived access key of the dedicated IAM
user — the role it assumes is where the read permissions live.
For CLI setup, save the credential JSON above as aws-secret.json and install jq.
jq -Rs '{secret: .}' aws-secret.json \
| limacharlie secret set --key aws-credentials --enabled \
&& rm -f aws-secret.json
jq -Rs wraps the file contents into the secret record for stdin — the equivalent of
limacharlie hive set --hive-name secret with {"secret": "<the credential JSON
as a string>"}.
Create the provider record¶
In the wizard's Configuration step, enter the role ARN from IAM → Roles →
LimaCharlieCloudSecRO and the external ID you generated. An ARN is AWS's full
identifier for a resource. Leave the member role field empty for a single-account
setup; it is for collecting across an AWS Organization. Then test and save.
The equivalent CLI configuration is below.
provider.yaml:
provider_type: aws
aws_role_arn: "arn:aws:iam::<ACCOUNT_ID>:role/LimaCharlieCloudSecRO"
aws_external_id: "<EXTERNAL_ID>"
credentials: hive://secret/aws-credentials
# aws_regions: [us-east-1, ...] # optional; omit = all enabled regions
# aws_member_role_name: LimaCharlieCloudSecRO # ONLY for AWS Organization member accounts
Verify & coverage¶
The report opens with the config and credential checks common to every
provider, documented once in Provider Setup;
the AWS-specific checks follow.
| Check | Required | Meaning if it fails |
|---|---|---|
auth |
✅ | sts:AssumeRole failed — wrong external ID, trust policy, or credentials. Nothing else is probed. |
ec2 |
✅ | Compute inventory unavailable. |
iam |
✅ | IAM inventory unavailable — the CIEM access graph cannot be built. |
iam_boundaries |
— | IAM permission boundaries unreadable (iam:GetAccountAuthorizationDetails). A principal whose boundary caps it below its policies is scored on the uncapped policies, so it can be reported as an administrator when it is not. |
s3 |
✅ | Storage inventory unavailable. |
regions |
— | Only meaningful when aws_regions is unset (with it set the check is skipped as unnecessary, and still counts as passing). Enabled-region enumeration was denied, so the sweep falls back to us-east-1 alone — list the regions you want in aws_regions. |
organizations |
— | Member-account discovery unavailable; only the connected account is swept. |
org_policies |
— | The organization's service control policies (SCPs) and resource control policies (RCPs) are unreadable, so they are not collected. Passes when the account is not in an organization, or no policy type is enabled on the organization root — in both cases there is nothing to read. |
inspector |
— | Workload vulnerability findings unavailable. |
secrets_manager |
— | Secret-store inventory unavailable. |
data_stores |
— | RDS / DynamoDB / Redshift inventory unavailable. |
front_doors |
— | Lambda / API Gateway / load-balancer inventory unavailable — a public function URL, API stage or internet-facing load balancer stays invisible to the exposure model. |
ai_services |
— | SageMaker / Bedrock inventory unavailable. |
With SecurityAudit + ViewOnlyAccess, every optional surface above also
passes — no extra policies needed.
Why front_doors probes more than one call
Lambda's exposure verdict needs three separate grants —
lambda:ListFunctions, lambda:ListFunctionUrlConfigs and
lambda:GetPolicy — and a role missing any one of them still passes a
single representative list call. The check therefore exercises the real
per-function reads against one existing function, so a hand-rolled
least-privilege policy is caught at connect time instead of leaving the
Lambda inventory unreadable on every sweep.
The two managed policies grant these BETWEEN them, not each on its own:
SecurityAudit supplies lambda:List* and lambda:GetPolicy,
ViewOnlyAccess the ELBv2 describes, and both supply apigateway:GET.
Attach both, as the setup above does.
Organization guardrails (SCPs and RCPs)¶
For an account in an AWS Organization, LimaCharlie records where the account sits in the organization tree and every service control policy and resource control policy that applies to it — those attached to the account itself, plus each organizational unit above it, plus the root. The policy documents are stored verbatim, each labelled with the level it is attached at.
Collected, not yet applied
These policies are inventory today, not part of the access calculation. Permission and attack-path answers are still the union of the permissions that were granted, without subtracting what an SCP forbids — so they can over-state access. If the collection role cannot read the policies at all, that is reported as a finding ("effective-permission analysis is unconstrained") rather than left implicit.
Collecting them uses these read-only actions on the role named by
aws_role_arn, all of which SecurityAudit already grants:
organizations:DescribeOrganization organizations:ListPolicies
organizations:DescribePolicy organizations:ListTargetsForPolicy
organizations:ListRoots organizations:ListAccounts
organizations:ListOrganizationalUnitsForParent
organizations:ListAccountsForParent
If you attached a hand-written least-privilege policy instead of
SecurityAudit, add the actions above — the org_policies check tells you
whether they are in place. A standalone account (not in an organization) needs
none of this and reports no finding: with no organization there are no
guardrails to account for.
Which account the role must live in
AWS only allows these reads from the organization's management account,
or from an account you have registered as a delegated administrator for
AWS Organizations. From any other member account they are refused with the
same AccessDenied you would get from a missing permission, and adding IAM
permissions will not change that. If org_policies keeps failing with a
fully-granted role, check which account aws_role_arn points at.
Propagation
Fresh IAM keys and role trust can take a few seconds to propagate; retry
once on a transient AccessDenied / InvalidClientTokenId.
Troubleshooting¶
provider test error |
Cause | Fix |
|---|---|---|
auth fails: … no EC2 IMDS role found |
Secret used the wrong key names → no static creds → default chain → IMDS | Use access_key_id / secret_access_key (no aws_ prefix) |
AccessDenied on sts:AssumeRole |
External ID mismatch, wrong trust-policy principal, or propagation | Confirm aws_external_id matches the trust condition; retry after a few seconds |