Einen VuXML-Eintrag von Hand zu schreiben, ist keine schwere Arbeit, aber eine fehleranfällige. Die Paketnamen müssen zu dem passen, was der Port wirklich baut, die Versionsbereiche brauchen die Epoche des Ports, die Einrückung muss so sein, wie make validate sie erwartet, und bei zehn CVEs zu einem Port schreibt man dieselben Grenzen zehnmal ab. Auf portstatus.bsdproject.de stehen heute 3.560 CVEs in 711 Ports, die kein VuXML-Eintrag nennt. Für jede davon liegt inzwischen ein fertiger Eintrag bereit.
Seit dem 29. Oktober hat jede Portseite mit offenen CVEs einen VuXML-Generator: Du hakst die CVEs an, die zusammengehören, und bekommst einen Eintrag zum Kopieren. Hier steht, wie er rechnet, wo er aufhört und woher seine Daten kommen. Wie portstatus CVEs überhaupt einem Port zuordnet, steht im ersten Beitrag zur Seite.
Vom Häkchen zum Eintrag
Ein Beispiel ist databases/pgbouncer. Das Paket steht auf 1.25.1_1, und vier CVEs vom Mai betreffen genau diese Version: ein Stack-Überlauf im SCRAM-Code, ein Integer-Überlauf beim Parsen von Netzwerkpaketen, eine Null-Dereferenzierung und eine fehlende Rechteprüfung für KILL_CLIENT. Alle vier sind ab 1.25.2 behoben, einen VuXML-Eintrag gibt es nicht.
Im Kasten „Unaddressed“ steht vor jeder CVE ein Kästchen. „Select all that fit“ wählt die größte Gruppe, die in einen Eintrag passt, hier alle vier. „Generate the VuXML entry“ setzt den Eintrag zusammen, „Copy to clipboard“ legt ihn in die Zwischenablage.

Der Kern des Eintrags ist der affects-Block:
<affects>
<package>
<name>pgbouncer</name>
<range><lt>1.25.2</lt></range>
</package>
</affects>
Darunter folgen die Beschreibungen der vier CVEs als eigene Absätze, jeder mit der CVE-Nummer davor, dann die Referenzen und die Daten. discovery ist das früheste Veröffentlichungsdatum der ausgewählten CVEs, entry der heutige Tag. Im Ports-Baum legt make newentry das Gerüst mit einer neuen vid in der Jahresdatei unter vuln/ an. Das ersetzt du durch den kopierten Eintrag, behältst die vid und prüfst:
% cd ~/ports/security/vuxml
% make newentry
% make validate
Das Thema bleibt dir überlassen. Der Generator schreibt pgbouncer -- FIXME, weil nur ein Mensch sagen kann, worum es in dem Eintrag geht.
Warum nicht jede Auswahl passt
Bei print/py-pypdf sieht es anders aus. Dort stehen 50 offene CVEs, aber nur vier davon lassen sich zusammen auswählen. Die übrigen sind in verschiedenen Versionen behoben, unter anderem in 6.21.2, 6.21.3 und 6.21.4, und ergeben zusammen 26 verschiedene affects-Blöcke.
Man könnte alle 50 unter die höchste Grenze legen. Das Ergebnis sähe gültig aus und liefe durch make validate. Es wäre trotzdem falsch: Der Eintrag behauptete dann für jede der 50 CVEs, sie betreffe auch 6.21.3, obwohl ein Teil davon dort längst behoben ist. pkg audit fiele das kaum auf, solange noch eine offene CVE in der Liste steht. Aber genau diese Zuordnung von CVE zu Version lesen andere Werkzeuge aus der VuXML heraus, portstatus eingeschlossen, und später auch jede Umwandlung nach OSV.
Deshalb trägt jede CVE einen Schlüssel, einen Hash über ihren fertigen affects-Block. Nur CVEs mit gleichem Schlüssel landen in einem Eintrag. Hakst du eine CVE an, die nicht passt, nennt die Seite sie und baut nichts. Über alle Ports gerechnet werden so aus 3.560 Einträgen je CVE 1.888 gebündelte, 760 davon fassen mehrere CVEs zusammen.
Was der Generator rechnet
Die Bereiche entstehen nicht aus dem Text der CVE, sondern aus den strukturierten Angaben: den Versionsbereichen in der NVD und in den Datensätzen der CNAs, soweit sie auf diesen Port passen. Der Generator übersetzt sie in pkg-Schreibweise und prüft jede Grenze mit libpkg, derselben Bibliothek, mit der pkg version -t vergleicht. Eine Grenze, unter die pkg das gebaute Paket nicht einordnen würde, fällt mit Begründung heraus, statt geraten zu werden.
Die eigentliche Falle ist die Epoche. Für pkg schlägt eine höhere Epoche jede Version, ein Bereich kann also nicht über zwei Epochen reichen. Beide naheliegenden Lösungen gehen schief, und beide haben echte Pakete getroffen. Steht die Epoche an beiden Grenzen (ge 1.3.0,1 lt 2.28.2,1), bleiben bei databases/mantis alle Versionen aus der Zeit vor der Epoche ungewarnt. Steht sie nur oben (ge 11.0.0 lt 11.0.3,1), meldet www/glpi jedes installierte 10.0.x,1. Richtig ist ein Bereich je Epoche, die der Port tatsächlich hatte, und die steht in seiner Commit-Historie:
<range><ge>1.3.0</ge><lt>2.28.2</lt></range>
<range><ge>1.3.0,1</ge><lt>2.28.2,1</lt></range>
pkg verknüpft mehrere range-Elemente eines Pakets mit ODER. Damit deckt der Eintrag jeden Epochenraum für sich ab und trifft keinen fremden.
Nennt die CVE keine Fix-Version für die Linie des Ports, schreibt der Generator FIXME und den Grund als Kommentar direkt in den Paketblock, zu dem er gehört. Solche Einträge sind als unfertig markiert, und die Seite sagt über der Vorlage, was fehlt. Das ist Absicht: Ein <lt>FIXME</lt> fällt beim Lesen auf, eine fehlende Obergrenze in gültigem XML nicht. Von den 3.560 Einträgen sind 3.061 fertig, 499 brauchen noch eine Grenze.

