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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DECRETO LEGISLATIVO 4 settembre 2024, n. 138 - Normattiva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ Logo Governo Presidenza del Consiglio dei Ministri +
+ +
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + +
+ +
+ + + + + +
+
+
+
+
+ +
+ + +
+
+
+
+ + + +
+
+ + +
+ +
+
+
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + +
+
+ +
+
+
+
+

stai visualizzando l'atto

+
+
+ +
+ + + + + + + + + + + + vigente al + + + + + +
+
+ +
+
+ + + +
+ +
+ + + + + + + + +
+
+
+ + + + + + + originario + + + +
+
+ +
+ +
+
+
+ + + + + + multivigente + + +
+
+
+ + + +
+
+
+ + +
+
+ + +
+ +
+ + + + + + + + + + +
+ + + +

+ + DECRETO LEGISLATIVO + + + + + + 4 + settembre + 2024, n. 138 + + + + + + + + +

+
+ +

Recepimento della direttiva (UE) 2022/2555, relativa a misure per un +livello comune elevato di cibersicurezza nell'Unione, recante +modifica del regolamento (UE) n. 910/2014 e della direttiva (UE) +2018/1972 e che abroga la direttiva (UE) 2016/1148. (24G00155) +

+ +
+ + + note: + + Entrata in vigore del provvedimento: 16/10/2024 + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + (GU n.230 del 01-10-2024) + + + + + + + + + + visualizza atto intero + + +
+ +
+ + +
+ + + + nascondi + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ vigente al + 23/10/2026 +
+ +
+ + + + + + +
+
    +
  • + Allegati +
  • +
+ + +
+ +
+
+ + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + Testo in vigore dal:  16-10-2024 + + + + + + +
+
+ +
+ + + +
+ + + +
+ + + + + + + +
+

+

+

Art. 24

