-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_lambda_permission_source_arn.py
More file actions
158 lines (134 loc) · 6.49 KB
/
Copy pathcheck_lambda_permission_source_arn.py
File metadata and controls
158 lines (134 loc) · 6.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
#!/usr/bin/env python3
"""
Verify every AWS::Lambda::Permission in SAM templates has either SourceArn
or SourceAccount set.
Custom check because cfn-nag 0.8.10 does NOT include a rule for missing
SourceArn/SourceAccount on Lambda::Permission. cfn-nag's Lambda rules
only check (W24) action is InvokeFunction and (F45) EventSourceToken is
not plaintext. None validates SourceArn/SourceAccount presence.
Rationale: AWS API Gateway does not set aws:SourceAccount or aws:SourceArn
when invoking Lambda authorizers (verified in orion-infrastructure PRs #73,
#75; see docs/adr/0007-api-gateway-authorizer-trust-policy.md). The
defense-in-depth is the Lambda resource policy, which REQUIRES SourceArn
to scope invocation. Removing SourceArn silently re-introduces the
cross-API confusion vulnerability, hence this check.
Approach: regex-based. For each `AWS::Lambda::Permission` logical id,
scan the indented block under it (until next top-level key) and assert
that `SourceArn:` or `SourceAccount:` appears within. False positives are
possible if the same string appears in a comment or unrelated resource
that happens to share the indentation, but in practice SAM templates in
the ORION monorepo are flat enough that this works reliably.
Originally in orion-backend (scripts/check-lambda-permission-source-arn.py,
PRs #102, #104). Promoted to a shared recipe so any SAM-based ORION service
can pick it up without duplicating the script.
Exits 0 if every Lambda::Permission has SourceArn or SourceAccount.
Exits 1 with a list of offending resources otherwise.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# Matches the start of a Lambda::Permission logical resource definition.
# Logical IDs in SAM templates are PascalCase (e.g. AuthorizerFunctionPermission,
# IdentityRegisterPermission) and the resource type line is indented by 2 spaces.
#
# NOTE: indent uses `[ \t]` (not `\s`) so it does not match newlines. With
# `\s`, the regex would happily consume the blank line that separates sibling
# resources and start the match one line too early (see fix history).
PERMISSION_HEADER = re.compile(
r"^(?P<indent>[ \t]{2,})(?P<logical_id>[A-Za-z][A-Za-z0-9]*):[ \t]*\n"
r"[ \t]+Type:[ \t]*AWS::Lambda::Permission[ \t]*$",
re.MULTILINE,
)
def _scan_template(template_path: Path) -> list[tuple[str, int]]:
"""Return (logical_id, line_number) for every Lambda::Permission
that does NOT have SourceArn or SourceAccount in its block."""
offenders: list[tuple[str, int]] = []
text = template_path.read_text(encoding="utf-8")
lines = text.splitlines()
# Find each Lambda::Permission header.
for match in PERMISSION_HEADER.finditer(text):
logical_id = match.group("logical_id")
header_indent_len = len(match.group("indent"))
# Header line number (1-indexed for human-readable output).
header_line = text.count("\n", 0, match.start()) + 1
# Scan forward from the next line, collecting indented lines until
# we hit a line at <= header_indent_len indentation (next sibling
# resource, top-level key, or EOF).
end_of_block = len(lines)
for i in range(header_line, len(lines)):
line = lines[i]
if not line.strip() or line.lstrip().startswith("#"):
continue # skip blanks/comments
# First non-blank, non-comment line at this indent or less
# marks the end of the block.
leading = len(line) - len(line.lstrip())
if leading <= header_indent_len:
end_of_block = i
break
# Strip comment lines before scanning for SourceArn/SourceAccount.
# A commented-out `# SourceArn: ...` should NOT count as a fix.
block_lines = lines[header_line:end_of_block]
active_lines = [
ln for ln in block_lines if not ln.lstrip().startswith("#")
]
block_text = "\n".join(active_lines)
if "SourceArn:" not in block_text and "SourceAccount:" not in block_text:
offenders.append((logical_id, header_line))
return offenders
def _resolve_scan_paths(roots: list[str]) -> list[Path]:
"""Resolve a list of root paths/globs into a deduplicated list of
template.yaml files to scan. Each entry is interpreted relative to
the current working directory; tilde expansion is applied; and
globs use the pathlib glob semantics (no shell expansion)."""
seen: set[Path] = set()
out: list[Path] = []
for raw in roots:
expanded = Path(raw).expanduser()
if expanded.is_file():
if expanded not in seen:
seen.add(expanded)
out.append(expanded)
continue
# Glob over directories; matches `contexts/*/template.yaml` style.
for match in sorted(expanded.glob("**/template.yaml")):
if match.is_file() and match not in seen:
seen.add(match)
out.append(match)
return out
def main() -> int:
# Optional positional CLI args: list of roots to scan. Default = the
# canonical ORION pattern (root template + one per bounded context).
roots = sys.argv[1:] or ["template.yaml", "contexts/"]
templates = _resolve_scan_paths(roots)
if not templates:
print(f"No template.yaml files found under: {roots}", file=sys.stderr)
return 0
all_offenders: list[tuple[str, str, int]] = []
for template_path in templates:
for logical_id, line_no in _scan_template(template_path):
all_offenders.append((str(template_path), logical_id, line_no))
if all_offenders:
print(
"ERROR: Lambda::Permission resources without SourceArn/SourceAccount:",
file=sys.stderr,
)
for path, logical_id, line_no in all_offenders:
print(f" - {path}:{logical_id} (line {line_no})", file=sys.stderr)
print("", file=sys.stderr)
print(
"Each AWS::Lambda::Permission MUST specify SourceArn (or SourceAccount) "
"to scope invocation to a specific API Gateway / event source. "
"Removing this property silently re-introduces the cross-API "
"confusion vulnerability documented in "
"orion-infrastructure/docs/adr/0007-api-gateway-authorizer-trust-policy.md.",
file=sys.stderr,
)
return 1
print(
f"OK: {len(templates)} template(s) scanned, all Lambda::Permission "
f"resources have SourceArn or SourceAccount."
)
return 0
if __name__ == "__main__":
sys.exit(main())