lambda-preflight 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lambda_preflight/__init__.py +58 -0
- lambda_preflight/events/__init__.py +21 -0
- lambda_preflight/events/builders.py +287 -0
- lambda_preflight/iam/__init__.py +32 -0
- lambda_preflight/iam/conditions.py +131 -0
- lambda_preflight/iam/evaluator.py +177 -0
- lambda_preflight/iam/intercept.py +101 -0
- lambda_preflight/iam/model.py +90 -0
- lambda_preflight/iam/puller.py +190 -0
- lambda_preflight/runtime/__init__.py +13 -0
- lambda_preflight/runtime/context.py +75 -0
- lambda_preflight/runtime/errors.py +34 -0
- lambda_preflight/runtime/harness.py +167 -0
- lambda_preflight/runtime/loader.py +94 -0
- lambda_preflight/testing/__init__.py +3 -0
- lambda_preflight/testing/fixtures.py +43 -0
- lambda_preflight-0.1.0.dist-info/METADATA +143 -0
- lambda_preflight-0.1.0.dist-info/RECORD +21 -0
- lambda_preflight-0.1.0.dist-info/WHEEL +4 -0
- lambda_preflight-0.1.0.dist-info/entry_points.txt +2 -0
- lambda_preflight-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""lambda_preflight — catch AWS Lambda IAM and event-shape bugs before you deploy.
|
|
2
|
+
|
|
3
|
+
The headline feature is a local **IAM linter**: it evaluates the AWS calls your
|
|
4
|
+
handler makes against your execution role's *real, pulled* policies and raises
|
|
5
|
+
AccessDenied locally — catching the permission mistakes you'd otherwise only
|
|
6
|
+
discover after deploying. Nothing else in the ecosystem does this.
|
|
7
|
+
|
|
8
|
+
Around it sits an AWS-faithful local runtime so the linter has something real to
|
|
9
|
+
run against: schema-accurate event fixtures, a faithful context object, timeout
|
|
10
|
+
enforcement, cold/warm semantics, and AWS-shape error envelopes. No Docker, no
|
|
11
|
+
SAM CLI, no cloud invocations (no invocation charges).
|
|
12
|
+
|
|
13
|
+
Honest scope: this eliminates the shape/contract/runtime-semantics class of
|
|
14
|
+
"works locally, breaks in AWS" failures. It does not and cannot reproduce
|
|
15
|
+
cold-start latency, live concurrency, SCPs, resource-based policies, or
|
|
16
|
+
account quotas — those exist only in the cloud. The IAM layer is a fast
|
|
17
|
+
pre-flight linter, not a parity guarantee, and fails loud (strict mode) on
|
|
18
|
+
anything it cannot faithfully evaluate.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from . import events
|
|
22
|
+
from .iam import (
|
|
23
|
+
AccessDenied,
|
|
24
|
+
CannotEvaluate,
|
|
25
|
+
PolicyEvaluator,
|
|
26
|
+
PolicySet,
|
|
27
|
+
PullError,
|
|
28
|
+
intercept_boto3,
|
|
29
|
+
load_role_policies,
|
|
30
|
+
pull_role,
|
|
31
|
+
)
|
|
32
|
+
from .runtime import (
|
|
33
|
+
HandlerLoadError,
|
|
34
|
+
InvocationResult,
|
|
35
|
+
LambdaContext,
|
|
36
|
+
LambdaHarness,
|
|
37
|
+
LambdaTimeout,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"LambdaHarness",
|
|
44
|
+
"InvocationResult",
|
|
45
|
+
"LambdaContext",
|
|
46
|
+
"LambdaTimeout",
|
|
47
|
+
"HandlerLoadError",
|
|
48
|
+
"events",
|
|
49
|
+
"PolicyEvaluator",
|
|
50
|
+
"PolicySet",
|
|
51
|
+
"pull_role",
|
|
52
|
+
"load_role_policies",
|
|
53
|
+
"intercept_boto3",
|
|
54
|
+
"AccessDenied",
|
|
55
|
+
"CannotEvaluate",
|
|
56
|
+
"PullError",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Schema-accurate event builders for common Lambda triggers."""
|
|
2
|
+
|
|
3
|
+
from .builders import (
|
|
4
|
+
api_gateway_v1,
|
|
5
|
+
api_gateway_v2,
|
|
6
|
+
dynamodb_stream,
|
|
7
|
+
eventbridge,
|
|
8
|
+
s3,
|
|
9
|
+
sns,
|
|
10
|
+
sqs,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"api_gateway_v1",
|
|
15
|
+
"api_gateway_v2",
|
|
16
|
+
"sqs",
|
|
17
|
+
"sns",
|
|
18
|
+
"s3",
|
|
19
|
+
"eventbridge",
|
|
20
|
+
"dynamodb_stream",
|
|
21
|
+
]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Schema-accurate event builders.
|
|
2
|
+
|
|
3
|
+
The most common cause of "worked locally, broke in AWS" is an event whose
|
|
4
|
+
*shape* differs from what the trigger actually delivers. These builders emit
|
|
5
|
+
payloads matching AWS's documented schemas so a handler exercised locally
|
|
6
|
+
receives the same structure it will in production.
|
|
7
|
+
|
|
8
|
+
Notably, API Gateway REST (v1) and HTTP (v2) differ meaningfully, so they are
|
|
9
|
+
separate builders rather than a single parameterised one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _now_ms() -> int:
|
|
23
|
+
return int(time.time() * 1000)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _epoch() -> int:
|
|
27
|
+
return int(time.time())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def api_gateway_v1(
|
|
31
|
+
method: str = "GET",
|
|
32
|
+
path: str = "/",
|
|
33
|
+
body: Any = None,
|
|
34
|
+
headers: Optional[Dict[str, str]] = None,
|
|
35
|
+
query: Optional[Dict[str, str]] = None,
|
|
36
|
+
path_parameters: Optional[Dict[str, str]] = None,
|
|
37
|
+
stage: str = "prod",
|
|
38
|
+
is_base64: bool = False,
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
"""API Gateway REST API (payload format 1.0) proxy event."""
|
|
41
|
+
headers = headers or {"Content-Type": "application/json"}
|
|
42
|
+
raw_body = _serialize_body(body)
|
|
43
|
+
return {
|
|
44
|
+
"resource": path,
|
|
45
|
+
"path": path,
|
|
46
|
+
"httpMethod": method.upper(),
|
|
47
|
+
"headers": headers,
|
|
48
|
+
"multiValueHeaders": {k: [v] for k, v in headers.items()},
|
|
49
|
+
"queryStringParameters": query,
|
|
50
|
+
"multiValueQueryStringParameters": (
|
|
51
|
+
{k: [v] for k, v in query.items()} if query else None
|
|
52
|
+
),
|
|
53
|
+
"pathParameters": path_parameters,
|
|
54
|
+
"stageVariables": None,
|
|
55
|
+
"requestContext": {
|
|
56
|
+
"resourceId": uuid.uuid4().hex[:6],
|
|
57
|
+
"resourcePath": path,
|
|
58
|
+
"httpMethod": method.upper(),
|
|
59
|
+
"path": f"/{stage}{path}",
|
|
60
|
+
"accountId": "000000000000",
|
|
61
|
+
"protocol": "HTTP/1.1",
|
|
62
|
+
"stage": stage,
|
|
63
|
+
"requestId": str(uuid.uuid4()),
|
|
64
|
+
"requestTimeEpoch": _now_ms(),
|
|
65
|
+
"identity": {"sourceIp": "127.0.0.1", "userAgent": "lambda_preflight"},
|
|
66
|
+
"apiId": uuid.uuid4().hex[:10],
|
|
67
|
+
},
|
|
68
|
+
"body": raw_body,
|
|
69
|
+
"isBase64Encoded": is_base64,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def api_gateway_v2(
|
|
74
|
+
method: str = "GET",
|
|
75
|
+
path: str = "/",
|
|
76
|
+
body: Any = None,
|
|
77
|
+
headers: Optional[Dict[str, str]] = None,
|
|
78
|
+
query: Optional[Dict[str, str]] = None,
|
|
79
|
+
path_parameters: Optional[Dict[str, str]] = None,
|
|
80
|
+
stage: str = "$default",
|
|
81
|
+
is_base64: bool = False,
|
|
82
|
+
) -> Dict[str, Any]:
|
|
83
|
+
"""API Gateway HTTP API (payload format 2.0) event.
|
|
84
|
+
|
|
85
|
+
Differs from v1: single ``rawPath``/``rawQueryString``, a ``routeKey``,
|
|
86
|
+
``cookies`` split out, and a flattened ``http`` block in requestContext.
|
|
87
|
+
"""
|
|
88
|
+
headers = headers or {"content-type": "application/json"}
|
|
89
|
+
raw_body = _serialize_body(body)
|
|
90
|
+
raw_query = "&".join(f"{k}={v}" for k, v in (query or {}).items())
|
|
91
|
+
return {
|
|
92
|
+
"version": "2.0",
|
|
93
|
+
"routeKey": f"{method.upper()} {path}",
|
|
94
|
+
"rawPath": path,
|
|
95
|
+
"rawQueryString": raw_query,
|
|
96
|
+
"cookies": [],
|
|
97
|
+
"headers": headers,
|
|
98
|
+
"queryStringParameters": query,
|
|
99
|
+
"pathParameters": path_parameters,
|
|
100
|
+
"requestContext": {
|
|
101
|
+
"accountId": "000000000000",
|
|
102
|
+
"apiId": uuid.uuid4().hex[:10],
|
|
103
|
+
"domainName": "local.execute-api.us-east-1.amazonaws.com",
|
|
104
|
+
"http": {
|
|
105
|
+
"method": method.upper(),
|
|
106
|
+
"path": path,
|
|
107
|
+
"protocol": "HTTP/1.1",
|
|
108
|
+
"sourceIp": "127.0.0.1",
|
|
109
|
+
"userAgent": "lambda_preflight",
|
|
110
|
+
},
|
|
111
|
+
"requestId": str(uuid.uuid4()),
|
|
112
|
+
"routeKey": f"{method.upper()} {path}",
|
|
113
|
+
"stage": stage,
|
|
114
|
+
"time": time.strftime("%d/%b/%Y:%H:%M:%S +0000"),
|
|
115
|
+
"timeEpoch": _now_ms(),
|
|
116
|
+
},
|
|
117
|
+
"body": raw_body,
|
|
118
|
+
"isBase64Encoded": is_base64,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sqs(
|
|
123
|
+
records: Optional[List[Any]] = None,
|
|
124
|
+
body: Any = None,
|
|
125
|
+
queue_name: str = "local-queue",
|
|
126
|
+
region: str = "us-east-1",
|
|
127
|
+
message_attributes: Optional[Dict[str, Any]] = None,
|
|
128
|
+
) -> Dict[str, Any]:
|
|
129
|
+
"""SQS event. Pass ``records`` for a batch, or ``body`` for one message."""
|
|
130
|
+
if records is None:
|
|
131
|
+
records = [body if body is not None else {}]
|
|
132
|
+
|
|
133
|
+
arn = f"arn:aws:sqs:{region}:000000000000:{queue_name}"
|
|
134
|
+
built = []
|
|
135
|
+
for item in records:
|
|
136
|
+
raw = _serialize_body(item)
|
|
137
|
+
built.append(
|
|
138
|
+
{
|
|
139
|
+
"messageId": str(uuid.uuid4()),
|
|
140
|
+
"receiptHandle": base64.b64encode(uuid.uuid4().bytes).decode(),
|
|
141
|
+
"body": raw,
|
|
142
|
+
"attributes": {
|
|
143
|
+
"ApproximateReceiveCount": "1",
|
|
144
|
+
"SentTimestamp": str(_now_ms()),
|
|
145
|
+
"SenderId": "AIDAIENQZJOLO23YVJ4VO",
|
|
146
|
+
"ApproximateFirstReceiveTimestamp": str(_now_ms()),
|
|
147
|
+
},
|
|
148
|
+
"messageAttributes": message_attributes or {},
|
|
149
|
+
"md5OfBody": hashlib.md5(raw.encode()).hexdigest(),
|
|
150
|
+
"eventSource": "aws:sqs",
|
|
151
|
+
"eventSourceARN": arn,
|
|
152
|
+
"awsRegion": region,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
return {"Records": built}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def sns(
|
|
159
|
+
message: Any = None,
|
|
160
|
+
subject: Optional[str] = None,
|
|
161
|
+
topic_name: str = "local-topic",
|
|
162
|
+
region: str = "us-east-1",
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""SNS event (single record, as SNS delivers)."""
|
|
165
|
+
arn = f"arn:aws:sns:{region}:000000000000:{topic_name}"
|
|
166
|
+
raw = _serialize_body(message)
|
|
167
|
+
return {
|
|
168
|
+
"Records": [
|
|
169
|
+
{
|
|
170
|
+
"EventSource": "aws:sns",
|
|
171
|
+
"EventVersion": "1.0",
|
|
172
|
+
"EventSubscriptionArn": f"{arn}:{uuid.uuid4()}",
|
|
173
|
+
"Sns": {
|
|
174
|
+
"Type": "Notification",
|
|
175
|
+
"MessageId": str(uuid.uuid4()),
|
|
176
|
+
"TopicArn": arn,
|
|
177
|
+
"Subject": subject,
|
|
178
|
+
"Message": raw,
|
|
179
|
+
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
|
180
|
+
"SignatureVersion": "1",
|
|
181
|
+
"MessageAttributes": {},
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def s3(
|
|
189
|
+
bucket: str = "local-bucket",
|
|
190
|
+
key: str = "object.txt",
|
|
191
|
+
event_name: str = "ObjectCreated:Put",
|
|
192
|
+
size: int = 1024,
|
|
193
|
+
region: str = "us-east-1",
|
|
194
|
+
) -> Dict[str, Any]:
|
|
195
|
+
"""S3 notification event (single record)."""
|
|
196
|
+
return {
|
|
197
|
+
"Records": [
|
|
198
|
+
{
|
|
199
|
+
"eventVersion": "2.1",
|
|
200
|
+
"eventSource": "aws:s3",
|
|
201
|
+
"awsRegion": region,
|
|
202
|
+
"eventTime": time.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
|
203
|
+
"eventName": event_name,
|
|
204
|
+
"s3": {
|
|
205
|
+
"s3SchemaVersion": "1.0",
|
|
206
|
+
"configurationId": "local-config",
|
|
207
|
+
"bucket": {
|
|
208
|
+
"name": bucket,
|
|
209
|
+
"arn": f"arn:aws:s3:::{bucket}",
|
|
210
|
+
},
|
|
211
|
+
"object": {
|
|
212
|
+
"key": key,
|
|
213
|
+
"size": size,
|
|
214
|
+
"eTag": uuid.uuid4().hex,
|
|
215
|
+
"sequencer": uuid.uuid4().hex[:16].upper(),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def eventbridge(
|
|
224
|
+
detail: Optional[Dict[str, Any]] = None,
|
|
225
|
+
detail_type: str = "local.event",
|
|
226
|
+
source: str = "lambda_preflight",
|
|
227
|
+
region: str = "us-east-1",
|
|
228
|
+
) -> Dict[str, Any]:
|
|
229
|
+
"""EventBridge (CloudWatch Events) event."""
|
|
230
|
+
return {
|
|
231
|
+
"version": "0",
|
|
232
|
+
"id": str(uuid.uuid4()),
|
|
233
|
+
"detail-type": detail_type,
|
|
234
|
+
"source": source,
|
|
235
|
+
"account": "000000000000",
|
|
236
|
+
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
237
|
+
"region": region,
|
|
238
|
+
"resources": [],
|
|
239
|
+
"detail": detail or {},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def dynamodb_stream(
|
|
244
|
+
keys: Optional[Dict[str, Any]] = None,
|
|
245
|
+
new_image: Optional[Dict[str, Any]] = None,
|
|
246
|
+
old_image: Optional[Dict[str, Any]] = None,
|
|
247
|
+
event_name: str = "INSERT",
|
|
248
|
+
table_name: str = "local-table",
|
|
249
|
+
region: str = "us-east-1",
|
|
250
|
+
) -> Dict[str, Any]:
|
|
251
|
+
"""DynamoDB Streams event (single record, values in DDB JSON form)."""
|
|
252
|
+
arn = (
|
|
253
|
+
f"arn:aws:dynamodb:{region}:000000000000:table/{table_name}"
|
|
254
|
+
f"/stream/{time.strftime('%Y-%m-%dT%H:%M:%S.000')}"
|
|
255
|
+
)
|
|
256
|
+
record_body: Dict[str, Any] = {
|
|
257
|
+
"Keys": keys or {},
|
|
258
|
+
"SequenceNumber": str(_now_ms()),
|
|
259
|
+
"SizeBytes": 64,
|
|
260
|
+
"StreamViewType": "NEW_AND_OLD_IMAGES",
|
|
261
|
+
}
|
|
262
|
+
if new_image is not None:
|
|
263
|
+
record_body["NewImage"] = new_image
|
|
264
|
+
if old_image is not None:
|
|
265
|
+
record_body["OldImage"] = old_image
|
|
266
|
+
return {
|
|
267
|
+
"Records": [
|
|
268
|
+
{
|
|
269
|
+
"eventID": uuid.uuid4().hex,
|
|
270
|
+
"eventName": event_name,
|
|
271
|
+
"eventVersion": "1.1",
|
|
272
|
+
"eventSource": "aws:dynamodb",
|
|
273
|
+
"awsRegion": region,
|
|
274
|
+
"dynamodb": record_body,
|
|
275
|
+
"eventSourceARN": arn,
|
|
276
|
+
}
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _serialize_body(body: Any) -> Optional[str]:
|
|
282
|
+
"""Match AWS: bodies arrive as strings. dict/list -> JSON, None -> None."""
|
|
283
|
+
if body is None:
|
|
284
|
+
return None
|
|
285
|
+
if isinstance(body, str):
|
|
286
|
+
return body
|
|
287
|
+
return json.dumps(body)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Local IAM evaluation: pull real policies, evaluate calls, fail loud.
|
|
2
|
+
|
|
3
|
+
Scope: identity policies (managed + inline) and permission boundary, with the
|
|
4
|
+
documented deny/allow/implicit-deny flow and common condition operators. SCPs
|
|
5
|
+
and resource-based policies are out of scope and recorded as such — this is a
|
|
6
|
+
fast pre-flight linter, not an IAM parity guarantee.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .evaluator import (
|
|
10
|
+
AccessDenied,
|
|
11
|
+
CannotEvaluate,
|
|
12
|
+
Decision,
|
|
13
|
+
PolicyEvaluator,
|
|
14
|
+
)
|
|
15
|
+
from .intercept import BotoIntercept, intercept_boto3, operation_to_action
|
|
16
|
+
from .model import PolicySet, Statement
|
|
17
|
+
from .puller import PullError, load_role_policies, pull_role
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"PolicyEvaluator",
|
|
21
|
+
"Decision",
|
|
22
|
+
"AccessDenied",
|
|
23
|
+
"CannotEvaluate",
|
|
24
|
+
"PolicySet",
|
|
25
|
+
"Statement",
|
|
26
|
+
"pull_role",
|
|
27
|
+
"load_role_policies",
|
|
28
|
+
"PullError",
|
|
29
|
+
"intercept_boto3",
|
|
30
|
+
"BotoIntercept",
|
|
31
|
+
"operation_to_action",
|
|
32
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Condition-operator evaluation and wildcard matching.
|
|
2
|
+
|
|
3
|
+
IAM's real bite lives in ``Condition`` blocks and wildcard action/resource
|
|
4
|
+
matching. This module implements the commonly used operators. Operators that
|
|
5
|
+
are *not* implemented are reported (not silently treated as true) so strict
|
|
6
|
+
mode can fail loudly rather than produce a false allow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import ipaddress
|
|
13
|
+
from typing import Any, Dict, List, Tuple
|
|
14
|
+
|
|
15
|
+
# Operators this module can evaluate. Anything outside this set is surfaced
|
|
16
|
+
# as "unevaluable" rather than assumed to pass.
|
|
17
|
+
SUPPORTED_OPERATORS = {
|
|
18
|
+
"StringEquals",
|
|
19
|
+
"StringNotEquals",
|
|
20
|
+
"StringLike",
|
|
21
|
+
"StringNotLike",
|
|
22
|
+
"Bool",
|
|
23
|
+
"NumericEquals",
|
|
24
|
+
"NumericLessThan",
|
|
25
|
+
"NumericGreaterThan",
|
|
26
|
+
"IpAddress",
|
|
27
|
+
"NotIpAddress",
|
|
28
|
+
"ArnLike",
|
|
29
|
+
"ArnEquals",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wildcard_match(pattern: str, value: str) -> bool:
|
|
34
|
+
"""IAM ``*``/``?`` wildcard match (case-insensitive for actions)."""
|
|
35
|
+
return fnmatch.fnmatchcase(value, pattern)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def action_matches(pattern: str, action: str) -> bool:
|
|
39
|
+
"""Match an action against a policy action pattern, case-insensitively."""
|
|
40
|
+
return fnmatch.fnmatchcase(action.lower(), pattern.lower())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resource_matches(pattern: str, resource: str) -> bool:
|
|
44
|
+
"""Match a resource ARN against a policy resource pattern."""
|
|
45
|
+
if pattern == "*":
|
|
46
|
+
return True
|
|
47
|
+
return fnmatch.fnmatchcase(resource, pattern)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _coerce_list(value: Any) -> List[str]:
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
return [str(v) for v in value]
|
|
53
|
+
return [str(value)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def evaluate_condition(
|
|
57
|
+
condition: Dict[str, Dict[str, Any]], context: Dict[str, Any]
|
|
58
|
+
) -> Tuple[bool, List[str]]:
|
|
59
|
+
"""Evaluate a statement's Condition block against a request context.
|
|
60
|
+
|
|
61
|
+
Returns ``(passed, unevaluable_operators)``. A condition with no operators
|
|
62
|
+
passes trivially. Any operator not in SUPPORTED_OPERATORS is added to the
|
|
63
|
+
unevaluable list so the caller (strict mode) can decide how to treat it.
|
|
64
|
+
"""
|
|
65
|
+
unevaluable: List[str] = []
|
|
66
|
+
if not condition:
|
|
67
|
+
return True, unevaluable
|
|
68
|
+
|
|
69
|
+
for operator, mapping in condition.items():
|
|
70
|
+
base_op = operator.replace("IfExists", "")
|
|
71
|
+
if base_op not in SUPPORTED_OPERATORS:
|
|
72
|
+
unevaluable.append(operator)
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
for key, expected in mapping.items():
|
|
76
|
+
actual = context.get(key)
|
|
77
|
+
if actual is None:
|
|
78
|
+
# Key absent from context. For *IfExists, treat as pass;
|
|
79
|
+
# otherwise the condition cannot be satisfied.
|
|
80
|
+
if operator.endswith("IfExists"):
|
|
81
|
+
continue
|
|
82
|
+
return False, unevaluable
|
|
83
|
+
|
|
84
|
+
if not _apply_operator(base_op, actual, _coerce_list(expected)):
|
|
85
|
+
return False, unevaluable
|
|
86
|
+
|
|
87
|
+
return True, unevaluable
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _apply_operator(op: str, actual: Any, expected: List[str]) -> bool:
|
|
91
|
+
actual_str = str(actual)
|
|
92
|
+
|
|
93
|
+
if op == "StringEquals":
|
|
94
|
+
return actual_str in expected
|
|
95
|
+
if op == "StringNotEquals":
|
|
96
|
+
return actual_str not in expected
|
|
97
|
+
if op == "StringLike":
|
|
98
|
+
return any(wildcard_match(p, actual_str) for p in expected)
|
|
99
|
+
if op == "StringNotLike":
|
|
100
|
+
return not any(wildcard_match(p, actual_str) for p in expected)
|
|
101
|
+
if op == "Bool":
|
|
102
|
+
return any(
|
|
103
|
+
str(actual).lower() == e.lower() for e in expected
|
|
104
|
+
)
|
|
105
|
+
if op == "NumericEquals":
|
|
106
|
+
return any(float(actual) == float(e) for e in expected)
|
|
107
|
+
if op == "NumericLessThan":
|
|
108
|
+
return all(float(actual) < float(e) for e in expected)
|
|
109
|
+
if op == "NumericGreaterThan":
|
|
110
|
+
return all(float(actual) > float(e) for e in expected)
|
|
111
|
+
if op in ("IpAddress", "NotIpAddress"):
|
|
112
|
+
inside = _ip_in_any(actual_str, expected)
|
|
113
|
+
return inside if op == "IpAddress" else not inside
|
|
114
|
+
if op in ("ArnLike", "ArnEquals"):
|
|
115
|
+
return any(wildcard_match(p, actual_str) for p in expected)
|
|
116
|
+
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _ip_in_any(addr: str, cidrs: List[str]) -> bool:
|
|
121
|
+
try:
|
|
122
|
+
ip = ipaddress.ip_address(addr)
|
|
123
|
+
except ValueError:
|
|
124
|
+
return False
|
|
125
|
+
for cidr in cidrs:
|
|
126
|
+
try:
|
|
127
|
+
if ip in ipaddress.ip_network(cidr, strict=False):
|
|
128
|
+
return True
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue
|
|
131
|
+
return False
|