VuXML entries in a few clicks: the portstatus generator

Writing a VuXML entry by hand is not hard work, but it is easy to get wrong. The package names have to match what the port really builds, the version ranges need the port’s epoch, the indentation has to be what make validate expects, and with ten CVEs for one port you copy the same bounds ten times. On portstatus.bsdproject.de there are currently 3,560 CVEs in 711 ports that no VuXML entry names. For every one of them, a finished entry is now waiting.

Since October 29, every port page with open CVEs has a VuXML generator: you tick the CVEs that belong together and get an entry to copy. This post explains how it computes the entry, where it stops, and where its data comes from. How portstatus maps CVEs to ports in the first place is covered in the first post about the site.

From checkbox to entry

Take databases/pgbouncer. The package is at 1.25.1_1, and four CVEs from May affect exactly this version: a stack overflow in the SCRAM code, an integer overflow while parsing network packets, a null pointer dereference and a missing authorization check for KILL_CLIENT. All four are fixed in 1.25.2, and there is no VuXML entry.

In the “Unaddressed” box, every CVE has a checkbox. “Select all that fit” picks the largest group that fits into one entry, here all four. “Generate the VuXML entry” assembles the entry, and “Copy to clipboard” puts it on the clipboard.

Port page of databases/pgbouncer: four open CVEs ticked, below them the finished VuXML entry with the range less than 1.25.2 and the Copy to clipboard button
Four CVEs, one range, one entry. Topic and vid stay FIXME, the committer sets both.

The core of the entry is the affects block:

    <affects>
      <package>
	<name>pgbouncer</name>
	<range><lt>1.25.2</lt></range>
      </package>
    </affects>

Below it come the descriptions of the four CVEs as separate paragraphs, each prefixed with its CVE number, then the references and the dates. discovery is the earliest publication date of the selected CVEs, entry is today. In the ports tree, make newentry creates the skeleton with a new vid in the yearly file under vuln/. Replace it with the copied entry, keep the vid, and check:

% cd ~/ports/security/vuxml
% make newentry
% make validate

The topic is up to you. The generator writes pgbouncer -- FIXME, because only a human can say what the entry is about.

Why not every selection fits

print/py-pypdf looks different. It has 50 open CVEs, but only four of them can be selected together. The others were fixed in different versions, among them 6.21.2, 6.21.3 and 6.21.4, and together they produce 26 different affects blocks.

You could put all 50 under the highest bound. The result would look valid and pass make validate. It would still be wrong: the entry would claim for each of the 50 CVEs that it also affects 6.21.3, although part of them is fixed there. pkg audit would hardly notice as long as one open CVE remains in the list. But other tools read exactly this mapping of CVE to version from VuXML, portstatus included, and later any conversion to OSV.

That is why every CVE carries a key, a hash over its finished affects block. Only CVEs with the same key end up in one entry. If you tick a CVE that does not fit, the page names it and builds nothing. Across all ports, this turns 3,560 entries per CVE into 1,888 bundled ones, 760 of which combine several CVEs.

What the generator computes

The ranges do not come from the text of the CVE but from the structured data: the version ranges in NVD and in the records of the CNAs, as far as they match this port. The generator translates them into pkg notation and checks every bound with libpkg, the same library pkg version -t compares with. A bound under which pkg would not place the built package is dropped with a reason instead of being guessed.

The real trap is the epoch. For pkg, a higher epoch beats every version, so one range cannot span two epochs. Both obvious solutions fail, and both hit real packages. With the epoch on both bounds (ge 1.3.0,1 lt 2.28.2,1), every version of databases/mantis from before the epoch goes without a warning. With the epoch only on the upper bound (ge 11.0.0 lt 11.0.3,1), www/glpi flags every installed 10.0.x,1. The correct answer is one range per epoch the port actually had, and that is recorded in its commit history:

<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 combines several range elements of one package with OR. The entry therefore covers each epoch on its own and never hits a foreign one.

If the CVE names no fixed version for the port’s branch, the generator writes FIXME and puts the reason as a comment right into the package block it belongs to. Such entries are marked as unfinished, and the page says above the template what is missing. That is deliberate: a <lt>FIXME</lt> stands out when you read it, a missing upper bound in valid XML does not. Of the 3,560 entries, 3,061 are finished, 499 still need a bound.

