diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e6aa8..e6c9026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ what has already shipped, and for nothing else. See `RELEASING.md`. ### Fixed +- **A critical CVE with no patch now cites art. 24 of d.lgs. 138/2024, not art. 21 of the NIS2 + directive.** The directive binds the Member States; in Italy the duty of essential and important + entities to take security measures is art. 24 of the decree that transposes it, downloaded from + Normattiva. The scope note points to art. 3 of the decree, and the note on CVEs not yet analysed names + the decree. `docs/nis2-compliance.md` says when Normattiva is now contacted. +- **A verified text names the source it came from.** EU acts are downloaded from Cellar + (publications.europa.eu), with EUR-Lex only as the fallback, but the report said "verified against + EUR-Lex" whichever answered. It now says Cellar, EUR-Lex or Normattiva, as the case was; + `law_checker.check` takes an optional HTTP client, so the path can be tested on recorded answers. - **`bump_version.py` left `uv.lock` behind.** `pyproject.toml` holds the version statically, so the lock records it too, and the bump moved only the other two files: on 2026-10-17 Quality refused the 2026.47 bump with "The lockfile at `uv.lock` needs to be updated", and the lock was moved by hand on diff --git a/docs/nis2-compliance.md b/docs/nis2-compliance.md index 5582d89..bb761ce 100644 --- a/docs/nis2-compliance.md +++ b/docs/nis2-compliance.md @@ -115,7 +115,7 @@ cosa ricevono: | `api.first.org` | `scan` | gli identificativi CVE trovati dalla scansione | | `publications.europa.eu` | `scan` da riga di comando, quando ci sono rilievi da citare | il numero CELEX dell'atto citato | | `eur-lex.europa.eu` | come sopra, se `publications.europa.eu` non risponde | il numero CELEX dell'atto citato | -| `www.normattiva.it` | con le citazioni attuali, mai: serve agli atti italiani, e PatchRadar cita solo GDPR e NIS2, pubblicati su EUR-Lex | il riferimento dell'atto e dell'articolo | +| `www.normattiva.it` | `scan` da riga di comando, quando una CVE critica non ha patch: art. 24 del d.lgs. 138/2024, che recepisce la NIS2 | il riferimento dell'atto e dell'articolo | Ogni host riceve inoltre, come per qualunque connessione, l'indirizzo IP della diff --git a/src/patchradar/cli.py b/src/patchradar/cli.py index d38782f..3a87ab8 100644 --- a/src/patchradar/cli.py +++ b/src/patchradar/cli.py @@ -250,7 +250,8 @@ def _print_law_check(law, out=None) -> None: act = status.act name, source = escape(act.name), escape(act.source) if status.source == "verified": - out.print(f"[dim]{name}: verified against {source} ({act.id_label} {escape(act.celex)})[/dim]") + origin = escape(status.origin or act.source) + out.print(f"[dim]{name}: verified against {origin} ({act.id_label} {escape(act.celex)})[/dim]") elif status.source == "cache": out.print( f"[yellow]{name}: {source} unreachable, " diff --git a/src/patchradar/law_checker.py b/src/patchradar/law_checker.py index 18df78a..e7c168a 100644 --- a/src/patchradar/law_checker.py +++ b/src/patchradar/law_checker.py @@ -13,9 +13,11 @@ from dataclasses import dataclass, field from datetime import UTC, datetime +import httpx + from patchradar import law_fetcher from patchradar.law_cache import Key, LawCache -from patchradar.law_fetcher import GDPR, NIS2, Act, LawFetchError, Provision +from patchradar.law_fetcher import DLGS_138_2024, GDPR, Act, LawFetchError, Provision # ─── Mapping: PatchRadar findings → provisions ──────────────────────────────── @@ -23,7 +25,7 @@ FINDING_ARTICLES = { "critical": ((GDPR, "32(2)"),), "unpatched": ((GDPR, "25"),), - "critical_unpatched": ((NIS2, "21"),), + "critical_unpatched": ((DLGS_138_2024, "24"),), "personal_data": ((GDPR, "32"),), } @@ -38,7 +40,7 @@ ALSO_FETCH: dict = {} NIS2_SCOPE_NOTE = ( - "NIS2 art. 21 binds essential and important entities (art. 3 of the directive): " + "D.Lgs. 138/2024 art. 24 binds essential and important entities (art. 3 of the decree): " "check that the organisation falls within scope" ) @@ -83,7 +85,7 @@ def notes_of(cves: list[dict]) -> list[str]: if unknown: notes.append( f"{unknown} CVEs with no patch information (not yet analysed by NVD): " - "not assessed against GDPR art. 25 or NIS2 art. 21" + "not assessed against GDPR art. 25 or D.Lgs. 138/2024 art. 24" ) if any(_is_critical(cve) and cve.get("patch_available") is False for cve in cves): notes.append(NIS2_SCOPE_NOTE) @@ -104,10 +106,11 @@ class Citation: @dataclass(frozen=True) class ActStatus: act: Act - # "verified": downloaded now from the act's source (EUR-Lex or Normattiva); - # "cache": source unreachable, cached copy; "unavailable": no text at all + # "verified": downloaded now; "cache": source unreachable, cached copy; "unavailable": no text at all source: str error: str | None = None + # Where a verified text came from: "Cellar", "EUR-Lex" (the fallback) or "Normattiva"; None otherwise + origin: str | None = None @dataclass @@ -128,6 +131,7 @@ def check( *, cache: LawCache | None = None, now: datetime | None = None, + client: httpx.Client | None = None, **context, ) -> LawCheckResult: """ @@ -153,13 +157,14 @@ def check( # Keyed by (celex, article) — the cache's key, not the fetcher's. fresh: dict[Key, Provision] = {} errors: dict[Act, str] = {} + origins: dict[Act, str] = {} for act in acts: # The articles cited ("32(1)(a)" is part of article 32) and ALSO_FETCH articles = tuple(dict.fromkeys( [ref.split("(")[0] for _, a, ref in cited if a == act] + list(ALSO_FETCH.get(act, ())) )) try: - by_article = law_fetcher.fetch_provisions(act, articles, now=now) + by_article, origins[act] = law_fetcher.fetch_provisions_from(act, articles, now=now, client=client) except LawFetchError as e: errors[act] = str(e) else: @@ -177,7 +182,7 @@ def check( statuses = [] for act in acts: if act not in errors: - statuses.append(ActStatus(act, "verified")) + statuses.append(ActStatus(act, "verified", origin=origins[act])) else: cached = any((act.celex, ref) in provisions for _, a, ref in cited if a == act) statuses.append(ActStatus(act, "cache" if cached else "unavailable", errors[act])) diff --git a/src/patchradar/law_fetcher.py b/src/patchradar/law_fetcher.py index e6af0a9..5ab52fb 100644 --- a/src/patchradar/law_fetcher.py +++ b/src/patchradar/law_fetcher.py @@ -73,6 +73,14 @@ def id_label(self) -> str: "testo consolidato al 19.12.2009", ) NIS2 = Act("NIS2 dir. 2022/2555", "32022L2555") +# The directive binds the Member States; in Italy the duties of essential and important entities are in +# d.lgs. 138/2024, which transposes it (art. 24: security measures; art. 3: scope). +DLGS_138_2024 = Act( + "D.Lgs. 138/2024", + "urn:nir:stato:decreto.legislativo:2024-09-04;138", + "testo vigente", + source="Normattiva", +) DIGITAL_CONTENT = Act("Contenuti digitali dir. 2019/770", "32019L0770") # Not on EUR-Lex, which lists Italian transposition measures without their text CONSUMER_CODE = Act( @@ -541,22 +549,31 @@ def fetch_act_html( Both failures are reported together: an error that says only "unreachable" turns a five-minute diagnosis into an afternoon. """ + return _fetch_act_page(act, lang, client=client, timeout=timeout)[0] + + +def _fetch_act_page( + act: Act, lang: str, *, client: httpx.Client | None, timeout: float = 30.0 +) -> tuple[str, str]: + """(page, the source that served it): "Cellar", or "EUR-Lex" when only the fallback answered.""" if act.source != "EUR-Lex": raise ValueError(f"{act.name} is published by {act.source}, which Cellar does not serve") language = CELLAR_LANGUAGES.get(lang.upper(), lang.lower()) try: - return fetch_html( + page = fetch_html( CELLAR_URL.format(celex=act.celex), client=client, timeout=timeout, headers={"Accept": CELLAR_TYPE, "Accept-Language": language}, ) + return page, "Cellar" except LawFetchError as cellar_failed: try: - return fetch_html( + page = fetch_html( SOURCE_URL.format(lang=lang, celex=act.celex), client=client, timeout=timeout ) + return page, "EUR-Lex" except LawFetchError as page_failed: raise LawFetchError(f"{cellar_failed}; and {page_failed}") from page_failed @@ -570,16 +587,33 @@ def fetch_provisions( client: httpx.Client | None = None, ) -> dict[str, Provision]: """Download `act` and return the provisions of `articles`, by reference.""" + return fetch_provisions_from(act, articles, lang=lang, now=now, client=client)[0] + + +def fetch_provisions_from( + act: Act, + articles: tuple[str, ...], + *, + lang: str = "IT", + now: datetime | None = None, + client: httpx.Client | None = None, +) -> tuple[dict[str, Provision], str]: + """As fetch_provisions, with the source that served the text: "Cellar", "EUR-Lex" or "Normattiva". + + A report names that source, not the act's publisher: the EU texts come from Cellar, and naming EUR-Lex + claimed a verification against a site the text did not come from. + """ if act.source == "Normattiva": texts: dict[str, str] = {} for article in articles: html = fetch_html(NORMATTIVA_URL.format(urn=act.celex, article=article), client=client) texts.update(parse_articles(html, (article,))) + source = "Normattiva" else: - html = fetch_act_html(act, lang, client=client) + html, source = _fetch_act_page(act, lang, client=client) texts = parse_articles(html, articles) fetched_at = utc_stamp(now or datetime.now(UTC)) return { ref: Provision.from_text(ref, text, fetched_at, act.celex) for ref, text in texts.items() - } + }, source diff --git a/tests/fixtures/normattiva_dlgs138_2024_art24_recorded.html b/tests/fixtures/normattiva_dlgs138_2024_art24_recorded.html new file mode 100644 index 0000000..3b40eaf --- /dev/null +++ b/tests/fixtures/normattiva_dlgs138_2024_art24_recorded.html @@ -0,0 +1,2191 @@ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Allegato I
Allegato II
Allegato III
Allegato IV