polycratia

Validate VEX on the way out, not on the way in

· 9 min read

A tool that both reads and writes VEX documents ends up with one struct carrying two opposite obligations. A document I issue is a claim somebody downstream will act on, so anything ambiguous has to be refused before it reaches disk. A document a supplier issued last quarter is a fact, and refusing to parse it hides exactly the statements someone needs in order to fix them. The mistake is serving both duties with one validator called from both sides.

I ran into this building vexdesk, a small Go tool that sits between a bill of materials and a VEX document: it reads what syft or trivy already wrote, works out which OSV advisories could touch the components, and turns my decisions into OpenVEX. From 11 September 2026 the EU Cyber Resilience Act expects manufacturers to answer the "is my product affected" question in writing, quickly, for every product they ship. That changes what these files are. They stop being an artefact of an internal process and become something a customer receives, stores, and compares against the one before it.

That last word carries the whole argument. Comparison is the only reason to be lenient on read, and it is a sufficient one.

Reading: refuse only what is not a document#

The read path has exactly one job: decode. Whether the document obeys the rules is information, not a gate.

go
// Parse reads a document that has already been issued. It does not validate: a
// document published last quarter is a fact, and refusing to read it would hide
// exactly the statements someone needs to see in order to fix them. Writing
// stays strict; reading does not.
func Parse(data []byte) (*Document, error) {
    var doc Document
    if err := json.Unmarshal(data, &doc); err != nil {
        return nil, fmt.Errorf("openvex: %w", err)
    }
    if doc.Context == "" && len(doc.Statements) == 0 {
        return nil, errors.New("openvex: not a VEX document: it has neither @context nor statements")
    }
    return &doc, nil
}

The single check left in there is not a conformance check. A payload with neither @context nor statements is not a malformed VEX document, it is some other JSON file, almost always because the wrong path was typed on the command line. Telling you that is helpful. Telling you that a vendor's not_affected is missing a justification code, and then refusing to open the file, is not: the finding you wanted to look at is inside the file you just declined to read.

The payoff shows up in the diff command, which is what a recipient of a new document actually wants:

console
$ vexdesk diff released/vex-2.0.0.json vex.json
3 change(s), 4 claim(s) unchanged

CHANGE       ADVISORY      PRODUCT                                      DETAIL
new          FIXTURE-0005  pkg:npm/cogwheel@4.1.0                       under_investigation
restated     FIXTURE-0004  pkg:npm/cogwheel@4.1.0                       affected → fixed
rejustified  FIXTURE-0001  pkg:golang/github.com/example/widget@v1.2.3  justification vulnerable_code_not_in_execute_path → component_not_present

The comparison runs claim by claim, one vulnerability against one product. For that to work, both sides have to be loadable, including the old one I issued before I tightened my own rules, and including the one from a supplier whose generator is sloppier than mine. A strict parser would make the second document in that pipeline unreadable precisely when it matters most.

Writing: refuse anything that leaves a reader guessing#

On the way out the posture inverts. Validate is called from exactly one place, the constructor, and it is unforgiving.

go
func (d *Document) Validate() error {
    var errs []error
    if strings.TrimSpace(d.Author) == "" {
        errs = append(errs, errors.New("document: author is required"))
    }
    if len(d.Statements) == 0 {
        errs = append(errs, errors.New("document: a document with no statements says nothing"))
    }
    for i, s := range d.Statements {
        if err := s.Validate(); err != nil {
            errs = append(errs, fmt.Errorf("statement %d: %w", i, err))
        }
    }
    return errors.Join(errs...)
}

Two details there are deliberate. Errors are joined rather than returned on the first failure, because somebody fixing a decisions file wants the whole list, not one problem per run. And each statement error is wrapped with its index, because "vulnerability name is required" is useless without knowing which of forty statements it belongs to.

not_affected is where the rule earns its keep#

The statement-level check that matters most is the one on not_affected, since that is the verdict nobody rechecks:

