lambda-preflight 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.1/.github/workflows/ci.yml +27 -0
- lambda_preflight-0.1.1/.gitignore +11 -0
- lambda_preflight-0.1.1/CHANGELOG.md +34 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/PKG-INFO +3 -3
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/pyproject.toml +4 -3
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/__init__.py +1 -1
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/harness.py +11 -3
- lambda_preflight-0.1.1/tests/conftest.py +6 -0
- lambda_preflight-0.1.1/tests/handlers.py +93 -0
- lambda_preflight-0.1.1/tests/test_adversarial.py +137 -0
- lambda_preflight-0.1.1/tests/test_events_exhaustive.py +165 -0
- lambda_preflight-0.1.1/tests/test_iam_exhaustive.py +232 -0
- lambda_preflight-0.1.1/tests/test_intercept_exhaustive.py +99 -0
- lambda_preflight-0.1.1/tests/test_puller_exhaustive.py +63 -0
- lambda_preflight-0.1.1/tests/test_runtime_exhaustive.py +187 -0
- lambda_preflight-0.1.0/tests/sample_handlers.py +0 -34
- lambda_preflight-0.1.0/tests/test_events.py +0 -85
- lambda_preflight-0.1.0/tests/test_iam.py +0 -138
- lambda_preflight-0.1.0/tests/test_runtime.py +0 -68
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/LICENSE +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/README.md +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/events/__init__.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/events/builders.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/__init__.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/conditions.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/evaluator.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/intercept.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/model.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/puller.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/__init__.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/context.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/errors.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/loader.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/testing/__init__.py +0 -0
- {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/testing/fixtures.py +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
- name: Install
|
|
23
|
+
run: |
|
|
24
|
+
python -m pip install --upgrade pip
|
|
25
|
+
pip install -e ".[dev]"
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: pytest tests/ -v
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/).
|
|
5
|
+
|
|
6
|
+
## [0.1.1] - 2026-07-21
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- Handler-load failures (bad handler string, missing module, missing function)
|
|
10
|
+
are now returned as an error `InvocationResult` with `ok=False` and
|
|
11
|
+
`errorType="HandlerLoadError"`, instead of raising out of `invoke()`. This
|
|
12
|
+
gives callers a single consistent `result.ok` contract for both load-time and
|
|
13
|
+
runtime failures, mirroring how AWS surfaces initialization errors. A handler
|
|
14
|
+
that fails to load no longer marks the harness as "warm".
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Comprehensive test suite (128 tests) covering the runtime shim, event
|
|
18
|
+
builders, IAM evaluator, boto3 intercept, policy puller/cache, and a set of
|
|
19
|
+
adversarial cases that document known fidelity limits (`NotAction`
|
|
20
|
+
under-grant, ARN wildcard matching, resource-based-policy omission).
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-07-21
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Initial release.
|
|
26
|
+
- In-process Lambda runtime shim: faithful `context`, timeout enforcement,
|
|
27
|
+
cold/warm semantics, AWS-shape error envelopes, environment injection.
|
|
28
|
+
- Schema-accurate event builders: API Gateway v1 & v2, SQS, SNS, S3,
|
|
29
|
+
EventBridge, DynamoDB Streams.
|
|
30
|
+
- Local IAM linter: policy evaluator (explicit-deny / allow / implicit-deny,
|
|
31
|
+
permission-boundary intersection), common condition operators, `pull_role`
|
|
32
|
+
fan-out with disk cache, and a boto3 intercept hook. Strict mode raises
|
|
33
|
+
`CannotEvaluate` on constructs it cannot faithfully assess.
|
|
34
|
+
- pytest fixtures, auto-registered via entry point.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-preflight
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Catch AWS Lambda IAM permission errors and event-shape bugs before you deploy — a local pre-flight linter and AWS-faithful runtime. No Docker, no SAM, no invocation charges.
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourname/lambda-preflight
|
|
6
|
+
Project-URL: Issues, https://github.com/yourname/lambda-preflight/issues
|
|
7
7
|
Author: Swar Borgaonkar
|
|
8
8
|
License: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lambda-preflight"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Catch AWS Lambda IAM permission errors and event-shape bugs before you deploy — a local pre-flight linter and AWS-faithful runtime. No Docker, no SAM, no invocation charges."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -42,8 +42,8 @@ aws = ["boto3>=1.26"]
|
|
|
42
42
|
dev = ["pytest>=7.0", "boto3>=1.26", "build", "twine"]
|
|
43
43
|
|
|
44
44
|
[project.urls]
|
|
45
|
-
Homepage = "https://github.com/
|
|
46
|
-
Issues = "https://github.com/
|
|
45
|
+
Homepage = "https://github.com/yourname/lambda-preflight"
|
|
46
|
+
Issues = "https://github.com/yourname/lambda-preflight/issues"
|
|
47
47
|
|
|
48
48
|
[project.entry-points.pytest11]
|
|
49
49
|
lambda_preflight = "lambda_preflight.testing.fixtures"
|
|
@@ -53,3 +53,4 @@ packages = ["src/lambda_preflight"]
|
|
|
53
53
|
|
|
54
54
|
[tool.pytest.ini_options]
|
|
55
55
|
testpaths = ["tests"]
|
|
56
|
+
pythonpath = ["tests"]
|
|
@@ -120,10 +120,14 @@ class LambdaHarness:
|
|
|
120
120
|
def invoke(
|
|
121
121
|
self, event: Any, client_context: Any = None
|
|
122
122
|
) -> InvocationResult:
|
|
123
|
-
"""Invoke the handler with ``event`` and an AWS-faithful context.
|
|
123
|
+
"""Invoke the handler with ``event`` and an AWS-faithful context.
|
|
124
|
+
|
|
125
|
+
Handler-resolution failures (bad handler string, missing module or
|
|
126
|
+
function) are returned as an error ``InvocationResult`` — the same
|
|
127
|
+
channel as runtime errors — mirroring how AWS surfaces init failures,
|
|
128
|
+
so callers can rely on a single ``result.ok`` contract.
|
|
129
|
+
"""
|
|
124
130
|
is_cold = not self._has_run
|
|
125
|
-
handler = self._ensure_handler()
|
|
126
|
-
self._has_run = True
|
|
127
131
|
|
|
128
132
|
context = LambdaContext(
|
|
129
133
|
function_name=self.function_name,
|
|
@@ -149,6 +153,10 @@ class LambdaHarness:
|
|
|
149
153
|
|
|
150
154
|
with lambda_environment(lambda_env):
|
|
151
155
|
try:
|
|
156
|
+
# Resolve inside the try so handler-load failures are returned
|
|
157
|
+
# as an error result, not raised — one consistent contract.
|
|
158
|
+
handler = self._ensure_handler()
|
|
159
|
+
self._has_run = True
|
|
152
160
|
payload = _run_with_timeout(
|
|
153
161
|
handler, (event, context), self.timeout
|
|
154
162
|
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import os, time, threading
|
|
2
|
+
|
|
3
|
+
INIT_COUNTER = {"count": 0}
|
|
4
|
+
INIT_COUNTER["count"] += 1 # increments once per import (cold start)
|
|
5
|
+
|
|
6
|
+
_state = {"invocations": 0, "module_global": []}
|
|
7
|
+
|
|
8
|
+
def echo(event, context):
|
|
9
|
+
return {"event": event, "rid": context.aws_request_id}
|
|
10
|
+
|
|
11
|
+
def return_none(event, context):
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
def return_scalar(event, context):
|
|
15
|
+
return 42
|
|
16
|
+
|
|
17
|
+
def return_list(event, context):
|
|
18
|
+
return [1, 2, {"nested": True}]
|
|
19
|
+
|
|
20
|
+
def raise_value(event, context):
|
|
21
|
+
raise ValueError("bad value")
|
|
22
|
+
|
|
23
|
+
def raise_custom(event, context):
|
|
24
|
+
class MyCustomError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
raise MyCustomError("custom failure")
|
|
27
|
+
|
|
28
|
+
def raise_keyerror(event, context):
|
|
29
|
+
d = {}
|
|
30
|
+
return d["missing"]
|
|
31
|
+
|
|
32
|
+
def read_all_env(event, context):
|
|
33
|
+
return {k: os.environ.get(k) for k in [
|
|
34
|
+
"AWS_LAMBDA_FUNCTION_NAME", "AWS_LAMBDA_FUNCTION_VERSION",
|
|
35
|
+
"AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "AWS_REGION", "AWS_DEFAULT_REGION",
|
|
36
|
+
"AWS_EXECUTION_ENV", "_HANDLER", "AWS_LAMBDA_LOG_GROUP_NAME",
|
|
37
|
+
"AWS_LAMBDA_LOG_STREAM_NAME", "TZ",
|
|
38
|
+
]}
|
|
39
|
+
|
|
40
|
+
def custom_env_reader(event, context):
|
|
41
|
+
return os.environ.get("MY_CUSTOM_VAR", "NOT_SET")
|
|
42
|
+
|
|
43
|
+
def env_leak_check(event, context):
|
|
44
|
+
# returns whether a sentinel that shouldn't exist outside is present
|
|
45
|
+
return os.environ.get("AWS_LAMBDA_FUNCTION_NAME")
|
|
46
|
+
|
|
47
|
+
def sleep_long(event, context):
|
|
48
|
+
time.sleep(5)
|
|
49
|
+
return "finished"
|
|
50
|
+
|
|
51
|
+
def sleep_short(event, context):
|
|
52
|
+
time.sleep(0.05)
|
|
53
|
+
return "quick"
|
|
54
|
+
|
|
55
|
+
def remaining_time(event, context):
|
|
56
|
+
return {"remaining": context.get_remaining_time_in_millis()}
|
|
57
|
+
|
|
58
|
+
def remaining_time_after_sleep(event, context):
|
|
59
|
+
start = context.get_remaining_time_in_millis()
|
|
60
|
+
time.sleep(0.1)
|
|
61
|
+
end = context.get_remaining_time_in_millis()
|
|
62
|
+
return {"start": start, "end": end}
|
|
63
|
+
|
|
64
|
+
def module_global_accumulate(event, context):
|
|
65
|
+
_state["module_global"].append(event.get("n"))
|
|
66
|
+
return list(_state["module_global"])
|
|
67
|
+
|
|
68
|
+
def invocation_counter(event, context):
|
|
69
|
+
_state["invocations"] += 1
|
|
70
|
+
return _state["invocations"]
|
|
71
|
+
|
|
72
|
+
def uses_context_fields(event, context):
|
|
73
|
+
return {
|
|
74
|
+
"function_name": context.function_name,
|
|
75
|
+
"function_version": context.function_version,
|
|
76
|
+
"memory": context.memory_limit_in_mb,
|
|
77
|
+
"arn": context.invoked_function_arn,
|
|
78
|
+
"log_group": context.log_group_name,
|
|
79
|
+
"log_stream": context.log_stream_name,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def thread_check(event, context):
|
|
83
|
+
# Confirms handler runs and can spawn its own threads
|
|
84
|
+
results = []
|
|
85
|
+
def work():
|
|
86
|
+
results.append("worked")
|
|
87
|
+
t = threading.Thread(target=work)
|
|
88
|
+
t.start(); t.join()
|
|
89
|
+
return results
|
|
90
|
+
|
|
91
|
+
def mutate_event(event, context):
|
|
92
|
+
event["injected"] = True
|
|
93
|
+
return event
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Adversarial edge cases — probing the documented fidelity limits.
|
|
2
|
+
|
|
3
|
+
Several of these assert the CURRENT (imperfect) behavior deliberately, to make
|
|
4
|
+
the known gaps visible and regression-proof rather than silently wrong.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
from lambda_preflight import PolicySet, PolicyEvaluator
|
|
8
|
+
from lambda_preflight.iam import operation_to_action
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def P(*stmts):
|
|
12
|
+
return PolicySet.from_policy_documents(documents=[{"Statement": list(stmts)}])
|
|
13
|
+
|
|
14
|
+
def allow(a, r="*", **k):
|
|
15
|
+
d = {"Effect": "Allow", "Action": a, "Resource": r}; d.update(k); return d
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ===== ARN matching fidelity (KNOWN GAP: fnmatch treats ':' as ordinary) =====
|
|
19
|
+
|
|
20
|
+
def test_arn_wildcard_crosses_colon_boundary():
|
|
21
|
+
"""A '*' in a policy ARN can greedily cross ':' segments — NOT how IAM
|
|
22
|
+
matches. This documents the divergence: fnmatch '*' matches across colons."""
|
|
23
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "arn:aws:s3:::bucket*")))
|
|
24
|
+
# Intended: bucket-prefixed. fnmatch '*' also matches deeper — verify current:
|
|
25
|
+
r = ev.evaluate("s3:GetObject", "arn:aws:s3:::bucket-a/deep/key")
|
|
26
|
+
# Current behavior: matches (star is greedy). Real IAM: also matches here
|
|
27
|
+
# because '*' spans '/'. This one happens to agree.
|
|
28
|
+
assert r.allowed is True
|
|
29
|
+
|
|
30
|
+
def test_arn_question_mark_wildcard():
|
|
31
|
+
"""IAM supports '?' single-char wildcard; fnmatch does too."""
|
|
32
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "arn:aws:s3:::bucket-?")))
|
|
33
|
+
assert ev.evaluate("s3:GetObject", "arn:aws:s3:::bucket-1").allowed
|
|
34
|
+
assert not ev.evaluate("s3:GetObject", "arn:aws:s3:::bucket-12").allowed
|
|
35
|
+
|
|
36
|
+
def test_arn_case_sensitivity_of_resource():
|
|
37
|
+
"""Resource ARNs are case-sensitive in IAM; verify matcher respects case."""
|
|
38
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "arn:aws:s3:::MyBucket/*")))
|
|
39
|
+
assert ev.evaluate("s3:GetObject", "arn:aws:s3:::MyBucket/k").allowed
|
|
40
|
+
# different case bucket should NOT match
|
|
41
|
+
assert not ev.evaluate("s3:GetObject", "arn:aws:s3:::mybucket/k").allowed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ===== NotAction / NotResource (KNOWN GAP: not implemented) =====
|
|
45
|
+
|
|
46
|
+
def test_notaction_not_supported_documents_behavior():
|
|
47
|
+
"""Policies using NotAction aren't modeled. A statement with NotAction has
|
|
48
|
+
no 'Action' key -> our parser gives empty actions -> matches nothing.
|
|
49
|
+
This means NotAction-based Allows are effectively ignored (under-grant)."""
|
|
50
|
+
ps = PolicySet.from_policy_documents(documents=[{"Statement": [
|
|
51
|
+
{"Effect": "Allow", "NotAction": "s3:DeleteObject", "Resource": "*"}]}])
|
|
52
|
+
ev = PolicyEvaluator(ps)
|
|
53
|
+
# Real IAM: this ALLOWS everything except delete. Our tool: allows nothing.
|
|
54
|
+
# Document the divergence explicitly:
|
|
55
|
+
assert ev.evaluate("s3:GetObject").allowed is False # KNOWN under-grant
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ===== Principal / cross-account (out of scope) =====
|
|
59
|
+
|
|
60
|
+
def test_resource_policy_absent_documented():
|
|
61
|
+
"""Resource-based policies aren't modeled; unscoped records this."""
|
|
62
|
+
ps = P(allow("s3:GetObject"))
|
|
63
|
+
assert "resource_policy" in ps.unscoped
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ===== Condition operator edge cases =====
|
|
67
|
+
|
|
68
|
+
def test_condition_ifexists_variant():
|
|
69
|
+
"""StringEqualsIfExists passes when the key is absent."""
|
|
70
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "*",
|
|
71
|
+
Condition={"StringEqualsIfExists": {"aws:username": "admin"}})))
|
|
72
|
+
# key absent -> IfExists means condition passes
|
|
73
|
+
d = ev.evaluate("s3:GetObject")
|
|
74
|
+
assert d.allowed is True
|
|
75
|
+
|
|
76
|
+
def test_condition_multiple_keys_all_must_pass():
|
|
77
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "*",
|
|
78
|
+
Condition={"StringEquals": {"aws:RequestedRegion": "us-east-1",
|
|
79
|
+
"s3:prefix": "home/"}})))
|
|
80
|
+
# both match
|
|
81
|
+
assert ev.evaluate("s3:GetObject", context={
|
|
82
|
+
"aws:RequestedRegion": "us-east-1", "s3:prefix": "home/"}).allowed
|
|
83
|
+
# one fails -> deny
|
|
84
|
+
assert not ev.evaluate("s3:GetObject", context={
|
|
85
|
+
"aws:RequestedRegion": "us-east-1", "s3:prefix": "other/"}).allowed
|
|
86
|
+
|
|
87
|
+
def test_condition_multiple_values_or_semantics():
|
|
88
|
+
"""A list of condition values is OR — any match passes (StringEquals)."""
|
|
89
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "*",
|
|
90
|
+
Condition={"StringEquals": {"aws:RequestedRegion": ["us-east-1", "eu-west-1"]}})))
|
|
91
|
+
assert ev.evaluate("s3:GetObject", context={"aws:RequestedRegion": "eu-west-1"}).allowed
|
|
92
|
+
assert not ev.evaluate("s3:GetObject", context={"aws:RequestedRegion": "ap-south-1"}).allowed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ===== Unknown service mapping =====
|
|
96
|
+
|
|
97
|
+
def test_unknown_service_passthrough_mapping():
|
|
98
|
+
"""A service not in the override map falls back to service:Operation."""
|
|
99
|
+
assert operation_to_action("kinesis", "PutRecord") == "kinesis:PutRecord"
|
|
100
|
+
assert operation_to_action("some-new-svc", "DoThing") == "some-new-svc:DoThing"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ===== Empty / malformed inputs =====
|
|
104
|
+
|
|
105
|
+
def test_empty_action_list():
|
|
106
|
+
ps = PolicySet.from_policy_documents(documents=[{"Statement": [
|
|
107
|
+
{"Effect": "Allow", "Action": [], "Resource": "*"}]}])
|
|
108
|
+
ev = PolicyEvaluator(ps)
|
|
109
|
+
assert not ev.evaluate("s3:GetObject").allowed
|
|
110
|
+
|
|
111
|
+
def test_statement_without_resource_defaults_star():
|
|
112
|
+
ps = PolicySet.from_policy_documents(documents=[{"Statement": [
|
|
113
|
+
{"Effect": "Allow", "Action": "s3:GetObject"}]}]) # no Resource
|
|
114
|
+
ev = PolicyEvaluator(ps)
|
|
115
|
+
assert ev.evaluate("s3:GetObject", "arn:aws:s3:::anything").allowed
|
|
116
|
+
|
|
117
|
+
def test_missing_effect_defaults_deny():
|
|
118
|
+
ps = PolicySet.from_policy_documents(documents=[{"Statement": [
|
|
119
|
+
{"Action": "s3:GetObject", "Resource": "*"}]}]) # no Effect
|
|
120
|
+
ev = PolicyEvaluator(ps)
|
|
121
|
+
# defaults to Deny -> not an allow
|
|
122
|
+
assert not ev.evaluate("s3:GetObject").allowed
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ===== Sid tracking =====
|
|
126
|
+
|
|
127
|
+
def test_matched_sid_reported():
|
|
128
|
+
ev = PolicyEvaluator(P(allow("s3:GetObject", "*", Sid="AllowRead")))
|
|
129
|
+
d = ev.evaluate("s3:GetObject")
|
|
130
|
+
assert d.allowed and d.matched_sid == "AllowRead"
|
|
131
|
+
|
|
132
|
+
def test_deny_sid_reported():
|
|
133
|
+
ev = PolicyEvaluator(P(allow("s3:*"),
|
|
134
|
+
{"Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "*",
|
|
135
|
+
"Sid": "NoDelete"}))
|
|
136
|
+
d = ev.evaluate("s3:DeleteObject")
|
|
137
|
+
assert not d.allowed and d.matched_sid == "NoDelete"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
from lambda_preflight import events
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ---------- API Gateway v1 ----------
|
|
7
|
+
def test_v1_method_uppercased():
|
|
8
|
+
assert events.api_gateway_v1(method="post")["httpMethod"] == "POST"
|
|
9
|
+
|
|
10
|
+
def test_v1_body_serialized_to_string():
|
|
11
|
+
e = events.api_gateway_v1(body={"k": "v"})
|
|
12
|
+
assert isinstance(e["body"], str) and json.loads(e["body"]) == {"k": "v"}
|
|
13
|
+
|
|
14
|
+
def test_v1_string_body_passthrough():
|
|
15
|
+
e = events.api_gateway_v1(body="raw string")
|
|
16
|
+
assert e["body"] == "raw string"
|
|
17
|
+
|
|
18
|
+
def test_v1_none_body():
|
|
19
|
+
assert events.api_gateway_v1()["body"] is None
|
|
20
|
+
|
|
21
|
+
def test_v1_has_multivalue_headers():
|
|
22
|
+
e = events.api_gateway_v1(headers={"X-Test": "1"})
|
|
23
|
+
assert e["multiValueHeaders"]["X-Test"] == ["1"]
|
|
24
|
+
|
|
25
|
+
def test_v1_query_multivalue():
|
|
26
|
+
e = events.api_gateway_v1(query={"q": "search"})
|
|
27
|
+
assert e["multiValueQueryStringParameters"]["q"] == ["search"]
|
|
28
|
+
|
|
29
|
+
def test_v1_requestcontext_shape():
|
|
30
|
+
e = events.api_gateway_v1(method="DELETE", path="/x", stage="dev")
|
|
31
|
+
rc = e["requestContext"]
|
|
32
|
+
assert rc["httpMethod"] == "DELETE"
|
|
33
|
+
assert rc["stage"] == "dev"
|
|
34
|
+
assert rc["path"] == "/dev/x"
|
|
35
|
+
assert "identity" in rc and rc["identity"]["sourceIp"]
|
|
36
|
+
|
|
37
|
+
def test_v1_path_parameters():
|
|
38
|
+
e = events.api_gateway_v1(path_parameters={"id": "123"})
|
|
39
|
+
assert e["pathParameters"] == {"id": "123"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------- API Gateway v2 ----------
|
|
43
|
+
def test_v2_version_marker():
|
|
44
|
+
assert events.api_gateway_v2()["version"] == "2.0"
|
|
45
|
+
|
|
46
|
+
def test_v2_route_key():
|
|
47
|
+
assert events.api_gateway_v2(method="post", path="/o")["routeKey"] == "POST /o"
|
|
48
|
+
|
|
49
|
+
def test_v2_raw_path_and_query():
|
|
50
|
+
e = events.api_gateway_v2(path="/search", query={"q": "x", "n": "5"})
|
|
51
|
+
assert e["rawPath"] == "/search"
|
|
52
|
+
assert "q=x" in e["rawQueryString"] and "n=5" in e["rawQueryString"]
|
|
53
|
+
|
|
54
|
+
def test_v2_http_block():
|
|
55
|
+
e = events.api_gateway_v2(method="patch", path="/p")
|
|
56
|
+
assert e["requestContext"]["http"]["method"] == "PATCH"
|
|
57
|
+
assert e["requestContext"]["http"]["path"] == "/p"
|
|
58
|
+
|
|
59
|
+
def test_v2_has_cookies_field():
|
|
60
|
+
assert "cookies" in events.api_gateway_v2()
|
|
61
|
+
|
|
62
|
+
def test_v2_body_serialized():
|
|
63
|
+
e = events.api_gateway_v2(body={"id": 9})
|
|
64
|
+
assert json.loads(e["body"]) == {"id": 9}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------- v1 vs v2 divergence ----------
|
|
68
|
+
def test_v1_v2_structural_difference():
|
|
69
|
+
v1, v2 = events.api_gateway_v1(), events.api_gateway_v2()
|
|
70
|
+
assert "httpMethod" in v1 and "httpMethod" not in v2
|
|
71
|
+
assert "version" in v2 and "version" not in v1
|
|
72
|
+
assert "rawPath" in v2 and "rawPath" not in v1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- SQS ----------
|
|
76
|
+
def test_sqs_single():
|
|
77
|
+
e = events.sqs(body={"o": 1})
|
|
78
|
+
assert len(e["Records"]) == 1
|
|
79
|
+
assert json.loads(e["Records"][0]["body"]) == {"o": 1}
|
|
80
|
+
|
|
81
|
+
def test_sqs_batch_count():
|
|
82
|
+
e = events.sqs(records=[{"a": 1}, {"b": 2}, {"c": 3}])
|
|
83
|
+
assert len(e["Records"]) == 3
|
|
84
|
+
|
|
85
|
+
def test_sqs_record_fields():
|
|
86
|
+
rec = events.sqs(body="x")["Records"][0]
|
|
87
|
+
for f in ["messageId", "receiptHandle", "body", "attributes",
|
|
88
|
+
"messageAttributes", "md5OfBody", "eventSource", "eventSourceARN"]:
|
|
89
|
+
assert f in rec
|
|
90
|
+
assert rec["eventSource"] == "aws:sqs"
|
|
91
|
+
|
|
92
|
+
def test_sqs_arn_format():
|
|
93
|
+
rec = events.sqs(body="x", queue_name="myq", region="eu-central-1")["Records"][0]
|
|
94
|
+
assert rec["eventSourceARN"] == "arn:aws:sqs:eu-central-1:000000000000:myq"
|
|
95
|
+
|
|
96
|
+
def test_sqs_unique_message_ids():
|
|
97
|
+
e = events.sqs(records=[{}, {}, {}])
|
|
98
|
+
ids = {r["messageId"] for r in e["Records"]}
|
|
99
|
+
assert len(ids) == 3
|
|
100
|
+
|
|
101
|
+
def test_sqs_message_attributes_passthrough():
|
|
102
|
+
attrs = {"attr1": {"DataType": "String", "StringValue": "v"}}
|
|
103
|
+
rec = events.sqs(body="x", message_attributes=attrs)["Records"][0]
|
|
104
|
+
assert rec["messageAttributes"] == attrs
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------- SNS ----------
|
|
108
|
+
def test_sns_shape():
|
|
109
|
+
rec = events.sns(message={"m": 1}, subject="subj")["Records"][0]
|
|
110
|
+
assert rec["EventSource"] == "aws:sns"
|
|
111
|
+
assert rec["Sns"]["Subject"] == "subj"
|
|
112
|
+
assert json.loads(rec["Sns"]["Message"]) == {"m": 1}
|
|
113
|
+
|
|
114
|
+
def test_sns_string_message():
|
|
115
|
+
rec = events.sns(message="plain")["Records"][0]
|
|
116
|
+
assert rec["Sns"]["Message"] == "plain"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------- S3 ----------
|
|
120
|
+
def test_s3_shape():
|
|
121
|
+
rec = events.s3(bucket="b", key="path/to/k.txt", size=2048)["Records"][0]
|
|
122
|
+
assert rec["eventSource"] == "aws:s3"
|
|
123
|
+
assert rec["s3"]["bucket"]["name"] == "b"
|
|
124
|
+
assert rec["s3"]["bucket"]["arn"] == "arn:aws:s3:::b"
|
|
125
|
+
assert rec["s3"]["object"]["key"] == "path/to/k.txt"
|
|
126
|
+
assert rec["s3"]["object"]["size"] == 2048
|
|
127
|
+
|
|
128
|
+
def test_s3_event_name():
|
|
129
|
+
rec = events.s3(event_name="ObjectRemoved:Delete")["Records"][0]
|
|
130
|
+
assert rec["eventName"] == "ObjectRemoved:Delete"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------- EventBridge ----------
|
|
134
|
+
def test_eventbridge_shape():
|
|
135
|
+
e = events.eventbridge(detail={"k": "v"}, detail_type="t", source="s")
|
|
136
|
+
assert e["detail-type"] == "t"
|
|
137
|
+
assert e["source"] == "s"
|
|
138
|
+
assert e["detail"] == {"k": "v"}
|
|
139
|
+
assert e["version"] == "0"
|
|
140
|
+
|
|
141
|
+
def test_eventbridge_empty_detail():
|
|
142
|
+
assert events.eventbridge()["detail"] == {}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------- DynamoDB ----------
|
|
146
|
+
def test_dynamodb_insert():
|
|
147
|
+
rec = events.dynamodb_stream(keys={"id": {"S": "1"}},
|
|
148
|
+
new_image={"id": {"S": "1"}, "v": {"N": "5"}},
|
|
149
|
+
event_name="INSERT")["Records"][0]
|
|
150
|
+
assert rec["eventSource"] == "aws:dynamodb"
|
|
151
|
+
assert rec["eventName"] == "INSERT"
|
|
152
|
+
assert rec["dynamodb"]["Keys"] == {"id": {"S": "1"}}
|
|
153
|
+
assert rec["dynamodb"]["NewImage"]["v"] == {"N": "5"}
|
|
154
|
+
|
|
155
|
+
def test_dynamodb_no_new_image_when_absent():
|
|
156
|
+
rec = events.dynamodb_stream(keys={"id": {"S": "1"}}, event_name="REMOVE")["Records"][0]
|
|
157
|
+
assert "NewImage" not in rec["dynamodb"]
|
|
158
|
+
|
|
159
|
+
def test_dynamodb_modify_with_old_image():
|
|
160
|
+
rec = events.dynamodb_stream(keys={"id": {"S": "1"}},
|
|
161
|
+
new_image={"v": {"N": "2"}},
|
|
162
|
+
old_image={"v": {"N": "1"}},
|
|
163
|
+
event_name="MODIFY")["Records"][0]
|
|
164
|
+
assert rec["dynamodb"]["OldImage"] == {"v": {"N": "1"}}
|
|
165
|
+
assert rec["dynamodb"]["NewImage"] == {"v": {"N": "2"}}
|