+
Obblighi in materia di misure di gestione dei rischi per la sicurezza informatica
+
+
+
+
+1. I soggetti essenziali e i soggetti importanti adottano misure tecniche, operative e organizzative adeguate e proporzionate, secondo le modalità e i termini di cui agli articoli 30, 31 e 32, alla gestione dei rischi posti alla sicurezza dei sistemi informativi e di rete che tali soggetti utilizzano nelle loro attività o nella fornitura dei loro servizi, nonché per prevenire o ridurre al minimo l'impatto degli incidenti per i destinatari dei loro servizi e per altri servizi. Tali misure:
+ +
+
a) assicurano un livello di sicurezza dei sistemi informativi e di rete adeguato ai rischi esistenti, tenuto conto delle conoscenze più aggiornate e dello stato dell'arte in materia e, ove applicabile, delle pertinenti norme nazionali, europee e internazionali, nonché dei costi di attuazione;
+ +
+
b) sono proporzionate al grado di esposizione a rischi del soggetto, alle dimensioni del soggetto e alla probabilità che si verifichino incidenti, nonché alla loro gravità, compreso il loro impatto sociale ed economico.
+ +
+
+
+
+
+
+2. Le misure di cui al comma 1 sono basate su un approccio multi-rischio, volto a proteggere i sistemi informativi e di rete nonché il loro ambiente fisico da incidenti, e comprendono almeno i seguenti elementi:
+ +
+
a) politiche di analisi dei rischi e di sicurezza dei sistemi informativi e di rete;
+ +
+
b) gestione degli incidenti, ivi incluse le procedure e gli strumenti per eseguire le notifiche di cui agli articoli 25 e 26;
+ +
+
c) continuità operativa, ivi inclusa la gestione di backup, il ripristino in caso di disastro, ove applicabile, e gestione delle crisi;
+ +
+
d) sicurezza della catena di approvvigionamento, ivi compresi gli aspetti relativi alla sicurezza riguardanti i rapporti tra ciascun soggetto e i suoi diretti fornitori o fornitori di servizi;
+ +
+
e) sicurezza dell'acquisizione, dello sviluppo e della manutenzione dei sistemi informativi e di rete, ivi comprese la gestione e la divulgazione delle vulnerabilità;
+ +
+
f) politiche e procedure per valutare l'efficacia delle misure di gestione dei rischi per la sicurezza informatica;
+ +
+
g) pratiche di igiene di base e di formazione in materia di sicurezza informatica;
+ +
+
h) politiche e procedure relative all'uso della crittografia e, ove opportuno, della cifratura;
+ +
+
i) sicurezza e affidabilità del personale, politiche di controllo dell'accesso e gestione dei beni e degli assetti;
+ +
+
l) uso di soluzioni di autenticazione a più fattori o di autenticazione continua, di comunicazioni vocali, video e testuali protette, e di sistemi di comunicazione di emergenza protetti da parte del soggetto al proprio interno, ove opportuno.
+ +
+
+
+
+3. Nel valutare quali misure di cui al comma 2, lettera d), siano adeguate, i soggetti tengono conto delle vulnerabilità specifiche per ogni diretto fornitore e fornitore di servizi e della qualità complessiva dei prodotti e delle pratiche di sicurezza informatica dei propri fornitori e fornitori di servizi, comprese le loro procedure di sviluppo sicuro. Per la medesima finalità i soggetti tengono altresì conto dei risultati delle valutazioni coordinate dei rischi per la sicurezza delle catene di approvvigionamento critiche effettuate dal Gruppo di cooperazione NIS.
+ +
+
+
+4. Qualora un soggetto rilevi di non essere conforme alle misure di cui al comma 2, esso adotta, senza indebito ritardo, tutte le misure appropriate e proporzionate correttive necessarie.
+ +
+
+
+
+ + +
+ + +
+
+ +
+ + nascondi + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_cli_law.py b/tests/test_cli_law.py index 26738d2..701ce1a 100644 --- a/tests/test_cli_law.py +++ b/tests/test_cli_law.py @@ -10,6 +10,7 @@ FIXTURES = Path(__file__).parent / "fixtures" FIXTURE = FIXTURES / "gdpr_it_excerpt.html" NIS2_PAGE = FIXTURES / "nis2_it_excerpt.html" +ART_24_PAGE = FIXTURES / "normattiva_dlgs138_2024_art24_recorded.html" runner = CliRunner() @@ -33,7 +34,7 @@ def eurlex(monkeypatch): def fake_fetch_html(url, **kwargs): calls.append(url) - page = NIS2_PAGE if "32022L2555" in url else FIXTURE + page = ART_24_PAGE if "normattiva.it" in url else NIS2_PAGE if "32022L2555" in url else FIXTURE return page.read_text(encoding="utf-8") monkeypatch.setattr(law_fetcher, "fetch_html", fake_fetch_html) @@ -73,14 +74,19 @@ def test_scan_cites_articles(monkeypatch, eurlex): assert eurlex == ["https://publications.europa.eu/resource/celex/32016R0679"] -def test_critical_unpatched_cites_nis2(monkeypatch, eurlex): +def test_critical_unpatched_cites_the_italian_nis2_decree(monkeypatch, eurlex): out = _scan(monkeypatch, [cve("CVE-2026-1006", severity="CRITICAL", patch=False)]) assert "Critical CVEs with no patch" in out.output - assert "Provision applied: NIS2 dir. 2022/2555 art. 21\n" in out.output - assert f"SHA256: {_sha('21', NIS2_PAGE)}" in out.output + assert "Provision applied: D.Lgs. 138/2024 art. 24\n" in out.output + assert f"SHA256: {_sha('24', ART_24_PAGE)}" in out.output assert "essential and important entities" in out.output - assert eurlex[-1] == "https://publications.europa.eu/resource/celex/32022L2555" + assert eurlex[-1] == ("https://www.normattiva.it/uri-res/N2Ls?" + "urn:nir:stato:decreto.legislativo:2024-09-04;138~art24!vig=") + flat = " ".join(out.output.split()) # Rich wraps the long URN at 80 columns + assert ("D.Lgs. 138/2024: verified against Normattiva " + "(URN urn:nir:stato:decreto.legislativo:2024-09-04;138)") in flat + assert "EUR-Lex" not in out.output def test_scan_of_whole_watchlist_cites_once(monkeypatch, eurlex): diff --git a/tests/test_law_checker.py b/tests/test_law_checker.py index cc1cceb..163915e 100644 --- a/tests/test_law_checker.py +++ b/tests/test_law_checker.py @@ -13,7 +13,7 @@ findings_of, notes_of, ) -from patchradar.law_fetcher import GDPR, NIS2, LawFetchError, Provision +from patchradar.law_fetcher import DLGS_138_2024, GDPR, LawFetchError, Provision DAY1 = datetime(2026, 9, 19, 14, 0, tzinfo=UTC) DAY2 = datetime(2026, 10, 1, 9, 30, tzinfo=UTC) @@ -36,7 +36,7 @@ def fake(act, articles, now=None, **kwargs): return { ref: Provision.from_text(ref, f"testo di {ref}{suffix}", stamp, act.celex) for ref in refs if ref.split("(")[0] in articles - } + }, "Cellar" fake.calls = calls return fake @@ -50,7 +50,7 @@ def cache(tmp_path): @pytest.fixture def online(monkeypatch): fake = _fake_fetch() - monkeypatch.setattr(law_fetcher, "fetch_provisions", fake) + monkeypatch.setattr(law_fetcher, "fetch_provisions_from", fake) return fake.calls @@ -60,7 +60,7 @@ def test_mapping(): assert FINDING_ARTICLES == { "critical": ((GDPR, "32(2)"),), "unpatched": ((GDPR, "25"),), - "critical_unpatched": ((NIS2, "21"),), + "critical_unpatched": ((DLGS_138_2024, "24"),), "personal_data": ((GDPR, "32"),), } assert set(FINDING_TITLES) == set(FINDING_ARTICLES) @@ -83,7 +83,7 @@ def test_unpatched_only_when_known(): def test_unknown_patch_status_is_noted(): assert notes_of([cve(patch=None), cve(patch=None), cve()]) == [ "2 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" ] assert notes_of([cve()]) == [] @@ -137,10 +137,10 @@ def test_citations(cache, online): assert [(c.finding, c.law, c.article) for c in law.citations] == [ ("critical", "GDPR", "32(2)"), ("unpatched", "GDPR", "25"), - ("critical_unpatched", "NIS2 dir. 2022/2555", "21"), + ("critical_unpatched", "D.Lgs. 138/2024", "24"), ("personal_data", "GDPR", "32"), ] - assert online == [(GDPR, ("32", "25")), (NIS2, ("21",))] + assert online == [(GDPR, ("32", "25")), (DLGS_138_2024, ("24",))] assert [s.source for s in law.acts] == ["verified", "verified"] assert NIS2_SCOPE_NOTE in law.notes assert all(c.sha256 and c.version_date == "2026-09-19" for c in law.citations) @@ -148,9 +148,9 @@ def test_citations(cache, online): def test_second_audit_changed_text(cache, monkeypatch): - monkeypatch.setattr(law_fetcher, "fetch_provisions", _fake_fetch()) + monkeypatch.setattr(law_fetcher, "fetch_provisions_from", _fake_fetch()) first = check([cve(severity="CRITICAL")], cache=cache, now=DAY1) - monkeypatch.setattr(law_fetcher, "fetch_provisions", _fake_fetch(" (rettificato)")) + monkeypatch.setattr(law_fetcher, "fetch_provisions_from", _fake_fetch(" (rettificato)")) law = check([cve(severity="CRITICAL")], cache=cache, now=DAY2) assert law.changed == {"GDPR art. 32(2)": first.citations[0].sha256} @@ -163,7 +163,7 @@ def test_offline_uses_cache(cache, online, monkeypatch): def offline(*args, **kwargs): raise LawFetchError("offline") - monkeypatch.setattr(law_fetcher, "fetch_provisions", offline) + monkeypatch.setattr(law_fetcher, "fetch_provisions_from", offline) law = check([cve(severity="CRITICAL")], cache=cache, now=DAY2) assert [s.source for s in law.acts] == ["cache"] diff --git a/tests/test_law_italian_sources.py b/tests/test_law_italian_sources.py new file mode 100644 index 0000000..6484a5b --- /dev/null +++ b/tests/test_law_italian_sources.py @@ -0,0 +1,120 @@ +"""The provisions are cited where they bind, and the source named is the one that answered. + +- The NIS2 directive binds the Member States; in Italy the duty of essential and important entities to take + security measures is art. 24 of d.lgs. 138/2024 (rubrica "Obblighi in materia di misure di gestione dei rischi + per la sicurezza informatica"), not art. 21 of the directive. +- EU texts are downloaded from Cellar (publications.europa.eu), with EUR-Lex only as the fallback: "verified + against EUR-Lex" named a site the text did not come from. + +Recorded at the HTTP boundary, as CLAUDE.md allows: fixtures/normattiva_dlgs138_2024_art24_recorded.html is +Normattiva's answer to the URL below, saved byte for byte on 2026-10-23 at 15:53 UTC (sha256 3a2ace83…ee76, the +same at 15:52); gdpr_it_excerpt.html is the GDPR page the other law tests already use. httpx.MockTransport answers +only those URLs, so nothing reaches the network and every request is seen. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import httpx +import pytest + +from patchradar import law_checker, law_fetcher +from patchradar.law_cache import LawCache + +# A critical CVE with no patch: GDPR art. 32(2) and 25, and the decree's art. 24 +CVE = {"id": "CVE-2026-0001", "software": "nginx", "severity": "CRITICAL", + "patch_available": False, "confidentiality_impact": "NONE"} + +FIXTURES = Path(__file__).parent / "fixtures" +NORMATTIVA_PAGE = (FIXTURES / "normattiva_dlgs138_2024_art24_recorded.html").read_text(encoding="utf-8") +GDPR_PAGE = (FIXTURES / "gdpr_it_excerpt.html").read_text(encoding="utf-8") +DLGS_138_URN = "urn:nir:stato:decreto.legislativo:2024-09-04;138" +ART_24_URL = f"https://www.normattiva.it/uri-res/N2Ls?{DLGS_138_URN}~art24!vig=" +GDPR_CELLAR_URL = "https://publications.europa.eu/resource/celex/32016R0679" +GDPR_EURLEX_URL = "https://eur-lex.europa.eu/legal-content/IT/TXT/HTML/?uri=CELEX:32016R0679" +NOW = datetime(2026, 10, 23, 16, 0, tzinfo=UTC) + + +@pytest.fixture +def real_fetch(monkeypatch): + """conftest disables fetch_html so that no test reaches the network; these tests pass a client that answers + only from the recordings, so the real function goes back.""" + monkeypatch.setattr(law_fetcher, "fetch_html", REAL_FETCH_HTML) + + +REAL_FETCH_HTML = law_fetcher.fetch_html + + +def recorded(pages: dict[str, str], seen: list[str]) -> httpx.Client: + def answer(request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + if str(request.url) in pages: + return httpx.Response(200, text=pages[str(request.url)]) + return httpx.Response(503) + + return httpx.Client(transport=httpx.MockTransport(answer)) + + +def test_the_recording_is_article_24_of_the_decree(): + """What the tests below rely on, so that they cannot pass on a page that no longer shows it.""" + assert "Obblighi in materia di misure di gestione dei rischi per la sicurezza informatica" in NORMATTIVA_PAGE + + +def test_article_24_of_dlgs_138_2024_is_read_from_normattiva(real_fetch): + seen: list[str] = [] + provisions = law_fetcher.fetch_provisions(law_fetcher.DLGS_138_2024, ("24",), now=NOW, + client=recorded({ART_24_URL: NORMATTIVA_PAGE}, seen)) + assert seen == [ART_24_URL] + assert provisions["24"].text.startswith( + "1. I soggetti essenziali e i soggetti importanti adottano misure tecniche, operative e organizzative") + assert law_fetcher.DLGS_138_2024.celex == DLGS_138_URN + assert law_fetcher.DLGS_138_2024.source == "Normattiva" + + +def test_a_critical_cve_with_no_patch_cites_the_italian_decree(): + """The directive's art. 21 is addressed to the Member States; the entities are bound by the decree.""" + cited = law_checker.FINDING_ARTICLES["critical_unpatched"] + assert cited == ((law_fetcher.DLGS_138_2024, "24"),) + + +def test_cves_without_patch_information_name_the_decree(): + unknown = dict(CVE, patch_available=None) + assert law_checker.notes_of([unknown])[0].endswith("not assessed against GDPR art. 25 or D.Lgs. 138/2024 art. 24") + + +def test_the_scope_note_names_the_decree(): + note = law_checker.NIS2_SCOPE_NOTE + assert "D.Lgs. 138/2024 art. 24" in note and "art. 3" in note + assert "essential and important entities" in note + assert "directive" not in note + + +def statuses(pages: dict[str, str], tmp_path: Path, seen: list[str]) -> dict[str, str | None]: + result = law_checker.check([CVE], cache=LawCache(tmp_path / "law_cache.json"), now=NOW, + client=recorded(pages, seen)) + return {status.act.name: (status.source, status.origin) for status in result.acts} + + +def test_the_status_names_cellar_when_cellar_answered(real_fetch, tmp_path): + seen: list[str] = [] + got = statuses({GDPR_CELLAR_URL: GDPR_PAGE, ART_24_URL: NORMATTIVA_PAGE}, tmp_path, seen) + assert got[law_fetcher.GDPR.name] == ("verified", "Cellar") + assert got[law_fetcher.DLGS_138_2024.name] == ("verified", "Normattiva") + assert GDPR_EURLEX_URL not in seen + + +def test_the_status_names_eur_lex_when_only_the_fallback_answered(real_fetch, tmp_path): + seen: list[str] = [] + got = statuses({GDPR_EURLEX_URL: GDPR_PAGE, ART_24_URL: NORMATTIVA_PAGE}, tmp_path, seen) + assert got[law_fetcher.GDPR.name] == ("verified", "EUR-Lex") + assert seen[:2] == [GDPR_CELLAR_URL, GDPR_EURLEX_URL] + + +def test_a_text_from_the_cache_names_no_source(real_fetch, tmp_path): + """Nothing answered now: the copy is the cache's, and no site is claimed.""" + seen: list[str] = [] + statuses({GDPR_CELLAR_URL: GDPR_PAGE, ART_24_URL: NORMATTIVA_PAGE}, tmp_path, seen) + got = statuses({}, tmp_path, seen) + assert got[law_fetcher.GDPR.name] == ("cache", None) diff --git a/tests/test_nis2_evidence.py b/tests/test_nis2_evidence.py index 35e95bb..ea9148b 100644 --- a/tests/test_nis2_evidence.py +++ b/tests/test_nis2_evidence.py @@ -257,13 +257,16 @@ def test_only_the_law_fetcher_names_the_repository_in_its_user_agent(doc): assert "python-httpx" in doc -def test_normattiva_is_not_reached_by_the_citations_patchradar_makes(doc): - """The law fetcher is shared by the Radar tools and can ask Normattiva for an - Italian act; PatchRadar's own mapping cites only acts EUR-Lex publishes.""" +def test_normattiva_is_reached_only_for_the_italian_nis2_decree(doc): + """PatchRadar cites the GDPR (EU, from Cellar) and, for a critical CVE with no + patch, art. 24 of d.lgs. 138/2024 (Italian, from Normattiva): the document says + when Normattiva is asked, and for which act.""" cited = {act for refs in law_checker.FINDING_ARTICLES.values() for act, _ in refs} cited |= set(law_checker.ALSO_FETCH) - assert cited and all(act.source == "EUR-Lex" for act in cited) - assert "GDPR e NIS2" in doc + italian = {act.name for act in cited if act.source == "Normattiva"} + assert italian == {"D.Lgs. 138/2024"} + assert all(act.source in ("EUR-Lex", "Normattiva") for act in cited) + assert "`scan` da riga di comando, quando una CVE critica non ha patch: art. 24 del d.lgs. 138/2024" in doc @reads_source