Files
rengine/shared/definitions/surface.py
T
Yogesh OjhaandClaude Opus 5 2b17f345af feat(software): infer CVEs from the versions assets report
A sixth result dimension. Versions parsed from httpx tech strings and
Server headers match against a local NVD corpus, so an asset's known
CVEs are named without sending a request.

The stored CPE is not the input: 81 of 7,771 entries carry a version,
so matching on it returns every CVE ever filed against the product.
Versions come from tech and the Server banner, and the NVD pair comes
from a curated map rather than a name match — popper:popper is a 2005
webmail app, aos is a switch OS, and Apache's CVEs live under
apache:http_server, not apache:apache_http_server. An unmapped product
is reported as unmapped.

Ranges compare as zero-padded keys so the match is one SQL pass: a
letter suffix takes its own segment, trailing zeros drop, and numeric
and alphabetic schemes never order against each other. A criteria with
no version statement is discarded at load, because it means every
release. Measured against a reference implementation over 3,000,000
comparisons, residual disagreement is 0.07% and each case is the new
rule being the correct one.

Every match names what is unverified — conditional, backport,
fingerprint, coarse — and confidence is the count of those. Nothing
inferred enters the vulnerability count, triage or coverage a scanner
earned; software_cves keeps its own.

The corpus is a fourth feed, keyless, from the fkie-cad mirror of the
retired NVD JSON feeds: 2,197,146 ranges and 372,244 CVEs in 5m40s,
streamed rather than parsed whole (1,254 MB resident becomes 86 MB).
It rebuilds nightly and re-matches every covering scan, since the
corpus moves without a scan.

Exploitation coverage and the intel change feed leave Arsenal for the
dashboard; Arsenal keeps the feeds and the provider it configures.
Feed row counts, nouns and tables now come from FeedSpec — three
places had hardcoded two kinds, so a third feed rendered 0.

Port versions split out of the Server header in the migration: 223
rows become 3,037.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4NuBh4EsywioeryFZRNQR
2026-09-12 11:00:07 +05:30

62 lines
2.3 KiB
Python

"""The result dimensions a scan produces, named as the UI names them."""
from __future__ import annotations
from enum import StrEnum
from shared.enums.scan import AssetKind
class SurfaceDimension(StrEnum):
WEB_ASSETS = "web_assets"
ENDPOINTS = "endpoints"
SERVICES = "services"
IPS = "ips"
VULNERABILITIES = "vulnerabilities"
SOFTWARE = "software"
SURFACE_ORDER: tuple[str, ...] = tuple(d.value for d in SurfaceDimension)
SURFACE_LABELS: dict[str, str] = {
SurfaceDimension.WEB_ASSETS.value: "Web assets",
SurfaceDimension.ENDPOINTS.value: "Endpoints",
SurfaceDimension.SERVICES.value: "Services",
SurfaceDimension.IPS.value: "IP addresses",
SurfaceDimension.VULNERABILITIES.value: "Vulnerabilities",
SurfaceDimension.SOFTWARE.value: "Software CVEs",
}
SURFACE_NOUN: dict[str, tuple[str, str]] = {
SurfaceDimension.WEB_ASSETS.value: ("web asset", "web assets"),
SurfaceDimension.ENDPOINTS.value: ("endpoint", "endpoints"),
SurfaceDimension.SERVICES.value: ("service", "services"),
SurfaceDimension.IPS.value: ("address", "addresses"),
SurfaceDimension.VULNERABILITIES.value: ("finding", "findings"),
SurfaceDimension.SOFTWARE.value: ("software CVE", "software CVEs"),
}
SURFACE_KINDS: dict[str, frozenset[str]] = {
SurfaceDimension.WEB_ASSETS.value: frozenset(
{AssetKind.HOSTS.value, AssetKind.HTTP_ASSETS.value}
),
SurfaceDimension.ENDPOINTS.value: frozenset({AssetKind.ENDPOINTS.value}),
SurfaceDimension.SERVICES.value: frozenset({AssetKind.PORTS.value}),
SurfaceDimension.IPS.value: frozenset({AssetKind.ADDRESSES.value}),
SurfaceDimension.VULNERABILITIES.value: frozenset(
{AssetKind.VULNERABILITIES.value}
),
# inferred from what the probe stages report, never produced by a stage of its own
SurfaceDimension.SOFTWARE.value: frozenset(
{AssetKind.HTTP_ASSETS.value, AssetKind.PORTS.value}
),
}
SURFACE_COUNT_COLUMNS: dict[str, tuple[str, ...]] = {
SurfaceDimension.WEB_ASSETS.value: ("subdomains_found", "http_assets_found"),
SurfaceDimension.ENDPOINTS.value: ("endpoints_found",),
SurfaceDimension.SERVICES.value: ("open_ports_found",),
SurfaceDimension.IPS.value: ("ips_found",),
SurfaceDimension.VULNERABILITIES.value: ("vulnerabilities_found",),
}