preflight-cli 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.
- preflight/__init__.py +1 -0
- preflight/cli.py +444 -0
- preflight/core/__init__.py +0 -0
- preflight/core/cfn.py +97 -0
- preflight/core/iam.py +108 -0
- preflight/keys.py +98 -0
- preflight/modules/__init__.py +95 -0
- preflight/modules/base.py +69 -0
- preflight/modules/cost.py +36 -0
- preflight/modules/planned.py +106 -0
- preflight/templates/role.cfn.yaml.j2 +87 -0
- preflight/ui.py +301 -0
- preflight_cli-0.1.0.dist-info/METADATA +107 -0
- preflight_cli-0.1.0.dist-info/RECORD +18 -0
- preflight_cli-0.1.0.dist-info/WHEEL +4 -0
- preflight_cli-0.1.0.dist-info/entry_points.txt +2 -0
- preflight_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- preflight_cli-0.1.0.dist-info/licenses/NOTICE +18 -0
preflight/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
preflight/cli.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any, NoReturn
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from preflight import __version__, keys, ui
|
|
12
|
+
from preflight.core.cfn import DEFAULT_FILENAME, render_role_template
|
|
13
|
+
from preflight.core.iam import collect_actions, selectable_services
|
|
14
|
+
from preflight.modules import (
|
|
15
|
+
BASE_ACTIONS,
|
|
16
|
+
REGISTRY,
|
|
17
|
+
Module,
|
|
18
|
+
ModuleError,
|
|
19
|
+
ModuleNotAvailableError,
|
|
20
|
+
UnknownModuleError,
|
|
21
|
+
available_modules,
|
|
22
|
+
resolve,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="preflight",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
add_completion=False,
|
|
29
|
+
help="Free, read-only AWS audit CLI. Finds the first three things to fix "
|
|
30
|
+
"in deploys, cost, and bus factor.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
TOTAL_STEPS = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _show_version(value: bool) -> None:
|
|
37
|
+
"""`--version` on stdout, so a script can read it."""
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(f"preflight {__version__}")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def main(
|
|
45
|
+
version: Annotated[
|
|
46
|
+
bool,
|
|
47
|
+
typer.Option(
|
|
48
|
+
"--version",
|
|
49
|
+
"-V",
|
|
50
|
+
callback=_show_version,
|
|
51
|
+
is_eager=True,
|
|
52
|
+
help="Show the version and exit.",
|
|
53
|
+
),
|
|
54
|
+
] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tokens(raw: str) -> list[str]:
|
|
60
|
+
"""Split `cost, security` or `1 2` into tokens."""
|
|
61
|
+
return [token for token in re.split(r"[,\s]+", raw.strip()) if token]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _select(raw: str) -> tuple[Module, ...]:
|
|
65
|
+
"""Turn picker input — numbers, keys, or `all` — into modules."""
|
|
66
|
+
options = available_modules()
|
|
67
|
+
wanted: list[str] = []
|
|
68
|
+
for token in _tokens(raw):
|
|
69
|
+
if token.isdigit():
|
|
70
|
+
index = int(token)
|
|
71
|
+
if not 1 <= index <= len(options):
|
|
72
|
+
raise ModuleError(f"There's no module {index} in the list.")
|
|
73
|
+
wanted.append(options[index - 1].key)
|
|
74
|
+
else:
|
|
75
|
+
wanted.append(token)
|
|
76
|
+
if not wanted:
|
|
77
|
+
raise ModuleError("Nothing selected.")
|
|
78
|
+
return resolve(wanted)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _report_selection_error(exc: ModuleError) -> None:
|
|
82
|
+
if isinstance(exc, UnknownModuleError):
|
|
83
|
+
ui.print_error(f"Don't know {_quote(exc.keys)}.")
|
|
84
|
+
elif isinstance(exc, ModuleNotAvailableError):
|
|
85
|
+
ui.print_error(f"{_quote(exc.keys)} isn't available yet.")
|
|
86
|
+
else:
|
|
87
|
+
ui.print_error(str(exc))
|
|
88
|
+
ui.print_hint(f"Available now: {_quote(tuple(m.key for m in available_modules()))}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _quote(keys: tuple[str, ...]) -> str:
|
|
92
|
+
return ", ".join(f"'{key}'" for key in keys) if keys else "nothing"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _abandoned() -> NoReturn:
|
|
96
|
+
"""Nobody answered — say so, and point at the non-interactive route."""
|
|
97
|
+
ui.console.print()
|
|
98
|
+
ui.print_error("No answer given, so nothing was written.")
|
|
99
|
+
ui.print_hint("In a script, skip the questions: preflight scan --modules cost --save")
|
|
100
|
+
raise typer.Exit(2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _ask(text: str, **kwargs: Any) -> str:
|
|
104
|
+
"""Ask a question, and fail helpfully when there's no one to answer."""
|
|
105
|
+
try:
|
|
106
|
+
return ui.ask(text, **kwargs)
|
|
107
|
+
except (EOFError, KeyboardInterrupt, click.Abort):
|
|
108
|
+
_abandoned()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _confirm(text: str) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
return ui.confirm(text)
|
|
114
|
+
except (EOFError, KeyboardInterrupt, click.Abort):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fail(message: str, hint: str | None = None, code: int = 1) -> NoReturn:
|
|
119
|
+
ui.print_error(message)
|
|
120
|
+
if hint:
|
|
121
|
+
ui.print_hint(hint)
|
|
122
|
+
raise typer.Exit(code)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _prompt_for_modules() -> tuple[Module, ...]:
|
|
126
|
+
"""Ask until the answer resolves to something we can run."""
|
|
127
|
+
default = "1" if available_modules() else None
|
|
128
|
+
while True:
|
|
129
|
+
raw = _ask("Modules to check (number, name, or 'all')", default=default)
|
|
130
|
+
try:
|
|
131
|
+
return _select(raw)
|
|
132
|
+
except ModuleError as exc:
|
|
133
|
+
_report_selection_error(exc)
|
|
134
|
+
ui.console.print()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
#: Words the permission picker understands on top of numbers and service names.
|
|
138
|
+
SELECT_ALL = {"a", "all", "*"}
|
|
139
|
+
SELECT_NONE = {"n", "none", "clear"}
|
|
140
|
+
PICKER_DONE = {"", "d", "done", "ok", "go", "continue", "y", "yes"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _select_services(raw: str, groups: dict[str, tuple[str, ...]]) -> list[str]:
|
|
144
|
+
"""Turn `1 3 ec2` into service names, in picker order."""
|
|
145
|
+
names = list(groups)
|
|
146
|
+
chosen: list[str] = []
|
|
147
|
+
for token in _tokens(raw):
|
|
148
|
+
if token.isdigit():
|
|
149
|
+
index = int(token)
|
|
150
|
+
if not 1 <= index <= len(names):
|
|
151
|
+
raise ModuleError(f"There's no permission {index} in the list.")
|
|
152
|
+
chosen.append(names[index - 1])
|
|
153
|
+
elif token.lower() in groups:
|
|
154
|
+
chosen.append(token.lower())
|
|
155
|
+
else:
|
|
156
|
+
raise ModuleError(f"Don't know the service '{token}'.")
|
|
157
|
+
# Deduplicated: a service named twice in one answer still toggles once.
|
|
158
|
+
return list(dict.fromkeys(chosen))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _requested_services(raw: str | None, groups: dict[str, tuple[str, ...]]) -> tuple[str, ...]:
|
|
162
|
+
"""Resolve `--services ec2,rds` against what the selected modules declare."""
|
|
163
|
+
if raw is None:
|
|
164
|
+
return tuple(groups)
|
|
165
|
+
wanted = [token.lower() for token in _tokens(raw)]
|
|
166
|
+
if "all" in wanted:
|
|
167
|
+
return tuple(groups)
|
|
168
|
+
unknown = tuple(dict.fromkeys(token for token in wanted if token not in groups))
|
|
169
|
+
if unknown:
|
|
170
|
+
ui.print_error(f"The modules you picked don't use {_quote(unknown)}.")
|
|
171
|
+
ui.print_hint(f"Services they do use: {_quote(tuple(groups))}")
|
|
172
|
+
raise typer.Exit(2)
|
|
173
|
+
if not wanted:
|
|
174
|
+
ui.print_error("No services selected, so there'd be no permissions to grant.")
|
|
175
|
+
ui.print_hint(f"Pick from: {_quote(tuple(groups))}")
|
|
176
|
+
raise typer.Exit(2)
|
|
177
|
+
chosen = set(wanted)
|
|
178
|
+
return tuple(service for service in groups if service in chosen)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
ARROW_KEYS = "Up/Down to move, Enter to check or uncheck, 'a' for all, 'n' for none"
|
|
182
|
+
TYPED_KEYS = "Toggle by number or name, 'all', 'none', or Enter to continue"
|
|
183
|
+
|
|
184
|
+
NOTHING_PICKED = "Nothing selected, so there'd be no permissions to grant."
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _prompt_for_services(
|
|
188
|
+
selected: tuple[Module, ...],
|
|
189
|
+
groups: dict[str, tuple[str, ...]],
|
|
190
|
+
start: Sequence[str],
|
|
191
|
+
) -> tuple[str, ...]:
|
|
192
|
+
"""Let the user switch off services they'd rather not grant.
|
|
193
|
+
|
|
194
|
+
Everything starts checked unless `--services` narrowed it, so continuing
|
|
195
|
+
straight away grants exactly what the modules asked for. Arrow keys when
|
|
196
|
+
there's a terminal to read them from, typed answers otherwise, so the
|
|
197
|
+
flow still works down a pipe.
|
|
198
|
+
"""
|
|
199
|
+
picker = _arrow_picker if keys.supported() else _typed_picker
|
|
200
|
+
return picker(selected, groups, set(start))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _arrow_picker(
|
|
204
|
+
selected: tuple[Module, ...],
|
|
205
|
+
groups: dict[str, tuple[str, ...]],
|
|
206
|
+
picked: set[str],
|
|
207
|
+
) -> tuple[str, ...]:
|
|
208
|
+
"""Up/Down to move, Enter to toggle, Enter on the last row to continue."""
|
|
209
|
+
services = list(groups)
|
|
210
|
+
cursor = 0
|
|
211
|
+
error: str | None = None
|
|
212
|
+
while True:
|
|
213
|
+
_redraw_permissions(selected, groups, picked, error, cursor=cursor, hint=ARROW_KEYS)
|
|
214
|
+
error = None
|
|
215
|
+
try:
|
|
216
|
+
key = keys.read_key()
|
|
217
|
+
except (EOFError, KeyboardInterrupt):
|
|
218
|
+
_abandoned()
|
|
219
|
+
if key == keys.UP:
|
|
220
|
+
cursor = (cursor - 1) % (len(services) + 1)
|
|
221
|
+
elif key == keys.DOWN:
|
|
222
|
+
cursor = (cursor + 1) % (len(services) + 1)
|
|
223
|
+
elif key == "a":
|
|
224
|
+
picked = set(groups)
|
|
225
|
+
elif key == "n":
|
|
226
|
+
picked = set()
|
|
227
|
+
elif key in (keys.ENTER, keys.SPACE):
|
|
228
|
+
# The row under the services is the one that leaves the picker.
|
|
229
|
+
if cursor < len(services):
|
|
230
|
+
picked.symmetric_difference_update({services[cursor]})
|
|
231
|
+
elif picked:
|
|
232
|
+
return tuple(service for service in groups if service in picked)
|
|
233
|
+
else:
|
|
234
|
+
error = NOTHING_PICKED
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _typed_picker(
|
|
238
|
+
selected: tuple[Module, ...],
|
|
239
|
+
groups: dict[str, tuple[str, ...]],
|
|
240
|
+
picked: set[str],
|
|
241
|
+
) -> tuple[str, ...]:
|
|
242
|
+
"""The same picker for terminals we can't read a single keypress from."""
|
|
243
|
+
error: str | None = None
|
|
244
|
+
while True:
|
|
245
|
+
_redraw_permissions(selected, groups, picked, error, hint=TYPED_KEYS)
|
|
246
|
+
error = None
|
|
247
|
+
raw = _ask("Toggle, 'all', 'none', or Enter to continue", default="", show_default=False)
|
|
248
|
+
token = raw.strip().lower()
|
|
249
|
+
if token in SELECT_ALL:
|
|
250
|
+
picked = set(groups)
|
|
251
|
+
continue
|
|
252
|
+
if token in SELECT_NONE:
|
|
253
|
+
picked = set()
|
|
254
|
+
continue
|
|
255
|
+
if token in PICKER_DONE:
|
|
256
|
+
if picked:
|
|
257
|
+
return tuple(service for service in groups if service in picked)
|
|
258
|
+
error = NOTHING_PICKED
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
picked.symmetric_difference_update(_select_services(raw, groups))
|
|
262
|
+
except ModuleError as exc:
|
|
263
|
+
error = str(exc)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _redraw_permissions(
|
|
267
|
+
selected: tuple[Module, ...],
|
|
268
|
+
groups: dict[str, tuple[str, ...]],
|
|
269
|
+
picked: set[str],
|
|
270
|
+
error: str | None,
|
|
271
|
+
*,
|
|
272
|
+
cursor: int | None = None,
|
|
273
|
+
hint: str | None = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
ui.clear_screen()
|
|
276
|
+
ui.print_step(2, TOTAL_STEPS, "Choose the permissions")
|
|
277
|
+
ui.print_checking(selected)
|
|
278
|
+
ui.print_permissions(groups, picked, always=BASE_ACTIONS, cursor=cursor, keys=hint)
|
|
279
|
+
if error:
|
|
280
|
+
ui.print_error(error)
|
|
281
|
+
ui.console.print()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_target(out: Path | None, save: bool) -> Path | None:
|
|
285
|
+
"""Where the template should be written, if anywhere."""
|
|
286
|
+
if out is None:
|
|
287
|
+
return Path(DEFAULT_FILENAME) if save else None
|
|
288
|
+
# A directory means "in here, under the usual name".
|
|
289
|
+
return out / DEFAULT_FILENAME if out.is_dir() else out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _write_template(path: Path, text: str, *, force: bool, interactive: bool) -> None:
|
|
293
|
+
if path.exists():
|
|
294
|
+
if not path.is_file():
|
|
295
|
+
_fail(f"{path} isn't a file.")
|
|
296
|
+
if not force and not (interactive and _confirm(f"{path} already exists. Overwrite it?")):
|
|
297
|
+
_fail(
|
|
298
|
+
f"{path} already exists, so nothing was written.",
|
|
299
|
+
"Re-run with --force to overwrite it, or --out <path> to write elsewhere.",
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
path.write_text(text, encoding="utf-8")
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
_fail(f"Couldn't write {path}: {exc.strerror or exc}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@app.command()
|
|
308
|
+
def demo() -> None:
|
|
309
|
+
"""Render a sample report with no AWS access required."""
|
|
310
|
+
typer.echo("demo: not yet implemented")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.command()
|
|
314
|
+
def scan(
|
|
315
|
+
modules: Annotated[
|
|
316
|
+
str | None,
|
|
317
|
+
typer.Option(
|
|
318
|
+
"--modules",
|
|
319
|
+
"-m",
|
|
320
|
+
metavar="KEYS",
|
|
321
|
+
help="Modules to include, comma-separated (e.g. 'cost' or 'all'). Skips the questions.",
|
|
322
|
+
),
|
|
323
|
+
] = None,
|
|
324
|
+
services: Annotated[
|
|
325
|
+
str | None,
|
|
326
|
+
typer.Option(
|
|
327
|
+
"--services",
|
|
328
|
+
"-s",
|
|
329
|
+
metavar="SERVICES",
|
|
330
|
+
help="Narrow the role to these AWS services, comma-separated (e.g. 'ec2,rds'). "
|
|
331
|
+
"Defaults to everything the selected modules need.",
|
|
332
|
+
show_default=False,
|
|
333
|
+
),
|
|
334
|
+
] = None,
|
|
335
|
+
save: Annotated[
|
|
336
|
+
bool,
|
|
337
|
+
typer.Option("--save", help=f"Write the role template to ./{DEFAULT_FILENAME}."),
|
|
338
|
+
] = False,
|
|
339
|
+
out: Annotated[
|
|
340
|
+
Path | None,
|
|
341
|
+
typer.Option(
|
|
342
|
+
"--out",
|
|
343
|
+
"-o",
|
|
344
|
+
help="Write the role template to this path instead.",
|
|
345
|
+
show_default=False,
|
|
346
|
+
),
|
|
347
|
+
] = None,
|
|
348
|
+
show: Annotated[
|
|
349
|
+
bool, typer.Option("--print", help="Write the role template to stdout.")
|
|
350
|
+
] = False,
|
|
351
|
+
force: Annotated[
|
|
352
|
+
bool, typer.Option("--force", help="Overwrite the output file if it exists.")
|
|
353
|
+
] = False,
|
|
354
|
+
list_modules: Annotated[
|
|
355
|
+
bool, typer.Option("--list-modules", help="List the modules and exit.")
|
|
356
|
+
] = False,
|
|
357
|
+
profile: Annotated[str | None, typer.Option(help="AWS profile to use.")] = None,
|
|
358
|
+
region: Annotated[str | None, typer.Option(help="AWS region to scan.")] = None,
|
|
359
|
+
role_arn: Annotated[
|
|
360
|
+
str | None, typer.Option(help="IAM role to assume before scanning.")
|
|
361
|
+
] = None,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Pick what to check and generate the read-only IAM role it needs.
|
|
364
|
+
|
|
365
|
+
Runs entirely offline: no AWS calls, no credentials, nothing leaves your
|
|
366
|
+
machine. You get a CloudFormation template you can read before you deploy
|
|
367
|
+
it, granting only the Get/List/Describe actions the modules you picked
|
|
368
|
+
declare.
|
|
369
|
+
"""
|
|
370
|
+
ui.print_banner()
|
|
371
|
+
|
|
372
|
+
if list_modules:
|
|
373
|
+
ui.print_modules(REGISTRY)
|
|
374
|
+
ui.console.print(
|
|
375
|
+
"Generate the role for one: [bold]preflight scan --modules cost --save[/bold]"
|
|
376
|
+
)
|
|
377
|
+
raise typer.Exit()
|
|
378
|
+
|
|
379
|
+
if profile or region or role_arn:
|
|
380
|
+
ui.console.print(
|
|
381
|
+
"[yellow]![/yellow] The checks themselves aren't wired up yet, so "
|
|
382
|
+
"--profile/--region/--role-arn do nothing for now."
|
|
383
|
+
)
|
|
384
|
+
ui.print_hint("This release gets the read-only role in place first.\n")
|
|
385
|
+
|
|
386
|
+
interactive = modules is None
|
|
387
|
+
|
|
388
|
+
ui.print_step(1, TOTAL_STEPS, "Choose what to check")
|
|
389
|
+
if interactive:
|
|
390
|
+
ui.print_modules(REGISTRY)
|
|
391
|
+
selected = _prompt_for_modules()
|
|
392
|
+
else:
|
|
393
|
+
try:
|
|
394
|
+
selected = _select(modules)
|
|
395
|
+
except ModuleError as exc:
|
|
396
|
+
_report_selection_error(exc)
|
|
397
|
+
raise typer.Exit(2) from None
|
|
398
|
+
|
|
399
|
+
# Step 2 gets a screen of its own: the picker is something you read and
|
|
400
|
+
# change, not something you scroll back through.
|
|
401
|
+
groups = selectable_services(selected)
|
|
402
|
+
requested = _requested_services(services, groups)
|
|
403
|
+
if interactive:
|
|
404
|
+
chosen_services = _prompt_for_services(selected, groups, requested)
|
|
405
|
+
ui.clear_screen()
|
|
406
|
+
else:
|
|
407
|
+
ui.print_step(2, TOTAL_STEPS, "Review the permissions")
|
|
408
|
+
chosen_services = requested
|
|
409
|
+
ui.print_checking(selected)
|
|
410
|
+
ui.print_permissions(groups, set(chosen_services), always=BASE_ACTIONS)
|
|
411
|
+
|
|
412
|
+
narrowed = tuple(chosen_services) if set(chosen_services) != set(groups) else None
|
|
413
|
+
actions = collect_actions(selected, narrowed)
|
|
414
|
+
template = render_role_template(selected, services=narrowed)
|
|
415
|
+
|
|
416
|
+
ui.print_step(3, TOTAL_STEPS, "Save the template")
|
|
417
|
+
ui.print_selection(selected, actions)
|
|
418
|
+
target = _resolve_target(out, save)
|
|
419
|
+
to_stdout = show
|
|
420
|
+
|
|
421
|
+
if interactive and not to_stdout and target is None:
|
|
422
|
+
ui.print_save_hint(DEFAULT_FILENAME)
|
|
423
|
+
answer = _ask("Save to", default=DEFAULT_FILENAME)
|
|
424
|
+
target = _resolve_target(Path(answer.strip() or DEFAULT_FILENAME), True)
|
|
425
|
+
elif target is None and not to_stdout:
|
|
426
|
+
# Non-interactive with no destination: stdout is the useful default.
|
|
427
|
+
to_stdout = True
|
|
428
|
+
|
|
429
|
+
if to_stdout:
|
|
430
|
+
ui.emit_template(template)
|
|
431
|
+
if target is not None:
|
|
432
|
+
_write_template(target, template, force=force, interactive=interactive)
|
|
433
|
+
ui.print_saved(str(target), actions)
|
|
434
|
+
ui.print_next_steps(str(target))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@app.command()
|
|
438
|
+
def preview() -> None:
|
|
439
|
+
"""Show exactly what would be sent if you opt in to email delivery."""
|
|
440
|
+
typer.echo("preview: not yet implemented")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
if __name__ == "__main__":
|
|
444
|
+
app()
|
|
File without changes
|
preflight/core/cfn.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Builds the CloudFormation role template from the selected modules.
|
|
2
|
+
|
|
3
|
+
The only input is the module registry — no AWS calls, no credentials, no
|
|
4
|
+
network. The output is deterministic: the same modules always produce the same
|
|
5
|
+
bytes, so a user who regenerates the template after upgrading Preflight gets a
|
|
6
|
+
readable diff instead of a reshuffled file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from importlib import resources
|
|
13
|
+
|
|
14
|
+
from jinja2 import Environment, StrictUndefined
|
|
15
|
+
|
|
16
|
+
from preflight import __version__
|
|
17
|
+
from preflight.core.iam import collect_actions
|
|
18
|
+
from preflight.modules import BASE_ACTIONS, Module
|
|
19
|
+
|
|
20
|
+
TEMPLATE_NAME = "role.cfn.yaml.j2"
|
|
21
|
+
DEFAULT_FILENAME = "preflight-role.cfn.yaml"
|
|
22
|
+
POLICY_NAME = "PreflightReadOnlyPolicy"
|
|
23
|
+
ROLE_NAME = "PreflightReadOnlyRole"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def regenerate_command(modules: Sequence[Module], services: Sequence[str] | None = None) -> str:
|
|
27
|
+
"""The command that reproduces a template, recorded in its header."""
|
|
28
|
+
keys = ",".join(module.key for module in modules)
|
|
29
|
+
narrowed = f" --services {','.join(services)}" if services else ""
|
|
30
|
+
return f"preflight scan --modules {keys}{narrowed} --save"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_template() -> str:
|
|
34
|
+
return (
|
|
35
|
+
resources.files("preflight")
|
|
36
|
+
.joinpath("templates", TEMPLATE_NAME)
|
|
37
|
+
.read_text(encoding="utf-8")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_role_template(
|
|
42
|
+
modules: Sequence[Module],
|
|
43
|
+
*,
|
|
44
|
+
services: Sequence[str] | None = None,
|
|
45
|
+
version: str = __version__,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Render the read-only CloudFormation role template for `modules`.
|
|
48
|
+
|
|
49
|
+
`services` narrows the role to a subset of the services the modules
|
|
50
|
+
declare — what the permission picker produces when the user turns some of
|
|
51
|
+
them off. Raises `ValueError` if no modules were given or if the narrowing
|
|
52
|
+
leaves nothing to grant, and `preflight.core.iam.WriteActionError` if any
|
|
53
|
+
declared action isn't provably read-only.
|
|
54
|
+
"""
|
|
55
|
+
if not modules:
|
|
56
|
+
raise ValueError("at least one module is required to generate a role")
|
|
57
|
+
|
|
58
|
+
actions = collect_actions(modules, services)
|
|
59
|
+
if set(actions) <= set(BASE_ACTIONS):
|
|
60
|
+
raise ValueError("at least one service is required to generate a role")
|
|
61
|
+
|
|
62
|
+
env = Environment(
|
|
63
|
+
trim_blocks=True,
|
|
64
|
+
lstrip_blocks=True,
|
|
65
|
+
keep_trailing_newline=True,
|
|
66
|
+
undefined=StrictUndefined,
|
|
67
|
+
autoescape=False, # YAML output, not HTML
|
|
68
|
+
)
|
|
69
|
+
template = env.from_string(_load_template())
|
|
70
|
+
return template.render(
|
|
71
|
+
version=version,
|
|
72
|
+
modules=tuple(_module_rows(modules, actions)),
|
|
73
|
+
module_names=", ".join(module.name for module in modules),
|
|
74
|
+
actions=actions,
|
|
75
|
+
base_actions=BASE_ACTIONS,
|
|
76
|
+
selected_services=tuple(services) if services else (),
|
|
77
|
+
policy_name=POLICY_NAME,
|
|
78
|
+
role_name=ROLE_NAME,
|
|
79
|
+
regenerate_command=regenerate_command(modules, services),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _module_rows(modules: Sequence[Module], actions: Sequence[str]) -> list[dict[str, object]]:
|
|
84
|
+
"""One header row per module, counting only the actions that made the cut.
|
|
85
|
+
|
|
86
|
+
The count has to come from the rendered action list rather than the module
|
|
87
|
+
declaration: with a narrowed service selection they aren't the same number.
|
|
88
|
+
"""
|
|
89
|
+
granted = set(actions)
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
"name": module.name,
|
|
93
|
+
"key": module.key,
|
|
94
|
+
"count": len(granted.intersection(module.iam_actions)),
|
|
95
|
+
}
|
|
96
|
+
for module in modules
|
|
97
|
+
]
|
preflight/core/iam.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The read-only invariant.
|
|
2
|
+
|
|
3
|
+
Preflight's main promise is that it cannot change anything in your account.
|
|
4
|
+
This module is where that promise is enforced in code: every action that
|
|
5
|
+
reaches a generated IAM policy passes through `assert_read_only` first, so a
|
|
6
|
+
mistake in a module declaration fails at generation time instead of ending up
|
|
7
|
+
in a role someone deploys.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from preflight.modules.base import BASE_ACTIONS, Module
|
|
15
|
+
|
|
16
|
+
#: Action verbs that cannot mutate state.
|
|
17
|
+
READ_ONLY_PREFIXES: tuple[str, ...] = ("Get", "List", "Describe")
|
|
18
|
+
|
|
19
|
+
#: Read-only actions that don't follow the Get/List/Describe naming convention.
|
|
20
|
+
#: Keep in sync with tests/test_policy.py and the policy-check CI job. Any
|
|
21
|
+
#: addition here must be justified in the pull request that makes it.
|
|
22
|
+
READ_ONLY_EXCEPTIONS: frozenset[str] = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"cloudtrail:LookupEvents", # read-only event query, used by Bus factor
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WriteActionError(ValueError):
|
|
30
|
+
"""An action that isn't provably read-only reached policy generation."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, actions: tuple[str, ...]) -> None:
|
|
33
|
+
self.actions = actions
|
|
34
|
+
super().__init__(
|
|
35
|
+
"refusing to generate a policy containing actions that are not "
|
|
36
|
+
f"read-only: {', '.join(actions)}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_read_only_action(action: str) -> bool:
|
|
41
|
+
"""Whether `action` is a specific, non-mutating AWS action.
|
|
42
|
+
|
|
43
|
+
Wildcards are never read-only: `*` and `s3:*` would both grant writes.
|
|
44
|
+
"""
|
|
45
|
+
if action in READ_ONLY_EXCEPTIONS:
|
|
46
|
+
return True
|
|
47
|
+
if "*" in action or ":" not in action:
|
|
48
|
+
return False
|
|
49
|
+
_service, verb = action.split(":", 1)
|
|
50
|
+
return verb.startswith(READ_ONLY_PREFIXES)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def assert_read_only(actions: Iterable[str]) -> None:
|
|
54
|
+
"""Raise `WriteActionError` if any action could change something."""
|
|
55
|
+
offenders = tuple(action for action in actions if not is_read_only_action(action))
|
|
56
|
+
if offenders:
|
|
57
|
+
raise WriteActionError(offenders)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def service_of(action: str) -> str:
|
|
61
|
+
"""The AWS service an action belongs to: `ec2` for `ec2:DescribeVolumes`."""
|
|
62
|
+
return action.split(":", 1)[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def group_by_service(actions: Iterable[str]) -> dict[str, tuple[str, ...]]:
|
|
66
|
+
"""Actions bucketed by service, both the buckets and their contents sorted."""
|
|
67
|
+
groups: dict[str, list[str]] = {}
|
|
68
|
+
for action in actions:
|
|
69
|
+
groups.setdefault(service_of(action), []).append(action)
|
|
70
|
+
return {service: tuple(sorted(groups[service])) for service in sorted(groups)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def selectable_services(modules: Iterable[Module]) -> dict[str, tuple[str, ...]]:
|
|
74
|
+
"""The service groups a user is allowed to narrow a role down to.
|
|
75
|
+
|
|
76
|
+
`BASE_ACTIONS` are left out: every scan needs them, so they aren't
|
|
77
|
+
something the picker can switch off.
|
|
78
|
+
"""
|
|
79
|
+
actions: set[str] = set()
|
|
80
|
+
for module in modules:
|
|
81
|
+
actions.update(module.iam_actions)
|
|
82
|
+
return group_by_service(actions - set(BASE_ACTIONS))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def collect_actions(
|
|
86
|
+
modules: Iterable[Module], services: Iterable[str] | None = None
|
|
87
|
+
) -> tuple[str, ...]:
|
|
88
|
+
"""The deduplicated, sorted actions needed to run `modules`.
|
|
89
|
+
|
|
90
|
+
Always includes `BASE_ACTIONS`. Passing `services` narrows the result to
|
|
91
|
+
those services, which is how the picker in `preflight scan` hands back a
|
|
92
|
+
role smaller than the modules alone would imply. Sorting keeps a
|
|
93
|
+
regenerated template byte-identical to the last one, so the file diffs
|
|
94
|
+
cleanly when a module's permissions change.
|
|
95
|
+
"""
|
|
96
|
+
actions = set(BASE_ACTIONS)
|
|
97
|
+
for module in modules:
|
|
98
|
+
actions.update(module.iam_actions)
|
|
99
|
+
if services is not None:
|
|
100
|
+
wanted = {service.strip().lower() for service in services}
|
|
101
|
+
actions = {
|
|
102
|
+
action
|
|
103
|
+
for action in actions
|
|
104
|
+
if action in BASE_ACTIONS or service_of(action).lower() in wanted
|
|
105
|
+
}
|
|
106
|
+
ordered = tuple(sorted(actions, key=lambda action: (service_of(action), action)))
|
|
107
|
+
assert_read_only(ordered)
|
|
108
|
+
return ordered
|