UBEL — Supply-Chain & Secrets Scanner for VS CodeMulti-ecosystem dependency and secrets scanner for the developer's machine and tools. What is UBEL?UBEL is a software composition analysis (SCA) tool, secrets detector, and install-blocking firewall built for teams who care about what enters their supply chain at every layer. Unlike report-only scanners, the full UBEL toolset enforces policy — if a scan fails, it blocks the operation and tells you exactly why. As a project, UBEL spans the entire delivery chain: from the moment a developer adds a dependency, through CI validation, to what is running on a deployment server or inside an AI agent's runtime environment. This specific extension covers the editor-side slice of that: dependency vulnerability scanning (SCA) enriched with exploit intelligence (CISA KEV + EPSS), secrets detection, license compliance, and host/editor-extension auditing, all in Extension's features
Commands
All five commands are also accessible via the Command Palette ( What each command runs
Only one scan runs at a time — starting a second while one is in progress shows a warning and does nothing. InstallationFrom the Marketplace Search for UBEL in the VS Code Extensions panel From VSIX
Scan Project (
|
| Ecosystem | Resolved From |
|---|---|
| Node.js (npm, pnpm, yarn, bun) | node_modules/ on-disk walk |
| Python | .venv/, venv/, virtual environment directories |
| PHP | vendor/, composer.lock |
| Rust | Cargo.lock (next to a Cargo.toml; workspace members are followed) |
| Go | go.mod (module-root marker and selected versions), cross-referenced with go.sum when present |
| C#/.NET | packages.lock.json, falling back to obj/project.assets.json; project root = a *.csproj / *.fsproj / *.vbproj |
| Java | pom.xml declarations (parent poms on disk, <dependencyManagement>, ${…} properties and <modules> followed); the full transitive tree when .ubel/maven-deps.txt exists. Gradle is not read |
| Ruby | Gemfile.lock (requires a Gemfile alongside) |
| Swift | Package.resolved (SwiftPM v1/v2/v3, incl. Xcode workspaces), .build/workspace-state.json fallback, Cartfile.resolved (Carthage) |
| Flutter / Dart | pubspec.lock, or .dart_tool/package_config.json when no lockfile is committed |
Go, Java, Ruby and .NET read manifests/lockfiles rather than an installed tree, so no build is needed — except that a .NET project with neither packages.lock.json nor obj/project.assets.json reports nothing (run dotnet restore first), and a Maven project only gets transitive dependencies if you generate .ubel/maven-deps.txt with mvn dependency:tree -DoutputFile=.ubel/maven-deps.txt. Versions a Maven parent pom outside the workspace would manage stay unresolved (reported undetermined, not sent to OSV). None of these four records license data, so their packages show license unknown.
Report location
<project-root>/.ubel/reports/latest.sca.*
Scan VS Code Extensions (Ctrl+Alt+X)
Scans the npm packages bundled inside your installed VS Code / Cursor / VS Codium extensions (~/.vscode/extensions or ~/.vscode-oss/extensions or ~/.cursor/extensions). Extensions are a meaningful supply-chain surface — they run with full Node.js access in the editor host process and are updated silently. The extension detects which editor is hosting it (VS Code, Cursor, or VSCodium) and scans that editor's directory. As with a project scan, the secrets pass and license classification are included, and the report header is tagged with the scanned editor and its version.
Report location
~/.vscode/extensions/.ubel/reports/latest.vscode_sca.*
or
~/.vscode-oss/extensions/.ubel/reports/latest.vscodium_sca.*
or
~/.cursor/extensions/.ubel/reports/latest.cursor_sca.*
Scan Host Platform (Ctrl+Alt+P)
Audits the system-level software installed on the developer's machine itself — a distinct attack surface from project dependencies. Vulnerabilities are matched using CPE 2.3 identifiers against the CVE/NVD database.
This catches what dependency scanners miss: a vulnerable version of Git, an unpatched Python interpreter, an outdated Docker Desktop install, or an end-of-life .NET runtime.
Every detected component carries its actual license or vendor EULA — proprietary Microsoft/vendor components (Windows itself, Defender, Edge, Chrome, Docker Desktop, Visual Studio, Cursor, Claude Code, …) resolve to a LicenseRef-* identifier rather than unknown, while open-source runtimes and tools resolve to their real SPDX id (e.g. MIT for Node.js/.NET, PSF-2.0 for Python). Findings are also enriched with KEV/EPSS data. The host-platform scan does not run the secrets pass.
Windows — detected via registry probes and PowerShell, no elevated privileges required:
| Category | Components |
|---|---|
| Operating system | Windows 10 / 11 (build-accurate CPE version) |
| Security | Windows Defender |
| Runtimes | Node.js, Python, PHP, Go, Rust, Ruby, JRE, JDK |
| .NET | All installed .NET Core / Desktop / ASP.NET runtimes (multi-version) |
| Browsers | Chrome, Firefox, Microsoft Edge |
| Developer tools | Git, Docker Desktop, Visual Studio Code / Visual Studio IDE, Cursor, VSCodium, Claude Code |
| Shell | PowerShell |
Linux — reads the system package database directly, works as a standard user on most distributions:
| Distribution | Package manager | Source | PURL type |
|---|---|---|---|
| Ubuntu | dpkg | /var/lib/dpkg/status |
pkg:deb/ubuntu/ |
| Debian | dpkg | /var/lib/dpkg/status |
pkg:deb/debian/ |
| Alpine / Alpaquita | apk | /lib/apk/db/installed |
pkg:apk/alpine/ |
| Red Hat / RHEL | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/redhat/ |
| AlmaLinux | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/almalinux/ |
| Rocky Linux | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/rocky-linux/ |
| CentOS / Fedora | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/redhat/ |
The distribution is read from /etc/os-release (/usr/lib/os-release as a fallback): ID is tried first, then each ID_LIKE token, so derivatives such as Amazon Linux (ID_LIKE="centos rhel fedora") resolve to their parent family. A distribution that matches none of the above (e.g. SUSE, Arch) stops the scan with an "Unsupported OS ecosystem" error rather than guessing.
Each package entry includes its binary install paths and direct dependency edges as reported by the package database.
No package-manager binaries are executed to read installed packages (dpkg, apk and rpm are not required): the rpm database is parsed in-process and its format is detected from the file's magic bytes — Berkeley DB Packages (RHEL/CentOS/Alma/Rocky ≤ 8, Amazon Linux 2) and SQLite rpmdb.sqlite (RHEL/Alma/Rocky ≥ 9, Fedora, Amazon Linux 2023). NDB (Packages.db, SUSE) is not supported and stops the scan with a clear error.
Each package is also tagged with the distro release it was read from (e.g. almalinux-8.10), which UBEL uses to match advisories to that release — see OS Package Release Matching.
Report location
The report is always written under ~/.ubel/reports/, independent of any open workspace. On Windows and Linux the host-OS scan has its own tag, so the files are latest.os_sca.* (JSON, HTML and SBOM — no SARIF); on any other platform the tag stays sca.
~/.ubel/reports/latest.os_sca.*
Because the home directory is not a project, this scan does not add .ubel/ entries to any .gitignore / .dockerignore.
Scan for Exposed Secrets (Ctrl+Alt+S)
Runs a secrets-only pass over the open workspace folder — no dependency resolution, no package-manager calls. Built on Trivy's ported secret-scanning ruleset (Apache-2.0, see sca/vendor/trivy/NOTICE), extended with UBEL's own rules for vendors Trivy's current upstream doesn't cover:
HashiCorp Vault tokens (
hvs.prefix)Google Cloud API keys (
AIza…) and OAuth access tokens (ya29.)Anthropic and OpenRouter API keys
Firebase server tokens
Amazon MWS auth tokens
Square OAuth secrets and access tokens, Braintree access tokens. A Square
EAAA…token must be a standalone string with enough entropy: it is not reported when it sits inside a longer base64/base64url run (inlined wasm, data URIs)Stripe live restricted keys (
rk_live_) — Trivy covers publishable/secret keys but not this format. Test-mode keys (rk_test_) are deliberately skipped, since they can only reach test-mode dataCredentials embedded in a git remote URL (
https://user:token@host/...) — covers GitHub, GitLab, Bitbucket, Azure DevOps, Codeberg, Gitea, Gitee and SourceHut, self-hosted instances whose hostname starts withgit,gitlab,bitbucket,gitea,gogsorforgejo, and any other host when the URL is a.gitremote. Placeholder passwords (password,token,xxxx, …) and${VAR}references are ignoredDatabase and broker connection strings with an embedded password — three forms, all reporting the password only:
- URL (rule
database-url-credentials):postgres://,postgresql://,mysql://,mariadb://,mongodb:///mongodb+srv://,redis:///rediss://,amqp:///amqps://,mssql://,sqlserver://,cockroachdb://,clickhouse://,neo4j://,bolt://, and SQLAlchemy-stylepostgresql+psycopg2:// - Key/value (rule
database-connection-string-password): ADO.NET, ODBC and libpq strings such asServer=…;Database=…;Password=…orhost=… password=…; a host/database key must appear earlier on the same line, so a barepassword=is left to the generic rule - JDBC (rule
jdbc-url-password):jdbc:mysql://host/db?user=u&password=…,jdbc:sqlserver://host;…;password=…
Not reported: placeholders and templates (
${DB_PASS},<password>,changeme,YOUR_PASSWORD,xxxx, …),user:userpairs such asguest:guest, default passwords (postgres,root,admin,test, …) againstlocalhost,127.0.0.1,host.docker.internalor a bare docker-compose service name, and hosts containingexample. The same default password against a real hostname is reported.- URL (rule
Generic high-entropy fallback for unknown vendors — a vendor-style prefix (
sk-,pk_,xox*,api_,key_,token_) followed by 32+ characters, where the part after the prefix must also clear a Shannon-entropy check, so repeated runs and plain snake_case identifiers aren't reportedGeneric key-value fallback —
password/secret/token/api_key/private_keyassigned a 32+ character value; there is no entropy check here, the key name is the signal
Twilio Account/App SIDs are intentionally not detected: the AC…/AP… + 32-character pattern has no checksum or distinguishing context and produced far too much noise.
The ruleset is a generated snapshot of Trivy's, regenerated from upstream when it changes — not a live mirror.
Match previews shown in every report are redacted — the raw secret value is never written to disk, in this report or any other.
In the other report formats of a regular project/extension scan (sca), secrets are exposed as a ubel:secrets entry in the SBOM's root properties, and as their own run in the SARIF file (separate tool driver and rule set from the dependency-vulnerability run). The standalone secrets command (secrets tag) writes JSON and HTML only — no SBOM or SARIF.
This scan also runs automatically as part of UBEL: Scan Project (Ctrl+Alt+U) — this command exists for when you want a fast, dependency-resolution-free pass, e.g. before a commit.
Report location
<project-root>/.ubel/reports/latest.secrets.*
UBEL: Scan Project (which also runs the secrets pass) writes
latest.sca.*, while this command writes its ownlatest.secrets.*, so the two never overwrite each other. Each run's timestamped zip goes to~/.ubel/history/<asset_id>/, and the newest one is also kept in~/.ubel/latest_reports/<asset_id>/(see Reports).
What gets scanned
- Every match on every line is reported, not just the first per rule; overlapping matches collapse to one finding (a specific rule beats a generic one).
- Private keys: PEM blocks are matched across lines (the finding points at the
BEGINline and carriesend_line), including encrypted/legacy PEM and PGP blocks. Extensionless key files are opened too:id_rsa/id_dsa/id_ecdsa/id_ed25519(andid_rsa.bak-style variants) are always scanned, and files with no extension or a backup-style one (.bak,.old,.orig,.p8,.ppk,.asc…) are scanned if they start with a PEM private-key header. .env*files are skipped (they aren't meant to be committed). The global allow-rules still apply, so a file such as.env.exampleis skipped either way.- Skipped directories: dependency trees, build output and caches are skipped (
node_modules,vendor,target,dist,build,out, virtualenvs, …).packages,bin,objandenvare ambiguous names, so they are skipped only when the layout says they hold generated content — a NuGetpackages/(next to a.sln, or containingrepositories.config), a .NETbin//obj/(next to a.sln/.csproj), a Python venv (pyvenv.cfg). A monorepo'spackages/*and a Nodebin/are scanned. Any default-skipped directory can be re-included withinclude-dir:in.ubelignore(below); VCS metadata (.git,.svn,.hg) and UBEL's own.ubel/directory are never walked and cannot be re-included.
Suppressing findings
Put a .ubelignore file at the workspace root. One entry per line; # starts a comment.
docs/generated/ # skip a directory
*.snap # skip files by glob, at any depth
/fixtures/keys/*.pem # leading "/" anchors to the scan root
src/seed.js rule:generic-key-value-credential # suppress one rule (comma list ok) on matching paths
rule:generic-fallback # disable a rule everywhere
fingerprint:3f9a1c0be27d4a55 # accept one specific finding
include-dir:dist # scan a directory that is skipped by default
exclude-dir:generated # skip a directory name wherever it appears
unallow:tests # switch off a builtin allow-rule (e.g. scan test paths)
Or mark the line in the source, in any comment syntax:
const k = "..."; // ubel:ignore
const k = "..."; // ubel:ignore[aws-access-key-id]
// ubel:ignore-next-line
Every finding carries a fingerprint in the JSON report — a hash of rule id, path and the secret, stable across line moves and never containing the secret itself — so a finding can be accepted by adding its fingerprint: line. The scan result also reports suppressed (how many findings the ignore rules dropped). The CLI's ubel-secrets --write-baseline, which writes these lines for you, is not available in the extension.
The builtin allow-list still skips paths containing test/example and secrets whose text contains "example"; unallow:tests / unallow:examples turn those off. The per-rule allow-lists of the database-connection-string rules are part of the rule, so unallow: does not affect them; switch one off with rule:<id>.
Scan for License Compliance (Ctrl+Alt+L)
Runs a license-only pass over the open workspace folder: full dependency resolution across every ecosystem present, license normalization and OSI-approval/risk classification — but no vulnerability lookups (no OSV.dev/NVD calls) and no secrets scan. Use this when you only need a license inventory (e.g. for legal/compliance review) without the time or network cost of a full vulnerability scan.
See License Compliance below for how licenses are normalized and classified — the classification logic is identical whether it runs standalone here or as part of UBEL: Scan Project.
This scan is also included automatically as part of UBEL: Scan Project (Ctrl+Alt+U) — this command exists for a faster, vulnerability-lookup-free pass when license data is all you need.
Report location
<project-root>/.ubel/reports/latest.licenses.*
UBEL: Scan Project writes
latest.sca.*, UBEL: Scan project for Exposed Secrets writeslatest.secrets.*, and this command writeslatest.licenses.*— the three never overwrite each other. Each run's timestamped zip goes to~/.ubel/history/<asset_id>/, and the newest one is also kept in~/.ubel/latest_reports/<asset_id>/(see Reports).
Files UBEL Writes to Your Workspace
Besides the reports under .ubel/ (see Reports), the Scan Project, Scan Code Editor's Extensions, Scan project for Exposed Secrets and Scan project for License Compliance commands make sure the scanned directory's .gitignore and .dockerignore ignore both .ubel/ and .ubelignore, creating either file if it doesn't exist. Scan Host Platform does not touch them.
- Idempotent. An entry counts as covered if any equivalent pattern is already present (
.ubel,/.ubel/,.ubel/*,.ubel*, …), so a hand-written entry is never duplicated. - Append-only. Existing content, ordering and line endings (LF/CRLF) are preserved; new entries go under a
# ubel:comment. - Opt-out respected. A negation such as
!.ubelignoremeans you want that entry tracked, so it is not re-added — this is how you commit a shared.ubelignorewhile everything else stays ignored. - Never fails a scan. A read-only checkout or a permissions problem is silently skipped.
- Kill switch: set
UBEL_NO_IGNORE_FILES=1in the environment the editor was launched from to turn this off.
Scan history is kept outside the workspace, under your home directory — timestamped zips in ~/.ubel/history/ and the newest zip of each scan type in ~/.ubel/latest_reports/ (see Reports).
Scan Results
Every scan ends with a VS Code notification:
| Result | Notification | Meaning |
|---|---|---|
| ✅ | Scan complete — no policy violations | All packages passed |
| ⚠️ | Policy violation | A malicious package, a vulnerability at or above the severity threshold, a known-exploited (CISA KEV) or high-EPSS vulnerability, an exposed secret, or a configured license-risk gate was hit — see Policy |
| ❌ | Scan error | Unexpected failure — message contains details. This includes an OSV.dev/NVD lookup that couldn't be completed: an incomplete lookup is a failed scan, never a clean one |
KEV/EPSS lookups are the exception: they only add risk signal, so if either feed is unreachable the scan still completes, the affected fields are null (unknown), a warning is shown in the report, and the matching policy rule is not enforced for that run — see Exploit Intelligence.
Every notification includes an Open Report button that opens the full interactive HTML report in your browser.
The HTML Report
Each scan produces a self-contained HTML file that works fully offline. It contains ten tabs:
| Tab | Contents |
|---|---|
| Dashboard | Vulnerability counts by severity, policy decision summary (including threat-intel feed warnings), license-risk stats card, scan metadata |
| Executive Summary | Plain-language risk rating, policy verdict, key findings, components to fix first, and suggested actions for non-technical readers, printable as a PDF — see Executive Summary |
| Secrets | Exposed secrets by category, severity, file/line, and redacted match preview |
| Vulnerabilities | Full list of matched CVEs with CVSS score, EPSS score/percentile, KEV badge, severity, fix version, reachability level, and policy decision. Click any row for a detail modal (CVSS vector, fix recommendations, OSV/NVD references, compliance frameworks, and — for OS packages — the release match) |
| Inventory | Every scanned package with version, PURL, CPE, ecosystem, state (safe / vulnerable / infected / undetermined), license risk (OSI-approved status, risk level), and vulnerability count. Click a package for its detail modal, which includes Suggested Fixes — see Recommended Package Fixes |
| Dependency Sequences | Interactive force-directed dependency graph — colour-coded by vulnerability status, with search, filter, drag, and pin |
| Detailed Stats | Severity distribution charts, top vulnerable packages, ecosystem breakdown |
| Compliance | One card per framework (OWASP Top 10, PCI DSS, HIPAA, SOC 2, ISO/IEC 27001, NIST SP 800-53, GDPR, CIS Controls v8) with control breakdown and finding counts — see Compliance Framework Mapping |
| Diffs | What changed since the previous report of this asset and scan type: new / unchanged / removed dependencies, new / still existing / fixed vulnerabilities, new / still existing / removed secrets, and — per compliance framework — new / existing / resolved findings. Click any count for the list — see Diffs Against the Previous Report |
| System Info | OS metadata, git info, Node.js version, engine/tool versions (no IP addresses or ports are recorded) |
Executive Summary
Every report (JSON and HTML) carries an executive_summary written for readers who don't work with CVEs, CVSS scores, or package URLs — management, risk and compliance teams, product owners. It is derived entirely from data already in the report (no extra scanning or network calls), so the JSON field and the HTML tab always show the same content. It is built after the policy decision is made and never fails a scan: if it can't be built, only the summary is omitted.
In the HTML report it is the Executive Summary tab, placed right after the Dashboard.
What it contains
| Section | What it tells the reader |
|---|---|
| Overall risk rating | One of Critical / High / Medium / Low / Minimal / Not assessed, with a rationale and general business-impact text |
| Headline & verdict | A one-sentence overview and whether the scan meets or fails the security policy, in plain language (with the technical reason alongside) |
| At a glance | Components reviewed and with issues, vulnerabilities by severity, how many have a fix available, how many are likely in use by your code vs. not, how many block policy, exposed credentials, how many are known to be exploited (KEV) or have a high exploit likelihood (EPSS) |
| Key findings | The handful of things that matter most, each with a severity |
| Components to fix first | Up to five components, ranked by malicious status, then known-exploited (and not judged unused), then whether they're likely in use, then worst severity, then forecast exploit likelihood, then issue count — each with a suggested upgrade action and the possible upgrade paths (see Recommended Package Fixes) |
| Suggested actions | Prioritized actions with a timeframe and the reason for each |
| Compliance overview | Which frameworks the findings touch and which are most affected (omitted when nothing maps to a framework) — see Compliance Framework Mapping |
| Scope, methodology & glossary | What was scanned, how this specific report was produced, and plain-language definitions of the terms used |
Layout and printing
The HTML tab is laid out so the first screen can be read on its own and fits one printed page:
- Cover — report title, what was scanned, report ID, date, and tool (
cover). - Bottom line — the overall risk rating next to the policy verdict, a one-paragraph summary, the Top risks, Do this first (each action with its timeframe and a suggested owner), and four key figures (
bottom_line). - Details — why this rating, all key findings, at-a-glance cards (
glance_cards), components to fix first, all suggested actions, and compliance exposure. - Appendix — methodology, "About this report" (scope and notes), and the plain-language glossary. The appendix is collapsed on screen and expanded automatically when printing.
A Print / save as PDF button at the top of the tab prints the summary only (on a white background, other tabs hidden), so it can be handed to someone who never opens the interactive report. In the Components to fix first table, the identifiers under each component are the advisory references, for tickets and audit trails. For multi-system scans, the tab also shows Configuration issues by area and Systems to review first tables when that data is present.
Overall risk rating
| Rating | Assigned when |
|---|---|
| Critical | any malicious (MAL-*) component was found |
| High | any Critical/High-severity vulnerability that isn't confirmed unreachable, any CISA KEV vulnerability that isn't confirmed unreachable (whatever its severity), or any exposed High/Critical credential |
| Medium | Medium/unrated issues, vulnerabilities at or above the EPSS threshold, lower-severity secrets, or Critical/High/KEV issues that reachability analysis confirmed are not used by production code |
| Low | only Low-severity issues (none at or above the EPSS threshold), or Medium/unrated issues that reachability analysis confirmed are all unused |
| Minimal | nothing found |
| Not assessed | the scan skipped vulnerability lookups (e.g. UBEL: Scan project for License Compliance) and nothing else raised the rating — shown instead of "Minimal" so an unchecked scan is never read as a clean one |
The rating and the policy verdict are independent: the rating discounts findings that reachability analysis confirmed are unused, while the verdict counts every finding at or above the blocking thresholds (and any exposed secret), so a report can be rated Medium yet still be blocked. Reachability is a heuristic; a finding with no reachability result is treated as potentially reachable.
Exploit intelligence in the summary. The summary uses the same two signals the policy blocks on, so a report is never rated "Low" while the policy blocks it for an actively exploited vulnerability:
- Known-exploited (
is_kev: true) — gets its own key finding (with CISA's earliest remediation due date), an Immediately suggested action, the Known to be exploited card, and raises the rating to High unless reachability analysis confirmed the code unused (then Medium). Components with a known-exploited issue rank first among the non-malicious ones and carry an Exploited badge. - High EPSS — vulnerabilities at or above
epss_thresholdthat aren't already KEV get a key finding and, when they are lower-severity, a Within days action; they set a floor of Medium. If the EPSS rule is turned off in the policy, 10% is still used for this informational reporting, and the text says the policy doesn't block on it. - Unknown stays unknown — if a feed was unreachable, the matching figures are
null(shown as "n/a", never0), a key finding says so, the methodology shows the lookup as incomplete, and a Low/Medium rating notes that it could be understated.
Checks that didn't run or didn't finish. When vulnerability lookups were skipped, the vulnerability-derived figures (including the exploit-intelligence ones) are null (shown as "n/a" in the HTML tab), not 0. When the secrets pass failed, exposed_credentials is null and a key finding says the result is unavailable instead of reporting zero. Components with no determinable version can't be matched against vulnerability databases, so they are called out in the methodology, limitations, and notes.
Good to know
- The rating scale is UBEL's own — not CVSS or a regulatory standard — and the impact text is general guidance per rating level.
- Action timeframes are built-in defaults, not your organization's remediation SLAs. The HTML tab labels the section "Suggested actions" for this reason.
- Suggested upgrades are indicative, not a guarantee.
- Malicious-component advisories are counted separately, so "Known weaknesses" can be lower than the Vulnerabilities tab total; a note says so when it applies.
scope.subjectidentifies what was scanned:name,repository,branch,commit(first 8 characters),scanned_atandtool, taken from the report's git, runtime, and tool metadata. Fields that aren't available are omitted from the header.overall_risk.basisandrecommended_actions_basisstate that the rating scale is UBEL's own (not CVSS or a regulatory standard) and that action timeframes are built-in defaults rather than your organization's remediation policy.- For host-platform scans, the project name is left empty rather than showing a temp or home directory. Credentials embedded in a git remote URL are stripped from the scan header.
JSON
{
"executive_summary": {
"overall_risk": { "level": "high", "label": "High", "rationale": "...", "business_impact": "..." },
"cover": { "title": "...", "subject": "my-app", "report_id": "...", "generated_at": "2026-10-06T09:30:00Z", "tool": "...", "classification": "...", "statement": "..." },
"bottom_line": { "risk_label": "High", "summary": "...",
"top_risks": [ { "severity": "high", "title": "...", "detail": "..." } ],
"do_first": [ { "action": "...", "timeframe": "Immediately", "owner": "..." } ],
"figures": [ { "label": "...", "value": 9, "sub": "...", "tone": "high" } ] },
"glance_cards": [ { "label": "...", "value": 142, "sub": "...", "tone": null } ],
"headline": "This scan reviewed 142 software components and found ...",
"verdict": { "status": "blocked", "label": "Does not meet security policy", "statement": "...", "technical_reason": "..." },
"at_a_glance": { "vulnerabilities_assessed": true, "components_reviewed": 142, "components_with_issues": 4, "malicious_components": 0,
"total_vulnerabilities": 9, "by_severity": { "critical": 1, "high": 2, "medium": 4, "low": 2, "unknown": 0 },
"fix_available": 7, "fix_available_percent": 78, "likely_in_use": 6, "not_in_use": 3,
"blocking_policy": 3, "exposed_credentials": 2,
"known_exploited": 1, "high_exploit_likelihood": 2, "exploit_data_complete": true },
"key_findings": [ { "severity": "high", "title": "...", "detail": "..." } ],
"components_to_fix_first": [ { "name": "lodash", "version": "4.17.15", "issue_count": 3, "worst_severity": "critical",
"worst_severity_label": "Critical", "likely_in_use": true, "blocks_policy": true, "known_exploited": 1, "max_epss": 0.42, "upgrade_to": "4.17.21",
"fix_options": [ { "version": "4.17.21", "range": "4.17.x", "scope": "minor", "resolves": 3, "of": 3, "resolves_known_exploited": 1, "known_exploited_total": 1, "recommended": true } ], "fix_options_more": 0, "no_fix_yet": 0,
"references": [ "GHSA-xxxx-xxxx-xxxx" ], "more_references": 2, "action": "Upgrade to version 4.17.21." } ],
"recommended_actions": [ { "priority": 1, "timeframe": "Immediately", "owner": "...", "action": "...", "why": "..." } ],
"compliance_overview": { "frameworks_touched": 3, "most_affected": [ { "framework": "...", "findings": 5 } ], "statement": "...", "disclaimer": "..." },
"scope": { "scan_type": "health", "description": "...", "target": "a code repository", "subject": { "name": "my-app", "repository": "...", "branch": "main", "commit": "a1b2c3d4", "scanned_at": "...", "tool": "..." }, "ecosystems": ["npm"], "components_reviewed": 142 },
"methodology": { "steps": [], "rating_rules": [], "prioritization": "...", "timeframes": "...", "limitations": [] },
"notes": [ "..." ],
"glossary": [ { "term": "Vulnerability", "meaning": "..." } ]
}
}
Methodology. executive_summary.methodology (and the Methodology section of the HTML tab) describes how that specific report was produced. Steps are included only if the stage ran for that scan: the usage estimate needs reachability results, the exploit-intelligence lookup needs vulnerability lookups (and is labelled incomplete when a feed failed), the credential search needs secrets scanning on, license review appears on health scans, and compliance mapping needs a compliance_summary. The Policy check step prints the policy values actually in force, including the KEV and EPSS rules.
Fix options. The suggested upgrade for each component comes from the per-package analysis in Recommended Package Fixes: the closest upgrade path that resolves the most of that component's issues (a major version change is called out, and any issues it leaves open are stated). Every upgrade path is also listed as fix_options — closest release line first, at most five, the recommended one always kept — with the version, its range, whether it is a major change, how many of the component's issues it resolves (and how many of the known-exploited ones), plus no_fix_yet for issues no version fixes. The HTML tab shows them as a Possible fixes list with Best and Major badges. If the per-package analysis is missing or failed, the older per-issue heuristic is used instead. Either way, the suggested upgrade is indicative, not a guarantee.
Recommended Package Fixes
For every package in the inventory, UBEL looks at all of its vulnerabilities and the fixed version of every affected range, then suggests which versions to upgrade to. Instead of a single "latest" version, suggestions are grouped per version range, so you can pick the least disruptive upgrade that still closes the most issues:
- One group per minor range above your installed version — e.g.
4.17.x,4.18.x(same major version, so typically non-breaking) - Then one group per higher major range — e.g.
5.x,6.x(may be breaking)
Groups are listed closest-first: minor ranges, then major ranges, with the highest version first inside a range.
How versions are picked. Inside each range, UBEL picks the version that fixes the most still-open vulnerabilities (ties go to the highest version), then repeats until nothing more can be fixed in that range — so you get the fewest, highest versions that cover the most. A vulnerability can therefore appear in several ranges: each range is an alternative upgrade path, not a sequence of steps to apply together.
Branch-aware matching. A candidate version is only counted as a fix if it genuinely falls outside every affected range of that vulnerability — not merely because it is numerically higher than some fixed version. This matters for advisories fixed on several release branches (e.g. a fix backported to 4.17.21 while 4.18.0–4.18.2 stays affected). When an advisory carries no usable range data, UBEL falls back to "at or after a known fixed version". Git-commit ranges are ignored since they can't be compared to package versions. For NVD-sourced advisories, each branch's lower bound is kept alongside its fix version, so a fix on a lower branch (say 1.2.5 next to 1.3.2) is recognized as a fix for that branch rather than looking still-affected.
Unfixed vulnerabilities. Anything with no fixed version above the one you have installed — or that no candidate version actually clears — is listed separately as unfixed rather than silently dropped, so you can see what an upgrade won't solve.
In the HTML report
Open the Inventory tab and click a package: its detail modal has a Suggested Fixes section.
- A table with one row per suggested version — Range (label plus minor range / major range), Suggested version (with "fixes N"), and Vulnerabilities fixed, each with its severity badge. Click any vulnerability to jump to its detail modal.
- Below it, Vulnerabilities with no fix (N) — ID, severity, and score for everything no suggested version resolves.
- For OS packages, a Release match badge on each suggested version (and on the unfixed list) shows how sure UBEL is that it is for your distro release — see OS Package Release Matching.
- Malicious (infection) entries are shown with an
infectionbadge. If nothing can be fixed by upgrading, the modal says so; if the package has no suggestion data, it says that instead.
Ordering. Vulnerabilities inside every group are ordered most severe first: malicious (infection), then critical, high, medium, low, unknown; ties are broken by CVSS score, then ID.
Ecosystem-aware version comparison. Versions are compared with one ecosystem-agnostic comparator that handles semver, PEP 440, and deb/rpm-style versions — including epochs (1:2.3), v prefixes, pre-release tags (1.0.0-rc1 sorts below 1.0.0), and ignored build metadata (+build) — across every supported ecosystem.
In SBOM and SARIF. SBOM: each component has a suggested_fixes property (JSON string), and each vulnerability gets the properties suggested_fix_version and suggested_fix_bulk_count when a suggested version covers it. SARIF: each result has suggested_fix_version and suggested_fixes in properties, and the run's properties.inventory_suggested_fixes lists the plan per package.
Suggestions are computed only when vulnerability lookups ran, so they are absent from license-only scans.
JSON
Each inventory item in the JSON report carries a suggested_fixes object:
{
"suggested_fixes": {
"fixes": [
{
"version": "4.17.21",
"count": 3,
"range": { "key": "m:4.17", "label": "4.17.x", "scope": "minor" },
"vulnerabilities": [
{ "id": "GHSA-xxxx-xxxx-xxxx", "severity": "critical", "severity_score": 9.8, "is_infection": false }
]
}
],
"unfixed": [
{ "id": "CVE-0000-0000", "severity": "medium", "severity_score": 5.3, "is_infection": false }
]
}
}
| Field | Meaning |
|---|---|
fixes[].version |
A suggested version to upgrade to |
fixes[].count |
How many of the package's vulnerabilities that version resolves |
fixes[].range |
The range the version belongs to: scope is minor (same major as installed) or major (higher major); label is e.g. 4.17.x or 5.x |
fixes[].vulnerabilities |
The vulnerabilities resolved, most severe first |
fixes[].release_confidence, fixes[].release_confidence_reason |
OS packages only: how sure UBEL is that the version is built for the installed distro release (lowest of the vulnerabilities it covers; low if the version is visibly for another release). unfixed[] entries carry release_confidence too — see OS Package Release Matching |
unfixed |
Vulnerabilities no suggested version resolves |
If suggestions can't be computed for a package, its suggested_fixes carries an error message with empty fixes/unfixed, and the rest of the report is unaffected. Suggestions are computed once per scan, right after vulnerabilities are matched, and before the executive summary is built.
OS Package Release Matching
An OS package (pkg:deb/…, pkg:rpm/…, pkg:apk/…) is only affected by advisories issued for the distro release it is installed on — an AlmaLinux 8 package is not vulnerable to an AlmaLinux 9 erratum whose fixed version is …el9_8. OSV's PURL lookup returns advisories for every release of a distro, so UBEL checks the release itself and reports how sure it is. In the extension this applies to the OS packages found by UBEL: Scan Host Platform on Linux; project and extension scans contain no OS packages, and application packages (npm, PyPI, …) are unaffected. Docker image scans and the apt/dnf/yum firewall use the same logic in the CLI (see the CLI docs).
How it works
- Release detection. Every OS package is tagged with a
distrovalue — the scanned system'sos-releaseIDandVERSION_ID, e.g.almalinux-8.10,debian-12,alpine-3.22.2. It is metadata only: the package's PURL andidare unchanged. A system with noVERSION_ID(e.g. Debian testing/sid) gets nodistro. - Lookup. The value is added to the OSV query as a PURL
distroqualifier (pkg:rpm/almalinux/acl@2.4.0-1.el8_10?distro=almalinux-8.10); results are still reported under the plain PURL. UBEL does not rely on OSV honouring the qualifier. - Response check. Each advisory names its release in
affected[].package.ecosystem(e.g.AlmaLinux:9). UBEL compares it with the installed release and drops the advisory only when it names the same distro family for that package but never the installed release. Anything ambiguous is kept (see the confidence levels below), so a doubtful case adds a finding rather than hiding one. The scan log reports how many were dropped (Dropped N OS advisories that apply to a different distro release than the one scanned.). An advisory that covers several releases is kept but trimmed to the matching entries, so its fixed versions and fix recommendations describe only your release.
A release matches when it equals the installed version or is its leading dotted part, so 8 matches 8.10 but not 10 or 18.
Distros matched
Distro (os-release ID) |
Advisory ecosystem matched | Example |
|---|---|---|
| Debian | Debian:<major> |
debian-12 ↔ Debian:12 |
| Ubuntu | Ubuntu:<VERSION_ID> (including the :LTS suffix and Ubuntu:Pro:…) |
ubuntu-24.04 ↔ Ubuntu:24.04:LTS |
| Alpine | Alpine:v<major.minor> |
alpine-3.22.2 ↔ Alpine:v3.22 |
| AlmaLinux | AlmaLinux:<major> |
almalinux-8.10 ↔ AlmaLinux:8 |
| Rocky Linux | Rocky Linux:<major> |
rocky-9.3 ↔ Rocky Linux:9 |
Every other distro — Red Hat / RHEL, CentOS, Fedora, Amazon Linux, Oracle Linux, and derivatives such as Linux Mint, Kali or Raspbian (their own os-release ID isn't one of the above) — is not filtered: all advisories are kept, with low confidence. Rocky Linux (ID=rocky) is detected as Rocky Linux (pkg:rpm/rocky-linux/), not as Red Hat.
Release confidence
Each OS-package vulnerability gets a release_confidence and a one-line release_confidence_reason:
| Level | Meaning |
|---|---|
high |
The advisory explicitly names the installed release (e.g. AlmaLinux:8 for almalinux-8.10) |
medium |
Same distro family, but the advisory names no release (e.g. a bare Debian), so it could not be confirmed |
low |
Nothing to verify against: the distro isn't in the table above, its release is unknown, or the advisory has no entry for the family — it may belong to another release |
Fixes. A suggested version's confidence is the lowest of the vulnerabilities it covers, and is forced to low when the version is visibly built for another release than the installed one (installed 2.4.0-1.el8_10, fix 2.4.0-1.el10_2; +deb12u1 vs +deb11u3). The same level is attached to every entry of a vulnerability's ranked fix versions (fix_versions_ranked[]).
Confidence is informational — it never changes the policy decision. Application packages (npm, PyPI, …) carry no confidence fields: the concept only applies to distro releases.
Where it appears
- JSON:
release_confidenceandrelease_confidence_reasonon each OS-package vulnerability, on everyfix_versions_ranked[]entry and on everysuggested_fixes.fixes[]entry;release_confidenceonsuggested_fixes.unfixed[]entries and infindings_summary. - HTML report: a Release match badge (reason on hover) in the package's Suggested Fixes table and its unfixed list; a Release match line in the vulnerability detail modal and a column in its fix-version table.
- SBOM (CycloneDX v1.6): the vulnerability properties
os.release_confidenceandos.release_confidence_reason.
{
"id": "ALSA-XXXX:NNNN",
"affected_package_id": "pkg:rpm/almalinux/acl@2.4.0-1.el8_10",
"release_confidence": "high",
"release_confidence_reason": "Advisory ecosystem AlmaLinux:8 matches the installed almalinux-8.10.",
"fix_versions_ranked": [
{ "version": "2.4.0-1.el8_10.1", "release_confidence": "high", "release_confidence_reason": "…" }
]
}
Exploit Intelligence (KEV & EPSS)
Severity says how bad a flaw could be; these two feeds say whether it is actually being exploited or is likely to be. Every vulnerability found by a scan that performs vulnerability lookups is enriched with:
| Field | Source | Meaning |
|---|---|---|
is_kev |
CISA KEV catalog | true if the CVE is in the catalog, false if not, null if the catalog couldn't be fetched |
kev_added |
CISA KEV | Date the CVE was added to the catalog (YYYY-MM-DD), else null |
kev_deadline |
CISA KEV | CISA's remediation due date, else null |
epss_score |
FIRST EPSS | Probability (0–1) of exploitation in the next 30 days, else null |
epss_percentile |
FIRST EPSS | Percentile (0–1) of that score among all scored CVEs, else null |
null always means unknown (feed down, or no EPSS score exists for the CVE), never "not exploited" or 0. The HTML report shows EPSS as percentages and flags KEV entries with a badge; the JSON report keeps the raw 0–1 values.
CVE matching. OSV advisories are frequently GHSA/other ids, so the CVE is taken from the vulnerability id if it starts with CVE-, and from its aliases otherwise. If an advisory maps to several CVEs, it is KEV if any of them is, and the highest EPSS score is reported.
Policy. With the defaults, a scan blocks on any KEV entry (block_kev: true) and on any vulnerability with epss_score >= 0.1 (10%), whatever its severity. Both are tunable in config.json — see Policy. The block reason names the offending ids, e.g. Blocked by policy: 1 known-exploited (CISA KEV) vulnerability detected: GHSA-xxxx-xxxx-xxxx.
When a feed is unreachable. Unlike OSV/NVD (where a failed lookup fails the scan, because a missing answer would look like "no vulnerabilities"), KEV/EPSS only add risk signal, so an outage degrades the result instead of aborting it:
- the scan completes and the affected fields are
null; - a warning is shown in the HTML decision box and recorded under
threat_intelin the report (statusofok,partial,unavailableorskippedper feed, plus the error); - the matching policy rule is not enforced for that run, and a passing verdict says so — e.g.
Policy passed (note: CISA KEV data unavailable — not enforced for this scan); - other rules (severity, the other feed, infections, secrets) are unaffected.
Each feed is tried with a 15-second timeout and two retries. There is currently no endpoint override for either feed, so a fully air-gapped setup runs without KEV/EPSS data.
Other report formats
- SBOM (CycloneDX v1.6): each
vulnerabilities[]entry gets the propertieskev.listed(true,falseorunknown),kev.date_added,kev.due_date,epss.scoreandepss.percentile; KEV entries also get an advisory link to the CISA catalog, and EPSS is added as a secondratings[]entry (sourceFIRST EPSS, score in percent). The rootpropertiescarrykev_vulnerabilitiesandubel:threat_intel— read the count together with the feed status, since an outage leaves the count at 0. - SARIF 2.1.0: rules and results carry
is_kev,kev_added,kev_deadline,epss_scoreandepss_percentileinproperties; rules get the tagskev/known-exploited/epsswhere they apply. Each result gets arank(0–100): 100 for a KEV entry, otherwise the EPSS probability ×100. A KEV result is reported at levelerrorwhatever its severity; reachability analysis never downgrades it.
Reachability Analysis
Every vulnerability in the report is annotated with a heuristic reachability assessment. The analyzer operates on the existing report fields — package type, scope, dependency depth, CVSS attack vector, and the dependency graph — and optionally performs a source-level import scan over the project files to confirm or refute whether the vulnerable package is actually used by application code. The host-platform scan has no project source, so it has no import-scan signal.
The goal is prioritization: to separate vulnerabilities in packages your code actively exercises from those in packages that are installed but unreachable from any production code path.
Decision ladder
Signals are evaluated in strict priority order. The first matching rule wins. Each rung yields a reachable flag and a level; unconditional reachability is reported as level: "critical".
| Priority | Signal | Result | Confidence |
|---|---|---|---|
| 0a | Vuln ID starts with MAL- |
reachable · critical |
high |
| 0b | Package scope includes env |
reachable · critical |
high |
| 1 | Package type is non-library (app, framework, plugin, OS package, …) | reachable · critical |
high |
| 2 | Scope is dev or test |
unreachable · low |
high |
| 3 | Import scan: package imported in source files | reachable · high or medium |
high |
| 4a | Import scan: direct import absent, but importing parent found | reachable · medium or low |
medium |
| 4b | Import scan: no direct or parent import found | unreachable · low |
medium |
| 5 | Orphan tool (no dependents in graph, no import scan available) | unreachable · low |
medium |
| 6 | Depth + attack vector heuristics | reachable (high / medium), or unreachable · low |
low |
Priority 0a (MAL-) — Malware advisories represent active supply-chain infections. The vulnerable code is the infection vector; reachability is unconditional regardless of how or whether the package is imported.
Priority 0b (env scope) — Packages carrying the env scope are part of the execution environment itself — OS packages, system libraries, runtimes, container-layer components. They are not imported by application code; they are the environment. Reachability is unconditional.
Priority 1 (non-library type) — Frameworks, applications, plugins, and OS-level packages have no meaningful import boundary. The component itself is the attack surface.
Priority 2 (dev/test scope) — Packages that are exclusively development or test dependencies are excluded from production runtimes. Scope is derived from package.json devDependencies (for Rust, from the [dev-dependencies] / [build-dependencies] sections of Cargo.toml and its workspace members) and propagated through the dependency graph via BFS.
Priorities 3–4 (import scan) — When a project root is provided, UBEL scans source files for import statements matching the package. For transitive dependencies where the package itself is not directly imported, it checks whether any of the package's parents in the dependency graph are imported — confirming that the transitive path is exercised.
Priority 5 (orphan tool) — Root packages with no dependents and no import scan result are most likely standalone CLI tools included in the environment but not called by application code.
Priority 6 (heuristics) — When no higher-priority signal is available, depth in the dependency tree and the CVSS attack vector are used as weak proxies. A root package (depth 0) with a network attack vector is reachable · high; a root with a local/physical vector, a transitive package with a network vector, or an unknown vector is reachable · medium; only a transitive package with a local/physical vector is unreachable · low.
Import scan coverage
Source files are scanned for ecosystem-appropriate import patterns:
| Ecosystem | Extensions | Patterns matched |
|---|---|---|
| Node.js | .js .ts .mjs .cjs .jsx .tsx |
require('<pkg>'), from '<pkg>' |
| Python | .py |
import <pkg>, from <pkg> |
| Java / Kotlin | .java .kt .groovy .scala |
import <group>.<artifact> |
| C# / .NET | .cs .vb .fs .fsx |
using <Namespace> |
| PHP | .php |
use <Vendor>\\, require '<pkg>' |
| Go | .go |
"<module-path>" |
| Rust | .rs |
use <crate>::, extern crate <crate> |
| Ruby | .rb |
require '<gem>' |
| Flutter / Dart | .dart |
import 'package:<pkg>/…', export 'package:<pkg>/…' |
| Swift | .swift .m .mm .h |
import <Module>, @import <Module>, #import <Module/…> |
Reachability results appear in the Vulnerabilities tab of the HTML report and in the machine-readable JSON report. Each vulnerability record includes a reachability object:
{
"reachability": {
"reachable": true,
"level": "high",
"confidence": "high",
"rationale": "Import of this package was found in project source code. Found in 2 source file(s): src/index.js, src/utils.js. Depth=0, AV=N.",
"tags": ["import_confirmed", "network_av"],
"signals": {
"depth": 0,
"attack_vector": "N",
"is_orphan_tool": false,
"scope": "prod",
"num_paths": 3,
"introduced_by_count": 1,
"pkg_type": "library",
"is_non_library": false,
"is_malware": false,
"has_env_scope": false,
"import_scan": {
"searched": true,
"found": true,
"files_scanned": 87,
"matched_files": ["src/index.js", "src/utils.js"],
"skipped_no_source": false
}
}
}
}
| Field | Description |
|---|---|
reachable |
true if the vulnerable code is considered reachable from production |
level |
critical, high, medium, or low (critical = unconditionally reachable) |
confidence |
high, medium, or low — how much evidence backs the verdict |
rationale |
Human-readable explanation of which signal drove the decision |
tags |
Machine-readable labels for the signals that fired (e.g. import_confirmed, dev_scope, malware, env_scope) |
signals |
Full signal snapshot — all inputs that were considered, regardless of which rule fired |
License Compliance
Every project scan classifies each package's declared license by default. Licenses arrive in inconsistent shapes across ecosystems (SPDX ids, free text like "Apache 2.0", npm's UNLICENSED proprietary sentinel, Python trove classifiers, OR/AND SPDX expressions, or missing entirely); UBEL normalizes all of them to a canonical SPDX identifier, checks it against the OSI-approved license list, and assigns a risk rating:
| Category | Examples | Risk |
|---|---|---|
| Permissive | MIT, Apache-2.0, BSD-2/3-Clause, ISC, 0BSD | low |
| Public domain | Unlicense (OSI-approved), CC0-1.0 (not OSI-approved but permissive in practice) | low |
| Weak copyleft | MPL-2.0, LGPL-2.1/3.0, EPL-2.0, CDDL | medium |
| Strong copyleft with linking exception | GPL-2.0-only WITH Classpath-exception-2.0 (OpenJDK JRE/JDK) | medium |
| Strong copyleft | GPL-2.0/3.0, AGPL-3.0 | high |
| Source-available / rejected by OSI | SSPL-1.0, BUSL-1.1, Elastic-2.0 | high |
| Proprietary | npm UNLICENSED, Proprietary, LicenseRef-* vendor EULAs (Windows, Defender, Chrome, Docker Desktop, …) |
high |
| None / unrecognized | missing, unknown, or unparseable text |
unknown |
npm's UNLICENSED sentinel (proprietary — all rights reserved) is deliberately not confused with the SPDX Unlicense public-domain license; dual-licensed packages (OR) are classified using the most favorable option, since the consumer may legally choose it, while conjunctively-licensed packages (AND) are classified using the most restrictive one, since all obligations stack.
Other shapes that are normalized: case/whitespace variants, SPDX expressions (GPL-2.0-or-later, GPL-2.0+), WITH exception expressions (the Classpath exception permits linking without inheriting GPL's copyleft obligations, so it's capped at medium), LicenseRef-* identifiers (a real, named vendor license with no SPDX id — treated as proprietary-leaning rather than "unrecognized"), legacy npm object/array forms, and Python trove classifiers (License :: OSI Approved :: MIT License → MIT). SEE LICENSE IN <file> is flagged as unverifiable rather than guessed at, and missing/empty/unknown values are classified as none. osi_approved is true only for licenses on the OSI-approved list, false for a real license that isn't on it (proprietary, source-available, Creative Commons), and null when there's nothing to check.
Run standalone via UBEL: Scan project for License Compliance (Ctrl+Alt+L) — see above — when you want license data only, with no vulnerability lookups or secrets scan.
Output fields. Each inventory item gets a license_info object:
{
"license": "UNLICENSED",
"license_info": {
"raw": "UNLICENSED",
"spdx": null,
"identifiers": [],
"osi_approved": false,
"risk": "high",
"category": "proprietary",
"reason": "npm \"UNLICENSED\" marker — explicitly no license grant (all rights reserved). Not to be confused with the SPDX \"Unlicense\" public-domain license."
}
}
The top-level stats.license_stats field summarizes the whole inventory:
{
"total": 142,
"osi_approved": 118,
"not_osi_approved": 9,
"unknown": 15,
"by_risk": { "low": 112, "medium": 6, "high": 9, "unknown": 15 }
}
- HTML report: the Inventory tab and the per-package detail modal render
license_infoas a risk-badged license table (SPDX id, identifiers, OSI-approved, risk, category, reason) rather than a bare string, and the Dashboard carries a license-risk stats card.
The SBOM and SARIF outputs below are produced by regular project and extension scans (sca tag); the standalone license command (licenses tag) writes JSON and HTML only.
- SBOM (CycloneDX v1.6):
components[].licensesuses the normalized SPDXexpressionform when a usable identifier was found, falling back to free-textlicense.name; OSI status, risk, category, and reason are added as componentproperties, and the rootpropertiescarry the OSI/unknown counts. - SARIF 2.1.0: a dedicated
ubel-license-compliancerun, separate from the vulnerability and secrets runs. Only packages that need review are reported — any package withrisk: "high", orosi_approvednot equal totrue— so a fully permissively-licensed tree produces no findings. Resultlevelmaps from risk (high→error,medium→warning,low→note).
Compliance Framework Mapping
Every dependency vulnerability and secrets-in-source finding is mapped onto industry compliance/security frameworks by default — no separate flag or mode needed, and included in every report format. This is distinct from License Compliance above, which is about license-obligation risk on installed software; this is about mapping security findings onto the frameworks an org is typically audited against.
Each finding is first assigned one or more internal risk categories — for a dependency vulnerability, derived from its advisory's CWE(s); for a secrets finding, always secrets_management (e.g. injection, secrets_management, vulnerable_components — the latter always applied to a dependency finding as a baseline, since every SCA finding is a known-vulnerable-component finding by definition), and each category carries a fixed list of framework control references, so two findings with the same underlying risk always map identically.
Frameworks covered
| Framework | Notes |
|---|---|
| OWASP Top 10 (2021) | Category codes (A01:2021–A10:2021) |
| PCI DSS v4.0 | Requirement numbers |
| HIPAA Security Rule | §164.312 / §164.308 citations |
| SOC 2 (Trust Services Criteria) | CC*/A1.* codes |
| ISO/IEC 27001:2022 (Annex A) | A.* control numbers |
| NIST SP 800-53 Rev. 5 | Control IDs (e.g. SI-10, AC-3) |
| GDPR | Article citations |
| CIS Controls v8 | Numbered controls |
This is best-effort guidance, not a certified compliance assessment. Control identifiers are the stable, publicly documented ones for each framework, but framework text, versioning, and applicable scope can change, and always depend on the org's own environment. Every report carries this disclaimer verbatim in compliance_summary.disclaimer — treat the mapping as a starting point for an audit conversation, not a citation to quote in one.
Output fields. Each vulnerability and secrets finding gets a compliance object:
{
"compliance": {
"categories": ["vulnerable_components", "injection"],
"frameworks": [
{
"id": "owasp_top10_2021",
"name": "OWASP Top 10 (2021)",
"controls": [
{ "id": "A06:2021", "title": "Vulnerable and Outdated Components" },
{ "id": "A03:2021", "title": "Injection" }
]
}
]
}
}
The top-level compliance_summary field aggregates every vulnerability and secrets finding in the report into per-framework, per-control finding counts:
{
"disclaimer": "Compliance framework references are best-effort guidance ...",
"frameworks": [
{
"id": "owasp_top10_2021",
"name": "OWASP Top 10 (2021)",
"findings_count": 14,
"controls": [
{ "id": "A06:2021", "title": "Vulnerable and Outdated Components", "findings_count": 11 },
{ "id": "A03:2021", "title": "Injection", "findings_count": 3 }
]
}
],
"by_category": { "vulnerable_components": 11, "injection": 3, "secrets_management": 2 }
}
Output
- HTML report: a dedicated Compliance tab with one card per framework (control breakdown + finding counts), plus a Compliance Frameworks section in each vulnerability's detail modal.
- JSON report: a
complianceobject on every vulnerability and secrets finding, plus a report-levelcompliance_summaryaggregating all findings into per-framework, per-control counts. - SARIF report:
compliance_categories/compliance_frameworkson each rule, and the fullcomplianceobject on each result — in both the dependency-vulnerability run and the secrets run.
Diffs Against the Previous Report
Project, extension and host-platform scans carry a diffs block in the JSON report, shown as the Diffs tab of the HTML report (between Compliance and System Info). It says what changed since the previous report of the same asset and the same scan type: a sca scan is compared only with an earlier sca scan, an os_sca scan only with an earlier os_sca scan, and so on for each editor's <editor>_sca scan. The previous report is read from the newest earlier zip of that type in the asset's history folder (~/.ubel/history/<asset_id>/, see Reports); a zip that can't be read is skipped in favour of the next older one.
| What | Matched by | Lists |
|---|---|---|
| Dependencies | component id (a PURL; for Windows host scans, a CPE 2.3 string) | new / unchanged / removed |
| Vulnerabilities | advisory id + affected component | new / still existing / fixed |
| Secrets | rule id + file + masked match (not the line, which moves when the file is edited) | new / still existing / removed |
| Compliance frameworks | the same keys as the vulnerabilities and secrets, grouped by each framework the finding maps to | new / existing / resolved, per framework |
In the HTML report each count is a button that opens the list behind it. The compliance frameworks appear below the secrets, one card per framework (OWASP Top 10, PCI DSS, HIPAA, SOC 2, ISO/IEC 27001, NIST SP 800-53, GDPR, CIS Controls v8) that either report mapped findings to; its list shows each finding's type, id, severity, title, component or file, and the framework's control ids. A finding that maps to several frameworks is counted under each of them.
- An upgrade is a removal plus an addition. The version is part of a component's id, so an upgraded package appears once under removed (old version) and once under new (new version). The same CVE on a second component is a new vulnerability.
- Secrets are compared only when both reports scanned for them. Otherwise the secrets lists are omitted and the tab says so, so a scan without the secrets pass is never read as "all secrets removed". The compliance section then covers vulnerabilities only, and says so.
- Older previous reports. A previous report from before compliance mapping existed has no per-framework data, so the compliance section is omitted rather than reporting every finding as new. A previous report that lists only advisory ids has its vulnerabilities compared by advisory id alone.
- First scan. With no earlier report for the asset and scan type, the tab says there is nothing to compare against.
- Never blocks a scan. If building the diff fails, the scan still completes and the tab is empty.
Policy
All ecosystems share the same policy engine. Policy is stored as JSON at .ubel/local/policy/config.json, relative to the scan root — <project-root> for project, secrets, and license scans, the editor's extensions directory for the extensions scan, and ~ for the host-platform scan. The file is created with defaults on the first scan.
The extension has no threshold / block-unknown / per-run-flag commands (those belong to the CLI) — to change policy, edit config.json directly. Default policy:
{
"severity_threshold": "high",
"block_unknown_vulnerabilities": true,
"license_risk_threshold": "none",
"block_unknown_license_risk": false,
"block_kev": true,
"epss_threshold": 0.1,
"block_undetermined": false,
"reachable_only": false
}
| Field | Values | Default | Behaviour |
|---|---|---|---|
severity_threshold |
low medium high critical none |
high |
Block packages at or above this severity |
block_unknown_vulnerabilities |
true false |
true |
Block packages with CVEs but no CVSS score |
license_risk_threshold |
none low medium high |
none |
Block packages whose license risk is at or above this level; never blocks on unknown regardless of setting (see block_unknown_license_risk) |
block_unknown_license_risk |
true false |
false |
Separately block packages whose license couldn't be classified at all |
block_kev |
true false |
true |
Block any vulnerability listed in the CISA KEV catalog, regardless of severity |
epss_threshold |
fraction in (0, 1], or "none" |
0.1 |
Block any vulnerability whose EPSS score is at or above this value (0.1 = 10%), regardless of severity |
block_undetermined |
true false |
false |
Block when any component's state is undetermined — no version, resolved from git / a tarball URL / a local path, or a non-Python conda package. These aren't matched against OSV/NVD, so a clean result says nothing about them. Not affected by reachable_only |
reachable_only |
true false |
false |
Apply the severity / unknown-severity / KEV / EPSS rules to reachable vulnerabilities only. One the reachability analysis confirms is unreachable is still listed in the report but marked allow (policy_exempt: "unreachable") and never blocks; one with no reachability result is not exempted. By default reachability is informational and does not change the verdict |
Infections (MAL-*) |
— | always blocked | Cannot be toggled; unconditionally blocked |
The severity threshold is inclusive — high blocks both high and critical. KEV and EPSS blocking apply on top of it: a Low-severity vulnerability that is known to be exploited, or whose EPSS score is at or above the threshold, still blocks. Both rules need data from an external feed; if it couldn't be fetched, that rule can't fire for the run (see Exploit Intelligence), and a passing verdict says so. Policy files created before these fields existed pick up the defaults automatically. Setting none disables severity blocking but infections are still blocked. license_risk_threshold/block_unknown_license_risk are opt-in (both default off) since license-risk tolerance varies by org and license detection has real gaps (free-text licenses, missing metadata); every extension scan runs in health mode, where these two gates are active.
Coverage at a Glance
| Surface |
|---|
| Source repos & monorepos |
| Exposed secrets in source |
| Developer machines (Windows / Linux) |
| VS Code extension |
Repos and Monorepos
UBEL walks the entire directory tree and detects all supported ecosystems in a single pass — no per-language configuration needed. Monorepos with mixed stacks (e.g. a Node.js frontend, Python backend, and Rust service in the same repo) are fully covered in one invocation.
Developer Machines
The VS Code extension (Ctrl+Alt+P) and the ubel-platform CLI binary scan the host machine: OS, installed runtimes, browsers, developer tools, and security software. Vulnerabilities are matched using CPE 2.3 identifiers against the CVE/NVD database.
This surface catches what dependency scanners miss — a vulnerable version of Git, an unpatched Python interpreter, or an outdated Docker Desktop install.
Windows
Detected via registry probes and PowerShell — no elevated privileges required.
| Category | Components |
|---|---|
| Operating system | Windows 10 / 11 (build-accurate CPE version) |
| Security | Windows Defender |
| Runtimes | Node.js, Python, PHP, Go, Rust, Ruby, JRE, JDK |
| .NET | All installed .NET Core / Desktop / ASP.NET runtimes (multi-version) |
| Browsers | Chrome, Firefox, Microsoft Edge |
| Developer tools | Git, Docker Desktop, Visual Studio Code / Visual Studio IDE, Cursor, VSCodium, Claude Code |
| Shell | PowerShell |
Linux
Detected by reading the system package database directly.
| Distribution | Package manager | Source | PURL type |
|---|---|---|---|
| Ubuntu | dpkg | /var/lib/dpkg/status |
pkg:deb/ubuntu/ |
| Debian | dpkg | /var/lib/dpkg/status |
pkg:deb/debian/ |
| Alpine / Alpaquita | apk | /lib/apk/db/installed |
pkg:apk/alpine/ |
| Red Hat / RHEL | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/redhat/ |
| AlmaLinux | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/almalinux/ |
| Rocky Linux | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/rocky-linux/ |
| CentOS / Fedora | rpm | rpm database (rpmdb.sqlite or Packages) |
pkg:rpm/redhat/ |
The distribution is read from /etc/os-release (/usr/lib/os-release as a fallback): ID is tried first, then each ID_LIKE token, so derivatives such as Amazon Linux (ID_LIKE="centos rhel fedora") resolve to their parent family. A distribution that matches none of the above (e.g. SUSE, Arch) stops the scan with an "Unsupported OS ecosystem" error rather than guessing.
Each package entry includes its binary install paths and direct dependency edges as reported by the package database.
No package-manager binaries are executed to read installed packages (dpkg, apk and rpm are not required): the rpm database is parsed in-process and its format is detected from the file's magic bytes — Berkeley DB Packages (RHEL/CentOS/Alma/Rocky ≤ 8, Amazon Linux 2) and SQLite rpmdb.sqlite (RHEL/Alma/Rocky ≥ 9, Fedora, Amazon Linux 2023). NDB (Packages.db, SUSE) is not supported and stops the scan with a clear error.
Each package is also tagged with the distro release it was read from (e.g. almalinux-8.10), which UBEL uses to match advisories to that release — see OS Package Release Matching.
Supported Ecosystems (Project Scan)
| Ecosystem | Package Manager | Resolved From |
|---|---|---|
| Node.js | npm, pnpm, yarn, bun | node_modules/ (on-disk walk) |
| Python | pip / virtualenv | .venv, venv, virtual environment directories |
| PHP | Composer | vendor/, composer.lock |
| Rust | Cargo | Cargo.lock (next to a Cargo.toml; workspace members are followed) |
| Go | Go Modules | go.mod (module root and selected versions) cross-referenced with go.sum (optional) |
| C#/.NET | NuGet | packages.lock.json, falling back to obj/project.assets.json |
| Java | Maven | pom.xml declarations (parent poms, <dependencyManagement>, properties and modules followed); full transitive tree from .ubel/maven-deps.txt when present. Gradle is not read |
| Ruby | Bundler | Gemfile.lock (with a Gemfile alongside) |
| Swift | SwiftPM, Carthage | Package.resolved / .build/workspace-state.json / Cartfile.resolved |
| Flutter/Dart | pub | pubspec.lock / .dart_tool/package_config.json |
Swift and Flutter/Dart notes. PURLs are pkg:swift/<host>/<owner>/<repo>@<version> (OSV ecosystem SwiftURL) and pkg:pub/<name>@<version>, with ?repository_url= / ?vcs_url= qualifiers on pub packages from a non-pub.dev registry or a git repository. Local packages (SwiftPM fileSystem / localSourceControl, pub path) and pub sdk packages are skipped, and CocoaPods (Podfile.lock) is intentionally not scanned because OSV has no CocoaPods ecosystem. A SwiftPM pin on a branch or bare commit is inventoried with an empty version and dropped from vulnerability lookups. Swift lockfiles carry no dev/prod signal, so every Swift package is prod; for pub, direct dev → dev and everything else → prod. Neither lockfile records a dependency graph or license data, so these packages have no introduced-by/parent edges and their license is unknown.
Reports
Every scan writes a self-contained interactive HTML report plus a machine-readable JSON file, and — depending on the scan type — a SBOM and SARIF file. A scan-type tag sits between the file name and the extension (<file_name>.<tag>.<extension>), so each command keeps its own set of files:
| Command | <tag> |
JSON | HTML | SBOM | SARIF |
|---|---|---|---|---|---|
| Scan Project | sca |
✅ | ✅ | ✅ | ✅ |
| Scan VS Code Extensions | vscode_sca / cursor_sca / vscodium_sca (the editor you run it in) |
✅ | ✅ | ✅ | ✅ |
| Scan Host Platform (Windows / Linux) | os_sca |
✅ | ✅ | ✅ | ❌ |
| Scan project for Exposed Secrets | secrets |
✅ | ✅ | ❌ | ❌ |
| Scan project for License Compliance | licenses |
✅ | ✅ | ❌ | ❌ |
On a platform other than Windows or Linux, Scan Host Platform falls back to the sca tag.
| File (SCA scans shown) | Format | Notes |
|---|---|---|
latest.sca.html |
Self-contained HTML | Works fully offline, no server needed |
latest.sca.json |
JSON | Full report, including the executive summary, suggested fixes, reachability, KEV/EPSS, and compliance data |
latest.sca.cdx.json |
CycloneDX v1.6 SBOM | Components, dependency graph, and vulnerabilities as VEX; secrets, license, KEV/EPSS, and suggested-fix data in properties |
latest.sca.sarif.json |
SARIF 2.1.0 | Separate runs for dependency vulnerabilities, secrets, and license compliance |
For the other commands, swap the tag: latest.os_sca.html, latest.secrets.html, latest.licenses.json, and so on (only the formats marked ✅ above exist for that tag). Open Report in each notification opens that command's latest.<tag>.html.
| Scan target | Report path |
|---|---|
| Workspace | <project-root>/.ubel/reports/latest.sca.* |
| Secrets-only scan | <project-root>/.ubel/reports/latest.secrets.* |
| License-only scan | <project-root>/.ubel/reports/latest.licenses.* |
| VS Code / VS Codium / Cursor extensions | ~/.vscode/extensions/.ubel/reports/latest.vscode_sca.* or ~/.vscode-oss/extensions/.ubel/reports/latest.vscodium_sca.* or ~/.cursor/extensions/.ubel/reports/latest.cursor_sca.* |
| Host platform | ~/.ubel/reports/latest.os_sca.* on Windows / Linux, latest.sca.* elsewhere (every report also carries the machine id from ~/.ubel/ubel_machine.json) |
Written atomically. Every report file UBEL writes — the latest.<tag>.* files above, the timestamped history zips and the latest_reports copy below — is first written to a temporary file in the same folder and then renamed over the target in one step. A script, CI job, or browser tab that reads latest.sca.json / latest.sca.html while a scan is finishing therefore gets either the complete previous report or the complete new one, never a partial file. If a write fails, the temporary file is removed, the previous file is left as it was, and the scan reports the error (on Windows, replacing a report can fail while another program holds it open — close it and scan again).
Previous scans are retained as timestamped zipped snapshots (<timestamp>.<tag>.zip, where <timestamp> is YYYY_MM_DD__HH_MM_SS in UTC, e.g. 2026_10_10__12_30_05.sca.zip). Inside each zip the files are always named report — report.<tag>.json, report.<tag>.html, and, where the scan type produces them, report.<tag>.cdx.json and report.<tag>.sarif.json. Zips are stored in ~/.ubel/history/<mode>/:
Every command files its zip in the same place — ~/.ubel/history/<asset_id>/. There is no per-command folder; the tag in the file name tells them apart: sca (Scan Project), <editor>_sca (Scan VS Code Extensions), os_sca on Windows / Linux (Scan Host Platform), secrets (Scan project for Exposed Secrets) and licenses (Scan project for License Compliance).
All projects and commands share this one folder, but each asset's zips live in a sub-folder named after its asset id — ~/.ubel/history/<asset_id>/<timestamp>.<tag>.zip, e.g. ~/.ubel/history/3f6c2a9e1b7d4c589a426e0d8b5f7a13/2026_10_10__12_30_05.sca.zip. The id comes from ubel_asset.json in the .ubel folder of whatever was scanned — <project-root>/.ubel/ubel_asset.json for the workspace, secrets and license commands, and the .ubel folder inside the extensions directory for Scan VS Code Extensions. The first scan in a folder creates it; the id never changes afterwards:
{ "asset_id": "3f6c2a9e1b7d4c589a426e0d8b5f7a13", "asset_name": "shop", "created_at": "2026-10-10T12:30:05.000Z" }
asset_name is a readable label — the git repository name when the workspace is a checkout, otherwise the folder name — and you can edit it. The id and name are also written into each report and shown as Asset and Asset ID in the HTML report's Scan Info panel, so a zip that has been moved or renamed still says where it came from. Each report also carries an asset_type (shown as Asset type), which depends on the command: Scan Project, Scan project for Exposed Secrets and Scan project for License Compliance are codebase, Scan VS Code Extensions is editor (and gets one asset id per editor), and Scan Host Platform is machine — see Asset types in the CLI docs. The type is not stored in ubel_asset.json; it is set per scan. Every report also has its own report_id, UBEL-<tag>-<asset_id>-<uuid> (for example UBEL-sca-… for Scan Project), the same in the report's JSON, HTML, SBOM and SARIF — see Report ID.
Scan Host Platform scans your home directory, so its asset id is the one in ~/.ubel/ubel_asset.json. Separately, every report carries a machine_id (shown as Machine ID in the HTML) from ~/.ubel/ubel_machine.json — the id of this machine, an id only, no name; the CLI's ubel-apt / ubel-dnf / ubel-yum use it as their asset id. If a ubel_asset.json can't be written, the scan still runs and its zip is written straight into ~/.ubel/history/ and the latest zip into ~/.ubel/latest_reports/. Zips from earlier versions stay where they are (~/.ubel/history/<mode>/<asset_id>/). If two scans of the same folder finish in the same second, the later zip gets a _2 suffix instead of overwriting the first.
Latest zip per scan type (latest_reports)
The newest scan of each tag is also kept in a folder of its own, apart from the history — ~/.ubel/latest_reports/<asset_id>/latest.<tag>.zip (one folder per asset, no mode folder):
| Command | Latest zip |
|---|---|
| Scan Project | ~/.ubel/latest_reports/<asset_id>/latest.sca.zip |
| Scan VS Code Extensions | ~/.ubel/latest_reports/<asset_id>/latest.<editor>_sca.zip (vscode_sca, cursor_sca or vscodium_sca) |
| Scan Host Platform (Windows / Linux) | ~/.ubel/latest_reports/<asset_id>/latest.os_sca.zip |
| Scan project for Exposed Secrets | ~/.ubel/latest_reports/<asset_id>/latest.secrets.zip |
| Scan project for License Compliance | ~/.ubel/latest_reports/<asset_id>/latest.licenses.zip |
It is a copy of the zip the scan just wrote and has the same contents (report.<tag>.json, .html, and .cdx.json / .sarif.json where applicable). It is refreshed after every scan, so a script can read the newest scan of a project from one fixed path without sorting timestamps, while history/ holds only timestamped zips and is never touched by this. The asset_id is the one in that folder's ubel_asset.json. The CLI uses the same folders, so the extension and the CLI share one history and one latest_reports tree.
- Projects scanned before this was added get their
latest_reportszip on their next scan; nothing is moved or deleted fromhistory/. - If the copy can't be written (for example a read-only home directory), the scan still completes with a warning and the history zip is unaffected; the previous latest zip, if any, is left in place.
Extension vs. CLI
The extension runs the same engine as the CLI's health mode. These parts of the @arcane-spark/ubel-node package are not in the extension:
- The install-time firewall (
check/installmodes) for npm, pnpm, bun, composer, pip, uv, pipx, conda, cargo, apt, dnf, and yum, including lockfile backup/revert and TOCTOU integrity protection (always with install scripts blocked) ubel-dockercontainer-image scanning- Fixed-configuration CLIs for AI-agent sandboxes and CI/CD (
ubel-agent,ubel-cicd) - The git pre-commit hook (
install-hook/uninstall-hook) and theubel-secretsextras: git-history scanning (--history), staged-changes scanning (--staged), and baselining (--write-baseline) — the extension scans the working tree only - Persistent policy modes and per-run policy flags (
--threshold,--block-kev,--epss-threshold, …) — in the extension, editconfig.jsoninstead - The GitHub Action
Requirements
- Node.js
>=18.0.0 - VS Code
^1.85.0(extension only) - Network access to osv.dev and the NVD API (or your configured mirrors) for vulnerability lookups; KEV/EPSS lookups are best-effort
Privacy
UBEL is fully local. The external calls it makes are:
| Service | Purpose | Failure behavior |
|---|---|---|
| osv.dev public API | Vulnerability lookups — receives package PURLs (package name + version) | Scan fails with an error |
| NVD API | Host-platform CPE lookups and advisory enrichment | Scan fails with an error |
CISA KEV catalog (www.cisa.gov) |
Known-exploited flag | Best-effort — scan completes, KEV fields are null |
FIRST EPSS API (api.first.org) |
Exploit-likelihood score, queried by CVE id | Best-effort — scan completes, EPSS fields are null |
No file contents, no dependency graphs, no machine identifiers, and no telemetry are sent anywhere. UBEL does not look up your public IP address, and reports do not record your local IP addresses or ports. Secrets findings never leave the machine at all — match previews shown in reports are redacted before being written to disk. If an OSV or NVD lookup can't be completed, the scan ends with an error message rather than reporting a clean result.
The OSV and NVD endpoints can be redirected to an internal mirror by setting UBEL_OSV_ENDPOINT / UBEL_NVD_ENDPOINT in the environment the editor was launched from (e.g. via VS Code's own terminal.integrated.env.* settings, or the OS environment) — useful for air-gapped or regulated environments where those calls need to stay on an internal network. A mirror must expose the same path shape as the public API, and the "view online" reference links in reports still point at the public sites. There is currently no endpoint override for KEV/EPSS: in a fully air-gapped setup the scan runs without that data and says so. See sca/README.md for details; this extension reads the same engine, so the same variables apply.
License
Source-available, internal use only: you may use UBEL on your own projects and systems and within your own organization. Redistribution, wrapping, and offering it as a service to others are not permitted.
See LICENSE.md for the full terms, or contact ala.bouali.1997@gmail.com to discuss licensing beyond internal use.