gumslone / laravel-vulns
Multi-source vulnerability lookups for PHP and Laravel — OSV, NVD, GitHub Advisories, MITRE, EUVD, CVE-Search, Red Hat, Shodan CVEDB, OSS Index, VulnCheck and Snyk behind one contract, with CVSS v2/v3/v4, EPSS/KEV enrichment, change detection and a self-audit command.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- illuminate/collections: ^11.0 || ^12.0 || ^13.0
- psr/log: ^2.0 || ^3.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- gumslone/laravel-package-url: ^1.2
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
Suggests
- gumslone/laravel-package-url: Convert download/release/archive/registry URLs into searchable coordinates (PackageData::fromUrl, VulnSearch::searchUrl)
Provides
None
Conflicts
None
Replaces
None
README
Multi-source vulnerability lookups for PHP — eleven production sources behind one contract, with CVSS v2/v3/v4 scoring, EPSS + CISA KEV threat enrichment and change classification. Extracted from and battle-tested in OSSaur.
| Source | Coverage |
|---|---|
OsvSource |
OSV.dev — registry ecosystems, PURLs, git commits (submodules), batch + pagination + payload caching |
GitHubAdvisorySource |
GitHub Advisory DB (GraphQL, token) and repository security advisories (REST, tokenless) |
NvdSource |
NVD 2.0 by CPE, rate-limit aware, parses configurations into real version ranges |
CveSearchSource |
CVE-Search / CIRCL by CPE |
EuvdSource |
ENISA EU Vulnerability Database |
SnykSource |
Snyk REST (token + org) |
OssIndexSource |
Sonatype OSS Index — purl-native, 128-coordinate batches (free account required) |
RedHatSource |
Red Hat Security Data — RPM ecosystem and container base images |
ShodanCvedbSource |
Shodan CVEDB — CVSS + EPSS + KEV in one record, product search |
MitreCveSource |
MITRE CVE Services — authoritative CVE Record v5 by id, often pre-NVD |
VulnCheckSource |
VulnCheck Community "NVD++" by id (free token) |
The core is framework-free (plain Guzzle, PSR-3, PSR-16); the Laravel service provider is optional sugar.
Install
composer require gumslone/laravel-vulns php artisan vulns:install # interactive: publishes config, asks for the # optional API keys (written to .env), offers # the built-in UI; --check smoke-tests every # enabled source against a live query
Built-in search UI
One dependency-free page at /vulns (no build step): paste anything
searchAny() accepts — advisory id, purl, CPE, commit sha, release/download
URL — and see the merged, enriched results with KEV / ransomware / exploit
tags, plus a warning when a source failed (empty + failed source = shown as
inconclusive, never as a clean bill). Off by default:
VULNS_UI_ENABLED=true
Path and middleware are configurable (vulns.ui) — put your auth middleware
in front for anything beyond local use.
Searching
Every lookup starts from a PackageData — build one from a purl, a CPE,
or explicit coordinates — and goes to one source or to all of them.
Search all sources at once
use Gumslone\Vulns\VulnSearch; $search = app(VulnSearch::class); // Laravel // $search = new VulnSearch([$osv, $nvd, …]); // plain PHP $vulns = $search->searchPurl('pkg:npm/lodash@4.17.20'); $vulns = $search->searchCpe('cpe:2.3:a:prasathmani:tiny_file_manager:2.6:*:*:*:*:*:*:*'); $vulns = $search->searchCommit('https://github.com/owner/repo/commit/bf04e5f2'); // or a bare sha $vulns = $search->search(new PackageData(name: 'lodash', version: '4.17.20', ecosystem: 'npm')); // One entry point for anything a user pastes — advisory id (CVE, GHSA, EUVD, // PYSEC-, RUSTSEC-, GO-, MAL-, RHSA-, DSA-, USN-, …), purl, CPE, commit sha, // or any package URL. Unrecognisable input throws. $vulns = $search->searchAny('CVE-2021-44228'); $vulns = $search->searchAny('cpe:2.3:a:tukaani:xz:5.6.0:*:*:*:*:*:*:*'); $vulns = $search->searchAny('https://github.com/vrana/adminer/releases/download/v5.5.1/adminer.zip'); // URL search understands commit pages natively; with // gumslone/laravel-package-url installed (suggested, not required) it also // converts release assets, archive zips (commit archives keep the sha for // OSV git-range matching), codeload, GitLab /-/archive/, npm tarballs, // PyPI wheels, and every other registry download URL that package speaks. $vulns = $search->searchUrl('https://github.com/laravel/framework/archive/bf04e5f2.zip'); foreach ($vulns as $v) { printf("%s %s %s\n", $v->vulnId, $v->severity->value, $v->cvssV3Score ?? '-'); }
Results are merged across sources: records that share any id — directly or through aliases (GHSA ↔ CVE ↔ SNYK) — are one advisory, shown under its CVE when a record carries one. Aliases, references, CWEs and fixed versions are pooled and the richest field kept — OSV's version ranges plus NVD's CVSS score and exploit links end up on the same record, sorted by score.
A source that fails does not abort the search; check errors() so an
unreachable feed reads as "possibly incomplete", never as "clean":
if ($search->errors()) { logger()->warning('Vulnerability sources failed', $search->errors()); }
Choosing which sources to search
only() and except() return a restricted copy — the library equivalent of
the CLI's --source=:
$search->only('nvd')->searchCpe($cpe); // one source $search->only(['osv', 'github'])->searchPurl($purl); // a subset $search->except('snyk')->search($package); // everything but one $search->availableSources(); // ['osv','github','nvd','cve_search','euvd','snyk', // 'oss_index','redhat','shodan_cvedb','mitre','vulncheck'] $search->sources(); // the enabled subset this instance will query
An unknown name throws rather than quietly searching fewer feeds — a typo that silently narrowed the search would look exactly like "nothing found".
When sources disagree: priority and freshness
Several sources usually know the same CVE, with different scores, severities and wording. The merge picks one record as the base — its fields win, the others only fill gaps (ranges, vectors, references are still pooled from all).
By default the base comes from the source trust order — osv → github → nvd → mitre → redhat → oss_index → vulncheck → snyk → euvd → shodan_cvedb → cve_search, configurable via config('vulns.priority') or per call:
$search->prioritize(['nvd', 'osv'])->search($package); // trust NVD's score first
preferLatest() makes the most recently modified record win instead, so a
CVSS rescore or rewritten description reaches the result no matter which feed
published it first — including downward rescores, which a "keep the highest
score" merge would silently undo:
$search->preferLatest()->search($package); // or globally: VULNS_MERGE=latest
Records without a modification date fall back to the trust order. Both
settings survive only() / except() chaining, and the merged record's
sourceModifiedAt always carries the base's timestamp so you can see how
fresh the winning data is.
For one advisory, latest() is the shortcut — every source asked, the most
recently modified answer winning, EPSS/KEV stamped:
$fresh = $search->latest('CVE-2021-44228'); // ?VulnerabilityData $search->errors(); // feeds that failed — null + errors ≠ "gone"
EPSS and KEV: how likely, and actually exploited?
CVSS says how bad; EPSS (FIRST.org) says how likely — the probability of exploitation in the wild within 30 days — and CISA KEV says it is being exploited. Merged results are stamped with both automatically (keyed by canonical CVE id, cached, no API keys needed):
$vuln->cvssV3Score; // 9.8 — severity (also cvssV4Score, cvssV2Score) $vuln->effectiveCvssScore(); // newest standard first: v4 → v3 → v2 $vuln->epssScore; // 0.94 — probability of exploitation (0..1) $vuln->epssPercentile; // 0.999 — relative to all scored CVEs $vuln->isKnownExploited; // true — listed in CISA KEV
Configure or disable via vulns.epss / vulns.kev (VULNS_EPSS_ENABLED,
VULNS_KEV_ENABLED), or per instance with $search->withEnricher(null).
A failing feed leaves results un-enriched and lands in errors() — a
missing EPSS score reads as "unknown", never "not exploited".
Detecting what changed on a re-query
When you refresh a stored advisory, changesSince() classifies the
difference so you can route on it — reopen triage on a major change, update
silently on a minor one:
$change = $fresh->changesSince($stored); // VulnChange $change->impact(); // ChangeImpact::None | Minor | Major $change->isMajor(); // score increased OR downgraded, severity shift, // affected ranges edited, a fix appeared, listed in // CISA KEV, or EPSS crossed the 0.1 triage threshold $change->changes; // [ChangeType::ScoreIncreased, ...] $change->details; // ['cvss_v3_score' => [5.0, 8.1], ...] $change->summary(); // "score increased (5 → 8.1), description updated"
A description or reference update alone is Minor. A downgrade is
deliberately as major as an upgrade — it can release an SLA-tracked
assessment, which someone should look at rather than have slip through.
A source dropping its score (value → null) is treated as upstream data
loss, not a rescore.
refresh() does the re-query and the comparison in one step — the freshest
record for the stored advisory's canonical id (see latest()), classified
against what you have:
$change = $search->refresh($stored); // ?VulnChange — null when no source knows it any more $change?->current; // the fresh merged record, ready to store $change?->isMajor();
Batching lets sources use their bulk endpoints and request pooling — one call for a whole lockfile, results keyed like the input:
$byPackage = $search->searchBatch([ 'lodash' => PackageData::fromPurl('pkg:npm/lodash@4.17.20'), 'guzzle' => PackageData::fromPurl('pkg:composer/guzzlehttp/guzzle@7.9.0'), ]); $byPackage['lodash']; // VulnerabilityData[]
Look one advisory up by id across every source:
$search->fetchById('CVE-2021-44228'); $search->fetchById('GHSA-jfh8-c2jp-5v3q');
Knowing what was covered
An empty answer from a source means "nothing found" only if the source could
look the package up at all — an unmapped ecosystem, a purl-less package on a
purl-keyed feed, or an id-only feed never does. coverage() says which
sources actually queried each package in the last batch search:
$search->searchBatch(['lodash' => $pkg]); $search->coverage()['lodash']; // ['queried' => ['osv', 'github', 'nvd'], 'skipped' => ['snyk', 'mitre'], 'failed' => ['euvd']]
Nothing queried is "not covered", not "clean" — treat it like errors().
failed also covers partial trouble: a source that answered for the rest of
the batch but hit a GraphQL rate limit on this one package, could not fetch
one advisory's details, or stopped at its max_pages cap. Its results are
kept, the reason lands in errors(), and the package does not count as
covered. (Calling a source directly? The same information is on
$source->warnings() and $source->incompleteKeys().)
One report per call
errors() and coverage() describe the most recent call on the instance —
fine in a script, fragile on a shared singleton (Octane, queue workers, nested
searches). report() returns results, failures and coverage together, as an
immutable value that belongs to its call:
$report = $search->report(['lodash' => $lodash, 'left-pad' => $leftPad]); $report->for('lodash'); // VulnerabilityData[] $report->errors; // ['nvd' => '503 Service Unavailable'] $report->isComplete(); // false — a source failed or was cut short $report->isConclusive('left-pad'); // may an EMPTY answer be shown as clean? $report->inconclusiveKeys(); // packages to flag as "not covered / under-reported" json_encode($report); // {results, errors, coverage}
Version filtering
GitHub, CVE-Search and Shodan answer by package name — every advisory the
name ever had, whatever version you run. VulnSearch therefore drops
advisories the package's version provably escapes, and only those:
$search->search(new PackageData(name: 'lodash', version: '5.0.0', ecosystem: 'npm')); // "< 1.0.0" → dropped (every range readable, none matches) // ">= 4.0.0, < 5.1.0" → kept // "< 6.0.0-beta.1" → kept (can't be ordered — "can't tell" is never "not affected") // no ranges at all → kept $search->filterByVersion(false)->search($pkg); // everything, unfiltered
Ranges tagged with a product (NVD configurations, EUVD product lists) are
judged only when they name the package — a sibling product's < 9.0.0
neither flags nor clears it. Config: vulns.version_filter.
Storing and rehydrating records
toArray() / fromArray() round-trip a record, so a stored snapshot can be
rebuilt for changesSince() / refresh(). Inferred values (see below) are
re-derived rather than restored, so the snapshot stays faithful to its source:
$stored = VulnerabilityData::fromArray($row); // toArray() shape, snake or camelCase keys $search->refresh($stored)?->isMajor();
Triage helpers
$v->isMalware(); // MAL- ids, Snyk "malware" issues, OSV malicious-package origins $v->isActivelyExploited(); // CISA KEV, or SSVC exploitation "active" on the CVE record $v->ssvc; // ['exploitation' => 'active', 'automatable' => 'no', // 'technical_impact' => 'total', …] — CISA's SSVC decision // points from the CVE record's ADP container (MITRE source) VersionRange::recommendedFix('4.17.20', $v->fixedVersions); // "4.17.21" — the lowest fix // at or above your version, same major line first; null when past all
SSVC "active" counts as exploited in exploitMaturity() (weaponized) and in
changesSince() (a KnownExploited major change), whether or not KEV has
caught up.
From the terminal
php artisan vulns:search CVE-2021-44228
php artisan vulns:search pkg:npm/lodash@4.17.20 --source=osv --source=nvd
php artisan vulns:search 'cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*' --json --latest
Exits non-zero when a source failed (results may be incomplete) or the query is unrecognisable — never merely because advisories were found.
Audit your own application
vulns:audit reads the app's composer.lock and package-lock.json (v1–v3)
and checks every dependency against the enabled sources — a CI gate in one line:
php artisan vulns:audit # both lockfiles in the project root php artisan vulns:audit --no-dev --min-severity=high # production dependencies, high and up php artisan vulns:audit --format=sarif > vulns.sarif # GitHub code scanning / GitLab / Azure DevOps php artisan vulns:audit --format=json --lock=frontend/package-lock.json
| Exit code | Meaning |
|---|---|
0 |
nothing at or above the threshold |
1 |
findings |
2 |
bad input (unknown lockfile, option) |
3 |
nothing found but a source failed — inconclusive, never a green build on an outage (--ignore-errors accepts it) |
Withdrawn advisories are left out (--include-withdrawn), unscored ones are
kept ("unknown" is not "harmless"), and the table names the version to upgrade
to. Schedule it like any command:
Schedule::command('vulns:audit --no-dev --min-severity=high')->daily()->emailOutputOnFailure('security@example.test');
Support\LockfileReader::read($path) gives you the same PackageData list for
your own tooling.
Calling one source directly
use Gumslone\Vulns\Data\PackageData; use Gumslone\Vulns\Sources\NvdSource; $nvd = app(NvdSource::class); // Laravel (config-wired) // $nvd = new NvdSource(new CpeResolver, options: ['api_key' => env('NVD_API_KEY')]); $vulns = $nvd->queryPackage(PackageData::fromCpe('cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*')); $vulns = $nvd->queryBatch([$pkgA, $pkgB]); // batch/pooled where supported $one = $nvd->fetchById('CVE-2021-44228'); // single advisory
What each source needs
| Source | Queried by | Needs | Notes |
|---|---|---|---|
OsvSource |
ecosystem + name + version; purl (deb/apk/rpm); git commit | — | Batch endpoint, pagination, payload cache. Ecosystems: composer, npm, pypi, maven, nuget, go, cargo, gem, cocoapods, pub, hex, swift, conan (+ deb/apk/rpm by purl). Unmapped ecosystems are skipped, not guessed. |
NvdSource |
CPE | api_key recommended |
5 req/30s anonymous, 50/30s with a key — the source throttles itself. Parses configurations into real version ranges. |
CveSearchSource |
CPE | — | CIRCL; records often carry no version data (treat as undeterminable). |
GitHubAdvisorySource |
ecosystem + name (registry); owner/repo (repository advisories) | token for the registry feed |
Repository advisories work without a token — they cover projects that are in no registry database. |
EuvdSource |
ecosystem + name | — | ENISA EUVD. |
SnykSource |
purl | token + org_id |
Disabled unless both are configured. |
OssIndexSource |
purl | username + api_token (free account) |
Sonatype's dataset; batched 128 purls per request. Disabled without credentials — anonymous access 401s since 2025. Versionless packages are skipped. |
RedHatSource |
name | — | Red Hat Security Data — the source for RPM-ecosystem and container base-image packages. Asked about OS-level packages only (ecosystems option: rpm/generic by default — npm's tar is not the RPM tar). NEVRA strings land in extra, not ranges. |
ShodanCvedbSource |
name (product search) | — | One record carries CVSS + EPSS + KEV. |
MitreCveSource |
fetchById only |
— | Authoritative CVE Record v5 (incl. CNA CVSS v4), often live before NVD analysis. No package search. |
VulnCheckSource |
fetchById only |
api_token |
VulnCheck Community "NVD++" — NVD 2.0-shaped records without the NVD lag. Disabled without a token. |
A CPE-driven source given a package without a CPE derives one from the purl or
name (CpeResolver), or from your curated catalog if you bind
Contracts\CpeLookup. Passing PackageData::fromCpe(...) — or cpe23: on the
constructor — always wins over both.
Coordinates convert in every direction: build a PackageData from a purl, a
CPE, or a git commit (fromPurl / fromCpe / fromCommit — bare sha or
forge commit URL), and read the other form back off it:
PackageData::fromPurl('pkg:composer/laravel/framework@11.0')->toCpe23(); // "cpe:2.3:a:laravel:framework:11.0:*:*:*:*:*:*:*" PackageData::fromCpe('cpe:2.3:a:tukaani:xz:5.6.0:*:*:*:*:*:*:*')->toPurl(); // "pkg:generic/xz@5.6.0" PackageData::fromCommit('https://github.com/owner/repo/commit/bf04e5f2')->purl; // "pkg:github/owner/repo@bf04e5f2" — OSV also matches the commit against git ranges PackageData::fromCommit('https://github.com/owner/repo/commit/bf04e5f2')->toCpe23(); // "cpe:2.3:a:owner:repo:bf04e5f2:*:*:*:*:*:*:*" (a bare sha converts to null — // a hash has no vendor/product identity) PackageData::commitFromUrl('https://gitlab.com/g/p/-/commit/bf04e5f2'); // "bf04e5f2" — the id out of any forge commit link PackageData::fromCommit('…/commit/bf04e5f2')->toCommitUrl(); // back to the forge page (GitLab keeps its /-/ route)
Credentials & configuration
No source needs a key to work; keys raise limits or unlock a feed:
| Env var | Used by | Effect if unset |
|---|---|---|
NVD_API_KEY |
NVD | Still works at 5 req/30s instead of 50 — the source throttles itself either way. |
GITHUB_TOKEN |
GitHub Advisories | Repository advisories still work; the registry GraphQL feed is skipped. |
SNYK_API_TOKEN + SNYK_ORG_ID |
Snyk | Source stays disabled (it needs both). |
OSS_INDEX_USERNAME + OSS_INDEX_API_TOKEN |
OSS Index | Source stays disabled (it needs both). |
VULNCHECK_API_TOKEN |
VulnCheck | Source stays disabled. |
OSV, CVE-Search, EUVD, Red Hat, Shodan CVEDB, MITRE — and the EPSS / KEV enrichment feeds — need no credentials at all.
In Laravel — just set the env vars. The package's config is merged
automatically, so app(VulnSearch::class) and every app(…Source::class) pick
the credentials up with no further wiring:
NVD_API_KEY=… GITHUB_TOKEN=… SNYK_API_TOKEN=… SNYK_ORG_ID=…
$vulns = app(VulnSearch::class)->searchPurl('pkg:npm/lodash@4.17.20'); // → OSV + GitHub + NVD (+ Snyk, once its two values are set) …
Publishing the config is optional — do it to change base URLs, cache TTLs, concurrency, or to toggle sources per environment:
php artisan vendor:publish --tag=vulns-config
// config/vulns.php 'nvd' => ['enabled' => true, 'api_key' => env('NVD_API_KEY')], 'snyk' => ['enabled' => true, 'api_token' => env('SNYK_API_TOKEN'), 'org_id' => env('SNYK_ORG_ID')],
Config beats env: anything you set in config/vulns.php (or at runtime with
config([...])) is what the source receives.
In plain PHP — there is no config file; pass the block directly, so the credential comes from wherever you keep secrets:
new NvdSource(new CpeResolver, options: [ 'api_key' => getenv('NVD_API_KEY') ?: null, 'timeout' => 30, ]);
Every source also understands enabled, timeout, retry and base_url
(point CVE-Search or OSV at a self-hosted instance). Keys are read per request
and never written anywhere by this package.
Laravel
Auto-discovered. Publish the config to tune sources:
php artisan vendor:publish --tag=vulns-config
use Gumslone\Vulns\Data\PackageData; use Gumslone\Vulns\Sources\OsvSource; $vulns = app(OsvSource::class)->queryPackage( new PackageData(name: 'lodash', version: '4.17.20', ecosystem: 'npm') ); // …or every enabled source at once foreach (app('vulns.enabled_sources') as $source) { $found = $source->queryPackage($package); }
Logging goes to the app logger and payload caching to the app cache
automatically. Bind Gumslone\Vulns\Contracts\CpeLookup to plug a curated
PURL→CPE catalog into the NVD-style sources.
Facade and events
use Gumslone\Vulns\Facades\Vulns; $vulns = Vulns::searchPurl('pkg:npm/lodash@4.17.20'); $report = Vulns::only(['osv', 'github'])->report($packages);
Every search reports to Laravel's event dispatcher (vulns.events, on by
default): Events\SourceFailed (source, message, partial) whenever a feed
threw or answered only partially, and Events\SearchCompleted (report) after
each batch — enough to alert on a feed that keeps failing or to count findings:
Event::listen(SourceFailed::class, fn (SourceFailed $e) => Log::warning("vulns: {$e->source} {$e->message}"));
Outside Laravel, $search->listen(fn (object $event) => …) is the same hook; a
listener that throws never breaks a search.
Custom sources
Implement Contracts\Source (or extend Sources\AbstractSource for the HTTP
client, caching, warnings() and supports() / knowsId() plumbing), bind it,
and tag it — VulnSearch picks up everything tagged vulns.sources:
$this->app->bind(InternalAdvisories::class, fn () => new InternalAdvisories(config('services.advisories'))); $this->app->tag([InternalAdvisories::class], 'vulns.sources');
Add its name() to vulns.priority to place it in the merge's trust order.
Plain PHP
$source = new OsvSource( options: ['timeout' => 15], cache: new Gumslone\Vulns\Support\ArrayCache, ); $vulns = $source->queryPackage(new PackageData(name: 'left-pad', version: '1.0', ecosystem: 'npm'));
Every source takes (?Client $http, array $options, ?LoggerInterface $logger, ?CacheInterface $cache).
What you get back
Gumslone\Vulns\Data\VulnerabilityData — a normalised record, whatever source
answered. A real result from searchPurl('pkg:npm/lodash@4.17.20'):
$v = $search->searchPurl('pkg:npm/lodash@4.17.20')[0]; $v->vulnId // "CVE-2019-10744" $v->canonicalId() // "CVE-2019-10744" — the CVE even when a source keyed it on a GHSA $v->source // "nvd" — which source won the merge $v->severity // Severity::Critical (->value === "critical") $v->cvssV3Score // 9.1 — a score always has its vector and a $v->cvssV3Vector // "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H" vector its score (see below) $v->cvssV2Score // 6.4 (also cvssV2Vector) $v->cvssV4Score // 8.7|null — v4 computed from the vector when $v->cvssV4Vector // the source publishes only CVSS:4.0 $v->effectiveCvssScore() // 8.7 — newest standard first: v4 → v3 → v2 $v->cvss() // CvssVector — the newest vector as an object (scores, metrics, adjustment) $v->inferredFields // ["cvss_v2_vector"] — what this record filled in itself $v->epssScore // 0.9432 — probability of exploitation within $v->epssPercentile // 0.999 30 days (FIRST.org EPSS) $v->isKnownExploited // true — listed in CISA KEV $v->kevSince // DateTimeInterface — when CISA listed it $v->kevDueDate // DateTimeInterface — US federal remediation deadline $v->usedInRansomware // true — confirmed ransomware campaign use $v->isWithdrawn // false — retracted/rejected advisories don't look live $v->isDisputed // false — contested by the vendor $v->exploitMaturity() // ExploitMaturity::Poc — none | poc | weaponized, derived $v->exploitReferences() // ["https://www.exploit-db.com/…"] — the exploit links $v->attackVector() // "network" | "adjacent" | "local" | "physical" | null $v->isNetworkExploitable() // true — for policy gates $v->requiresUserInteraction() // false (null when no v3/v4 vector) $v->requiresPrivileges() // false $v->summary // "Versions of lodash lower than 4.17.12 are vulnerable to Prototype Pollution…" $v->details // long-form description, when the source has one $v->aliases // ["GHSA-jf85-cpcp-j695", …] — every other id for the same advisory $v->affectedRanges // [["range" => "< 4.17.12", "source" => "nvd"], …] $v->fixedVersions // ["4.17.12"] (source-dependent) $v->isFixed // true when a fix is published $v->cwes // ["CWE-1321"] $v->references // [["type" => …, "url" => "https://…"], …] $v->affectedEcosystems // ["npm"] (OSV-style records) $v->sourceUrl // "https://nvd.nist.gov/vuln/detail/CVE-2019-10744" — never empty $v->sourcePublishedAt // DateTimeInterface|null $v->sourceModifiedAt // DateTimeInterface|null $v->rawDataChecksum // sha256 of the raw payload — cheap change detection $v->extra // source-specific leftovers (e.g. ghsa_id, vuln_status)
Fields a given source doesn't provide are null or empty — merging across
sources is what fills them in, so OSV's ranges and NVD's score end up on the
same record. EPSS and KEV are stamped after the merge by the threat enricher
(see above), and $fresh->changesSince($stored) classifies what a re-query
changed — including landing in KEV or crossing the EPSS triage threshold.
Safe to render
Feeds relay whatever a reporter typed, so the record cleans up once, at
construction, rather than at every render site: references and sourceUrl
only ever hold absolute http(s) / ftp URLs (a javascript: or data:
link is dropped; sourceUrl then falls back to the advisory's canonical page),
ids and aliases are trimmed, de-duplicated and in official casing, and a CVSS
score outside 0.0–10.0 is discarded. VulnerabilityData::isSafeUrl() is the
same check for URLs you hold elsewhere.
CVSS: a score always has its vector
Feeds are inconsistent here — EUVD, Snyk, Shodan and Red Hat often publish a
bare score, OSV only a vector, and some file a CVSS:4.0 vector in the v3
column. Every VulnerabilityData completes this on construction:
- a vector without a score gets the score its base metrics compute (v2, v3 and v4 calculators — FIRST reference ports);
- a score without a vector gets a representative base vector that scores
exactly that (
CvssVectorTable: deterministic, the "obvious" vector for each score — 9.8 →AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H); a score no base vector produces (a temporal or environmental figure) maps to the nearest one; - a vector lands in the slot of its own version, whatever slot it arrived in;
- severity follows the best score when the source gave none;
sourceUrlis never empty — the canonical page for the id stands in (NVD for CVEs, GitHub for GHSAs, the issuing database for OSV-indexed ids).
Anything filled in this way is listed in $v->inferredFields (isInferred('cvss_v3_vector'),
reported('cvss_v3_vector') for the source's own value or null), so reports
can mark it — and the merge always prefers a source's own vector or link over
another record's inferred one, whichever record wins. Merged records also
keep every source's link in $v->extra['source_urls'].
Adjusting a score for your environment (vector merging)
A CVSS vector is three groups of metrics:
| Group | What it says | v3 metrics | v4 metrics |
|---|---|---|---|
| Base | how bad the flaw is, as published by the advisory | AV AC PR UI S C I A |
AV AC AT PR UI VC VI VA SC SI SA |
| Temporal (v4: threat) | how real the threat is right now — exploit code, a patch | E RL RC |
E |
| Environmental | what it means for you — your deployment, your data | CR IR AR + MAV MAC MPR MUI MS MC MI MA |
CR IR AR + MAV … MSA |
CvssVector (v2.0, v3.0, v3.1, v4.0) keeps them apart. The base group is the
advisory's and never changes; you set the other two and read the score each
group yields.
Scenario 1 — an assessor adjusts an advisory for their own deployment.
CVE-… is a 9.8; there's only proof-of-concept code, an official fix exists,
and in this deployment the component is reachable only locally by admins:
use Gumslone\Vulns\Support\CvssVector; $advisory = CvssVector::parse('CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'); $advisory->baseScore(); // 9.8 $now = $advisory->withTemporal(['E' => 'P', 'RL' => 'O', 'RC' => 'C']); $now->temporalScore(); // 8.8 — PoC only, fix available $here = $now->withEnvironmental(['MAV' => 'L', 'MPR' => 'H', 'CR' => 'L']); $here->environmentalScore(); // 5.8 — local, admin-only, low confidentiality need $here->baseScore(); // 9.8 — still the advisory's number $here->score(); // 5.8 — "the score this vector expresses" (string) $here; // "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/E:P/RL:O/RC:C/CR:L/MAV:L/MPR:H"
Set a metric to X (or null) to unset it again; an illegal value or an
unknown metric throws instead of silently scoring as something else.
Scenario 2 — you have two vector strings: take the temporal + environmental metrics from A and put them on the base of B. Typical when your environment profile lives in one vector and the advisory in another — or when the advisory (B) already carries somebody else's modifiers that you want replaced with yours (A):
$a = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/E:P/RL:O/MAV:L/CR:L'; // A: the modifiers you want $b = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H/E:U/RL:W/MAV:A/CR:H'; // B: the base you want; its own modifiers go $c = CvssVector::parse($b)->withModifiersOf($a); (string) $c; // "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H/E:P/RL:O/CR:L/MAV:L" // └── B's base ─────────────────────────────┘ └── A's modifiers ──┘ $c->baseScore(); // 9.6 — B's base, untouched $c->temporalScore(); // 8.6 — B's base × A's E:P/RL:O $c->environmentalScore(); // 7.6 — B's base in A's environment (was 7.9 with B's own MAV:A/CR:H)
B's E:U/RL:W/MAV:A/CR:H are gone entirely — replaced, not combined. That
holds metric by metric: if A had no RL, C would have no RL either, and if
A carries no modifiers at all, C is B's bare base. To move one group only:
CvssVector::parse($b)->withTemporalOf($a); // …/E:P/RL:O/CR:H/MAV:A — A's temporal, B's own environmental kept CvssVector::parse($b)->withEnvironmentalOf($a); // …/E:U/RL:W/CR:L/MAV:L — B's own temporal kept, A's environmental
The three ways to combine, side by side. Same B, and an A that sets E:P
and MAV:L but no RL:
$a = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/E:P/MAV:L'; $b = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H/E:U/RL:W/MAV:A/CR:H'; $B = CvssVector::parse($b); $B->withModifiersOf($a); // …/E:P/MAV:L only A's modifiers — B's RL:W and CR:H dropped $B->merge($a); // …/E:P/RL:W/CR:H/MAV:L overlay: A wins where both set one, B keeps the rest $B->fill($a); // …/E:U/RL:W/CR:H/MAV:A gaps only: B keeps everything, A adds only what B lacked (nothing here)
| B's base | metrics both set (E, MAV) |
metrics only B sets (RL, CR) |
metrics only A sets | |
|---|---|---|---|---|
withModifiersOf($a) |
kept | A's | dropped | A's |
merge($a) |
kept | A's | B's | A's |
fill($a) |
kept | B's | B's | A's |
merge($a, keepBase: false) flips the roles (A's base, B's modifiers on top).
Rules that apply to all of them. Scores are always recomputed from the
metrics — CVSS has no way to carry a temporal or environmental score over as
a number, only the metrics that produce it. Both vectors must share a major
version: v3.0 and v3.1 mix (the result keeps the base's prefix), v3 and v4
don't — a v3 environmental group means nothing on a v4 base, so that throws
InvalidArgumentException. In v4 the "temporal" group is the single threat
metric E, and the base's supplemental metrics (S AU R V RE U) pass through:
$v4 = CvssVector::parse('CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N'); $v4->baseScore(); // 9.3 $v4->withTemporal(['E' => 'P'])->score(); // 8.9 $v4->with(['E' => 'U', 'MAV' => 'L'])->score(); // 6.1
On a vulnerability record the same operations act on the record's own vector; the stored base score field is never rewritten, the adjusted figure is read separately:
$adjusted = $vuln->withCvssModifiers(['E' => 'P', 'MAV' => 'L']); // metrics onto the record's vector $adjusted->cvssV3Score; // 9.8 — the advisory's base score, untouched $adjusted->adjustedCvssScore(); // what the vector now expresses (environmental here) $adjusted->cvss(); // the CvssVector, for the individual scores $vuln->withCvssModifiersOf($myEnvironmentVector); // the advisory's base + ONLY your modifiers
Does it actually affect my version?
$vuln->affects('4.17.20', 'lodash'); // true — inside an affected range $vuln->affects('4.17.21', 'lodash'); // false — PROVABLY outside every range about lodash $vuln->affects('1.1.1k', 'openssl'); // null — can't tell: treat as possibly affected $vuln->recommendedFix('4.17.20'); // '4.17.21' — lowest fix at/above, same major line first
false is only ever a proof: every range that speaks about the package was
readable and none matched. Anything else is null — no version, no ranges, or
a range/version that can't be ordered safely. Ordered: dotted numbers
(1.0 = 1.0.0), pre-release tags every ecosystem agrees on (1.0.0-rc.1 <
1.0.0, 2.0-beta9 < 2.0-beta10), build metadata (ignored), Maven release
markers (5.3.0.RELEASE = 5.3.0), and OSV introduced / fixed /
last_affected event timelines. Never guessed: Debian/RPM epochs and
revisions (1:2.0, 1.0-1), PEP 440 suffixes (1.0rc1, 1.0.post1), letter
releases (1.1.1k), Go pseudo-versions, unknown qualifiers.
VersionRange::isVulnerable(), relevantTo() and Version::order() are the
same logic for ranges and versions you hold elsewhere.
Exporting
use Gumslone\Vulns\Export\{OsvExporter, CycloneDxExporter, OpenVexExporter, SarifExporter}; OsvExporter::export($vuln); // OSV-schema document CycloneDxExporter::export($vuln, ['pkg:npm/lodash@4.17.20']); // CycloneDX 1.6 vulnerabilities[] entry (+ optional VEX analysis) SarifExporter::export($packages, $report->results); // SARIF 2.1.0 log for code-scanning UIs $statement = OpenVexExporter::statement($vuln, ['pkg:npm/lodash@4.17.20'], 'not_affected', 'vulnerable_code_not_in_execute_path'); OpenVexExporter::document([$statement], author: 'security@example.test'); // OpenVEX 0.2.0
Exports only state what a source stated: a vector this package inferred for
a bare score (see below) is left out of OSV and CycloneDX output. OpenVEX
statements are validated against the specification (a not_affected verdict
needs its justification, an affected one its action statement).
Lookup caching
Product-level lookups — NVD by CPE, CVE-Search, EUVD, Shodan CVEDB, Red Hat —
are cached for an hour when the source has a PSR-16 cache (automatic in
Laravel): one answer serves every version of a product, which matters most on
NVD's anonymous rate limit of one request every six seconds. Only complete
answers are stored (never a truncated or failed one), the version filter still
runs per package, and result_cache_ttl (seconds, 0 = off) tunes it per
source. OSV advisory payloads are cached separately by id + modified stamp.
Related
- gumslone/GumVulns — a standalone, dependency-free CLI for ad-hoc CVE / keyword / CPE lookups across an even wider set of feeds. Different tool, different job: that one answers "tell me about this identifier", this one answers "what affects this package at this version".
Testing code that uses this package
Gumslone\Vulns\Testing\FakeSource is a canned source for your own tests —
answers by package name or purl, finds records by id or alias, and can be
made to fail so the "source down" path is exercised:
use Gumslone\Vulns\Testing\FakeSource; $search = new VulnSearch([ new FakeSource('osv', ['lodash' => [$vuln]]), (new FakeSource('nvd'))->failing('503 Service Unavailable'), ]);
Tests
composer check runs Pint, PHPStan and the suite; CI runs them on PHP 8.2–8.5
against Laravel 11, 12 and 13, plus the lowest supported dependency set.
composer install && composer test
Support
If this package saves you time, consider supporting its development:
License
MIT. See LICENSE.