Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions providers/aws/iam/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,14 @@ func stringOp(op string) (cmp func(ctxVal, policyVal string) bool, negate, ok bo
}
}

// arnOp handles ARN operators. ArnEquals and ArnLike both allow the * wildcard
// (AWS treats them equivalently apart from documented case handling).
// arnOp handles ARN operators. ArnEquals and ArnLike behave the same in IAM:
// both match component by component and allow '*' and '?' in each component.
func arnOp(op string) (cmp func(ctxVal, policyVal string) bool, negate, ok bool) {
switch op {
case "ArnEquals", "ArnLike":
return strLike, false, true
return arnMatch, false, true
case "ArnNotEquals", "ArnNotLike":
return strLike, true, true
return arnMatch, true, true
default:
return nil, false, false
}
Expand All @@ -214,8 +214,9 @@ func ipOp(op string) (cmp func(ctxVal, policyVal string) bool, negate, ok bool)

func strEqual(ctxVal, policyVal string) bool { return ctxVal == policyVal }

// strLike matches ctxVal against a policy pattern that may contain * wildcards.
func strLike(ctxVal, policyVal string) bool { return wildcardMatch(policyVal, ctxVal) }
// strLike matches ctxVal against a policy pattern that may contain '*' and '?'
// wildcards, case-sensitively and against the whole value.
func strLike(ctxVal, policyVal string) bool { return globMatch(policyVal, ctxVal) }

// boolMatch compares the request and policy values as booleans.
func boolMatch(ctxVal, policyVal string) bool {
Expand Down
33 changes: 2 additions & 31 deletions providers/aws/iam/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -806,35 +806,6 @@ func (s *policyStatement) resourceMatches(resource string) bool {
}
}

func wildcardMatch(pattern, value string) bool {
if pattern == "*" {
return true
}

pParts := strings.Split(pattern, "*")

if len(pParts) == 1 {
return pattern == value
}

if !strings.HasPrefix(value, pParts[0]) {
return false
}

remaining := value[len(pParts[0]):]

for i := 1; i < len(pParts); i++ {
idx := strings.Index(remaining, pParts[i])
if idx < 0 {
return false
}

remaining = remaining[idx+len(pParts[i]):]
}

return true
}

func toStringSlice(v any) []string {
switch val := v.(type) {
case string:
Expand All @@ -856,7 +827,7 @@ func toStringSlice(v any) []string {

func matchesAction(actions []string, action string) bool {
for _, a := range actions {
if wildcardMatch(a, action) {
if actionMatch(a, action) {
return true
}
}
Expand All @@ -866,7 +837,7 @@ func matchesAction(actions []string, action string) bool {

func matchesResource(resources []string, resource string) bool {
for _, r := range resources {
if wildcardMatch(r, resource) {
if globMatch(r, resource) {
return true
}
}
Expand Down
2 changes: 1 addition & 1 deletion providers/aws/iam/iam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ func TestWildcardMatch(t *testing.T) {

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := wildcardMatch(tc.pattern, tc.value)
result := globMatch(tc.pattern, tc.value)
assertEqual(t, tc.expect, result)
})
}
Expand Down
41 changes: 32 additions & 9 deletions providers/aws/iam/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,39 @@ func containsStar(resources []string) bool {
}

// anyCoversService reports whether one pattern matches every action of svc.
// Matching the pattern against the literal "svc:*" does that: "*", "s3:*" and
// "s*" cover s3, "s3:Get*" does not.
func anyCoversService(patterns []string, svc string) bool {
for _, p := range patterns {
if wildcardMatch(p, svc+":*") {
if coversService(p, svc) {
return true
}
}

return false
}

// coversService reports whether pattern matches every "svc:Action". It holds
// when the pattern is some head followed only by '*'s and the head matches a
// prefix of "svc:": the trailing stars then take the rest of any action. So "*",
// "s3:*" and "s*" cover s3 while "s3:Get*" and "s3:?" do not. It may say no for
// an odd pattern that does cover the service ("s3:?*"), never the reverse,
// which is the safe direction for both callers.
func coversService(pattern, svc string) bool {
pattern = strings.ToLower(pattern)
prefix := strings.ToLower(svc) + ":"

for i := len(pattern) - 1; i >= 0 && pattern[i] == '*'; i-- {
head := pattern[:i]

for k := 0; k <= len(prefix); k++ {
if globMatch(head, prefix[:k]) {
return true
}
}
}

return false
}

func anyCouldMatchService(patterns []string, svc string) bool {
for _, p := range patterns {
if couldMatchService(p, svc) {
Expand All @@ -255,20 +276,22 @@ func anyCouldMatchService(patterns []string, svc string) bool {
// may say yes for a pattern that cannot really match, never the reverse, which
// is the safe direction for both of its callers.
func couldMatchService(pattern, svc string) bool {
pattern, svc = strings.ToLower(pattern), strings.ToLower(svc)

if head, _, ok := strings.Cut(pattern, ":"); ok {
// Actions carry exactly one colon, so the pattern's first colon lines up
// with it and the head must match the service name.
return wildcardMatch(head, svc)
return globMatch(head, svc)
}

// With no colon, only a '*' can span the separator, so the text before the
// first '*' must be a prefix of the service name.
star := strings.IndexByte(pattern, '*')
if star < 0 {
// With no colon, only a wildcard can stand in for the separator, so the
// text before the first '*' or '?' must be a prefix of the service name.
wild := strings.IndexAny(pattern, "*?")
if wild < 0 {
return false
}

return strings.HasPrefix(svc, pattern[:star])
return strings.HasPrefix(svc, pattern[:wild])
}

// EvaluatePermission reports the tri-state decision for one action. With
Expand Down
2 changes: 1 addition & 1 deletion providers/aws/iam/trust_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,5 @@ func principalEntryMatches(entry, caller string) bool {
// A trust policy that names the account root trusts every principal in that
// account; the caller is the account root, so an exact match already covers
// it. Fall back to wildcard matching for patterns like "arn:...:role/*".
return wildcardMatch(entry, caller)
return globMatch(entry, caller)
}
72 changes: 72 additions & 0 deletions providers/aws/iam/wildcard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package iam

import "strings"

// arnSegments is the number of colon-separated components in an ARN:
// arn:partition:service:region:account:resource. The resource component may
// itself contain colons.
const arnSegments = 6

// globMatch reports whether value matches pattern under IAM's wildcard rules:
// '*' matches any run of characters (including none), '?' matches exactly one,
// and everything else is literal. The whole value must match, so the pattern is
// anchored at both ends. The comparison is case-sensitive.
func globMatch(pattern, value string) bool {
p, v := []rune(pattern), []rune(value)
pi, vi := 0, 0
star, mark := -1, 0

for vi < len(v) {
switch {
case pi < len(p) && p[pi] == '*':
star, mark = pi, vi
pi++
case pi < len(p) && (p[pi] == '?' || p[pi] == v[vi]):
pi++
vi++
case star >= 0:
// Let the last '*' take one more character and retry from there.
mark++
pi, vi = star+1, mark
default:
return false
}
}

for pi < len(p) && p[pi] == '*' {
pi++
}

return pi == len(p)
}

// actionMatch matches an Action or NotAction entry against an action name.
// Action names are case-insensitive in IAM.
func actionMatch(pattern, action string) bool {
return globMatch(strings.ToLower(pattern), strings.ToLower(action))
}

// arnMatch implements ArnEquals and ArnLike: each of the six ARN components is
// matched on its own, so a wildcard never spans the ':' between components (the
// last component keeps any colons it carries). Matching is case-sensitive. A
// pattern with no ':' at all, such as "*", is matched against the whole value.
func arnMatch(value, pattern string) bool {
if !strings.Contains(pattern, ":") {
return globMatch(pattern, value)
}

pp := strings.SplitN(pattern, ":", arnSegments)
vp := strings.SplitN(value, ":", arnSegments)

if len(pp) != arnSegments || len(vp) != arnSegments {
return false
}

for i := range pp {
if !globMatch(pp[i], vp[i]) {
return false
}
}

return true
}
Loading
Loading