keyfleet 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.
- keyfleet/__init__.py +5 -0
- keyfleet/bundled.py +243 -0
- keyfleet/checks.py +263 -0
- keyfleet/cli.py +196 -0
- keyfleet/crypto.py +67 -0
- keyfleet/data/advisories.yaml +32 -0
- keyfleet/data/example.yaml +99 -0
- keyfleet/data/models.yaml +52 -0
- keyfleet/data/services.yaml +271 -0
- keyfleet/impact.py +86 -0
- keyfleet/model.py +418 -0
- keyfleet/report.py +430 -0
- keyfleet-0.1.0.dist-info/METADATA +181 -0
- keyfleet-0.1.0.dist-info/RECORD +17 -0
- keyfleet-0.1.0.dist-info/WHEEL +4 -0
- keyfleet-0.1.0.dist-info/entry_points.txt +2 -0
- keyfleet-0.1.0.dist-info/licenses/LICENSE +201 -0
keyfleet/__init__.py
ADDED
keyfleet/bundled.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Bundled reference data: services.yaml, models.yaml, advisories.yaml.
|
|
2
|
+
|
|
3
|
+
Every entry in the data files carries ``source_url`` and ``verified`` — facts
|
|
4
|
+
are read from the vendor's/service's own pages, never guessed (AGENTS.md §6).
|
|
5
|
+
Loading happens once via :func:`load_bundled`; checks receive the result as a
|
|
6
|
+
plain argument so they stay pure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
19
|
+
|
|
20
|
+
from keyfleet.model import (
|
|
21
|
+
Advisory,
|
|
22
|
+
Capability,
|
|
23
|
+
Interface,
|
|
24
|
+
Key,
|
|
25
|
+
Ledger,
|
|
26
|
+
LedgerError,
|
|
27
|
+
StrictModel,
|
|
28
|
+
Vendor,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_URL_PATTERN = r"^https?://\S+$"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CapacityRule(StrictModel):
|
|
35
|
+
"""Discoverable-credential capacity for a firmware range (lt exclusive, ge inclusive)."""
|
|
36
|
+
|
|
37
|
+
firmware_lt: str | None = None
|
|
38
|
+
firmware_ge: str | None = None
|
|
39
|
+
capacity: int = Field(ge=0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KeyModelInfo(StrictModel):
|
|
43
|
+
"""One hardware-key model family in models.yaml."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
vendor: Vendor
|
|
47
|
+
family: str = Field(
|
|
48
|
+
min_length=1,
|
|
49
|
+
description="Case-insensitive prefix matched against Key.model; longest family wins.",
|
|
50
|
+
)
|
|
51
|
+
capabilities: list[Capability] | None = None
|
|
52
|
+
interfaces: list[Interface] | None = None
|
|
53
|
+
discoverable_capacity: int | list[CapacityRule] | None = None
|
|
54
|
+
source_url: str = Field(pattern=_URL_PATTERN)
|
|
55
|
+
verified: dt.date
|
|
56
|
+
notes: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ServiceInfo(StrictModel):
|
|
60
|
+
"""One service entry in services.yaml."""
|
|
61
|
+
|
|
62
|
+
name: str = Field(min_length=1)
|
|
63
|
+
security_settings_url: str | None = Field(None, pattern=_URL_PATTERN)
|
|
64
|
+
max_keys: int | None = Field(None, ge=0)
|
|
65
|
+
fido2_discoverable: bool | None = None
|
|
66
|
+
notes: str = ""
|
|
67
|
+
source_url: str = Field(pattern=_URL_PATTERN)
|
|
68
|
+
verified: dt.date
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BundledData(BaseModel):
|
|
72
|
+
"""All bundled reference data, loaded once and passed around."""
|
|
73
|
+
|
|
74
|
+
services: dict[str, ServiceInfo] = Field(default_factory=dict)
|
|
75
|
+
models: list[KeyModelInfo] = Field(default_factory=list)
|
|
76
|
+
advisories: list[Advisory] = Field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_data_yaml(name: str) -> Any:
|
|
80
|
+
file = resources.files("keyfleet").joinpath("data", name)
|
|
81
|
+
try:
|
|
82
|
+
text = file.read_text(encoding="utf-8")
|
|
83
|
+
except FileNotFoundError as exc:
|
|
84
|
+
raise LedgerError(f"bundled data file {name} is missing from the package") from exc
|
|
85
|
+
return yaml.safe_load(text)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def example_ledger_text() -> str:
|
|
89
|
+
"""The packaged example ledger (mirrors keyfleet.example.yaml at the repo root)."""
|
|
90
|
+
return resources.files("keyfleet").joinpath("data", "example.yaml").read_text(encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@lru_cache(maxsize=1)
|
|
94
|
+
def load_bundled() -> BundledData:
|
|
95
|
+
"""Load and validate the three bundled data files."""
|
|
96
|
+
try:
|
|
97
|
+
return BundledData.model_validate(
|
|
98
|
+
{
|
|
99
|
+
"services": (_read_data_yaml("services.yaml") or {}).get("services", {}),
|
|
100
|
+
"models": (_read_data_yaml("models.yaml") or {}).get("models", []),
|
|
101
|
+
"advisories": (_read_data_yaml("advisories.yaml") or {}).get("advisories", []),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
except ValidationError as exc:
|
|
105
|
+
raise LedgerError(f"bundled data is invalid (packaging bug):\n{exc}") from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def services_markdown(bundled: BundledData) -> str:
|
|
109
|
+
"""The generated docs/SERVICES.md content."""
|
|
110
|
+
lines = [
|
|
111
|
+
"# Services",
|
|
112
|
+
"",
|
|
113
|
+
"Generated from `src/keyfleet/data/services.yaml` — do not edit by hand;",
|
|
114
|
+
"regenerate with `uv run python scripts/gen_services_md.py`.",
|
|
115
|
+
"",
|
|
116
|
+
"Every fact comes from the service's own page (*source*), read on the",
|
|
117
|
+
"*verified* date. `—` means the service documents no such fact; `?` means",
|
|
118
|
+
"the page does not say either way.",
|
|
119
|
+
"",
|
|
120
|
+
"| Service | Security-key settings | Max keys | Passkeys | Source | Verified |",
|
|
121
|
+
"|---|---|---|---|---|---|",
|
|
122
|
+
]
|
|
123
|
+
for service_id, service in bundled.services.items():
|
|
124
|
+
settings = (
|
|
125
|
+
f"[settings]({service.security_settings_url})" if service.security_settings_url else "—"
|
|
126
|
+
)
|
|
127
|
+
max_keys = "—" if service.max_keys is None else str(service.max_keys)
|
|
128
|
+
passkeys = {True: "yes", False: "no", None: "?"}[service.fido2_discoverable]
|
|
129
|
+
lines.append(
|
|
130
|
+
f"| {service.name} (`{service_id}`) | {settings} | {max_keys} "
|
|
131
|
+
f"| {passkeys} | [source]({service.source_url}) | {service.verified} |"
|
|
132
|
+
)
|
|
133
|
+
lines += ["", f"{len(bundled.services)} services."]
|
|
134
|
+
return "\n".join(lines) + "\n"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def firmware_tuple(firmware: str) -> tuple[int, ...]:
|
|
138
|
+
"""'5.7.1' → (5, 7, 1). Non-numeric segment suffixes are ignored ('4a' → 4)."""
|
|
139
|
+
parts: list[int] = []
|
|
140
|
+
for segment in firmware.strip().split("."):
|
|
141
|
+
digits = ""
|
|
142
|
+
for char in segment:
|
|
143
|
+
if char.isdigit():
|
|
144
|
+
digits += char
|
|
145
|
+
else:
|
|
146
|
+
break
|
|
147
|
+
parts.append(int(digits) if digits else 0)
|
|
148
|
+
return tuple(parts)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def firmware_in_range(
|
|
152
|
+
firmware: str, *, firmware_ge: str | None = None, firmware_lt: str | None = None
|
|
153
|
+
) -> bool:
|
|
154
|
+
"""Inclusive lower bound, exclusive upper bound; missing bounds are open."""
|
|
155
|
+
value = firmware_tuple(firmware)
|
|
156
|
+
if firmware_ge is not None and value < firmware_tuple(firmware_ge):
|
|
157
|
+
return False
|
|
158
|
+
return not (firmware_lt is not None and value >= firmware_tuple(firmware_lt))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def model_info_for_key(models: list[KeyModelInfo], key: Key) -> KeyModelInfo | None:
|
|
162
|
+
"""The models.yaml entry for a ledger key: same vendor, longest family prefix."""
|
|
163
|
+
if not key.model:
|
|
164
|
+
return None
|
|
165
|
+
name = key.model.strip().lower()
|
|
166
|
+
candidates = [
|
|
167
|
+
info
|
|
168
|
+
for info in models
|
|
169
|
+
if info.vendor is key.vendor and name.startswith(info.family.lower())
|
|
170
|
+
]
|
|
171
|
+
if not candidates:
|
|
172
|
+
return None
|
|
173
|
+
return max(candidates, key=lambda info: len(info.family))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class AdvisoryMatch:
|
|
178
|
+
"""One ledger key against the advisory list."""
|
|
179
|
+
|
|
180
|
+
key_id: str
|
|
181
|
+
key_label: str
|
|
182
|
+
firmware: str | None # None → the key needs `firmware:` set to evaluate
|
|
183
|
+
advisories: tuple[Advisory, ...]
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def needs_firmware(self) -> bool:
|
|
187
|
+
return self.firmware is None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def match_advisories(ledger: Ledger, bundled: BundledData) -> list[AdvisoryMatch]:
|
|
191
|
+
"""Match every ledger key against bundled + ledger advisories.
|
|
192
|
+
|
|
193
|
+
Ledger advisories extend the bundled list; on an id collision the ledger
|
|
194
|
+
entry wins. A key with no firmware whose vendor has advisories on file
|
|
195
|
+
yields a needs-firmware entry (brief §9). Keys of vendors with no
|
|
196
|
+
advisories are omitted entirely.
|
|
197
|
+
"""
|
|
198
|
+
merged: dict[str, Advisory] = {advisory.id: advisory for advisory in bundled.advisories}
|
|
199
|
+
for advisory in ledger.advisories:
|
|
200
|
+
merged[advisory.id] = advisory
|
|
201
|
+
by_vendor: dict[Vendor, list[Advisory]] = {}
|
|
202
|
+
for advisory in merged.values():
|
|
203
|
+
by_vendor.setdefault(advisory.vendor, []).append(advisory)
|
|
204
|
+
|
|
205
|
+
matches: list[AdvisoryMatch] = []
|
|
206
|
+
for key in ledger.keys:
|
|
207
|
+
candidates = by_vendor.get(key.vendor, [])
|
|
208
|
+
if not candidates:
|
|
209
|
+
continue
|
|
210
|
+
if key.firmware is None:
|
|
211
|
+
matches.append(AdvisoryMatch(key.id, key.label, None, ()))
|
|
212
|
+
continue
|
|
213
|
+
matched = tuple(
|
|
214
|
+
sorted(
|
|
215
|
+
(
|
|
216
|
+
advisory
|
|
217
|
+
for advisory in candidates
|
|
218
|
+
if firmware_in_range(
|
|
219
|
+
key.firmware,
|
|
220
|
+
firmware_ge=advisory.affects.firmware_ge,
|
|
221
|
+
firmware_lt=advisory.affects.firmware_lt,
|
|
222
|
+
)
|
|
223
|
+
),
|
|
224
|
+
key=lambda advisory: advisory.id,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
if matched:
|
|
228
|
+
matches.append(AdvisoryMatch(key.id, key.label, key.firmware, matched))
|
|
229
|
+
return matches
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def discoverable_capacity(info: KeyModelInfo, firmware: str | None) -> int | None:
|
|
233
|
+
"""Resolve a key's discoverable-credential capacity, or None if indeterminate."""
|
|
234
|
+
rules = info.discoverable_capacity
|
|
235
|
+
if rules is None or isinstance(rules, int):
|
|
236
|
+
return rules
|
|
237
|
+
if firmware is None:
|
|
238
|
+
capacities = {rule.capacity for rule in rules}
|
|
239
|
+
return capacities.pop() if len(capacities) == 1 else None
|
|
240
|
+
for rule in rules:
|
|
241
|
+
if firmware_in_range(firmware, firmware_ge=rule.firmware_ge, firmware_lt=rule.firmware_lt):
|
|
242
|
+
return rule.capacity
|
|
243
|
+
return None
|
keyfleet/checks.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Pure check functions over a validated :class:`~keyfleet.model.Ledger`.
|
|
2
|
+
|
|
3
|
+
Checks take the loaded ledger and return findings; no file or terminal I/O
|
|
4
|
+
happens here (AGENTS.md §5). Rendering lives in :mod:`keyfleet.report`.
|
|
5
|
+
Core rules: brief §9.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from keyfleet.bundled import BundledData, discoverable_capacity, model_info_for_key
|
|
14
|
+
from keyfleet.model import Account, KeyStatus, Ledger, RegistrationType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Level(StrEnum):
|
|
18
|
+
FAIL = "FAIL"
|
|
19
|
+
WARN = "WARN"
|
|
20
|
+
INFO = "INFO"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_LEVEL_ORDER = {Level.FAIL: 0, Level.WARN: 1, Level.INFO: 2}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Finding:
|
|
28
|
+
"""One actionable result of one check."""
|
|
29
|
+
|
|
30
|
+
level: Level
|
|
31
|
+
check: str # machine-readable check id, e.g. "min-keys"
|
|
32
|
+
message: str
|
|
33
|
+
account_id: str | None = None
|
|
34
|
+
key_id: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Key statuses that count toward an account's coverage (brief §9): a lost or
|
|
38
|
+
#: retired key is not a backup.
|
|
39
|
+
COVERAGE_STATUSES = frozenset({KeyStatus.ACTIVE, KeyStatus.SPARE})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def covering_key_ids(ledger: Ledger, account: Account) -> set[str]:
|
|
43
|
+
"""Distinct keys registered on ``account`` whose status counts as coverage."""
|
|
44
|
+
status_by_id = {key.id: key.status for key in ledger.keys}
|
|
45
|
+
return {
|
|
46
|
+
registration.key
|
|
47
|
+
for registration in account.registrations
|
|
48
|
+
if status_by_id[registration.key] in COVERAGE_STATUSES
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_min_keys(ledger: Ledger) -> list[Finding]:
|
|
53
|
+
"""FAIL for every account with fewer counted keys than ``policy.min_keys[tier]``."""
|
|
54
|
+
findings: list[Finding] = []
|
|
55
|
+
for account in ledger.accounts:
|
|
56
|
+
required = ledger.policy.min_keys.for_tier(account.tier)
|
|
57
|
+
have = len(covering_key_ids(ledger, account))
|
|
58
|
+
if have < required:
|
|
59
|
+
noun = "hardware key" if have == 1 else "hardware keys"
|
|
60
|
+
findings.append(
|
|
61
|
+
Finding(
|
|
62
|
+
level=Level.FAIL,
|
|
63
|
+
check="min-keys",
|
|
64
|
+
message=(
|
|
65
|
+
f'{account.tier} "{account.label}" has {have} {noun} registered; '
|
|
66
|
+
f"policy requires {required}"
|
|
67
|
+
),
|
|
68
|
+
account_id=account.id,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return findings
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_lost_retired(ledger: Ledger) -> list[Finding]:
|
|
75
|
+
"""FAIL for every lost/retired key still carrying registrations.
|
|
76
|
+
|
|
77
|
+
Whoever holds the key can still use those credentials — de-register them
|
|
78
|
+
(``keyfleet lost KEY`` prints the ordered checklist).
|
|
79
|
+
"""
|
|
80
|
+
accounts_using: dict[str, int] = {}
|
|
81
|
+
for account in ledger.accounts:
|
|
82
|
+
for key_id in {registration.key for registration in account.registrations}:
|
|
83
|
+
accounts_using[key_id] = accounts_using.get(key_id, 0) + 1
|
|
84
|
+
findings: list[Finding] = []
|
|
85
|
+
for key in ledger.keys:
|
|
86
|
+
if key.status not in (KeyStatus.LOST, KeyStatus.RETIRED):
|
|
87
|
+
continue
|
|
88
|
+
count = accounts_using.get(key.id, 0)
|
|
89
|
+
if count:
|
|
90
|
+
noun = "account" if count == 1 else "accounts"
|
|
91
|
+
findings.append(
|
|
92
|
+
Finding(
|
|
93
|
+
level=Level.FAIL,
|
|
94
|
+
check="lost-retired",
|
|
95
|
+
message=(
|
|
96
|
+
f"Key {key.id} is {key.status.value.upper()} but still registered "
|
|
97
|
+
f"on {count} {noun} → run: keyfleet lost {key.id}"
|
|
98
|
+
),
|
|
99
|
+
key_id=key.id,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return findings
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_weak_factors(ledger: Ledger) -> list[Finding]:
|
|
106
|
+
"""WARN for every factor an account carries that its tier's policy warns against."""
|
|
107
|
+
findings: list[Finding] = []
|
|
108
|
+
for account in ledger.accounts:
|
|
109
|
+
warned = ledger.policy.warn_factors.get(account.tier, [])
|
|
110
|
+
findings.extend(
|
|
111
|
+
Finding(
|
|
112
|
+
level=Level.WARN,
|
|
113
|
+
check="weak-factor",
|
|
114
|
+
message=f'{account.tier} "{account.label}" lists {factor.value} as a factor',
|
|
115
|
+
account_id=account.id,
|
|
116
|
+
)
|
|
117
|
+
for factor in account.other_factors
|
|
118
|
+
if factor in warned
|
|
119
|
+
)
|
|
120
|
+
return findings
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_spare_unregistered(ledger: Ledger) -> list[Finding]:
|
|
124
|
+
"""WARN for spare keys registered nowhere — an unregistered spare is not a backup."""
|
|
125
|
+
registered = {
|
|
126
|
+
registration.key for account in ledger.accounts for registration in account.registrations
|
|
127
|
+
}
|
|
128
|
+
return [
|
|
129
|
+
Finding(
|
|
130
|
+
level=Level.WARN,
|
|
131
|
+
check="spare-unregistered",
|
|
132
|
+
message=(
|
|
133
|
+
f"Spare key {key.id} is registered nowhere "
|
|
134
|
+
"(a spare that isn't registered is not a backup)"
|
|
135
|
+
),
|
|
136
|
+
key_id=key.id,
|
|
137
|
+
)
|
|
138
|
+
for key in ledger.keys
|
|
139
|
+
if key.status is KeyStatus.SPARE and key.id not in registered
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_recovery_codes(ledger: Ledger) -> list[Finding]:
|
|
144
|
+
"""INFO when a tier that requires recovery codes has an account without a stored pointer."""
|
|
145
|
+
required = set(ledger.policy.require_recovery_codes_for)
|
|
146
|
+
findings: list[Finding] = []
|
|
147
|
+
for account in ledger.accounts:
|
|
148
|
+
if account.tier not in required:
|
|
149
|
+
continue
|
|
150
|
+
pointer = account.recovery_codes
|
|
151
|
+
if pointer is None:
|
|
152
|
+
detail = "has no recovery-code pointer"
|
|
153
|
+
elif not pointer.stored:
|
|
154
|
+
detail = "marks recovery codes as not stored"
|
|
155
|
+
else:
|
|
156
|
+
continue
|
|
157
|
+
findings.append(
|
|
158
|
+
Finding(
|
|
159
|
+
level=Level.INFO,
|
|
160
|
+
check="recovery-codes",
|
|
161
|
+
message=(
|
|
162
|
+
f'{account.tier} "{account.label}" {detail} '
|
|
163
|
+
f"(policy requires recovery codes for {account.tier})"
|
|
164
|
+
),
|
|
165
|
+
account_id=account.id,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return findings
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: Discoverable-credential usage fractions at which capacity findings appear.
|
|
172
|
+
CAPACITY_INFO_AT = 0.5
|
|
173
|
+
CAPACITY_WARN_AT = 0.9
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def check_capacity(ledger: Ledger, bundled: BundledData) -> list[Finding]:
|
|
177
|
+
"""Discoverable-credential usage per key vs models.yaml capacity.
|
|
178
|
+
|
|
179
|
+
Ledger-based estimate — it can undercount (registrations made outside the
|
|
180
|
+
ledger are invisible). INFO from 50% usage, WARN from 90%.
|
|
181
|
+
"""
|
|
182
|
+
counts: dict[str, int] = {}
|
|
183
|
+
for account in ledger.accounts:
|
|
184
|
+
for registration in account.registrations:
|
|
185
|
+
if registration.type is RegistrationType.FIDO2_DISCOVERABLE:
|
|
186
|
+
counts[registration.key] = counts.get(registration.key, 0) + 1
|
|
187
|
+
findings: list[Finding] = []
|
|
188
|
+
for key in ledger.keys:
|
|
189
|
+
count = counts.get(key.id, 0)
|
|
190
|
+
if not count:
|
|
191
|
+
continue
|
|
192
|
+
info = model_info_for_key(bundled.models, key)
|
|
193
|
+
capacity = discoverable_capacity(info, key.firmware) if info else None
|
|
194
|
+
if not capacity:
|
|
195
|
+
continue
|
|
196
|
+
usage = count / capacity
|
|
197
|
+
if usage >= CAPACITY_WARN_AT:
|
|
198
|
+
level, action = Level.WARN, "nearly full; free slots or add a key"
|
|
199
|
+
elif usage >= CAPACITY_INFO_AT:
|
|
200
|
+
level, action = Level.INFO, "plan capacity"
|
|
201
|
+
else:
|
|
202
|
+
continue
|
|
203
|
+
findings.append(
|
|
204
|
+
Finding(
|
|
205
|
+
level=level,
|
|
206
|
+
check="capacity",
|
|
207
|
+
message=(
|
|
208
|
+
f"{key.id}: {count}/{capacity} discoverable credentials "
|
|
209
|
+
f"(ledger count) — {action}"
|
|
210
|
+
),
|
|
211
|
+
key_id=key.id,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return findings
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_unknown_service(ledger: Ledger, bundled: BundledData) -> list[Finding]:
|
|
218
|
+
"""WARN when an account's service id names no bundled service ("other" is exempt).
|
|
219
|
+
|
|
220
|
+
Usually a typo; a correct id buys the account working links in
|
|
221
|
+
``keyfleet lost`` and ``keyfleet report``.
|
|
222
|
+
"""
|
|
223
|
+
return [
|
|
224
|
+
Finding(
|
|
225
|
+
level=Level.WARN,
|
|
226
|
+
check="unknown-service",
|
|
227
|
+
message=(
|
|
228
|
+
f'account "{account.label}": service "{account.service}" is not in the '
|
|
229
|
+
'bundled services.yaml — typo, or use "other" (contributions welcome)'
|
|
230
|
+
),
|
|
231
|
+
account_id=account.id,
|
|
232
|
+
)
|
|
233
|
+
for account in ledger.accounts
|
|
234
|
+
if account.service != "other" and account.service not in bundled.services
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
#: Ledger-only checks, in run order.
|
|
239
|
+
ALL_CHECKS = (
|
|
240
|
+
check_min_keys,
|
|
241
|
+
check_lost_retired,
|
|
242
|
+
check_weak_factors,
|
|
243
|
+
check_spare_unregistered,
|
|
244
|
+
check_recovery_codes,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
#: Checks that also need the bundled reference data.
|
|
248
|
+
DATA_CHECKS = (check_capacity, check_unknown_service)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def run_checks(ledger: Ledger, bundled: BundledData | None = None) -> list[Finding]:
|
|
252
|
+
"""Run every check; findings ordered FAIL → WARN → INFO, stable within a level.
|
|
253
|
+
|
|
254
|
+
Without ``bundled``, the data-driven checks (capacity, unknown service)
|
|
255
|
+
are skipped.
|
|
256
|
+
"""
|
|
257
|
+
findings: list[Finding] = []
|
|
258
|
+
for check in ALL_CHECKS:
|
|
259
|
+
findings.extend(check(ledger))
|
|
260
|
+
if bundled is not None:
|
|
261
|
+
for data_check in DATA_CHECKS:
|
|
262
|
+
findings.extend(data_check(ledger, bundled))
|
|
263
|
+
return sorted(findings, key=lambda finding: _LEVEL_ORDER[finding.level])
|