Search by

gumslone / laravel-vulns

gumslone

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.

Package info

github.com/gumslone/laravel-vulns

pkg:composer/gumslone/laravel-vulns

Statistics

Installs: 313

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.20.0 2026-09-21 19:35 UTC

README

CI License: MIT Donate

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;
  • sourceUrl is 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:

Buy Me A Coffee

Donate

License

MIT. See LICENSE.