Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/nis2-compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
<!-- hosts:end -->

Ogni host riceve inoltre, come per qualunque connessione, l'indirizzo IP della
Expand Down
3 changes: 2 additions & 1 deletion src/patchradar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
Expand Down
21 changes: 13 additions & 8 deletions src/patchradar/law_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@
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 ────────────────────────────────

# Finding → cited provisions, in report order
FINDING_ARTICLES = {
"critical": ((GDPR, "32(2)"),),
"unpatched": ((GDPR, "25"),),
"critical_unpatched": ((NIS2, "21"),),
"critical_unpatched": ((DLGS_138_2024, "24"),),
"personal_data": ((GDPR, "32"),),
}

Expand All @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -128,6 +131,7 @@ def check(
*,
cache: LawCache | None = None,
now: datetime | None = None,
client: httpx.Client | None = None,
**context,
) -> LawCheckResult:
"""
Expand All @@ -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:
Expand All @@ -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]))
Expand Down
42 changes: 38 additions & 4 deletions src/patchradar/law_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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
Loading
Loading