CEL Expression Reference for VEX Rules

A VEX rule matches vulnerabilities using CEL (Common Expression Language). An expression must evaluate to a boolean: true means the rule applies to that vulnerability. This page documents everything the expression environment provides.

The environment at a glance

The environment is deliberately small:

  • exactly one variable: vuln
  • three DevGuard-specific functions: matchesPattern, matchesPurl, and now — only the first two return a boolean on their own
  • the standard CEL operators: ==, !=, <, <=, >, >=, &&, ||, !, in, ?:
  • CEL's built-in date/time support: timestamp(string), duration(string), and arithmetic/comparison between the two — see Working with timestamps below

The editor checks two things as you type. First, that the expression parses. Second, that it evaluates to a boolean — a bare field access like vuln.cveId is a string and is rejected with Expression must evaluate to a bool, e.g. via ==, !=, matchesPattern(...) or matchesPurl(...). Wrap it in a comparison.

Lines beginning with // are comments and are skipped. Every other line is checked as an expression in its own right, which is why the rules DevGuard pre-fills for you put their explanatory comment on a separate line above the expression.

The vuln variable

vuln is the dependency vulnerability being evaluated. The editor's autocomplete offers these fields — type vuln. to see them:

FieldTypeDescription
vuln.idstringThe finding's own identifier.
vuln.statestringThe finding's current state, e.g. "open", "accepted", "falsePositive".
vuln.cveIdstringThe advisory id, e.g. "CVE-2021-1234". Empty for findings without a CVE.
vuln.cvemap or nullThe nested CVE object — type . again for its fields.
vuln.componentPurlstringPackage URL of the vulnerable component, e.g. "pkg:npm/lodash@4.17.21".
vuln.componentFixedVersionstring or nullThe bare version of the vulnerable component itself that resolves this finding, e.g. "4.17.21"null while no fix has been published.
vuln.directDependencyFixedVersionstring or nullA purl for the direct dependency you'd need to bump instead, e.g. "pkg:npm/web@1.2.0", resolved by walking the dependency path — null while unresolved, even if componentFixedVersion is set.
vuln.vulnerabilityPathlist<string>The dependency path purls; read via matchesPattern, not usually compared directly.
vuln.assetVersionNamestringThe branch or tag the finding belongs to.
vuln.riskAssessmentnumber or nullDevGuard's computed risk score for the finding.
vuln.ticketId / vuln.ticketUrlstring or nullThe linked ticket, if one was created.
vuln.lastStateChange / vuln.createdAt / vuln.updatedAt / vuln.riskRecalculatedAtstring (RFC3339)Timestamps on the finding — see Working with timestamps to compare them. lastStateChange updates whenever vuln.state changes (open → accepted, accepted → reopened, etc.), not on every rescan.
vuln.manualTicketCreationboolWhether ticket creation for this finding was triggered manually.

CVE details: vuln.cve

Type vuln.cve. for these:

FieldTypeDescription
vuln.cve.cvestringThe CVE identifier, e.g. "CVE-2021-1234".
vuln.cve.cvssnumberCVSS base score.
vuln.cve.vectorstringThe CVSS vector string.
vuln.cve.epssnumber or nullEPSS exploitation probability.
vuln.cve.percentilenumber or nullEPSS percentile.
vuln.cve.descriptionstringThe advisory description text.
vuln.cve.referencesstringReference links for the advisory.
vuln.cve.datePublished / vuln.cve.dateLastModifiedstring (RFC3339)Advisory publish/update timestamps.
vuln.cve.riskmapNested risk metrics — type . again for its fields.
vuln.cve.cisaExploitAdd / vuln.cve.cisaActionDuestring (date) or nullCISA KEV catalog dates, if listed.
vuln.cve.cisaRequiredAction / vuln.cve.cisaVulnerabilityNamestring or nullCISA KEV catalog metadata, if listed.
vuln.cve.euvdExploitAddstring (date) or nullEUVD exploited-in-the-wild date, if listed.
vuln.cve.weaknesses / vuln.cve.exploitslist<map>Associated CWEs and known exploits.

Risk metrics: vuln.cve.risk

Type vuln.cve.risk. for these:

