Skip to content

Commit 0bf508f

Browse files
authored
New rule I3510 to validate action and resources match (#4019)
* New rule I3510 to validate IAM asterisk resources
1 parent 8609fc2 commit 0bf508f

File tree

14 files changed

+92588
-24899
lines changed

14 files changed

+92588
-24899
lines changed

src/cfnlint/data/AdditionalSpecs/Policies.json

+91,892-24,824
Large diffs are not rendered by default.

src/cfnlint/data/schemas/other/iam/policy.json

+3
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@
239239
]
240240
}
241241
],
242+
"cfnLint": [
243+
"AWS::IAM::Policy/Properties/PolicyDocument/Statement"
244+
],
242245
"properties": {
243246
"Action": {
244247
"$ref": "#/definitions/Action"

src/cfnlint/data/schemas/other/iam/policy_identity.json

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
]
2424
}
2525
],
26+
"cfnLint": [
27+
"AWS::IAM::Policy/Properties/PolicyDocument/Statement"
28+
],
2629
"properties": {
2730
"Action": {
2831
"$ref": "policy#/definitions/Action"

src/cfnlint/maintenance.py

+57-15
Original file line numberDiff line numberDiff line change
@@ -123,30 +123,72 @@ def update_documentation(rules):
123123
def update_iam_policies():
124124
"""update iam policies file"""
125125

126-
url = "https://awspolicygen.s3.amazonaws.com/js/policies.js"
126+
url = "https://servicereference.us-east-1.amazonaws.com"
127127

128128
filename = os.path.join(
129129
os.path.dirname(cfnlint.data.AdditionalSpecs.__file__), "Policies.json"
130130
)
131131
LOGGER.debug("Downloading policies %s into %s", url, filename)
132132

133-
content = get_url_content(url)
133+
services = json.loads(get_url_content(url))
134134

135-
content = content.split("app.PolicyEditorConfig=")[1]
136-
content = json.loads(content)
135+
def _clean_arn_formats(arn):
136+
arn_parts = arn.split(":", 5)
137137

138-
actions = {
139-
"Manage Amazon API Gateway": ["HEAD", "OPTIONS"],
140-
"Amazon API Gateway Management": ["HEAD", "OPTIONS"],
141-
"Amazon API Gateway Management V2": ["HEAD", "OPTIONS"],
142-
"Amazon Kinesis Video Streams": ["StartStreamEncryption"],
143-
}
144-
for k, v in actions.items():
145-
if content.get("serviceMap").get(k):
146-
content["serviceMap"][k]["Actions"].extend(v)
138+
resource = arn_parts[5]
139+
delimiter = None
140+
for d in [":", "/"]:
141+
if d in resource:
142+
delimiter = d
143+
break
147144
else:
148-
LOGGER.debug('"%s" was not found in the policies file', k)
145+
delimiter = ":"
146+
147+
if delimiter:
148+
resource_parts = []
149+
for resource_part in resource.split(delimiter):
150+
if "${" in resource_part:
151+
resource_parts.append(".*")
152+
break
153+
154+
resource_parts.append(resource_part)
155+
156+
arn_parts[5] = delimiter.join(resource_parts)
157+
158+
return ":".join(arn_parts)
159+
160+
def _processes_a_service(data):
161+
results = {
162+
"Actions": {},
163+
"Resources": {},
164+
}
165+
166+
for action in data.get("Actions", []):
167+
results["Actions"][action.get("Name").lower()] = {}
168+
if "Resources" in action:
169+
results["Actions"][action.get("Name").lower()] = {
170+
"Resources": list([i["Name"] for i in action["Resources"]])
171+
}
172+
173+
for resource in data.get("Resources", []):
174+
results["Resources"][resource.get("Name").lower()] = {
175+
"ARNFormats": [
176+
_clean_arn_formats(arn) for arn in resource.get("ARNFormats")
177+
],
178+
}
179+
if "ConditionKeys" in resource:
180+
results["Resources"][resource.get("Name").lower()]["ConditionKeys"] = (
181+
resource.get("ConditionKeys")
182+
)
183+
184+
return results
185+
186+
data = {}
187+
for service in services:
188+
name = service.get("service")
189+
content = json.loads(get_url_content(service.get("url")))
190+
data[name] = _processes_a_service(content)
149191

150192
with open(filename, "w", encoding="utf-8") as f:
151-
json.dump(content, f, indent=1, sort_keys=True, separators=(",", ": "))
193+
json.dump(data, f, indent=1, sort_keys=True, separators=(",", ": "))
152194
f.write("\n")

src/cfnlint/rules/resources/iam/Permissions.py

+4-29
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def __init__(self):
2727
super().__init__(
2828
["AWS::IAM::Policy/Properties/PolicyDocument/Statement/Action"]
2929
)
30-
self.service_map = self.load_service_map()
30+
self.service_map = load_resource(AdditionalSpecs, "Policies.json")
3131

3232
def validate(
3333
self, validator: Validator, _, instance: Any, schema: dict[str, Any]
@@ -56,7 +56,7 @@ def validate(
5656
permission = permission.lower()
5757

5858
if service in self.service_map:
59-
enums = self.service_map[service]
59+
enums = list(self.service_map[service].get("Actions", []).keys())
6060
if permission == "*":
6161
pass
6262
elif permission.endswith("*"):
@@ -69,15 +69,12 @@ def validate(
6969

7070
elif permission.startswith("*"):
7171
wilcarded_permission = permission.split("*")[1]
72-
if not any(
73-
wilcarded_permission in action
74-
for action in self.service_map[service]
75-
):
72+
if not any(wilcarded_permission in action for action in enums):
7673
yield ValidationError(
7774
f"{permission!r} is not one of {enums!r}",
7875
rule=self,
7976
)
80-
elif permission not in self.service_map[service]:
77+
elif permission not in enums:
8178
yield ValidationError(
8279
f"{permission!r} is not one of {enums!r}",
8380
rule=self,
@@ -87,25 +84,3 @@ def validate(
8784
f"{service!r} is not one of {list(self.service_map.keys())!r}",
8885
rule=self,
8986
)
90-
91-
def load_service_map(self):
92-
"""
93-
Convert policies.json into a simpler version for more efficient key lookup.
94-
"""
95-
service_map = load_resource(AdditionalSpecs, "Policies.json")["serviceMap"]
96-
97-
policy_service_map = {}
98-
99-
for _, properties in service_map.items():
100-
# The services and actions are case insensitive
101-
service = properties["StringPrefix"].lower()
102-
actions = [x.lower() for x in properties["Actions"]]
103-
104-
# Some services have the same name for different
105-
# generations; like elasticloadbalancing.
106-
if service in policy_service_map:
107-
policy_service_map[service] += actions
108-
else:
109-
policy_service_map[service] = actions
110-
111-
return policy_service_map

src/cfnlint/rules/resources/iam/Policy.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,4 @@ def validate(
7171
)
7272

7373
for err in iam_validator.iter_errors(policy):
74-
if not err.validator.startswith("fn_") and err.validator not in ["cfnLint"]:
75-
err.rule = self
76-
yield err
74+
yield self._clean_error(err)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: MIT-0
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from collections import deque
9+
from typing import Any
10+
11+
from cfnlint.data import AdditionalSpecs
12+
from cfnlint.helpers import ensure_list, is_function, load_resource
13+
from cfnlint.jsonschema import ValidationError, ValidationResult, Validator
14+
from cfnlint.rules.helpers import get_value_from_path
15+
from cfnlint.rules.jsonschema.CfnLintKeyword import CfnLintKeyword
16+
17+
18+
class _Arn:
19+
20+
def __init__(self, full_arn: str):
21+
arn_parts = full_arn.split(":", 5)
22+
23+
self._parts: list[str] = ["*", "*", "*", "*", "*", "*"]
24+
25+
for i, arn_part in enumerate(arn_parts):
26+
self._parts[i] = arn_part
27+
28+
def __repr__(self):
29+
return ":".join(self._parts)
30+
31+
@property
32+
def parts(self):
33+
return self._parts
34+
35+
def __eq__(self, value: Any):
36+
if not isinstance(value, _Arn):
37+
return False
38+
39+
for i in range(0, 5):
40+
if self.parts[i] == "*":
41+
continue
42+
if value.parts[i] in ["${Partition}", "${Region}", "${Account}"]:
43+
continue
44+
if self.parts[i] != value.parts[i]:
45+
return False
46+
47+
if self.parts[5] == "*":
48+
return True
49+
50+
delimiter = None
51+
if ":" in value.parts[5]:
52+
delimiter = ":"
53+
elif "/" in value.parts[5]:
54+
delimiter = "/"
55+
56+
if not delimiter:
57+
return True
58+
59+
for x, y in zip(
60+
self.parts[5].split(delimiter), value.parts[5].split(delimiter)
61+
):
62+
if x == y:
63+
continue
64+
if x == "*" or y == "" or y == ".*":
65+
return True
66+
if x.startswith(y) and "*" in x:
67+
return True
68+
69+
return False
70+
71+
return True
72+
73+
74+
class StatementResources(CfnLintKeyword):
75+
76+
id = "I3510"
77+
shortdesc = "Validate statement resources match the actions"
78+
description = (
79+
"IAM policy statements have different constraints between actions and "
80+
"resources. This rule validates that resource ARNs or asterisks match "
81+
"the actions."
82+
)
83+
source_url = "https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/iam-access-control-overview-cwl.html"
84+
tags = ["resources", "iam"]
85+
86+
def __init__(self):
87+
super().__init__(
88+
["AWS::IAM::Policy/Properties/PolicyDocument/Statement"],
89+
)
90+
self.service_map = load_resource(AdditionalSpecs, "Policies.json")
91+
92+
def validate(
93+
self, validator: Validator, keywords: Any, instance: Any, schema: dict[str, Any]
94+
) -> ValidationResult:
95+
96+
if not validator.is_type(instance, "object"):
97+
return
98+
99+
using_fn_arns = False
100+
all_resources: set[str] = set()
101+
for resources, _ in get_value_from_path(
102+
validator, instance, path=deque(["Resource"])
103+
):
104+
resources = ensure_list(resources)
105+
106+
for resource in resources:
107+
if not isinstance(resource, str):
108+
k, v = is_function(resource)
109+
if k is None:
110+
continue
111+
if k == "Ref" and validator.is_type(v, "string"):
112+
if v in validator.context.parameters:
113+
return
114+
using_fn_arns = True
115+
continue
116+
if resource == "*":
117+
return
118+
all_resources.add(resource)
119+
120+
all_resource_arns = [_Arn(a) for a in all_resources]
121+
for actions, _ in get_value_from_path(
122+
validator, instance, path=deque(["Action"])
123+
):
124+
actions = ensure_list(actions)
125+
126+
for action in actions:
127+
128+
if not validator.is_type(action, "string"):
129+
continue
130+
if "*" in action:
131+
continue
132+
if ":" not in action:
133+
continue
134+
service, permission = action.split(":", 1)
135+
service = service.lower()
136+
permission = permission.lower()
137+
138+
if service not in self.service_map:
139+
continue
140+
141+
if permission not in self.service_map[service]["Actions"]:
142+
continue
143+
144+
resources = self.service_map[service]["Actions"][permission].get(
145+
"Resources", None
146+
)
147+
if isinstance(resources, (list, str)):
148+
if using_fn_arns:
149+
continue
150+
resources = ensure_list(resources)
151+
for resource in resources:
152+
arn_formats = self.service_map[service]["Resources"][
153+
resource
154+
].get("ARNFormats")
155+
for arn_format in arn_formats:
156+
arn = _Arn(arn_format)
157+
if arn not in all_resource_arns:
158+
yield ValidationError(
159+
(
160+
f"action {action!r} requires "
161+
f"a resource of {arn_formats!r}"
162+
),
163+
path=deque(["Resource"]),
164+
rule=self,
165+
)
166+
else:
167+
yield ValidationError(
168+
f"action {action!r} requires a resource of '*'",
169+
path=deque(["Resource"]),
170+
rule=self,
171+
)

test/fixtures/results/quickstart/nist_application.json

+6-6
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@
8989
},
9090
{
9191
"Filename": "test/fixtures/templates/quickstart/nist_application.yaml",
92-
"Id": "81670f17-4f0b-a2b6-f94e-4385058cb73d",
93-
"Level": "Error",
92+
"Id": "e7287f1c-73b2-cb51-ec2b-8ab42c30ca27",
93+
"Level": "Warning",
9494
"Location": {
9595
"End": {
9696
"ColumnNumber": 12,
@@ -109,10 +109,10 @@
109109
"Message": "{'Ref': 'pSecurityAlarmTopic'} does not match '(^arn:(aws|aws-cn|aws-us-gov):[^:]+:[^:]*(:(?:\\\\d{12}|\\\\*|aws)?:.+|)|\\\\*)$' when 'Ref' is resolved",
110110
"ParentId": null,
111111
"Rule": {
112-
"Description": "IAM identity polices are embedded JSON in CloudFormation. This rule validates those embedded policies.",
113-
"Id": "E3510",
114-
"ShortDescription": "Validate identity based IAM polices",
115-
"Source": "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_identity-vs-resource.html"
112+
"Description": "Resolve the Ref and then validate the values against the schema",
113+
"Id": "W1030",
114+
"ShortDescription": "Validate the values that come from a Ref function",
115+
"Source": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ref.html"
116116
}
117117
},
118118
{

test/fixtures/results/quickstart/nist_iam.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
},
5858
{
5959
"Filename": "test/fixtures/templates/quickstart/nist_iam.yaml",
60-
"Id": "2902b820-10fb-7008-c4f2-b74be7c678f6",
60+
"Id": "4c9089fd-fdc9-d2fc-b4ad-6c0aacb89a01",
6161
"Level": "Warning",
6262
"Location": {
6363
"End": {
@@ -78,7 +78,7 @@
7878
"LineNumber": 165
7979
}
8080
},
81-
"Message": "'get*' is not one of ['associateappblockbuilderappblock', 'associateapplicationfleet', 'associateapplicationtoentitlement', 'associatefleet', 'batchassociateuserstack', 'batchdisassociateuserstack', 'copyimage', 'createappblock', 'createappblockbuilder', 'createappblockbuilderstreamingurl', 'createapplication', 'createdirectoryconfig', 'createentitlement', 'createfleet', 'createimagebuilder', 'createimagebuilderstreamingurl', 'createstack', 'createstreamingurl', 'createthemeforstack', 'createupdatedimage', 'createusagereportsubscription', 'createuser', 'deleteappblock', 'deleteappblockbuilder', 'deleteapplication', 'deletedirectoryconfig', 'deleteentitlement', 'deletefleet', 'deleteimage', 'deleteimagebuilder', 'deleteimagepermissions', 'deletestack', 'deletethemeforstack', 'deleteusagereportsubscription', 'deleteuser', 'describeappblockbuilderappblockassociations', 'describeappblockbuilders', 'describeappblocks', 'describeapplicationfleetassociations', 'describeapplications', 'describedirectoryconfigs', 'describeentitlements', 'describefleets', 'describeimagebuilders', 'describeimagepermissions', 'describeimages', 'describesessions', 'describestacks', 'describethemeforstack', 'describeusagereportsubscriptions', 'describeuserstackassociations', 'describeusers', 'disableuser', 'disassociateappblockbuilderappblock', 'disassociateapplicationfleet', 'disassociateapplicationfromentitlement', 'disassociatefleet', 'enableuser', 'expiresession', 'listassociatedfleets', 'listassociatedstacks', 'listentitledapplications', 'listtagsforresource', 'startappblockbuilder', 'startfleet', 'startimagebuilder', 'stopappblockbuilder', 'stopfleet', 'stopimagebuilder', 'stream', 'tagresource', 'untagresource', 'updateappblockbuilder', 'updateapplication', 'updatedirectoryconfig', 'updateentitlement', 'updatefleet', 'updateimagepermissions', 'updatestack', 'updatethemeforstack']",
81+
"Message": "'get*' is not one of ['associateappblockbuilderappblock', 'associateapplicationfleet', 'associateapplicationtoentitlement', 'associatefleet', 'batchassociateuserstack', 'batchdisassociateuserstack', 'copyimage', 'createappblock', 'createappblockbuilder', 'createappblockbuilderstreamingurl', 'createapplication', 'createdirectoryconfig', 'createentitlement', 'createfleet', 'createimagebuilder', 'createimagebuilderstreamingurl', 'createstack', 'createstreamingurl', 'createthemeforstack', 'createupdatedimage', 'createusagereportsubscription', 'createuser', 'deleteappblock', 'deleteappblockbuilder', 'deleteapplication', 'deletedirectoryconfig', 'deleteentitlement', 'deletefleet', 'deleteimage', 'deleteimagebuilder', 'deleteimagepermissions', 'deletestack', 'deletethemeforstack', 'deleteusagereportsubscription', 'deleteuser', 'describeappblockbuilderappblockassociations', 'describeappblockbuilders', 'describeappblocks', 'describeapplicationfleetassociations', 'describeapplications', 'describedirectoryconfigs', 'describeentitlements', 'describefleets', 'describeimagebuilders', 'describeimagepermissions', 'describeimages', 'describesessions', 'describestacks', 'describethemeforstack', 'describeusagereportsubscriptions', 'describeusers', 'describeuserstackassociations', 'disableuser', 'disassociateappblockbuilderappblock', 'disassociateapplicationfleet', 'disassociateapplicationfromentitlement', 'disassociatefleet', 'enableuser', 'expiresession', 'listassociatedfleets', 'listassociatedstacks', 'listentitledapplications', 'listtagsforresource', 'startappblockbuilder', 'startfleet', 'startimagebuilder', 'stopappblockbuilder', 'stopfleet', 'stopimagebuilder', 'stream', 'tagresource', 'untagresource', 'updateappblockbuilder', 'updateapplication', 'updatedirectoryconfig', 'updateentitlement', 'updatefleet', 'updateimagepermissions', 'updatestack', 'updatethemeforstack']",
8282
"ParentId": null,
8383
"Rule": {
8484
"Description": "Check for valid IAM Permissions",

0 commit comments

Comments
 (0)