flagrante 0.0.1__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.
flagrante/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """flagrante -- which of your components start a CRA Article 14 24-hour clock."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from .classify import Assessment, Finding, Tier, assess, early_warning_fields
6
+ from .sbom import Component, SBOMDocument, SBOMError, parse, parse_file
7
+ from .scan import scan_document, scan_file, scan_text
8
+ from .sources import FeedError
9
+
10
+ __all__ = [
11
+ "Assessment",
12
+ "Component",
13
+ "FeedError",
14
+ "Finding",
15
+ "SBOMDocument",
16
+ "SBOMError",
17
+ "Tier",
18
+ "assess",
19
+ "early_warning_fields",
20
+ "parse",
21
+ "parse_file",
22
+ "scan_document",
23
+ "scan_file",
24
+ "scan_text",
25
+ "__version__",
26
+ ]
flagrante/classify.py ADDED
@@ -0,0 +1,273 @@
1
+ """Exposure classification for CRA Article 14.
2
+
3
+ The single most important design rule in this file: there is no "clear" tier.
4
+
5
+ Article 14 obliges a manufacturer to send an early warning within 24 hours of
6
+ becoming aware that a vulnerability *contained in their product* is *actively
7
+ exploited*. This tool can establish that a component carries a CVE, and that
8
+ the CVE is known to be exploited somewhere in the world. It cannot establish
9
+ that the vulnerable code path ships in, or is reachable from, a given product.
10
+
11
+ So every outcome here is an instruction to assess, never a permission to stand
12
+ down. A tool that tells a manufacturer they need not report, and is wrong,
13
+ costs them a fine of up to EUR 15 million. A tool that over-flags costs them
14
+ an afternoon. The asymmetry decides every threshold below.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timedelta, timezone
21
+ from enum import Enum
22
+
23
+ from .sbom import Component, SBOMDocument
24
+ from .sources import KevEntry, Vulnerability
25
+
26
+ # EPSS is a probability of exploitation within 30 days. The vast majority of
27
+ # CVEs sit below 0.01; 0.10 is roughly the top few per cent of the corpus and
28
+ # is a widely used operational cut-off for "treat this as likely".
29
+ EPSS_ELEVATED = 0.10
30
+ EPSS_HIGH = 0.30
31
+
32
+ # Article 14 reporting cascade, from the moment of awareness.
33
+ EARLY_WARNING_HOURS = 24
34
+ NOTIFICATION_HOURS = 72
35
+ FINAL_REPORT_DAYS = 14
36
+
37
+
38
+ class Tier(str, Enum):
39
+ """Ordered by urgency. Note the absence of anything meaning 'compliant'."""
40
+
41
+ CLOCK_RUNNING = "clock_running"
42
+ URGENT_REVIEW = "urgent_review"
43
+ MONITOR = "monitor"
44
+ UNRESOLVABLE = "unresolvable"
45
+
46
+ @property
47
+ def headline(self) -> str:
48
+ return {
49
+ Tier.CLOCK_RUNNING: "24-hour clock likely running",
50
+ Tier.URGENT_REVIEW: "Assess today",
51
+ Tier.MONITOR: "Track",
52
+ Tier.UNRESOLVABLE: "Cannot be checked",
53
+ }[self]
54
+
55
+ @property
56
+ def rank(self) -> int:
57
+ return {
58
+ Tier.CLOCK_RUNNING: 0,
59
+ Tier.URGENT_REVIEW: 1,
60
+ Tier.MONITOR: 2,
61
+ Tier.UNRESOLVABLE: 3,
62
+ }[self]
63
+
64
+
65
+ @dataclass
66
+ class Finding:
67
+ component: Component
68
+ vulnerability: Vulnerability
69
+ tier: Tier
70
+ cves: tuple[str, ...]
71
+ kev: KevEntry | None = None
72
+ epss: float | None = None
73
+
74
+ @property
75
+ def primary_cve(self) -> str:
76
+ return self.cves[0] if self.cves else self.vulnerability.id
77
+
78
+ @property
79
+ def reason(self) -> str:
80
+ if self.tier is Tier.CLOCK_RUNNING and self.kev is not None:
81
+ ransom = ", linked to ransomware campaigns" if self.kev.ransomware else ""
82
+ return (
83
+ f"listed in CISA KEV since {self.kev.date_added} as exploited "
84
+ f"in the wild{ransom}"
85
+ )
86
+ if self.tier is Tier.URGENT_REVIEW and self.epss is not None:
87
+ return (
88
+ f"EPSS {self.epss:.0%} probability of exploitation within 30 days "
89
+ "-- not yet confirmed exploited, but above the threshold where "
90
+ "confirmation often follows"
91
+ )
92
+ if self.epss is not None:
93
+ return f"known vulnerability, EPSS {self.epss:.1%}"
94
+ return "known vulnerability, no exploitation signal"
95
+
96
+
97
+ @dataclass
98
+ class Assessment:
99
+ document: SBOMDocument
100
+ findings: list[Finding] = field(default_factory=list)
101
+ unresolvable: list[Component] = field(default_factory=list)
102
+ scanned_at: datetime = field(
103
+ default_factory=lambda: datetime.now(timezone.utc)
104
+ )
105
+ components_checked: int = 0
106
+
107
+ def of_tier(self, tier: Tier) -> list[Finding]:
108
+ return [f for f in self.findings if f.tier is tier]
109
+
110
+ def components_of_tier(self, tier: Tier) -> list[Component]:
111
+ """Distinct components at a tier.
112
+
113
+ One component routinely carries several CVEs, so counting findings and
114
+ calling them components overstates the blast radius. In a tool whose
115
+ only asset is being believed, that is not a rounding error.
116
+ """
117
+ seen: dict[str, Component] = {}
118
+ for finding in self.of_tier(tier):
119
+ key = finding.component.purl or finding.component.label
120
+ seen.setdefault(key, finding.component)
121
+ return list(seen.values())
122
+
123
+ @property
124
+ def clock_running(self) -> list[Finding]:
125
+ return self.of_tier(Tier.CLOCK_RUNNING)
126
+
127
+ @property
128
+ def urgent(self) -> list[Finding]:
129
+ return self.of_tier(Tier.URGENT_REVIEW)
130
+
131
+ @property
132
+ def monitor(self) -> list[Finding]:
133
+ return self.of_tier(Tier.MONITOR)
134
+
135
+ @property
136
+ def deadlines(self) -> dict[str, datetime]:
137
+ """Article 14 cascade, counted from now.
138
+
139
+ Presented only when something is flagged, and always framed as counting
140
+ from the moment of awareness -- which may well predate this scan.
141
+ """
142
+ return {
143
+ "early_warning": self.scanned_at + timedelta(hours=EARLY_WARNING_HOURS),
144
+ "notification": self.scanned_at + timedelta(hours=NOTIFICATION_HOURS),
145
+ "final_report": self.scanned_at + timedelta(days=FINAL_REPORT_DAYS),
146
+ }
147
+
148
+ @property
149
+ def verdict(self) -> str:
150
+ """Deliberately never says 'compliant', 'clear', or 'no action needed'."""
151
+ if self.clock_running:
152
+ components = len(self.components_of_tier(Tier.CLOCK_RUNNING))
153
+ vulns = len(self.clock_running)
154
+ noun = "component" if components == 1 else "components"
155
+ verb = "carries" if components == 1 else "carry"
156
+ count = (
157
+ "a vulnerability"
158
+ if vulns == 1
159
+ else f"{vulns} vulnerabilities"
160
+ )
161
+ return (
162
+ f"{components} {noun} {verb} {count} confirmed exploited in the "
163
+ "wild. If any of these ship in a product you place on the EU "
164
+ "market, assess Article 14 reporting now."
165
+ )
166
+ if self.urgent:
167
+ components = len(self.components_of_tier(Tier.URGENT_REVIEW))
168
+ noun = "component" if components == 1 else "components"
169
+ verb = "carries" if components == 1 else "carry"
170
+ return (
171
+ f"No confirmed-exploited component found, but {components} "
172
+ f"{noun} {verb} a vulnerability with elevated exploitation "
173
+ "probability. These are the ones that become Article 14 "
174
+ "obligations first."
175
+ )
176
+ if self.unresolvable:
177
+ return (
178
+ "No exploitation signal among the components we could identify. "
179
+ f"{len(self.unresolvable)} component(s) carried no package URL and "
180
+ "could not be checked at all -- that is an unknown, not a clear."
181
+ )
182
+ return (
183
+ "No exploitation signal found in this SBOM at this moment. That is a "
184
+ "point-in-time observation about known feeds, not a determination "
185
+ "that no reporting obligation exists."
186
+ )
187
+
188
+
189
+ def _tier_for(kev: KevEntry | None, epss: float | None) -> Tier:
190
+ if kev is not None:
191
+ return Tier.CLOCK_RUNNING
192
+ if epss is not None and epss >= EPSS_ELEVATED:
193
+ return Tier.URGENT_REVIEW
194
+ return Tier.MONITOR
195
+
196
+
197
+ def assess(
198
+ document: SBOMDocument,
199
+ osv_by_purl: dict[str, list[str]],
200
+ vulnerabilities: dict[str, Vulnerability],
201
+ kev: dict[str, KevEntry],
202
+ epss: dict[str, float],
203
+ ) -> Assessment:
204
+ """Join component -> vulnerability -> exploitation status into findings."""
205
+ result = Assessment(
206
+ document=document,
207
+ unresolvable=list(document.unresolvable),
208
+ components_checked=len(document.identifiable),
209
+ )
210
+
211
+ # Several OSV advisories routinely alias the same CVE (a GHSA record and
212
+ # the CVE record itself, say). Reporting that component/CVE pair twice
213
+ # inflates the count and reads as a bug to anyone who knows the ecosystem,
214
+ # so collapse on (component, CVE) and keep the most urgent tier seen.
215
+ deduped: dict[tuple[str, str], Finding] = {}
216
+
217
+ for component in document.identifiable:
218
+ assert component.purl is not None
219
+ for vuln_id in osv_by_purl.get(component.purl, []):
220
+ vuln = vulnerabilities.get(vuln_id)
221
+ if vuln is None:
222
+ continue
223
+
224
+ cves = vuln.cves
225
+ kev_hit = next((kev[c] for c in cves if c in kev), None)
226
+ scores = [epss[c] for c in cves if c in epss]
227
+ epss_score = max(scores) if scores else None
228
+
229
+ finding = Finding(
230
+ component=component,
231
+ vulnerability=vuln,
232
+ tier=_tier_for(kev_hit, epss_score),
233
+ cves=cves,
234
+ kev=kev_hit,
235
+ epss=epss_score,
236
+ )
237
+
238
+ key = (component.purl, finding.primary_cve)
239
+ existing = deduped.get(key)
240
+ if existing is None or finding.tier.rank < existing.tier.rank:
241
+ deduped[key] = finding
242
+
243
+ result.findings = list(deduped.values())
244
+
245
+ result.findings.sort(
246
+ key=lambda f: (
247
+ f.tier.rank,
248
+ -(f.epss or 0.0),
249
+ f.component.name,
250
+ )
251
+ )
252
+ return result
253
+
254
+
255
+ def early_warning_fields(finding: Finding, assessment: Assessment) -> dict[str, str]:
256
+ """The content an Article 14(2)(a) early warning has to carry.
257
+
258
+ Supplied so a manufacturer can see what the Single Reporting Platform will
259
+ ask for. The blanks are the parts only they can fill -- deliberately left
260
+ blank rather than guessed.
261
+ """
262
+ return {
263
+ "vulnerability": finding.primary_cve,
264
+ "component": finding.component.label,
265
+ "exploitation_evidence": finding.reason,
266
+ "product_affected": "<your product name and version>",
267
+ "member_states_made_available": "<EU member states where the product is on the market>",
268
+ "corrective_measures_taken": "<mitigations shipped or planned>",
269
+ "manufacturer": "<legal manufacturer name and contact>",
270
+ "awareness_timestamp": "<when you first became aware -- may predate this scan>",
271
+ "submit_to": "ENISA Single Reporting Platform, and the CSIRT of your "
272
+ "member state of main establishment",
273
+ }
flagrante/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .classify import Tier
9
+ from .html import render_html
10
+ from .report import render, render_json
11
+ from .sbom import SBOMError
12
+ from .scan import scan_file, scan_text
13
+ from .sources import FeedError
14
+
15
+ EXIT_OK = 0
16
+ EXIT_CLOCK_RUNNING = 1
17
+ EXIT_URGENT = 2
18
+ EXIT_ERROR = 3
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="flagrante",
24
+ description=(
25
+ "Find which components in an SBOM carry vulnerabilities confirmed "
26
+ "exploited in the wild -- the ones that start a CRA Article 14 "
27
+ "24-hour reporting clock."
28
+ ),
29
+ epilog=(
30
+ "Generate an SBOM first, for example: syft dir:. -o cyclonedx-json > sbom.json"
31
+ ),
32
+ )
33
+ parser.add_argument(
34
+ "sbom",
35
+ nargs="?",
36
+ default="-",
37
+ help="path to a CycloneDX or SPDX JSON SBOM, or - for stdin",
38
+ )
39
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
40
+ parser.add_argument(
41
+ "--html",
42
+ action="store_true",
43
+ help="standalone shareable HTML result page, written to stdout",
44
+ )
45
+ parser.add_argument(
46
+ "--all", action="store_true", help="also list vulnerabilities with no exploitation signal"
47
+ )
48
+ parser.add_argument(
49
+ "--refresh", action="store_true", help="bypass the local feed cache"
50
+ )
51
+ parser.add_argument(
52
+ "--quiet", action="store_true", help="suppress progress messages"
53
+ )
54
+ parser.add_argument(
55
+ "--fail-on",
56
+ choices=["never", "exploited", "urgent"],
57
+ default="exploited",
58
+ help=(
59
+ "exit non-zero when findings reach this level (default: exploited), "
60
+ "so CI can gate a release"
61
+ ),
62
+ )
63
+ return parser
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ args = build_parser().parse_args(argv)
68
+
69
+ def progress(message: str) -> None:
70
+ if not args.quiet and not args.json and not args.html:
71
+ print(f" {message}", file=sys.stderr)
72
+
73
+ try:
74
+ if args.sbom == "-":
75
+ if sys.stdin.isatty():
76
+ print(
77
+ "flagrante: reading an SBOM from stdin; pass a file path or pipe one in.\n"
78
+ " try: syft dir:. -o cyclonedx-json | flagrante",
79
+ file=sys.stderr,
80
+ )
81
+ return EXIT_ERROR
82
+ assessment = scan_text(sys.stdin.read(), progress, args.refresh)
83
+ else:
84
+ assessment = scan_file(args.sbom, progress, args.refresh)
85
+
86
+ except SBOMError as exc:
87
+ print(f"flagrante: cannot read that SBOM -- {exc}", file=sys.stderr)
88
+ return EXIT_ERROR
89
+ except FeedError as exc:
90
+ # Never degrade to a clean result: an unreachable exploitation feed and
91
+ # an empty one look identical and mean opposite things.
92
+ print(
93
+ f"flagrante: {exc}\n"
94
+ " Refusing to report a result without the exploitation feeds.",
95
+ file=sys.stderr,
96
+ )
97
+ return EXIT_ERROR
98
+ except FileNotFoundError:
99
+ print(f"flagrante: no such file: {args.sbom}", file=sys.stderr)
100
+ return EXIT_ERROR
101
+ except KeyboardInterrupt:
102
+ return EXIT_ERROR
103
+
104
+ if args.json:
105
+ render_json(assessment)
106
+ elif args.html:
107
+ sys.stdout.write(render_html(assessment))
108
+ else:
109
+ render(assessment, show_monitor=args.all)
110
+
111
+ if args.fail_on == "never":
112
+ return EXIT_OK
113
+ if assessment.clock_running:
114
+ return EXIT_CLOCK_RUNNING
115
+ if args.fail_on == "urgent" and assessment.urgent:
116
+ return EXIT_URGENT
117
+ return EXIT_OK
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())