This issue has been assessed as high severity. Review affected configurations immediately.
Why the trust policy is the control
GitHub Actions can obtain short-lived AWS credentials through OpenID Connect, so no long-lived access keys sit in repository secrets. The workflow presents a GitHub-issued token to AWS STS, and the IAM role’s trust policy decides whether to accept it. Nothing else stands between the token and the role’s permissions, so the trust policy conditions are the whole access check.
GitHub’s documentation for configuring OIDC in AWS shows two condition keys: token.actions.githubusercontent.com:aud, set to sts.amazonaws.com, and token.actions.githubusercontent.com:sub, which identifies the repository and the branch, environment or other context of the job.
A tight trust policy
This follows the pattern in GitHub’s documentation, pinning the subject to one repository and one branch (replace the placeholders with your own):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main"
}
}
}]
}
For deployments gated by a GitHub environment (with required reviewers), GitHub’s documentation shows a subject of the form repo:octo-org/octo-repo:environment:prod. Our reading is that tying a production role to an environment lets that environment’s protection rules (GitHub recommends adding them) also gate cloud access.
Misconfigurations to look for
- No
subcondition. If onlyaudis checked, the policy does not restrict which repository can assume the role. Note thataudis the same for every workflow using the standard setup, so on its own it identifies nothing. - Broad wildcards in
sub. GitHub’s documentation illustratesStringLikewithrepo:octo-org/octo-repo:*to allow any branch, pull request merge branch or environment in one repository. That is a legitimate choice for low-privilege roles. For a role that can deploy or read secrets, it lets any branch (including one from a contributor with write access) assume it. A pattern such asrepo:example-org/*widens that to every repository in the organisation (our assessment: avoid it for anything privileged). - One shared role for many pipelines. A single role with broad permissions assumed from several repositories means the weakest repository sets the risk.
- Over-permissioned role policies. Even a perfect trust policy is dangerous if the role has
AdministratorAccess. Scope permissions per pipeline.
Audit your account with the AWS CLI
This Node.js script (we tested it against eight sample role documents, not a live account) reads aws iam list-roles output, finds Allow statements that trust the GitHub OIDC provider, and rates each sub condition: NO SUB CONDITION, WILDCARD-REPO (a * inside the organisation/repository part), WILDCARD-CONTEXT (any branch, pull request or environment in one repository), prefix (a trailing wildcard on a fixed branch prefix) or ok. Wildcards are only rated for *Like operators, because * is literal under StringEquals. It needs iam:ListRoles and Node 18 or later.
// Usage: aws iam list-roles --output json | node audit-gh-oidc.mjs
import { readFileSync } from 'node:fs';
const GH = 'token.actions.githubusercontent.com';
const { Roles } = JSON.parse(readFileSync(0, 'utf8'));
const arr = (x) => (x === undefined ? [] : Array.isArray(x) ? x : [x]);
// Rank a single sub value. Wildcards only count for *Like operators; elsewhere "*" is literal.
function rate(op, v) {
if (!/Like/i.test(op)) return 'ok';
if (!v.includes('*')) return 'ok';
const [, repoPart = '', rest = ''] = v.match(/^repo:([^:]*)(.*)$/s) ?? [];
if (!v.startsWith('repo:') || repoPart.includes('*')) return 'WILDCARD-REPO'; // any repo in the org (or worse)
if (/^:?\*?$/.test(rest) || /:environment:\*/.test(rest)) return 'WILDCARD-CONTEXT'; // any branch / PR / environment
if (/^:ref:refs\/heads\/[^*]+\*$/.test(rest)) return 'prefix'; // trailing wildcard on a fixed branch prefix
return 'WILDCARD-OTHER';
}
for (const role of Roles) {
for (const st of arr(role.AssumeRolePolicyDocument?.Statement)) {
if (st.Effect !== 'Allow') continue;
if (!arr(st.Principal?.Federated).some((p) => p.includes(GH))) continue;
const subs = [];
for (const [op, conds] of Object.entries(st.Condition ?? {})) {
for (const [key, val] of Object.entries(conds)) {
if (key === `${GH}:sub`) subs.push(...arr(val).map((v) => ({ op, v, flag: rate(op, v) })));
}
}
const flags = subs.length === 0 ? ['NO SUB CONDITION'] : [...new Set(subs.map((s) => s.flag))];
console.log(`${role.RoleName}\t${flags.join(',')}\t${subs.map((s) => `${s.op} ${s.v}`).join(' | ')}`);
}
}
Run it with aws iam list-roles --output json | node audit-gh-oidc.mjs. Treat NO SUB CONDITION and WILDCARD-REPO as urgent, and review the other wildcard rows by hand. Deny statements are skipped, and statements with Principal: "*" and no Federated key are not examined, so review those separately. It is a heuristic, not a complete policy analyser.
Detect misuse in CloudTrail
Successful and failed AssumeRoleWithWebIdentity calls appear in CloudTrail. In CloudTrail Lake or Athena, review which subjects are assuming which roles:
SELECT eventTime, errorCode, sourceIPAddress,
json_extract_scalar(requestParameters, '$.roleArn') AS role,
userIdentity.userName AS subject
FROM cloudtrail_logs
WHERE eventName = 'AssumeRoleWithWebIdentity'
AND eventTime > to_iso8601(current_timestamp - interval '7' day)
ORDER BY eventTime DESC;
Table and column names vary with how you configured CloudTrail, and we have not verified which userIdentity field carries the GitHub subject in your logs. AWS’s user-identity reference shows WebIdentityUser events with userName and a principalId of the form provider:audience:subject, but its example is not GitHub, so inspect a sample event and adjust the query first. What to look for: roles assumed by subjects you do not recognise, bursts of AccessDenied errors (someone probing), and assumption outside your normal deploy windows.
Hardening checklist
- Every GitHub-trusting role has an
audand asubcondition usingStringEqualswhere possible. - Production roles are bound to a protected branch or a GitHub environment with required reviewers.
- One role per pipeline, with least-privilege permissions and a permissions boundary.
- Set a short maximum session duration on CI roles.
- Alert on new IAM OIDC providers and on changes to trust policies (
UpdateAssumeRolePolicy,CreateOpenIDConnectProvider). - Remove any remaining long-lived AWS keys from repository secrets once OIDC works.