c7n-kit 1.0.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.
- c7n_kit/__init__.py +0 -0
- c7n_kit/cadence.py +181 -0
- c7n_kit/cli.py +37 -0
- c7n_kit/coverage.py +315 -0
- c7n_kit/dashboard.py +437 -0
- c7n_kit/gaps.py +413 -0
- c7n_kit/policies.py +147 -0
- c7n_kit/testing.py +382 -0
- c7n_kit-1.0.0.dist-info/METADATA +230 -0
- c7n_kit-1.0.0.dist-info/RECORD +14 -0
- c7n_kit-1.0.0.dist-info/WHEEL +5 -0
- c7n_kit-1.0.0.dist-info/entry_points.txt +2 -0
- c7n_kit-1.0.0.dist-info/licenses/LICENSE +202 -0
- c7n_kit-1.0.0.dist-info/top_level.txt +1 -0
c7n_kit/__init__.py
ADDED
|
File without changes
|
c7n_kit/cadence.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""c7n_kit/cadence.py -- grow without blowing up the window.
|
|
2
|
+
|
|
3
|
+
WHY CADENCE APPLIES PER TYPE AND NOT PER POLICY
|
|
4
|
+
`c7n-org` enumerates resources ONCE per policy file and then applies all
|
|
5
|
+
the filters over that same list. The cost of a run is driven by the
|
|
6
|
+
number of ENUMERATIONS, not the number of rules: adding a rule to a type
|
|
7
|
+
you already enumerate is nearly free (it's one more filter over data
|
|
8
|
+
already in memory); adding a new type costs a full enumeration across
|
|
9
|
+
every account and region.
|
|
10
|
+
|
|
11
|
+
Direct consequence: if cadence were declared and executed per policy, a
|
|
12
|
+
resource type with rules in both frequencies would get enumerated TWICE a
|
|
13
|
+
day -- once in the fast run, once in the slow one -- and separating would
|
|
14
|
+
end up MORE expensive than not separating at all. That's why cadence is
|
|
15
|
+
DECLARED per policy (in its `metadata.frequency`, which is where the
|
|
16
|
+
rule's author thinks about it) but EXECUTED per resource type, taking the
|
|
17
|
+
fastest one requested by any of its policies: that way each type gets
|
|
18
|
+
enumerated exactly once per run, always.
|
|
19
|
+
|
|
20
|
+
Taking the fastest one (not the slowest, not an average) is what
|
|
21
|
+
guarantees separating by cadence never degrades an existing detection: a
|
|
22
|
+
single high-frequency policy on a type whose rules are mostly slow
|
|
23
|
+
PROMOTES the whole type to the fast cadence. That costs more than it
|
|
24
|
+
would if that policy didn't exist, and that's why `promoted_to_fast`
|
|
25
|
+
exists: it's the concrete lever to bring the cost down (moving that one
|
|
26
|
+
policy alone to the slow cadence brings the whole type down with it).
|
|
27
|
+
|
|
28
|
+
WHY AN INVALID `frequency` RAISES INSTEAD OF FALLING BACK TO THE DEFAULT
|
|
29
|
+
A typo like `frequency: dayly` treated as if it were the default runs too
|
|
30
|
+
often if the default is the fast cadence (expensive, but visible: shows
|
|
31
|
+
up in the bill) or too rarely if the default is the slow one (cheap, but
|
|
32
|
+
invisible: the rule stops detecting at the frequency its author intended
|
|
33
|
+
and nobody notices until it was needed). Neither way of failing silently
|
|
34
|
+
is acceptable, so a value not in the list of valid cadences raises
|
|
35
|
+
`ValueError` the moment it's read, not later.
|
|
36
|
+
|
|
37
|
+
SHAPE OF `policy` OBJECTS
|
|
38
|
+
This file does NOT import the `Policy` dataclass from `c7n_kit/policies.py`
|
|
39
|
+
on purpose: every module in the kit has to be copyable on its own (see
|
|
40
|
+
CONTRACTS.md), and tying it to another module in the same kit breaks that
|
|
41
|
+
promise the moment someone copies one without the other. Instead, it
|
|
42
|
+
accesses the same three fields the `Policy` contract defines, via duck
|
|
43
|
+
typing:
|
|
44
|
+
|
|
45
|
+
.name str -- to say WHICH policy promoted a type
|
|
46
|
+
.resource str -- the resource type, canonical (prefixed with `aws.`)
|
|
47
|
+
.metadata dict -- the `metadata:` block as-is, `frequency` comes from there
|
|
48
|
+
|
|
49
|
+
Any object with those three attributes works, whether it's the kit's
|
|
50
|
+
real `Policy`, a dict wrapped in `types.SimpleNamespace`, or the result
|
|
51
|
+
of your own YAML parser.
|
|
52
|
+
"""
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
# Valid cadences, ORDERED from fastest to slowest. This order is what
|
|
56
|
+
# defines "the fastest wins": adding a new cadence just means inserting
|
|
57
|
+
# it at the right position in this tuple, nothing else.
|
|
58
|
+
DEFAULT_CADENCES = ("4h", "daily")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _metadata_of(policy) -> dict:
|
|
62
|
+
return getattr(policy, "metadata", None) or {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _name_of(policy) -> str:
|
|
66
|
+
return getattr(policy, "name", None) or "(unnamed)"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resource_of(policy) -> str:
|
|
70
|
+
resource = getattr(policy, "resource", None)
|
|
71
|
+
if not resource:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"policy {_name_of(policy)!r}: doesn't declare `resource` (resource "
|
|
74
|
+
f"type), cadence cannot be grouped without it")
|
|
75
|
+
return resource
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def policy_cadence(policy, cadences=DEFAULT_CADENCES) -> str:
|
|
79
|
+
"""The cadence declared by ONE policy, validated.
|
|
80
|
+
|
|
81
|
+
The default (when `metadata.frequency` isn't present) is
|
|
82
|
+
`cadences[0]`, i.e. the FASTEST in the list. This choice is
|
|
83
|
+
deliberate: the default has to be the expensive one, so forgetting to
|
|
84
|
+
declare `frequency` costs money (visible in the bill) and not
|
|
85
|
+
invisibility (a rule that thinks it runs every 4h but actually runs
|
|
86
|
+
once a day). If your organization prefers the opposite default, pass
|
|
87
|
+
`cadences` with the order that fits: the first one always wins as
|
|
88
|
+
both the default and the "fastest."
|
|
89
|
+
"""
|
|
90
|
+
metadata = _metadata_of(policy)
|
|
91
|
+
value = metadata.get("frequency", cadences[0])
|
|
92
|
+
if value not in cadences:
|
|
93
|
+
# Raise instead of falling back to the default: see module docstring.
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"policy {_name_of(policy)!r}: frequency={value!r} is not a "
|
|
96
|
+
f"valid cadence. Valid ones: {list(cadences)}")
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _group_by_resource(policies):
|
|
101
|
+
by_resource: dict[str, list] = {}
|
|
102
|
+
for p in policies:
|
|
103
|
+
by_resource.setdefault(_resource_of(p), []).append(p)
|
|
104
|
+
return by_resource
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cadence_by_type(policies, cadences=DEFAULT_CADENCES) -> dict[str, str]:
|
|
108
|
+
"""{resource_type: cadence}, taking the FASTEST one requested by any
|
|
109
|
+
of the policies of that type.
|
|
110
|
+
|
|
111
|
+
`policies` is any iterable of objects with `.resource` and `.metadata`
|
|
112
|
+
(see module docstring). Every invalid `frequency` raises as soon as
|
|
113
|
+
that policy is evaluated -- there's no way for a typo to slip through
|
|
114
|
+
unnoticed, not even on a type where it ends up not mattering because
|
|
115
|
+
another policy already fixed the fast cadence.
|
|
116
|
+
"""
|
|
117
|
+
by_resource = _group_by_resource(policies)
|
|
118
|
+
result = {}
|
|
119
|
+
for resource, items in by_resource.items():
|
|
120
|
+
requested = {policy_cadence(p, cadences) for p in items}
|
|
121
|
+
result[resource] = min(requested, key=cadences.index)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def promoted_to_fast(policies, cadences=DEFAULT_CADENCES
|
|
126
|
+
) -> dict[str, list[str]]:
|
|
127
|
+
"""{resource_type: [policy names]} for the types that ended up on the
|
|
128
|
+
FAST cadence (`cadences[0]`) because of a single policy, while the
|
|
129
|
+
rest of their rules asked for something slower.
|
|
130
|
+
|
|
131
|
+
This is the concrete lever to bring down the cost of a run: a type on
|
|
132
|
+
this list gets enumerated at the full expensive frequency because of
|
|
133
|
+
ONE rule. Moving it alone to a slower cadence brings the whole type
|
|
134
|
+
down with it, without touching any other policy.
|
|
135
|
+
|
|
136
|
+
A type where ALL policies already ask for the fast cadence doesn't
|
|
137
|
+
show up here: there's nothing to "promote," that's simply the cadence
|
|
138
|
+
the whole type is entitled to on its own.
|
|
139
|
+
"""
|
|
140
|
+
fast = cadences[0]
|
|
141
|
+
by_resource = _group_by_resource(policies)
|
|
142
|
+
result = {}
|
|
143
|
+
for resource, items in by_resource.items():
|
|
144
|
+
requested = {policy_cadence(p, cadences) for p in items}
|
|
145
|
+
if len(requested) > 1 and min(requested, key=cadences.index) == fast:
|
|
146
|
+
culprits = sorted(
|
|
147
|
+
_name_of(p) for p in items
|
|
148
|
+
if policy_cadence(p, cadences) == fast)
|
|
149
|
+
result[resource] = culprits
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cli(argv=None):
|
|
154
|
+
"""What runs on each cadence, from the command line."""
|
|
155
|
+
import sys
|
|
156
|
+
from c7n_kit.policies import load
|
|
157
|
+
|
|
158
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
159
|
+
if len(argv) != 1:
|
|
160
|
+
sys.exit("usage: python -m c7n_kit.cadence <policies-directory>")
|
|
161
|
+
|
|
162
|
+
ps = load(argv[0])
|
|
163
|
+
by_type = cadence_by_type(ps)
|
|
164
|
+
for resource_type, cad in sorted(by_type.items()):
|
|
165
|
+
print(f" {resource_type:<24} {cad}")
|
|
166
|
+
|
|
167
|
+
promoted = promoted_to_fast(ps)
|
|
168
|
+
print()
|
|
169
|
+
if promoted:
|
|
170
|
+
# The actionable data: moving that single policy brings the whole
|
|
171
|
+
# type down.
|
|
172
|
+
print("types on the fast cadence because of ONE policy:")
|
|
173
|
+
for resource_type, policy in sorted(promoted.items()) if isinstance(promoted, dict) else promoted:
|
|
174
|
+
print(f" {resource_type:<24} promoted by: {policy}")
|
|
175
|
+
else:
|
|
176
|
+
print("promoted to the fast cadence: none")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
raise SystemExit(_cli())
|
c7n_kit/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Unified entrypoint for the `c7n-kit` console script.
|
|
2
|
+
|
|
3
|
+
Each subcommand dispatches to the same `_cli(argv)` function that module's
|
|
4
|
+
own `python -m c7n_kit.<module>` invocation already calls, so both
|
|
5
|
+
invocation styles produce byte-identical output. Nothing here duplicates
|
|
6
|
+
logic that lives in `coverage.py`, `cadence.py` or `dashboard.py`.
|
|
7
|
+
|
|
8
|
+
`gaps.py` has no subcommand here: unlike the other three modules it has no
|
|
9
|
+
`if __name__ == "__main__":` block, because it is meant to be used as a
|
|
10
|
+
library fed a `c7n-org` log rather than invoked directly.
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
USAGE = "usage: c7n-kit <coverage|cadence|dashboard> [args...]"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
19
|
+
|
|
20
|
+
if not argv or argv[0] not in ("coverage", "cadence", "dashboard"):
|
|
21
|
+
print(USAGE, file=sys.stderr)
|
|
22
|
+
return 1
|
|
23
|
+
|
|
24
|
+
subcommand, rest = argv[0], argv[1:]
|
|
25
|
+
|
|
26
|
+
if subcommand == "coverage":
|
|
27
|
+
from c7n_kit.coverage import _cli
|
|
28
|
+
elif subcommand == "cadence":
|
|
29
|
+
from c7n_kit.cadence import _cli
|
|
30
|
+
else:
|
|
31
|
+
from c7n_kit.dashboard import _cli
|
|
32
|
+
|
|
33
|
+
return _cli(rest)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
raise SystemExit(main())
|
c7n_kit/coverage.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Cross-checks what policies CLAIM to cover against a catalog of controls.
|
|
2
|
+
|
|
3
|
+
This module doesn't measure whether a policy works: it measures whether
|
|
4
|
+
the policy -> control mapping is honest. There are two ways it can be
|
|
5
|
+
dishonest, and both are detectable without running anything:
|
|
6
|
+
|
|
7
|
+
1. A policy cites a control, FROM THE FAMILY OF THE CATALOG PASSED IN,
|
|
8
|
+
that doesn't exist in that catalog (typo, ID of a retired control,
|
|
9
|
+
an old version of the framework). If that were ignored, the
|
|
10
|
+
reported coverage would look higher than it really is and nobody
|
|
11
|
+
would notice until an external audit. That's why `orphans` is a
|
|
12
|
+
required field of `Coverage`, not an optional detail.
|
|
13
|
+
2. A policy declares `metadata.framework` (singular, a string) AND
|
|
14
|
+
`metadata.frameworks` (plural, a list) at the same time. There's no
|
|
15
|
+
reasonable "who wins" rule: whichever one the code picks, someone
|
|
16
|
+
is going to edit the other thinking it has an effect, and nothing
|
|
17
|
+
will happen. That's why this raises instead of resolving silently.
|
|
18
|
+
|
|
19
|
+
THE FAMILY FILTER (why it exists)
|
|
20
|
+
A real policy maps to several frameworks at once, e.g.
|
|
21
|
+
`frameworks: ['CIS 5.6', 'FSBP EC2.8']`. If `coverage()` is called with
|
|
22
|
+
only the FSBP catalog, `CIS 5.6` can't be treated as an orphan: it's not
|
|
23
|
+
that the control doesn't exist, it's that this control isn't this
|
|
24
|
+
cross-check's business. The first version of this module didn't make
|
|
25
|
+
this distinction and treated any control missing from the catalog passed
|
|
26
|
+
in as an orphan -- the result was that `orphans` screamed on EVERY run
|
|
27
|
+
(with the controls from the other frameworks the policy also cites), it
|
|
28
|
+
became useless as a signal, and whoever consumed it ended up muting it.
|
|
29
|
+
That's why `coverage()` first computes which families the catalog has
|
|
30
|
+
(`evaluated_families`) and discards, without adding them to
|
|
31
|
+
covered/missing/orphans, any control cited from ANY OTHER family. Only
|
|
32
|
+
what is the catalog's own business enters the cross-check.
|
|
33
|
+
|
|
34
|
+
Rule of the kit: AN ABSENCE IS NOT A ZERO. If the catalog has no control
|
|
35
|
+
from a given family, that family simply doesn't appear in `by_family` --
|
|
36
|
+
it doesn't show up as "(0, 0)" mixed in with families that do exist and
|
|
37
|
+
are at real zero coverage. For the same reason, `evaluated_families`
|
|
38
|
+
exists so a "21% coverage" figure is never read without knowing over
|
|
39
|
+
which universe that percentage was computed.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import re
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
# Special case: "SOX ITGC" is a TWO-word family (unlike "FSBP", "CIS",
|
|
49
|
+
# "PCI"). Without this special case, `_family("SOX ITGC 1.1")` would
|
|
50
|
+
# return "SOX" alone, that "family" would never match the real family of
|
|
51
|
+
# the SOX catalog ("SOX ITGC"), and any policy citing a SOX control would
|
|
52
|
+
# end up silently dropped from the cross-check -- exactly the kind of
|
|
53
|
+
# "absence read as zero" this kit exists to prevent. It's declared HERE
|
|
54
|
+
# (not only in `_family`) because it's also needed to validate a catalog
|
|
55
|
+
# line: a "SOX ITGC 1.1" line has TWO spaces, and without this special
|
|
56
|
+
# case the SOX catalog couldn't even load.
|
|
57
|
+
_TWO_WORD_FAMILIES = ("SOX ITGC",)
|
|
58
|
+
|
|
59
|
+
# A catalog line is "<FAMILY> <ID>" with one separating space, except for
|
|
60
|
+
# the two-word-family special case (`_TWO_WORD_FAMILIES`), where it's two
|
|
61
|
+
# spaces: "SOX ITGC" + " " + "<ID>".
|
|
62
|
+
# FAMILY: letters (possibly with digits/dashes, e.g. "PCI", "CIS-AWS").
|
|
63
|
+
# ID: alphanumeric with dots (e.g. "RDS.1", "2.1.1", "3.5.1"). Any other
|
|
64
|
+
# shape (no space, with tabs, extra columns) is a formatting error:
|
|
65
|
+
# better to raise now than let a control through that will never match in
|
|
66
|
+
# `coverage()` and shows up as "missing" forever.
|
|
67
|
+
_TWO_WORD_FAMILY_ALT = "|".join(re.escape(f) for f in _TWO_WORD_FAMILIES)
|
|
68
|
+
_CATALOG_LINE_RE = re.compile(
|
|
69
|
+
rf"^(?:{_TWO_WORD_FAMILY_ALT}|[A-Za-z][A-Za-z0-9_-]*) [A-Za-z0-9][A-Za-z0-9.]*$"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AmbiguousMetadataError(ValueError):
|
|
74
|
+
"""A policy declares `framework` and `frameworks` at the same time.
|
|
75
|
+
|
|
76
|
+
See the module docstring: there's no safe tiebreaker rule, so instead
|
|
77
|
+
of picking one and surprising whoever edits the other, loading stops
|
|
78
|
+
here.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class InvalidCatalogError(ValueError):
|
|
83
|
+
"""The catalog file has duplicate or malformed lines.
|
|
84
|
+
|
|
85
|
+
A duplicate inflates `by_family` (it counts the same control twice
|
|
86
|
+
as "total"). A malformed line will never match any real
|
|
87
|
+
`metadata.frameworks`, so the control is doomed to appear in
|
|
88
|
+
`missing` forever even if it's covered -- neither case is allowed to
|
|
89
|
+
pass quietly.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Catalog(list):
|
|
94
|
+
"""The list of controls, which ALSO knows whether it's complete.
|
|
95
|
+
|
|
96
|
+
It's a subclass of `list` on purpose: the contract says
|
|
97
|
+
`load_catalog` returns `list[str]`, and that's still true, but this
|
|
98
|
+
way completeness travels attached to the data instead of being a
|
|
99
|
+
separate parameter the caller can forget to pass.
|
|
100
|
+
|
|
101
|
+
And forgetting it wouldn't be a minor detail. An incomplete catalog
|
|
102
|
+
produces false orphans (a policy cites a control that exists but
|
|
103
|
+
isn't in the file) and coverage that lies low. If completeness
|
|
104
|
+
traveled as a separate boolean, the day someone doesn't pass it the
|
|
105
|
+
report claims a completeness that nobody verified.
|
|
106
|
+
|
|
107
|
+
`complete is None` means the catalog doesn't declare it. It does
|
|
108
|
+
NOT mean complete.
|
|
109
|
+
"""
|
|
110
|
+
__slots__ = ("complete", "path")
|
|
111
|
+
|
|
112
|
+
def __init__(self, items, complete=None, path=None):
|
|
113
|
+
super().__init__(items)
|
|
114
|
+
self.complete = complete
|
|
115
|
+
self.path = path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Coverage:
|
|
120
|
+
covered: dict[str, list[str]] # control -> names of the policies covering it
|
|
121
|
+
missing: list[str] # catalog controls with no policy at all
|
|
122
|
+
orphans: list[str] # controls cited from the catalog's own family/families that are NOT in it
|
|
123
|
+
by_family: dict[str, tuple[int, int]] # family -> (covered, total)
|
|
124
|
+
families_evaluated: list[str] # families the catalog passed to this run had
|
|
125
|
+
# `True`/`False` as declared by the catalog, `None` if it doesn't declare it.
|
|
126
|
+
# A percentage computed over an incomplete catalog is not comparable to
|
|
127
|
+
# one computed over a complete one, and whoever reads the number needs
|
|
128
|
+
# to be able to tell which of the two they're looking at.
|
|
129
|
+
catalog_complete: bool | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_COMPLETE_RE = re.compile(r'^#\s*complete:\s*(yes|no)\b', re.I | re.M)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_catalog(path: str) -> list[str]:
|
|
136
|
+
"""Reads a catalog file (one control per line) and validates it.
|
|
137
|
+
|
|
138
|
+
Doesn't delegate validation to `coverage()` because by the time the
|
|
139
|
+
catalog gets there it's too late to tell whether a "missing" control
|
|
140
|
+
is a real, unimplemented control or a control that could never match
|
|
141
|
+
because the file had a tab instead of a space. It's validated here,
|
|
142
|
+
with the file in view, so the error can point at the line number.
|
|
143
|
+
"""
|
|
144
|
+
controls: list[str] = []
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
duplicates: set[str] = set()
|
|
147
|
+
malformed: list[tuple[int, str]] = []
|
|
148
|
+
|
|
149
|
+
with Path(path).open("r", encoding="utf-8") as f:
|
|
150
|
+
for number, raw_line in enumerate(f, start=1):
|
|
151
|
+
line = raw_line.rstrip("\n")
|
|
152
|
+
stripped = line.strip()
|
|
153
|
+
if not stripped or stripped.startswith("#"):
|
|
154
|
+
continue # blank line or comment: not a control
|
|
155
|
+
if not _CATALOG_LINE_RE.match(stripped):
|
|
156
|
+
malformed.append((number, line))
|
|
157
|
+
continue
|
|
158
|
+
if stripped in seen:
|
|
159
|
+
duplicates.add(stripped)
|
|
160
|
+
seen.add(stripped)
|
|
161
|
+
controls.append(stripped)
|
|
162
|
+
|
|
163
|
+
if malformed or duplicates:
|
|
164
|
+
parts = []
|
|
165
|
+
if malformed:
|
|
166
|
+
detail = ", ".join(f"line {n}: {l!r}" for n, l in malformed)
|
|
167
|
+
parts.append(f"malformed ({detail})")
|
|
168
|
+
if duplicates:
|
|
169
|
+
parts.append(f"duplicated ({', '.join(sorted(duplicates))})")
|
|
170
|
+
raise InvalidCatalogError(f"{path}: " + "; ".join(parts))
|
|
171
|
+
|
|
172
|
+
_m = _COMPLETE_RE.search(Path(path).read_text(encoding="utf-8"))
|
|
173
|
+
_complete = None if _m is None else _m.group(1).lower() == 'yes'
|
|
174
|
+
return Catalog(controls, complete=_complete, path=path)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _family(control: str) -> str:
|
|
178
|
+
"""Returns the family of a control ("FSBP", "CIS", "PCI", "SOX ITGC").
|
|
179
|
+
|
|
180
|
+
Used both to classify what a policy cites and to see which families a
|
|
181
|
+
catalog has -- both sides of `coverage()` have to use the same rule
|
|
182
|
+
to be comparable.
|
|
183
|
+
"""
|
|
184
|
+
for family in _TWO_WORD_FAMILIES:
|
|
185
|
+
if control == family or control.startswith(family + " "):
|
|
186
|
+
return family
|
|
187
|
+
return control.split(" ", 1)[0]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _declared_controls(policy) -> list[str]:
|
|
191
|
+
"""Returns the controls a policy claims to cover, or [] if it claims none."""
|
|
192
|
+
frameworks = policy.metadata.get("frameworks")
|
|
193
|
+
framework = policy.metadata.get("framework")
|
|
194
|
+
|
|
195
|
+
if frameworks is not None and framework is not None:
|
|
196
|
+
raise AmbiguousMetadataError(
|
|
197
|
+
f"{policy.name} ({policy.file}) declares 'framework' and "
|
|
198
|
+
"'frameworks' at the same time"
|
|
199
|
+
)
|
|
200
|
+
if frameworks is not None:
|
|
201
|
+
return list(frameworks)
|
|
202
|
+
if framework is not None:
|
|
203
|
+
return [framework]
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def coverage(policies, catalog: list[str]) -> Coverage:
|
|
208
|
+
"""Cross-checks `policies` against `catalog` and builds the coverage report.
|
|
209
|
+
|
|
210
|
+
`catalog` is the source of truth for which controls exist FOR ITS
|
|
211
|
+
OWN FAMILY/FAMILIES (see `families_evaluated`). A real policy cites
|
|
212
|
+
controls from several frameworks at once
|
|
213
|
+
(`frameworks: ['CIS 5.6', 'FSBP EC2.8']`); if this function is called
|
|
214
|
+
with only the FSBP catalog, `CIS 5.6` is neither orphan nor missing
|
|
215
|
+
nor covered -- it's a control from a family this run never evaluated.
|
|
216
|
+
Only what belongs to the catalog's own family/families enters the
|
|
217
|
+
cross-check; everything else is ignored ON PURPOSE (see the module
|
|
218
|
+
docstring, section "THE FAMILY FILTER").
|
|
219
|
+
"""
|
|
220
|
+
catalog_set = set(catalog) # defensive dedup; the real validation lives in load_catalog
|
|
221
|
+
catalog_families = {_family(c) for c in catalog_set}
|
|
222
|
+
|
|
223
|
+
covered: dict[str, list[str]] = {}
|
|
224
|
+
orphans_set: set[str] = set()
|
|
225
|
+
|
|
226
|
+
for policy in policies:
|
|
227
|
+
for control in _declared_controls(policy):
|
|
228
|
+
if _family(control) not in catalog_families:
|
|
229
|
+
continue # not this catalog's business: neither covers, nor is missing, nor an orphan
|
|
230
|
+
if control in catalog_set:
|
|
231
|
+
covered.setdefault(control, []).append(policy.name)
|
|
232
|
+
else:
|
|
233
|
+
orphans_set.add(control)
|
|
234
|
+
|
|
235
|
+
missing = sorted(c for c in catalog_set if c not in covered)
|
|
236
|
+
orphans = sorted(orphans_set)
|
|
237
|
+
|
|
238
|
+
families_total: dict[str, int] = {}
|
|
239
|
+
families_covered: dict[str, int] = {}
|
|
240
|
+
for control in catalog_set:
|
|
241
|
+
family = _family(control)
|
|
242
|
+
families_total[family] = families_total.get(family, 0) + 1
|
|
243
|
+
if control in covered:
|
|
244
|
+
families_covered[family] = families_covered.get(family, 0) + 1
|
|
245
|
+
|
|
246
|
+
by_family = {
|
|
247
|
+
family: (families_covered.get(family, 0), total)
|
|
248
|
+
for family, total in families_total.items()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Completeness travels with the catalog, not as a separate parameter:
|
|
252
|
+
# if it were a parameter, the day someone doesn't pass it the report
|
|
253
|
+
# would claim a completeness nobody verified.
|
|
254
|
+
return Coverage(
|
|
255
|
+
covered=covered,
|
|
256
|
+
missing=missing,
|
|
257
|
+
orphans=orphans,
|
|
258
|
+
by_family=by_family,
|
|
259
|
+
families_evaluated=sorted(catalog_families),
|
|
260
|
+
# `getattr`, not direct access: `coverage()` accepts any iterable
|
|
261
|
+
# of controls, not just a `Catalog`. If a plain list is passed,
|
|
262
|
+
# completeness stays `None`, which means "unknown," not
|
|
263
|
+
# "complete."
|
|
264
|
+
catalog_complete=getattr(catalog, "complete", None),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _cli(argv=None):
|
|
269
|
+
"""Coverage report from the command line.
|
|
270
|
+
|
|
271
|
+
Without this, `python -m c7n_kit.coverage` imports the module, does
|
|
272
|
+
nothing and exits 0. A command that exits clean without having done
|
|
273
|
+
anything is the same shape of bug this kit chases, and this repo's
|
|
274
|
+
README had it until this got run.
|
|
275
|
+
"""
|
|
276
|
+
import sys
|
|
277
|
+
from c7n_kit.policies import load
|
|
278
|
+
|
|
279
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
280
|
+
if len(argv) != 2:
|
|
281
|
+
sys.exit("usage: python -m c7n_kit.coverage <catalog.txt> <policies-directory>")
|
|
282
|
+
|
|
283
|
+
catalog = load_catalog(argv[0])
|
|
284
|
+
c = coverage(load(argv[1]), catalog)
|
|
285
|
+
total = len(c.covered) + len(c.missing)
|
|
286
|
+
|
|
287
|
+
for fam in c.families_evaluated:
|
|
288
|
+
cov, tot = c.by_family.get(fam, (0, 0))
|
|
289
|
+
pct = f"{100 * cov // tot}%" if tot else "-"
|
|
290
|
+
print(f"{fam} {cov}/{tot} controls ({pct})")
|
|
291
|
+
|
|
292
|
+
# Completeness is ALWAYS printed, even when it's True. A percentage
|
|
293
|
+
# without its declared denominator can't be taken to an audit, and
|
|
294
|
+
# only showing the notice in the bad case trains people not to look
|
|
295
|
+
# for it.
|
|
296
|
+
if c.catalog_complete is True:
|
|
297
|
+
print(f"catalog complete: yes")
|
|
298
|
+
elif c.catalog_complete is False:
|
|
299
|
+
print(f"catalog complete: NO -- the percentage is a floor, not the real number")
|
|
300
|
+
else:
|
|
301
|
+
print(f"catalog complete: undeclared -- the denominator cannot be asserted")
|
|
302
|
+
|
|
303
|
+
if c.orphans:
|
|
304
|
+
print(f"\norphans ({len(c.orphans)}) -- cited by a policy and "
|
|
305
|
+
f"absent from the catalog, they add coverage that doesn't exist:")
|
|
306
|
+
for h in c.orphans:
|
|
307
|
+
print(f" {h}")
|
|
308
|
+
else:
|
|
309
|
+
print("orphans: none")
|
|
310
|
+
|
|
311
|
+
return 0 if not c.orphans else 1
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
raise SystemExit(_cli())
|