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/keys.py ADDED
@@ -0,0 +1,98 @@
1
+ """Single keypresses, for the pickers that want arrow keys.
2
+
3
+ Only used when there's a real terminal on the other end. Anything else — a
4
+ pipe, a CI job, the test runner — gets `supported() is False` and the caller
5
+ falls back to asking a question and reading a line, so every flow stays
6
+ scriptable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ UP = "up"
14
+ DOWN = "down"
15
+ ENTER = "enter"
16
+ SPACE = "space"
17
+ ESCAPE = "escape"
18
+
19
+ #: What a CSI sequence's final byte means to us.
20
+ _CSI: dict[str, str] = {"A": UP, "B": DOWN}
21
+
22
+
23
+ def supported() -> bool:
24
+ """Whether we can read keypresses one at a time."""
25
+ if not (sys.stdin.isatty() and sys.stdin.readable()):
26
+ return False
27
+ try:
28
+ if sys.platform == "win32":
29
+ import msvcrt # noqa: F401
30
+ else:
31
+ import termios # noqa: F401
32
+ import tty # noqa: F401
33
+ except ImportError: # pragma: no cover - platform without either
34
+ return False
35
+ return True
36
+
37
+
38
+ def read_key() -> str:
39
+ """Block for one keypress and name it.
40
+
41
+ Returns `UP`, `DOWN`, `ENTER`, `SPACE`, `ESCAPE`, or the character itself
42
+ lowercased. Raises `KeyboardInterrupt` on Ctrl-C and `EOFError` on Ctrl-D,
43
+ so callers can treat them the way they treat an abandoned prompt.
44
+ """
45
+ char = _read_char_windows() if sys.platform == "win32" else _read_char_posix()
46
+ if char == "\x03":
47
+ raise KeyboardInterrupt
48
+ if char == "\x04":
49
+ raise EOFError
50
+ if char in ("\r", "\n"):
51
+ return ENTER
52
+ if char == " ":
53
+ return SPACE
54
+ if char == "\x1b":
55
+ return ESCAPE
56
+ return char.lower()
57
+
58
+
59
+ def _read_char_posix() -> str:
60
+ """One keypress from a POSIX terminal, with escape sequences collapsed.
61
+
62
+ Reads the file descriptor rather than `sys.stdin`: the text wrapper would
63
+ pull a whole `ESC [ A` into its own buffer while answering `read(1)` with
64
+ just the ESC, and then `select` — which only sees the descriptor — would
65
+ report nothing left and we'd call it a bare Escape.
66
+ """
67
+ import os
68
+ import select
69
+ import termios
70
+ import tty
71
+
72
+ descriptor = sys.stdin.fileno()
73
+ saved = termios.tcgetattr(descriptor)
74
+ try:
75
+ tty.setraw(descriptor)
76
+ first = os.read(descriptor, 1)
77
+ if first != b"\x1b":
78
+ return first.decode("utf-8", "replace")
79
+ # An arrow key arrives as ESC [ A. A bare Escape arrives alone, so
80
+ # don't block waiting for the rest of a sequence that isn't coming.
81
+ rest = b""
82
+ while len(rest) < 2 and select.select([descriptor], [], [], 0.05)[0]:
83
+ rest += os.read(descriptor, 8)
84
+ if rest[:1] != b"[":
85
+ return "\x1b"
86
+ return _CSI.get(rest[1:2].decode("utf-8", "replace"), "\x1b")
87
+ finally:
88
+ termios.tcsetattr(descriptor, termios.TCSADRAIN, saved)
89
+
90
+
91
+ def _read_char_windows() -> str: # pragma: no cover - exercised on Windows only
92
+ """One keypress from a Windows console."""
93
+ import msvcrt
94
+
95
+ char = msvcrt.getwch()
96
+ if char in ("\x00", "\xe0"):
97
+ return {"H": UP, "P": DOWN}.get(msvcrt.getwch(), "\x1b")
98
+ return char
@@ -0,0 +1,95 @@
1
+ """The module registry.
2
+
3
+ `REGISTRY` is the list `preflight scan` shows and the single source of truth
4
+ for which IAM actions end up in a generated role.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+
11
+ from preflight.modules.base import (
12
+ BASE_ACTIONS,
13
+ Module,
14
+ ModuleError,
15
+ ModuleNotAvailableError,
16
+ ModuleStatus,
17
+ UnknownModuleError,
18
+ )
19
+ from preflight.modules.cost import COST
20
+ from preflight.modules.planned import (
21
+ BUS_FACTOR,
22
+ DELIVERY,
23
+ OBSERVABILITY,
24
+ RELIABILITY,
25
+ SECURITY,
26
+ )
27
+
28
+ # Display order for the picker: available modules first, then the rest in the
29
+ # order we expect to ship them.
30
+ REGISTRY: tuple[Module, ...] = (
31
+ COST,
32
+ SECURITY,
33
+ RELIABILITY,
34
+ DELIVERY,
35
+ OBSERVABILITY,
36
+ BUS_FACTOR,
37
+ )
38
+
39
+ __all__ = [
40
+ "BASE_ACTIONS",
41
+ "REGISTRY",
42
+ "Module",
43
+ "ModuleError",
44
+ "ModuleNotAvailableError",
45
+ "ModuleStatus",
46
+ "UnknownModuleError",
47
+ "available_modules",
48
+ "by_key",
49
+ "resolve",
50
+ ]
51
+
52
+
53
+ def available_modules() -> tuple[Module, ...]:
54
+ """Modules that can run today."""
55
+ return tuple(module for module in REGISTRY if module.is_available)
56
+
57
+
58
+ def by_key(key: str) -> Module | None:
59
+ """Look a module up by key, case- and whitespace-insensitively."""
60
+ wanted = key.strip().lower()
61
+ for module in REGISTRY:
62
+ if module.key == wanted:
63
+ return module
64
+ return None
65
+
66
+
67
+ def resolve(keys: Iterable[str]) -> tuple[Module, ...]:
68
+ """Turn module keys into modules, in registry order.
69
+
70
+ `all` expands to every available module. Raises `UnknownModuleError` for
71
+ keys that don't exist and `ModuleNotAvailableError` for ones that haven't
72
+ shipped, so the caller can tell the user which of the two happened.
73
+ """
74
+ wanted: list[str] = []
75
+ for key in keys:
76
+ cleaned = key.strip().lower()
77
+ if not cleaned:
78
+ continue
79
+ if cleaned == "all":
80
+ wanted.extend(module.key for module in available_modules())
81
+ else:
82
+ wanted.append(cleaned)
83
+
84
+ unknown = tuple(dict.fromkeys(key for key in wanted if by_key(key) is None))
85
+ if unknown:
86
+ raise UnknownModuleError(unknown)
87
+
88
+ not_ready = tuple(
89
+ dict.fromkeys(key for key in wanted if not by_key(key).is_available) # type: ignore[union-attr]
90
+ )
91
+ if not_ready:
92
+ raise ModuleNotAvailableError(not_ready)
93
+
94
+ selected = set(wanted)
95
+ return tuple(module for module in REGISTRY if module.key in selected)
@@ -0,0 +1,69 @@
1
+ """The shape of a Preflight module.
2
+
3
+ A module is a group of related checks plus the IAM actions those checks need.
4
+ Nothing here touches AWS: a module declares its permissions so Preflight can
5
+ build the smallest possible read-only role before any scan happens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+
13
+ # Actions every scan needs, whatever modules are selected. Preflight calls
14
+ # GetCallerIdentity first so it can show you which account it's about to read
15
+ # and warn you if the identity has more access than it needs.
16
+ BASE_ACTIONS: tuple[str, ...] = ("sts:GetCallerIdentity",)
17
+
18
+
19
+ class ModuleStatus(Enum):
20
+ """Whether a module can run yet."""
21
+
22
+ AVAILABLE = "available"
23
+ COMING_SOON = "coming-soon"
24
+
25
+ @property
26
+ def label(self) -> str:
27
+ return "available" if self is ModuleStatus.AVAILABLE else "coming soon"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Module:
32
+ """A named group of checks and the read-only IAM actions they need.
33
+
34
+ Modules are declared, not discovered: adding one means adding a `Module`
35
+ to the registry with every action its checks call. The actions are the
36
+ contract — `preflight scan` builds the IAM role from them, so a check that
37
+ calls something it didn't declare will fail against a Preflight role
38
+ rather than quietly reading more than the user agreed to.
39
+ """
40
+
41
+ key: str
42
+ name: str
43
+ summary: str
44
+ iam_actions: tuple[str, ...]
45
+ status: ModuleStatus = ModuleStatus.COMING_SOON
46
+
47
+ @property
48
+ def is_available(self) -> bool:
49
+ return self.status is ModuleStatus.AVAILABLE
50
+
51
+
52
+ class ModuleError(Exception):
53
+ """Base class for problems with a module selection."""
54
+
55
+
56
+ class UnknownModuleError(ModuleError):
57
+ """A requested module key isn't in the registry."""
58
+
59
+ def __init__(self, keys: tuple[str, ...]) -> None:
60
+ self.keys = keys
61
+ super().__init__(f"unknown module(s): {', '.join(keys)}")
62
+
63
+
64
+ class ModuleNotAvailableError(ModuleError):
65
+ """A requested module exists but hasn't shipped yet."""
66
+
67
+ def __init__(self, keys: tuple[str, ...]) -> None:
68
+ self.keys = keys
69
+ super().__init__(f"module(s) not available yet: {', '.join(keys)}")
@@ -0,0 +1,36 @@
1
+ """Cost Check: what you're paying for that you aren't using."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from preflight.modules.base import Module, ModuleStatus
6
+
7
+ COST = Module(
8
+ key="cost",
9
+ name="Cost Check",
10
+ summary="Unattached volumes and IPs, idle NAT gateways and load balancers, "
11
+ "stopped instances, old snapshots, oversized instances.",
12
+ status=ModuleStatus.AVAILABLE,
13
+ iam_actions=(
14
+ # What exists, and how big it is.
15
+ "ec2:DescribeRegions",
16
+ "ec2:DescribeInstances",
17
+ "ec2:DescribeVolumes",
18
+ "ec2:DescribeAddresses",
19
+ "ec2:DescribeSnapshots",
20
+ "ec2:DescribeNatGateways",
21
+ "ec2:DescribeImages",
22
+ "elasticloadbalancing:DescribeLoadBalancers",
23
+ "elasticloadbalancing:DescribeTargetGroups",
24
+ "elasticloadbalancing:DescribeTargetHealth",
25
+ "autoscaling:DescribeAutoScalingGroups",
26
+ "rds:DescribeDBInstances",
27
+ "rds:DescribeDBSnapshots",
28
+ # Whether it's actually being used.
29
+ "cloudwatch:GetMetricStatistics",
30
+ # What it costs, and what the alternatives cost.
31
+ "ce:GetCostAndUsage",
32
+ "pricing:GetProducts",
33
+ # Who owns it.
34
+ "tag:GetResources",
35
+ ),
36
+ )
@@ -0,0 +1,106 @@
1
+ """Modules that are declared but haven't shipped yet.
2
+
3
+ Their actions are real: between them and `cost.py` they account for every
4
+ action in `iam/policy.json`, which is the role you get with every module
5
+ selected. They live here rather than in their own files until their checks
6
+ are written — graduating one means moving it to `modules/<key>.py` and
7
+ flipping its status to `AVAILABLE`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from preflight.modules.base import Module
13
+
14
+ SECURITY = Module(
15
+ key="security",
16
+ name="Security & IAM",
17
+ summary="MFA, stale access keys, root account usage, public buckets, "
18
+ "open security groups, CloudTrail, encryption defaults.",
19
+ iam_actions=(
20
+ "iam:GetAccountSummary",
21
+ "iam:GetAccountPasswordPolicy",
22
+ "iam:ListUsers",
23
+ "iam:ListAccessKeys",
24
+ "iam:GetAccessKeyLastUsed",
25
+ "iam:ListMFADevices",
26
+ "iam:ListRoles",
27
+ "iam:ListPolicies",
28
+ "ec2:DescribeSecurityGroups",
29
+ "s3:ListAllMyBuckets",
30
+ "s3:GetBucketLocation",
31
+ "s3:GetBucketPolicyStatus",
32
+ "s3:GetBucketPublicAccessBlock",
33
+ "s3:GetEncryptionConfiguration",
34
+ "cloudtrail:DescribeTrails",
35
+ "cloudtrail:GetTrailStatus",
36
+ ),
37
+ )
38
+
39
+ RELIABILITY = Module(
40
+ key="reliability",
41
+ name="Reliability",
42
+ summary="Single-AZ databases, missing backups, no autoscaling, missing health checks.",
43
+ iam_actions=(
44
+ "ec2:DescribeInstances",
45
+ "rds:DescribeDBInstances",
46
+ "rds:DescribeDBSnapshots",
47
+ "autoscaling:DescribeAutoScalingGroups",
48
+ "elasticloadbalancing:DescribeLoadBalancers",
49
+ "elasticloadbalancing:DescribeTargetGroups",
50
+ "elasticloadbalancing:DescribeTargetHealth",
51
+ "ecs:ListClusters",
52
+ "ecs:ListServices",
53
+ "ecs:DescribeClusters",
54
+ "ecs:DescribeServices",
55
+ ),
56
+ )
57
+
58
+ DELIVERY = Module(
59
+ key="delivery",
60
+ name="Delivery & IaC",
61
+ summary="How much is managed as code, CloudFormation drift, deploy strategy, "
62
+ "`latest` image tags, stale AMIs, SSH-only access.",
63
+ iam_actions=(
64
+ "cloudformation:ListStacks",
65
+ "cloudformation:DescribeStacks",
66
+ "cloudformation:DescribeStackDriftDetectionStatus",
67
+ "cloudformation:DescribeStackResourceDrifts",
68
+ "ecr:DescribeRepositories",
69
+ "ecr:DescribeImages",
70
+ "ec2:DescribeImages",
71
+ "ec2:DescribeLaunchTemplates",
72
+ "ec2:DescribeSecurityGroups",
73
+ "codedeploy:ListApplications",
74
+ "codedeploy:GetDeploymentConfig",
75
+ ),
76
+ )
77
+
78
+ OBSERVABILITY = Module(
79
+ key="observability",
80
+ name="Observability",
81
+ summary="Alarm coverage, log retention, where alerts actually go.",
82
+ iam_actions=(
83
+ "cloudwatch:DescribeAlarms",
84
+ "logs:DescribeLogGroups",
85
+ "sns:ListTopics",
86
+ "sns:ListSubscriptions",
87
+ ),
88
+ )
89
+
90
+ BUS_FACTOR = Module(
91
+ key="bus-factor",
92
+ name="Bus factor",
93
+ summary="Whether changes come from one or two people, how many admins you have, "
94
+ "resources with no clear owner.",
95
+ iam_actions=(
96
+ # Read-only event query. Listed as an exception to the Get/List/Describe
97
+ # rule in core/iam.py — it reads the CloudTrail event history and
98
+ # mutates nothing.
99
+ "cloudtrail:LookupEvents",
100
+ "iam:ListUsers",
101
+ "iam:ListRoles",
102
+ "iam:ListPolicies",
103
+ "s3:GetBucketTagging",
104
+ "tag:GetResources",
105
+ ),
106
+ )
@@ -0,0 +1,87 @@
1
+ # Generated by Preflight {{ version }} -- read it, then deploy it. Regenerate
2
+ # rather than editing by hand:
3
+ #
4
+ # {{ regenerate_command }}
5
+ #
6
+ # Modules covered:
7
+ {% for module in modules %}
8
+ # - {{ module.name }} ({{ module.key }}), {{ module.count }} actions
9
+ {% endfor %}
10
+ {% if selected_services %}
11
+ # Narrowed at generation time to these services:
12
+ # {{ selected_services | join(", ") }}
13
+ {% endif %}
14
+ # Always included:
15
+ {% for action in base_actions %}
16
+ # - {{ action }}, so Preflight can show you which account it is reading
17
+ {% endfor %}
18
+ #
19
+ # {{ actions | length }} read-only actions in total -- no write actions and no
20
+ # wildcards. Every action below is one a check you selected actually calls.
21
+ AWSTemplateFormatVersion: "2010-09-09"
22
+ Description: >
23
+ Dedicated read-only IAM role for Jet1 Preflight ({{ module_names }}). Creates a
24
+ role that can be assumed (optionally cross-account) with only the actions the
25
+ selected Preflight modules declare -- no write or wildcard actions. Safe to
26
+ deploy once and reuse for every Preflight scan.
27
+
28
+ Parameters:
29
+ TrustedPrincipalArn:
30
+ Type: String
31
+ Description: >
32
+ ARN of the IAM user/role that will assume this role to run Preflight.
33
+ Use your own account's user/role ARN for same-account use, or an
34
+ external account's ARN only if you intend cross-account access.
35
+ ExternalId:
36
+ Type: String
37
+ Default: ""
38
+ Description: >
39
+ Optional external ID to require on AssumeRole, recommended for
40
+ cross-account trust relationships.
41
+ NoEcho: true
42
+
43
+ Conditions:
44
+ HasExternalId: !Not [!Equals [!Ref ExternalId, ""]]
45
+
46
+ Resources:
47
+ PreflightReadOnlyPolicy:
48
+ Type: AWS::IAM::ManagedPolicy
49
+ Properties:
50
+ ManagedPolicyName: {{ policy_name }}
51
+ Description: Read-only permissions used by Jet1 Preflight. Get*/List*/Describe* only.
52
+ PolicyDocument:
53
+ Version: "2012-10-17"
54
+ Statement:
55
+ - Sid: PreflightReadOnly
56
+ Effect: Allow
57
+ Action:
58
+ {% for action in actions %}
59
+ - {{ action }}
60
+ {% endfor %}
61
+ Resource: "*"
62
+
63
+ PreflightReadOnlyRole:
64
+ Type: AWS::IAM::Role
65
+ Properties:
66
+ RoleName: {{ role_name }}
67
+ Description: Role assumed by Jet1 Preflight to run a read-only AWS audit.
68
+ MaxSessionDuration: 3600
69
+ AssumeRolePolicyDocument:
70
+ Version: "2012-10-17"
71
+ Statement:
72
+ - Effect: Allow
73
+ Principal:
74
+ AWS: !Ref TrustedPrincipalArn
75
+ Action: sts:AssumeRole
76
+ Condition: !If
77
+ - HasExternalId
78
+ - StringEquals:
79
+ sts:ExternalId: !Ref ExternalId
80
+ - !Ref AWS::NoValue
81
+ ManagedPolicyArns:
82
+ - !Ref PreflightReadOnlyPolicy
83
+
84
+ Outputs:
85
+ RoleArn:
86
+ Description: ARN to pass to `preflight scan --role-arn`
87
+ Value: !GetAtt PreflightReadOnlyRole.Arn