epugh commented on code in PR #204:
URL: https://github.com/apache/solr-site/pull/204#discussion_r3611350401
##########
plugins/vex/vex.py:
##########
@@ -156,6 +159,299 @@ def pelican_init(pelicanobj):
with open('%s/solr.vex.json' % output_path, 'w') as out:
json.dump(vex, out, indent=2)
+ # OpenVEX (https://openvex.dev/) output, derived from the same entries and
+ # validated against the vendored OpenVEX JSON schema.
+ openvex = build_openvex(vex_input)
+ with open(SCHEMA_DIR / 'openvex_json_schema.json') as schema:
+ validate(openvex, json.load(schema))
+ with open('%s/solr.openvex.json' % output_path, 'w') as out:
+ json.dump(openvex, out, indent=2)
+
+
+# --- OpenVEX generation -----------------------------------------------------
+
+# CycloneDX analysis.state -> OpenVEX status vocabulary.
+OPENVEX_STATUS = {
+ 'not_affected': 'not_affected',
+ 'exploitable': 'affected',
+ 'in_triage': 'under_investigation',
+ 'resolved': 'fixed',
+}
+
+# CycloneDX analysis.justification -> OpenVEX justification, for the cases that
+# have a clean equivalent. 'requires_configuration' has no OpenVEX counterpart,
+# so those not_affected statements rely on impact_statement instead.
+OPENVEX_JUSTIFICATION = {
+ 'code_not_present': 'vulnerable_code_not_present',
+ 'code_not_reachable': 'vulnerable_code_not_in_execute_path',
+}
+
+# Maven groupIds for the artifacts our VEX entries reference, so a JAR name can
+# be emitted as a proper purl subcomponent. Artifacts not listed here (or JARs
+# whose version can't be parsed, e.g. "guava-*.jar") fall back to a bare @id.
+JAR_GROUPS = {
+ 'opennlp-tools': 'org.apache.opennlp',
+ 'jetty-http': 'org.eclipse.jetty',
+ 'jetty-server': 'org.eclipse.jetty',
+ 'json-path': 'com.jayway.jsonpath',
+ 'zookeeper': 'org.apache.zookeeper',
+ 'xercesImpl': 'xerces',
+ 'protobuf-java': 'com.google.protobuf',
+ 'commons-beanutils': 'commons-beanutils',
+ 'commons-compress': 'org.apache.commons',
+ 'commons-configuration2': 'org.apache.commons',
+ 'commons-lang3': 'org.apache.commons',
+ 'commons-text': 'org.apache.commons',
+ 'dom4j': 'dom4j',
+ 'avatica-core': 'org.apache.calcite.avatica',
+ 'calcite': 'org.apache.calcite',
+ 'slf4j-api': 'org.slf4j',
+ 'icu4j': 'com.ibm.icu',
+ 'netty-all': 'io.netty',
+ 'hadoop-auth': 'org.apache.hadoop',
+ 'hadoop-common': 'org.apache.hadoop',
+ 'log4j-core': 'org.apache.logging.log4j',
+ 'log4j-1.2-api': 'org.apache.logging.log4j',
+ 'log4j-layout-template-json': 'org.apache.logging.log4j',
+ 'lucene-analyzers-icu': 'org.apache.lucene',
+ 'vorbis-java-tika': 'org.gagravarr',
+ 'velocity-tools': 'org.apache.velocity',
+ 'org.restlet': 'org.restlet.jee',
+ 'simple-xml': 'org.simpleframework',
+ 'carrot2-guava': 'org.carrot2.shaded',
+ 'junit': 'junit',
+ 'guava': 'com.google.guava',
+ 'jackson-databind': 'com.fasterxml.jackson.core',
+ 'jdom': 'org.jdom',
+ 'jdom2': 'org.jdom',
+ 'tika-core': 'org.apache.tika',
+ 'calcite': 'org.apache.calcite',
+ 'calcite-core': 'org.apache.calcite',
+ 'jcl-over-slf4j': 'org.slf4j',
+ 'jul-to-slf4j': 'org.slf4j',
+ 'hadoop-hdfs': 'org.apache.hadoop',
+ 'hadoop-client': 'org.apache.hadoop',
+ 'simple-xml': 'org.simpleframework',
+}
+
+# Split a JAR name into (artifact, version). Standard `artifact-version.jar`
uses
+# a greedy artifact so the version is the *last* "-<digits...>" segment
(handles
+# artifacts that themselves contain version-like parts, e.g. `log4j-1.2-api`).
+# The dot-separated form (`tika-core.1.17.jar`) is a rare fallback.
+_JAR_HYPHEN_RE = re.compile(r'^(.+)-(\d[\w.]*)\.jar$')
+_JAR_DOT_RE = re.compile(r'^(.+?)\.(\d[\w.]*)\.jar$')
+
+
+def jar_to_component(jar):
+ """Turn a vulnerable-JAR name into an OpenVEX subcomponent: a Maven purl
when
+ the coordinates are known, otherwise a bare @id using the raw name (e.g.
for
+ wildcard or descriptive entries like `guava-*.jar` or `jetty-9.4.6 to
9.4.36`)."""
+ match = _JAR_HYPHEN_RE.match(jar) or _JAR_DOT_RE.match(jar)
+ if match:
+ artifact, version = match.group(1), match.group(2)
+ group = JAR_GROUPS.get(artifact)
+ if group:
+ return {'@id': 'pkg:maven/%s/%s@%s' % (group, artifact, version)}
+ return {'@id': jar}
+
+
+def jar_coordinates(jar):
+ """Parse a JAR name into (group, artifact) when the group is known, else
None.
+ The version in the JAR name is ignored — callers derive the concrete
versions
+ Solr actually shipped from solr-dependency-versions.json."""
+ match = _JAR_HYPHEN_RE.match(jar) or _JAR_DOT_RE.match(jar)
+ if not match:
+ return None
+ artifact = match.group(1)
+ group = JAR_GROUPS.get(artifact)
+ return (group, artifact) if group else None
+
+
+DEP_VERSIONS_FILE = Path(__file__).resolve().parent /
'solr-dependency-versions.json'
+_dep_versions = None
+
+
+def dep_versions():
+ """Load the {'group:artifact': {solr_version: dep_version}} map that
records
+ which version of each dependency every Solr release shipped."""
+ global _dep_versions
+ if _dep_versions is None:
+ _dep_versions = json.loads(DEP_VERSIONS_FILE.read_text())
+ return _dep_versions
+
+
+# Authoritative list of every released Solr version, used to expand the
+# free-form `versions` ranges into concrete per-version purls for OpenVEX.
+VERSIONS_FILE = Path(__file__).resolve().parent / 'solr-versions.txt'
+_solr_versions = None
+_INF = 10 ** 9
+
+
+def solr_versions():
+ global _solr_versions
+ if _solr_versions is None:
+ _solr_versions = [
+ ln.strip()
+ for ln in VERSIONS_FILE.read_text().splitlines()
+ if ln.strip() and not ln.strip().startswith('#')
+ ]
+ return _solr_versions
+
+
+def _vkey(version):
+ """Concrete version string -> comparable 4-tuple (missing parts default to
0)."""
+ parts = [int(re.match(r'\d+', p).group()) for p in version.split('.') if
re.match(r'\d+', p)]
+ return tuple((parts + [0, 0, 0, 0])[:4])
+
+
+def _token_bounds(token):
+ """A version token -> (low, high) inclusive keys it covers. Fewer
components
+ or a trailing '.x' widen it to the whole line: '9.9.0' is exact, '9.10' is
the
+ 9.10.x line, '8.x' is the whole 8.x major line."""
+ token = token.strip()
+ wildcard = token.endswith('.x')
+ core = token[:-2] if wildcard else token
+ parts = [int(re.match(r'\d+', p).group()) for p in core.split('.') if
re.match(r'\d+', p)]
+ low = tuple((parts + [0, 0, 0, 0])[:4])
+ if not wildcard and len(parts) >= 3: # fully-specified version ->
exact
+ return low, low
+ return low, tuple((parts + [_INF, _INF, _INF, _INF])[:4])
+
+
+def _matches_token(vk, tok):
+ tok = tok.strip()
+ if tok.startswith('≤') or tok.startswith('<='):
+ return vk <= _token_bounds(tok.lstrip('≤<= ').strip())[1]
+ if tok.startswith('<'):
+ return vk < _token_bounds(tok.lstrip('< ').strip())[0]
+ if tok.startswith('≥') or tok.startswith('>='):
+ return vk >= _token_bounds(tok.lstrip('≥>= ').strip())[0]
+ if '-' in tok:
+ a, b = tok.split('-', 1)
+ return _token_bounds(a)[0] <= vk <= _token_bounds(b)[1]
+ low, high = _token_bounds(tok)
+ return low <= vk <= high
+
+
+def expand_versions(range_str):
+ """Expand a `versions` range string (e.g. '4.6.0-8.x') into the concrete
+ released Solr versions it covers.
+
+ Only fully-closed ranges are expanded. Ranges with an open lower bound
+ ('≤'/'<'), and 'all'/empty ranges, return [] so the caller falls back to a
+ version-less product purl. Without a real lower bound, expansion would run
+ back to the earliest release (1.1.0) and over-claim versions that predate
the
+ affected dependency. (Closing those bounds is a follow-up in the VEX
data.)"""
+ if not range_str or range_str.strip() == 'all':
+ return []
+ tokens = range_str.split(',')
+ if any(t.strip().startswith(('≤', '<')) for t in tokens):
+ return []
+ return [v for v in solr_versions() if any(_matches_token(_vkey(v), t) for
t in tokens)]
+
+
+def jar_products(jars, versions):
+ """Turn an entry's vulnerable JARs into OpenVEX product purls.
+
+ A single VEX statement must match a dependency across every Solr image in
the
+ affected range, but each Solr release ships a different pinned version of
that
+ dependency. So for each JAR whose coordinates are known, we expand it to a
purl
+ per concrete dependency version Solr actually shipped over the affected
Solr
+ range (from solr-dependency-versions.json), rather than the single version
in
+ the JAR name. JARs we can't resolve (unknown group, open-lower-bound
range, or a
+ dependency not in the map) fall back to the literal purl from the JAR
name."""
+ affected_solr = expand_versions(versions)
+ products, seen = [], set()
+
+ def add(component):
+ if component['@id'] not in seen:
+ seen.add(component['@id'])
+ products.append(component)
+
+ for jar in jars:
+ coords = jar_coordinates(jar)
+ shipped = dep_versions().get('%s:%s' % coords) if coords else None
+ derived = sorted({shipped[sv] for sv in affected_solr if sv in
shipped}) \
+ if (coords and shipped and affected_solr) else []
+ if derived:
+ group, artifact = coords
+ for ver in derived:
+ add({'@id': 'pkg:maven/%s/%s@%s' % (group, artifact, ver)})
+ else:
+ add(jar_to_component(jar))
+ return products
+
+
+def build_openvex(entries):
+ """Build an OpenVEX (v0.2.0) document from read_vex_articles() entries.
+
+ The vulnerable JAR purls are emitted as the statement `products`, because
+ scanners (e.g. Docker Scout) match VEX on the product purl of the affected
+ package. Entries with no JAR (Solr-native CVEs) use the Solr product
instead,
+ version-expanded from a closed range (via solr-versions.txt) or
version-less
+ for an open lower bound. The Solr version range is preserved in
status_notes.
+ """
+ statements = []
+ for v in entries:
+ if not v['ids']:
+ continue
+ status = OPENVEX_STATUS.get(v['analysis']['state'],
'under_investigation')
+ detail = (v['analysis'].get('detail') or '').strip()
+
+ vulnerability = {'name': v['ids'][0]}
+ if len(v['ids']) > 1:
+ vulnerability['aliases'] = v['ids'][1:]
+
+ # Scanners (e.g. Docker Scout) match VEX statements on the product
purl,
+ # so the vulnerable JAR(s) are the products. For an entry with no JAR
+ # (e.g. a Solr-native CVE), the product is Solr itself: a versioned
purl
+ # per affected release when the range is closed, else version-less. The
+ # human-readable Solr version range is carried in status_notes below.
+ if v['jars']:
Review Comment:
fair point. That we don't have one "solr", we have multiple.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]