Diagram: hourly matching supplies CVE rows, package names and epochs, vuxmlgen builds ranges and a key per CVE from them, the port page joins CVEs with the same key into one entry that the committer checks
Computed once, in Python. The port page only joins what fits together.

Two design decisions matter most to me. First, the entry is generated exactly once, in Python (portstatus/vuxmlgen), in the hourly run after matching. The website only prints it, and while you select, the browser joins finished pieces of text without computing a single bound. Before that, the same rules lived in Python and in PHP, both sides stayed green in their tests and still drifted apart. Second, facts and output are separate: one module collects which packages are affected with which ranges, another writes VuXML from that. The same entry can later be written as OSV without touching the rules.

Limit 1: the CPE

The generator can only write about CVEs that portstatus maps to a port, and that mapping almost always goes through the CPE, the identifier made of vendor and product. Of 34,817 ports, 3,325 have one. The rest only get CVEs through an unambiguous package name, for example on PyPI and CPAN, or not at all. So a port without a CPE often has no generator, and that does not mean it is clean.

An existing CPE has gaps, too:

  • One product, several vendors. NVD lists nginx under three vendor names: igor_sysoev, nginx and f5. A port with one of these identifiers does not see the CVEs filed under the other two.
  • Shared identifiers. sqlite2 and sqlite3 share a CPE. The generator can then suggest a version bound that never existed for one of the two ports. That is why every template says to check the version numbers before committing.
  • No port revision, no epoch. PORTREVISION and PORTEPOCH do not exist in the version field of a CPE. The generator takes the epoch from the port’s history, see above.
  • Patched ports. A port that ships the fix as a patch under files/ keeps the vulnerable upstream version and still shows red. The generator cannot see that. Before you commit an entry, check whether the port already has the fix.
  • Disputed CVEs. What NVD or the CVE record marks as disputed, portstatus does not count as open. There is no entry for it either.

Limit 2: the licenses

An entry is put together from data of different origin, and that data comes with different terms. VuXML itself has its own BSD-style license, found at the top of vuln.xml (Copyright Jacques Vidrine and contributors).

  • NVD: a work of a US federal agency and therefore in the public domain. NVD asks to be credited as the source and forbids claiming its endorsement. The links to nvd.nist.gov in the entry are exactly that credit.
  • CVE records (cvelistV5): covered by the CVE Terms of Use. They allow reproduction and distribution as long as MITRE’s copyright designation and the license come along.
  • Repology: gives portstatus only a CPE fallback and the newest upstream version. None of it ends up in the entry.

For the generator, this means: ranges, package names, CVE numbers and links are plain data. The description, however, is the literal text of the CVE record. The generator inserts it so you can see which sentence belongs to which CVE, and it is meant as a suggestion. I summarize it in my own words before committing, or quote the upstream project’s advisory with its source, as many entries do. That is better in substance, too: the CNA texts describe the upstream product, not the FreeBSD package.

Where the CVEs come from

portstatus uses open sources only, and each one has its own schedule:

  • NVD feeds (JSON 2.0): CVEs with CPE and version ranges, daily, in full on Sundays. Plus the NVD CPE dictionary with around 50,000 vendor and product pairs, also daily.
  • cvelistV5 from CVE.org: the records of the CNAs, hourly as a delta. They often arrive days or weeks before the NVD analysis and carry their own version ranges.
  • ports.git: commits on main every minute, the quarterly branches, VuXML hourly. The history since 1994 provides the epochs the generator needs.
  • pkg.freebsd.org: the package index for latest and quarterly. It tells which package names a port really builds, and in which version.
  • Repology dump: CPE fallback for ports without their own identifier, and the newest upstream version.
  • Bugzilla: open problem reports per port, hourly.

Where NVD already has data for a CVE and a product, it takes precedence. cvelistV5 fills in where NVD has nothing yet, and for new CVEs that is the rule.

Conclusion

  • For each of the 3,560 open CVEs an entry is ready, 3,061 of them without anything left to fill in.
  • Several CVEs become one entry when their ranges are the same. The page refuses everything else instead of building a wrong entry.
  • The bounds are checked with libpkg, the epochs come from the port’s history.
  • Topic, vid, the port’s own patches and the description in your own words stay with the committer.

The generator does not replace a committer. It takes over the copying and leaves the checking to them.

Try it: look up one of your ports on portstatus.bsdproject.de, tick the open CVEs and look at the entry. If a bound is wrong, report it through “Report wrong data” on the port page, with the port name and the CVE.

Schreibe einen Kommentar