go
case NotAffected:
    // One of the closed set of codes, always. Prose explains a decision to
    // the person reading this document; only the code lets a reviewer, a
    // diff or another tool compare it against every other not_affected.
    // An impact statement is welcome alongside it, never instead of it.
    switch {
    case s.Justification == "" && strings.TrimSpace(s.ImpactStatement) != "":
        errs = append(errs, fmt.Errorf(
            "not_affected has an impact_statement but no justification: prose does not stand in for one of %v",
            justifications))
    case s.Justification == "":
        errs = append(errs, fmt.Errorf("not_affected requires a justification, one of %v", justifications))
    case !slices.Contains(justifications, s.Justification):
        errs = append(errs, fmt.Errorf("justification %q is not one of %v", s.Justification, justifications))
    }

The first case took a real argument with myself to keep. A sentence like "the affected parser is only reached from the admin importer, which this build does not include" is genuinely more informative to a human than the code vulnerable_code_not_in_execute_path. It is also unusable by the diff above. A reviewer putting this quarter's document next to last quarter's can see that a code changed; nobody can tell whether two paragraphs of prose express the same conclusion. So the code is mandatory and the prose is encouraged next to it, and the error message lists the accepted codes rather than sending the author off to find the spec.

The symmetric rules follow from the same test: can you act on this without asking me a question. affected requires an action_statement, because telling a customer they are affected and nothing else gives them no move to make. A justification on any status other than not_affected is refused outright: a code attached to fixed looks like it means something and does not.

Note that none of this is enforced on read. A supplier document that breaks every one of these rules parses fine and diffs fine, and that is what splitting the paths buys.

The identifier comes from the statements#

go
// New assembles a document and validates it. The identifier is derived from the
// statements, so the same decisions produce the same document id — a rebuild in
// CI does not look like a new document every time.
func New(author string, at time.Time, tooling string, statements []Statement) (*Document, error) {
    doc := &Document{
        Context:    Context,
        Author:     author,
        Timestamp:  at.UTC().Format(time.RFC3339),
        Version:    1,
        Tooling:    tooling,
        Statements: statements,
    }
    doc.ID = deriveID(statements)
    if err := doc.Validate(); err != nil {
        return nil, err
    }
    return doc, nil
}

deriveID is a hash of the marshalled statements, nothing else. A random UUID would have been one line shorter and would have made every CI run produce a document that looks new. The timestamp moves on every build and should; identity should not. If my decisions did not change, the @id does not change, and a recipient automating against these files can tell a reissue from a revision without reading the body.

That makes the placement of the validation matter in a way that is easy to miss: the ID is assigned before Validate runs, so a rejected document never escapes with a plausible-looking identifier attached. There is no partially-constructed Document lying around for a caller to grab and write out anyway.

No issuer, no document#

The vex command refuses to run without -author. The library would refuse too, a few hundred milliseconds later, after reading the SBOM and the advisory directory, so the flag check is redundant in the strictest sense. I keep it because the reason is not about flags. A VEX document without an issuer cannot be trusted by its reader: an anonymous not_affected is a claim with nobody behind it, which is worth less than no claim at all. Checking it at the edge says that out loud, and checking it again in Validate means it holds no matter who calls the package.

What I would do differently#

Leniency on read currently means silence on read. The information is available, since Validate is exported and I could run it against a parsed supplier document, but nothing in the read path surfaces it, so a document with a bare impact_statement and no code passes through unremarked. I would add that as a report rather than a refusal: parse, diff, and separately say "this document breaks three rules I would not have let myself emit." Accepting a flawed document and failing to mention the flaws are two different decisions, and I conflated them.

Two smaller things. Version is hardcoded to 1 while the @id moves with the statements, which leaves two identity-shaped fields following different rules. And because deriveID hashes only the statements, the same claims issued under a different author produce the same identifier, defensible enough since the ID names a set of decisions and the author is a separate field, but it is a property I arrived at rather than chose...

The general shape has held up, though, and I think it is not specific to VEX. Any format you both consume and produce wants the validator on one side only. Strict where you are the author and responsible for what the reader can do with it; permissive where somebody else already made their choices and all you can do is read them.

react

$ new-project --brief

or email hey@polycratia.com