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.
@@ -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