FieldTypeDescription
vuln.cve.risk.baseScorenumberRisk score before environment/threat-intel adjustments.
vuln.cve.risk.withEnvironmentnumberRisk score adjusted for your environment.
vuln.cve.risk.withThreatIntelligencenumberRisk score adjusted for threat intelligence.
vuln.cve.risk.withEnvironmentAndThreatIntelligencenumberRisk score adjusted for both.

matchesPattern(vuln, pattern)

Matches the vulnerability's dependency path. This is the function you want whenever the decision depends on how a package enters your product rather than which package it is.

The first argument is always vuln itself (the function reads vulnerabilityPath and artifactPurls from it). The second is a list of strings, each being either a purl or one of two special tokens.

TokenMeaning
*Zero or more path elements — any ancestors above the next named element.
ROOTAnchors the pattern at the repository root, i.e. the element after it must be a direct dependency. Matches all artifacts and branches.

Matching semantics

By default a pattern is matched as a suffix of the dependency path. The pattern ["pkg:npm/a@1.0.0", "pkg:npm/b@2.0.0"] matches any path that ends with a → b, regardless of what comes before.

If the pattern's first element identifies one of the repository's artifacts, that element is consumed and the remainder is matched anchored from the start of the path instead. This is what lets DevGuard's own CSAF and CycloneDX exports round-trip cleanly back into rules.

Individual elements match by exact purl string first; failing that, the pattern element's version is interpreted as a semver constraint (see the caveat below).

matchesPurl(purl, constraint)

Matches a purl against a version constraint — for dismissing a whole version range rather than one exact version.

The type, namespace and name must be exactly equal. Only the version part is treated as a constraint, using Masterminds semver syntax:

ConstraintMatches
pkg:npm/undici@>=6.0.06.0.0 and above
pkg:npm/undici@6.26.*any 6.26.z
pkg:npm/undici@6.xany 6.y.z
pkg:npm/undici@~6.26.06.26.0 up to but excluding 6.27.0
pkg:npm/undici@^6.0.06.0.0 up to but excluding 7.0.0
pkg:npm/undici@>=6.0.0 <6.27.0a bounded range

Working with timestamps

Timestamp fields such as vuln.lastStateChange are exposed as RFC3339 strings, not CEL's native timestamp type. Wrap a field in timestamp(...) to compare it, and use now() to get the current time:

FunctionReturnsNotes
now()timestampThe time the expression is evaluated, not when the rule was saved.
timestamp(string)timestampParses an RFC3339 string, e.g. a vuln.* timestamp field.
duration(string)durationParses a Go-style duration string — "24h", "720h". CEL has no built-in unit larger than hours, so express days as multiples of 24h.

Subtracting one timestamp from another yields a duration, which is what makes the age check above possible. Timestamps and durations also support <, <=, >, >=, == directly against each other, and +/- between a timestamp and a duration.

Cookbook

Dismiss one advisory everywhere

Dismiss one exact component version

Use this form — not matchesPurl — for Debian, Alpine and other non-semver ecosystems:

Dismiss a version range

Dismiss one advisory only along one dependency path

The most precise form, and the one the path graph generates for you:

The leading "*" is here only for readability — it makes the pattern read as "any ancestors, then this path". Since matching is suffix-based by default, omitting it matches exactly the same vulnerabilities.

Dismiss an advisory only when it arrives through a direct dependency

Accept everything below a CVSS threshold

Accept low-severity findings that are also unlikely to be exploited

Accept risk and False positive rules only re-evaluate against open vulnerabilities. A rule like this dismisses a finding once, but it will not reopen it later if the EPSS score subsequently rises above the threshold — you'd need a matching Reopen rule for that (see below) and is why "accept" rules are less common than "dismiss" rules.

Reopen once a fix is available

A Reopen rule is the mirror image: it only re-evaluates against vulnerabilities currently accepted as a known risk, twice a day, and reopens the ones it matches. Combine it with the fixed-version fields to reopen a risk you accepted only because there was nothing to fix yet:

componentFixedVersion and directDependencyFixedVersion are populated by the same analysis behind the finding's Quickfix suggestion — see Transitive Vulnerability Path Analysis: The Quickfix Algorithm for how DevGuard resolves them.

See Reopen a VEX Rule for how to create one.

Scope a rule to one branch

Dismiss any of several advisories in the same component

Reopen a finding accepted more than 30 days ago

See Reopen a stale accepted risk for how to turn this into a Reopen rule.

Have feedback? We want to hear from you!

Fields marked with * are required