Zwei Entscheidungen im Aufbau halte ich für die wichtigsten. Erstens entsteht der Eintrag genau einmal, in Python (portstatus/vuxmlgen), im stündlichen Lauf nach dem Abgleich. Die Webseite gibt ihn nur aus, und der Browser fügt beim Auswählen fertige Textbausteine aneinander, ohne eine einzige Grenze zu rechnen. Vorher standen dieselben Regeln in Python und in PHP, beide Seiten blieben in ihren Tests grün und liefen trotzdem auseinander. Zweitens sind Fakten und Ausgabe getrennt: Ein Modul sammelt, welche Pakete mit welchen Bereichen betroffen sind, ein anderes schreibt daraus VuXML. Derselbe Eintrag lässt sich so später auch als OSV ausgeben, ohne die Regeln anzufassen.
Grenze 1: die CPE
Der Generator kann nur über CVEs schreiben, die portstatus einem Port zuordnet, und diese Zuordnung läuft fast immer über die CPE, die Kennung aus Hersteller und Produkt. Von 34.817 Ports haben 3.325 eine. Die übrigen bekommen CVEs nur über einen eindeutigen Paketnamen, etwa bei PyPI und CPAN, oder gar nicht. Für einen Port ohne CPE gibt es deshalb oft keinen Generator, und das heißt nicht, dass er sauber ist.
Auch eine vorhandene CPE hat Lücken:
- Ein Produkt, mehrere Hersteller. Die NVD führt nginx unter drei Herstellernamen:
igor_sysoev,nginxundf5. Ein Port mit einer dieser Kennungen sieht die CVEs unter den beiden anderen nicht. - Geteilte Kennungen.
sqlite2undsqlite3teilen sich eine CPE. Der Generator kann dann eine Versionsgrenze vorschlagen, die es für einen der beiden Ports nie gab. Darum steht über jeder Vorlage der Satz, die Versionsnummern vor dem Commit zu prüfen. - Keine Portrevision, keine Epoche. PORTREVISION und PORTEPOCH gibt es im Versionsfeld der CPE nicht. Die Epoche holt der Generator aus der Historie des Ports, siehe oben.
- Gepatchte Ports. Ein Port, der den Fix als Patch unter
files/mitbringt, behält die verwundbare Upstream-Version und steht trotzdem rot. Das sieht der Generator nicht. Bevor du einen Eintrag committest, prüf, ob der Port den Fix nicht schon hat. - Bestrittene CVEs. Was NVD oder der CVE-Datensatz als disputed markiert, zählt portstatus nicht als offen. Dafür gibt es auch keinen Eintrag.
Grenze 2: die Lizenzen
Ein Eintrag setzt sich aus Daten verschiedener Herkunft zusammen, und die stehen unter verschiedenen Bedingungen. Die VuXML selbst hat eine eigene BSD-artige Lizenz, die im Kopf von vuln.xml steht (Copyright Jacques Vidrine and contributors).
- NVD: Werk einer US-Bundesbehörde und damit gemeinfrei. Die NVD bittet darum, sie als Quelle zu nennen, und untersagt, eine Billigung durch sie zu behaupten. Die Verweise auf nvd.nist.gov im Eintrag sind genau diese Nennung.
- CVE-Datensätze (cvelistV5): stehen unter den CVE Terms of Use. Sie erlauben Vervielfältigung und Weitergabe, wenn der Copyright-Vermerk von MITRE und die Lizenz mitkommen.
- Repology: liefert portstatus nur CPE-Ersatz und die neueste Upstream-Version. Davon landet nichts im Eintrag.
Für den Generator heißt das: Bereiche, Paketnamen, CVE-Nummern und Links sind reine Angaben. Die Beschreibung dagegen ist der wörtliche Text des CVE-Datensatzes. Der Generator setzt ihn ein, damit du siehst, welcher Satz zu welcher CVE gehört, gemeint ist er als Vorschlag. Ich fasse ihn vor dem Commit in eigenen Worten zusammen oder zitiere die Meldung des Upstream-Projekts mit Quelle, wie es viele Einträge tun. Das ist auch inhaltlich besser: Die CNA-Texte beschreiben das Upstream-Produkt, nicht das FreeBSD-Paket.
Woher die CVEs kommen
portstatus nutzt nur offene Quellen, und jede hat ihren eigenen Takt:
- NVD-Feeds (JSON 2.0): CVEs mit CPE und Versionsbereichen, täglich, sonntags komplett. Dazu das CPE-Wörterbuch der NVD mit rund 50.000 Paaren aus Hersteller und Produkt, ebenfalls täglich.
- cvelistV5 von CVE.org: die Datensätze der CNAs, stündlich als Delta. Sie sind oft Tage oder Wochen vor der Analyse der NVD da und tragen eigene Versionsbereiche.
- ports.git: Commits auf main jede Minute, die Quarterly-Zweige, VuXML stündlich. Aus der Historie seit 1994 stammen die Epochen, die der Generator braucht.
- pkg.freebsd.org: der Paketindex für latest und quarterly. Er sagt, welche Paketnamen ein Port wirklich baut und in welcher Version.
- Repology-Dump: CPE-Ersatz für Ports ohne eigene Kennung und die neueste Upstream-Version.
- Bugzilla: offene Problem Reports je Port, stündlich.
Hat die NVD für eine CVE und ein Produkt schon Angaben, haben diese Vorrang. cvelistV5 springt dort ein, wo die NVD noch nichts hat, und bei neuen CVEs ist das die Regel.
Fazit
- Für jede der 3.560 offenen CVEs liegt ein Eintrag bereit, 3.061 davon ohne offene Stelle.
- Mehrere CVEs werden zu einem Eintrag, wenn ihre Bereiche gleich sind. Alles andere lehnt die Seite ab, statt einen falschen Eintrag zu bauen.
- Die Grenzen sind mit libpkg geprüft, die Epochen kommen aus der Historie des Ports.
- Thema, vid, eigene Patches im Port und die Beschreibung in eigenen Worten bleiben beim Committer.
Der Generator ersetzt keinen Committer. Er nimmt ihm das Abschreiben ab und lässt ihm das Prüfen.
Probier es aus: Such einen deiner Ports auf portstatus.bsdproject.de, hak die offenen CVEs an und schau dir den Eintrag an. Stimmt eine Grenze nicht, meld es über „Report wrong data“ auf der Portseite, mit Portname und CVE.